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