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 @@ + + +
+ + +Evidence-Based Workout Program Designer
+Building your 5-week mesocycle...
+| Muscle | MEV | Week 1 | MRV |
|---|---|---|---|
| {{ vt.muscle.name | replace('_', ' ') | title }} | +{{ vt.mev | round(1) }} | +{{ vt.working }} | +{{ vt.mrv | int }} | +
| Wk | Volume | RIR | Load |
|---|---|---|---|
| {{ mo.week }} | +{{ mo.volume_description }} | +{{ mo.rir }} | +{{ mo.load_note }} | +