120 lines
4.5 KiB
Python
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,
|
|
)
|