diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f516d9899 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +.env diff --git a/README.md b/README.md index 2d5c20fc0..74a833877 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,63 @@ -# Complete-Python-3-Bootcamp -Course Files for Complete Python 3 Bootcamp Course on Udemy +# Ironforge — Evidence-Based Workout Program Designer + +A Python CLI tool that generates personalized training programs based on peer-reviewed exercise science (18 papers, 2023-2025). + +## How It Works + +1. **Intake** — Answers structured questions about goals, training history, schedule, equipment, and individual factors +2. **Algorithm** — Applies evidence-based rules for volume, frequency, exercise selection, load/RIR, and periodization +3. **Output** — Generates a complete mesocycle with per-session exercises, sets, reps, RIR, rest periods, progression, and deload protocols + +## Quick Start + +```bash +python -m ironforge +``` + +Or with JSON output: + +```bash +python -m ironforge --format json +``` + +## What It Generates + +1. **Training Level Assessment** — Per movement pattern (squat, bench, row, etc.) +2. **Volume Targets** — Weekly fractional sets per muscle group (MEV → MRV) +3. **Split Structure** — Full Body / Upper-Lower / PPL based on available days +4. **Weekly Program** — Each session with exercises, sets × reps, RIR, rest, coaching notes +5. **Progression Instructions** — Linear / Double Progression / Autoregulated based on level +6. **Deload Protocol** — Active deload with reactive triggers +7. **Mesocycle Overview** — 5-week block structure (4 training + 1 deload) + +## Key Algorithm Features + +- **Fractional set accounting** — Compound exercises give partial credit to secondary muscles +- **Per-pattern classification** — You can be intermediate on bench but beginner on squat +- **97 approved exercises** — Only evidence-backed movements with proper muscle targeting +- **A/B session variation** — Different exercises between repeated session types +- **Antagonist supersets** — Optional time-saving pairing (reduces session time ~36%) +- **Sex-adjusted rep ranges** — Females biased toward higher rep ranges (more Type I fiber) +- **Equipment filtering** — Works for full gyms, limited gyms, and home setups + +## Project Structure + +``` +ironforge/ +├── data/ # Exercise database, muscle groups, algorithm constants +├── intake/ # Questionnaire flow and user profile +├── engine/ # Classifier, volume, frequency, selection, load, supersets, periodization +├── program/ # Data models and builder (orchestrator) +└── output/ # Terminal formatter +``` + +## Requirements + +- Python 3.11+ +- Zero external dependencies + +## Tests + +```bash +python -m unittest tests.test_program -v +``` diff --git a/api/index.py b/api/index.py new file mode 100644 index 000000000..9e8522329 --- /dev/null +++ b/api/index.py @@ -0,0 +1,11 @@ +"""Vercel serverless entry point — wraps the Flask app.""" + +import sys +import os + +# Add project root to path so ironforge package is importable +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ironforge.web import app + +# Vercel expects a WSGI-compatible `app` object diff --git a/ironforge/__init__.py b/ironforge/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ironforge/__main__.py b/ironforge/__main__.py new file mode 100644 index 000000000..742bd4e15 --- /dev/null +++ b/ironforge/__main__.py @@ -0,0 +1,4 @@ +from ironforge.cli import main + +if __name__ == "__main__": + main() diff --git a/ironforge/cli.py b/ironforge/cli.py new file mode 100644 index 000000000..3877cd178 --- /dev/null +++ b/ironforge/cli.py @@ -0,0 +1,44 @@ +"""CLI entry point for Ironforge.""" + +import argparse +import json +from dataclasses import asdict + +from ironforge.intake.flow import run_intake +from ironforge.program.builder import build_program +from ironforge.output.formatter import render + + +def main(): + parser = argparse.ArgumentParser( + description="Ironforge — Evidence-Based Workout Program Designer", + ) + parser.add_argument( + "--format", choices=["text", "json"], default="text", + help="Output format (default: text)", + ) + args = parser.parse_args() + + # Run intake questionnaire + profile = run_intake() + + # Build program + program = build_program(profile) + + # Output + if args.format == "json": + # Convert enums to strings for JSON serialization + def _convert(obj): + if hasattr(obj, "name") and hasattr(obj, "value"): + return obj.name + raise TypeError(f"Cannot serialize {type(obj)}") + + data = asdict(program) + print(json.dumps(data, indent=2, default=_convert)) + else: + output = render(program) + print(output) + + +if __name__ == "__main__": + main() diff --git a/ironforge/data/__init__.py b/ironforge/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ironforge/data/constants.py b/ironforge/data/constants.py new file mode 100644 index 000000000..5498b0910 --- /dev/null +++ b/ironforge/data/constants.py @@ -0,0 +1,128 @@ +"""Algorithm constants: volume landmarks, rep ranges, RIR targets, rest periods.""" + +from ironforge.data.muscle_groups import VolumeMuscle, Tier, TrainingLevel +from dataclasses import dataclass + + +@dataclass(frozen=True) +class VolumeLandmarks: + mev_low: float + mev_high: float + mav_low: float + mav_high: float + mrv: float + + +# Volume landmarks in fractional sets per week +VOLUME_LANDMARKS: dict[VolumeMuscle, VolumeLandmarks] = { + VolumeMuscle.QUADS: VolumeLandmarks(4, 6, 6, 14, 18), + VolumeMuscle.CHEST: VolumeLandmarks(10, 10, 12, 20, 22), + VolumeMuscle.BACK: VolumeLandmarks(8, 8, 10, 20, 25), + VolumeMuscle.BICEPS: VolumeLandmarks(8, 8, 14, 20, 20), + VolumeMuscle.SIDE_REAR_DELTS: VolumeLandmarks(8, 8, 16, 22, 26), + VolumeMuscle.HAMSTRINGS: VolumeLandmarks(4, 6, 6, 12, 16), + VolumeMuscle.TRICEPS: VolumeLandmarks(4, 4, 8, 18, 18), + VolumeMuscle.CALVES: VolumeLandmarks(12, 12, 12, 18, 20), + VolumeMuscle.GLUTES: VolumeLandmarks(4, 6, 6, 14, 18), + VolumeMuscle.ABS: VolumeLandmarks(2, 4, 4, 10, 14), + VolumeMuscle.FOREARMS: VolumeLandmarks(2, 2, 4, 8, 10), +} + + +# Rep ranges by tier — all capped to 4-10 +REP_RANGES: dict[Tier, tuple[int, int]] = { + Tier.T1: (4, 6), + Tier.T2: (6, 8), + Tier.T3: (8, 10), +} + +# Female bias: slightly higher within the 4-10 window +REP_RANGES_FEMALE: dict[Tier, tuple[int, int]] = { + Tier.T1: (5, 8), + Tier.T2: (6, 10), + Tier.T3: (8, 10), +} + + +# RIR targets by week of mesocycle (1-indexed) +RIR_BY_WEEK: dict[int, int] = { + 1: 3, + 2: 2, + 3: 2, + 4: 1, +} + +RIR_DELOAD: int = 5 + +# Beginner RIR override (their calibration is off by 3-5 reps) +RIR_BEGINNER: int = 3 + +# Rest periods in seconds (min, max) +REST_PERIODS: dict[Tier, tuple[int, int]] = { + Tier.T1: (120, 180), + Tier.T2: (120, 150), + Tier.T3: (90, 120), +} + +# Per-session ceiling: direct hard sets per muscle +PER_SESSION_DIRECT_CEILING = 8 +PER_SESSION_FRACTIONAL_CEILING = 11 + +# Maximum sets per exercise per session +MAX_SETS_PER_EXERCISE = 3 + +# Compound spillover contributions (fractional sets) +# Maps from exercise muscle group categories to secondary contributions +COMPOUND_SPILLOVER = { + "bench_press": { + "primary": "chest", + "secondary": {"triceps": 0.5, "front_delt": 0.5}, + }, + "overhead_press": { + "primary": "front_delt", + "secondary": {"triceps": 0.5, "side_delt": 0.3}, + }, + "row": { + "primary": "back", + "secondary": {"biceps": 0.5, "rear_delt": 0.3}, + }, + "pulldown": { + "primary": "lats", + "secondary": {"biceps": 0.5}, + }, + "squat": { + "primary": "quads", + "secondary": {"glutes": 0.5}, + }, + "rdl": { + "primary": "hamstrings", + "secondary": {"glutes": 0.5, "spinal_erectors": 0.5}, + }, + "hip_thrust": { + "primary": "glutes", + "secondary": {}, + }, + "dip": { + "primary": "chest", + "secondary": {"triceps": 0.5}, + }, +} + + +# Split templates by days per week +SPLIT_TEMPLATES = { + 3: "full_body", + 4: "upper_lower", + 5: "ul_ppl_hybrid", + 6: "ppl", +} + +# Deload frequency by training level (weeks between deloads) +DELOAD_FREQUENCY: dict[TrainingLevel, tuple[int, int]] = { + TrainingLevel.BEGINNER: (8, 12), + TrainingLevel.INTERMEDIATE: (4, 6), + TrainingLevel.ADVANCED: (3, 5), +} + +# Mesocycle length (training weeks only — no forced deload) +MESO_LENGTH = 4 diff --git a/ironforge/data/exercises.py b/ironforge/data/exercises.py new file mode 100644 index 000000000..9cecb9fc3 --- /dev/null +++ b/ironforge/data/exercises.py @@ -0,0 +1,737 @@ +"""Approved exercise database — 97 exercises with metadata.""" + +from dataclasses import dataclass, field +from ironforge.data.muscle_groups import ( + MuscleGroup, MovementPattern, Tier, Equipment, +) + + +@dataclass(frozen=True) +class ExerciseDefinition: + name: str + primary: MuscleGroup + secondary: tuple[tuple[MuscleGroup, float], ...] = () + pattern: MovementPattern = MovementPattern.ISOLATION + tier: Tier = Tier.T3 + equipment: tuple[Equipment, ...] = (Equipment.MACHINE,) + bilateral: bool = True + notes: str = "" + tags: tuple[str, ...] = () + + +def _ex(name, primary, *, secondary=(), pattern=MovementPattern.ISOLATION, + tier=Tier.T3, equipment=(Equipment.MACHINE,), bilateral=True, + notes="", tags=()): + return ExerciseDefinition( + name=name, primary=primary, + secondary=tuple(secondary), + pattern=pattern, tier=tier, + equipment=tuple(equipment), + bilateral=bilateral, notes=notes, + tags=tuple(tags), + ) + + +# ─── CHEST ─────────────────────────────────────────────────────────────────── + +INCLINE_SMITH_BENCH = _ex( + "Incline Smith Bench Press", MuscleGroup.CHEST_CLAVICULAR, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.FRONT_DELT, 0.5)), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T1, + equipment=(Equipment.SMITH_MACHINE,), + tags=("upper_chest", "press"), +) +SMITH_CHEST_PRESS = _ex( + "Smith Guided Chest Press", MuscleGroup.CHEST_STERNAL, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.FRONT_DELT, 0.5)), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T1, + equipment=(Equipment.SMITH_MACHINE,), + tags=("flat_chest", "press"), +) +INCLINE_BENCH = _ex( + "Incline Bench", MuscleGroup.CHEST_CLAVICULAR, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.FRONT_DELT, 0.5)), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T1, + equipment=(Equipment.BARBELL,), + tags=("upper_chest", "press"), +) +FLAT_BENCH = _ex( + "Flat Bench", MuscleGroup.CHEST_STERNAL, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.FRONT_DELT, 0.5)), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T1, + equipment=(Equipment.BARBELL,), + tags=("flat_chest", "press"), +) +INCLINE_DB_PRESS = _ex( + "Incline Dumbbell Press", MuscleGroup.CHEST_CLAVICULAR, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.FRONT_DELT, 0.5)), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T2, + equipment=(Equipment.DUMBBELL,), + tags=("upper_chest", "press"), +) +FLAT_DB_PRESS = _ex( + "Flat Dumbbell Press", MuscleGroup.CHEST_STERNAL, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.FRONT_DELT, 0.5)), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T2, + equipment=(Equipment.DUMBBELL,), + tags=("flat_chest", "press"), +) +DB_BENCH = _ex( + "Dumbbell Bench", MuscleGroup.CHEST_STERNAL, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.FRONT_DELT, 0.5)), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T2, + equipment=(Equipment.DUMBBELL,), + tags=("flat_chest", "press"), +) +FLAT_BENCH_CABLES = _ex( + "Flat Bench Cables", MuscleGroup.CHEST_STERNAL, + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T3, + equipment=(Equipment.CABLE,), + tags=("flat_chest", "fly"), +) +LOW_CHEST_CABLE_FLY = _ex( + "Low Chest Cable Fly", MuscleGroup.CHEST_STERNAL, + equipment=(Equipment.CABLE,), + tags=("flat_chest", "fly", "lower_chest"), +) +MID_CHEST_CABLE_FLY = _ex( + "Mid Chest Cable Fly", MuscleGroup.CHEST_STERNAL, + equipment=(Equipment.CABLE,), + tags=("flat_chest", "fly"), +) +UPPER_CHEST_CABLE_FLY = _ex( + "Upper Chest Cable Fly", MuscleGroup.CHEST_CLAVICULAR, + equipment=(Equipment.CABLE,), + tags=("upper_chest", "fly"), +) +CHEST_PRESS_MACHINE = _ex( + "Chest Press Machine", MuscleGroup.CHEST_STERNAL, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.FRONT_DELT, 0.5)), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("flat_chest", "press"), +) +INCLINE_MACHINE_PRESS = _ex( + "Incline Machine Chest Press", MuscleGroup.CHEST_CLAVICULAR, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.FRONT_DELT, 0.5)), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("upper_chest", "press"), +) +PEC_DECK = _ex( + "Pec Deck", MuscleGroup.CHEST_STERNAL, + equipment=(Equipment.MACHINE,), + tags=("flat_chest", "fly"), +) +UPPER_CHEST_PEC_DECK = _ex( + "Upper Chest Pec Deck", MuscleGroup.CHEST_CLAVICULAR, + equipment=(Equipment.MACHINE,), + tags=("upper_chest", "fly"), +) + +# ─── SHOULDERS ─────────────────────────────────────────────────────────────── + +SHOULDER_PRESS_MACHINE = _ex( + "Shoulder Press Machine", MuscleGroup.FRONT_DELT, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.SIDE_DELT, 0.3)), + pattern=MovementPattern.VERTICAL_PUSH, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("ohp",), +) +SHOULDER_PRESS = _ex( + "Shoulder Press", MuscleGroup.FRONT_DELT, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5), (MuscleGroup.SIDE_DELT, 0.3)), + pattern=MovementPattern.VERTICAL_PUSH, tier=Tier.T1, + equipment=(Equipment.DUMBBELL,), + tags=("ohp",), +) +LATERAL_RAISE_MACHINE = _ex( + "Lateral Raise Machine", MuscleGroup.SIDE_DELT, + equipment=(Equipment.MACHINE,), + tags=("side_delt",), +) +LATERAL_RAISE = _ex( + "Lateral Raise", MuscleGroup.SIDE_DELT, + equipment=(Equipment.DUMBBELL,), + tags=("side_delt",), +) +LATERAL_RAISE_CABLE = _ex( + "Lateral Raise with Cable", MuscleGroup.SIDE_DELT, + equipment=(Equipment.CABLE,), + tags=("side_delt",), +) +REAR_DELT_FLY = _ex( + "Rear Delt Fly", MuscleGroup.REAR_DELT, + equipment=(Equipment.DUMBBELL,), + tags=("rear_delt",), +) +REAR_DELT_FLY_SUPPORTED = _ex( + "Rear Delt Fly with Chest Support", MuscleGroup.REAR_DELT, + equipment=(Equipment.DUMBBELL,), + tags=("rear_delt", "chest_supported"), +) +CABLE_FACE_PULL = _ex( + "Cable Face Pull", MuscleGroup.REAR_DELT, + equipment=(Equipment.CABLE,), + tags=("rear_delt", "health_accessory"), + notes="Health accessory only — not primary rear delt volume", +) + +# ─── TRICEPS ───────────────────────────────────────────────────────────────── + +DIP_MACHINE = _ex( + "Dip Machine", MuscleGroup.TRICEPS_LATERAL, + secondary=((MuscleGroup.CHEST_STERNAL, 0.5),), + pattern=MovementPattern.HORIZONTAL_PUSH, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("triceps", "compound"), +) +ROPE_TRICEP_EXT = _ex( + "Rope Tricep Extension", MuscleGroup.TRICEPS_LATERAL, + equipment=(Equipment.CABLE,), + tags=("triceps", "pushdown"), +) +OH_ROPE_TRICEP_EXT = _ex( + "Overhead Rope Tricep Extension", MuscleGroup.TRICEPS_LONG, + equipment=(Equipment.CABLE,), + tags=("triceps", "overhead", "long_head"), +) +STRAIGHT_BAR_TRICEP_EXT = _ex( + "Straight Bar Tricep Extension", MuscleGroup.TRICEPS_LATERAL, + equipment=(Equipment.CABLE,), + tags=("triceps", "pushdown"), +) +OH_STRAIGHT_BAR_TRICEP_EXT = _ex( + "Overhead Straight Bar Tricep Extension", MuscleGroup.TRICEPS_LONG, + equipment=(Equipment.CABLE,), + tags=("triceps", "overhead", "long_head"), +) +VBAR_TRICEP_EXT = _ex( + "V-Bar Tricep Extension", MuscleGroup.TRICEPS_LATERAL, + equipment=(Equipment.CABLE,), + tags=("triceps", "pushdown"), +) +OH_VBAR_TRICEP_EXT = _ex( + "Overhead V-Bar Tricep Extension", MuscleGroup.TRICEPS_LONG, + equipment=(Equipment.CABLE,), + tags=("triceps", "overhead", "long_head"), +) +SINGLE_ARM_DHANDLE_TRICEP = _ex( + "Single Arm D-Handle Tricep Extension", MuscleGroup.TRICEPS_LATERAL, + equipment=(Equipment.CABLE,), bilateral=False, + tags=("triceps", "pushdown"), +) +OH_SINGLE_ARM_DB_EXT = _ex( + "Overhead Single Arm Dumbbell Extension", MuscleGroup.TRICEPS_LONG, + equipment=(Equipment.DUMBBELL,), bilateral=False, + tags=("triceps", "overhead", "long_head"), +) +OH_EZ_BAR_TRICEP_EXT = _ex( + "Overhead EZ Bar Tricep Extension", MuscleGroup.TRICEPS_LONG, + equipment=(Equipment.EZ_BAR,), + tags=("triceps", "overhead", "long_head"), +) +EZ_BAR_SKULL_CRUSHER = _ex( + "EZ Bar Skull Crusher", MuscleGroup.TRICEPS_LONG, + equipment=(Equipment.EZ_BAR,), + tags=("triceps", "long_head", "heavy"), +) +EZ_BAR_JM_PRESS = _ex( + "EZ Bar JM Press", MuscleGroup.TRICEPS_LONG, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5),), + equipment=(Equipment.EZ_BAR,), tier=Tier.T2, + tags=("triceps", "long_head", "heavy"), +) +SMITH_JM_PRESS = _ex( + "Smith JM Press", MuscleGroup.TRICEPS_LONG, + secondary=((MuscleGroup.TRICEPS_LATERAL, 0.5),), + equipment=(Equipment.SMITH_MACHINE,), tier=Tier.T2, + tags=("triceps", "long_head", "heavy"), +) + +# ─── BICEPS ────────────────────────────────────────────────────────────────── + +PREACHER_CURL = _ex( + "Preacher Curl", MuscleGroup.BICEPS, + equipment=(Equipment.DUMBBELL,), + tags=("biceps", "distal"), +) +MACHINE_PREACHER_CURL = _ex( + "Machine Preacher Curl", MuscleGroup.BICEPS, + equipment=(Equipment.MACHINE,), + tags=("biceps", "distal"), +) +EZ_BAR_PREACHER_CURL = _ex( + "EZ Bar Preacher Curl", MuscleGroup.BICEPS, + equipment=(Equipment.EZ_BAR,), + tags=("biceps", "distal"), +) +SINGLE_ARM_DB_PREACHER = _ex( + "Single Arm Dumbbell Preacher Curl", MuscleGroup.BICEPS, + equipment=(Equipment.DUMBBELL,), bilateral=False, + tags=("biceps", "distal"), +) +SPIDER_CURL = _ex( + "Spider Curl", MuscleGroup.BICEPS, + equipment=(Equipment.DUMBBELL,), + tags=("biceps", "distal"), +) +INCLINE_DB_CURL = _ex( + "Incline Bench Dumbbell Curl", MuscleGroup.BICEPS, + equipment=(Equipment.DUMBBELL,), + tags=("biceps", "proximal", "stretch"), + notes="30° bench; primary stretch-position curl", +) +STANDING_ALT_DB_CURL = _ex( + "Standing Alternating Dumbbell Curl", MuscleGroup.BICEPS, + equipment=(Equipment.DUMBBELL,), bilateral=False, + tags=("biceps", "general"), +) +HAMMER_CURL = _ex( + "Hammer Curl", MuscleGroup.BRACHIALIS, + equipment=(Equipment.DUMBBELL,), + tags=("brachialis",), + notes="Do NOT count as biceps volume", +) +SEATED_HAMMER_CURL = _ex( + "Seated Hammer Curl", MuscleGroup.BRACHIALIS, + equipment=(Equipment.DUMBBELL,), + tags=("brachialis",), + notes="Do NOT count as biceps volume", +) +BAYESIAN_CURL = _ex( + "Bayesian Curl", MuscleGroup.BICEPS, + equipment=(Equipment.CABLE,), + tags=("biceps", "proximal", "stretch"), + notes="Arm-behind-body position; excellent stretch-position curl", +) +EZ_BAR_CABLE_CURL = _ex( + "EZ Bar Cable Curl", MuscleGroup.BICEPS, + equipment=(Equipment.CABLE,), + tags=("biceps", "general"), +) + +# ─── BACK — VERTICAL PULLS ────────────────────────────────────────────────── + +LAT_PULLDOWN_MACHINE = _ex( + "Lat Pulldown Machine", MuscleGroup.LATS, + secondary=((MuscleGroup.BICEPS, 0.5),), + pattern=MovementPattern.VERTICAL_PULL, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("vertical_pull", "lat"), +) +LAT_PULLDOWN_WIDE = _ex( + "Lat Pulldown Wide Grip", MuscleGroup.LATS, + secondary=((MuscleGroup.BICEPS, 0.5),), + pattern=MovementPattern.VERTICAL_PULL, tier=Tier.T2, + equipment=(Equipment.CABLE,), + tags=("vertical_pull", "lat"), +) +LAT_PULLDOWN_NEUTRAL = _ex( + "Lat Pulldown Neutral Grip", MuscleGroup.LATS, + secondary=((MuscleGroup.BICEPS, 0.5),), + pattern=MovementPattern.VERTICAL_PULL, tier=Tier.T2, + equipment=(Equipment.CABLE,), + tags=("vertical_pull", "lat"), +) +LAT_PULLDOWN_CLOSE = _ex( + "Lat Pulldown Close Grip", MuscleGroup.LATS, + secondary=((MuscleGroup.BICEPS, 0.5),), + pattern=MovementPattern.VERTICAL_PULL, tier=Tier.T2, + equipment=(Equipment.CABLE,), + tags=("vertical_pull", "lat"), +) +SINGLE_ARM_LAT_PULLDOWN = _ex( + "Single Arm Lat Pulldown", MuscleGroup.LATS, + secondary=((MuscleGroup.BICEPS, 0.5),), + pattern=MovementPattern.VERTICAL_PULL, tier=Tier.T2, + equipment=(Equipment.CABLE,), bilateral=False, + tags=("vertical_pull", "lat"), +) +PULLDOWN_ROW_MACHINE = _ex( + "Pulldown & Row Machine", MuscleGroup.LATS, + secondary=((MuscleGroup.BICEPS, 0.5), (MuscleGroup.MID_BACK, 0.5)), + pattern=MovementPattern.VERTICAL_PULL, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("vertical_pull", "lat", "row"), +) +EZ_BAR_CABLE_PULLOVER = _ex( + "EZ Bar Cable Pullover", MuscleGroup.LATS, + equipment=(Equipment.CABLE,), + tags=("lat", "stretch"), + notes="Stretch-focused lat exercise", +) +SINGLE_ARM_CABLE_PULLOVER = _ex( + "Single Arm Cable Pullover", MuscleGroup.LATS, + equipment=(Equipment.CABLE,), bilateral=False, + tags=("lat", "stretch"), + notes="Stretch-focused lat exercise", +) +FLAT_PULLOVER_MACHINE = _ex( + "Flat Pullover Machine", MuscleGroup.LATS, + equipment=(Equipment.MACHINE,), + tags=("lat", "stretch"), + notes="Stretch-focused lat exercise", +) + +# ─── BACK — HORIZONTAL ROWS ───────────────────────────────────────────────── + +TBAR_ROW = _ex( + "T-Bar Row", MuscleGroup.MID_BACK, + secondary=((MuscleGroup.LATS, 0.5), (MuscleGroup.BICEPS, 0.5)), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T1, + equipment=(Equipment.BARBELL,), + tags=("row", "mid_back"), +) +SMITH_BARBELL_ROW = _ex( + "Smith Machine Barbell Row", MuscleGroup.MID_BACK, + secondary=((MuscleGroup.LATS, 0.5), (MuscleGroup.BICEPS, 0.5)), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T1, + equipment=(Equipment.SMITH_MACHINE,), + tags=("row", "mid_back"), + notes="Do not program above 20 reps", +) +BARBELL_ROW = _ex( + "Barbell Row", MuscleGroup.MID_BACK, + secondary=((MuscleGroup.LATS, 0.5), (MuscleGroup.BICEPS, 0.5)), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T1, + equipment=(Equipment.BARBELL,), + tags=("row", "mid_back"), + notes="Do not program above 20 reps", +) +SEATED_CHEST_SUPPORTED_ROW = _ex( + "Seated Chest-Supported Row", MuscleGroup.MID_BACK, + secondary=((MuscleGroup.LATS, 0.5), (MuscleGroup.BICEPS, 0.5)), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("row", "mid_back", "chest_supported"), + notes="Preferred for stability and stretch", +) +CHEST_SUPPORTED_ELBOWS_TUCKED = _ex( + "Chest Supported Elbows Tucked Row", MuscleGroup.LATS, + secondary=((MuscleGroup.BICEPS, 0.5),), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("row", "lat_emphasis", "chest_supported"), +) +CHEST_SUPPORTED_ELBOWS_FLARED = _ex( + "Chest Supported Elbows Flared Row", MuscleGroup.MID_BACK, + secondary=((MuscleGroup.REAR_DELT, 0.5), (MuscleGroup.BICEPS, 0.5)), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("row", "mid_back", "rear_delt", "chest_supported"), +) +SINGLE_ARM_CABLE_ROW = _ex( + "Single Arm Cable Row", MuscleGroup.MID_BACK, + secondary=((MuscleGroup.LATS, 0.5), (MuscleGroup.BICEPS, 0.5)), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T2, + equipment=(Equipment.CABLE,), bilateral=False, + tags=("row", "mid_back"), +) +WIDE_GRIP_CABLE_ROW = _ex( + "Wide Grip Cable Row", MuscleGroup.MID_BACK, + secondary=((MuscleGroup.REAR_DELT, 0.5), (MuscleGroup.BICEPS, 0.5)), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T2, + equipment=(Equipment.CABLE,), + tags=("row", "mid_back", "rear_delt"), +) +DB_ROW_TO_HIP = _ex( + "Dumbbell Row to Hip", MuscleGroup.LATS, + secondary=((MuscleGroup.BICEPS, 0.5),), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T2, + equipment=(Equipment.DUMBBELL,), bilateral=False, + tags=("row", "lat_emphasis"), +) +ROW_MACHINE = _ex( + "Row Machine", MuscleGroup.MID_BACK, + secondary=((MuscleGroup.LATS, 0.5), (MuscleGroup.BICEPS, 0.5)), + pattern=MovementPattern.HORIZONTAL_PULL, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("row", "mid_back"), +) + +# ─── BACK — TRAPS / ACCESSORY ─────────────────────────────────────────────── + +DB_SHRUG = _ex( + "DB Shrug", MuscleGroup.UPPER_TRAP, + equipment=(Equipment.DUMBBELL,), + tags=("trap",), +) +BACK_EXTENSION = _ex( + "Back Extension / Hyperextension", MuscleGroup.SPINAL_ERECTORS, + secondary=((MuscleGroup.GLUTES, 0.3), (MuscleGroup.HAMSTRINGS, 0.3)), + equipment=(Equipment.BODYWEIGHT,), + tags=("lower_back", "lower_trap_sub"), + notes="Use Y-raise arm variation for lower trap work if possible", +) + +# ─── LEGS — QUADS ──────────────────────────────────────────────────────────── + +BARBELL_SQUAT = _ex( + "Barbell Squat", MuscleGroup.QUADS, + secondary=((MuscleGroup.GLUTES, 0.5),), + pattern=MovementPattern.SQUAT, tier=Tier.T1, + equipment=(Equipment.BARBELL,), + tags=("squat", "quad"), + notes="High-bar, full depth", +) +SMITH_SQUAT = _ex( + "Smith Machine Squat", MuscleGroup.QUADS, + secondary=((MuscleGroup.GLUTES, 0.5),), + pattern=MovementPattern.SQUAT, tier=Tier.T1, + equipment=(Equipment.SMITH_MACHINE,), + tags=("squat", "quad"), +) +SQUAT_RACK = _ex( + "Squat Rack", MuscleGroup.QUADS, + secondary=((MuscleGroup.GLUTES, 0.5),), + pattern=MovementPattern.SQUAT, tier=Tier.T1, + equipment=(Equipment.BARBELL,), + tags=("squat", "quad"), +) +HACK_SQUAT = _ex( + "Hack Squat", MuscleGroup.QUADS, + pattern=MovementPattern.SQUAT, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("squat", "quad", "quad_isolator"), +) +HACK_SQUAT_MACHINE = _ex( + "Hack Squat Machine", MuscleGroup.QUADS, + pattern=MovementPattern.SQUAT, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("squat", "quad", "quad_isolator"), + notes="Purest quad isolator; 8-15+ reps", +) +BELT_SQUAT = _ex( + "Belt Squat", MuscleGroup.QUADS, + secondary=((MuscleGroup.GLUTES, 0.3),), + pattern=MovementPattern.SQUAT, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("squat", "quad"), +) +PENDULUM_SQUAT = _ex( + "Pendulum Squat", MuscleGroup.QUADS, + pattern=MovementPattern.SQUAT, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("squat", "quad"), +) +BULGARIAN_SPLIT_SQUAT = _ex( + "Bulgarian Split Squat", MuscleGroup.QUADS, + secondary=((MuscleGroup.GLUTES, 0.5),), + pattern=MovementPattern.SQUAT, tier=Tier.T2, + equipment=(Equipment.DUMBBELL,), bilateral=False, + tags=("squat", "quad", "glute", "unilateral"), +) +SMITH_BULGARIAN = _ex( + "Smith Machine Bulgarian Split Squat", MuscleGroup.QUADS, + secondary=((MuscleGroup.GLUTES, 0.5),), + pattern=MovementPattern.SQUAT, tier=Tier.T2, + equipment=(Equipment.SMITH_MACHINE,), bilateral=False, + tags=("squat", "quad", "glute", "unilateral"), +) +LEG_PRESS = _ex( + "Leg Press", MuscleGroup.QUADS, + secondary=((MuscleGroup.GLUTES, 0.3),), + pattern=MovementPattern.SQUAT, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("squat", "quad"), + notes="VL-dominant; pair with leg extension", +) +LEG_PRESS_ELEVATED = _ex( + "Leg Press Feet Elevated", MuscleGroup.QUADS, + secondary=((MuscleGroup.GLUTES, 0.5), (MuscleGroup.HAMSTRINGS, 0.3)), + pattern=MovementPattern.SQUAT, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("squat", "quad", "glute"), +) +LEG_PRESS_LOWERED = _ex( + "Leg Press Feet Lowered", MuscleGroup.QUADS, + pattern=MovementPattern.SQUAT, tier=Tier.T2, + equipment=(Equipment.MACHINE,), + tags=("squat", "quad", "quad_bias"), + notes="Increased quad bias", +) +LEG_EXTENSION = _ex( + "Leg Extension", MuscleGroup.QUADS, + pattern=MovementPattern.KNEE_EXTENSION, tier=Tier.T3, + equipment=(Equipment.MACHINE,), + tags=("quad", "isolation", "rf"), + notes="MUST use reclined backrest (40° hip flexion) for RF growth", +) + +# ─── LEGS — HAMSTRINGS ─────────────────────────────────────────────────────── + +RDL = _ex( + "RDL", MuscleGroup.HAMSTRINGS, + secondary=((MuscleGroup.GLUTES, 0.5), (MuscleGroup.SPINAL_ERECTORS, 0.5)), + pattern=MovementPattern.HIP_HINGE, tier=Tier.T1, + equipment=(Equipment.BARBELL,), + tags=("hamstring", "hinge", "bflh"), + notes="Mandatory hip extension component", +) +SLDL = _ex( + "SLDL", MuscleGroup.HAMSTRINGS, + secondary=((MuscleGroup.GLUTES, 0.5), (MuscleGroup.SPINAL_ERECTORS, 0.5)), + pattern=MovementPattern.HIP_HINGE, tier=Tier.T1, + equipment=(Equipment.BARBELL,), + tags=("hamstring", "hinge", "bflh"), + notes="Mandatory hip extension component", +) +LYING_HAM_CURL = _ex( + "Lying Hamstring Curl", MuscleGroup.HAMSTRINGS, + pattern=MovementPattern.KNEE_FLEXION, + equipment=(Equipment.MACHINE,), + tags=("hamstring", "curl"), +) +SEATED_HAM_CURL = _ex( + "Seated Hamstring Curl", MuscleGroup.HAMSTRINGS, + pattern=MovementPattern.KNEE_FLEXION, + equipment=(Equipment.MACHINE,), + tags=("hamstring", "curl", "primary"), + notes="Primary hamstring exercise; 70-90% of curl volume; hip-flexed = BFlh stretch", +) +ROUNDED_BACK_HYPER = _ex( + "Rounded Back Hyperextension", MuscleGroup.HAMSTRINGS, + secondary=((MuscleGroup.GLUTES, 0.3),), + pattern=MovementPattern.HIP_HINGE, + equipment=(Equipment.BODYWEIGHT,), + tags=("hamstring", "hinge"), +) + +# ─── LEGS — GLUTES ─────────────────────────────────────────────────────────── + +HIP_THRUST = _ex( + "Hip Thrust", MuscleGroup.GLUTES, + pattern=MovementPattern.HIP_HINGE, tier=Tier.T2, + equipment=(Equipment.BARBELL,), + tags=("glute", "primary"), + notes="Mandatory when glute growth is a goal; zero hamstring CSA change", +) +SMITH_HIP_THRUST = _ex( + "Smith Machine Hip Thrust", MuscleGroup.GLUTES, + pattern=MovementPattern.HIP_HINGE, tier=Tier.T2, + equipment=(Equipment.SMITH_MACHINE,), + tags=("glute", "primary"), +) +GLUTE_KICKBACK = _ex( + "Glute Kickback", MuscleGroup.GLUTES, + equipment=(Equipment.CABLE,), + tags=("glute", "isolation"), +) +KICKBACK_MACHINE = _ex( + "Kickback Machine", MuscleGroup.GLUTES, + equipment=(Equipment.MACHINE,), + tags=("glute", "isolation"), +) + +# ─── CALVES ────────────────────────────────────────────────────────────────── + +CALF_EXTENSION = _ex( + "Calf Extension", MuscleGroup.CALVES, + equipment=(Equipment.MACHINE,), + tags=("calf", "gastrocnemius"), + notes="Use standing/knee-extended; post-failure lengthened partials every set", +) +DONKEY_CALF_RAISE = _ex( + "Donkey Calf Raise", MuscleGroup.CALVES, + equipment=(Equipment.MACHINE,), + tags=("calf", "gastrocnemius"), + notes="Excellent stretch; post-failure lengthened partials every set", +) + +# ─── ADDITIONAL ────────────────────────────────────────────────────────────── + +AB_CURL_MACHINE = _ex( + "Ab Curl Machine", MuscleGroup.ABS, + equipment=(Equipment.MACHINE,), + tags=("abs",), +) +ADDUCTOR_MACHINE = _ex( + "Adductor Machine", MuscleGroup.ADDUCTORS, + equipment=(Equipment.MACHINE,), + tags=("adductor",), +) +ABDUCTOR_MACHINE = _ex( + "Abductor Machine", MuscleGroup.ABDUCTORS, + equipment=(Equipment.MACHINE,), + tags=("abductor",), +) +FOREARM_WRIST_CURL = _ex( + "Forearm Wrist Curl", MuscleGroup.FOREARMS, + equipment=(Equipment.DUMBBELL,), + tags=("forearm",), +) + + +# ─── MASTER LIST ───────────────────────────────────────────────────────────── + +ALL_EXERCISES: list[ExerciseDefinition] = [ + # Chest + INCLINE_SMITH_BENCH, SMITH_CHEST_PRESS, INCLINE_BENCH, FLAT_BENCH, + INCLINE_DB_PRESS, FLAT_DB_PRESS, DB_BENCH, FLAT_BENCH_CABLES, + LOW_CHEST_CABLE_FLY, MID_CHEST_CABLE_FLY, UPPER_CHEST_CABLE_FLY, + CHEST_PRESS_MACHINE, INCLINE_MACHINE_PRESS, PEC_DECK, + UPPER_CHEST_PEC_DECK, + # Shoulders + SHOULDER_PRESS_MACHINE, SHOULDER_PRESS, LATERAL_RAISE_MACHINE, + LATERAL_RAISE, LATERAL_RAISE_CABLE, REAR_DELT_FLY, + REAR_DELT_FLY_SUPPORTED, CABLE_FACE_PULL, + # Triceps + DIP_MACHINE, ROPE_TRICEP_EXT, OH_ROPE_TRICEP_EXT, + STRAIGHT_BAR_TRICEP_EXT, OH_STRAIGHT_BAR_TRICEP_EXT, + VBAR_TRICEP_EXT, OH_VBAR_TRICEP_EXT, SINGLE_ARM_DHANDLE_TRICEP, + OH_SINGLE_ARM_DB_EXT, OH_EZ_BAR_TRICEP_EXT, EZ_BAR_SKULL_CRUSHER, + EZ_BAR_JM_PRESS, SMITH_JM_PRESS, + # Biceps + PREACHER_CURL, MACHINE_PREACHER_CURL, EZ_BAR_PREACHER_CURL, + SINGLE_ARM_DB_PREACHER, SPIDER_CURL, INCLINE_DB_CURL, + STANDING_ALT_DB_CURL, HAMMER_CURL, SEATED_HAMMER_CURL, + BAYESIAN_CURL, EZ_BAR_CABLE_CURL, + # Back — Vertical + LAT_PULLDOWN_MACHINE, LAT_PULLDOWN_WIDE, LAT_PULLDOWN_NEUTRAL, + LAT_PULLDOWN_CLOSE, SINGLE_ARM_LAT_PULLDOWN, PULLDOWN_ROW_MACHINE, + EZ_BAR_CABLE_PULLOVER, SINGLE_ARM_CABLE_PULLOVER, + FLAT_PULLOVER_MACHINE, + # Back — Horizontal + TBAR_ROW, SMITH_BARBELL_ROW, BARBELL_ROW, + SEATED_CHEST_SUPPORTED_ROW, CHEST_SUPPORTED_ELBOWS_TUCKED, + CHEST_SUPPORTED_ELBOWS_FLARED, SINGLE_ARM_CABLE_ROW, + WIDE_GRIP_CABLE_ROW, DB_ROW_TO_HIP, ROW_MACHINE, + PULLDOWN_ROW_MACHINE, + # Back — Accessory + DB_SHRUG, BACK_EXTENSION, + # Legs — Quads + BARBELL_SQUAT, SMITH_SQUAT, SQUAT_RACK, HACK_SQUAT, + HACK_SQUAT_MACHINE, BELT_SQUAT, PENDULUM_SQUAT, + BULGARIAN_SPLIT_SQUAT, SMITH_BULGARIAN, LEG_PRESS, + LEG_PRESS_ELEVATED, LEG_PRESS_LOWERED, LEG_EXTENSION, + # Legs — Hamstrings + RDL, SLDL, LYING_HAM_CURL, SEATED_HAM_CURL, ROUNDED_BACK_HYPER, + # Legs — Glutes + HIP_THRUST, SMITH_HIP_THRUST, GLUTE_KICKBACK, KICKBACK_MACHINE, + # Calves + CALF_EXTENSION, DONKEY_CALF_RAISE, + # Additional + AB_CURL_MACHINE, ADDUCTOR_MACHINE, ABDUCTOR_MACHINE, + FOREARM_WRIST_CURL, +] + + +def exercises_by_muscle(muscle: MuscleGroup) -> list[ExerciseDefinition]: + """Return all exercises where the given muscle is the primary target.""" + return [e for e in ALL_EXERCISES if e.primary == muscle] + + +def exercises_by_tag(tag: str) -> list[ExerciseDefinition]: + """Return all exercises that have the given tag.""" + return [e for e in ALL_EXERCISES if tag in e.tags] + + +def exercises_for_equipment( + available: set[Equipment], +) -> list[ExerciseDefinition]: + """Return exercises usable with the given equipment set.""" + return [ + e for e in ALL_EXERCISES + if any(eq in available for eq in e.equipment) + ] diff --git a/ironforge/data/muscle_groups.py b/ironforge/data/muscle_groups.py new file mode 100644 index 000000000..0ed59e941 --- /dev/null +++ b/ironforge/data/muscle_groups.py @@ -0,0 +1,118 @@ +"""Enumerations for muscle groups, movement patterns, equipment, and training levels.""" + +from enum import Enum, auto + + +class MuscleGroup(Enum): + CHEST_CLAVICULAR = auto() + CHEST_STERNAL = auto() + FRONT_DELT = auto() + SIDE_DELT = auto() + REAR_DELT = auto() + TRICEPS_LONG = auto() + TRICEPS_LATERAL = auto() + BICEPS = auto() + BRACHIALIS = auto() + LATS = auto() + MID_BACK = auto() + LOWER_TRAP = auto() + UPPER_TRAP = auto() + QUADS = auto() + HAMSTRINGS = auto() + GLUTES = auto() + CALVES = auto() + ABS = auto() + ADDUCTORS = auto() + ABDUCTORS = auto() + FOREARMS = auto() + SPINAL_ERECTORS = auto() + + +# Aggregate groups used in volume landmark tables +class VolumeMuscle(Enum): + QUADS = auto() + CHEST = auto() + BACK = auto() + BICEPS = auto() + SIDE_REAR_DELTS = auto() + HAMSTRINGS = auto() + TRICEPS = auto() + CALVES = auto() + GLUTES = auto() + ABS = auto() + FOREARMS = auto() + + +# Which detailed MuscleGroups roll up into each VolumeMuscle +VOLUME_MUSCLE_MAP: dict[VolumeMuscle, list[MuscleGroup]] = { + VolumeMuscle.QUADS: [MuscleGroup.QUADS], + VolumeMuscle.CHEST: [MuscleGroup.CHEST_CLAVICULAR, MuscleGroup.CHEST_STERNAL], + VolumeMuscle.BACK: [MuscleGroup.LATS, MuscleGroup.MID_BACK], + VolumeMuscle.BICEPS: [MuscleGroup.BICEPS], + VolumeMuscle.SIDE_REAR_DELTS: [MuscleGroup.SIDE_DELT, MuscleGroup.REAR_DELT], + VolumeMuscle.HAMSTRINGS: [MuscleGroup.HAMSTRINGS], + VolumeMuscle.TRICEPS: [MuscleGroup.TRICEPS_LONG, MuscleGroup.TRICEPS_LATERAL], + VolumeMuscle.CALVES: [MuscleGroup.CALVES], + VolumeMuscle.GLUTES: [MuscleGroup.GLUTES], + VolumeMuscle.ABS: [MuscleGroup.ABS], + VolumeMuscle.FOREARMS: [MuscleGroup.FOREARMS], +} + + +class MovementPattern(Enum): + SQUAT = auto() + HIP_HINGE = auto() + HORIZONTAL_PUSH = auto() + HORIZONTAL_PULL = auto() + VERTICAL_PUSH = auto() + VERTICAL_PULL = auto() + KNEE_FLEXION = auto() + KNEE_EXTENSION = auto() + ISOLATION = auto() + + +class TrainingLevel(Enum): + BEGINNER = auto() + INTERMEDIATE = auto() + ADVANCED = auto() + + +class Equipment(Enum): + BARBELL = auto() + DUMBBELL = auto() + CABLE = auto() + MACHINE = auto() + SMITH_MACHINE = auto() + BODYWEIGHT = auto() + EZ_BAR = auto() + + +class Tier(Enum): + T1 = 1 + T2 = 2 + T3 = 3 + + +class Goal(Enum): + HYPERTROPHY = auto() + STRENGTH = auto() + HYBRID = auto() + RECOMP = auto() + + +class Sex(Enum): + MALE = auto() + FEMALE = auto() + + +class CaloricPhase(Enum): + SURPLUS = auto() + DEFICIT = auto() + MAINTENANCE = auto() + + +class EquipmentAccess(Enum): + FULL_GYM = auto() + LIMITED_GYM = auto() + HOME_GYM = auto() + OTHER = auto() diff --git a/ironforge/engine/__init__.py b/ironforge/engine/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ironforge/engine/classifier.py b/ironforge/engine/classifier.py new file mode 100644 index 000000000..01a968a0c --- /dev/null +++ b/ironforge/engine/classifier.py @@ -0,0 +1,47 @@ +"""Training level classification per movement pattern.""" + +from ironforge.data.muscle_groups import MovementPattern, TrainingLevel +from ironforge.intake.profile import UserProfile + + +# Default patterns to classify +CORE_PATTERNS = [ + MovementPattern.SQUAT, + MovementPattern.HIP_HINGE, + MovementPattern.HORIZONTAL_PUSH, + MovementPattern.VERTICAL_PULL, + MovementPattern.HORIZONTAL_PULL, + MovementPattern.VERTICAL_PUSH, +] + + +def classify(profile: UserProfile) -> dict[MovementPattern, TrainingLevel]: + """Determine training level per movement pattern based on intake answers.""" + result: dict[MovementPattern, TrainingLevel] = {} + + for pattern in CORE_PATTERNS: + # If the user gave per-pattern answers, use those + if pattern in profile.pattern_experience: + result[pattern] = profile.pattern_experience[pattern] + continue + + # Otherwise, infer from global training history + if profile.training_months < 6: + result[pattern] = TrainingLevel.BEGINNER + elif profile.training_months < 36: + # Check if they can still linearly progress + if profile.can_add_weight_every_session: + result[pattern] = TrainingLevel.BEGINNER + elif profile.adds_weight_every_1_2_weeks: + result[pattern] = TrainingLevel.INTERMEDIATE + else: + result[pattern] = TrainingLevel.INTERMEDIATE + else: + if profile.can_add_weight_every_session: + result[pattern] = TrainingLevel.INTERMEDIATE + elif profile.adds_weight_every_1_2_weeks: + result[pattern] = TrainingLevel.INTERMEDIATE + else: + result[pattern] = TrainingLevel.ADVANCED + + return result diff --git a/ironforge/engine/frequency.py b/ironforge/engine/frequency.py new file mode 100644 index 000000000..b6eb8e7af --- /dev/null +++ b/ironforge/engine/frequency.py @@ -0,0 +1,486 @@ +"""Frequency planner — determines split structure and session templates. + +RULE: No muscle group may appear on two consecutive training days. +Full-body splits are the exception — they require rest days between sessions. +""" + +from dataclasses import dataclass, field +from ironforge.data.muscle_groups import VolumeMuscle +from ironforge.intake.profile import UserProfile +from ironforge.program.models import VolumeTarget + + +@dataclass +class SessionTemplate: + day_label: str + muscle_focus: list[VolumeMuscle] = field(default_factory=list) + + +@dataclass +class SplitPlan: + key: str + name: str + sessions: list[SessionTemplate] + rationale: str + requires_rest_days: bool = False # True for full-body (non-consecutive days) + + +# ─── Muscle group building blocks ───────────────────────────────────────────── +# These are defined so that NO two adjacent blocks in any split share a muscle. +# +# The key fix: SIDE_REAR_DELTS lives on Push (side delt focus) only. +# Pull gets rear delt volume indirectly from rows (chest-supported flared rows, +# wide-grip cable rows all count toward rear delt volume per the algorithm rules). + +UPPER = [VolumeMuscle.CHEST, VolumeMuscle.BACK, VolumeMuscle.SIDE_REAR_DELTS, + VolumeMuscle.BICEPS, VolumeMuscle.TRICEPS] +LOWER = [VolumeMuscle.QUADS, VolumeMuscle.HAMSTRINGS, VolumeMuscle.GLUTES, + VolumeMuscle.CALVES, VolumeMuscle.ABS] + +# Push: chest, side/rear delts, triceps +# Pull: back, biceps (NO delts — rear delts get indirect row volume) +PUSH = [VolumeMuscle.CHEST, VolumeMuscle.SIDE_REAR_DELTS, VolumeMuscle.TRICEPS] +PULL = [VolumeMuscle.BACK, VolumeMuscle.BICEPS] + +LEGS = [VolumeMuscle.QUADS, VolumeMuscle.HAMSTRINGS, VolumeMuscle.GLUTES, + VolumeMuscle.CALVES, VolumeMuscle.ABS] + +# Posterior / Anterior +POSTERIOR = [VolumeMuscle.BACK, VolumeMuscle.SIDE_REAR_DELTS, + VolumeMuscle.HAMSTRINGS, VolumeMuscle.GLUTES, VolumeMuscle.CALVES] +ANTERIOR = [VolumeMuscle.CHEST, VolumeMuscle.QUADS, + VolumeMuscle.TRICEPS, VolumeMuscle.BICEPS, VolumeMuscle.ABS] + +# Upper/Lower crossed with Posterior/Anterior +UPPER_POSTERIOR = [VolumeMuscle.BACK, VolumeMuscle.SIDE_REAR_DELTS, VolumeMuscle.BICEPS] +LOWER_ANTERIOR = [VolumeMuscle.QUADS, VolumeMuscle.ABS, VolumeMuscle.CALVES] +UPPER_ANTERIOR = [VolumeMuscle.CHEST, VolumeMuscle.TRICEPS] +LOWER_POSTERIOR = [VolumeMuscle.HAMSTRINGS, VolumeMuscle.GLUTES] + +# Torso / Limbs +TORSO = [VolumeMuscle.CHEST, VolumeMuscle.BACK, + VolumeMuscle.SIDE_REAR_DELTS, VolumeMuscle.ABS] +LIMBS = [VolumeMuscle.BICEPS, VolumeMuscle.TRICEPS, + VolumeMuscle.QUADS, VolumeMuscle.HAMSTRINGS, + VolumeMuscle.GLUTES, VolumeMuscle.CALVES] + + +def _has_consecutive_overlap(sessions: list[SessionTemplate]) -> bool: + """Check that no muscle appears on two adjacent days.""" + for i in range(len(sessions) - 1): + a = set(sessions[i].muscle_focus) + b = set(sessions[i + 1].muscle_focus) + if a & b: + return True + return False + + +# ═══════════════════════════════════════════════════════════════════════ +# 3-DAY SPLITS +# ═══════════════════════════════════════════════════════════════════════ + +def _full_body_3() -> SplitPlan: + ALL = UPPER + [VolumeMuscle.QUADS, VolumeMuscle.HAMSTRINGS, + VolumeMuscle.CALVES] + return SplitPlan( + key="full_body_3", name="Full Body", + requires_rest_days=True, + rationale="Full-body 3x/week (e.g. Mon/Wed/Fri with rest days between). " + "Distributes volume across sessions — requires at least 1 rest day between sessions.", + sessions=[ + SessionTemplate("Day 1 — Full Body A", ALL + [VolumeMuscle.GLUTES]), + SessionTemplate("Day 2 — Full Body B", ALL + [VolumeMuscle.ABS]), + SessionTemplate("Day 3 — Full Body C", ALL + [VolumeMuscle.GLUTES, VolumeMuscle.ABS]), + ], + ) + +def _push_pull_legs_3() -> SplitPlan: + # Push → Pull → Legs: no overlap (delts only on Push) + return SplitPlan( + key="ppl_3", name="Push / Pull / Legs", + rationale="PPL 1x/week. Each muscle hit once per week — minimum effective frequency. " + "No consecutive muscle overlap.", + sessions=[ + SessionTemplate("Day 1 — Push", PUSH), + SessionTemplate("Day 2 — Pull", PULL), + SessionTemplate("Day 3 — Legs", LEGS), + ], + ) + +def _upper_lower_upper_3() -> SplitPlan: + return SplitPlan( + key="ulu_3", name="Upper / Lower / Upper", + rationale="Upper-focused 3-day split. Upper body hit 2x/week, lower 1x. " + "No consecutive muscle overlap.", + sessions=[ + SessionTemplate("Day 1 — Upper A", UPPER), + SessionTemplate("Day 2 — Lower", LOWER), + SessionTemplate("Day 3 — Upper B", UPPER), + ], + ) + +def _lower_upper_lower_3() -> SplitPlan: + return SplitPlan( + key="lul_3", name="Lower / Upper / Lower", + rationale="Lower-focused 3-day split. Lower body hit 2x/week, upper 1x. " + "No consecutive muscle overlap.", + sessions=[ + SessionTemplate("Day 1 — Lower A", LOWER), + SessionTemplate("Day 2 — Upper", UPPER), + SessionTemplate("Day 3 — Lower B", LOWER), + ], + ) + + +# ═══════════════════════════════════════════════════════════════════════ +# 4-DAY SPLITS +# ═══════════════════════════════════════════════════════════════════════ + +def _upper_lower_4() -> SplitPlan: + # U→L→U→L: no overlap ✓ + return SplitPlan( + key="ul_4", name="Upper / Lower", + rationale="Upper/Lower 4x/week — each muscle 2x/week. No consecutive overlap. " + "Optimal for intermediates.", + sessions=[ + SessionTemplate("Day 1 — Upper A", UPPER), + SessionTemplate("Day 2 — Lower A", LOWER), + SessionTemplate("Day 3 — Upper B", UPPER), + SessionTemplate("Day 4 — Lower B", LOWER), + ], + ) + +def _push_pull_4() -> SplitPlan: + # Push→Legs→Pull→Legs: interleave with legs to avoid Push→Pull overlap + return SplitPlan( + key="pp_4", name="Push / Legs / Pull / Legs", + rationale="Push and Pull separated by Legs — eliminates the delt overlap problem. " + "Quads and hamstrings each hit 2x/week.", + sessions=[ + SessionTemplate("Day 1 — Push", PUSH), + SessionTemplate("Day 2 — Legs A", LEGS), + SessionTemplate("Day 3 — Pull", PULL), + SessionTemplate("Day 4 — Legs B", LEGS), + ], + ) + +def _full_body_4() -> SplitPlan: + ALL = UPPER + [VolumeMuscle.QUADS, VolumeMuscle.HAMSTRINGS, + VolumeMuscle.CALVES] + return SplitPlan( + key="fb_4", name="Full Body (x4)", + requires_rest_days=True, + rationale="Full body 4x/week (e.g. Mon/Tue/Thu/Fri). Maximizes frequency. " + "Requires rest days between sessions — never run consecutive days.", + sessions=[ + SessionTemplate("Day 1 — Full Body A", ALL + [VolumeMuscle.GLUTES]), + SessionTemplate("Day 2 — Full Body B", ALL + [VolumeMuscle.ABS]), + SessionTemplate("Day 3 — Full Body C", ALL + [VolumeMuscle.GLUTES]), + SessionTemplate("Day 4 — Full Body D", ALL + [VolumeMuscle.ABS]), + ], + ) + +def _upper_lower_push_pull_4() -> SplitPlan: + # Upper→Lower→Push→Pull: Lower→Push had CALVES overlap, Push→Pull had DELTS overlap + # Fix: remove CALVES from Push, delts already only on Push not Pull + return SplitPlan( + key="ulpp_4", name="Upper / Lower / Push / Pull", + rationale="Hybrid 4-day: Upper/Lower for compound focus, then Push/Pull for isolation. " + "No consecutive muscle overlap.", + sessions=[ + SessionTemplate("Day 1 — Upper", UPPER), + SessionTemplate("Day 2 — Lower", LOWER), + SessionTemplate("Day 3 — Push", PUSH), # no calves (was on Lower) + SessionTemplate("Day 4 — Pull", PULL + [VolumeMuscle.HAMSTRINGS, VolumeMuscle.ABS]), + ], + ) + +def _post_ant_4() -> SplitPlan: + # Post→Ant→Post→Ant: no overlap ✓ + return SplitPlan( + key="pa_4", name="Posterior / Anterior (x2)", + rationale="Posterior/Anterior groups muscles by chain. Posterior = back, delts, " + "hamstrings, glutes, calves. Anterior = chest, quads, triceps, biceps, abs. " + "No consecutive overlap.", + sessions=[ + SessionTemplate("Day 1 — Posterior A", POSTERIOR), + SessionTemplate("Day 2 — Anterior A", ANTERIOR), + SessionTemplate("Day 3 — Posterior B", POSTERIOR), + SessionTemplate("Day 4 — Anterior B", ANTERIOR), + ], + ) + +def _upper_post_lower_ant_4() -> SplitPlan: + # UP→LA→UA→LP: no overlap ✓ + return SplitPlan( + key="upla_4", name="Upper Post / Lower Ant / Upper Ant / Lower Post", + rationale="Crosses upper-lower and posterior-anterior axes. No two consecutive " + "sessions share any muscle group. Distributes fatigue uniquely.", + sessions=[ + SessionTemplate("Day 1 — Upper Posterior", UPPER_POSTERIOR), + SessionTemplate("Day 2 — Lower Anterior", LOWER_ANTERIOR), + SessionTemplate("Day 3 — Upper Anterior", UPPER_ANTERIOR), + SessionTemplate("Day 4 — Lower Posterior", LOWER_POSTERIOR), + ], + ) + +def _torso_limbs_4() -> SplitPlan: + # T→L→T→L: no overlap ✓ + return SplitPlan( + key="tl_4", name="Torso / Limbs (x2)", + rationale="Torso = chest, back, shoulders, abs. Limbs = arms, quads, hamstrings, " + "glutes, calves. No consecutive overlap.", + sessions=[ + SessionTemplate("Day 1 — Torso A", TORSO), + SessionTemplate("Day 2 — Limbs A", LIMBS), + SessionTemplate("Day 3 — Torso B", TORSO), + SessionTemplate("Day 4 — Limbs B", LIMBS), + ], + ) + + +# ═══════════════════════════════════════════════════════════════════════ +# 5-DAY SPLITS +# ═══════════════════════════════════════════════════════════════════════ + +def _ul_ppl_5() -> SplitPlan: + # Upper→Lower→Push→Pull→Legs + # Push→Pull had delts overlap. Fixed: delts only on Push. + return SplitPlan( + key="ulppl_5", name="Upper / Lower / Push / Pull / Legs", + rationale="UL + PPL hybrid. Priority muscles get 3x/week. " + "No consecutive muscle overlap.", + sessions=[ + SessionTemplate("Day 1 — Upper", UPPER), + SessionTemplate("Day 2 — Lower", LOWER), + SessionTemplate("Day 3 — Push", PUSH), + SessionTemplate("Day 4 — Pull", PULL), + SessionTemplate("Day 5 — Legs", LEGS), + ], + ) + +def _ppl_ul_5() -> SplitPlan: + # Push→Pull→Legs→Upper→Lower + # Push→Pull had delts overlap. Fixed: delts only on Push. + return SplitPlan( + key="pplul_5", name="Push / Pull / Legs / Upper / Lower", + rationale="PPL then UL — every muscle at least 2x/week, upper body 3x. " + "No consecutive overlap.", + sessions=[ + SessionTemplate("Day 1 — Push", PUSH), + SessionTemplate("Day 2 — Pull", PULL), + SessionTemplate("Day 3 — Legs", LEGS), + SessionTemplate("Day 4 — Upper", UPPER), + SessionTemplate("Day 5 — Lower", LOWER), + ], + ) + +def _upper_lower_push_lower_pull_5() -> SplitPlan: + # Was Upper/Lower/Legs/Upper/Lower which had Lower→Legs full overlap + # Redesigned: Upper→Lower→Push→Legs→Pull — all distinct adjacent pairs + return SplitPlan( + key="ulplp_5", name="Upper / Lower / Push / Legs / Pull", + rationale="5-day alternating split. Each session is distinct from its neighbors. " + "Legs 2x, upper body push/pull 2x each. No consecutive overlap.", + sessions=[ + SessionTemplate("Day 1 — Upper", UPPER), + SessionTemplate("Day 2 — Lower", LOWER), + SessionTemplate("Day 3 — Push", PUSH), + SessionTemplate("Day 4 — Legs", LEGS), + SessionTemplate("Day 5 — Pull", PULL), + ], + ) + +def _arnold_5() -> SplitPlan: + # CB→SA→L→CB→SA+L: all adjacent pairs are distinct ✓ + return SplitPlan( + key="arnold_5", name="Chest+Back / Shoulders+Arms / Legs (x2-ish)", + rationale="Arnold-style: chest+back supersetted, shoulders+arms, legs. " + "Each hit ~2x/week. No consecutive overlap.", + sessions=[ + SessionTemplate("Day 1 — Chest + Back", + [VolumeMuscle.CHEST, VolumeMuscle.BACK]), + SessionTemplate("Day 2 — Shoulders + Arms", + [VolumeMuscle.SIDE_REAR_DELTS, VolumeMuscle.BICEPS, + VolumeMuscle.TRICEPS]), + SessionTemplate("Day 3 — Legs A", LEGS), + SessionTemplate("Day 4 — Chest + Back", + [VolumeMuscle.CHEST, VolumeMuscle.BACK]), + SessionTemplate("Day 5 — Shoulders + Arms + Legs", + [VolumeMuscle.SIDE_REAR_DELTS, VolumeMuscle.BICEPS, + VolumeMuscle.TRICEPS, VolumeMuscle.QUADS, + VolumeMuscle.HAMSTRINGS, VolumeMuscle.CALVES]), + ], + ) + +def _bro_5() -> SplitPlan: + # Was Chest/Back/Shoulders/Arms/Legs — Shoulders→Arms had TRICEPS overlap + # Fix: reorder to Chest/Back/Arms/Shoulders/Legs — no overlap + return SplitPlan( + key="bro_5", name="Chest / Back / Arms / Shoulders / Legs", + rationale="Classic body-part split reordered: arms between back and shoulders " + "to eliminate consecutive triceps overlap. Each muscle 1x/week with " + "high per-session volume.", + sessions=[ + SessionTemplate("Day 1 — Chest", + [VolumeMuscle.CHEST]), + SessionTemplate("Day 2 — Back", + [VolumeMuscle.BACK]), + SessionTemplate("Day 3 — Arms", + [VolumeMuscle.BICEPS, VolumeMuscle.TRICEPS, + VolumeMuscle.FOREARMS]), + SessionTemplate("Day 4 — Shoulders", + [VolumeMuscle.SIDE_REAR_DELTS]), + SessionTemplate("Day 5 — Legs", LEGS), + ], + ) + + +# ═══════════════════════════════════════════════════════════════════════ +# 6-DAY SPLITS +# ═══════════════════════════════════════════════════════════════════════ + +def _ppl_6() -> SplitPlan: + # Push→Pull→Legs x2: Push→Pull had delts. Fixed: delts only on Push. + return SplitPlan( + key="ppl_6", name="PPL (x2)", + rationale="PPL 2x/week. Maximum frequency and volume distribution. " + "No consecutive overlap. Requires excellent recovery.", + sessions=[ + SessionTemplate("Day 1 — Push A", PUSH), + SessionTemplate("Day 2 — Pull A", PULL), + SessionTemplate("Day 3 — Legs A", LEGS), + SessionTemplate("Day 4 — Push B", PUSH), + SessionTemplate("Day 5 — Pull B", PULL), + SessionTemplate("Day 6 — Legs B", LEGS), + ], + ) + +def _arnold_6() -> SplitPlan: + # CB→SA→L x2: all adjacent distinct ✓ + return SplitPlan( + key="arnold_6", name="Arnold Split (x2)", + rationale="Arnold split 2x/week: chest+back, shoulders+arms, legs. " + "High frequency, no consecutive overlap. Advanced only.", + sessions=[ + SessionTemplate("Day 1 — Chest + Back A", + [VolumeMuscle.CHEST, VolumeMuscle.BACK]), + SessionTemplate("Day 2 — Shoulders + Arms A", + [VolumeMuscle.SIDE_REAR_DELTS, VolumeMuscle.BICEPS, + VolumeMuscle.TRICEPS]), + SessionTemplate("Day 3 — Legs A", LEGS), + SessionTemplate("Day 4 — Chest + Back B", + [VolumeMuscle.CHEST, VolumeMuscle.BACK]), + SessionTemplate("Day 5 — Shoulders + Arms B", + [VolumeMuscle.SIDE_REAR_DELTS, VolumeMuscle.BICEPS, + VolumeMuscle.TRICEPS]), + SessionTemplate("Day 6 — Legs B", LEGS), + ], + ) + +def _ul_3x_6() -> SplitPlan: + # U→L x3: no overlap ✓ + return SplitPlan( + key="ul3_6", name="Upper / Lower (x3)", + rationale="Upper/Lower 3x each — every muscle trained 3x/week. " + "No consecutive overlap.", + sessions=[ + SessionTemplate("Day 1 — Upper A", UPPER), + SessionTemplate("Day 2 — Lower A", LOWER), + SessionTemplate("Day 3 — Upper B", UPPER), + SessionTemplate("Day 4 — Lower B", LOWER), + SessionTemplate("Day 5 — Upper C", UPPER), + SessionTemplate("Day 6 — Lower C", LOWER), + ], + ) + +def _ppl_arnold_6() -> SplitPlan: + # Was PPL + specialization with multiple overlaps. + # Redesigned: PPL + Arnold (CB/SA/L) — all adjacent pairs distinct + return SplitPlan( + key="hybrid_6", name="Push / Pull / Legs / Chest+Back / Arms+Delts / Legs", + rationale="PPL first half, Arnold-style second half. Every muscle 2x/week, " + "no consecutive overlap.", + sessions=[ + SessionTemplate("Day 1 — Push", PUSH), + SessionTemplate("Day 2 — Pull", PULL), + SessionTemplate("Day 3 — Legs A", LEGS), + SessionTemplate("Day 4 — Chest + Back", + [VolumeMuscle.CHEST, VolumeMuscle.BACK]), + SessionTemplate("Day 5 — Arms + Delts", + [VolumeMuscle.BICEPS, VolumeMuscle.TRICEPS, + VolumeMuscle.SIDE_REAR_DELTS]), + SessionTemplate("Day 6 — Legs B", LEGS), + ], + ) + +def _post_ant_6() -> SplitPlan: + # Post→Ant x3: no overlap ✓ + return SplitPlan( + key="pa_6", name="Posterior / Anterior (x3)", + rationale="Posterior/Anterior 3x each — 3x/week per chain. " + "No consecutive overlap.", + sessions=[ + SessionTemplate("Day 1 — Posterior A", POSTERIOR), + SessionTemplate("Day 2 — Anterior A", ANTERIOR), + SessionTemplate("Day 3 — Posterior B", POSTERIOR), + SessionTemplate("Day 4 — Anterior B", ANTERIOR), + SessionTemplate("Day 5 — Posterior C", POSTERIOR), + SessionTemplate("Day 6 — Anterior C", ANTERIOR), + ], + ) + +def _torso_limbs_6() -> SplitPlan: + # T→L x3: no overlap ✓ + return SplitPlan( + key="tl_6", name="Torso / Limbs (x3)", + rationale="Torso/Limbs 3x each — maximum frequency. " + "No consecutive overlap.", + sessions=[ + SessionTemplate("Day 1 — Torso A", TORSO), + SessionTemplate("Day 2 — Limbs A", LIMBS), + SessionTemplate("Day 3 — Torso B", TORSO), + SessionTemplate("Day 4 — Limbs B", LIMBS), + SessionTemplate("Day 5 — Torso C", TORSO), + SessionTemplate("Day 6 — Limbs C", LIMBS), + ], + ) + + +# ═══════════════════════════════════════════════════════════════════════ +# REGISTRY +# ═══════════════════════════════════════════════════════════════════════ + +SPLITS_BY_DAYS: dict[int, list[SplitPlan]] = { + 3: [_full_body_3(), _push_pull_legs_3(), _upper_lower_upper_3(), _lower_upper_lower_3()], + 4: [_upper_lower_4(), _push_pull_4(), _full_body_4(), _upper_lower_push_pull_4(), + _post_ant_4(), _upper_post_lower_ant_4(), _torso_limbs_4()], + 5: [_ul_ppl_5(), _ppl_ul_5(), _upper_lower_push_lower_pull_5(), + _arnold_5(), _bro_5()], + 6: [_ppl_6(), _arnold_6(), _ul_3x_6(), _ppl_arnold_6(), + _post_ant_6(), _torso_limbs_6()], +} + +# Flat lookup +ALL_SPLITS: dict[str, SplitPlan] = {} +for splits in SPLITS_BY_DAYS.values(): + for s in splits: + ALL_SPLITS[s.key] = s + + +def get_split_options(days: int) -> list[SplitPlan]: + """Return all available split options for the given number of days.""" + days = max(3, min(6, days)) + return SPLITS_BY_DAYS[days] + + +def plan_frequency( + profile: UserProfile, + volume_targets: list[VolumeTarget], + split_key: str | None = None, +) -> SplitPlan: + """Select a split based on available training days and optional key.""" + days = max(3, min(6, profile.days_per_week)) + if split_key and split_key in ALL_SPLITS: + return ALL_SPLITS[split_key] + return SPLITS_BY_DAYS[days][0] diff --git a/ironforge/engine/load.py b/ironforge/engine/load.py new file mode 100644 index 000000000..91c8fe3b0 --- /dev/null +++ b/ironforge/engine/load.py @@ -0,0 +1,39 @@ +"""Load, RIR, and rest period assignment.""" + +from ironforge.data.muscle_groups import Tier, TrainingLevel, Sex +from ironforge.data.constants import ( + REP_RANGES, REP_RANGES_FEMALE, RIR_BY_WEEK, RIR_BEGINNER, REST_PERIODS, +) +from ironforge.program.models import LoadPrescription + + +def assign_loading( + tier: Tier, + level: TrainingLevel, + sex: Sex, + week: int = 1, + sets: int = 3, +) -> LoadPrescription: + """Create a LoadPrescription for a given exercise tier and context.""" + ranges = REP_RANGES_FEMALE if sex == Sex.FEMALE else REP_RANGES + rep_low, rep_high = ranges[tier] + + # RIR drops across the mesocycle: week 1=3, week 2=2, week 3=2, week 4=1 + if level == TrainingLevel.BEGINNER: + rir = max(RIR_BEGINNER, RIR_BY_WEEK.get(week, 2)) + else: + rir = RIR_BY_WEEK.get(week, 2) + + # T1 compounds get +1 RIR buffer in week 1 + if tier == Tier.T1 and week == 1: + rir = min(rir + 1, 4) + + rest_low, rest_high = REST_PERIODS[tier] + + return LoadPrescription( + sets=sets, + rep_low=rep_low, + rep_high=rep_high, + rir=rir, + rest_seconds=rest_high, + ) diff --git a/ironforge/engine/periodization.py b/ironforge/engine/periodization.py new file mode 100644 index 000000000..c430d8177 --- /dev/null +++ b/ironforge/engine/periodization.py @@ -0,0 +1,140 @@ +"""Mesocycle structure, progression models, and deload logic.""" + +from ironforge.data.muscle_groups import TrainingLevel, Goal, CaloricPhase +from ironforge.data.constants import DELOAD_FREQUENCY +from ironforge.intake.profile import UserProfile +from ironforge.program.models import MesocycleOverview + + +def build_mesocycle_overview( + profile: UserProfile, + levels: dict, +) -> list[MesocycleOverview]: + """Build the 4-week mesocycle overview. Deload is reactive, not scheduled.""" + return [ + MesocycleOverview( + week=1, + volume_description="Same sets as programmed", + rir="3-4 RIR", + load_note="Establish working weights", + ), + MesocycleOverview( + week=2, + volume_description="Same sets", + rir="2-3 RIR", + load_note="Attempt +2.5-5 lbs if top of rep range hit", + ), + MesocycleOverview( + week=3, + volume_description="Same sets", + rir="2 RIR", + load_note="Attempt +2.5-5 lbs if top of rep range hit", + ), + MesocycleOverview( + week=4, + volume_description="Same sets", + rir="1-2 RIR", + load_note="Push hard — last week before reassessment", + ), + ] + + +def build_progression_instructions( + profile: UserProfile, + levels: dict, +) -> str: + """Generate progression instructions based on training level.""" + overall = profile.overall_level + goal = profile.primary_goal + + sections: list[str] = [] + + if overall == TrainingLevel.BEGINNER: + sections.append( + "LINEAR PROGRESSION:\n" + "- Add weight every 1-2 sessions\n" + "- Compounds: +5-10 lbs/session; Isolations: +2.5-5 lbs\n" + "- When you cannot add weight for 3 consecutive sessions on a lift, " + "switch to double progression for that lift\n" + "- Track RIR passively — do not use for load prescription yet\n" + "- Volume: maintain 10-15 hard sets/muscle/week" + ) + elif overall == TrainingLevel.INTERMEDIATE: + sections.append( + "DOUBLE PROGRESSION:\n" + "- Each exercise has a rep range (e.g., 3x8-12)\n" + "- Start at the bottom of the range with a challenging weight\n" + "- Add reps each session until you hit the top of the range on ALL sets\n" + "- Then add weight (+5 lbs compounds, +2.5 lbs isolations) and drop " + "back to the bottom of the range\n" + "- Expected load increase: every ~3-5 sessions\n" + "- Volume: 12-20 hard sets/muscle/week" + ) + else: + sections.append( + "PERIODIZED AUTOREGULATION:\n" + "- Use the prescribed RIR targets to autoregulate load\n" + "- Within a mesocycle: increase load when you achieve the top of the " + "rep range at the target RIR on set 1\n" + "- Primary overload lever = adding sets week to week\n" + "- Secondary lever = adding load while maintaining rep range\n" + "- After 2-4 consecutive hypertrophy mesocycles: run a 2-4 week " + "maintenance phase (~6 sets/muscle/week), then restart at MEV\n" + "- Volume: 10-20 hard sets/muscle/week (quality > quantity)" + ) + + # Universal rules + sections.append( + "\nUNIVERSAL RULES:\n" + "- Across mesocycles: start next block 1-2 sets/muscle higher than " + "previous starting point\n" + "- Add 2.5-5 lbs to compounds, 2.5 lbs to isolations at start of each new block\n" + "- Keep 60-70% of program identical between mesocycles; rotate 30-40%\n" + "- Reset RIR to 3-4 in Week 1 of every new mesocycle\n" + "- A true plateau requires 4+ weeks of stalled progress to diagnose — " + "do not change programming after 1-2 bad sessions" + ) + + if goal in (Goal.STRENGTH, Goal.HYBRID): + sections.append( + "\nSTRENGTH NOTES:\n" + "- Prioritize load on the bar for main compounds\n" + "- Include 1 strength-emphasis mesocycle (5-8 rep focus) per macrocycle" + ) + + return "\n".join(sections) + + +def build_deload_instructions(profile: UserProfile) -> str: + """Generate deload instructions based on training level.""" + overall = profile.overall_level + freq = DELOAD_FREQUENCY[overall] + + lines = [ + "ACTIVE DELOAD PROTOCOL (never take full rest weeks):", + f"- Frequency: every {freq[0]}-{freq[1]} weeks, or when 2+ reactive triggers appear", + "- Cut volume 40-60% (drop sets, not load)", + "- Reduce load ~10% OR add 2-3 RIR to all exercises", + "- Maintain frequency — keep all training days", + "- Drop accessories, keep main compounds", + "", + "REACTIVE DELOAD TRIGGERS (deload when 2+ present):", + "- Performance drop >5% across 2+ consecutive sessions", + "- Persistent soreness still present when retraining the same muscle", + "- Rising session RPE at stable volume for 2+ weeks", + "- Joint pain escalating session to session", + "- Loss of motivation / dreading training", + "- Appetite suppression during a hard training block", + "", + "NUCKOLS RULE: Schedule a deload after your BEST sessions too — a huge", + "PR means you dug very deep, and failing to pull back often leads to", + "significant regression the following week.", + ] + + if profile.caloric_phase == CaloricPhase.DEFICIT: + lines.append( + "\nDEFICIT NOTE: Deload same frequency or more often. Recovery is " + "impaired — err on the side of deloading earlier." + ) + + return "\n".join(lines) diff --git a/ironforge/engine/selection.py b/ironforge/engine/selection.py new file mode 100644 index 000000000..1b99df03b --- /dev/null +++ b/ironforge/engine/selection.py @@ -0,0 +1,219 @@ +"""Exercise selection engine — picks exercises per session with strict set bounds. + +Rules enforced: +- Every exercise gets exactly 2 or 3 sets (never 1, never 4+) +- Weekly sets per muscle group: 4-12 (enforced via exercise count, not set count) +- Exercises are picked from the approved list filtered by equipment +""" + +import random +from ironforge.data.muscle_groups import ( + MuscleGroup, VolumeMuscle, Equipment, Tier, TrainingLevel, VOLUME_MUSCLE_MAP, +) +from ironforge.data.exercises import ( + ExerciseDefinition, ALL_EXERCISES, + # Chest + INCLINE_BENCH, FLAT_BENCH, INCLINE_DB_PRESS, FLAT_DB_PRESS, + INCLINE_SMITH_BENCH, SMITH_CHEST_PRESS, CHEST_PRESS_MACHINE, + INCLINE_MACHINE_PRESS, PEC_DECK, UPPER_CHEST_PEC_DECK, + MID_CHEST_CABLE_FLY, UPPER_CHEST_CABLE_FLY, LOW_CHEST_CABLE_FLY, + # Shoulders + SHOULDER_PRESS, SHOULDER_PRESS_MACHINE, + LATERAL_RAISE, LATERAL_RAISE_CABLE, LATERAL_RAISE_MACHINE, + REAR_DELT_FLY_SUPPORTED, REAR_DELT_FLY, + # Triceps + OH_ROPE_TRICEP_EXT, OH_STRAIGHT_BAR_TRICEP_EXT, OH_VBAR_TRICEP_EXT, + OH_SINGLE_ARM_DB_EXT, OH_EZ_BAR_TRICEP_EXT, + EZ_BAR_SKULL_CRUSHER, ROPE_TRICEP_EXT, VBAR_TRICEP_EXT, + STRAIGHT_BAR_TRICEP_EXT, + # Biceps + INCLINE_DB_CURL, BAYESIAN_CURL, PREACHER_CURL, MACHINE_PREACHER_CURL, + EZ_BAR_PREACHER_CURL, EZ_BAR_CABLE_CURL, STANDING_ALT_DB_CURL, + HAMMER_CURL, SEATED_HAMMER_CURL, + # Back vertical + LAT_PULLDOWN_MACHINE, LAT_PULLDOWN_WIDE, LAT_PULLDOWN_NEUTRAL, + LAT_PULLDOWN_CLOSE, EZ_BAR_CABLE_PULLOVER, SINGLE_ARM_CABLE_PULLOVER, + FLAT_PULLOVER_MACHINE, + # Back horizontal + SEATED_CHEST_SUPPORTED_ROW, CHEST_SUPPORTED_ELBOWS_TUCKED, + CHEST_SUPPORTED_ELBOWS_FLARED, DB_ROW_TO_HIP, ROW_MACHINE, + TBAR_ROW, BARBELL_ROW, SMITH_BARBELL_ROW, + SINGLE_ARM_CABLE_ROW, WIDE_GRIP_CABLE_ROW, + # Quads + BARBELL_SQUAT, SMITH_SQUAT, HACK_SQUAT_MACHINE, LEG_PRESS, + LEG_PRESS_LOWERED, LEG_EXTENSION, BELT_SQUAT, PENDULUM_SQUAT, + BULGARIAN_SPLIT_SQUAT, + # Hamstrings + SEATED_HAM_CURL, LYING_HAM_CURL, RDL, SLDL, + # Glutes + HIP_THRUST, SMITH_HIP_THRUST, GLUTE_KICKBACK, KICKBACK_MACHINE, + # Calves + CALF_EXTENSION, DONKEY_CALF_RAISE, + # Additional + AB_CURL_MACHINE, ADDUCTOR_MACHINE, +) +from ironforge.engine.frequency import SessionTemplate +from ironforge.engine.load import assign_loading +from ironforge.intake.profile import UserProfile +from ironforge.program.models import ProgrammedExercise, VolumeTarget + +# ─── Set bounds ────────────────────────────────────────────────────────────── +MIN_SETS_PER_EXERCISE = 2 +MAX_SETS_PER_EXERCISE = 3 + + +def _pick(options: list[ExerciseDefinition], equip: set[Equipment], + exclude: set[str] | None = None) -> ExerciseDefinition | None: + exclude = exclude or set() + for e in options: + if any(eq in equip for eq in e.equipment) and e.name not in exclude: + return e + return None + + +def _count_muscle_in_sessions( + muscle: VolumeMuscle, all_sessions: list[SessionTemplate], +) -> int: + return sum(1 for s in all_sessions if muscle in s.muscle_focus) + + +def select_exercises_for_session( + template: SessionTemplate, + all_sessions: list[SessionTemplate], + profile: UserProfile, + levels: dict, + volume_targets: list[VolumeTarget], + session_variant: int = 0, + week: int = 1, +) -> list[ProgrammedExercise]: + """Select exercises for a single session. Every exercise gets 2 or 3 sets.""" + equip = profile.available_equipment + exercises: list[ProgrammedExercise] = [] + used_names: set[str] = set() + overall = profile.overall_level + + def _add(ex: ExerciseDefinition | None, sets: int = 2, + notes: str = "") -> bool: + if ex is None or ex.name in used_names: + return False + sets = max(MIN_SETS_PER_EXERCISE, min(sets, MAX_SETS_PER_EXERCISE)) + load = assign_loading(ex.tier, overall, profile.sex, week=week, sets=sets) + note = notes if notes else (ex.notes or "") + exercises.append(ProgrammedExercise( + exercise=ex, load=load, tier=ex.tier, notes=note, + )) + used_names.add(ex.name) + return True + + is_a = session_variant % 2 == 0 + + # ─── CHEST ─────────────────────────────────────────────────────── + if VolumeMuscle.CHEST in template.muscle_focus: + if is_a: + press_opts = [INCLINE_BENCH, INCLINE_SMITH_BENCH, INCLINE_DB_PRESS, + INCLINE_MACHINE_PRESS] + fly_opts = [UPPER_CHEST_PEC_DECK, UPPER_CHEST_CABLE_FLY] + else: + press_opts = [FLAT_BENCH, SMITH_CHEST_PRESS, FLAT_DB_PRESS, + CHEST_PRESS_MACHINE] + fly_opts = [PEC_DECK, MID_CHEST_CABLE_FLY, LOW_CHEST_CABLE_FLY] + + _add(_pick(press_opts, equip, used_names), 3) + _add(_pick(fly_opts, equip, used_names), 2) + + # ─── BACK ──────────────────────────────────────────────────────── + if VolumeMuscle.BACK in template.muscle_focus: + if is_a: + vpull_opts = [LAT_PULLDOWN_WIDE, LAT_PULLDOWN_MACHINE, LAT_PULLDOWN_NEUTRAL] + row_opts = [SEATED_CHEST_SUPPORTED_ROW, CHEST_SUPPORTED_ELBOWS_TUCKED, ROW_MACHINE] + else: + vpull_opts = [LAT_PULLDOWN_NEUTRAL, LAT_PULLDOWN_CLOSE, LAT_PULLDOWN_MACHINE] + row_opts = [DB_ROW_TO_HIP, CHEST_SUPPORTED_ELBOWS_FLARED, TBAR_ROW, BARBELL_ROW] + + stretch_opts = [EZ_BAR_CABLE_PULLOVER, SINGLE_ARM_CABLE_PULLOVER, FLAT_PULLOVER_MACHINE] + + _add(_pick(vpull_opts, equip, used_names), 3, + "Depress scapulae first, then pull; full protraction at top") + _add(_pick(row_opts, equip, used_names), 3, + "Full scapular protraction at stretch") + _add(_pick(stretch_opts, equip, used_names), 2, + "Stretch-focused lat exercise") + + # ─── SIDE/REAR DELTS ───────────────────────────────────────────── + if VolumeMuscle.SIDE_REAR_DELTS in template.muscle_focus: + side_opts = [LATERAL_RAISE_CABLE, LATERAL_RAISE, LATERAL_RAISE_MACHINE] + rear_opts = [REAR_DELT_FLY_SUPPORTED, REAR_DELT_FLY] + + _add(_pick(side_opts, equip, used_names), 3, + "Neutral grip (thumbs forward); slight scaption 15-30°") + _add(_pick(rear_opts, equip, used_names), 2) + + # ─── TRICEPS ───────────────────────────────────────────────────── + if VolumeMuscle.TRICEPS in template.muscle_focus: + oh_opts = [OH_ROPE_TRICEP_EXT, OH_STRAIGHT_BAR_TRICEP_EXT, + OH_VBAR_TRICEP_EXT, OH_SINGLE_ARM_DB_EXT, OH_EZ_BAR_TRICEP_EXT] + pd_opts = [ROPE_TRICEP_EXT, VBAR_TRICEP_EXT, STRAIGHT_BAR_TRICEP_EXT] + + _add(_pick(oh_opts, equip, used_names), 3, + "Overhead = ~40% more triceps growth than pushdowns") + _add(_pick(pd_opts, equip, used_names), 2) + + # ─── BICEPS ────────────────────────────────────────────────────── + if VolumeMuscle.BICEPS in template.muscle_focus: + if is_a: + stretch_opts = [INCLINE_DB_CURL, BAYESIAN_CURL] + general_opts = [EZ_BAR_PREACHER_CURL, MACHINE_PREACHER_CURL, PREACHER_CURL] + else: + stretch_opts = [BAYESIAN_CURL, INCLINE_DB_CURL] + general_opts = [PREACHER_CURL, STANDING_ALT_DB_CURL, EZ_BAR_CABLE_CURL] + + _add(_pick(stretch_opts, equip, used_names), 2, "Stretch-position curl") + _add(_pick(general_opts, equip, used_names), 2) + + # Brachialis — separate, not counted as biceps + brach_opts = [HAMMER_CURL, SEATED_HAMMER_CURL] + _add(_pick(brach_opts, equip, used_names), 3, + "Brachialis — do NOT count as biceps volume") + + # ─── QUADS ─────────────────────────────────────────────────────── + if VolumeMuscle.QUADS in template.muscle_focus: + if is_a: + squat_opts = [BARBELL_SQUAT, SMITH_SQUAT, HACK_SQUAT_MACHINE, BELT_SQUAT] + else: + squat_opts = [HACK_SQUAT_MACHINE, LEG_PRESS, LEG_PRESS_LOWERED, SMITH_SQUAT] + + _add(_pick(squat_opts, equip, used_names), 3, + "Full depth; track knee over second toe") + _add(_pick([LEG_EXTENSION], equip, used_names), 2, + "Reclined backrest (40° hip flexion) for RF growth") + + # ─── HAMSTRINGS ────────────────────────────────────────────────── + if VolumeMuscle.HAMSTRINGS in template.muscle_focus: + _add(_pick([SEATED_HAM_CURL, LYING_HAM_CURL], equip, used_names), 3, + "Primary hamstring exercise; 70-90% of curl volume") + _add(_pick([RDL, SLDL], equip, used_names), 2, + "Mandatory hip extension component") + + # ─── GLUTES ────────────────────────────────────────────────────── + if VolumeMuscle.GLUTES in template.muscle_focus: + if profile.wants_glute_focus: + _add(_pick([HIP_THRUST, SMITH_HIP_THRUST], equip, used_names), 3, + "Primary glute builder") + _add(_pick([BULGARIAN_SPLIT_SQUAT, GLUTE_KICKBACK, KICKBACK_MACHINE], + equip, used_names), 2) + else: + _add(_pick([HIP_THRUST, SMITH_HIP_THRUST, GLUTE_KICKBACK], + equip, used_names), 2) + + # ─── CALVES ────────────────────────────────────────────────────── + if VolumeMuscle.CALVES in template.muscle_focus: + _add(_pick([CALF_EXTENSION, DONKEY_CALF_RAISE], equip, used_names), 3, + "Post-failure lengthened partials every set") + + # ─── ABS ───────────────────────────────────────────────────────── + if VolumeMuscle.ABS in template.muscle_focus: + _add(_pick([AB_CURL_MACHINE], equip, used_names), 3) + + # Sort: T1 first, then T2, then T3 + exercises.sort(key=lambda e: e.tier.value) + return exercises diff --git a/ironforge/engine/substitutions.py b/ironforge/engine/substitutions.py new file mode 100644 index 000000000..e65f218c7 --- /dev/null +++ b/ironforge/engine/substitutions.py @@ -0,0 +1,61 @@ +"""Exercise substitution groups — exercises that target the same region of the same muscle. + +Used by the regenerate feature to swap exercises without changing the program's +muscle targeting. If a group has only 1 exercise, it's not substitutable. +""" + +from ironforge.data.exercises import ExerciseDefinition, ALL_EXERCISES +from collections import defaultdict + + +def _region_tags(ex: ExerciseDefinition) -> tuple[str, ...]: + """Extract region-identifying tags from an exercise.""" + REGION_TAGS = { + 'upper_chest', 'flat_chest', 'press', 'fly', + 'side_delt', 'rear_delt', 'overhead', 'long_head', 'pushdown', + 'proximal', 'distal', 'stretch', 'general', 'brachialis', + 'vertical_pull', 'lat', 'row', 'mid_back', 'lat_emphasis', + 'squat', 'quad', 'quad_isolator', 'rf', + 'hamstring', 'curl', 'hinge', 'bflh', + 'glute', 'calf', 'gastrocnemius', + } + return tuple(sorted(t for t in ex.tags if t in REGION_TAGS)) + + +def _build_groups() -> dict[str, list[ExerciseDefinition]]: + """Build substitution groups keyed by (primary_muscle, region_tags).""" + groups: dict[tuple, list[ExerciseDefinition]] = defaultdict(list) + seen_names: set[str] = set() + for ex in ALL_EXERCISES: + if ex.name in seen_names: + continue + seen_names.add(ex.name) + key = (ex.primary.name, _region_tags(ex)) + groups[key].append(ex) + # Convert to string keys for JSON-friendliness + return { + f"{muscle}|{'_'.join(tags) if tags else 'base'}": exercises + for (muscle, tags), exercises in groups.items() + } + + +SUBSTITUTION_GROUPS = _build_groups() + +# Reverse lookup: exercise name → group key +EXERCISE_TO_GROUP: dict[str, str] = {} +for group_key, exercises in SUBSTITUTION_GROUPS.items(): + for ex in exercises: + EXERCISE_TO_GROUP[ex.name] = group_key + + +def get_substitutes(exercise_name: str) -> list[ExerciseDefinition]: + """Return all exercises that can substitute for the given one (same region).""" + group_key = EXERCISE_TO_GROUP.get(exercise_name) + if not group_key: + return [] + return [ex for ex in SUBSTITUTION_GROUPS[group_key] if ex.name != exercise_name] + + +def is_substitutable(exercise_name: str) -> bool: + """True if the exercise has at least one valid substitute.""" + return len(get_substitutes(exercise_name)) > 0 diff --git a/ironforge/engine/supersets.py b/ironforge/engine/supersets.py new file mode 100644 index 000000000..ff2d0c9a9 --- /dev/null +++ b/ironforge/engine/supersets.py @@ -0,0 +1,71 @@ +"""Antagonist superset pairing logic.""" + +from ironforge.data.muscle_groups import VolumeMuscle, Tier +from ironforge.program.models import ProgrammedExercise + + +# Antagonist pairs for superset matching +ANTAGONIST_PAIRS: list[tuple[VolumeMuscle, VolumeMuscle]] = [ + (VolumeMuscle.CHEST, VolumeMuscle.BACK), + (VolumeMuscle.BICEPS, VolumeMuscle.TRICEPS), + (VolumeMuscle.QUADS, VolumeMuscle.HAMSTRINGS), + (VolumeMuscle.SIDE_REAR_DELTS, VolumeMuscle.BACK), +] + +# Reverse mapping for quick lookup +_PAIR_MAP: dict[VolumeMuscle, VolumeMuscle] = {} +for a, b in ANTAGONIST_PAIRS: + _PAIR_MAP[a] = b + _PAIR_MAP[b] = a + + +def _muscle_to_volume_muscle(ex: ProgrammedExercise) -> VolumeMuscle | None: + """Map an exercise's primary muscle to a VolumeMuscle for pairing.""" + from ironforge.data.muscle_groups import MuscleGroup, VOLUME_MUSCLE_MAP + primary = ex.exercise.primary + for vm, muscles in VOLUME_MUSCLE_MAP.items(): + if primary in muscles: + return vm + return None + + +def pair_supersets( + exercises: list[ProgrammedExercise], + allow_supersets: bool = True, +) -> list[ProgrammedExercise]: + """Assign superset pair IDs to antagonist exercises. + + T1 compounds are never supersetted — they are always done as straight sets. + Returns the same list with superset_pair_id set on paired exercises. + """ + if not allow_supersets: + return exercises + + # Separate T1 (always straight) from T2/T3 (can be supersetted) + straight = [e for e in exercises if e.tier == Tier.T1] + candidates = [e for e in exercises if e.tier != Tier.T1] + + pair_id = 1 + paired: set[int] = set() + + for i, ex_a in enumerate(candidates): + if i in paired: + continue + vm_a = _muscle_to_volume_muscle(ex_a) + if vm_a is None or vm_a not in _PAIR_MAP: + continue + + target_vm = _PAIR_MAP[vm_a] + for j, ex_b in enumerate(candidates): + if j <= i or j in paired: + continue + vm_b = _muscle_to_volume_muscle(ex_b) + if vm_b == target_vm: + ex_a.superset_pair_id = pair_id + ex_b.superset_pair_id = pair_id + paired.add(i) + paired.add(j) + pair_id += 1 + break + + return straight + candidates diff --git a/ironforge/engine/volume.py b/ironforge/engine/volume.py new file mode 100644 index 000000000..80be665a2 --- /dev/null +++ b/ironforge/engine/volume.py @@ -0,0 +1,53 @@ +"""Volume calculator — fractional sets, MEV/MAV/MRV landmarks.""" + +from ironforge.data.muscle_groups import ( + MovementPattern, TrainingLevel, VolumeMuscle, CaloricPhase, Goal, +) +from ironforge.data.constants import VOLUME_LANDMARKS +from ironforge.intake.profile import UserProfile +from ironforge.program.models import VolumeTarget + + +def compute_volume( + profile: UserProfile, + levels: dict[MovementPattern, TrainingLevel], +) -> list[VolumeTarget]: + """Compute weekly fractional set targets for each muscle group, starting at MEV.""" + overall = profile.overall_level + targets: list[VolumeTarget] = [] + + for muscle, landmarks in VOLUME_LANDMARKS.items(): + mev = (landmarks.mev_low + landmarks.mev_high) / 2 + mrv = landmarks.mrv + + # Start at MEV for week 1 (mesocycle start) + working = mev + + # Priority muscles: bump toward low-MAV + if muscle in profile.priority_muscles: + working = landmarks.mav_low + + # Don't exceed 30% above habitual baseline + if profile.current_sets_per_muscle > 0: + max_allowed = profile.current_sets_per_muscle * 1.3 + working = min(working, max_allowed) + + # In deficit: keep at MEV minimum, don't over-reach + if profile.caloric_phase == CaloricPhase.DEFICIT: + working = max(mev, min(working, landmarks.mav_low)) + + # Advanced lifters: quality over quantity + if overall == TrainingLevel.ADVANCED: + working = min(working, landmarks.mav_high) + + # Ensure at least MEV + working = max(working, mev) + + targets.append(VolumeTarget( + muscle=muscle, + mev=mev, + working=round(working, 1), + mrv=mrv, + )) + + return targets diff --git a/ironforge/intake/__init__.py b/ironforge/intake/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ironforge/intake/flow.py b/ironforge/intake/flow.py new file mode 100644 index 000000000..781ec75f4 --- /dev/null +++ b/ironforge/intake/flow.py @@ -0,0 +1,160 @@ +"""Conversational intake engine — walks through questions, builds UserProfile.""" + +from ironforge.data.muscle_groups import ( + Goal, Sex, CaloricPhase, EquipmentAccess, Equipment, + MovementPattern, TrainingLevel, VolumeMuscle, +) +from ironforge.intake.profile import UserProfile +from ironforge.intake.questions import ALL_BLOCKS + + +def _ask(prompt: str, options: list[str] | None = None) -> str: + """Ask a question and validate input.""" + print() + print(prompt) + while True: + answer = input("\n> ").strip().lower() + if not answer: + print("Please enter a response.") + continue + if options and answer not in options: + print(f"Please enter one of: {', '.join(options)}") + continue + return answer + + +def _parse_priority_muscles(text: str) -> list[VolumeMuscle]: + """Parse free-text priority muscles into VolumeMuscle list.""" + text = text.lower() + if text in ("none", "no", "n/a", ""): + return [] + + mapping = { + "chest": VolumeMuscle.CHEST, + "back": VolumeMuscle.BACK, + "bicep": VolumeMuscle.BICEPS, + "arm": VolumeMuscle.BICEPS, + "tricep": VolumeMuscle.TRICEPS, + "shoulder": VolumeMuscle.SIDE_REAR_DELTS, + "delt": VolumeMuscle.SIDE_REAR_DELTS, + "quad": VolumeMuscle.QUADS, + "leg": VolumeMuscle.QUADS, + "ham": VolumeMuscle.HAMSTRINGS, + "glute": VolumeMuscle.GLUTES, + "calf": VolumeMuscle.CALVES, + "calves": VolumeMuscle.CALVES, + "ab": VolumeMuscle.ABS, + "core": VolumeMuscle.ABS, + } + + found: list[VolumeMuscle] = [] + for keyword, muscle in mapping.items(): + if keyword in text and muscle not in found: + found.append(muscle) + + # If "arms" mentioned, add both biceps and triceps + if "arm" in text: + if VolumeMuscle.TRICEPS not in found: + found.append(VolumeMuscle.TRICEPS) + + return found + + +def run_intake() -> UserProfile: + """Run the full intake questionnaire and return a UserProfile.""" + profile = UserProfile() + + print("=" * 60) + print(" IRONFORGE — Evidence-Based Workout Program Designer") + print("=" * 60) + print("\nI'll ask you a series of questions to build your program.") + print("Answer each one before we proceed.\n") + + for block_name, questions in ALL_BLOCKS: + print(f"\n{'─' * 40}") + print(f" {block_name}") + print(f"{'─' * 40}") + + for q in questions: + answer = _ask(q.prompt, q.options) + + # ── Parse answers into profile ── + if q.key == "primary_goal": + profile.primary_goal = { + "a": Goal.HYPERTROPHY, "b": Goal.STRENGTH, + "c": Goal.HYBRID, "d": Goal.RECOMP, + }[answer] + + elif q.key == "priority_muscles": + profile.priority_muscles = _parse_priority_muscles(answer) + if VolumeMuscle.GLUTES in profile.priority_muscles: + profile.wants_glute_focus = True + + elif q.key == "caloric_phase": + profile.caloric_phase = { + "a": CaloricPhase.SURPLUS, "b": CaloricPhase.DEFICIT, + "c": CaloricPhase.MAINTENANCE, + }[answer] + + elif q.key == "training_months": + profile.training_months = {"a": 3, "b": 18, "c": 48}[answer] + + elif q.key == "progression_rate": + if answer == "a": + profile.can_add_weight_every_session = True + elif answer == "b": + profile.adds_weight_every_1_2_weeks = True + + elif q.key == "days_per_week": + profile.days_per_week = {"a": 3, "b": 4, "c": 5, "d": 6}[answer] + + elif q.key == "session_minutes": + profile.session_minutes = { + "a": 40, "b": 52, "c": 67, "d": 82, "e": 95, + }[answer] + + elif q.key == "prefers_supersets": + profile.prefers_supersets = answer in ("a", "c") + + elif q.key == "equipment_access": + profile.equipment_access = { + "a": EquipmentAccess.FULL_GYM, + "b": EquipmentAccess.LIMITED_GYM, + "c": EquipmentAccess.HOME_GYM, + "d": EquipmentAccess.OTHER, + }[answer] + # Set available equipment based on access + if profile.equipment_access == EquipmentAccess.HOME_GYM: + profile.available_equipment = { + Equipment.BARBELL, Equipment.DUMBBELL, + Equipment.EZ_BAR, Equipment.BODYWEIGHT, + } + elif profile.equipment_access == EquipmentAccess.LIMITED_GYM: + profile.available_equipment = { + Equipment.BARBELL, Equipment.DUMBBELL, + Equipment.CABLE, Equipment.EZ_BAR, + Equipment.BODYWEIGHT, Equipment.MACHINE, + } + # Full gym keeps the default (everything) + + elif q.key == "sex": + profile.sex = {"a": Sex.MALE, "b": Sex.FEMALE}[answer] + + elif q.key == "injuries": + if answer.lower() not in ("none", "no", "n/a"): + profile.injuries = [answer] + + elif q.key == "current_sets": + try: + profile.current_sets_per_muscle = int(answer) + except ValueError: + profile.current_sets_per_muscle = 0 + + elif q.key == "current_program": + profile.current_program = answer + + print(f"\n{'─' * 40}") + print(" Intake complete! Generating your program...") + print(f"{'─' * 40}\n") + + return profile diff --git a/ironforge/intake/profile.py b/ironforge/intake/profile.py new file mode 100644 index 000000000..7e438ad27 --- /dev/null +++ b/ironforge/intake/profile.py @@ -0,0 +1,55 @@ +"""User profile dataclass — the contract between intake and engine.""" + +from dataclasses import dataclass, field +from ironforge.data.muscle_groups import ( + Goal, Sex, CaloricPhase, EquipmentAccess, Equipment, + MovementPattern, TrainingLevel, VolumeMuscle, +) + + +@dataclass +class UserProfile: + # Goals + primary_goal: Goal = Goal.HYPERTROPHY + priority_muscles: list[VolumeMuscle] = field(default_factory=list) + caloric_phase: CaloricPhase = CaloricPhase.MAINTENANCE + + # Training history + training_months: int = 0 + pattern_experience: dict[MovementPattern, TrainingLevel] = field(default_factory=dict) + can_add_weight_every_session: bool = False + adds_weight_every_1_2_weeks: bool = False + + # Schedule + days_per_week: int = 4 + session_minutes: int = 60 + prefers_supersets: bool = True + + # Equipment + equipment_access: EquipmentAccess = EquipmentAccess.FULL_GYM + available_equipment: set[Equipment] = field(default_factory=lambda: { + Equipment.BARBELL, Equipment.DUMBBELL, Equipment.CABLE, + Equipment.MACHINE, Equipment.SMITH_MACHINE, Equipment.EZ_BAR, + Equipment.BODYWEIGHT, + }) + + # Individual + sex: Sex = Sex.MALE + injuries: list[str] = field(default_factory=list) + current_sets_per_muscle: int = 0 + current_program: str = "starting fresh" + wants_glute_focus: bool = False + split_key: str = "" # optional: specific split selection + + @property + def overall_level(self) -> TrainingLevel: + """Most common training level across patterns, for general decisions.""" + if not self.pattern_experience: + if self.training_months < 6: + return TrainingLevel.BEGINNER + elif self.training_months < 36: + return TrainingLevel.INTERMEDIATE + return TrainingLevel.ADVANCED + levels = list(self.pattern_experience.values()) + from collections import Counter + return Counter(levels).most_common(1)[0][0] diff --git a/ironforge/intake/questions.py b/ironforge/intake/questions.py new file mode 100644 index 000000000..7abea85c6 --- /dev/null +++ b/ironforge/intake/questions.py @@ -0,0 +1,162 @@ +"""Intake question definitions.""" + +from dataclasses import dataclass + + +@dataclass +class Question: + key: str + prompt: str + options: list[str] | None = None + allow_free_text: bool = False + + +BLOCK_A = [ + Question( + key="primary_goal", + prompt=( + "What is your primary training goal?\n" + " (a) Maximum muscle growth (hypertrophy)\n" + " (b) Maximum strength (1RM progression)\n" + " (c) Both muscle and strength (hybrid)\n" + " (d) Maintain/build muscle while losing fat (body recomposition)" + ), + options=["a", "b", "c", "d"], + ), + Question( + key="priority_muscles", + prompt=( + "Do you have any secondary goals or priority muscle groups to bring up?\n" + "(e.g., 'chest and arms', 'glutes', 'back', or 'none')" + ), + allow_free_text=True, + ), + Question( + key="caloric_phase", + prompt=( + "Are you currently in a caloric surplus, deficit, or maintenance?\n" + " (a) Surplus\n" + " (b) Deficit\n" + " (c) Maintenance" + ), + options=["a", "b", "c"], + ), +] + +BLOCK_B = [ + Question( + key="training_months", + prompt=( + "How long have you been training consistently with progressive overload?\n" + " (a) Less than 6 months\n" + " (b) 6 months - 3 years\n" + " (c) 3+ years" + ), + options=["a", "b", "c"], + ), + Question( + key="progression_rate", + prompt=( + "Can you currently add weight to your main compound lifts every single session?\n" + " (a) Yes — I add weight every session with good form\n" + " (b) No, but I add weight every 1-2 weeks\n" + " (c) No, weight goes up over weeks to months" + ), + options=["a", "b", "c"], + ), +] + +BLOCK_C = [ + Question( + key="days_per_week", + prompt=( + "How many days per week can you train?\n" + " (a) 3\n" + " (b) 4\n" + " (c) 5\n" + " (d) 6" + ), + options=["a", "b", "c", "d"], + ), + Question( + key="session_minutes", + prompt=( + "How long can each session be?\n" + " (a) Under 45 min\n" + " (b) 45-60 min\n" + " (c) 60-75 min\n" + " (d) 75-90 min\n" + " (e) 90+ min" + ), + options=["a", "b", "c", "d", "e"], + ), + Question( + key="prefers_supersets", + prompt=( + "Do you prefer to superset antagonist movements to save time?\n" + " (a) Yes\n" + " (b) No\n" + " (c) No preference" + ), + options=["a", "b", "c"], + ), +] + +BLOCK_D = [ + Question( + key="equipment_access", + prompt=( + "What equipment do you have access to?\n" + " (a) Full commercial gym (barbells, cables, machines, dumbbells)\n" + " (b) Gym with limited machines/cables\n" + " (c) Home gym with dumbbells and barbell only\n" + " (d) Other — describe" + ), + options=["a", "b", "c", "d"], + ), +] + +BLOCK_E = [ + Question( + key="sex", + prompt=( + "Are you male or female?\n" + "(Affects rep range recommendations — females bias slightly higher rep ranges)\n" + " (a) Male\n" + " (b) Female" + ), + options=["a", "b"], + ), + Question( + key="injuries", + prompt=( + "Do you have any injuries, pain, or movement limitations?\n" + "(Describe, or say 'none')" + ), + allow_free_text=True, + ), + Question( + key="current_sets", + prompt=( + "Approximately how many hard sets per muscle group per week are you\n" + "currently doing? (Rough number, e.g., '10', '15', or '0' if new)" + ), + allow_free_text=True, + ), + Question( + key="current_program", + prompt=( + 'What does your current program look like?\n' + '(Brief description, or "starting fresh")' + ), + allow_free_text=True, + ), +] + +ALL_BLOCKS = [ + ("Block A — Goals", BLOCK_A), + ("Block B — Training History", BLOCK_B), + ("Block C — Schedule & Logistics", BLOCK_C), + ("Block D — Equipment", BLOCK_D), + ("Block E — Individual Factors", BLOCK_E), +] diff --git a/ironforge/output/__init__.py b/ironforge/output/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ironforge/output/formatter.py b/ironforge/output/formatter.py new file mode 100644 index 000000000..11465d7a7 --- /dev/null +++ b/ironforge/output/formatter.py @@ -0,0 +1,85 @@ +"""Renders a Program to terminal output.""" + +from ironforge.program.models import Program, ProgrammedExercise + + +def _header(title: str) -> str: + return f"\n{'=' * 60}\n {title}\n{'=' * 60}\n" + + +def _subheader(title: str) -> str: + return f"\n{'─' * 40}\n {title}\n{'─' * 40}" + + +def _format_exercise(ex: ProgrammedExercise, idx: int) -> str: + """Format a single exercise line.""" + load = ex.load + tier_label = f"[T{ex.tier.value}]" + superset = "" + if ex.superset_pair_id is not None: + superset = f" [Superset {ex.superset_pair_id}]" + + line = ( + f" {idx}. {tier_label} {ex.exercise.name}\n" + f" {load.sets} x {load.rep_low}-{load.rep_high} reps | " + f"RIR {load.rir} | Rest {load.rest_seconds}s{superset}" + ) + if ex.notes: + line += f"\n Notes: {ex.notes}" + return line + + +def render(program: Program) -> str: + """Render the complete program as formatted text.""" + lines: list[str] = [] + + # ── Section 1: Training Level Assessment ── + lines.append(_header("1. TRAINING LEVEL ASSESSMENT")) + for pattern, level in program.level_assessment.items(): + lines.append(f" {pattern.name:<20s} → {level.name}") + + # ── Section 2: Volume Targets ── + lines.append(_header("2. WEEKLY VOLUME TARGETS (Fractional Sets)")) + lines.append(f" {'Muscle Group':<20s} {'MEV':>5s} {'Week 1':>7s} {'MRV':>5s}") + lines.append(f" {'─' * 42}") + for vt in program.volume_targets: + lines.append( + f" {vt.muscle.name:<20s} {vt.mev:>5.1f} {vt.working:>7.1f} {vt.mrv:>5.0f}" + ) + + # ── Section 3: Split Structure ── + lines.append(_header("3. SPLIT STRUCTURE")) + lines.append(f" Split: {program.split_name}") + lines.append(f" Rationale: {program.split_rationale}") + + # ── Section 4: Weekly Program (All Weeks) ── + for week in program.weeks: + label = "DELOAD" if week.week_number == 5 else f"Week {week.week_number}" + lines.append(_header(f"4. WEEKLY PROGRAM — {label}")) + for session in week.sessions: + lines.append(_subheader(session.day_label)) + for idx, ex in enumerate(session.exercises, 1): + lines.append(_format_exercise(ex, idx)) + lines.append("") + + # ── Section 5: Progression Instructions ── + lines.append(_header("5. PROGRESSION INSTRUCTIONS")) + lines.append(program.progression_instructions) + + # ── Section 6: Deload Instructions ── + lines.append(_header("6. DELOAD INSTRUCTIONS")) + lines.append(program.deload_instructions) + + # ── Section 7: Mesocycle Progression ── + lines.append(_header("7. MESOCYCLE PROGRESSION (Weeks 1-5)")) + lines.append( + f" {'Week':<6s} {'Volume':<35s} {'RIR':<10s} {'Load'}" + ) + lines.append(f" {'─' * 70}") + for mo in program.mesocycle_overview: + lines.append( + f" {mo.week:<6d} {mo.volume_description:<35s} " + f"{mo.rir:<10s} {mo.load_note}" + ) + + return "\n".join(lines) diff --git a/ironforge/program/__init__.py b/ironforge/program/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ironforge/program/builder.py b/ironforge/program/builder.py new file mode 100644 index 000000000..9ba5e68aa --- /dev/null +++ b/ironforge/program/builder.py @@ -0,0 +1,102 @@ +"""Program builder — orchestrates all engine modules into a complete Program.""" + +from ironforge.data.constants import MESO_LENGTH +from ironforge.intake.profile import UserProfile +from ironforge.engine.classifier import classify +from ironforge.engine.volume import compute_volume +from ironforge.engine.frequency import plan_frequency +from ironforge.engine.selection import select_exercises_for_session +from ironforge.engine.supersets import pair_supersets +from ironforge.engine.periodization import ( + build_mesocycle_overview, build_progression_instructions, + build_deload_instructions, +) +from ironforge.engine.substitutions import get_substitutes, is_substitutable +from ironforge.program.models import Program, ProgramWeek, ProgramSession, ProgrammedExercise + +import random + + +def _swap_exercises(program: Program, equip: set) -> Program: + """Swap each substitutable exercise with a random substitute from the same group.""" + # Build a swap map: old_name → new ExerciseDefinition (consistent across weeks) + swap_map: dict[str, any] = {} + for s in program.weeks[0].sessions: + for ex in s.exercises: + name = ex.exercise.name + if name in swap_map: + continue + if is_substitutable(name): + subs = [sub for sub in get_substitutes(name) + if any(eq in equip for eq in sub.equipment)] + if subs: + swap_map[name] = random.choice(subs) + + # Apply swaps across all weeks + for week in program.weeks: + for s in week.sessions: + for ex in s.exercises: + if ex.exercise.name in swap_map: + new_ex_def = swap_map[ex.exercise.name] + ex.exercise = new_ex_def + ex.notes = new_ex_def.notes or "" + return program + + +def build_program(profile: UserProfile) -> Program: + """Build a complete training program from a user profile.""" + levels = classify(profile) + volume_targets = compute_volume(profile, levels) + split = plan_frequency(profile, volume_targets, split_key=profile.split_key or None) + + # A/B variant mapping (same exercises across all weeks) + focus_seen: dict[tuple, int] = {} + variant_map: list[int] = [] + for template in split.sessions: + focus_key = tuple(sorted(m.name for m in template.muscle_focus)) + variant = focus_seen.get(focus_key, 0) + focus_seen[focus_key] = variant + 1 + variant_map.append(variant) + + # Build 4 training weeks — same exercises/sets, only RIR changes + all_weeks: list[ProgramWeek] = [] + for week_num in range(1, MESO_LENGTH + 1): + sessions: list[ProgramSession] = [] + for i, template in enumerate(split.sessions): + exercises = select_exercises_for_session( + template=template, + all_sessions=split.sessions, + profile=profile, + levels=levels, + volume_targets=volume_targets, + session_variant=variant_map[i], + week=week_num, + ) + if profile.prefers_supersets: + exercises = pair_supersets(exercises, allow_supersets=True) + sessions.append(ProgramSession( + day_label=template.day_label, + exercises=exercises, + )) + all_weeks.append(ProgramWeek(week_number=week_num, sessions=sessions)) + + progression = build_progression_instructions(profile, levels) + deload = build_deload_instructions(profile) + meso_overview = build_mesocycle_overview(profile, levels) + + return Program( + level_assessment=levels, + volume_targets=volume_targets, + split_name=split.name, + split_rationale=split.rationale, + weeks=all_weeks, + progression_instructions=progression, + deload_instructions=deload, + mesocycle_overview=meso_overview, + ) + + +def build_program_randomized(profile: UserProfile) -> Program: + """Build a program then swap exercises for substitutes from the same muscle region.""" + program = build_program(profile) + return _swap_exercises(program, profile.available_equipment) diff --git a/ironforge/program/models.py b/ironforge/program/models.py new file mode 100644 index 000000000..476ee44d3 --- /dev/null +++ b/ironforge/program/models.py @@ -0,0 +1,72 @@ +"""Program data models — the complete program representation.""" + +from dataclasses import dataclass, field +from ironforge.data.exercises import ExerciseDefinition +from ironforge.data.muscle_groups import ( + MuscleGroup, MovementPattern, TrainingLevel, Tier, VolumeMuscle, +) + + +@dataclass +class LoadPrescription: + sets: int + rep_low: int + rep_high: int + rir: int + rest_seconds: int + + +@dataclass +class ProgrammedExercise: + exercise: ExerciseDefinition + load: LoadPrescription + tier: Tier + notes: str = "" + superset_pair_id: int | None = None + + +@dataclass +class ProgramSession: + day_label: str + exercises: list[ProgrammedExercise] = field(default_factory=list) + + +@dataclass +class ProgramWeek: + week_number: int + sessions: list[ProgramSession] = field(default_factory=list) + + +@dataclass +class VolumeTarget: + muscle: VolumeMuscle + mev: float + working: float # programmed sets for week 1 + mrv: float + + +@dataclass +class MesocycleOverview: + week: int + volume_description: str + rir: str + load_note: str + + +@dataclass +class Program: + # Section 1 + level_assessment: dict[MovementPattern, TrainingLevel] + # Section 2 + volume_targets: list[VolumeTarget] + # Section 3 + split_name: str + split_rationale: str + # Section 4 + weeks: list[ProgramWeek] + # Section 5 + progression_instructions: str + # Section 6 + deload_instructions: str + # Section 7 + mesocycle_overview: list[MesocycleOverview] diff --git a/ironforge/templates/error.html b/ironforge/templates/error.html new file mode 100644 index 000000000..1f62b5a78 --- /dev/null +++ b/ironforge/templates/error.html @@ -0,0 +1,32 @@ + + + + + +Error {{ code }} — Ironforge + + + +
{{ code }}
+
{{ message }}
+ Back to Home + + diff --git a/ironforge/templates/index.html b/ironforge/templates/index.html new file mode 100644 index 000000000..cc76ce946 --- /dev/null +++ b/ironforge/templates/index.html @@ -0,0 +1,591 @@ + + + + + +Ironforge — Workout Program Designer + + + + +
+

