111 lines
8.4 KiB
Python
111 lines
8.4 KiB
Python
"""Reviewable, editable training analyses; no inferred medical or HR-zone thresholds."""
|
|
|
|
import click
|
|
|
|
from .extensions import db
|
|
from .models import Activity, SavedAnalysis, User
|
|
from .stats import explore, parse_query, query_text
|
|
|
|
|
|
def signal_queries(pace_low="6:30", pace_high="7:00", long_run=10, route_id=None):
|
|
match = (f"where pace between {pace_low} and {pace_high} /km\n"
|
|
"and elapsed >= 10 min\nand duration >= 25 s\n"
|
|
"and grade between -2 and 2 %\nand grade_variability <= 2 %\n"
|
|
"and pace_variability <= 15 %\nand sensor_coverage >= 90 %\n"
|
|
"and hr_artifact_percent <= 0 %")
|
|
common = "during last 16 weeks\n"
|
|
definitions = [
|
|
("HR at matched flat effort", "intervals\n" + common +
|
|
"reduce by run min 3 min\n" + match +
|
|
'\nsummarize mean(heart_rate) as "Matched HR"\nby run\nsort by date asc\ncompare none\nchart line',
|
|
"full", "After ten elapsed minutes; matched continuous windows with flat, consistent terrain and speed. At least three matched minutes per session. Each run contributes one observation. HR lag, weather and session intent remain possible explanations; this is not a diagnosis."),
|
|
("Training rhythm", "runs\n" + common +
|
|
'summarize sum(distance) as "Weekly km", count() as "Sessions"\nby week\ncompare none\nchart bar\ndisplay overlay',
|
|
"half", "Weeks start Monday. The current week is incomplete. Recorded running only; other training or unimported runs may explain gaps."),
|
|
("Performance ladder", "runs\n" + common +
|
|
'summarize min(best_time(5 km)) as "Best 5 km", min(best_time(10 km)) as "Best 10 km"\nby month\ncompare none\nchart line\ndisplay overlay',
|
|
"half", "Fastest recorded elapsed windows inside each month's activities. Missing distances stay missing. These are not standardized races or terrain-matched performances."),
|
|
("Long-run continuity", "days\n" + common +
|
|
f'summarize latest(days_since_run(distance >= {long_run:g} km)) as "Days since long run", latest(rolling_sum(distance, 28 days)) as "28-day km"\nby day\ncompare none\nchart line\ndisplay overlay',
|
|
"half", f"Long run means at least {long_run:g} km; edit this threshold. Daily values advance on rest days. No earlier qualifying run is missing rather than a fabricated gap. The 28-day window includes today and the preceding 27 local days."),
|
|
("Endurance fade", "intervals\n" + common +
|
|
"reduce by run min 20 min\nwhere elapsed >= 10 min\nand duration >= 25 s\n"
|
|
"and grade between -2 and 2 %\nand grade_variability <= 2 %\n"
|
|
"and pace_variability <= 15 %\nand sensor_coverage >= 90 %\n"
|
|
"and hr_artifact_percent <= 0 %\n"
|
|
'summarize heart_rate_drift as "Post-warm-up decoupling"\nby run\nsort by date asc\ncompare none\nchart line\nreference heart_rate_drift = 0 %',
|
|
"half", "Matched moving-time halves after warm-up, with at least ten minutes of usable HR and terrain in each half. Hidden when halves differ more than 10% in speed or one percentage point in mean grade. Filtered windows need not be adjacent; the result remains descriptive."),
|
|
("Daily workload and consistency", "days\n" + common +
|
|
'summarize latest(rolling_sum(distance, 7 days)), latest(rolling_sum(distance, 28 days)), latest(run_day_streak)\nby day\ncompare none\nchart line\ndisplay overlay',
|
|
"half", "Daily workload includes rest days, uses local calendar windows and includes all recorded runs before filters. Streak returns to zero on a rest day."),
|
|
("Longest run each week", "runs\n" + common +
|
|
'summarize max(distance), count()\nby week\ncompare none\nchart bar',
|
|
"half", "Weekly longest recorded distance and session count; inspect the full week's volume for concentration."),
|
|
("Short-distance performance", "runs\n" + common +
|
|
'summarize min(best_time(1 km)), min(best_time(2 km))\nby month\ncompare none\nchart line\ndisplay overlay',
|
|
"half", "Fastest recorded 1 km and 2 km elapsed windows; effort and terrain may differ."),
|
|
("Recovery context", "health\n" + common +
|
|
'summarize resting_hr, hrv, sleep_hours, stress\nby day\ncompare none\nchart line',
|
|
"full", "Private Garmin daily observations, without interpolation or a composite readiness score. Empty plots mean the watch/provider has no usable observations. Configure recovery sync in Settings."),
|
|
("Personal HR-band minutes", "runs\n" + common +
|
|
'summarize sum(lower_hr_minutes), sum(middle_hr_minutes), sum(upper_hr_minutes)\nby week\ncompare none\nchart bar\ndisplay overlay',
|
|
"full", "Actual recorded moving seconds in your own HR bands, excluding suspect HR intervals. Set both boundaries in Settings; no thresholds are assumed."),
|
|
("Mechanics at matched effort", "intervals\n" + common +
|
|
"reduce by run min 3 min\n" + match +
|
|
'\nand cadence_coverage >= 80 %\nand ground_contact_coverage >= 80 %\n'
|
|
'summarize mean(cadence), mean(ground_contact), mean(stride_length)\nby run\nsort by date asc\ncompare none\nchart line',
|
|
"full", "Recording-level mechanics matched to the same flat pace band after warm-up. Missing measurements stay missing. Differences are observations, not technique prescriptions."),
|
|
("Recording evidence quality", "intervals\n" + common +
|
|
'summarize mean(sensor_coverage), mean(terrain_coverage), mean(hr_artifact_percent)\nby week\ncompare none\nchart line\ndisplay overlay',
|
|
"full", "Coverage and coarse HR jump/range checks across continuous windows. These checks cannot detect every sensor artifact, including cadence lock."),
|
|
]
|
|
if route_id:
|
|
definitions.append(("Repeated-route benchmark", "runs\n" + common +
|
|
f"route similar to {route_id}\n" +
|
|
'summarize pace, heart_rate\nby run\nsort by date asc\ncompare none\nchart line',
|
|
"full", "Approximate full-course matching against the reference run: same start/direction/end, length within 10%, mean course separation at most 100 m and 90th-percentile separation at most 200 m. Recording breaks are excluded. Inspect contributing routes before interpreting a trend."))
|
|
return [{"name": name, "query": source, "width": width, "notes": notes,
|
|
"pinned": i < 5, "view": "chart", "position": i,
|
|
"collection": "Training signals"} for i, (name, source, width, notes) in enumerate(definitions)]
|
|
|
|
|
|
def install(user, definitions):
|
|
existing = {r.name: r for r in db.session.scalars(db.select(SavedAnalysis).where(
|
|
SavedAnalysis.owner_id == user.id, SavedAnalysis.collection == "Training signals"))}
|
|
created = 0
|
|
count = db.session.scalar(db.select(db.func.count(SavedAnalysis.id)).where(
|
|
SavedAnalysis.owner_id == user.id))
|
|
if count + sum(d["name"] not in existing for d in definitions) > 100:
|
|
raise ValueError("Keep at most 100 account analyses; remove an unused analysis first.")
|
|
for definition in definitions:
|
|
if definition["name"] in existing:
|
|
continue # Preserve any edits made in the account library.
|
|
q = parse_query({"query": definition["query"]})
|
|
explore(user.id, user.timezone, q)
|
|
row = SavedAnalysis(owner_id=user.id, **{**definition, "query": query_text(q)})
|
|
db.session.add(row)
|
|
created += 1
|
|
db.session.commit()
|
|
return created
|
|
|
|
|
|
def register_cli(app):
|
|
@app.cli.command("install-training-signals")
|
|
@click.option("--email", required=True)
|
|
@click.option("--pace-low", default="6:30")
|
|
@click.option("--pace-high", default="7:00")
|
|
@click.option("--long-run", default=10.0, type=click.FloatRange(1, 100))
|
|
def install_signals(email, pace_low, pace_high, long_run):
|
|
user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower()))
|
|
if not user:
|
|
raise click.ClickException("Account not found.")
|
|
reference = db.session.scalar(db.select(Activity.id).where(
|
|
Activity.owner_id == user.id, Activity.has_route.is_(True),
|
|
Activity.sport == "running").order_by(Activity.started_at.desc()).limit(1))
|
|
try:
|
|
count = install(user, signal_queries(pace_low, pace_high, long_run, reference))
|
|
except ValueError as error:
|
|
db.session.rollback()
|
|
raise click.ClickException(str(error)) from None
|
|
click.echo(f"Created {count} editable analyses; the first five are pinned.")
|