""" Populate the 8 new Exercise fields using rule-based derivation from existing data. Fields populated: 1. is_compound — derived from joints_used count (3+ joints = compound) 2. difficulty_level — derived from movement patterns, equipment, and name keywords 3. exercise_tier — derived from is_compound + movement patterns + is_weight 4. complexity_rating — derived from movement pattern count, equipment, and name keywords 5. hr_elevation_rating — derived from movement patterns and muscle mass involved 6. impact_level — derived from movement patterns and name keywords 7. stretch_position — derived from biomechanical analysis of exercise type 8. progression_of — set only for clear, verifiable progressions All rules are based on exercise science principles; no values are guessed. """ import re from django.core.management.base import BaseCommand from exercise.models import Exercise # ── 1. is_compound ──────────────────────────────────────────────────────── # Compound = exercise where multiple joints are PRIMARY movers under load. # The joints_used field includes stabilizer joints (shoulder in curls, wrist in rows), # so we use movement patterns as the primary signal, with joint count as tiebreaker. # These movement patterns are ALWAYS isolation (single primary joint) ISOLATION_PATTERNS = {'arms'} # These patterns are never compound in the programming sense NON_COMPOUND_PATTERNS = { 'yoga', 'massage', 'breathing', 'mobility - static', 'mobility,mobility - static', } # Name keywords that indicate isolation regardless of joints listed ISOLATION_NAME_KEYWORDS = [ 'curl', 'raise', 'shrug', 'kickback', 'kick back', 'pushdown', 'push down', 'fly ', 'flye', 'wrist', 'forearm', 'calf raise', 'heel raise', 'leg extension', 'leg curl', 'hamstring curl', 'reverse fly', 'pec deck', 'concentration', ] # Name keywords that indicate compound regardless COMPOUND_NAME_KEYWORDS = [ 'squat', 'deadlift', 'bench press', 'overhead press', 'row', 'pull-up', 'pull up', 'chin-up', 'chin up', 'dip', 'lunge', 'clean', 'snatch', 'thruster', 'push press', 'push-up', 'push up', 'good morning', 'hip thrust', 'step up', 'step-up', 'burpee', 'man maker', 'turkish', ] def derive_is_compound(ex): mp = (ex.movement_patterns or '').lower() name_lower = (ex.name or '').lower() # Non-training exercises: never compound if mp in NON_COMPOUND_PATTERNS or mp.startswith('yoga') or mp.startswith('massage') or mp == 'breathing': return False # Mobility exercises: never compound if mp.startswith('mobility') and 'plyometric' not in mp: return False # Arms pattern = isolation by definition if mp == 'arms': return False # Check isolation name keywords (more specific, check first) for kw in ISOLATION_NAME_KEYWORDS: if kw in name_lower: # Exception: "clean and press" contains "press" but is compound # We check isolation first, so if both match, isolation wins # But some should still be compound (e.g., "barbell curl to press") if any(ckw in name_lower for ckw in ['to press', 'and press', 'clean', 'squat']): return True return False # Check compound name keywords for kw in COMPOUND_NAME_KEYWORDS: if kw in name_lower: return True # Core-only exercises: not compound (load targets core, other joints position body) if mp.startswith('core') and 'carry' not in mp and 'upper' not in mp and 'lower' not in mp: return False # Machine exercises: depends on movement pattern if 'machine' in mp: # Machine compound patterns (leg press, lat pulldown, chest press) if any(p in mp for p in ['squat', 'push', 'pull', 'hip hinge']): return True return False # For remaining exercises, use joint count (3+ primary joints) joints = [j.strip() for j in (ex.joints_used or '').split(',') if j.strip()] # Discount wrist (grip stabilizer) when counting primary_joints = [j for j in joints if j != 'wrist'] return len(primary_joints) >= 3 # ── 2. difficulty_level ─────────────────────────────────────────────────── ADVANCED_KEYWORDS = [ 'pistol', 'muscle up', 'muscle-up', 'handstand', 'dragon flag', 'planche', 'l-sit', 'turkish get up', 'turkish get-up', 'snatch', 'clean and jerk', 'clean & jerk', 'windmill', 'scorpion', 'single-leg barbell', 'sissy squat', 'nordic', 'dragon', 'human flag', 'iron cross', 'front lever', 'back lever', 'skin the cat', 'one arm', 'one-arm', 'archer', 'typewriter', 'barbell overhead squat', ] ADVANCED_EXACT = { 'crow', 'crane', 'firefly', 'scorpion', 'wheel', 'king pigeon', 'flying pigeon', 'eight angle pose', } BEGINNER_PATTERNS = { 'massage', 'breathing', 'mobility,mobility - static', 'mobility - static', 'yoga,mobility,mobility - static', 'yoga,mobility - static', 'yoga,mobility - static,mobility', } BEGINNER_KEYWORDS = [ 'foam roll', 'lacrosse ball', 'stretch', 'child\'s pose', 'corpse', 'breathing', 'cat cow', 'cat-cow', 'band assisted', 'assisted dip', 'assisted chin', 'assisted pull', 'wall sit', ] INTERMEDIATE_FLOOR_KEYWORDS = [ 'barbell', 'kettlebell', 'trap bar', ] def derive_difficulty_level(ex): name_lower = (ex.name or '').lower() mp = (ex.movement_patterns or '').lower() equip = (ex.equipment_required or '').lower() # Check advanced keywords for kw in ADVANCED_KEYWORDS: if kw in name_lower: return 'advanced' # Word-boundary match for short keywords that could be substrings if re.search(r'\bl[ -]?sit\b', name_lower): return 'advanced' for kw in ADVANCED_EXACT: if name_lower == kw: return 'advanced' # Advanced: plyometric + true weighted resistance weight_equip = {'barbell', 'dumbbell', 'kettlebell', 'weighted vest', 'plate', 'medicine ball', 'ez bar', 'trap bar'} equip_lower = (ex.equipment_required or '').lower() if 'plyometric' in mp and any(we in equip_lower for we in weight_equip): return 'advanced' # Beginner categories if mp in BEGINNER_PATTERNS: return 'beginner' for kw in BEGINNER_KEYWORDS: if kw in name_lower: return 'beginner' # Massage is always beginner if 'massage' in mp or 'foam roll' in name_lower: return 'beginner' # Simple bodyweight exercises if not ex.is_weight and not equip and mp in ( 'core', 'core,core - anti-extension', 'core,core - rotational', 'core,core - anti-rotation', 'core,core - anti-lateral flexion', ): return 'beginner' # Bodyweight upper push/pull basics if not ex.is_weight and not equip: basic_bw = ['push-up', 'push up', 'body weight squat', 'bodyweight squat', 'air squat', 'glute bridge', 'bird dog', 'dead bug', 'clamshell', 'wall sit', 'plank', 'mountain climber', 'jumping jack', 'high knee', 'butt kick'] for kw in basic_bw: if kw in name_lower: return 'beginner' # Barbell/kettlebell movements default to intermediate minimum for kw in INTERMEDIATE_FLOOR_KEYWORDS: if kw in equip: return 'intermediate' # Machine exercises are generally beginner-intermediate if 'machine' in mp: return 'beginner' # Yoga: most poses are intermediate unless specifically beginner/advanced if mp.startswith('yoga'): beginner_yoga = ['child', 'corpse', 'mountain', 'warrior i', 'warrior 1', 'downward dog', 'cat cow', 'cobra', 'bridge', 'tree'] for kw in beginner_yoga: if kw in name_lower: return 'beginner' advanced_yoga = ['crow', 'crane', 'firefly', 'scorpion', 'wheel', 'headstand', 'handstand', 'king pigeon', 'eight angle', 'flying pigeon', 'peacock', 'side crow'] for kw in advanced_yoga: if kw in name_lower: return 'advanced' return 'intermediate' # Combat if 'combat' in mp: if 'spinning' in name_lower or 'flying' in name_lower: return 'advanced' return 'intermediate' # Cardio if mp == 'cardio/locomotion': return 'beginner' # Dynamic mobility if 'mobility - dynamic' in mp or 'mobility,mobility - dynamic' in mp: return 'beginner' # Balance exercises with weight are intermediate+ if 'balance' in mp: if ex.is_weight: return 'intermediate' return 'beginner' # Default return 'intermediate' # ── 3. exercise_tier ────────────────────────────────────────────────────── PRIMARY_MOVEMENT_PATTERNS = { 'lower push,lower push - squat', 'lower pull,lower pull - hip hinge', 'upper push,upper push - horizontal', 'upper push,upper push - vertical', 'upper pull,upper pull - horizonal', 'upper pull,upper pull - vertical', } # Secondary patterns: lunges, core carries, compound but not primary lift patterns SECONDARY_MOVEMENT_PATTERNS = { 'lower push,lower push - lunge', 'lower pull', # hip thrusts, glute bridges 'core,core - carry', 'core - carry,core', 'core,core - anti-lateral flexion,core - carry', 'core,core - carry,core - anti-lateral flexion', } # Non-training patterns: these are always accessory ACCESSORY_ONLY_PATTERNS = { 'yoga', 'massage', 'breathing', 'mobility,mobility - static', 'mobility - static', 'mobility,mobility - dynamic', 'mobility - dynamic', 'cardio/locomotion', 'balance', 'yoga,mobility,mobility - static', 'yoga,mobility - static', 'yoga,mobility - static,mobility', 'yoga,balance', 'yoga,breathing', 'yoga,mobility', 'yoga,massage,massage', 'yoga,mobility,core - rotational', 'yoga,mobility,mobility - dynamic', } def derive_exercise_tier(ex, is_compound): mp = (ex.movement_patterns or '').lower() # Non-training exercises are always accessory if mp in ACCESSORY_ONLY_PATTERNS or mp.startswith('yoga') or mp.startswith('massage'): return 'accessory' # Weighted compound exercises in primary movement patterns if is_compound and mp in PRIMARY_MOVEMENT_PATTERNS and ex.is_weight: return 'primary' # Compound exercises in primary patterns without weight (e.g., pull-ups) if is_compound and mp in PRIMARY_MOVEMENT_PATTERNS: name_lower = (ex.name or '').lower() # Pull-ups and chin-ups are primary even without is_weight if any(kw in name_lower for kw in ['pull-up', 'pull up', 'chin-up', 'chin up']): return 'primary' # Bodyweight squats, push-ups are secondary return 'secondary' # Compound exercises in secondary patterns if is_compound and (mp in SECONDARY_MOVEMENT_PATTERNS or 'lunge' in mp): return 'secondary' # Weighted compound exercises not in named patterns if is_compound and ex.is_weight: return 'secondary' # Non-weighted compound if is_compound: return 'secondary' # Isolation exercises if mp == 'arms': return 'accessory' # Core exercises (non-compound) if mp.startswith('core'): return 'accessory' # Plyometrics if 'plyometric' in mp: return 'secondary' # Combat if 'combat' in mp: return 'secondary' # Machine isolation if 'machine' in mp: # Machine compound movements if is_compound: return 'secondary' return 'accessory' return 'accessory' # ── 4. complexity_rating (1-5) ──────────────────────────────────────────── COMPLEXITY_5_KEYWORDS = [ 'snatch', 'clean and jerk', 'clean & jerk', 'turkish get up', 'turkish get-up', 'muscle up', 'muscle-up', 'pistol squat', 'handstand push', 'handstand walk', 'human flag', 'planche', 'front lever', 'back lever', 'iron cross', ] COMPLEXITY_4_KEYWORDS = [ 'overhead squat', 'windmill', 'get up', 'get-up', 'renegade', 'man maker', 'man-maker', 'thruster', 'archer', 'typewriter', 'one arm', 'one-arm', ] # Single-leg/arm is only complex for FREE WEIGHT exercises, not machines COMPLEXITY_4_UNILATERAL = [ 'single-leg', 'single leg', 'one leg', 'one-leg', ] # Olympic lift variants (complexity 4, not quite 5) COMPLEXITY_4_OLYMPIC = [ 'hang clean', 'power clean', 'clean pull', 'clean high pull', 'hang snatch', 'power snatch', ] COMPLEXITY_1_KEYWORDS = [ 'foam roll', 'lacrosse ball', 'stretch', 'breathing', 'massage', 'child\'s pose', 'corpse', 'mountain pose', ] COMPLEXITY_1_PATTERNS = { 'massage', 'breathing', 'mobility,mobility - static', 'mobility - static', 'yoga,mobility,mobility - static', } def derive_complexity_rating(ex, is_compound): name_lower = (ex.name or '').lower() mp = (ex.movement_patterns or '').lower() # Level 5: Olympic lifts, gymnastics movements for kw in COMPLEXITY_5_KEYWORDS: if kw in name_lower: return 5 # Level 4: Olympic lift variants for kw in COMPLEXITY_4_OLYMPIC: if kw in name_lower: return 4 # Level 4: Clean as distinct word (not "cleaning") if re.search(r'\bclean\b', name_lower): return 4 # Level 4: Complex compound movements for kw in COMPLEXITY_4_KEYWORDS: if kw in name_lower: return 4 # Level 4: Single-leg/arm for FREE WEIGHT only (not machines) if 'machine' not in mp: for kw in COMPLEXITY_4_UNILATERAL: if kw in name_lower: # Suspension trainers add complexity if 'suspension' in (ex.equipment_required or '').lower(): return 4 # Weighted single-leg = 4, bodyweight single-leg = 3 if ex.is_weight: return 4 return 3 # Level 1: Simple recovery/mobility for kw in COMPLEXITY_1_KEYWORDS: if kw in name_lower: return 1 if mp in COMPLEXITY_1_PATTERNS: return 1 # Massage if 'massage' in mp: return 1 # Level 1-2: Static holds / simple yoga if mp.startswith('yoga'): # Basic poses basic = ['warrior', 'tree', 'bridge', 'cobra', 'downward', 'chair', 'triangle', 'pigeon', 'cat', 'cow'] for kw in basic: if kw in name_lower: return 2 return 3 # Most yoga has moderate complexity # Dynamic mobility if 'mobility - dynamic' in mp: return 2 # Machine exercises: generally lower complexity (guided path) if 'machine' in mp: # Machine single-leg is slightly more complex if 'single' in name_lower: return 2 return 2 # Arms isolation — curls, extensions, raises, kickbacks if mp == 'arms': return 1 isolation_kw = ['curl', 'kickback', 'kick back', 'pushdown', 'push down', 'lateral raise', 'front raise', 'shrug', 'wrist'] for kw in isolation_kw: if kw in name_lower and is_compound is False: return 1 # Basic cardio if mp == 'cardio/locomotion': return 2 # Combat if 'combat' in mp: if 'combination' in name_lower or 'combo' in name_lower: return 4 return 3 # Plyometrics if 'plyometric' in mp: if 'box jump' in name_lower or 'depth' in name_lower: return 4 return 3 # Core if mp.startswith('core'): if 'carry' in mp: return 3 if is_compound: return 3 return 2 # Balance if 'balance' in mp: if ex.is_weight: return 4 return 3 # Compound weighted exercises if is_compound and ex.is_weight: # Count movement patterns as proxy for complexity patterns = [p.strip() for p in mp.split(',') if p.strip()] if len(patterns) >= 4: return 4 return 3 # Compound bodyweight if is_compound: return 3 # Default return 2 # ── 5. hr_elevation_rating (1-10) ──────────────────────────────────────── # High-HR keywords (regardless of movement pattern) HIGH_HR_KEYWORDS = [ 'burpee', 'man maker', 'man-maker', 'thruster', 'battle rope', 'slam', 'sprint', ] MODERATE_HR_KEYWORDS = [ 'mountain climber', 'bear crawl', 'inchworm', 'walkout', ] def derive_hr_elevation(ex, is_compound): mp = (ex.movement_patterns or '').lower() name_lower = (ex.name or '').lower() # Breathing / massage: minimal if mp == 'breathing' or 'massage' in mp or 'foam roll' in name_lower: return 1 # Static mobility / static stretches if 'mobility - static' in mp and 'dynamic' not in mp: return 1 # High HR keywords (check before movement pattern defaults) for kw in HIGH_HR_KEYWORDS: if kw in name_lower: return 9 # Moderate-high HR keywords for kw in MODERATE_HR_KEYWORDS: if kw in name_lower: return 7 # Yoga: low unless vigorous flow if mp.startswith('yoga'): vigorous = ['chaturanga', 'sun salutation', 'vinyasa', 'chair', 'warrior iii', 'crow', 'crane', 'handstand', 'headstand'] for kw in vigorous: if kw in name_lower: return 4 return 2 # Plyometric + cardio = very high if 'plyometric' in mp and 'cardio' in mp: return 9 # Plyometric = high if 'plyometric' in mp: if 'burpee' in name_lower or 'man maker' in name_lower: return 9 return 8 # Cardio/locomotion if mp == 'cardio/locomotion': high_cardio = ['bike', 'rower', 'elliptical', 'treadmill', 'stair', 'sprint', 'run', 'jog'] for kw in high_cardio: if kw in name_lower: return 8 return 7 # Combat: moderate-high if 'combat' in mp: if 'burnout' in name_lower: return 8 return 6 # Dynamic mobility if 'mobility - dynamic' in mp: return 3 # Balance (low impact) if mp == 'balance' or (mp.startswith('balance') and not is_compound): return 2 # Lower body compound (large muscle mass = higher HR) lower_compound = ['lower push,lower push - squat', 'lower pull,lower pull - hip hinge'] if mp in lower_compound and is_compound: if ex.is_weight: return 7 return 6 # Lunges if 'lunge' in mp: if ex.is_weight: return 7 return 6 # Lower pull (hip thrusts, glute bridges) if mp == 'lower pull': if ex.is_weight: return 5 return 4 # Upper body compound upper_compound = [ 'upper push,upper push - horizontal', 'upper push,upper push - vertical', 'upper pull,upper pull - horizonal', 'upper pull,upper pull - vertical', ] if mp in upper_compound and is_compound: if ex.is_weight: return 5 return 4 # Core if mp.startswith('core'): if 'carry' in mp: return 5 # Loaded carries elevate HR return 3 # Arms (isolation, small muscle mass) if mp == 'arms': return 3 # Machine exercises if 'machine' in mp: if is_compound: return 5 return 3 # Multi-pattern compound exercises (full body) patterns = [p.strip() for p in mp.split(',') if p.strip()] if len(patterns) >= 3 and is_compound: # Check if it spans upper + lower body = true full body has_upper = any('upper' in p or 'pull' in p for p in patterns) has_lower = any('lower' in p or 'hip' in p or 'squat' in p or 'lunge' in p for p in patterns) if has_upper and has_lower: return 8 # Full body complex return 7 # Default compound if is_compound: return 5 return 4 # ── 6. impact_level (none/low/medium/high) ─────────────────────────────── HIGH_IMPACT_KEYWORDS = [ 'jump', 'hop', 'bound', 'skip', 'box jump', 'depth jump', 'tuck jump', 'squat jump', 'split jump', 'broad jump', 'burpee', 'slam', 'sprint', ] def derive_impact_level(ex): name_lower = (ex.name or '').lower() mp = (ex.movement_patterns or '').lower() # High impact: plyometrics, jumping exercises if 'plyometric' in mp: return 'high' for kw in HIGH_IMPACT_KEYWORDS: if kw in name_lower: return 'high' # Medium impact: fast ground-contact exercises (mountain climbers, bear crawls) medium_impact_kw = ['mountain climber', 'bear crawl', 'battle rope'] for kw in medium_impact_kw: if kw in name_lower: return 'medium' # No impact: seated, lying, machine, yoga, massage, breathing, static mobility no_impact_patterns = ['massage', 'breathing', 'mobility - static'] for nip in no_impact_patterns: if nip in mp: return 'none' if mp.startswith('yoga'): # Standing yoga poses have low impact standing = ['warrior', 'tree', 'chair', 'triangle', 'mountain', 'half moon', 'dancer', 'eagle'] for kw in standing: if kw in name_lower: return 'low' return 'none' if 'machine' in mp: return 'none' # No impact keywords in exercise name no_impact_name_kw = ['seated', 'bench', 'lying', 'floor press', 'floor fly', 'foam roll', 'lacrosse', 'incline bench', 'preacher', 'prone', 'supine'] for kw in no_impact_name_kw: if kw in name_lower: return 'none' # Combat: punches/kicks are medium impact if 'combat' in mp: if 'kick' in name_lower or 'knee' in name_lower: return 'medium' return 'low' # Cardio with ground contact if mp == 'cardio/locomotion': low_impact_cardio = ['bike', 'elliptical', 'rower', 'stair climber', 'swimming'] for kw in low_impact_cardio: if kw in name_lower: return 'none' if 'run' in name_lower or 'jog' in name_lower or 'sprint' in name_lower: return 'medium' return 'low' # Dynamic mobility: low impact if 'mobility - dynamic' in mp: return 'low' # Standing exercises with weight: low impact (feet planted) if ex.is_weight: return 'low' # Core exercises: mostly no impact if mp.startswith('core'): if 'carry' in mp: return 'low' # Walking with load return 'none' # Arms: no impact if mp == 'arms': return 'none' # Balance if 'balance' in mp: return 'low' # Standing bodyweight: low return 'low' # ── 7. stretch_position (lengthened/mid/shortened/None) ─────────────────── # Based on where peak muscle tension occurs in the range of motion. # Only set for resistance exercises; null for yoga/mobility/cardio/massage/breathing. # Lengthened: peak tension when muscle is stretched LENGTHENED_KEYWORDS = [ 'rdl', 'romanian deadlift', 'stiff leg', 'stiff-leg', 'good morning', 'deficit deadlift', 'deficit', 'incline curl', 'incline dumbbell curl', 'incline bicep', 'overhead extension', 'overhead tricep', 'skull crusher', 'skullcrusher', 'french press', 'pullover', 'fly', 'flye', 'cable fly', 'preacher curl', 'spider curl', 'sissy squat', 'nordic', 'hanging leg raise', 'straight arm pulldown', 'dumbbell pull over', 'dumbbell pullover', ] # Shortened: peak tension when muscle is contracted/shortened SHORTENED_KEYWORDS = [ 'hip thrust', 'glute bridge', 'kickback', 'kick back', 'concentration curl', 'cable curl', 'lateral raise', 'front raise', 'rear delt', 'reverse fly', 'reverse flye', 'face pull', 'calf raise', 'heel raise', 'leg curl', 'hamstring curl', 'leg extension', 'shrug', 'upright row', 'cable crossover', 'pec deck', 'squeeze press', 'superman', 'skydiver', ] # Null: non-resistance exercises NULL_STRETCH_PATTERNS = [ 'yoga', 'massage', 'breathing', 'mobility', 'cardio/locomotion', 'balance', 'combat', ] def derive_stretch_position(ex, is_compound): name_lower = (ex.name or '').lower() mp = (ex.movement_patterns or '').lower() # Non-resistance exercises: null for pattern in NULL_STRETCH_PATTERNS: if mp.startswith(pattern) or mp == pattern: return None # Dynamic mobility: null if 'mobility' in mp and 'plyometric' not in mp: return None # Plyometrics: null (explosive, not about tension position) if mp == 'plyometric' or mp.startswith('plyometric'): # Exception: weighted plyometric compound movements if not ex.is_weight: return None # Check lengthened keywords for kw in LENGTHENED_KEYWORDS: if kw in name_lower: return 'lengthened' # Check shortened keywords for kw in SHORTENED_KEYWORDS: if kw in name_lower: return 'shortened' # Movement pattern-based defaults for resistance exercises: # Based on biomechanical research — peak muscle tension position. # Squats: lengthened — peak tension at "the hole" (bottom), where quads/glutes # are at greatest length. Sticking point is just above parallel. if 'squat' in mp and 'squat' in name_lower: return 'lengthened' # Deadlifts (standard): lengthened — hardest off the floor where posterior chain # is maximally stretched. At lockout, tension is minimal. if 'hip hinge' in mp: if 'deadlift' in name_lower: return 'lengthened' # KB swings, pulls: also lengthened at bottom of hinge return 'lengthened' # Bench press / push-ups: mid-range — sticking point ~halfway up, load shared # between pec, anterior delt, and triceps through the ROM. if 'upper push - horizontal' in mp: if 'fly' in name_lower or 'flye' in name_lower: return 'lengthened' # Pecs peak-loaded at open/stretched position return 'mid' # Overhead press: mid-range — sticking point at ~forehead height. if 'upper push - vertical' in mp: return 'mid' # Rows: shortened — back muscles (lats, rhomboids, mid-traps) peak-loaded at top # with scapulae fully retracted. At bottom (arms extended), load is minimal. if 'upper pull - horizonal' in mp: return 'shortened' # Pull-ups / pulldowns: lengthened — lats maximally loaded at dead hang where # they're at full stretch. Initiating the pull from hang is the hardest part. # Research shows bottom half produces superior lat hypertrophy. if 'upper pull - vertical' in mp: if 'straight arm' in name_lower: return 'lengthened' return 'lengthened' # Lunges: lengthened (deep stretch on hip flexors/quads at bottom) if 'lunge' in mp: return 'lengthened' # Arms isolation if mp == 'arms': if 'curl' in name_lower: if 'incline' in name_lower or 'preacher' in name_lower or 'spider' in name_lower: return 'lengthened' if 'concentration' in name_lower or 'cable' in name_lower: return 'shortened' return 'mid' if 'extension' in name_lower or 'skull' in name_lower: return 'lengthened' if 'kickback' in name_lower or 'pushdown' in name_lower or 'push down' in name_lower: return 'shortened' return 'mid' # Core exercises if mp.startswith('core'): if 'plank' in name_lower or 'hold' in name_lower: return 'mid' # Isometric at mid-range position if 'crunch' in name_lower or 'sit-up' in name_lower or 'sit up' in name_lower: return 'shortened' # Abs peak-loaded at full flexion if 'v-up' in name_lower or 'v up' in name_lower or 'bicycle' in name_lower: return 'shortened' # Same as crunches if 'superman' in name_lower or 'skydiver' in name_lower: return 'shortened' # Back extensors peak-loaded at full extension if 'roll out' in name_lower or 'rollout' in name_lower or 'ab wheel' in name_lower: return 'lengthened' # Core stretched at full extension return 'mid' # Carries, rotational, anti-rotation default to mid # Machine exercises if 'machine' in mp: if 'leg extension' in name_lower: return 'shortened' # Quad peak-loaded at full knee extension (contracted) if 'leg curl' in name_lower: return 'shortened' # Hamstrings peak-loaded at full flexion if 'lat pull' in name_lower: return 'lengthened' # Lats peak-loaded at top (stretched, like pull-ups) if 'chest press' in name_lower: return 'mid' if 'pec deck' in name_lower or 'fly' in name_lower: return 'lengthened' # Pecs peak-loaded at open/stretched position if 'calf raise' in name_lower: return 'shortened' if 'row' in name_lower: return 'shortened' # Same as barbell rows return 'mid' # Lower pull general (hip thrusts, glute bridges) if mp == 'lower pull': if 'thrust' in name_lower or 'bridge' in name_lower: return 'shortened' return 'mid' # Default for resistance exercises with weight if ex.is_weight and is_compound: return 'mid' return None # ── 8. progression_of ──────────────────────────────────────────────────── # Only set for clear, verifiable progressions. # We match by name patterns — e.g., "Band Assisted Pull-Up" → "Pull-Up" PROGRESSION_MAP = { 'band assisted pull-up (from foot)': 'Pull-Up', 'band assisted pull-up (from knee)': 'Pull-Up', 'band assisted pull up (from foot)': 'Pull-Up', 'band assisted pull up (from knee)': 'Pull-Up', 'band assisted chin-up (from foot)': 'Chin-Up', 'band assisted chin-up (from knee)': 'Chin-Up', 'band assisted chin up (from foot)': 'Chin-Up', 'band assisted chin up (from knee)': 'Chin-Up', 'assisted pull up': 'Pull-Up', 'assisted chin up': 'Chin-Up', 'assisted dip': 'Dip', 'barbell back squat to bench': 'Barbell Back Squat', 'banded push up': 'Push-Up', 'banded push-up': 'Push-Up', 'incline push-up': 'Push-Up', 'incline push up': 'Push-Up', 'knee push-up': 'Push-Up', 'knee push up': 'Push-Up', } class Command(BaseCommand): help = 'Populate the 8 new Exercise fields using rule-based derivation' def add_arguments(self, parser): parser.add_argument( '--dry-run', action='store_true', help='Show changes without applying', ) parser.add_argument( '--verbose', action='store_true', help='Print every exercise update', ) parser.add_argument( '--classification-strategy', choices=['rules', 'regex'], default='rules', help='Classification strategy: "rules" (default) uses movement-pattern logic, ' '"regex" uses name-based regex patterns from classify_exercises.', ) def handle(self, *args, **options): dry_run = options['dry_run'] verbose = options['verbose'] strategy = options.get('classification_strategy', 'rules') exercises = list(Exercise.objects.all()) total = len(exercises) updated = 0 stats = { 'is_compound': {'True': 0, 'False': 0}, 'difficulty_level': {}, 'exercise_tier': {}, 'complexity_rating': {}, 'hr_elevation_rating': {}, 'impact_level': {}, 'stretch_position': {}, } # Pre-fetch all exercises for progression_of lookups name_to_exercise = {} for ex in exercises: if ex.name: name_to_exercise[ex.name] = ex # Collect exercises to bulk_update instead of saving one at a time exercises_to_update = [] fields_to_update = set() for ex in exercises: if strategy == 'regex': from generator.management.commands.classify_exercises import classify_exercise difficulty, complexity = classify_exercise(ex) # Use rules-based derivation for the remaining fields is_compound = derive_is_compound(ex) tier = derive_exercise_tier(ex, is_compound) hr = derive_hr_elevation(ex, is_compound) impact = derive_impact_level(ex) stretch = derive_stretch_position(ex, is_compound) else: is_compound = derive_is_compound(ex) difficulty = derive_difficulty_level(ex) tier = derive_exercise_tier(ex, is_compound) complexity = derive_complexity_rating(ex, is_compound) hr = derive_hr_elevation(ex, is_compound) impact = derive_impact_level(ex) stretch = derive_stretch_position(ex, is_compound) # Progression lookup progression_target = None name_lower = (ex.name or '').lower() if name_lower in PROGRESSION_MAP: target_name = PROGRESSION_MAP[name_lower] progression_target = name_to_exercise.get(target_name) # Track stats stats['is_compound'][str(is_compound)] = stats['is_compound'].get(str(is_compound), 0) + 1 stats['difficulty_level'][difficulty] = stats['difficulty_level'].get(difficulty, 0) + 1 stats['exercise_tier'][tier] = stats['exercise_tier'].get(tier, 0) + 1 stats['complexity_rating'][str(complexity)] = stats['complexity_rating'].get(str(complexity), 0) + 1 stats['hr_elevation_rating'][str(hr)] = stats['hr_elevation_rating'].get(str(hr), 0) + 1 stats['impact_level'][impact] = stats['impact_level'].get(impact, 0) + 1 stats['stretch_position'][str(stretch)] = stats['stretch_position'].get(str(stretch), 0) + 1 # Check if anything changed changes = [] if ex.is_compound != is_compound: changes.append(('is_compound', ex.is_compound, is_compound)) if ex.difficulty_level != difficulty: changes.append(('difficulty_level', ex.difficulty_level, difficulty)) if ex.exercise_tier != tier: changes.append(('exercise_tier', ex.exercise_tier, tier)) if ex.complexity_rating != complexity: changes.append(('complexity_rating', ex.complexity_rating, complexity)) if ex.hr_elevation_rating != hr: changes.append(('hr_elevation_rating', ex.hr_elevation_rating, hr)) if ex.impact_level != impact: changes.append(('impact_level', ex.impact_level, impact)) if ex.stretch_position != stretch: changes.append(('stretch_position', ex.stretch_position, stretch)) if ex.progression_of != progression_target: changes.append(('progression_of', ex.progression_of, progression_target)) if changes: if not dry_run: ex.is_compound = is_compound ex.difficulty_level = difficulty ex.exercise_tier = tier ex.complexity_rating = complexity ex.hr_elevation_rating = hr ex.impact_level = impact ex.stretch_position = stretch if progression_target: ex.progression_of = progression_target exercises_to_update.append(ex) for field, _, _ in changes: fields_to_update.add(field) updated += 1 if verbose: prefix = '[DRY RUN] ' if dry_run else '' self.stdout.write(f'{prefix}{ex.name}:') for field, old, new in changes: self.stdout.write(f' {field}: {old} -> {new}') # Bulk update all modified exercises in batches if exercises_to_update and not dry_run: Exercise.objects.bulk_update( exercises_to_update, list(fields_to_update), batch_size=500 ) # Fix #11: Correct is_weight=True on known non-weight exercises NON_WEIGHT_OVERRIDES = ['wall sit', 'agility ladder', 'plank', 'dead hang', 'l sit'] weight_fixed = 0 for pattern in NON_WEIGHT_OVERRIDES: qs = Exercise.objects.filter(name__icontains=pattern, is_weight=True) # Avoid fixing exercises that genuinely use weight (e.g., "weighted plank") qs = qs.exclude(name__icontains='weighted') count = qs.count() if count > 0: if not dry_run: qs.update(is_weight=False) weight_fixed += count if verbose: prefix = '[DRY RUN] ' if dry_run else '' self.stdout.write(f'{prefix}Fixed is_weight for {count} "{pattern}" exercises') if weight_fixed: weight_action = 'Would fix' if dry_run else 'Fixed' self.stdout.write(f'\n{weight_action} is_weight on {weight_fixed} non-weight exercises') # Print summary action = 'Would update' if dry_run else 'Updated' self.stdout.write(f'\n{action} {updated}/{total} exercises\n') self.stdout.write('\n=== Distribution Summary ===') for field, dist in stats.items(): self.stdout.write(f'\n{field}:') for val, count in sorted(dist.items(), key=lambda x: -x[1]): pct = count / total * 100 self.stdout.write(f' {val}: {count} ({pct:.1f}%)')