"""Progress metrics of an exercise computed from its history. Weighted exercises are tracked by the estimated one-rep max (e1RM) and by the volume load; exercises done without extra weight (pull-ups, dips) by the best set and the total number of reps. """ from dataclasses import dataclass from datetime import date from typing import List, Optional, Sequence from app.core.dto.training import ApproachDTO, ExerciseSessionDTO def estimate_1rm(weight: float, reps: int) -> float: """Epley formula: weight * (1 + reps / 30). A single rep is the weight itself.""" return weight if reps <= 1 else weight * (1 + reps / 30) @dataclass(frozen=True) class SessionMetrics: max_weight: float e1rm: Optional[float] # best set by e1RM, None when there are no weighted sets volume: float # sum of weight * reps over weighted sets total_reps: int # all sets, with or without weight best_reps: int # reps of the best set def session_metrics(approaches: Sequence[ApproachDTO]) -> SessionMetrics: # Sets without weight (warm-ups, bodyweight) don't count for e1RM and volume weighted_sets = [approach for approach in approaches if approach.weight > 0] return SessionMetrics( max_weight=max((approach.weight for approach in approaches), default=0.0), e1rm=max( (estimate_1rm(approach.weight, approach.reps) for approach in weighted_sets), default=None, ), volume=sum(approach.weight * approach.reps for approach in weighted_sets), total_reps=sum(approach.reps for approach in approaches), best_reps=max((approach.reps for approach in approaches), default=0), ) @dataclass(frozen=True) class SessionRow: session: ExerciseSessionDTO metrics: SessionMetrics @dataclass(frozen=True) class Record: value: float date: date @dataclass(frozen=True) class ExerciseProgress: # True if any session has weighted sets: then e1RM and volume are the main # metrics, otherwise reps of the best set and total reps weighted: bool rows: List[SessionRow] # same order as the history: newest first max_weight: float best_primary: Optional[Record] # e1RM / best set reps best_secondary: Optional[Record] # volume / total reps of a session first_primary: Optional[float] # in the oldest session that has the metric last_primary: Optional[float] # in the newest session that has the metric @property def primary_change(self) -> Optional[float]: """Absolute change of the main metric between the first and the last session.""" if self.first_primary is None or self.last_primary is None: return None return self.last_primary - self.first_primary @property def primary_change_percent(self) -> Optional[float]: change = self.primary_change if change is None or not self.first_primary: return None return change / self.first_primary * 100 def summarize(history: Sequence[ExerciseSessionDTO]) -> ExerciseProgress: """Progress of an exercise; history is newest first (as the repository returns it).""" rows = [SessionRow(item, session_metrics(item.approaches)) for item in history] weighted = any(row.metrics.e1rm is not None for row in rows) def primary(row: SessionRow) -> Optional[float]: return row.metrics.e1rm if weighted else float(row.metrics.best_reps) def secondary(row: SessionRow) -> float: return row.metrics.volume if weighted else float(row.metrics.total_reps) best_primary: Optional[Record] = None best_secondary: Optional[Record] = None first_primary: Optional[float] = None last_primary: Optional[float] = None # Oldest first: a record is kept on the date it was first reached for row in reversed(rows): value = primary(row) if value is not None: if first_primary is None: first_primary = value last_primary = value if best_primary is None or value > best_primary.value: best_primary = Record(value, row.session.date) extra = secondary(row) if best_secondary is None or extra > best_secondary.value: best_secondary = Record(extra, row.session.date) return ExerciseProgress( weighted=weighted, rows=rows, max_weight=max((row.metrics.max_weight for row in rows), default=0.0), best_primary=best_primary, best_secondary=best_secondary, first_primary=first_primary, last_primary=last_primary, )