Add matched training signals, daily recovery and performance analysis
This commit is contained in:
+77
-11
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user