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