156 lines
7.4 KiB
Python
156 lines
7.4 KiB
Python
"""Owner-scoped daily observations and matched recording reductions."""
|
|
|
|
from collections import defaultdict
|
|
from copy import copy
|
|
from datetime import datetime, timedelta
|
|
from types import SimpleNamespace
|
|
|
|
from .extensions import db
|
|
from .models import DailyHealth
|
|
from .stats_analysis import evaluate, expression_tree, valid
|
|
|
|
|
|
def daily(history, owner_id, zone, start, end, q, catalog):
|
|
by_day = defaultdict(list)
|
|
for row in history:
|
|
by_day[row.day].append(row)
|
|
observations = {r.day: r for r in db.session.scalars(
|
|
db.select(DailyHealth).where(DailyHealth.owner_id == owner_id,
|
|
DailyHealth.day >= start, DailyHealth.day < end))}
|
|
result = []
|
|
last_run = last_long = None
|
|
streak = 0
|
|
earlier = [r for r in history if r.day < start]
|
|
if earlier:
|
|
last_run = max(r.day for r in earlier)
|
|
longs = [r.day for r in earlier if r.distance_m >= q["long_run"] * 1000]
|
|
last_long = max(longs) if longs else None
|
|
day = start - timedelta(days=1)
|
|
while by_day[day]:
|
|
streak += 1
|
|
day -= timedelta(days=1)
|
|
for index in range((end - start).days):
|
|
day = start + timedelta(days=index)
|
|
rows = by_day[day]
|
|
if rows:
|
|
last_run = day
|
|
streak += 1
|
|
else:
|
|
streak = 0
|
|
if any(r.distance_m >= q["long_run"] * 1000 for r in rows):
|
|
last_long = day
|
|
values = {key: None for key in catalog}
|
|
distance = sum(r.distance_m for r in rows)
|
|
duration = sum(r.duration_s for r in rows)
|
|
values.update(distance=distance / 1000, duration=duration / 3600,
|
|
sessions=len(rows), count=1, run_day_streak=streak,
|
|
days_since_run=(day - last_run).days if last_run else None,
|
|
days_since_long_run=(day - last_long).days if last_long else None)
|
|
for days in (7, 28):
|
|
window = [r for d in range(days) for r in by_day[day - timedelta(days=d)]]
|
|
values[f"rolling_{days}d_distance"] = sum(r.distance_m for r in window) / 1000
|
|
values[f"rolling_{days}d_count"] = len(window)
|
|
if days == 7:
|
|
total = sum(r.distance_m for r in window)
|
|
values["long_run_share"] = max(r.distance_m for r in window) / total * 100 if total else None
|
|
for field in ("perceived_effort", "fatigue"):
|
|
known = [r.values[field] for r in rows if valid(r.values.get(field))]
|
|
values[field] = sum(known) / len(known) if known else None
|
|
health = observations.get(day)
|
|
if health:
|
|
values.update({key: value for key, value in health.values.items() if key in catalog})
|
|
if q["source"] == "health" and not (health and any(valid(v) for v in health.values.values())):
|
|
continue
|
|
instant = datetime.combine(day, datetime.min.time(), zone)
|
|
row = SimpleNamespace(id=day.toordinal(), day=day, local=instant, instant=instant,
|
|
started_at=instant, title=f"Training day · {day}",
|
|
distance_m=distance, duration_s=duration, elevation_m=None,
|
|
avg_hr=None, analysis_summary=values, values=values, previous=None,
|
|
activity_id=None, activity_ids=[r.id for r in rows])
|
|
result.append(row)
|
|
for spec in q["derived"]:
|
|
if "window" in spec:
|
|
config = spec["window"]
|
|
from .stats_analysis import statistic
|
|
|
|
for row in result:
|
|
window = [r.values.get(config["field"]) for r in result
|
|
if row.day - timedelta(days=config["days"]) < r.day <= row.day]
|
|
row.values[spec["name"]] = statistic(window, config["function"])
|
|
else:
|
|
tree = expression_tree(spec["expression"], catalog)[0]
|
|
for row in result:
|
|
row.values[spec["name"]] = evaluate(tree, row.values)
|
|
return result
|
|
|
|
|
|
def reduce_runs(rows, minimum, catalog):
|
|
"""Each session contributes exactly one observation after recording filters."""
|
|
groups = defaultdict(list)
|
|
for row in rows:
|
|
groups[row.activity_id].append(row)
|
|
result = []
|
|
for activity_id, units in groups.items():
|
|
units.sort(key=lambda r: r.values.get("elapsed") or r.values.get("moving_offset") or 0)
|
|
duration = sum(r.duration_s for r in units)
|
|
if duration + 1e-7 < minimum * 60:
|
|
continue
|
|
row = copy(units[0])
|
|
row.id = row.activity_id = activity_id
|
|
row.title = getattr(row, "parent_title", row.title)
|
|
row.distance_m = sum(r.distance_m for r in units)
|
|
row.duration_s = duration
|
|
values = {key: None for key in catalog}
|
|
for key in catalog:
|
|
coverage_key = "sensor_coverage" if key == "heart_rate" else key + "_coverage"
|
|
pairs = [(r.values.get(key), r.duration_s * r.values.get(coverage_key, 100) / 100)
|
|
for r in units if valid(r.values.get(key))]
|
|
weight = sum(w for _, w in pairs)
|
|
values[key] = sum(v * w for v, w in pairs) / weight if weight else None
|
|
values.update(distance=row.distance_m / 1000, duration=duration / 3600,
|
|
pace=duration * 1000 / row.distance_m, count=1,
|
|
matched_minutes=duration / 60, matched_windows=len(units))
|
|
# Post-filter halves of matched moving time. Require >=10 minutes each,
|
|
# comparable pace (10%) and mean signed grade (one percentage point).
|
|
halves = [[0.0, 0.0, 0.0, 0.0, 0.0] for _ in range(2)]
|
|
elapsed = 0.0
|
|
for unit in units:
|
|
hr, grade = unit.values.get("heart_rate"), unit.values.get("grade")
|
|
recorded = getattr(unit, "recording", {})
|
|
fraction = recorded.get("joint_coverage", 100) / 100
|
|
hr = recorded.get("joint_hr", hr)
|
|
grade = recorded.get("joint_grade", grade)
|
|
speed = recorded.get("joint_speed", unit.distance_m / unit.duration_s)
|
|
for index, low, high in ((0, 0, duration / 2), (1, duration / 2, duration)):
|
|
dt = max(0, min(elapsed + unit.duration_s, high) - max(elapsed, low))
|
|
if dt and valid(hr) and valid(grade) and valid(speed):
|
|
dt *= fraction
|
|
part = halves[index]
|
|
part[0] += dt
|
|
part[1] += speed * dt
|
|
part[2] += hr * dt
|
|
part[3] += grade * dt
|
|
part[4] += dt
|
|
elapsed += unit.duration_s
|
|
drift = None
|
|
if all(p[0] >= 600 and p[0] >= duration * .4 and p[1] > 0 and p[2] > 0 for p in halves):
|
|
first, second = halves
|
|
speeds = [p[1] / p[0] for p in halves]
|
|
grades = [p[3] / p[0] for p in halves]
|
|
if abs(speeds[1] / speeds[0] - 1) <= .1 and abs(grades[1] - grades[0]) <= 1:
|
|
drift = (1 - (second[1] / second[2]) / (first[1] / first[2])) * 100
|
|
values["heart_rate_drift"] = drift
|
|
row.avg_hr = values["heart_rate"]
|
|
row.values = row.analysis_summary = values
|
|
result.append(row)
|
|
return result
|
|
|
|
|
|
def evidence(rows, source):
|
|
ids = {getattr(row, "activity_id", row.id) for row in rows}
|
|
ids.discard(None)
|
|
if source in {"days", "health"}:
|
|
ids = {i for row in rows for i in row.activity_ids}
|
|
return {"sessions": len(ids), "inputs": len(rows),
|
|
"minutes": round(sum(row.duration_s for row in rows) / 60, 1)}
|