Files
SneakySwole/app/services/progression_service.py
Phillip Tarrant 52e48f8ed4 feat: replace wk1/wk4 targets with 6→8→10→12 rep ladder progression
Simplifies the progression model to a universal rep ladder: every exercise
follows 6→8→10→12 reps at current weight, then +5 lbs and reset to 6.
Replaces per-user wk1/wk4 rep and weight targets with a single
starting_weight field.

- Add Alembic migration to drop wk1_reps/wk4_reps/wk1_weight/wk4_weight,
  add starting_weight (migrated from wk1_weight)
- Run Alembic migrations on app startup instead of create_all, with
  auto-detection and stamping for legacy databases
- Include alembic/ and alembic.ini in Docker image
- Rewrite progression_service.get_suggestion() with ladder logic:
  climb, hold, weight_increase, hold_at_top, deload
- Replace wk1/wk4 grid in exercise cards with rep ladder progress bar
- Add color-coded progression badges by type
- Change weight log input from text to number with pre-filled suggestion
- Normalize weight input in routes (0→BW, bare number→N lbs)
- Remove schedule page (route, template, nav link, tests)
- Simplify user_programs.yaml from 4 fields to 1 per exercise
- Update all tests for new schema and progression logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:57:02 -05:00

303 lines
10 KiB
Python

