Add matched training signals, daily recovery and performance analysis

This commit is contained in:
Peter Stockings
2026-10-03 13:22:32 +10:00
parent e6e2f22479
commit 418e03583a
37 changed files with 1380 additions and 47 deletions
+77 -11
View File
@@ -7,7 +7,7 @@ from types import SimpleNamespace
from zoneinfo import ZoneInfo
from .extensions import db
from .models import Activity
from .models import Activity, DailyHealth, User
from .stats import METRICS, format_value, metric_value, period_bounds
from .stats import aggregate as legacy_aggregate
from .stats_analysis import describe, evaluate, expression_tree, statistic, valid
@@ -16,6 +16,12 @@ from .stats_dsl import QueryError
def catalog_for(q):
catalog = dict(METRICS)
if q["source"] in {"days", "health"}:
for days in (7, 28):
for field in ("distance", "count"):
key = f"rolling_{days}d_{field}"
label, unit, _ = catalog[key]
catalog[key] = (label, unit, f"Complete recorded running {field} over this local day and the preceding {days - 1} local days, before filters. Rest days are included.")
for spec in q["derived"]:
catalog[spec["name"]] = (
spec["name"].replace("_", " ").capitalize(),
@@ -37,6 +43,9 @@ def summarize(rows, key, q):
return len(rows), len(rows)
values = [value(row, field) for row in rows]
coverage = sum(valid(number) for number in values)
if function == "latest":
last = max(rows, key=lambda r: (r.instant, r.id)) if rows else None
return (value(last, field) if last else None), coverage
if function == "weighted_mean":
pairs = [
(value(row, field), row.distance_m if field == "pace" else row.duration_s)
@@ -71,7 +80,7 @@ def formatted(number, key, q):
return "—"
spec = q["aggregates"].get(key)
raw = spec["field"] if spec and spec["function"] not in {"count", "stddev", "sum"} else key
if raw in {"pace", "gap"}:
if raw in {"pace", "gap"} or raw == "effort_time" or raw.startswith("best_"):
return format_value(number, "pace")
if key == "count" or (spec and spec["function"] == "count"):
return f"{number:,.0f}"
@@ -158,6 +167,12 @@ def prepare(raw, zone, q, catalog):
calories=(row.metrics or {}).get("total_calories"),
training_effect=(row.metrics or {}).get("total_training_effect"),
)
row.values.update({key: (row.session_context or {}).get(key)
for key in ("perceived_effort", "fatigue")})
for km in (1, 2, 5, 10):
efforts = [e.get("seconds") for e in row.best_efforts or []
if e.get("distance") == km * 1000 and valid(e.get("seconds"))]
row.values[f"best_{km}km"] = min(efforts) if efforts else None
for field in (
"pace",
"heart_rate",
@@ -210,6 +225,8 @@ def prepare(raw, zone, q, catalog):
def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, raw_group=False):
from .training_sources import evidence as evidence_coverage
zone, catalog = ZoneInfo(timezone), catalog_for(q)
start, end, prior_start = period_bounds(q, timezone)
prior_end = start
@@ -235,6 +252,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
Activity.avg_hr,
Activity.analysis_summary,
Activity.metrics,
Activity.best_efforts,
Activity.session_context,
*([Activity.route_geometry] if q["route"] else []),
)
.where(
Activity.owner_id == owner_id,
@@ -243,7 +263,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
)
.order_by(Activity.started_at, Activity.id)
)
if q["source"] != "runs" and not q["all_time"]:
if q["source"] not in {"runs", "days", "health"} and not q["all_time"]:
lower = min(
start,
prior_start if q["compare"] else start,
@@ -252,14 +272,47 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
statement = statement.where(Activity.started_at >= boundary(lower))
raw = db.session.execute(statement).all()
history = prepare(raw, zone, q, catalog)
if q["source"] != "runs":
from .stats_sources import expand
user = db.session.get(User, owner_id)
if user and user.hr_bands:
for row in history:
intervals = (row.analysis_summary or {}).get("intervals", [])
usable = any(unit.get("hr_seconds") for unit in intervals)
for name in ("lower", "middle", "upper"):
row.values[name + "_hr_minutes"] = 0.0 if usable else None
for unit in intervals:
for hr, seconds in unit.get("hr_seconds", {}).items():
hr = float(hr)
name = "lower" if hr < user.hr_bands[0] else "middle" if hr < user.hr_bands[1] else "upper"
row.values[name + "_hr_minutes"] += seconds / 60
if q["route"]:
from .route_benchmarks import similar
history = expand(history, q, catalog)
reference = db.session.scalar(db.select(Activity.route_geometry).where(
Activity.id == q["route"], Activity.owner_id == owner_id))
if reference is None:
raise QueryError("Choose one of your own recorded routes.")
history = [row for row in history if similar(reference, row.route_geometry)]
if q["all_time"] and history:
start = history[0].day
prior_start = start
if q["all_time"] and q["source"] in {"days", "health"}:
first_health = db.session.scalar(db.select(db.func.min(DailyHealth.day)).where(
DailyHealth.owner_id == owner_id, DailyHealth.day < end))
if first_health:
start = min(start, first_health) if history else first_health
prior_start = start
if q["source"] in {"days", "health"}:
from .training_sources import daily
lower = min(start, prior_start if q["compare"] else start,
start - timedelta(days=q["baseline"] or 0))
lower -= timedelta(days=max((spec.get("window", {}).get("days", 0)
for spec in q["derived"]), default=0))
history = daily(history, owner_id, zone, lower, end, q, catalog)
elif q["source"] != "runs":
from .stats_sources import expand
history = expand(history, q, catalog)
def included(row):
return (
row.distance_m >= q["minimum"] * 1000
@@ -270,6 +323,12 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
and (not q["filter_groups"] or any(matches(row, group) for group in q["filter_groups"]))
)
if q["reduce"]:
from .training_sources import reduce_runs
history = reduce_runs([row for row in history if included(row)], q["reduce"], catalog)
included = lambda row: True
current = ordered(
[row for row in history if start <= row.day < end and included(row)], q["sort"]
)
@@ -417,8 +476,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
number, coverage = summarize(current, key, q)
before, _ = summarize(prior, key, q)
title, unit, definition = descriptors[key]
if unit == "runs":
unit = q["source"]
if unit == "runs" and key != "sessions" and not key.startswith("rolling_"):
unit = "runs" if q["reduce"] else q["source"]
cards.append(
{
"key": key,
@@ -480,8 +539,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
return results
title, unit, _ = descriptors[key]
if unit == "runs":
unit = q["source"]
if unit == "runs" and key != "sessions" and not key.startswith("rolling_"):
unit = "runs" if q["reduce"] else q["source"]
pace_axis = unit == "/km"
if q["trend"] == "percent_change":
unit, pace_axis = "%", False
@@ -507,6 +566,10 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
"reference": q["reference"],
"dates": [token.isoformat() for token in tokens] if group == "day" else [],
"source": q["source"],
"timezone": timezone,
"timestamps": [next(row.instant.timestamp() * 1000 for row in current if row.id == token)
for token in tokens] if group == "run" else [],
"coverage": [evidence_coverage(current_groups[token], q["source"]) for token in tokens],
}
if q["cohorts"]:
cohorts = [{"label": cohort["name"], "values": []} for cohort in q["cohorts"]]
@@ -613,6 +676,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
"distance": f"{row.distance_m / 1000:.2f}",
"values": [formatted(value(row, field), field, q) for field in inputs],
"raw_values": [value(row, field) for field in inputs],
"activity_ids": getattr(row, "activity_ids", []),
"previous_run": {
"id": row.previous.id,
"title": row.previous.title,
@@ -624,7 +688,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
return {
"cards": cards,
"source": q["source"],
"source": "matched runs" if q["reduce"] else q["source"],
"coverage": evidence_coverage(current, q["source"]),
"group_coverage": [evidence_coverage(current_groups[token], q["source"]) for token in tokens],
"chart": chart,
"insights": insights,
"groups": groups,