Add matched training signals, daily recovery and performance analysis
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""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_5km) as "Best 5 km", min(best_10km) 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"long run distance >= {long_run:g} km\n" +
|
||||
'summarize latest(days_since_long_run) as "Days since long run", latest(rolling_28d_distance) 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_7d_distance), latest(rolling_28d_distance), 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_1km), min(best_2km)\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.")
|
||||
Reference in New Issue
Block a user