"""Auto-progression engine using a rep ladder model.
Every exercise follows the same 6 → 8 → 10 → 12 rep ladder at current weight.
At 12 reps with all sets felt easy, weight increases by 5 lbs and reps reset to 6.
Deload triggers after 4+ consecutive struggling sessions (-20% weight, reset to 6).
"""
import re
from datetime import date
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.progress_log import ProgressLog
from app.models.user_exercise_program import UserExerciseProgram
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
REP_LADDER = [6, 8, 10, 12]
SETS_PER_EXERCISE = 3
WEIGHT_INCREMENT = 5
DELOAD_FACTOR = 0.8
STRUGGLE_THRESHOLD = 4
def _parse_weight(weight_str: str) -> Optional[float]:
"""Extract numeric weight from a string like '30 lbs' or 'BW'.
Args:
weight_str: Weight as a string.
Returns:
Numeric weight in lbs, or None for bodyweight.
"""
if not weight_str or weight_str.upper() == "BW":
return None
match = re.search(r"(\d+(?:\.\d+)?)", weight_str)
return float(match.group(1)) if match else None
def _format_weight(weight_lbs: Optional[float]) -> str:
"""Format a numeric weight back to a display string.
Args:
weight_lbs: Weight in lbs, or None for bodyweight.
Returns:
Formatted string like '35 lbs' or 'BW'.
"""
if weight_lbs is None:
return "BW"
if weight_lbs == int(weight_lbs):
return f"{int(weight_lbs)} lbs"
return f"{weight_lbs:.1f} lbs"
def _snap_to_ladder(reps: int) -> int:
"""Clamp reps into the ladder range [6, 12]."""
return max(REP_LADDER[0], min(reps, REP_LADDER[-1]))
def _ladder_position(reps: int) -> int:
"""Return the index (0-3) of reps in REP_LADDER, or -1 if outside."""
snapped = _snap_to_ladder(reps)
try:
return REP_LADDER.index(snapped)
except ValueError:
# reps is in range but not on a ladder step (e.g. 7, 9, 11)
# find the highest step at or below current reps
for i in range(len(REP_LADDER) - 1, -1, -1):
if REP_LADDER[i] <= snapped:
return i
return -1
class ProgressionService:
"""Implements the rep ladder auto-progression engine.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def _get_program(
self, user_id: int, exercise_id: int,
) -> Optional[UserExerciseProgram]:
"""Look up the user's program for a specific exercise."""
statement = select(UserExerciseProgram).where(
UserExerciseProgram.user_id == user_id,
UserExerciseProgram.exercise_id == exercise_id,
)
return self._session.exec(statement).first()
def _get_recent_sessions(
self, user_id: int, exercise_id: int, limit: int = 5,
) -> list[dict]:
"""Get recent session summaries for an exercise.
Returns a list of dicts with: date, avg_reps, weight, all_felt_easy.
"""
statement = (
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
.limit(limit * 2)
)
sessions = self._session.exec(statement).all()
results = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(
WorkoutLog.session_id == ws.id,
WorkoutLog.exercise_id == exercise_id,
)
).all()
if not logs:
continue
avg_reps = sum(log.reps_completed for log in logs) / len(logs)
weight = logs[0].weight_used
all_felt_easy = all(log.felt_easy for log in logs)
results.append({
"date": ws.date,
"avg_reps": avg_reps,
"weight": weight,
"all_felt_easy": all_felt_easy,
"set_count": len(logs),
})
if len(results) >= limit:
break
return results
def get_suggestion(
self, user_id: int, exercise_id: int,
) -> dict:
"""Generate a progression suggestion using the rep ladder model.
Returns:
Dict with keys: suggested_reps, suggested_weight, suggested_sets,
ladder_position, progression_type, message.
"""
program = self._get_program(user_id, exercise_id)
if program is None:
return {
"suggested_reps": 0,
"suggested_weight": "",
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": -1,
"progression_type": "no_program",
"message": "No program found for this exercise.",
}
starting_weight = program.starting_weight
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
# No history — baseline suggestion
if not recent:
return {
"suggested_reps": REP_LADDER[0],
"suggested_weight": starting_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": 0,
"progression_type": "baseline",
"message": f"Start with {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ {starting_weight}.",
}
latest = recent[0]
current_reps = _snap_to_ladder(int(round(latest["avg_reps"])))
current_weight = latest["weight"]
current_weight_num = _parse_weight(current_weight)
all_felt_easy = latest["all_felt_easy"]
# Count consecutive struggling sessions (not felt easy)
struggle_count = 0
for s in recent:
if not s["all_felt_easy"]:
struggle_count += 1
else:
break
# Deload: 4+ consecutive struggling sessions
if struggle_count >= STRUGGLE_THRESHOLD:
if current_weight_num is not None:
deload_weight = current_weight_num * DELOAD_FACTOR
return {
"suggested_reps": REP_LADDER[0],
"suggested_weight": _format_weight(deload_weight),
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": 0,
"progression_type": "deload",
"message": (
f"Deload: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
f"{_format_weight(deload_weight)} (-20%)."
),
}
# Bodyweight — can't reduce weight, just reset reps
return {
"suggested_reps": REP_LADDER[0],
"suggested_weight": current_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": 0,
"progression_type": "deload",
"message": f"Deload: reset to {SETS_PER_EXERCISE}x{REP_LADDER[0]}.",
}
# At top of ladder (12 reps) and felt easy
if current_reps >= REP_LADDER[-1] and all_felt_easy:
if current_weight_num is not None:
new_weight = current_weight_num + WEIGHT_INCREMENT
return {
"suggested_reps": REP_LADDER[0],
"suggested_weight": _format_weight(new_weight),
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": 0,
"progression_type": "weight_increase",
"message": (
f"Weight up: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
f"{_format_weight(new_weight)} (+{WEIGHT_INCREMENT} lbs)."
),
}
# Bodyweight — hold at top
return {
"suggested_reps": REP_LADDER[-1],
"suggested_weight": current_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": len(REP_LADDER) - 1,
"progression_type": "hold_at_top",
"message": (
f"Hold: {SETS_PER_EXERCISE}x{REP_LADDER[-1]} @ {current_weight} "
f"(bodyweight max)."
),
}
# Below top and felt easy — climb to next ladder step
if all_felt_easy:
pos = _ladder_position(current_reps)
next_pos = min(pos + 1, len(REP_LADDER) - 1)
next_reps = REP_LADDER[next_pos]
return {
"suggested_reps": next_reps,
"suggested_weight": current_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": next_pos,
"progression_type": "climb",
"message": (
f"Climb: {SETS_PER_EXERCISE}x{next_reps} @ {current_weight}."
),
}
# Not all felt easy — hold at current
pos = _ladder_position(current_reps)
return {
"suggested_reps": current_reps,
"suggested_weight": current_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": pos,
"progression_type": "hold",
"message": f"Hold: {SETS_PER_EXERCISE}x{current_reps} @ {current_weight}.",
}
def record_progression(
self,
user_id: int,
exercise_id: int,
suggested_reps: int,
suggested_weight: str,
actual_reps: int,
actual_weight: str,
progression_type: str,
) -> ProgressLog:
"""Record a progression entry in the progress_log table."""
progress_log = ProgressLog(
user_id=user_id,
exercise_id=exercise_id,
date=date.today(),
suggested_reps=suggested_reps,
suggested_weight=suggested_weight,
actual_reps=actual_reps,
actual_weight=actual_weight,
progression_applied=progression_type,
)
self._session.add(progress_log)
self._session.commit()
self._session.refresh(progress_log)
logger.info(
"progression_recorded",
user_id=user_id,
exercise_id=exercise_id,
type=progression_type,
)
return progress_log