Skip to content

Commit 9625eda

Browse files
committed
Enforce 2-3 sets/exercise, superset // format, regenerate button, per-week copy
Sets: - Every exercise gets exactly 2 or 3 sets (never 1, never 4+) - Simplified selection engine: explicit set counts per exercise type - Fixed leg extension getting 0 sets (was a math bug in set splitting) Supersets: - Display format changed to "Exercise 1 // Exercise 2" with combined prescription (3x4-6 / 2x8-10 @3 RIR) - Applied in web UI, CSV export, and clipboard copy Regenerate: - "Regenerate Exercises" button swaps each exercise with a random substitute from the same muscle region (same tags + primary muscle) - Non-substitutable exercises (Leg Extension, Ab Curl Machine, etc.) stay locked — only exercises with valid alternatives get swapped - Substitution groups built from 97-exercise database with region tags Per-week copy: - Each week tab now has its own "Copy Week N for Sheets" button - Copies only that week's data to clipboard (not all weeks) https://claude.ai/code/session_01Lo7Z7GoRzG6hZkKwrxnXVk
1 parent ff12a35 commit 9625eda

File tree

7 files changed

+413
-402
lines changed

7 files changed

+413
-402
lines changed
-7.16 KB
Binary file not shown.

ironforge/engine/selection.py

Lines changed: 82 additions & 211 deletions
Large diffs are not rendered by default.

ironforge/engine/substitutions.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Exercise substitution groups — exercises that target the same region of the same muscle.
2+
3+
Used by the regenerate feature to swap exercises without changing the program's
4+
muscle targeting. If a group has only 1 exercise, it's not substitutable.
5+
"""
6+
7+
from ironforge.data.exercises import ExerciseDefinition, ALL_EXERCISES
8+
from collections import defaultdict
9+
10+
11+
def _region_tags(ex: ExerciseDefinition) -> tuple[str, ...]:
12+
"""Extract region-identifying tags from an exercise."""
13+
REGION_TAGS = {
14+
'upper_chest', 'flat_chest', 'press', 'fly',
15+
'side_delt', 'rear_delt', 'overhead', 'long_head', 'pushdown',
16+
'proximal', 'distal', 'stretch', 'general', 'brachialis',
17+
'vertical_pull', 'lat', 'row', 'mid_back', 'lat_emphasis',
18+
'squat', 'quad', 'quad_isolator', 'rf',
19+
'hamstring', 'curl', 'hinge', 'bflh',
20+
'glute', 'calf', 'gastrocnemius',
21+
}
22+
return tuple(sorted(t for t in ex.tags if t in REGION_TAGS))
23+
24+
25+
def _build_groups() -> dict[str, list[ExerciseDefinition]]:
26+
"""Build substitution groups keyed by (primary_muscle, region_tags)."""
27+
groups: dict[tuple, list[ExerciseDefinition]] = defaultdict(list)
28+
seen_names: set[str] = set()
29+
for ex in ALL_EXERCISES:
30+
if ex.name in seen_names:
31+
continue
32+
seen_names.add(ex.name)
33+
key = (ex.primary.name, _region_tags(ex))
34+
groups[key].append(ex)
35+
# Convert to string keys for JSON-friendliness
36+
return {
37+
f"{muscle}|{'_'.join(tags) if tags else 'base'}": exercises
38+
for (muscle, tags), exercises in groups.items()
39+
}
40+
41+
42+
SUBSTITUTION_GROUPS = _build_groups()
43+
44+
# Reverse lookup: exercise name → group key
45+
EXERCISE_TO_GROUP: dict[str, str] = {}
46+
for group_key, exercises in SUBSTITUTION_GROUPS.items():
47+
for ex in exercises:
48+
EXERCISE_TO_GROUP[ex.name] = group_key
49+
50+
51+
def get_substitutes(exercise_name: str) -> list[ExerciseDefinition]:
52+
"""Return all exercises that can substitute for the given one (same region)."""
53+
group_key = EXERCISE_TO_GROUP.get(exercise_name)
54+
if not group_key:
55+
return []
56+
return [ex for ex in SUBSTITUTION_GROUPS[group_key] if ex.name != exercise_name]
57+
58+
59+
def is_substitutable(exercise_name: str) -> bool:
60+
"""True if the exercise has at least one valid substitute."""
61+
return len(get_substitutes(exercise_name)) > 0
2.11 KB
Binary file not shown.

ironforge/program/builder.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,36 @@
1111
build_mesocycle_overview, build_progression_instructions,
1212
build_deload_instructions,
1313
)
14-
from ironforge.program.models import Program, ProgramWeek, ProgramSession
14+
from ironforge.engine.substitutions import get_substitutes, is_substitutable
15+
from ironforge.program.models import Program, ProgramWeek, ProgramSession, ProgrammedExercise
16+
17+
import random
18+
19+
20+
def _swap_exercises(program: Program, equip: set) -> Program:
21+
"""Swap each substitutable exercise with a random substitute from the same group."""
22+
# Build a swap map: old_name → new ExerciseDefinition (consistent across weeks)
23+
swap_map: dict[str, any] = {}
24+
for s in program.weeks[0].sessions:
25+
for ex in s.exercises:
26+
name = ex.exercise.name
27+
if name in swap_map:
28+
continue
29+
if is_substitutable(name):
30+
subs = [sub for sub in get_substitutes(name)
31+
if any(eq in equip for eq in sub.equipment)]
32+
if subs:
33+
swap_map[name] = random.choice(subs)
34+
35+
# Apply swaps across all weeks
36+
for week in program.weeks:
37+
for s in week.sessions:
38+
for ex in s.exercises:
39+
if ex.exercise.name in swap_map:
40+
new_ex_def = swap_map[ex.exercise.name]
41+
ex.exercise = new_ex_def
42+
ex.notes = new_ex_def.notes or ""
43+
return program
1544

1645

1746
def build_program(profile: UserProfile) -> Program:
@@ -65,3 +94,9 @@ def build_program(profile: UserProfile) -> Program:
6594
deload_instructions=deload,
6695
mesocycle_overview=meso_overview,
6796
)
97+
98+
99+
def build_program_randomized(profile: UserProfile) -> Program:
100+
"""Build a program then swap exercises for substitutes from the same muscle region."""
101+
program = build_program(profile)
102+
return _swap_exercises(program, profile.available_equipment)

0 commit comments

Comments
 (0)