108 lines
3.1 KiB
Python
108 lines
3.1 KiB
Python
"""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
|