IRONFORGE

+

Evidence-Based Workout Program Designer

+
+ +
+
+
+
+
+
+
+
+
+
Block A — Goals
+ +
+ + + +
+
Goals
+ +
+ +
+ + + + +
+
+ +
+ +
Select all that apply (or none)
+
+ + + + + + + + + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+
+ + +
+
Training History
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ +
+ + +
+
+ + +
+
Schedule & Logistics
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + + + +
+
+ +
+ +
Saves ~36% session time with equal results
+
+ + + +
+
+ +
+ + +
+
+ + +
+
Choose Your Split
+ +
+ +
Options update based on your training days
+
+ +
+
+ +
+ + +
+
+ + +
+
Equipment Access
+ +
+ +
+ + + +
+
+ +
+ + +
+
+ + +
+
Individual Factors
+ +
+ +
Affects rep range bias — females skew higher reps
+
+ + +
+
+ +
+ + +
+ +
+ +
Rough estimate — e.g. 10, 15, or 0 if new
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ +
+
+

Building your 5-week mesocycle...

+
+ + + + + diff --git a/ironforge/templates/program.html b/ironforge/templates/program.html new file mode 100644 index 000000000..458726994 --- /dev/null +++ b/ironforge/templates/program.html @@ -0,0 +1,354 @@ + + + + + +Your Program — Ironforge + + + + +
+

