Add running fitness history and configurable heart rate zones
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""Running load v1: personal five-zone minutes, with daily exponential decay."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from math import exp
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy.orm import undefer
|
||||
|
||||
from .extensions import db
|
||||
from .heart_rate import zones
|
||||
from .models import Activity
|
||||
|
||||
|
||||
def effort(activity, bands):
|
||||
"""Require 80% timer-time HR coverage; never extrapolate missing HR."""
|
||||
result = {"score": None, "coverage": 0, "reason": "Set your HR zones in Settings."}
|
||||
if activity.sport != "running":
|
||||
result["reason"] = "Running activities only."
|
||||
return result
|
||||
units = (activity.analysis_summary or {}).get("intervals", [])
|
||||
seconds = [0.0] * (len(bands) + 1 if bands else 5)
|
||||
for unit in units:
|
||||
for hr, duration in unit.get("hr_seconds", {}).items():
|
||||
hr = float(hr)
|
||||
if 30 <= hr <= 240:
|
||||
band = sum(hr >= boundary for boundary in bands) if bands else 0
|
||||
seconds[band] += duration
|
||||
coverage = sum(seconds) / activity.duration_s if activity.duration_s > 0 else 0
|
||||
result["coverage"] = round(min(coverage, 1) * 100, 1)
|
||||
if not bands:
|
||||
return result
|
||||
result["reason"] = "Insufficient recorded HR (80% timer-time coverage required)."
|
||||
if coverage >= 0.8:
|
||||
result.update(
|
||||
score=sum(t * w for w, t in enumerate(seconds, 1)) / 60,
|
||||
reason="Recorded HR · zone weights 1 / 2 / 3 / 4 / 5",
|
||||
)
|
||||
result["zone_minutes"] = [round(t / 60, 2) for t in seconds]
|
||||
return result
|
||||
|
||||
|
||||
def history(user, today=None):
|
||||
zone = ZoneInfo(user.timezone)
|
||||
today = today or datetime.now(zone).date()
|
||||
zone_settings = zones(user, today)
|
||||
rows = db.session.scalars(
|
||||
db.select(Activity)
|
||||
.where(Activity.owner_id == user.id, Activity.sport == "running")
|
||||
.options(undefer(Activity.analysis_summary))
|
||||
.order_by(Activity.started_at, Activity.id)
|
||||
).all()
|
||||
days = defaultdict(list)
|
||||
runs = []
|
||||
for row in rows:
|
||||
day = row.started_at.replace(tzinfo=UTC).astimezone(zone).date()
|
||||
if day > today:
|
||||
continue
|
||||
scored = effort(row, zone_settings["boundaries"])
|
||||
run = {"id": row.id, "title": row.title, "day": day.isoformat(), **scored}
|
||||
days[day].append(run)
|
||||
runs.append(run)
|
||||
series = []
|
||||
fitness = fatigue = 0.0
|
||||
incomplete = False
|
||||
if days:
|
||||
day = min(days)
|
||||
start = day
|
||||
while day <= today:
|
||||
entries = days.get(day, [])
|
||||
missing = sum(r["score"] is None for r in entries)
|
||||
incomplete |= missing > 0
|
||||
load = sum(r["score"] for r in entries if r["score"] is not None)
|
||||
fitness += (load - fitness) * (1 - exp(-1 / 42))
|
||||
fatigue += (load - fatigue) * (1 - exp(-1 / 7))
|
||||
series.append(
|
||||
{
|
||||
"day": day.isoformat(),
|
||||
"load": None if missing else load,
|
||||
"fitness": round(fitness, 2),
|
||||
"fatigue": round(fatigue, 2),
|
||||
"form": round(fitness - fatigue, 2),
|
||||
"missing": missing,
|
||||
"provisional": (day - start).days < 126 or incomplete,
|
||||
}
|
||||
)
|
||||
day += timedelta(days=1)
|
||||
return {
|
||||
"series": series,
|
||||
"runs": list(reversed(runs)),
|
||||
"unscored": sum(r["score"] is None for r in runs),
|
||||
"zones": zone_settings,
|
||||
}
|
||||
Reference in New Issue
Block a user