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