This commit is contained in:
xds
2026-03-16 14:46:20 +03:00
parent 00db55720c
commit de8c2472e2
45 changed files with 3714 additions and 140 deletions

View File

@@ -0,0 +1,107 @@
"""CTL / ATL / TSB (Fitness / Fatigue / Form) calculation service."""
from datetime import date, timedelta
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.activity import Activity, ActivityMetrics
from backend.app.models.fitness import FitnessHistory
CTL_DAYS = 42 # Chronic Training Load time constant
ATL_DAYS = 7 # Acute Training Load time constant
async def rebuild_fitness_history(
rider_id,
session: AsyncSession,
days_back: int = 365,
) -> list[FitnessHistory]:
"""Rebuild CTL/ATL/TSB for a rider from scratch using exponential moving averages."""
end_date = date.today()
start_date = end_date - timedelta(days=days_back)
# Get all activities with TSS in the date range
query = (
select(
func.date(Activity.date).label("activity_date"),
func.sum(ActivityMetrics.tss).label("daily_tss"),
)
.join(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
.where(Activity.rider_id == rider_id)
.where(Activity.date >= start_date)
.where(ActivityMetrics.tss.isnot(None))
.group_by(func.date(Activity.date))
.order_by(func.date(Activity.date))
)
result = await session.execute(query)
tss_by_date: dict[date, float] = {}
for row in result:
tss_by_date[row.activity_date] = float(row.daily_tss)
# Delete existing history for this rider
existing = await session.execute(
select(FitnessHistory).where(FitnessHistory.rider_id == rider_id)
)
for entry in existing.scalars().all():
await session.delete(entry)
# Calculate EMA-based CTL/ATL/TSB
ctl = 0.0
atl = 0.0
prev_ctl = 0.0
entries: list[FitnessHistory] = []
current = start_date
while current <= end_date:
daily_tss = tss_by_date.get(current, 0.0)
ctl = ctl + (daily_tss - ctl) / CTL_DAYS
atl = atl + (daily_tss - atl) / ATL_DAYS
tsb = ctl - atl
ramp_rate = ctl - prev_ctl
entry = FitnessHistory(
rider_id=rider_id,
date=current,
ctl=round(ctl, 1),
atl=round(atl, 1),
tsb=round(tsb, 1),
ramp_rate=round(ramp_rate, 2),
)
entries.append(entry)
prev_ctl = ctl
current += timedelta(days=1)
session.add_all(entries)
await session.flush()
return entries
async def get_fitness_history(
rider_id,
session: AsyncSession,
days: int = 90,
) -> list[FitnessHistory]:
"""Get fitness history for a rider, rebuilding if needed."""
cutoff = date.today() - timedelta(days=days)
query = (
select(FitnessHistory)
.where(FitnessHistory.rider_id == rider_id)
.where(FitnessHistory.date >= cutoff)
.order_by(FitnessHistory.date)
)
result = await session.execute(query)
entries = list(result.scalars().all())
# If no data or stale, rebuild
if not entries or entries[-1].date < date.today() - timedelta(days=1):
all_entries = await rebuild_fitness_history(rider_id, session)
entries = [e for e in all_entries if e.date >= cutoff]
return entries