f1tness_parser/backend/app/core/metrics.py

120 lines
4.5 KiB
Python

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