workout generator audit: rules engine, structure rules, split patterns, injury UX, metadata cleanup
- Add rules_engine.py with quantitative rules for all 8 workout types - Add quality gate retry loop in generate_single_workout() - Expand calibrate_structure_rules to all 120 combinations (8 types × 5 goals × 3 sections) - Wire WeeklySplitPattern DB records into _pick_weekly_split() - Enforce movement patterns from WorkoutStructureRule in exercise selection - Add straight-set strength support (single main lift, 4-6 rounds) - Add modality consistency check for duration-dominant workout types - Add InjuryStep component to onboarding and preferences - Add sibling exercise exclusion in regenerate and preview_day endpoints - Display generator warnings on dashboard - Expand fix_rep_durations, fix_exercise_flags, fix_movement_pattern_typo - Add audit_exercise_data and check_rules_drift management commands - Add Next.js frontend with dashboard, onboarding, preferences, history pages - Add generator app with ML-powered workout generation pipeline - 96 new tests across 7 test modules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
generator/management/commands/__init__.py
Normal file
0
generator/management/commands/__init__.py
Normal file
115
generator/management/commands/analyze_workouts.py
Normal file
115
generator/management/commands/analyze_workouts.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Django management command to analyze existing workouts and extract ML patterns.
|
||||
|
||||
Usage:
|
||||
python manage.py analyze_workouts
|
||||
python manage.py analyze_workouts --dry-run
|
||||
python manage.py analyze_workouts --verbosity 2
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from generator.services.workout_analyzer import WorkoutAnalyzer
|
||||
from generator.models import (
|
||||
MuscleGroupSplit,
|
||||
MovementPatternOrder,
|
||||
WeeklySplitPattern,
|
||||
WorkoutStructureRule,
|
||||
WorkoutType,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
'Analyze existing workouts in the database and extract ML patterns '
|
||||
'into WorkoutType, MuscleGroupSplit, WeeklySplitPattern, '
|
||||
'WorkoutStructureRule, and MovementPatternOrder models.'
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='Print what would be done without writing to the database.',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options.get('dry_run', False)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'DRY RUN mode - no changes will be written to the database.\n'
|
||||
'Remove --dry-run to actually run the analysis.\n'
|
||||
))
|
||||
self._print_current_state()
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
analyzer = WorkoutAnalyzer()
|
||||
analyzer.analyze()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
self.stdout.write('')
|
||||
self._print_current_state()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\nAnalysis complete in {elapsed:.2f}s!'
|
||||
))
|
||||
|
||||
def _print_current_state(self):
|
||||
"""Print a summary of the current state of all ML pattern models."""
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('\nCurrent ML Pattern Model State:'))
|
||||
self.stdout.write(f' WorkoutType: {WorkoutType.objects.count()} records')
|
||||
self.stdout.write(f' MuscleGroupSplit: {MuscleGroupSplit.objects.count()} records')
|
||||
self.stdout.write(f' WeeklySplitPattern: {WeeklySplitPattern.objects.count()} records')
|
||||
self.stdout.write(f' WorkoutStructureRule: {WorkoutStructureRule.objects.count()} records')
|
||||
self.stdout.write(f' MovementPatternOrder: {MovementPatternOrder.objects.count()} records')
|
||||
|
||||
# List WorkoutTypes
|
||||
wts = WorkoutType.objects.all().order_by('name')
|
||||
if wts.exists():
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('\n WorkoutTypes:'))
|
||||
for wt in wts:
|
||||
self.stdout.write(
|
||||
f' - {wt.name}: reps {wt.rep_range_min}-{wt.rep_range_max}, '
|
||||
f'rounds {wt.round_range_min}-{wt.round_range_max}, '
|
||||
f'intensity={wt.typical_intensity}'
|
||||
)
|
||||
|
||||
# List MuscleGroupSplits
|
||||
splits = MuscleGroupSplit.objects.all().order_by('-frequency')
|
||||
if splits.exists():
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('\n Top MuscleGroupSplits:'))
|
||||
for s in splits[:10]:
|
||||
muscles_str = ', '.join(s.muscle_names[:5])
|
||||
if len(s.muscle_names) > 5:
|
||||
muscles_str += f' (+{len(s.muscle_names) - 5} more)'
|
||||
self.stdout.write(
|
||||
f' - [{s.split_type}] {s.label} | '
|
||||
f'freq={s.frequency}, ex_count={s.typical_exercise_count} | '
|
||||
f'{muscles_str}'
|
||||
)
|
||||
|
||||
# List WeeklySplitPatterns
|
||||
patterns = WeeklySplitPattern.objects.all().order_by('-frequency')
|
||||
if patterns.exists():
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('\n Top WeeklySplitPatterns:'))
|
||||
for p in patterns[:10]:
|
||||
self.stdout.write(
|
||||
f' - {p.days_per_week}-day: {p.pattern_labels} '
|
||||
f'(freq={p.frequency}, rest_days={p.rest_day_positions})'
|
||||
)
|
||||
|
||||
# List WorkoutStructureRule goal distribution
|
||||
rules = WorkoutStructureRule.objects.all()
|
||||
if rules.exists():
|
||||
from collections import Counter
|
||||
goal_counts = Counter(rules.values_list('goal_type', flat=True))
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('\n WorkoutStructureRule by goal:'))
|
||||
for goal, count in sorted(goal_counts.items()):
|
||||
self.stdout.write(f' - {goal}: {count} rules')
|
||||
202
generator/management/commands/audit_exercise_data.py
Normal file
202
generator/management/commands/audit_exercise_data.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Comprehensive audit of exercise data quality.
|
||||
|
||||
Checks for:
|
||||
1. Null estimated_rep_duration on rep-based exercises
|
||||
2. is_weight false positives (bodyweight exercises marked as weighted)
|
||||
3. Exercises with no muscle assignments
|
||||
4. "horizonal" typo in movement_patterns
|
||||
5. Null metadata fields summary (difficulty_level, exercise_tier, etc.)
|
||||
|
||||
Exits with code 1 if any CRITICAL issues are found.
|
||||
|
||||
Usage:
|
||||
python manage.py audit_exercise_data
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from exercise.models import Exercise
|
||||
from muscle.models import ExerciseMuscle
|
||||
|
||||
|
||||
# Same bodyweight patterns as fix_exercise_flags for consistency
|
||||
BODYWEIGHT_PATTERNS = [
|
||||
r'\bwall sit\b',
|
||||
r'\bplank\b',
|
||||
r'\bmountain climber\b',
|
||||
r'\bburpee\b',
|
||||
r'\bpush ?up\b',
|
||||
r'\bpushup\b',
|
||||
r'\bpull ?up\b',
|
||||
r'\bpullup\b',
|
||||
r'\bchin ?up\b',
|
||||
r'\bchinup\b',
|
||||
r'\bdips?\b',
|
||||
r'\bpike\b',
|
||||
r'\bhandstand\b',
|
||||
r'\bl sit\b',
|
||||
r'\bv sit\b',
|
||||
r'\bhollow\b',
|
||||
r'\bsuperman\b',
|
||||
r'\bbird dog\b',
|
||||
r'\bdead bug\b',
|
||||
r'\bbear crawl\b',
|
||||
r'\bcrab walk\b',
|
||||
r'\binchworm\b',
|
||||
r'\bjumping jack\b',
|
||||
r'\bhigh knee\b',
|
||||
r'\bbutt kick\b',
|
||||
r'\bskater\b',
|
||||
r'\blunge jump\b',
|
||||
r'\bjump lunge\b',
|
||||
r'\bsquat jump\b',
|
||||
r'\bjump squat\b',
|
||||
r'\bbox jump\b',
|
||||
r'\btuck jump\b',
|
||||
r'\bbroad jump\b',
|
||||
r'\bsprinter\b',
|
||||
r'\bagility ladder\b',
|
||||
r'\bbody ?weight\b',
|
||||
r'\bbodyweight\b',
|
||||
r'\bcalisthenics?\b',
|
||||
r'\bflutter kick\b',
|
||||
r'\bleg raise\b',
|
||||
r'\bsit ?up\b',
|
||||
r'\bcrunch\b',
|
||||
r'\bstretch\b',
|
||||
r'\byoga\b',
|
||||
r'\bfoam roll\b',
|
||||
r'\bjump rope\b',
|
||||
r'\bspider crawl\b',
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Audit exercise data quality -- exits 1 if critical issues found'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
issues = []
|
||||
|
||||
# 1. Null estimated_rep_duration (excluding duration-only exercises)
|
||||
null_duration = Exercise.objects.filter(
|
||||
estimated_rep_duration__isnull=True,
|
||||
is_reps=True,
|
||||
).exclude(
|
||||
is_duration=True, is_reps=False
|
||||
).count()
|
||||
if null_duration > 0:
|
||||
issues.append(
|
||||
f"CRITICAL: {null_duration} rep-based exercises have null estimated_rep_duration"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'OK: All rep-based exercises have estimated_rep_duration'
|
||||
))
|
||||
|
||||
# 2. is_weight false positives -- bodyweight exercises marked as weighted
|
||||
weight_false_positives = 0
|
||||
weighted_exercises = Exercise.objects.filter(is_weight=True)
|
||||
for ex in weighted_exercises:
|
||||
if not ex.name:
|
||||
continue
|
||||
name_lower = ex.name.lower()
|
||||
if any(re.search(pat, name_lower) for pat in BODYWEIGHT_PATTERNS):
|
||||
weight_false_positives += 1
|
||||
|
||||
if weight_false_positives > 0:
|
||||
issues.append(
|
||||
f"WARNING: {weight_false_positives} bodyweight exercises still have is_weight=True"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'OK: No bodyweight exercises incorrectly marked as weighted'
|
||||
))
|
||||
|
||||
# 3. Exercises with no muscles
|
||||
exercises_with_muscles = set(
|
||||
ExerciseMuscle.objects.values_list('exercise_id', flat=True).distinct()
|
||||
)
|
||||
exercises_no_muscles = Exercise.objects.exclude(
|
||||
pk__in=exercises_with_muscles
|
||||
).count()
|
||||
if exercises_no_muscles > 0:
|
||||
issues.append(
|
||||
f"CRITICAL: {exercises_no_muscles} exercises have no muscle assignments"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'OK: All exercises have muscle assignments'
|
||||
))
|
||||
|
||||
# 4. "horizonal" typo
|
||||
typo_count = Exercise.objects.filter(
|
||||
movement_patterns__icontains='horizonal'
|
||||
).count()
|
||||
if typo_count > 0:
|
||||
issues.append(
|
||||
f'WARNING: {typo_count} exercises have "horizonal" typo in movement_patterns'
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'OK: No "horizonal" typos in movement_patterns'
|
||||
))
|
||||
|
||||
# 5. Null metadata fields summary
|
||||
total = Exercise.objects.count()
|
||||
if total > 0:
|
||||
# Base field always present
|
||||
metadata_fields = {
|
||||
'movement_patterns': Exercise.objects.filter(
|
||||
movement_patterns__isnull=True
|
||||
).count() + Exercise.objects.filter(movement_patterns='').count(),
|
||||
}
|
||||
|
||||
# Optional fields that may not exist in all environments
|
||||
optional_fields = ['difficulty_level', 'exercise_tier']
|
||||
for field_name in optional_fields:
|
||||
if hasattr(Exercise, field_name):
|
||||
try:
|
||||
null_count = Exercise.objects.filter(
|
||||
**{f'{field_name}__isnull': True}
|
||||
).count() + Exercise.objects.filter(
|
||||
**{field_name: ''}
|
||||
).count()
|
||||
metadata_fields[field_name] = null_count
|
||||
except Exception:
|
||||
pass # Field doesn't exist in DB schema yet
|
||||
|
||||
self.stdout.write(f'\nMetadata coverage ({total} total exercises):')
|
||||
for field, null_count in metadata_fields.items():
|
||||
filled = total - null_count
|
||||
pct = (filled / total) * 100
|
||||
self.stdout.write(f' {field}: {filled}/{total} ({pct:.1f}%)')
|
||||
if null_count > total * 0.5: # More than 50% missing
|
||||
issues.append(
|
||||
f"WARNING: {field} is missing on {null_count}/{total} exercises ({100-pct:.1f}%)"
|
||||
)
|
||||
|
||||
# Report
|
||||
self.stdout.write('') # blank line
|
||||
if not issues:
|
||||
self.stdout.write(self.style.SUCCESS('All exercise data checks passed!'))
|
||||
else:
|
||||
for issue in issues:
|
||||
if issue.startswith('CRITICAL'):
|
||||
self.stdout.write(self.style.ERROR(issue))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(issue))
|
||||
|
||||
critical = [i for i in issues if i.startswith('CRITICAL')]
|
||||
if critical:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f'\n{len(critical)} critical issue(s) found. Run fix commands to resolve.'
|
||||
))
|
||||
sys.exit(1)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'\n{len(issues)} non-critical warning(s) found.'
|
||||
))
|
||||
1375
generator/management/commands/calibrate_structure_rules.py
Normal file
1375
generator/management/commands/calibrate_structure_rules.py
Normal file
File diff suppressed because it is too large
Load Diff
105
generator/management/commands/check_rules_drift.py
Normal file
105
generator/management/commands/check_rules_drift.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
CI management command: check for drift between workout_research.md
|
||||
calibration values and WorkoutType DB records.
|
||||
|
||||
Usage:
|
||||
python manage.py check_rules_drift
|
||||
python manage.py check_rules_drift --verbosity 2
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from generator.models import WorkoutType
|
||||
from generator.rules_engine import DB_CALIBRATION
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
'Check for drift between research doc calibration values '
|
||||
'and WorkoutType DB records. Exits 1 if mismatches found.'
|
||||
)
|
||||
|
||||
# Fields to compare between DB_CALIBRATION and WorkoutType model
|
||||
FIELDS_TO_CHECK = [
|
||||
'duration_bias',
|
||||
'typical_rest_between_sets',
|
||||
'typical_intensity',
|
||||
'rep_range_min',
|
||||
'rep_range_max',
|
||||
'round_range_min',
|
||||
'round_range_max',
|
||||
'superset_size_min',
|
||||
'superset_size_max',
|
||||
]
|
||||
|
||||
def handle(self, *args, **options):
|
||||
verbosity = options.get('verbosity', 1)
|
||||
mismatches = []
|
||||
missing_in_db = []
|
||||
checked = 0
|
||||
|
||||
for type_name, expected_values in DB_CALIBRATION.items():
|
||||
try:
|
||||
wt = WorkoutType.objects.get(name=type_name)
|
||||
except WorkoutType.DoesNotExist:
|
||||
missing_in_db.append(type_name)
|
||||
continue
|
||||
|
||||
for field_name in self.FIELDS_TO_CHECK:
|
||||
if field_name not in expected_values:
|
||||
continue
|
||||
|
||||
expected = expected_values[field_name]
|
||||
actual = getattr(wt, field_name, None)
|
||||
checked += 1
|
||||
|
||||
if actual != expected:
|
||||
mismatches.append({
|
||||
'type': type_name,
|
||||
'field': field_name,
|
||||
'expected': expected,
|
||||
'actual': actual,
|
||||
})
|
||||
elif verbosity >= 2:
|
||||
self.stdout.write(
|
||||
f" OK {type_name}.{field_name} = {actual}"
|
||||
)
|
||||
|
||||
# Report results
|
||||
self.stdout.write('')
|
||||
self.stdout.write(f'Checked {checked} field(s) across {len(DB_CALIBRATION)} workout types.')
|
||||
self.stdout.write('')
|
||||
|
||||
if missing_in_db:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'Missing from DB ({len(missing_in_db)}):'
|
||||
))
|
||||
for name in missing_in_db:
|
||||
self.stdout.write(f' - {name}')
|
||||
self.stdout.write('')
|
||||
|
||||
if mismatches:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f'DRIFT DETECTED: {len(mismatches)} mismatch(es)'
|
||||
))
|
||||
self.stdout.write('')
|
||||
header = f'{"Workout Type":<35} {"Field":<30} {"Expected":<15} {"Actual":<15}'
|
||||
self.stdout.write(header)
|
||||
self.stdout.write('-' * len(header))
|
||||
for m in mismatches:
|
||||
self.stdout.write(
|
||||
f'{m["type"]:<35} {m["field"]:<30} '
|
||||
f'{str(m["expected"]):<15} {str(m["actual"]):<15}'
|
||||
)
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'To fix: update WorkoutType records in the DB or '
|
||||
'update DB_CALIBRATION in generator/rules_engine.py.'
|
||||
))
|
||||
sys.exit(1)
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'No drift detected. DB values match research calibration.'
|
||||
))
|
||||
798
generator/management/commands/classify_exercises.py
Normal file
798
generator/management/commands/classify_exercises.py
Normal file
@@ -0,0 +1,798 @@
|
||||
"""
|
||||
Classifies all Exercise records by difficulty_level and complexity_rating
|
||||
using name-based keyword matching and movement_patterns fallback rules.
|
||||
|
||||
difficulty_level: 'beginner', 'intermediate', 'advanced'
|
||||
complexity_rating: 1-5 integer scale
|
||||
|
||||
Classification strategy (applied in order, first match wins):
|
||||
|
||||
1. **Name-based keyword rules** -- regex patterns matched against exercise.name
|
||||
- ADVANCED_NAME_PATTERNS -> 'advanced'
|
||||
- BEGINNER_NAME_PATTERNS -> 'beginner'
|
||||
- Unmatched -> 'intermediate' (default)
|
||||
|
||||
2. **Name-based complexity rules** -- regex patterns matched against exercise.name
|
||||
- COMPLEXITY_5_PATTERNS -> 5 (Olympic lifts, advanced gymnastics)
|
||||
- COMPLEXITY_4_PATTERNS -> 4 (complex multi-joint, unilateral loaded)
|
||||
- COMPLEXITY_1_PATTERNS -> 1 (single-joint isolation, simple stretches)
|
||||
- COMPLEXITY_2_PATTERNS -> 2 (basic compound or standard bodyweight)
|
||||
- Unmatched -> movement_patterns fallback -> default 3
|
||||
|
||||
3. **Movement-pattern fallback** for exercises not caught by name rules,
|
||||
using the exercise's movement_patterns CharField.
|
||||
|
||||
Usage:
|
||||
python manage.py classify_exercises
|
||||
python manage.py classify_exercises --dry-run
|
||||
python manage.py classify_exercises --dry-run --verbose
|
||||
"""
|
||||
|
||||
import re
|
||||
from django.core.management.base import BaseCommand
|
||||
from exercise.models import Exercise
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DIFFICULTY LEVEL RULES (name-based)
|
||||
# ============================================================================
|
||||
# Each entry: (compiled_regex, difficulty_level)
|
||||
# Matched against exercise.name.lower(). First match wins.
|
||||
# Patterns use word boundaries (\b) where appropriate to avoid false positives.
|
||||
|
||||
ADVANCED_NAME_PATTERNS = [
|
||||
# --- Olympic lifts & derivatives ---
|
||||
r'\bsnatch\b',
|
||||
r'\bclean and jerk\b',
|
||||
r'\bclean & jerk\b',
|
||||
r'\bpower clean\b',
|
||||
r'\bhang clean\b',
|
||||
r'\bsquat clean\b',
|
||||
r'\bclean pull\b',
|
||||
r'\bcluster\b.*\bclean\b',
|
||||
r'\bclean\b.*\bto\b.*\bpress\b', # clean to press / clean to push press
|
||||
r'\bclean\b.*\bto\b.*\bjerk\b',
|
||||
r'\bpush jerk\b',
|
||||
r'\bsplit jerk\b',
|
||||
r'\bjerk\b(?!.*chicken)', # jerk but not "chicken jerk" type food
|
||||
r'\bthruster\b',
|
||||
r'\bwall ball\b', # high coordination + explosive
|
||||
|
||||
# --- Advanced gymnastics / calisthenics ---
|
||||
r'\bpistol\b.*\bsquat\b',
|
||||
r'\bpistol squat\b',
|
||||
r'\bmuscle.?up\b',
|
||||
r'\bhandstand\b',
|
||||
r'\bhand\s*stand\b',
|
||||
r'\bdragon flag\b',
|
||||
r'\bplanche\b',
|
||||
r'\bl.?sit\b',
|
||||
r'\bhuman flag\b',
|
||||
r'\bfront lever\b',
|
||||
r'\bback lever\b',
|
||||
r'\biron cross\b',
|
||||
r'\bmaltese\b',
|
||||
r'\bstrict press.*handstand\b',
|
||||
r'\bskin the cat\b',
|
||||
r'\bwindshield wiper\b(?!.*stretch)', # weighted windshield wipers, not stretch
|
||||
|
||||
# --- Advanced barbell lifts ---
|
||||
r'\bturkish get.?up\b',
|
||||
r'\bturkish getup\b',
|
||||
r'\btgu\b',
|
||||
r'\bzercher\b', # zercher squat/carry
|
||||
r'\bdeficit deadlift\b',
|
||||
r'\bsnatch.?grip deadlift\b',
|
||||
r'\bsumo deadlift\b', # wider stance = more mobility demand
|
||||
r'\bhack squat\b.*\bbarbell\b', # barbell hack squat (not machine)
|
||||
r'\boverhead squat\b',
|
||||
r'\bsingle.?leg deadlift\b.*\bbarbell\b',
|
||||
r'\bbarbell\b.*\bsingle.?leg deadlift\b',
|
||||
r'\bscorpion\b', # scorpion press
|
||||
|
||||
# --- Plyometric / explosive ---
|
||||
r'\bbox jump\b',
|
||||
r'\bdepth jump\b',
|
||||
r'\btuck jump\b',
|
||||
r'\bbroad jump\b',
|
||||
r'\bclap push.?up\b',
|
||||
r'\bclapping push.?up\b',
|
||||
r'\bplyometric push.?up\b',
|
||||
r'\bplyo push.?up\b',
|
||||
r'\bexplosive\b',
|
||||
r'\bkipping\b',
|
||||
|
||||
# --- Advanced core ---
|
||||
r'\bab.?wheel\b',
|
||||
r'\bab roller\b',
|
||||
r'\btoes.?to.?bar\b',
|
||||
r'\bknees.?to.?elbow\b',
|
||||
r'\bhanging.?leg.?raise\b',
|
||||
r'\bhanging.?knee.?raise\b',
|
||||
]
|
||||
|
||||
BEGINNER_NAME_PATTERNS = [
|
||||
# --- Simple machine isolation ---
|
||||
r'\bleg press\b',
|
||||
r'\bleg extension\b',
|
||||
r'\bleg curl\b',
|
||||
r'\bhamstring curl\b.*\bmachine\b',
|
||||
r'\bmachine\b.*\bhamstring curl\b',
|
||||
r'\bcalf raise\b.*\bmachine\b',
|
||||
r'\bmachine\b.*\bcalf raise\b',
|
||||
r'\bseated calf raise\b',
|
||||
r'\bchest fly\b.*\bmachine\b',
|
||||
r'\bmachine\b.*\bchest fly\b',
|
||||
r'\bpec.?deck\b',
|
||||
r'\bpec fly\b.*\bmachine\b',
|
||||
r'\bcable\b.*\bcurl\b',
|
||||
r'\bcable\b.*\btricep\b',
|
||||
r'\bcable\b.*\bpushdown\b',
|
||||
r'\btricep.?pushdown\b',
|
||||
r'\blat pulldown\b',
|
||||
r'\bseated row\b.*\bmachine\b',
|
||||
r'\bmachine\b.*\brow\b',
|
||||
r'\bsmith machine\b',
|
||||
|
||||
# --- Basic bodyweight ---
|
||||
r'\bwall sit\b',
|
||||
r'\bwall push.?up\b',
|
||||
r'\bincline push.?up\b',
|
||||
r'\bdead hang\b',
|
||||
r'\bplank\b(?!.*\bjack\b)(?!.*\bup\b.*\bdown\b)', # plank but not plank jacks or up-down planks
|
||||
r'\bside plank\b',
|
||||
r'\bglute bridge\b',
|
||||
r'\bhip bridge\b',
|
||||
r'\bbird.?dog\b',
|
||||
r'\bsuperman\b(?!.*\bpush.?up\b)',
|
||||
r'\bcrunches?\b',
|
||||
r'\bsit.?up\b',
|
||||
r'\bbicycle\b.*\bcrunch\b',
|
||||
r'\bflutter kick\b',
|
||||
r'\bleg raise\b(?!.*\bhanging\b)', # lying leg raise (not hanging)
|
||||
r'\blying\b.*\bleg raise\b',
|
||||
r'\bcalf raise\b(?!.*\bbarbell\b)(?!.*\bsingle\b)', # basic standing calf raise
|
||||
r'\bstanding calf raise\b',
|
||||
|
||||
# --- Stretches and foam rolling ---
|
||||
r'\bstretch\b',
|
||||
r'\bstretching\b',
|
||||
r'\bfoam roll\b',
|
||||
r'\bfoam roller\b',
|
||||
r'\blacrosse ball\b',
|
||||
r'\bmyofascial\b',
|
||||
r'\bself.?massage\b',
|
||||
|
||||
# --- Breathing ---
|
||||
r'\bbreathing\b',
|
||||
r'\bbreathe\b',
|
||||
r'\bdiaphragmatic\b',
|
||||
r'\bbox breathing\b',
|
||||
r'\bbreath\b',
|
||||
|
||||
# --- Basic mobility ---
|
||||
r'\bneck\b.*\bcircle\b',
|
||||
r'\barm\b.*\bcircle\b',
|
||||
r'\bshoulder\b.*\bcircle\b',
|
||||
r'\bankle\b.*\bcircle\b',
|
||||
r'\bhip\b.*\bcircle\b',
|
||||
r'\bwrist\b.*\bcircle\b',
|
||||
r'\bcat.?cow\b',
|
||||
r'\bchild.?s?\s*pose\b',
|
||||
|
||||
# --- Simple cardio ---
|
||||
r'\bwalking\b(?!.*\blunge\b)', # walking but not walking lunges
|
||||
r'\bwalk\b(?!.*\bout\b)(?!.*\blunge\b)', # walk but not walkouts or walk lunges
|
||||
r'\bjogging\b',
|
||||
r'\bjog\b',
|
||||
r'\bstepping\b',
|
||||
r'\bstep.?up\b(?!.*\bweighted\b)(?!.*\bbarbell\b)(?!.*\bdumbbell\b)',
|
||||
r'\bjumping jack\b',
|
||||
r'\bhigh knee\b',
|
||||
r'\bbutt kick\b',
|
||||
r'\bbutt kicker\b',
|
||||
r'\bmountain climber\b',
|
||||
|
||||
# --- Simple yoga poses ---
|
||||
r'\bdownward.?dog\b',
|
||||
r'\bupward.?dog\b',
|
||||
r'\bwarrior\b.*\bpose\b',
|
||||
r'\btree\b.*\bpose\b',
|
||||
r'\bcorpse\b.*\bpose\b',
|
||||
r'\bsavasana\b',
|
||||
r'\bchild.?s?\s*pose\b',
|
||||
]
|
||||
|
||||
# Compile for performance
|
||||
_ADVANCED_NAME_RE = [(re.compile(p, re.IGNORECASE), 'advanced') for p in ADVANCED_NAME_PATTERNS]
|
||||
_BEGINNER_NAME_RE = [(re.compile(p, re.IGNORECASE), 'beginner') for p in BEGINNER_NAME_PATTERNS]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COMPLEXITY RATING RULES (name-based, 1-5 scale)
|
||||
# ============================================================================
|
||||
# 1 = Single-joint, simple movement (curls, calf raises, stretches)
|
||||
# 2 = Basic compound or standard bodyweight
|
||||
# 3 = Standard compound with moderate coordination (bench press, squat, row)
|
||||
# 4 = Complex multi-joint, unilateral loaded, high coordination demand
|
||||
# 5 = Highly technical (Olympic lifts, advanced gymnastics)
|
||||
|
||||
COMPLEXITY_5_PATTERNS = [
|
||||
# --- Olympic lifts ---
|
||||
r'\bsnatch\b',
|
||||
r'\bclean and jerk\b',
|
||||
r'\bclean & jerk\b',
|
||||
r'\bpower clean\b',
|
||||
r'\bhang clean\b',
|
||||
r'\bsquat clean\b',
|
||||
r'\bclean pull\b',
|
||||
r'\bclean\b.*\bto\b.*\bpress\b',
|
||||
r'\bclean\b.*\bto\b.*\bjerk\b',
|
||||
r'\bpush jerk\b',
|
||||
r'\bsplit jerk\b',
|
||||
r'\bjerk\b(?!.*chicken)',
|
||||
|
||||
# --- Advanced gymnastics ---
|
||||
r'\bmuscle.?up\b',
|
||||
r'\bhandstand\b.*\bpush.?up\b',
|
||||
r'\bplanche\b',
|
||||
r'\bhuman flag\b',
|
||||
r'\bfront lever\b',
|
||||
r'\bback lever\b',
|
||||
r'\biron cross\b',
|
||||
r'\bmaltese\b',
|
||||
r'\bskin the cat\b',
|
||||
|
||||
# --- Complex loaded movements ---
|
||||
r'\bturkish get.?up\b',
|
||||
r'\bturkish getup\b',
|
||||
r'\btgu\b',
|
||||
r'\boverhead squat\b',
|
||||
]
|
||||
|
||||
COMPLEXITY_4_PATTERNS = [
|
||||
# --- Complex compound ---
|
||||
r'\bthruster\b',
|
||||
r'\bwall ball\b',
|
||||
r'\bzercher\b',
|
||||
r'\bdeficit deadlift\b',
|
||||
r'\bsumo deadlift\b',
|
||||
r'\bsnatch.?grip deadlift\b',
|
||||
r'\bpistol\b.*\bsquat\b',
|
||||
r'\bpistol squat\b',
|
||||
r'\bdragon flag\b',
|
||||
r'\bl.?sit\b',
|
||||
r'\bhandstand\b(?!.*\bpush.?up\b)', # handstand hold (not HSPU, that's 5)
|
||||
r'\bwindshield wiper\b',
|
||||
r'\btoes.?to.?bar\b',
|
||||
r'\bknees.?to.?elbow\b',
|
||||
r'\bkipping\b',
|
||||
|
||||
# --- Single-leg loaded (barbell/dumbbell) ---
|
||||
r'\bsingle.?leg deadlift\b',
|
||||
r'\bsingle.?leg rdl\b',
|
||||
r'\bsingle.?leg squat\b(?!.*\bpistol\b)',
|
||||
r'\bbulgarian split squat\b',
|
||||
r'\brear.?foot.?elevated\b.*\bsplit\b',
|
||||
|
||||
# --- Explosive / plyometric ---
|
||||
r'\bbox jump\b',
|
||||
r'\bdepth jump\b',
|
||||
r'\btuck jump\b',
|
||||
r'\bbroad jump\b',
|
||||
r'\bclap push.?up\b',
|
||||
r'\bclapping push.?up\b',
|
||||
r'\bplyometric push.?up\b',
|
||||
r'\bplyo push.?up\b',
|
||||
r'\bexplosive\b',
|
||||
|
||||
# --- Advanced core ---
|
||||
r'\bab.?wheel\b',
|
||||
r'\bab roller\b',
|
||||
r'\bhanging.?leg.?raise\b',
|
||||
r'\bhanging.?knee.?raise\b',
|
||||
|
||||
# --- Complex upper body ---
|
||||
r'\barcher\b.*\bpush.?up\b',
|
||||
r'\bdiamond push.?up\b',
|
||||
r'\bpike push.?up\b',
|
||||
r'\bmilitary press\b',
|
||||
r'\bstrict press\b',
|
||||
|
||||
# --- Carries (unilateral loaded / coordination) ---
|
||||
r'\bfarmer.?s?\s*carry\b',
|
||||
r'\bfarmer.?s?\s*walk\b',
|
||||
r'\bsuitcase carry\b',
|
||||
r'\boverhead carry\b',
|
||||
r'\brack carry\b',
|
||||
r'\bwaiter.?s?\s*carry\b',
|
||||
r'\bwaiter.?s?\s*walk\b',
|
||||
r'\bcross.?body carry\b',
|
||||
]
|
||||
|
||||
COMPLEXITY_1_PATTERNS = [
|
||||
# --- Single-joint isolation ---
|
||||
r'\bbicep curl\b',
|
||||
r'\bcurl\b(?!.*\bleg\b)(?!.*\bhamstring\b)(?!.*\bnordic\b)',
|
||||
r'\btricep extension\b',
|
||||
r'\btricep kickback\b',
|
||||
r'\btricep.?pushdown\b',
|
||||
r'\bskull.?crusher\b',
|
||||
r'\bcable\b.*\bfly\b',
|
||||
r'\bcable\b.*\bpushdown\b',
|
||||
r'\bcable\b.*\bcurl\b',
|
||||
r'\bleg extension\b',
|
||||
r'\bleg curl\b',
|
||||
r'\bhamstring curl\b',
|
||||
r'\bcalf raise\b',
|
||||
r'\blateral raise\b',
|
||||
r'\bfront raise\b',
|
||||
r'\brear delt fly\b',
|
||||
r'\breverse fly\b',
|
||||
r'\bpec.?deck\b',
|
||||
r'\bchest fly\b.*\bmachine\b',
|
||||
r'\bmachine\b.*\bchest fly\b',
|
||||
r'\bshrug\b',
|
||||
r'\bwrist curl\b',
|
||||
r'\bforearm curl\b',
|
||||
r'\bconcentration curl\b',
|
||||
r'\bhammer curl\b',
|
||||
r'\bpreacher curl\b',
|
||||
r'\bincline curl\b',
|
||||
|
||||
# --- Stretches / foam rolling ---
|
||||
r'\bstretch\b',
|
||||
r'\bstretching\b',
|
||||
r'\bfoam roll\b',
|
||||
r'\bfoam roller\b',
|
||||
r'\blacrosse ball\b',
|
||||
r'\bmyofascial\b',
|
||||
r'\bself.?massage\b',
|
||||
|
||||
# --- Breathing ---
|
||||
r'\bbreathing\b',
|
||||
r'\bbreathe\b',
|
||||
r'\bdiaphragmatic\b',
|
||||
r'\bbox breathing\b',
|
||||
r'\bbreath\b',
|
||||
|
||||
# --- Simple isolation machines ---
|
||||
r'\bpec fly\b',
|
||||
r'\bseated calf raise\b',
|
||||
|
||||
# --- Simple mobility ---
|
||||
r'\bneck\b.*\bcircle\b',
|
||||
r'\barm\b.*\bcircle\b',
|
||||
r'\bshoulder\b.*\bcircle\b',
|
||||
r'\bankle\b.*\bcircle\b',
|
||||
r'\bhip\b.*\bcircle\b',
|
||||
r'\bwrist\b.*\bcircle\b',
|
||||
r'\bcat.?cow\b',
|
||||
r'\bchild.?s?\s*pose\b',
|
||||
r'\bcorpse\b.*\bpose\b',
|
||||
r'\bsavasana\b',
|
||||
]
|
||||
|
||||
COMPLEXITY_2_PATTERNS = [
|
||||
# --- Basic bodyweight compound ---
|
||||
r'\bpush.?up\b(?!.*\bclap\b)(?!.*\bplyometric\b)(?!.*\bplyo\b)(?!.*\bpike\b)(?!.*\bdiamond\b)(?!.*\barcher\b)(?!.*\bexplosive\b)',
|
||||
r'\bsit.?up\b',
|
||||
r'\bcrunches?\b',
|
||||
r'\bbicycle\b.*\bcrunch\b',
|
||||
r'\bflutter kick\b',
|
||||
r'\bplank\b',
|
||||
r'\bside plank\b',
|
||||
r'\bglute bridge\b',
|
||||
r'\bhip bridge\b',
|
||||
r'\bbird.?dog\b',
|
||||
r'\bsuperman\b',
|
||||
r'\bwall sit\b',
|
||||
r'\bdead hang\b',
|
||||
r'\bbodyweight squat\b',
|
||||
r'\bair squat\b',
|
||||
r'\blying\b.*\bleg raise\b',
|
||||
r'\bleg raise\b(?!.*\bhanging\b)',
|
||||
r'\bjumping jack\b',
|
||||
r'\bhigh knee\b',
|
||||
r'\bbutt kick\b',
|
||||
r'\bbutt kicker\b',
|
||||
r'\bmountain climber\b',
|
||||
r'\bstep.?up\b(?!.*\bweighted\b)(?!.*\bbarbell\b)',
|
||||
|
||||
# --- Basic machine compound ---
|
||||
r'\bleg press\b',
|
||||
r'\blat pulldown\b',
|
||||
r'\bseated row\b.*\bmachine\b',
|
||||
r'\bmachine\b.*\brow\b',
|
||||
r'\bchest press\b.*\bmachine\b',
|
||||
r'\bmachine\b.*\bchest press\b',
|
||||
r'\bsmith machine\b',
|
||||
|
||||
# --- Cardio / locomotion ---
|
||||
r'\bwalking\b',
|
||||
r'\bwalk\b(?!.*\bout\b)',
|
||||
r'\bjogging\b',
|
||||
r'\bjog\b',
|
||||
r'\brunning\b',
|
||||
r'\bsprinting\b',
|
||||
r'\browing\b.*\bmachine\b',
|
||||
r'\bassault bike\b',
|
||||
r'\bstationary bike\b',
|
||||
r'\belliptical\b',
|
||||
r'\bjump rope\b',
|
||||
r'\bskipping\b',
|
||||
|
||||
# --- Simple yoga poses ---
|
||||
r'\bdownward.?dog\b',
|
||||
r'\bupward.?dog\b',
|
||||
r'\bwarrior\b.*\bpose\b',
|
||||
r'\btree\b.*\bpose\b',
|
||||
|
||||
# --- Basic combat ---
|
||||
r'\bjab\b',
|
||||
r'\bcross\b(?!.*\bbody\b.*\bcarry\b)',
|
||||
r'\bshadow\s*box\b',
|
||||
|
||||
# --- Basic resistance band ---
|
||||
r'\bband\b.*\bpull.?apart\b',
|
||||
r'\bband\b.*\bface pull\b',
|
||||
]
|
||||
|
||||
# Compile for performance
|
||||
_COMPLEXITY_5_RE = [(re.compile(p, re.IGNORECASE), 5) for p in COMPLEXITY_5_PATTERNS]
|
||||
_COMPLEXITY_4_RE = [(re.compile(p, re.IGNORECASE), 4) for p in COMPLEXITY_4_PATTERNS]
|
||||
_COMPLEXITY_1_RE = [(re.compile(p, re.IGNORECASE), 1) for p in COMPLEXITY_1_PATTERNS]
|
||||
_COMPLEXITY_2_RE = [(re.compile(p, re.IGNORECASE), 2) for p in COMPLEXITY_2_PATTERNS]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MOVEMENT PATTERN -> DIFFICULTY FALLBACK
|
||||
# ============================================================================
|
||||
# When name-based rules don't match, use movement_patterns field.
|
||||
# Keys are substring matches against movement_patterns (lowercased).
|
||||
# Order matters: first match wins.
|
||||
|
||||
MOVEMENT_PATTERN_DIFFICULTY = [
|
||||
# --- Advanced patterns ---
|
||||
('plyometric', 'advanced'),
|
||||
('olympic', 'advanced'),
|
||||
|
||||
# --- Beginner patterns ---
|
||||
('massage', 'beginner'),
|
||||
('breathing', 'beginner'),
|
||||
('mobility - static', 'beginner'),
|
||||
('yoga', 'beginner'),
|
||||
('stretch', 'beginner'),
|
||||
|
||||
# --- Intermediate (default for all loaded / compound patterns) ---
|
||||
('upper push - vertical', 'intermediate'),
|
||||
('upper push - horizontal', 'intermediate'),
|
||||
('upper pull - vertical', 'intermediate'),
|
||||
('upper pull - horizonal', 'intermediate'), # note: typo matches DB
|
||||
('upper pull - horizontal', 'intermediate'),
|
||||
('upper push', 'intermediate'),
|
||||
('upper pull', 'intermediate'),
|
||||
('lower push - squat', 'intermediate'),
|
||||
('lower push - lunge', 'intermediate'),
|
||||
('lower pull - hip hinge', 'intermediate'),
|
||||
('lower push', 'intermediate'),
|
||||
('lower pull', 'intermediate'),
|
||||
('core - anti-extension', 'intermediate'),
|
||||
('core - rotational', 'intermediate'),
|
||||
('core - anti-rotation', 'intermediate'),
|
||||
('core - carry', 'intermediate'),
|
||||
('core', 'intermediate'),
|
||||
('arms', 'intermediate'),
|
||||
('machine', 'intermediate'),
|
||||
('balance', 'intermediate'),
|
||||
('mobility - dynamic', 'intermediate'),
|
||||
('mobility', 'intermediate'),
|
||||
('combat', 'intermediate'),
|
||||
('cardio/locomotion', 'intermediate'),
|
||||
('cardio', 'intermediate'),
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MOVEMENT PATTERN -> COMPLEXITY FALLBACK
|
||||
# ============================================================================
|
||||
# When name-based rules don't match, use movement_patterns field.
|
||||
# Order matters: first match wins.
|
||||
|
||||
MOVEMENT_PATTERN_COMPLEXITY = [
|
||||
# --- Complexity 5 ---
|
||||
('olympic', 5),
|
||||
|
||||
# --- Complexity 4 ---
|
||||
('plyometric', 4),
|
||||
('core - carry', 4),
|
||||
|
||||
# --- Complexity 3 (standard compound) ---
|
||||
('upper push - vertical', 3),
|
||||
('upper push - horizontal', 3),
|
||||
('upper pull - vertical', 3),
|
||||
('upper pull - horizonal', 3), # typo matches DB
|
||||
('upper pull - horizontal', 3),
|
||||
('upper push', 3),
|
||||
('upper pull', 3),
|
||||
('lower push - squat', 3),
|
||||
('lower push - lunge', 3),
|
||||
('lower pull - hip hinge', 3),
|
||||
('lower push', 3),
|
||||
('lower pull', 3),
|
||||
('core - anti-extension', 3),
|
||||
('core - rotational', 3),
|
||||
('core - anti-rotation', 3),
|
||||
('balance', 3),
|
||||
('combat', 3),
|
||||
|
||||
# --- Complexity 2 ---
|
||||
('core', 2),
|
||||
('machine', 2),
|
||||
('arms', 2),
|
||||
('mobility - dynamic', 2),
|
||||
('cardio/locomotion', 2),
|
||||
('cardio', 2),
|
||||
('yoga', 2),
|
||||
|
||||
# --- Complexity 1 ---
|
||||
('mobility - static', 1),
|
||||
('massage', 1),
|
||||
('stretch', 1),
|
||||
('breathing', 1),
|
||||
('mobility', 1), # generic mobility fallback
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EQUIPMENT-BASED ADJUSTMENTS
|
||||
# ============================================================================
|
||||
# Some exercises can be bumped up or down based on equipment context.
|
||||
# These are applied AFTER name + movement_pattern rules as modifiers.
|
||||
|
||||
def _apply_equipment_adjustments(exercise, difficulty, complexity):
|
||||
"""
|
||||
Apply equipment-based adjustments to difficulty and complexity.
|
||||
|
||||
- Barbell compound lifts: ensure at least intermediate / 3
|
||||
- Kettlebell: bump complexity +1 for most movements (unstable load)
|
||||
- Stability ball: bump complexity +1 (balance demand)
|
||||
- Suspension trainer (TRX): bump complexity +1 (instability)
|
||||
- Machine: cap complexity at 2 (guided path, low coordination)
|
||||
- Resistance band: no change
|
||||
"""
|
||||
name_lower = (exercise.name or '').lower()
|
||||
equip = (exercise.equipment_required or '').lower()
|
||||
patterns = (exercise.movement_patterns or '').lower()
|
||||
|
||||
# --- Machine cap: complexity should not exceed 2 ---
|
||||
is_machine = (
|
||||
'machine' in equip
|
||||
or 'machine' in name_lower
|
||||
or 'smith' in name_lower
|
||||
or 'machine' in patterns
|
||||
)
|
||||
# But only if it's truly a guided-path machine, not cable
|
||||
is_cable = 'cable' in equip or 'cable' in name_lower
|
||||
if is_machine and not is_cable:
|
||||
complexity = min(complexity, 2)
|
||||
|
||||
# --- Kettlebell bump: +1 complexity (unstable center of mass) ---
|
||||
is_kettlebell = 'kettlebell' in equip or 'kettlebell' in name_lower
|
||||
if is_kettlebell and complexity < 5:
|
||||
# Only bump for compound movements, not simple swings etc.
|
||||
if any(kw in patterns for kw in ['upper push', 'upper pull', 'lower push', 'lower pull', 'core - carry']):
|
||||
complexity = min(complexity + 1, 5)
|
||||
|
||||
# --- Stability ball bump: +1 complexity ---
|
||||
is_stability_ball = 'stability ball' in equip or 'stability ball' in name_lower
|
||||
if is_stability_ball and complexity < 5:
|
||||
complexity = min(complexity + 1, 5)
|
||||
|
||||
# --- Suspension trainer (TRX) bump: +1 complexity ---
|
||||
is_suspension = (
|
||||
'suspension' in equip or 'trx' in name_lower
|
||||
or 'suspension' in name_lower
|
||||
)
|
||||
if is_suspension and complexity < 5:
|
||||
complexity = min(complexity + 1, 5)
|
||||
|
||||
# --- Barbell floor: ensure at least intermediate / 3 for big lifts ---
|
||||
is_barbell = 'barbell' in equip or 'barbell' in name_lower
|
||||
if is_barbell:
|
||||
for lift in ['squat', 'deadlift', 'bench', 'press', 'row', 'lunge']:
|
||||
if lift in name_lower:
|
||||
if difficulty == 'beginner':
|
||||
difficulty = 'intermediate'
|
||||
complexity = max(complexity, 3)
|
||||
break
|
||||
|
||||
return difficulty, complexity
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLASSIFICATION FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
def classify_difficulty(exercise):
|
||||
"""Return difficulty_level for an exercise. First match wins."""
|
||||
name = (exercise.name or '').lower()
|
||||
|
||||
# 1. Check advanced name patterns
|
||||
for regex, level in _ADVANCED_NAME_RE:
|
||||
if regex.search(name):
|
||||
return level
|
||||
|
||||
# 2. Check beginner name patterns
|
||||
for regex, level in _BEGINNER_NAME_RE:
|
||||
if regex.search(name):
|
||||
return level
|
||||
|
||||
# 3. Movement pattern fallback
|
||||
patterns = (exercise.movement_patterns or '').lower()
|
||||
if patterns:
|
||||
for keyword, level in MOVEMENT_PATTERN_DIFFICULTY:
|
||||
if keyword in patterns:
|
||||
return level
|
||||
|
||||
# 4. Default: intermediate
|
||||
return 'intermediate'
|
||||
|
||||
|
||||
def classify_complexity(exercise):
|
||||
"""Return complexity_rating (1-5) for an exercise. First match wins."""
|
||||
name = (exercise.name or '').lower()
|
||||
|
||||
# 1. Complexity 5
|
||||
for regex, rating in _COMPLEXITY_5_RE:
|
||||
if regex.search(name):
|
||||
return rating
|
||||
|
||||
# 2. Complexity 4
|
||||
for regex, rating in _COMPLEXITY_4_RE:
|
||||
if regex.search(name):
|
||||
return rating
|
||||
|
||||
# 3. Complexity 1 (check before 2 since some patterns overlap)
|
||||
for regex, rating in _COMPLEXITY_1_RE:
|
||||
if regex.search(name):
|
||||
return rating
|
||||
|
||||
# 4. Complexity 2
|
||||
for regex, rating in _COMPLEXITY_2_RE:
|
||||
if regex.search(name):
|
||||
return rating
|
||||
|
||||
# 5. Movement pattern fallback
|
||||
patterns = (exercise.movement_patterns or '').lower()
|
||||
if patterns:
|
||||
for keyword, rating in MOVEMENT_PATTERN_COMPLEXITY:
|
||||
if keyword in patterns:
|
||||
return rating
|
||||
|
||||
# 6. Default: 3 (moderate)
|
||||
return 3
|
||||
|
||||
|
||||
def classify_exercise(exercise):
|
||||
"""
|
||||
Classify a single exercise and return (difficulty_level, complexity_rating).
|
||||
"""
|
||||
difficulty = classify_difficulty(exercise)
|
||||
complexity = classify_complexity(exercise)
|
||||
|
||||
# Apply equipment-based adjustments
|
||||
difficulty, complexity = _apply_equipment_adjustments(
|
||||
exercise, difficulty, complexity
|
||||
)
|
||||
|
||||
return difficulty, complexity
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MANAGEMENT COMMAND
|
||||
# ============================================================================
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
'Classify all exercises by difficulty_level and complexity_rating '
|
||||
'using name-based keyword rules and movement_patterns fallback.'
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would change without saving.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Print each exercise classification.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--only-unset',
|
||||
action='store_true',
|
||||
help='Only classify exercises that have NULL difficulty/complexity.',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"classify_exercises is deprecated. Use 'populate_exercise_fields' instead, "
|
||||
"which populates all 8 exercise fields including difficulty and complexity.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self.stderr.write(self.style.WARNING(
|
||||
"DEPRECATED: Use 'python manage.py populate_exercise_fields' instead. "
|
||||
"This command only sets difficulty_level and complexity_rating, while "
|
||||
"populate_exercise_fields sets all 8 fields."
|
||||
))
|
||||
|
||||
dry_run = options['dry_run']
|
||||
verbose = options['verbose']
|
||||
only_unset = options['only_unset']
|
||||
|
||||
exercises = Exercise.objects.all().order_by('name')
|
||||
if only_unset:
|
||||
exercises = exercises.filter(
|
||||
difficulty_level__isnull=True
|
||||
) | exercises.filter(
|
||||
complexity_rating__isnull=True
|
||||
)
|
||||
exercises = exercises.distinct().order_by('name')
|
||||
|
||||
total = exercises.count()
|
||||
updated = 0
|
||||
unchanged = 0
|
||||
|
||||
# Counters for summary
|
||||
difficulty_counts = {'beginner': 0, 'intermediate': 0, 'advanced': 0}
|
||||
complexity_counts = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||||
|
||||
for ex in exercises:
|
||||
difficulty, complexity = classify_exercise(ex)
|
||||
|
||||
difficulty_counts[difficulty] += 1
|
||||
complexity_counts[complexity] += 1
|
||||
|
||||
changed = (
|
||||
ex.difficulty_level != difficulty
|
||||
or ex.complexity_rating != complexity
|
||||
)
|
||||
|
||||
if verbose:
|
||||
marker = '*' if changed else ' '
|
||||
self.stdout.write(
|
||||
f' {marker} {ex.name:<55} '
|
||||
f'difficulty={difficulty:<14} '
|
||||
f'complexity={complexity} '
|
||||
f'patterns="{ex.movement_patterns or ""}"'
|
||||
)
|
||||
|
||||
if changed:
|
||||
updated += 1
|
||||
if not dry_run:
|
||||
ex.difficulty_level = difficulty
|
||||
ex.complexity_rating = complexity
|
||||
ex.save(update_fields=['difficulty_level', 'complexity_rating'])
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
# Summary
|
||||
prefix = '[DRY RUN] ' if dry_run else ''
|
||||
self.stdout.write('')
|
||||
self.stdout.write(f'{prefix}Processed {total} exercises:')
|
||||
self.stdout.write(f' {updated} updated, {unchanged} unchanged')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('Difficulty distribution:')
|
||||
for level, count in difficulty_counts.items():
|
||||
pct = (count / total * 100) if total else 0
|
||||
self.stdout.write(f' {level:<14} {count:>5} ({pct:.1f}%)')
|
||||
self.stdout.write('')
|
||||
self.stdout.write('Complexity distribution:')
|
||||
for rating in sorted(complexity_counts.keys()):
|
||||
count = complexity_counts[rating]
|
||||
pct = (count / total * 100) if total else 0
|
||||
self.stdout.write(f' {rating} {count:>5} ({pct:.1f}%)')
|
||||
222
generator/management/commands/fix_exercise_flags.py
Normal file
222
generator/management/commands/fix_exercise_flags.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Fix exercise flags and assign missing muscle associations.
|
||||
|
||||
1. Fix is_weight flags on exercises that are bodyweight but incorrectly marked
|
||||
is_weight=True (wall sits, agility ladder, planks, bodyweight exercises, etc.)
|
||||
|
||||
2. Assign muscle groups to exercises that have no ExerciseMuscle rows, using
|
||||
name keyword matching.
|
||||
|
||||
Known false positives: wall sits, agility ladder, planks, body weight exercises,
|
||||
and similar movements that use no external resistance.
|
||||
|
||||
Usage:
|
||||
python manage.py fix_exercise_flags
|
||||
python manage.py fix_exercise_flags --dry-run
|
||||
"""
|
||||
|
||||
import re
|
||||
from django.core.management.base import BaseCommand
|
||||
from exercise.models import Exercise
|
||||
from muscle.models import Muscle, ExerciseMuscle
|
||||
|
||||
try:
|
||||
from equipment.models import WorkoutEquipment
|
||||
except ImportError:
|
||||
WorkoutEquipment = None
|
||||
|
||||
|
||||
# Patterns that indicate bodyweight exercises (no external weight).
|
||||
# Uses word boundary matching to avoid substring issues (e.g. "l sit" in "wall sit").
|
||||
BODYWEIGHT_PATTERNS = [
|
||||
r'\bwall sit\b',
|
||||
r'\bplank\b',
|
||||
r'\bmountain climber\b',
|
||||
r'\bburpee\b',
|
||||
r'\bpush ?up\b',
|
||||
r'\bpushup\b',
|
||||
r'\bpull ?up\b',
|
||||
r'\bpullup\b',
|
||||
r'\bchin ?up\b',
|
||||
r'\bchinup\b',
|
||||
r'\bdips?\b',
|
||||
r'\bpike\b',
|
||||
r'\bhandstand\b',
|
||||
r'\bl sit\b',
|
||||
r'\bv sit\b',
|
||||
r'\bhollow\b',
|
||||
r'\bsuperman\b',
|
||||
r'\bbird dog\b',
|
||||
r'\bdead bug\b',
|
||||
r'\bbear crawl\b',
|
||||
r'\bcrab walk\b',
|
||||
r'\binchworm\b',
|
||||
r'\bjumping jack\b',
|
||||
r'\bhigh knee\b',
|
||||
r'\bbutt kick\b',
|
||||
r'\bskater\b',
|
||||
r'\blunge jump\b',
|
||||
r'\bjump lunge\b',
|
||||
r'\bsquat jump\b',
|
||||
r'\bjump squat\b',
|
||||
r'\bbox jump\b',
|
||||
r'\btuck jump\b',
|
||||
r'\bbroad jump\b',
|
||||
r'\bsprinter\b',
|
||||
r'\bagility ladder\b',
|
||||
r'\bbody ?weight\b',
|
||||
r'\bbodyweight\b',
|
||||
r'\bcalisthenics?\b',
|
||||
r'\bflutter kick\b',
|
||||
r'\bleg raise\b',
|
||||
r'\bsit ?up\b',
|
||||
r'\bcrunch\b',
|
||||
r'\bstretch\b',
|
||||
r'\byoga\b',
|
||||
r'\bfoam roll\b',
|
||||
r'\bjump rope\b',
|
||||
r'\bspider crawl\b',
|
||||
]
|
||||
|
||||
# Keywords for assigning muscles to exercises with no ExerciseMuscle rows.
|
||||
# Each muscle name maps to a list of name keywords to match against exercise name.
|
||||
EXERCISE_MUSCLE_KEYWORDS = {
|
||||
'chest': ['chest', 'pec', 'bench press', 'push up', 'fly'],
|
||||
'back': ['back', 'lat', 'row', 'pull up', 'pulldown'],
|
||||
'shoulders': ['shoulder', 'delt', 'press', 'raise', 'shrug'],
|
||||
'quads': ['quad', 'squat', 'leg press', 'lunge', 'extension'],
|
||||
'hamstrings': ['hamstring', 'curl', 'deadlift', 'rdl'],
|
||||
'glutes': ['glute', 'hip thrust', 'bridge'],
|
||||
'biceps': ['bicep', 'curl'],
|
||||
'triceps': ['tricep', 'pushdown', 'extension', 'dip'],
|
||||
'core': ['core', 'ab', 'crunch', 'plank', 'sit up'],
|
||||
'calves': ['calf', 'calves', 'calf raise'],
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fix is_weight flags and assign missing muscle associations'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would change without writing to DB',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('Step 1: Fix is_weight false positives'))
|
||||
weight_fixed = self._fix_is_weight_false_positives(dry_run)
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('\nStep 2: Assign missing muscles'))
|
||||
muscle_assigned = self._assign_missing_muscles(dry_run)
|
||||
|
||||
prefix = '[DRY RUN] ' if dry_run else ''
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n{prefix}Summary: Fixed {weight_fixed} is_weight flags, '
|
||||
f'assigned muscles to {muscle_assigned} exercises'
|
||||
))
|
||||
|
||||
def _fix_is_weight_false_positives(self, dry_run):
|
||||
"""Fix exercises that are bodyweight but incorrectly marked is_weight=True."""
|
||||
# Get exercises that have is_weight=True
|
||||
weighted_exercises = Exercise.objects.filter(is_weight=True)
|
||||
|
||||
# Get exercises that have equipment assigned (if WorkoutEquipment exists)
|
||||
exercises_with_equipment = set()
|
||||
if WorkoutEquipment is not None:
|
||||
exercises_with_equipment = set(
|
||||
WorkoutEquipment.objects.values_list('exercise_id', flat=True).distinct()
|
||||
)
|
||||
|
||||
fixed = 0
|
||||
for ex in weighted_exercises:
|
||||
if not ex.name:
|
||||
continue
|
||||
|
||||
name_lower = ex.name.lower()
|
||||
|
||||
# Check if name matches any bodyweight pattern
|
||||
is_bodyweight_name = any(
|
||||
re.search(pat, name_lower) for pat in BODYWEIGHT_PATTERNS
|
||||
)
|
||||
|
||||
# Also check if the exercise has no equipment assigned
|
||||
has_no_equipment = ex.pk not in exercises_with_equipment
|
||||
|
||||
if is_bodyweight_name and has_no_equipment:
|
||||
if dry_run:
|
||||
self.stdout.write(f' Would fix: {ex.name} (id={ex.pk})')
|
||||
else:
|
||||
ex.is_weight = False
|
||||
ex.save(update_fields=['is_weight'])
|
||||
self.stdout.write(f' Fixed: {ex.name} (id={ex.pk})')
|
||||
fixed += 1
|
||||
|
||||
prefix = '[DRY RUN] ' if dry_run else ''
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'{prefix}Fixed {fixed} exercises from is_weight=True to is_weight=False'
|
||||
))
|
||||
return fixed
|
||||
|
||||
def _assign_missing_muscles(self, dry_run):
|
||||
"""Assign muscle groups to exercises that have no ExerciseMuscle rows."""
|
||||
# Find exercises with no muscle associations
|
||||
exercises_with_muscles = set(
|
||||
ExerciseMuscle.objects.values_list('exercise_id', flat=True).distinct()
|
||||
)
|
||||
orphan_exercises = Exercise.objects.exclude(pk__in=exercises_with_muscles)
|
||||
|
||||
if not orphan_exercises.exists():
|
||||
self.stdout.write(' No exercises without muscle assignments found.')
|
||||
return 0
|
||||
|
||||
self.stdout.write(f' Found {orphan_exercises.count()} exercises without muscle assignments')
|
||||
|
||||
# Build a cache of muscle objects by name (case-insensitive)
|
||||
muscle_cache = {}
|
||||
for muscle in Muscle.objects.all():
|
||||
muscle_cache[muscle.name.lower()] = muscle
|
||||
|
||||
assigned_count = 0
|
||||
for ex in orphan_exercises:
|
||||
if not ex.name:
|
||||
continue
|
||||
|
||||
name_lower = ex.name.lower()
|
||||
matched_muscles = []
|
||||
|
||||
for muscle_name, keywords in EXERCISE_MUSCLE_KEYWORDS.items():
|
||||
for keyword in keywords:
|
||||
if keyword in name_lower:
|
||||
# Find the muscle in the cache
|
||||
muscle_obj = muscle_cache.get(muscle_name)
|
||||
if muscle_obj and muscle_obj not in matched_muscles:
|
||||
matched_muscles.append(muscle_obj)
|
||||
break # One keyword match per muscle group is enough
|
||||
|
||||
if matched_muscles:
|
||||
if dry_run:
|
||||
muscle_names = ', '.join(m.name for m in matched_muscles)
|
||||
self.stdout.write(
|
||||
f' Would assign: {ex.name} (id={ex.pk}) -> [{muscle_names}]'
|
||||
)
|
||||
else:
|
||||
for muscle_obj in matched_muscles:
|
||||
ExerciseMuscle.objects.get_or_create(
|
||||
exercise=ex,
|
||||
muscle=muscle_obj,
|
||||
)
|
||||
muscle_names = ', '.join(m.name for m in matched_muscles)
|
||||
self.stdout.write(
|
||||
f' Assigned: {ex.name} (id={ex.pk}) -> [{muscle_names}]'
|
||||
)
|
||||
assigned_count += 1
|
||||
|
||||
prefix = '[DRY RUN] ' if dry_run else ''
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'{prefix}Assigned muscles to {assigned_count} exercises'
|
||||
))
|
||||
return assigned_count
|
||||
109
generator/management/commands/fix_movement_pattern_typo.py
Normal file
109
generator/management/commands/fix_movement_pattern_typo.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Fix the "horizonal" typo in movement_patterns fields.
|
||||
|
||||
The database has "horizonal" (missing 't') instead of "horizontal" in
|
||||
both Exercise.movement_patterns and MovementPatternOrder.movement_pattern.
|
||||
|
||||
This command is idempotent -- running it multiple times is safe.
|
||||
|
||||
Usage:
|
||||
python manage.py fix_movement_pattern_typo --dry-run
|
||||
python manage.py fix_movement_pattern_typo
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from exercise.models import Exercise
|
||||
|
||||
# Import MovementPatternOrder if available (may not exist in test environments)
|
||||
try:
|
||||
from generator.models import MovementPatternOrder
|
||||
except ImportError:
|
||||
MovementPatternOrder = None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fix "horizonal" -> "horizontal" typo in movement_patterns'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would change without writing to DB',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
|
||||
# Idempotency guard: check if the typo still exists
|
||||
exercises_with_typo = Exercise.objects.filter(movement_patterns__icontains='horizonal')
|
||||
has_pattern_typo = False
|
||||
if MovementPatternOrder is not None:
|
||||
patterns_with_typo = MovementPatternOrder.objects.filter(
|
||||
movement_pattern__icontains='horizonal'
|
||||
)
|
||||
has_pattern_typo = patterns_with_typo.exists()
|
||||
|
||||
if not exercises_with_typo.exists() and not has_pattern_typo:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
'No "horizonal" typos found -- already fixed.'
|
||||
))
|
||||
return
|
||||
|
||||
exercise_fixed = 0
|
||||
pattern_fixed = 0
|
||||
|
||||
with transaction.atomic():
|
||||
# Fix Exercise.movement_patterns
|
||||
for ex in exercises_with_typo:
|
||||
old = ex.movement_patterns
|
||||
new = old.replace('horizonal', 'horizontal')
|
||||
if old != new:
|
||||
if dry_run:
|
||||
self.stdout.write(f' Exercise {ex.pk} "{ex.name}": "{old}" -> "{new}"')
|
||||
else:
|
||||
ex.movement_patterns = new
|
||||
ex.save(update_fields=['movement_patterns'])
|
||||
exercise_fixed += 1
|
||||
|
||||
# Fix MovementPatternOrder.movement_pattern
|
||||
if MovementPatternOrder is not None:
|
||||
patterns = MovementPatternOrder.objects.filter(
|
||||
movement_pattern__icontains='horizonal'
|
||||
)
|
||||
for mp in patterns:
|
||||
old = mp.movement_pattern
|
||||
new = old.replace('horizonal', 'horizontal')
|
||||
if old != new:
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
f' MovementPatternOrder {mp.pk}: "{old}" -> "{new}"'
|
||||
)
|
||||
else:
|
||||
mp.movement_pattern = new
|
||||
mp.save(update_fields=['movement_pattern'])
|
||||
pattern_fixed += 1
|
||||
|
||||
if dry_run:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
prefix = '[DRY RUN] ' if dry_run else ''
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n{prefix}Fixed {exercise_fixed} Exercise records and '
|
||||
f'{pattern_fixed} MovementPatternOrder records'
|
||||
))
|
||||
|
||||
# Verify
|
||||
if not dry_run:
|
||||
remaining = Exercise.objects.filter(
|
||||
movement_patterns__icontains='horizonal'
|
||||
).count()
|
||||
if remaining:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f' WARNING: {remaining} exercises still have "horizonal"'
|
||||
))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
' No "horizonal" typos remain.'
|
||||
))
|
||||
463
generator/management/commands/fix_rep_durations.py
Normal file
463
generator/management/commands/fix_rep_durations.py
Normal file
@@ -0,0 +1,463 @@
|
||||
"""
|
||||
Fixes estimated_rep_duration on all Exercise records using three sources:
|
||||
|
||||
1. **Exact match** from JSON workout files (AI/all_workouts_data/ and AI/cho/workouts/)
|
||||
Each set has `estimated_duration` (total seconds) and `reps`.
|
||||
We compute per_rep = estimated_duration / reps, averaged across all
|
||||
appearances of each exercise.
|
||||
|
||||
2. **Fuzzy match** from the same JSON data for exercises whose DB name
|
||||
doesn't match exactly. Uses name normalization (strip parentheticals,
|
||||
punctuation, plurals) + difflib with a 0.85 cutoff, rejecting matches
|
||||
where the equipment type differs (e.g. barbell vs dumbbell).
|
||||
|
||||
3. **Movement-pattern lookup** for exercises not found by either method.
|
||||
Uses the exercise's `movement_patterns` field against PATTERN_DURATIONS.
|
||||
|
||||
4. **Category-based defaults** for exercises that don't match any pattern.
|
||||
Falls back to DEFAULT_DURATION (3.0s).
|
||||
|
||||
Duration-only exercises (is_duration=True AND is_reps=False) are skipped
|
||||
since they use the `duration` field instead.
|
||||
|
||||
Usage:
|
||||
python manage.py fix_rep_durations
|
||||
python manage.py fix_rep_durations --dry-run
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from exercise.models import Exercise
|
||||
|
||||
|
||||
# Movement-pattern lookup table: maps movement pattern keywords to per-rep durations.
|
||||
PATTERN_DURATIONS = {
|
||||
'compound_push': 3.0,
|
||||
'compound_pull': 3.0,
|
||||
'squat': 3.0,
|
||||
'hinge': 3.0,
|
||||
'lunge': 3.0,
|
||||
'isolation_push': 2.5,
|
||||
'isolation_pull': 2.5,
|
||||
'isolation': 2.5,
|
||||
'olympic': 2.0,
|
||||
'explosive': 2.0,
|
||||
'plyometric': 2.0,
|
||||
'carry': 1.0,
|
||||
'core': 2.5,
|
||||
}
|
||||
|
||||
# Category defaults keyed by substring match on movement_patterns.
|
||||
# Order matters: first match wins. More specific patterns go first.
|
||||
CATEGORY_DEFAULTS = [
|
||||
# Explosive / ballistic -- fast reps
|
||||
('plyometric', 1.5),
|
||||
('combat', 1.0),
|
||||
('cardio/locomotion', 1.0),
|
||||
|
||||
# Compound lower -- heavy, slower
|
||||
('lower pull - hip hinge', 5.0),
|
||||
('lower push - squat', 4.5),
|
||||
('lower push - lunge', 4.0),
|
||||
('lower pull', 4.5),
|
||||
('lower push', 4.0),
|
||||
|
||||
# Compound upper
|
||||
('upper push - horizontal', 3.5),
|
||||
('upper push - vertical', 3.5),
|
||||
('upper pull - vertical', 4.0),
|
||||
('upper pull - horizonal', 3.5), # note: typo is in DB
|
||||
('upper pull - horizontal', 3.5), # also match corrected version
|
||||
('upper push', 3.5),
|
||||
('upper pull', 3.5),
|
||||
|
||||
# Isolation / machine
|
||||
('machine', 2.5),
|
||||
('arms', 2.5),
|
||||
|
||||
# Core
|
||||
('core - anti-extension', 3.5),
|
||||
('core - carry', 3.0),
|
||||
('core', 3.0),
|
||||
|
||||
# Mobility / yoga -- slow, controlled
|
||||
('yoga', 5.0),
|
||||
('mobility - static', 5.0),
|
||||
('mobility - dynamic', 4.0),
|
||||
('mobility', 4.0),
|
||||
|
||||
# Olympic lifts -- explosive, technical
|
||||
('olympic', 4.0),
|
||||
|
||||
# Isolation
|
||||
('isolation', 2.5),
|
||||
|
||||
# Carry / farmer walk
|
||||
('carry', 3.0),
|
||||
|
||||
# Agility
|
||||
('agility', 1.5),
|
||||
|
||||
# Stretch / activation
|
||||
('stretch', 5.0),
|
||||
('activation', 3.0),
|
||||
('warm up', 3.0),
|
||||
('warmup', 3.0),
|
||||
]
|
||||
|
||||
# Fallback if nothing matches
|
||||
DEFAULT_DURATION = 3.0
|
||||
|
||||
# For backwards compat, also expose as DEFAULT_PER_REP
|
||||
DEFAULT_PER_REP = DEFAULT_DURATION
|
||||
|
||||
# Equipment words -- if these differ between DB and JSON name, reject the match
|
||||
EQUIPMENT_WORDS = {
|
||||
'barbell', 'dumbbell', 'kettlebell', 'cable', 'band', 'machine',
|
||||
'smith', 'trx', 'ez-bar', 'ez bar', 'landmine', 'medicine ball',
|
||||
'resistance band', 'bodyweight',
|
||||
}
|
||||
|
||||
|
||||
def _normalize_name(name):
|
||||
"""Normalize an exercise name for fuzzy comparison."""
|
||||
n = name.lower().strip()
|
||||
# Remove parenthetical content: "Squat (Back)" -> "Squat"
|
||||
n = re.sub(r'\([^)]*\)', '', n)
|
||||
# Remove common suffixes/noise
|
||||
n = re.sub(r'\b(each side|per side|each leg|per leg|each arm|per arm)\b', '', n)
|
||||
# Remove direction words (forward/backward variants are same exercise)
|
||||
n = re.sub(r'\b(forward|backward|forwards|backwards)\b', '', n)
|
||||
# Normalize punctuation and whitespace
|
||||
n = re.sub(r'[^\w\s]', ' ', n)
|
||||
n = re.sub(r'\s+', ' ', n).strip()
|
||||
# De-pluralize each word (handles "lunges"->"lunge", "curls"->"curl")
|
||||
words = []
|
||||
for w in n.split():
|
||||
if w.endswith('s') and not w.endswith('ss') and len(w) > 2:
|
||||
w = w[:-1]
|
||||
words.append(w)
|
||||
return ' '.join(words)
|
||||
|
||||
|
||||
def _extract_equipment(name):
|
||||
"""Extract the equipment word from an exercise name, if any."""
|
||||
name_lower = name.lower()
|
||||
for eq in EQUIPMENT_WORDS:
|
||||
if eq in name_lower:
|
||||
return eq
|
||||
return None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fix estimated_rep_duration using JSON workout data + pattern/category defaults'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would change without writing to DB',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
|
||||
# -- Step 1: Parse JSON files for real per-rep timing --
|
||||
json_durations = self._parse_json_files()
|
||||
self.stdout.write(
|
||||
f'Parsed JSON: {len(json_durations)} exercises with real timing data'
|
||||
)
|
||||
|
||||
# -- Step 1b: Build fuzzy lookup from normalized JSON names --
|
||||
fuzzy_index = self._build_fuzzy_index(json_durations)
|
||||
|
||||
# -- Step 2: Update exercises --
|
||||
exercises = Exercise.objects.all()
|
||||
from_json_exact = 0
|
||||
from_json_fuzzy = 0
|
||||
from_pattern = 0
|
||||
from_category = 0
|
||||
skipped_duration_only = 0
|
||||
set_null = 0
|
||||
unchanged = 0
|
||||
fuzzy_matches = []
|
||||
|
||||
for ex in exercises:
|
||||
# Skip duration-only exercises (is_duration=True AND is_reps=False)
|
||||
if ex.is_duration and not ex.is_reps:
|
||||
if ex.estimated_rep_duration is not None:
|
||||
if not dry_run:
|
||||
ex.estimated_rep_duration = None
|
||||
ex.save(update_fields=['estimated_rep_duration'])
|
||||
set_null += 1
|
||||
else:
|
||||
skipped_duration_only += 1
|
||||
continue
|
||||
|
||||
# Duration-only exercises that aren't reps-based
|
||||
if not ex.is_reps and not ex.is_duration:
|
||||
# Edge case: neither reps nor duration -- skip
|
||||
unchanged += 1
|
||||
continue
|
||||
|
||||
# Try exact match first
|
||||
name_lower = ex.name.lower().strip()
|
||||
if name_lower in json_durations:
|
||||
new_val = json_durations[name_lower]
|
||||
source = 'json-exact'
|
||||
from_json_exact += 1
|
||||
else:
|
||||
# Try fuzzy match
|
||||
fuzzy_result = self._fuzzy_match(ex.name, json_durations, fuzzy_index)
|
||||
if fuzzy_result is not None:
|
||||
new_val, matched_name = fuzzy_result
|
||||
source = 'json-fuzzy'
|
||||
from_json_fuzzy += 1
|
||||
fuzzy_matches.append((ex.name, matched_name, new_val))
|
||||
else:
|
||||
# Try movement-pattern lookup
|
||||
pattern_val = self._get_pattern_duration(ex)
|
||||
if pattern_val is not None:
|
||||
new_val = pattern_val
|
||||
source = 'pattern'
|
||||
from_pattern += 1
|
||||
else:
|
||||
# Fall back to category defaults
|
||||
new_val = self._get_category_default(ex)
|
||||
source = 'category'
|
||||
from_category += 1
|
||||
|
||||
old_val = ex.estimated_rep_duration
|
||||
|
||||
if dry_run:
|
||||
if old_val != new_val:
|
||||
self.stdout.write(
|
||||
f' [{source}] {ex.name}: {old_val:.2f}s -> {new_val:.2f}s'
|
||||
if old_val else
|
||||
f' [{source}] {ex.name}: None -> {new_val:.2f}s'
|
||||
)
|
||||
else:
|
||||
ex.estimated_rep_duration = new_val
|
||||
ex.save(update_fields=['estimated_rep_duration'])
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n{"[DRY RUN] " if dry_run else ""}'
|
||||
f'Updated {from_json_exact + from_json_fuzzy + from_pattern + from_category + set_null} exercises: '
|
||||
f'{from_json_exact} from JSON (exact), {from_json_fuzzy} from JSON (fuzzy), '
|
||||
f'{from_pattern} from pattern lookup, {from_category} from category defaults, '
|
||||
f'{set_null} set to null (duration-only), '
|
||||
f'{skipped_duration_only} already null (duration-only), '
|
||||
f'{unchanged} unchanged'
|
||||
))
|
||||
|
||||
# Show fuzzy matches for review
|
||||
if fuzzy_matches:
|
||||
self.stdout.write(f'\nFuzzy matches ({len(fuzzy_matches)}):')
|
||||
for db_name, json_name, val in sorted(fuzzy_matches):
|
||||
self.stdout.write(f' {db_name:50s} -> {json_name} ({val:.2f}s)')
|
||||
|
||||
# -- Step 3: Show summary stats --
|
||||
reps_exercises = Exercise.objects.filter(is_reps=True)
|
||||
total_reps = reps_exercises.count()
|
||||
with_duration = reps_exercises.exclude(estimated_rep_duration__isnull=True).count()
|
||||
without_duration = reps_exercises.filter(estimated_rep_duration__isnull=True).count()
|
||||
|
||||
coverage_pct = (with_duration / total_reps * 100) if total_reps > 0 else 0
|
||||
self.stdout.write(
|
||||
f'\nCoverage: {with_duration}/{total_reps} rep-based exercises '
|
||||
f'have estimated_rep_duration ({coverage_pct:.1f}%)'
|
||||
)
|
||||
if without_duration > 0:
|
||||
self.stdout.write(
|
||||
f' {without_duration} exercises still missing estimated_rep_duration'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
durations = list(
|
||||
reps_exercises
|
||||
.exclude(estimated_rep_duration__isnull=True)
|
||||
.values_list('estimated_rep_duration', flat=True)
|
||||
)
|
||||
if durations:
|
||||
self.stdout.write(
|
||||
f'\nNew stats for rep-based exercises ({len(durations)}):'
|
||||
f'\n Min: {min(durations):.2f}s'
|
||||
f'\n Max: {max(durations):.2f}s'
|
||||
f'\n Mean: {statistics.mean(durations):.2f}s'
|
||||
f'\n Median: {statistics.median(durations):.2f}s'
|
||||
)
|
||||
|
||||
def _build_fuzzy_index(self, json_durations):
|
||||
"""
|
||||
Build a dict of {normalized_name: original_name} for fuzzy matching.
|
||||
"""
|
||||
index = {}
|
||||
for original_name in json_durations:
|
||||
norm = _normalize_name(original_name)
|
||||
# Keep the first occurrence if duplicates after normalization
|
||||
if norm not in index:
|
||||
index[norm] = original_name
|
||||
return index
|
||||
|
||||
def _fuzzy_match(self, db_name, json_durations, fuzzy_index):
|
||||
"""
|
||||
Try to fuzzy-match a DB exercise name to a JSON exercise name.
|
||||
|
||||
Strategy:
|
||||
1. Exact match on normalized names
|
||||
2. Containment match: all words of the shorter name appear in the longer
|
||||
3. High-cutoff difflib (0.88) with word overlap >= 75%
|
||||
|
||||
Equipment must match in all cases.
|
||||
|
||||
Returns (duration_value, matched_json_name) or None.
|
||||
"""
|
||||
db_norm = _normalize_name(db_name)
|
||||
db_equipment = _extract_equipment(db_name)
|
||||
db_words = set(db_norm.split())
|
||||
|
||||
# First try: exact match on normalized names
|
||||
if db_norm in fuzzy_index:
|
||||
original = fuzzy_index[db_norm]
|
||||
json_equipment = _extract_equipment(original)
|
||||
if db_equipment and json_equipment and db_equipment != json_equipment:
|
||||
return None
|
||||
return json_durations[original], original
|
||||
|
||||
# Second try: containment match -- shorter name's words are a
|
||||
# subset of the longer name's words (e.g. "barbell good morning"
|
||||
# is contained in "barbell russian good morning")
|
||||
for json_norm, original in fuzzy_index.items():
|
||||
json_words = set(json_norm.split())
|
||||
shorter, longer = (
|
||||
(db_words, json_words) if len(db_words) <= len(json_words)
|
||||
else (json_words, db_words)
|
||||
)
|
||||
# All words of the shorter must appear in the longer
|
||||
if shorter.issubset(longer) and len(shorter) >= 2:
|
||||
# But names shouldn't differ by too many words (max 2 extra)
|
||||
if len(longer) - len(shorter) > 2:
|
||||
continue
|
||||
json_equipment = _extract_equipment(original)
|
||||
if db_equipment and json_equipment and db_equipment != json_equipment:
|
||||
continue
|
||||
if (db_equipment is None) != (json_equipment is None):
|
||||
continue
|
||||
return json_durations[original], original
|
||||
|
||||
# Third try: high-cutoff difflib with strict word overlap
|
||||
normalized_json_names = list(fuzzy_index.keys())
|
||||
matches = difflib.get_close_matches(
|
||||
db_norm, normalized_json_names, n=3, cutoff=0.88,
|
||||
)
|
||||
|
||||
for match_norm in matches:
|
||||
original = fuzzy_index[match_norm]
|
||||
json_equipment = _extract_equipment(original)
|
||||
if db_equipment and json_equipment and db_equipment != json_equipment:
|
||||
continue
|
||||
if (db_equipment is None) != (json_equipment is None):
|
||||
continue
|
||||
# Require >= 75% word overlap
|
||||
match_words = set(match_norm.split())
|
||||
overlap = len(db_words & match_words)
|
||||
total = max(len(db_words), len(match_words))
|
||||
if total > 0 and overlap / total < 0.75:
|
||||
continue
|
||||
return json_durations[original], original
|
||||
|
||||
return None
|
||||
|
||||
def _parse_json_files(self):
|
||||
"""
|
||||
Parse all workout JSON files and compute average per-rep duration
|
||||
for each exercise. Returns {lowercase_name: avg_seconds_per_rep}.
|
||||
"""
|
||||
base = settings.BASE_DIR
|
||||
patterns = [
|
||||
os.path.join(base, 'AI', 'all_workouts_data', '*.json'),
|
||||
os.path.join(base, 'AI', 'cho', 'workouts', '*.json'),
|
||||
]
|
||||
files = []
|
||||
for pat in patterns:
|
||||
files.extend(sorted(glob.glob(pat)))
|
||||
|
||||
exercise_samples = defaultdict(list)
|
||||
|
||||
for fpath in files:
|
||||
with open(fpath) as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
workouts = [data] if isinstance(data, dict) else data
|
||||
|
||||
for workout in workouts:
|
||||
if not isinstance(workout, dict):
|
||||
continue
|
||||
for section in workout.get('sections', []):
|
||||
for s in section.get('sets', []):
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
ex = s.get('exercise', {})
|
||||
if not isinstance(ex, dict):
|
||||
continue
|
||||
name = ex.get('name', '').strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
reps = s.get('reps', 0) or 0
|
||||
est_dur = s.get('estimated_duration', 0) or 0
|
||||
set_type = s.get('type', '')
|
||||
|
||||
if set_type == 'reps' and reps > 0 and est_dur > 0:
|
||||
per_rep = est_dur / reps
|
||||
# Sanity: ignore outliers (< 0.5s or > 20s per rep)
|
||||
if 0.5 <= per_rep <= 20.0:
|
||||
exercise_samples[name.lower()].append(per_rep)
|
||||
|
||||
# Average across all samples per exercise
|
||||
result = {}
|
||||
for name, samples in exercise_samples.items():
|
||||
result[name] = round(statistics.mean(samples), 2)
|
||||
|
||||
return result
|
||||
|
||||
def _get_pattern_duration(self, exercise):
|
||||
"""
|
||||
Return a per-rep duration based on the PATTERN_DURATIONS lookup table.
|
||||
Checks the exercise's movement_patterns field for matching patterns.
|
||||
Returns the first match, or None if no match.
|
||||
"""
|
||||
patterns_str = (exercise.movement_patterns or '').lower()
|
||||
if not patterns_str:
|
||||
return None
|
||||
|
||||
for pattern_key, duration in PATTERN_DURATIONS.items():
|
||||
if pattern_key in patterns_str:
|
||||
return duration
|
||||
|
||||
return None
|
||||
|
||||
def _get_category_default(self, exercise):
|
||||
"""
|
||||
Return a per-rep duration based on the exercise's movement_patterns
|
||||
using the more detailed CATEGORY_DEFAULTS table.
|
||||
"""
|
||||
patterns = (exercise.movement_patterns or '').lower()
|
||||
|
||||
for keyword, duration in CATEGORY_DEFAULTS:
|
||||
if keyword in patterns:
|
||||
return duration
|
||||
|
||||
return DEFAULT_DURATION
|
||||
116
generator/management/commands/normalize_muscle_names.py
Normal file
116
generator/management/commands/normalize_muscle_names.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Normalize muscle names in the database and merge duplicates.
|
||||
|
||||
Uses the MUSCLE_NORMALIZATION_MAP from muscle_normalizer.py to:
|
||||
1. Rename each Muscle record to its canonical lowercase form
|
||||
2. Merge duplicates by updating ExerciseMuscle FKs to point to the canonical Muscle
|
||||
3. Delete orphaned duplicate Muscle records
|
||||
|
||||
Usage:
|
||||
python manage.py normalize_muscle_names --dry-run
|
||||
python manage.py normalize_muscle_names
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from muscle.models import Muscle, ExerciseMuscle
|
||||
from generator.services.muscle_normalizer import normalize_muscle_name
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Normalize muscle names and merge duplicates using MUSCLE_NORMALIZATION_MAP'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would change without writing to DB',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
|
||||
all_muscles = Muscle.objects.all().order_by('id')
|
||||
self.stdout.write(f'Found {all_muscles.count()} muscle records')
|
||||
|
||||
# Group muscles by their canonical name
|
||||
canonical_groups = defaultdict(list)
|
||||
for muscle in all_muscles:
|
||||
canonical = normalize_muscle_name(muscle.name)
|
||||
if canonical:
|
||||
canonical_groups[canonical].append(muscle)
|
||||
|
||||
renamed = 0
|
||||
merged = 0
|
||||
deleted = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for canonical_name, muscles in canonical_groups.items():
|
||||
# Pick the keeper: prefer the one with the lowest ID (oldest)
|
||||
keeper = muscles[0]
|
||||
|
||||
# Rename keeper if needed
|
||||
if keeper.name != canonical_name:
|
||||
if dry_run:
|
||||
self.stdout.write(f' Rename: "{keeper.name}" -> "{canonical_name}" (id={keeper.pk})')
|
||||
else:
|
||||
keeper.name = canonical_name
|
||||
keeper.save(update_fields=['name'])
|
||||
renamed += 1
|
||||
|
||||
# Merge duplicates into keeper
|
||||
for dup in muscles[1:]:
|
||||
# Count affected ExerciseMuscle rows
|
||||
em_count = ExerciseMuscle.objects.filter(muscle=dup).count()
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
f' Merge: "{dup.name}" (id={dup.pk}) -> "{canonical_name}" '
|
||||
f'(id={keeper.pk}), {em_count} ExerciseMuscle rows'
|
||||
)
|
||||
else:
|
||||
# Update ExerciseMuscle FKs, handling unique_together conflicts
|
||||
for em in ExerciseMuscle.objects.filter(muscle=dup):
|
||||
# Check if keeper already has this exercise
|
||||
existing = ExerciseMuscle.objects.filter(
|
||||
exercise=em.exercise, muscle=keeper
|
||||
).exists()
|
||||
if existing:
|
||||
em.delete()
|
||||
else:
|
||||
em.muscle = keeper
|
||||
em.save(update_fields=['muscle'])
|
||||
|
||||
dup.delete()
|
||||
|
||||
merged += em_count
|
||||
deleted += 1
|
||||
|
||||
if dry_run:
|
||||
# Roll back the transaction for dry run
|
||||
transaction.set_rollback(True)
|
||||
|
||||
prefix = '[DRY RUN] ' if dry_run else ''
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n{prefix}Results:'
|
||||
f'\n Renamed: {renamed} muscles'
|
||||
f'\n Merged: {merged} ExerciseMuscle references'
|
||||
f'\n Deleted: {deleted} duplicate Muscle records'
|
||||
))
|
||||
|
||||
# Verify
|
||||
if not dry_run:
|
||||
dupes = (
|
||||
Muscle.objects.values('name')
|
||||
.annotate(c=__import__('django.db.models', fromlist=['Count']).Count('id'))
|
||||
.filter(c__gt=1)
|
||||
)
|
||||
if dupes.exists():
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f' WARNING: {dupes.count()} duplicate names still exist!'
|
||||
))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(' No duplicate muscle names remain.'))
|
||||
130
generator/management/commands/normalize_muscles.py
Normal file
130
generator/management/commands/normalize_muscles.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Management command to normalize muscle names in the database.
|
||||
|
||||
Fixes casing duplicates (e.g. "Quads" vs "quads") and updates
|
||||
ExerciseMuscle records to point to the canonical muscle entries.
|
||||
|
||||
Usage:
|
||||
python manage.py normalize_muscles # apply changes
|
||||
python manage.py normalize_muscles --dry-run # preview only
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from muscle.models import Muscle, ExerciseMuscle
|
||||
from generator.services.muscle_normalizer import normalize_muscle_name
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Normalize muscle names (fix casing duplicates) and consolidate ExerciseMuscle records.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Preview changes without modifying the database.',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('DRY RUN - no changes will be made.\n'))
|
||||
|
||||
muscles = Muscle.objects.all().order_by('name')
|
||||
self.stdout.write(f'Total muscles in DB: {muscles.count()}\n')
|
||||
|
||||
# Build a mapping: canonical_name -> list of Muscle objects with that canonical name
|
||||
canonical_map = {}
|
||||
for m in muscles:
|
||||
canonical = normalize_muscle_name(m.name)
|
||||
if canonical is None:
|
||||
canonical = m.name.strip().lower()
|
||||
canonical_map.setdefault(canonical, []).append(m)
|
||||
|
||||
# Identify duplicates (canonical names with > 1 Muscle record)
|
||||
duplicates = {k: v for k, v in canonical_map.items() if len(v) > 1}
|
||||
|
||||
if not duplicates:
|
||||
self.stdout.write(self.style.SUCCESS('No duplicate muscles found. Nothing to normalize.'))
|
||||
return
|
||||
|
||||
self.stdout.write(f'Found {len(duplicates)} canonical names with duplicates:\n')
|
||||
|
||||
merged_count = 0
|
||||
reassigned_count = 0
|
||||
|
||||
for canonical, muscle_list in sorted(duplicates.items()):
|
||||
names = [m.name for m in muscle_list]
|
||||
self.stdout.write(f'\n "{canonical}" <- {names}')
|
||||
|
||||
# Keep the first one (or the one whose name already matches canonical)
|
||||
keep = None
|
||||
for m in muscle_list:
|
||||
if m.name == canonical:
|
||||
keep = m
|
||||
break
|
||||
if keep is None:
|
||||
keep = muscle_list[0]
|
||||
|
||||
to_merge = [m for m in muscle_list if m.pk != keep.pk]
|
||||
|
||||
for old_muscle in to_merge:
|
||||
# Reassign ExerciseMuscle records from old_muscle to keep
|
||||
em_records = ExerciseMuscle.objects.filter(muscle=old_muscle)
|
||||
count = em_records.count()
|
||||
|
||||
if count > 0:
|
||||
self.stdout.write(f' Reassigning {count} ExerciseMuscle records: '
|
||||
f'"{old_muscle.name}" (id={old_muscle.pk}) -> '
|
||||
f'"{keep.name}" (id={keep.pk})')
|
||||
if not dry_run:
|
||||
# Check for conflicts (same exercise already linked to keep)
|
||||
for em in em_records:
|
||||
existing = ExerciseMuscle.objects.filter(
|
||||
exercise=em.exercise, muscle=keep
|
||||
).exists()
|
||||
if existing:
|
||||
em.delete()
|
||||
else:
|
||||
em.muscle = keep
|
||||
em.save()
|
||||
reassigned_count += count
|
||||
|
||||
# Rename keep to canonical if needed
|
||||
if keep.name != canonical and not dry_run:
|
||||
keep.name = canonical
|
||||
keep.save()
|
||||
|
||||
# Delete the duplicate
|
||||
self.stdout.write(f' Deleting duplicate: "{old_muscle.name}" (id={old_muscle.pk})')
|
||||
if not dry_run:
|
||||
old_muscle.delete()
|
||||
merged_count += 1
|
||||
|
||||
# Also fix names that aren't duplicates but have wrong casing
|
||||
rename_count = 0
|
||||
for canonical, muscle_list in canonical_map.items():
|
||||
if len(muscle_list) == 1:
|
||||
m = muscle_list[0]
|
||||
if m.name != canonical:
|
||||
self.stdout.write(f'\n Renaming: "{m.name}" -> "{canonical}"')
|
||||
if not dry_run:
|
||||
m.name = canonical
|
||||
m.save()
|
||||
rename_count += 1
|
||||
|
||||
self.stdout.write('\n')
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'DRY RUN complete. Would merge {merged_count} duplicates, '
|
||||
f'reassign {reassigned_count} ExerciseMuscle records, '
|
||||
f'rename {rename_count} muscles.'
|
||||
))
|
||||
else:
|
||||
remaining = Muscle.objects.count()
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'Done. Merged {merged_count} duplicates, '
|
||||
f'reassigned {reassigned_count} ExerciseMuscle records, '
|
||||
f'renamed {rename_count} muscles. '
|
||||
f'{remaining} muscles remaining.'
|
||||
))
|
||||
1042
generator/management/commands/populate_exercise_fields.py
Normal file
1042
generator/management/commands/populate_exercise_fields.py
Normal file
File diff suppressed because it is too large
Load Diff
105
generator/management/commands/recalculate_workout_times.py
Normal file
105
generator/management/commands/recalculate_workout_times.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Recalculates estimated_time on all Workout and Superset records using
|
||||
the corrected estimated_rep_duration values + rest between rounds.
|
||||
|
||||
Formula per superset:
|
||||
active_time = sum(reps * exercise.estimated_rep_duration) + sum(durations)
|
||||
rest_time = rest_between_rounds * (rounds - 1)
|
||||
superset.estimated_time = active_time (stores single-round active time)
|
||||
|
||||
Formula per workout:
|
||||
workout.estimated_time = sum(superset_active_time * rounds + rest_time)
|
||||
|
||||
Usage:
|
||||
python manage.py recalculate_workout_times
|
||||
python manage.py recalculate_workout_times --dry-run
|
||||
python manage.py recalculate_workout_times --rest=45
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from workout.models import Workout
|
||||
from superset.models import Superset, SupersetExercise
|
||||
|
||||
|
||||
DEFAULT_REST_BETWEEN_ROUNDS = 45 # seconds
|
||||
DEFAULT_REP_DURATION = 3.0 # fallback if null
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Recalculate estimated_time on all Workouts and Supersets'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show changes without writing to DB',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--rest',
|
||||
type=int,
|
||||
default=DEFAULT_REST_BETWEEN_ROUNDS,
|
||||
help=f'Rest between rounds in seconds (default: {DEFAULT_REST_BETWEEN_ROUNDS})',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
rest_between_rounds = options['rest']
|
||||
|
||||
workouts = Workout.objects.all()
|
||||
total = workouts.count()
|
||||
updated = 0
|
||||
|
||||
for workout in workouts:
|
||||
supersets = Superset.objects.filter(workout=workout).order_by('order')
|
||||
workout_total_time = 0
|
||||
|
||||
for ss in supersets:
|
||||
exercises = SupersetExercise.objects.filter(superset=ss)
|
||||
active_time = 0.0
|
||||
|
||||
for se in exercises:
|
||||
if se.reps and se.reps > 0:
|
||||
rep_dur = se.exercise.estimated_rep_duration or DEFAULT_REP_DURATION
|
||||
active_time += se.reps * rep_dur
|
||||
elif se.duration and se.duration > 0:
|
||||
active_time += se.duration
|
||||
|
||||
# Rest between rounds (not after the last round)
|
||||
rest_time = rest_between_rounds * max(0, ss.rounds - 1)
|
||||
|
||||
# Superset stores single-round active time
|
||||
old_ss_time = ss.estimated_time
|
||||
ss.estimated_time = active_time
|
||||
if not dry_run:
|
||||
ss.save(update_fields=['estimated_time'])
|
||||
|
||||
# Workout accumulates: active per round * rounds + rest
|
||||
workout_total_time += (active_time * ss.rounds) + rest_time
|
||||
|
||||
old_time = workout.estimated_time
|
||||
new_time = workout_total_time
|
||||
|
||||
if not dry_run:
|
||||
workout.estimated_time = new_time
|
||||
workout.save(update_fields=['estimated_time'])
|
||||
|
||||
updated += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'{"[DRY RUN] " if dry_run else ""}'
|
||||
f'Recalculated {updated}/{total} workouts '
|
||||
f'(rest between rounds: {rest_between_rounds}s)'
|
||||
))
|
||||
|
||||
# Show some examples
|
||||
if not dry_run:
|
||||
self.stdout.write('\nSample workouts:')
|
||||
for w in Workout.objects.order_by('-id')[:5]:
|
||||
mins = w.estimated_time / 60 if w.estimated_time else 0
|
||||
ss_count = Superset.objects.filter(workout=w).count()
|
||||
ex_count = SupersetExercise.objects.filter(superset__workout=w).count()
|
||||
self.stdout.write(
|
||||
f' #{w.id} "{w.name}": {mins:.0f}m '
|
||||
f'({ss_count} supersets, {ex_count} exercises)'
|
||||
)
|
||||
Reference in New Issue
Block a user