fix
This commit is contained in:
107
backend/app/services/fitness.py
Normal file
107
backend/app/services/fitness.py
Normal 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
|
||||
Reference in New Issue
Block a user