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>
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
"""Auto-progression engine for workout programming.
|
||||
"""Auto-progression engine using a rep ladder model.
|
||||
|
||||
Analyzes workout log history and applies the progression model:
|
||||
- +1-2 reps/week until wk4 rep target
|
||||
- +5 lbs every 2 weeks once at rep target
|
||||
- Deload at week 5 (-20% weight, reset to wk1 reps)
|
||||
- Accelerated weight increase when all sets felt easy
|
||||
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
|
||||
@@ -21,6 +19,12 @@ 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'.
|
||||
@@ -53,8 +57,27 @@ def _format_weight(weight_lbs: Optional[float]) -> str:
|
||||
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 auto-progression engine.
|
||||
"""Implements the rep ladder auto-progression engine.
|
||||
|
||||
Args:
|
||||
session: An active SQLModel Session.
|
||||
@@ -120,14 +143,11 @@ class ProgressionService:
|
||||
def get_suggestion(
|
||||
self, user_id: int, exercise_id: int,
|
||||
) -> dict:
|
||||
"""Generate a progression suggestion for the next workout.
|
||||
|
||||
Analyzes recent log history against the user's program targets
|
||||
and applies progression rules.
|
||||
"""Generate a progression suggestion using the rep ladder model.
|
||||
|
||||
Returns:
|
||||
Dict with keys: suggested_reps, suggested_weight,
|
||||
progression_type, message.
|
||||
Dict with keys: suggested_reps, suggested_weight, suggested_sets,
|
||||
ladder_position, progression_type, message.
|
||||
"""
|
||||
program = self._get_program(user_id, exercise_id)
|
||||
|
||||
@@ -135,107 +155,118 @@ class ProgressionService:
|
||||
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.",
|
||||
}
|
||||
|
||||
try:
|
||||
wk1_reps = int(program.wk1_reps)
|
||||
wk4_reps = int(program.wk4_reps)
|
||||
except (ValueError, TypeError):
|
||||
wk1_reps = 0
|
||||
wk4_reps = 0
|
||||
|
||||
wk1_weight = program.wk1_weight
|
||||
|
||||
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": wk1_reps,
|
||||
"suggested_weight": wk1_weight,
|
||||
"suggested_reps": REP_LADDER[0],
|
||||
"suggested_weight": starting_weight,
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": 0,
|
||||
"progression_type": "baseline",
|
||||
"message": f"Start with {wk1_reps} reps @ {wk1_weight}.",
|
||||
"message": f"Start with {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ {starting_weight}.",
|
||||
}
|
||||
|
||||
latest = recent[0]
|
||||
current_reps = int(round(latest["avg_reps"]))
|
||||
current_reps = _snap_to_ladder(int(round(latest["avg_reps"])))
|
||||
current_weight = latest["weight"]
|
||||
current_weight_num = _parse_weight(current_weight)
|
||||
consecutive_sessions = len(recent)
|
||||
all_felt_easy = latest["all_felt_easy"]
|
||||
|
||||
# Rule: Deload at week 5 (4 consecutive sessions completed)
|
||||
if consecutive_sessions >= 4:
|
||||
# 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 * 0.8
|
||||
deload_weight = current_weight_num * DELOAD_FACTOR
|
||||
return {
|
||||
"suggested_reps": wk1_reps,
|
||||
"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 week: {wk1_reps} reps @ "
|
||||
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": wk1_reps,
|
||||
"suggested_reps": REP_LADDER[0],
|
||||
"suggested_weight": current_weight,
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": 0,
|
||||
"progression_type": "deload",
|
||||
"message": f"Deload week: reset to {wk1_reps} reps.",
|
||||
"message": f"Deload: reset to {SETS_PER_EXERCISE}x{REP_LADDER[0]}.",
|
||||
}
|
||||
|
||||
# Rule: Weight increase if at rep target and felt easy
|
||||
if current_reps >= wk4_reps and latest["all_felt_easy"]:
|
||||
# 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 + 5
|
||||
new_weight = current_weight_num + WEIGHT_INCREMENT
|
||||
return {
|
||||
"suggested_reps": wk1_reps,
|
||||
"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: {wk1_reps} reps @ "
|
||||
f"{_format_weight(new_weight)} (+5 lbs)."
|
||||
f"Weight up: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
|
||||
f"{_format_weight(new_weight)} (+{WEIGHT_INCREMENT} lbs)."
|
||||
),
|
||||
}
|
||||
|
||||
# Rule: Weight increase after 2 weeks at rep target
|
||||
if (
|
||||
current_reps >= wk4_reps
|
||||
and len(recent) >= 2
|
||||
and int(round(recent[1]["avg_reps"])) >= wk4_reps
|
||||
):
|
||||
if current_weight_num is not None:
|
||||
new_weight = current_weight_num + 5
|
||||
return {
|
||||
"suggested_reps": wk1_reps,
|
||||
"suggested_weight": _format_weight(new_weight),
|
||||
"progression_type": "weight_increase",
|
||||
"message": (
|
||||
f"2 weeks at target: {wk1_reps} reps @ "
|
||||
f"{_format_weight(new_weight)} (+5 lbs)."
|
||||
),
|
||||
}
|
||||
|
||||
# Rule: Rep increase (+1-2 reps)
|
||||
if current_reps < wk4_reps:
|
||||
increment = 2 if latest["all_felt_easy"] else 1
|
||||
new_reps = min(current_reps + increment, wk4_reps)
|
||||
# Bodyweight — hold at top
|
||||
return {
|
||||
"suggested_reps": new_reps,
|
||||
"suggested_reps": REP_LADDER[-1],
|
||||
"suggested_weight": current_weight,
|
||||
"progression_type": "reps_increase",
|
||||
"suggested_sets": SETS_PER_EXERCISE,
|
||||
"ladder_position": len(REP_LADDER) - 1,
|
||||
"progression_type": "hold_at_top",
|
||||
"message": (
|
||||
f"Reps up: {new_reps} reps @ {current_weight} "
|
||||
f"(+{increment})."
|
||||
f"Hold: {SETS_PER_EXERCISE}x{REP_LADDER[-1]} @ {current_weight} "
|
||||
f"(bodyweight max)."
|
||||
),
|
||||
}
|
||||
|
||||
# Hold: at target, waiting for biweekly weight increase
|
||||
# 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 at {current_reps} reps @ {current_weight}.",
|
||||
"message": f"Hold: {SETS_PER_EXERCISE}x{current_reps} @ {current_weight}.",
|
||||
}
|
||||
|
||||
def record_progression(
|
||||
|
||||
Reference in New Issue
Block a user