Files
Stride/app/training_signals.py

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.")