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