YOUR PROGRAM

+
+ New Program + +
+
+ +
Copied! Paste into Google Sheets
+ +
+ + +
+
1. Training Level Assessment
+
+
+ {% for pattern, level in program.level_assessment.items() %} + {{ pattern.name | replace('_', ' ') | title }} + {{ level.name | title }} + {% endfor %} +
+
+
+ + +
+
2. Weekly Volume Targets
+
+ + + {% for vt in program.volume_targets %} + + + + + + + {% endfor %} +
MuscleMEVWeek 1MRV
{{ vt.muscle.name | replace('_', ' ') | title }}{{ vt.mev | round(1) }}{{ vt.working }}{{ vt.mrv | int }}
+
+
+ + +
+
3. Split Structure
+
+
{{ program.split_name }}
+
{{ program.split_rationale }}
+
+
+ + +
+
4. Training Program — 4 Weeks
+
+ +
+ {% for week in program.weeks %} +
+ Wk {{ week.week_number }} +
+ {% endfor %} +
+ + {% for week in program.weeks %} + {% set week_idx = loop.index0 %} +
+ +
+ +
+ +
+ {% for session in week.sessions %} +
+ {{ session.day_label | replace('Day ' ~ loop.index ~ ' — ', '') }} +
+ {% endfor %} +
+ + {% for session in week.sessions %} +
+ + {# Render exercises — supersetted pairs shown as "Ex1 // Ex2" #} + {% for ex in session.grouped_exercises %} + {% if ex.is_superset %} +
+
+ {{ loop.index }} + {{ ex.name_a }} // {{ ex.name_b }} +
+
+ {{ ex.rx_a }} + / {{ ex.rx_b }} + RIR {{ ex.rir }} +
+
+ {% else %} +
+
+ {{ loop.index }} + {{ ex.name }} + T{{ ex.tier }} +
+
+ {{ ex.rx }} + RIR {{ ex.rir }} + Rest {{ ex.rest }}s +
+ {% if ex.notes %} +
{{ ex.notes }}
+ {% endif %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} + +
+
+ + + + + + + + +
+
7. Mesocycle Overview
+
+ + + {% for mo in program.mesocycle_overview %} + + + + + + + {% endfor %} +
WkVolumeRIRLoad
{{ mo.week }}{{ mo.volume_description }}{{ mo.rir }}{{ mo.load_note }}
+
+
+ + Generate Another Program +
+ + + + + diff --git a/ironforge/web.py b/ironforge/web.py new file mode 100644 index 000000000..ad1921c78 --- /dev/null +++ b/ironforge/web.py @@ -0,0 +1,494 @@ +"""Flask web frontend for Ironforge.""" + +import csv +import hashlib +import io +import os +import secrets +import time +from collections import defaultdict +from functools import wraps + +from flask import ( + Flask, render_template, request, Response, session, jsonify, abort, + after_this_request, redirect, url_for, +) +from markupsafe import escape + +from ironforge.data.muscle_groups import ( + Goal, Sex, CaloricPhase, EquipmentAccess, Equipment, + VolumeMuscle, +) +from ironforge.intake.profile import UserProfile +from ironforge.engine.frequency import get_split_options, ALL_SPLITS +from ironforge.program.builder import build_program +from ironforge.program.models import Program + +app = Flask(__name__) +app.secret_key = os.environ.get("IRONFORGE_SECRET_KEY", secrets.token_hex(32)) +app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=os.environ.get("IRONFORGE_HTTPS", "false").lower() == "true", + PERMANENT_SESSION_LIFETIME=3600, +) + + +# ─── Security Headers ──────────────────────────────────────────────────────── + +@app.after_request +def set_security_headers(response): + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = ( + "camera=(), microphone=(), geolocation=(), payment=()" + ) + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "font-src 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'" + ) + return response + + +# ─── CSRF Protection ───────────────────────────────────────────────────────── + +def _generate_csrf_token(): + if "_csrf_token" not in session: + session["_csrf_token"] = secrets.token_hex(32) + return session["_csrf_token"] + + +app.jinja_env.globals["csrf_token"] = _generate_csrf_token + + +def _check_csrf(): + """Validate CSRF token on state-changing requests.""" + token = session.get("_csrf_token") + form_token = request.form.get("_csrf_token") + if not token or not form_token or not secrets.compare_digest(token, form_token): + abort(403, description="Invalid or missing CSRF token.") + + +# ─── Rate Limiting ──────────────────────────────────────────────────────────── + +_rate_store: dict[str, list[float]] = defaultdict(list) +RATE_LIMIT = 20 # max requests per window +RATE_WINDOW = 60 # seconds + + +def _rate_limit(): + """Simple in-memory rate limiter by IP.""" + ip = request.remote_addr or "unknown" + now = time.monotonic() + # Prune old entries + _rate_store[ip] = [t for t in _rate_store[ip] if now - t < RATE_WINDOW] + if len(_rate_store[ip]) >= RATE_LIMIT: + abort(429, description="Too many requests. Please wait a minute.") + _rate_store[ip].append(now) + + +@app.before_request +def before_request_checks(): + _rate_limit() + if request.method == "POST": + _check_csrf() + + +# ─── Per-Session Program Storage ────────────────────────────────────────────── +# Store serialized program data in the session instead of a global variable. +# This prevents data leaking between users and race conditions. + +_session_programs: dict[str, Program] = {} + + +def _store_program(program: Program) -> str: + """Store program keyed by session ID, return the key.""" + sid = session.get("_sid") + if not sid: + sid = secrets.token_hex(16) + session["_sid"] = sid + _session_programs[sid] = program + # Evict old entries if store grows too large (simple LRU-ish) + if len(_session_programs) > 500: + oldest_keys = list(_session_programs.keys())[:250] + for k in oldest_keys: + _session_programs.pop(k, None) + return sid + + +def _get_program() -> Program | None: + sid = session.get("_sid") + if sid: + return _session_programs.get(sid) + return None + + +# ─── Input Validation ───────────────────────────────────────────────────────── + +# Whitelists for validated inputs +VALID_GOALS = {"a", "b", "c", "d"} +VALID_CALORIC = {"surplus", "deficit", "maintenance"} +VALID_TRAINING = {"a", "b", "c"} +VALID_PROGRESSION = {"a", "b", "c"} +VALID_DAYS = {3, 4, 5, 6} +VALID_MINUTES = {40, 52, 67, 82, 95} +VALID_SUPERSETS = {"yes", "no", "no_pref"} +VALID_EQUIP = {"a", "b", "c", "d"} +VALID_SEX = {"male", "female"} +VALID_MUSCLES = {m.name for m in VolumeMuscle} + +MAX_TEXT_LEN = 500 + + +def _clean_text(value: str, max_len: int = MAX_TEXT_LEN) -> str: + """Truncate and strip text input.""" + return value.strip()[:max_len] + + +def _safe_int(value: str, default: int, lo: int, hi: int) -> int: + """Parse int with bounds, return default on failure.""" + try: + n = int(value) + return max(lo, min(hi, n)) + except (ValueError, TypeError): + return default + + +def _parse_profile(form) -> UserProfile: + """Parse and validate form data into a UserProfile.""" + profile = UserProfile() + + # Goals — whitelist + goal_key = form.get("primary_goal", "a") + if goal_key not in VALID_GOALS: + goal_key = "a" + profile.primary_goal = { + "a": Goal.HYPERTROPHY, "b": Goal.STRENGTH, + "c": Goal.HYBRID, "d": Goal.RECOMP, + }[goal_key] + + # Priority muscles — whitelist against enum names + for val in form.getlist("priority_muscles"): + if val in VALID_MUSCLES: + muscle = VolumeMuscle[val] + if muscle not in profile.priority_muscles: + profile.priority_muscles.append(muscle) + if VolumeMuscle.GLUTES in profile.priority_muscles: + profile.wants_glute_focus = True + + caloric = form.get("caloric_phase", "maintenance") + if caloric not in VALID_CALORIC: + caloric = "maintenance" + profile.caloric_phase = { + "surplus": CaloricPhase.SURPLUS, + "deficit": CaloricPhase.DEFICIT, + "maintenance": CaloricPhase.MAINTENANCE, + }[caloric] + + # Training history — whitelist + training = form.get("training_months", "b") + if training not in VALID_TRAINING: + training = "b" + profile.training_months = {"a": 3, "b": 18, "c": 48}[training] + + progression = form.get("progression_rate", "b") + if progression not in VALID_PROGRESSION: + progression = "b" + if progression == "a": + profile.can_add_weight_every_session = True + elif progression == "b": + profile.adds_weight_every_1_2_weeks = True + + # Schedule — bounded ints + whitelist + days = _safe_int(form.get("days_per_week", "4"), 4, 3, 6) + if days not in VALID_DAYS: + days = 4 + profile.days_per_week = days + + minutes = _safe_int(form.get("session_minutes", "67"), 67, 30, 120) + if minutes not in VALID_MINUTES: + minutes = 67 + profile.session_minutes = minutes + + supersets = form.get("prefers_supersets", "yes") + if supersets not in VALID_SUPERSETS: + supersets = "yes" + profile.prefers_supersets = supersets in ("yes", "no_pref") + + # Split selection — validate against known keys + split_key = form.get("split_key", "") + if split_key and split_key not in ALL_SPLITS: + split_key = "" + profile.split_key = split_key + + # Equipment — whitelist + equip = form.get("equipment_access", "a") + if equip not in VALID_EQUIP: + equip = "a" + profile.equipment_access = { + "a": EquipmentAccess.FULL_GYM, + "b": EquipmentAccess.LIMITED_GYM, + "c": EquipmentAccess.HOME_GYM, + "d": EquipmentAccess.OTHER, + }[equip] + if profile.equipment_access == EquipmentAccess.HOME_GYM: + profile.available_equipment = { + Equipment.BARBELL, Equipment.DUMBBELL, + Equipment.EZ_BAR, Equipment.BODYWEIGHT, + } + elif profile.equipment_access == EquipmentAccess.LIMITED_GYM: + profile.available_equipment = { + Equipment.BARBELL, Equipment.DUMBBELL, + Equipment.CABLE, Equipment.EZ_BAR, + Equipment.BODYWEIGHT, Equipment.MACHINE, + } + + # Individual — whitelist + bounded text + sex = form.get("sex", "male") + if sex not in VALID_SEX: + sex = "male" + profile.sex = {"male": Sex.MALE, "female": Sex.FEMALE}[sex] + + injuries = _clean_text(form.get("injuries", "")) + if injuries and injuries.lower() not in ("none", "no", "n/a", ""): + profile.injuries = [injuries] + + profile.current_sets_per_muscle = _safe_int( + form.get("current_sets", "0"), 0, 0, 50 + ) + profile.current_program = _clean_text( + form.get("current_program", "starting fresh") + ) + + return profile + + +# ─── Routes ────────────────────────────────────────────────────────────────── + +@app.route("/") +def index(): + from ironforge.engine.frequency import SPLITS_BY_DAYS + return render_template("index.html", splits_by_days=SPLITS_BY_DAYS) + + +@app.route("/api/splits/") +def api_splits(days: int): + """Return split options for a given day count (AJAX endpoint).""" + if days not in VALID_DAYS: + abort(400, description="Invalid day count.") + options = get_split_options(days) + return jsonify([{"key": s.key, "name": s.name, "rationale": s.rationale} for s in options]) + + +def _format_exercise_line(ex) -> str: + """Format one exercise as 'Name\\tSETSxREPS @RIR RIR'.""" + load = ex.load + return f"{ex.exercise.name}\t{load.sets}x{load.rep_low}-{load.rep_high} @{load.rir} RIR" + + +def _build_week_text(week) -> str: + """Build tab-separated text for a single week.""" + lines = [f"Week {week.week_number}", ""] + for sess in week.sessions: + lines.append(sess.day_label.replace(" — ", " - ")) + # Group supersets as "Ex1 // Ex2" + rendered_pairs: set[int] = set() + for ex in sess.exercises: + if ex.superset_pair_id and ex.superset_pair_id not in rendered_pairs: + rendered_pairs.add(ex.superset_pair_id) + partner = next( + (o for o in sess.exercises + if o.superset_pair_id == ex.superset_pair_id + and o.exercise.name != ex.exercise.name), None) + if partner: + name = f"{ex.exercise.name} // {partner.exercise.name}" + load = ex.load + p_load = partner.load + rx = (f"{load.sets}x{load.rep_low}-{load.rep_high} / " + f"{p_load.sets}x{p_load.rep_low}-{p_load.rep_high} @{load.rir} RIR") + lines.append(f"{name}\t{rx}") + else: + lines.append(_format_exercise_line(ex)) + elif not ex.superset_pair_id: + lines.append(_format_exercise_line(ex)) + lines.append("") + return "\n".join(lines) + + +def _build_weeks_data(program: Program) -> list[str]: + """Build per-week text for individual copy buttons.""" + return [_build_week_text(week) for week in program.weeks] + + +def _group_exercises(session): + """Pre-process exercises into a flat list of dicts for the template. + Supersetted pairs become a single entry with is_superset=True.""" + grouped = [] + rendered_pairs: set[int] = set() + for ex in session.exercises: + load = ex.load + if ex.superset_pair_id and ex.superset_pair_id not in rendered_pairs: + rendered_pairs.add(ex.superset_pair_id) + partner = next( + (o for o in session.exercises + if o.superset_pair_id == ex.superset_pair_id + and o.exercise.name != ex.exercise.name), None) + if partner: + p = partner.load + grouped.append({ + "is_superset": True, + "name_a": ex.exercise.name, + "name_b": partner.exercise.name, + "rx_a": f"{load.sets}\u00d7{load.rep_low}-{load.rep_high}", + "rx_b": f"{p.sets}\u00d7{p.rep_low}-{p.rep_high}", + "rir": load.rir, + }) + else: + grouped.append({ + "is_superset": False, + "name": ex.exercise.name, + "rx": f"{load.sets}\u00d7{load.rep_low}-{load.rep_high}", + "rir": load.rir, "rest": load.rest_seconds, + "tier": ex.tier.value, "notes": ex.notes, + }) + elif not ex.superset_pair_id: + grouped.append({ + "is_superset": False, + "name": ex.exercise.name, + "rx": f"{load.sets}\u00d7{load.rep_low}-{load.rep_high}", + "rir": load.rir, "rest": load.rest_seconds, + "tier": ex.tier.value, "notes": ex.notes, + }) + return grouped + + +def _prepare_program(program: Program): + """Attach grouped_exercises to each session for template rendering.""" + for week in program.weeks: + for sess in week.sessions: + sess.grouped_exercises = _group_exercises(sess) + + +@app.route("/generate", methods=["POST"]) +def generate(): + profile = _parse_profile(request.form) + program = build_program(profile) + _store_program(program) + _prepare_program(program) + session["_last_form"] = dict(request.form) + weeks_data = _build_weeks_data(program) + return render_template("program.html", program=program, profile=profile, + weeks_data=weeks_data) + + +@app.route("/regenerate") +def regenerate(): + """Regenerate program with different exercise selections (same muscles).""" + form_data = session.get("_last_form") + if not form_data: + return redirect("/") + + from werkzeug.datastructures import ImmutableMultiDict + form = ImmutableMultiDict(form_data) + profile = _parse_profile(form) + + # Import and use the randomized builder + from ironforge.program.builder import build_program_randomized + program = build_program_randomized(profile) + _store_program(program) + _prepare_program(program) + weeks_data = _build_weeks_data(program) + return render_template("program.html", program=program, profile=profile, + weeks_data=weeks_data) + + +@app.route("/export.csv") +def export_csv(): + """Export the session's program as CSV — clean 2-column format for Sheets.""" + program = _get_program() + if program is None: + return "No program generated yet. Go back and generate one first.", 400 + + output = io.StringIO() + writer = csv.writer(output) + + for week in program.weeks: + writer.writerow([f"Week {week.week_number}"]) + writer.writerow([]) + + for sess in week.sessions: + label = sess.day_label.replace(" — ", " - ") + writer.writerow([label]) + + rendered_pairs: set[int] = set() + for ex in sess.exercises: + load = ex.load + if ex.superset_pair_id and ex.superset_pair_id not in rendered_pairs: + rendered_pairs.add(ex.superset_pair_id) + partner = next( + (o for o in sess.exercises + if o.superset_pair_id == ex.superset_pair_id + and o.exercise.name != ex.exercise.name), None) + if partner: + p = partner.load + name = f"{ex.exercise.name} // {partner.exercise.name}" + rx = (f"{load.sets}x{load.rep_low}-{load.rep_high} / " + f"{p.sets}x{p.rep_low}-{p.rep_high} @{load.rir} RIR") + writer.writerow([name, rx]) + else: + writer.writerow([ex.exercise.name, + f"{load.sets}x{load.rep_low}-{load.rep_high} @{load.rir} RIR"]) + elif not ex.superset_pair_id: + writer.writerow([ex.exercise.name, + f"{load.sets}x{load.rep_low}-{load.rep_high} @{load.rir} RIR"]) + + writer.writerow([]) + + csv_content = output.getvalue() + return Response( + csv_content, + mimetype="text/csv", + headers={"Content-Disposition": "attachment; filename=ironforge_program.csv"}, + ) + + +# ─── Error Handlers ────────────────────────────────────────────────────────── + +@app.errorhandler(403) +def forbidden(e): + return render_template("error.html", code=403, message=e.description), 403 + + +@app.errorhandler(429) +def rate_limited(e): + return render_template("error.html", code=429, message=e.description), 429 + + +@app.errorhandler(400) +def bad_request(e): + return render_template("error.html", code=400, message=e.description), 400 + + +@app.errorhandler(500) +def server_error(e): + return render_template("error.html", code=500, + message="Something went wrong. Please try again."), 500 + + +# ─── Entry Point ───────────────────────────────────────────────────────────── + +def run(): + debug = os.environ.get("IRONFORGE_DEBUG", "false").lower() == "true" + app.run(host="0.0.0.0", port=5000, debug=debug) + + +if __name__ == "__main__": + run() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..d60cee41e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "ironforge" +version = "0.2.0" +description = "Evidence-based workout program designer" +requires-python = ">=3.11" +dependencies = ["flask>=3.0"] + +[project.scripts] +ironforge = "ironforge.cli:main" +ironforge-web = "ironforge.web:run" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..e6365da06 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask==3.1.3 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_program.py b/tests/test_program.py new file mode 100644 index 000000000..7e5a46735 --- /dev/null +++ b/tests/test_program.py @@ -0,0 +1,344 @@ +"""End-to-end tests for the workout program generator.""" + +import unittest +from ironforge.data.muscle_groups import ( + Goal, Sex, CaloricPhase, EquipmentAccess, Equipment, + MovementPattern, TrainingLevel, VolumeMuscle, Tier, +) +from ironforge.data.exercises import ALL_EXERCISES, exercises_by_muscle, MuscleGroup +from ironforge.intake.profile import UserProfile +from ironforge.engine.classifier import classify +from ironforge.engine.volume import compute_volume +from ironforge.engine.frequency import plan_frequency +from ironforge.engine.selection import select_exercises_for_session +from ironforge.engine.supersets import pair_supersets +from ironforge.engine.load import assign_loading +from ironforge.engine.periodization import ( + build_mesocycle_overview, build_progression_instructions, + build_deload_instructions, +) +from ironforge.program.builder import build_program +from ironforge.output.formatter import render + + +class TestExerciseDatabase(unittest.TestCase): + def test_exercise_count(self): + # Should have ~97 entries (some may appear twice in ALL_EXERCISES + # due to dual-category listing like PULLDOWN_ROW_MACHINE) + unique = {e.name for e in ALL_EXERCISES} + self.assertGreaterEqual(len(unique), 90) + + def test_all_exercises_have_equipment(self): + for ex in ALL_EXERCISES: + self.assertTrue(len(ex.equipment) > 0, f"{ex.name} has no equipment") + + def test_exercises_by_muscle(self): + chest = exercises_by_muscle(MuscleGroup.CHEST_STERNAL) + self.assertTrue(len(chest) > 0) + for ex in chest: + self.assertEqual(ex.primary, MuscleGroup.CHEST_STERNAL) + + +class TestClassifier(unittest.TestCase): + def test_beginner_classification(self): + p = UserProfile(training_months=3) + levels = classify(p) + for pattern, level in levels.items(): + self.assertEqual(level, TrainingLevel.BEGINNER) + + def test_intermediate_classification(self): + p = UserProfile(training_months=18, adds_weight_every_1_2_weeks=True) + levels = classify(p) + for pattern, level in levels.items(): + self.assertEqual(level, TrainingLevel.INTERMEDIATE) + + def test_advanced_classification(self): + p = UserProfile(training_months=48) + levels = classify(p) + for pattern, level in levels.items(): + self.assertEqual(level, TrainingLevel.ADVANCED) + + def test_per_pattern_override(self): + p = UserProfile( + training_months=18, + pattern_experience={ + MovementPattern.SQUAT: TrainingLevel.BEGINNER, + MovementPattern.HORIZONTAL_PUSH: TrainingLevel.ADVANCED, + }, + ) + levels = classify(p) + self.assertEqual(levels[MovementPattern.SQUAT], TrainingLevel.BEGINNER) + self.assertEqual(levels[MovementPattern.HORIZONTAL_PUSH], TrainingLevel.ADVANCED) + + +class TestVolume(unittest.TestCase): + def test_volume_at_mev(self): + p = UserProfile(training_months=18) + levels = classify(p) + targets = compute_volume(p, levels) + self.assertTrue(len(targets) > 0) + for t in targets: + self.assertGreaterEqual(t.working, t.mev) + + def test_priority_muscles_get_more_volume(self): + p_no_priority = UserProfile(training_months=18) + p_priority = UserProfile( + training_months=18, + priority_muscles=[VolumeMuscle.CHEST], + ) + levels = classify(p_no_priority) + targets_no = compute_volume(p_no_priority, levels) + targets_yes = compute_volume(p_priority, levels) + + chest_no = next(t for t in targets_no if t.muscle == VolumeMuscle.CHEST) + chest_yes = next(t for t in targets_yes if t.muscle == VolumeMuscle.CHEST) + self.assertGreaterEqual(chest_yes.working, chest_no.working) + + +class TestFrequency(unittest.TestCase): + def test_no_consecutive_muscle_overlap(self): + """Every non-full-body split must have zero muscle overlap on adjacent days.""" + from ironforge.engine.frequency import SPLITS_BY_DAYS + for days, splits in SPLITS_BY_DAYS.items(): + for split in splits: + if split.requires_rest_days: + continue + for i in range(len(split.sessions) - 1): + a = set(split.sessions[i].muscle_focus) + b = set(split.sessions[i + 1].muscle_focus) + overlap = a & b + self.assertEqual( + overlap, set(), + f"{split.name}: {split.sessions[i].day_label} and " + f"{split.sessions[i+1].day_label} share {overlap}", + ) + + def test_all_rep_ranges_within_4_10(self): + """Every rep range must be between 4 and 10.""" + from ironforge.data.constants import REP_RANGES, REP_RANGES_FEMALE + for ranges in [REP_RANGES, REP_RANGES_FEMALE]: + for tier, (lo, hi) in ranges.items(): + self.assertGreaterEqual(lo, 4, f"{tier}: rep_low {lo} < 4") + self.assertLessEqual(hi, 10, f"{tier}: rep_high {hi} > 10") + + def test_3_day_full_body(self): + p = UserProfile(days_per_week=3) + targets = compute_volume(p, classify(p)) + split = plan_frequency(p, targets) + self.assertEqual(split.name, "Full Body") + self.assertEqual(len(split.sessions), 3) + + def test_4_day_upper_lower(self): + p = UserProfile(days_per_week=4) + targets = compute_volume(p, classify(p)) + split = plan_frequency(p, targets) + self.assertEqual(split.name, "Upper / Lower") + self.assertEqual(len(split.sessions), 4) + + def test_6_day_ppl(self): + p = UserProfile(days_per_week=6) + targets = compute_volume(p, classify(p)) + split = plan_frequency(p, targets) + self.assertEqual(split.name, "PPL (x2)") + self.assertEqual(len(split.sessions), 6) + + +class TestLoad(unittest.TestCase): + def test_t1_higher_rir_week1(self): + load = assign_loading(Tier.T1, TrainingLevel.INTERMEDIATE, Sex.MALE, week=1) + self.assertGreaterEqual(load.rir, 3) + + def test_beginner_higher_rir(self): + load = assign_loading(Tier.T2, TrainingLevel.BEGINNER, Sex.MALE) + self.assertGreaterEqual(load.rir, 3) + + def test_female_higher_reps(self): + male = assign_loading(Tier.T1, TrainingLevel.INTERMEDIATE, Sex.MALE) + female = assign_loading(Tier.T1, TrainingLevel.INTERMEDIATE, Sex.FEMALE) + self.assertGreater(female.rep_low, male.rep_low) + + def test_rest_periods_minimum(self): + for tier in Tier: + load = assign_loading(tier, TrainingLevel.INTERMEDIATE, Sex.MALE) + self.assertGreaterEqual(load.rest_seconds, 90) + + +class TestSelection(unittest.TestCase): + def test_chest_session_has_press_and_fly(self): + from ironforge.engine.frequency import SessionTemplate + p = UserProfile(days_per_week=4) + levels = classify(p) + targets = compute_volume(p, levels) + split = plan_frequency(p, targets) + + template = split.sessions[0] # Upper A + exercises = select_exercises_for_session( + template, split.sessions, p, levels, targets, 0, + ) + chest_exercises = [ + e for e in exercises + if e.exercise.primary in (MuscleGroup.CHEST_CLAVICULAR, MuscleGroup.CHEST_STERNAL) + ] + self.assertGreaterEqual(len(chest_exercises), 2) + + def test_back_has_vertical_and_horizontal(self): + from ironforge.engine.frequency import SessionTemplate + p = UserProfile(days_per_week=4) + levels = classify(p) + targets = compute_volume(p, levels) + split = plan_frequency(p, targets) + + template = split.sessions[0] # Upper A + exercises = select_exercises_for_session( + template, split.sessions, p, levels, targets, 0, + ) + patterns = {e.exercise.pattern for e in exercises} + self.assertIn(MovementPattern.VERTICAL_PULL, patterns) + self.assertIn(MovementPattern.HORIZONTAL_PULL, patterns) + + +class TestSupersets(unittest.TestCase): + def test_t1_never_supersetted(self): + from ironforge.engine.frequency import SessionTemplate + p = UserProfile(days_per_week=4, prefers_supersets=True) + levels = classify(p) + targets = compute_volume(p, levels) + split = plan_frequency(p, targets) + + template = split.sessions[0] + exercises = select_exercises_for_session( + template, split.sessions, p, levels, targets, 0, + ) + exercises = pair_supersets(exercises) + for ex in exercises: + if ex.tier == Tier.T1: + self.assertIsNone(ex.superset_pair_id) + + +class TestEndToEnd(unittest.TestCase): + def _build(self, **kwargs): + p = UserProfile(**kwargs) + program = build_program(p) + return program, render(program) + + def test_beginner_hypertrophy_4day(self): + program, output = self._build( + primary_goal=Goal.HYPERTROPHY, training_months=3, days_per_week=4, + ) + self.assertIn("BEGINNER", output) + self.assertIn("Upper / Lower", output) + self.assertIn("LINEAR PROGRESSION", output) + + def test_intermediate_strength_5day(self): + program, output = self._build( + primary_goal=Goal.STRENGTH, training_months=18, days_per_week=5, + ) + self.assertIn("INTERMEDIATE", output) + self.assertIn("Upper / Lower / Push / Pull / Legs", output) + self.assertIn("STRENGTH NOTES", output) + + def test_advanced_recomp_6day(self): + program, output = self._build( + primary_goal=Goal.RECOMP, training_months=48, days_per_week=6, + caloric_phase=CaloricPhase.DEFICIT, + ) + self.assertIn("ADVANCED", output) + self.assertIn("PPL", output) + self.assertIn("DEFICIT NOTE", output) + + def test_female_higher_reps(self): + program, output = self._build( + training_months=18, days_per_week=4, sex=Sex.FEMALE, + ) + # Female T1 should be 5-8, not 4-6 + self.assertIn("5-8", output) + + def test_home_gym_equipment_filter(self): + program, output = self._build( + training_months=18, days_per_week=4, + equipment_access=EquipmentAccess.HOME_GYM, + available_equipment={ + Equipment.BARBELL, Equipment.DUMBBELL, + Equipment.EZ_BAR, Equipment.BODYWEIGHT, + }, + ) + # Should not contain any machine-only exercises + self.assertNotIn("Machine Chest Press", output) + + def test_glute_focus(self): + program, output = self._build( + training_months=18, days_per_week=4, + wants_glute_focus=True, + priority_muscles=[VolumeMuscle.GLUTES], + ) + self.assertIn("Hip Thrust", output) + + def test_all_sessions_have_exercises(self): + for days in [3, 4, 5, 6]: + program, output = self._build( + training_months=18, days_per_week=days, + ) + for session in program.weeks[0].sessions: + self.assertTrue( + len(session.exercises) > 0, + f"{days}-day split, session {session.day_label} has no exercises", + ) + + def test_mesocycle_has_4_weeks(self): + program, _ = self._build(training_months=18, days_per_week=4) + self.assertEqual(len(program.mesocycle_overview), 4) + self.assertEqual(len(program.weeks), 4) + + def test_sets_constant_across_weeks(self): + """Same exercises and sets every week — only RIR changes.""" + program, _ = self._build(training_months=18, days_per_week=4) + w1 = [(e.exercise.name, e.load.sets) + for s in program.weeks[0].sessions for e in s.exercises] + w4 = [(e.exercise.name, e.load.sets) + for s in program.weeks[3].sessions for e in s.exercises] + self.assertEqual(w1, w4) + + def test_max_3_sets_per_exercise(self): + for days in [3, 4, 5, 6]: + program, _ = self._build(training_months=18, days_per_week=days) + for week in program.weeks: + for s in week.sessions: + for e in s.exercises: + self.assertLessEqual( + e.load.sets, 3, + f"{e.exercise.name} has {e.load.sets} sets", + ) + + def test_rir_decreases_across_weeks(self): + program, _ = self._build(training_months=18, days_per_week=4) + rir_w1 = program.weeks[0].sessions[0].exercises[0].load.rir + rir_w4 = program.weeks[3].sessions[0].exercises[0].load.rir + self.assertGreater(rir_w1, rir_w4) + + def test_deload_instructions_present(self): + _, output = self._build(training_months=18, days_per_week=4) + self.assertIn("ACTIVE DELOAD", output) + self.assertIn("NUCKOLS RULE", output) + + def test_split_selection(self): + program, _ = self._build( + training_months=18, days_per_week=6, split_key="arnold_6", + ) + self.assertEqual(program.split_name, "Arnold Split (x2)") + + def test_multiple_priority_muscles(self): + program, output = self._build( + training_months=18, days_per_week=4, + priority_muscles=[VolumeMuscle.CHEST, VolumeMuscle.BACK, VolumeMuscle.BICEPS], + ) + # All three should be at MAV-low, not MEV + chest_target = next(t for t in program.volume_targets if t.muscle == VolumeMuscle.CHEST) + back_target = next(t for t in program.volume_targets if t.muscle == VolumeMuscle.BACK) + biceps_target = next(t for t in program.volume_targets if t.muscle == VolumeMuscle.BICEPS) + self.assertGreater(chest_target.working, chest_target.mev) + self.assertGreater(back_target.working, back_target.mev) + self.assertGreater(biceps_target.working, biceps_target.mev) + + +if __name__ == "__main__": + unittest.main() diff --git a/vercel.json b/vercel.json new file mode 100644 index 000000000..b9b875e53 --- /dev/null +++ b/vercel.json @@ -0,0 +1,14 @@ +{ + "builds": [ + { + "src": "api/index.py", + "use": "@vercel/python" + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "api/index.py" + } + ] +}