""" Rule-based training plan generator. Generates a weekly ExercisePlan from a UserFitnessProfile by applying: - Goal-based templates (weight_loss, muscle_gain, maintain) - Difficulty gating (beginner/intermediate/advanced) - Equipment filtering - Intensity-driven days-per-week - BMR/TDEE calculation """ import math from datetime import date from typing import List, Dict, Optional from sqlmodel import Session, select from models import ( UserFitnessProfile, ExercisePlan, PlanDay, PlanDayExercise, Exercise, GoalEnum, SkillLevelEnum, IntensityEnum, DifficultyLevelEnum, ExerciseCategory, ExerciseDifficulty, ) # ── BMR / TDEE helpers ───────────────────────────────────────────────────── def _calculate_bmr(gender: str, weight: float, height: float, age: int) -> float: """Mifflin-St Jeor equation.""" if gender == "male": return 10 * weight + 6.25 * height - 5 * age + 5 else: return 10 * weight + 6.25 * height - 5 * age - 161 def _calculate_tdee(bmr: float, intensity: str) -> float: multipliers = {"low": 1.375, "medium": 1.55, "high": 1.725} return bmr * multipliers.get(intensity, 1.55) def _calculate_macros(goal: str, tdee: float) -> dict: """Return {kcal, protein_g, carbs_g, fat_g}.""" if goal == GoalEnum.weight_loss: kcal = tdee - 500 elif goal == GoalEnum.muscle_gain: kcal = tdee + 300 else: kcal = tdee protein_ratio = 0.30 if goal == GoalEnum.muscle_gain else 0.25 fat_ratio = 0.25 carb_ratio = 1.0 - protein_ratio - fat_ratio return { "kcal": round(kcal), "protein_g": round(kcal * protein_ratio / 4), "carbs_g": round(kcal * carb_ratio / 4), "fat_g": round(kcal * fat_ratio / 9), } # ── Intensity → days per week ────────────────────────────────────────────── _DAYS_MAP = { IntensityEnum.low: 3, IntensityEnum.medium: 4, IntensityEnum.high: 5, } def _days_per_week(intensity: str) -> int: return _DAYS_MAP.get(intensity, 4) # ── Rest-day placement ───────────────────────────────────────────────────── def _rest_days(days_active: int) -> List[int]: """Return day-of-week indices (0=Mon … 6=Sun) that should be rest days.""" all_days = list(range(7)) if days_active >= 7: return [] # Spread rest days evenly; always include Sunday (6) as rest when possible n_rest = 7 - days_active rest = [] step = 7 / n_rest for i in range(n_rest): d = int((6 + i * step) % 7) if d not in rest: rest.append(d) else: # fallback: pick next available for fallback in all_days: if fallback not in rest: rest.append(fallback) break return rest # ── Goal-based set/rep schemes ────────────────────────────────────────────── def _set_rep_scheme(goal: str, difficulty: str) -> Dict[str, int]: """Return (sets, reps, duration_seconds) template based on goal.""" if goal == GoalEnum.weight_loss: return {"sets": 3, "reps": 15, "duration": 0} elif goal == GoalEnum.muscle_gain: return {"sets": 4, "reps": 10, "duration": 0} else: # maintain return {"sets": 3, "reps": 12, "duration": 0} # ── Allowed difficulty levels based on skill ──────────────────────────────── _ALLOWED_DIFFICULTIES = { SkillLevelEnum.beginner: [ExerciseDifficulty.beginner], SkillLevelEnum.intermediate: [ExerciseDifficulty.beginner, ExerciseDifficulty.intermediate], SkillLevelEnum.advanced: [ExerciseDifficulty.beginner, ExerciseDifficulty.intermediate, ExerciseDifficulty.advanced], } # ── Muscle-group split templates ──────────────────────────────────────────── _DAILY_FOCUS = { GoalEnum.weight_loss: [ ["kardio", "cardio", "perut", "inti", "core", "full body"], # Cardio & Core Focus ["dada", "chest", "bahu", "shoulders", "triceps", "punggung", "kardio", "cardio"], # Upper & Cardio ["kaki", "legs", "bokong", "glutes", "kardio", "cardio"], # Lower & Cardio ["full_body", "full body", "chest", "dada", "kaki", "legs"], # Full Body HIIT ], GoalEnum.muscle_gain: [ ["dada", "chest", "bahu", "shoulders", "triceps", "punggung", "back", "biceps", "lengan"], # Upper Body ["kaki", "legs", "bokong", "glutes", "perut", "inti", "core"], # Lower Body & Core ["full_body", "full body", "chest", "dada", "kaki", "legs"], # Full Body Power ], GoalEnum.maintain: [ ["dada", "chest", "bahu", "shoulders", "punggung", "perut", "inti", "core"], # Upper & Core ["kaki", "legs", "bokong", "glutes", "kardio", "cardio"], # Lower & Cardio ["full_body", "full body"], # Full Body ], } def _match_exercise_focus(exercise: Exercise, focus_groups: List[str]) -> bool: """Check if an exercise targets any of the desired muscle groups.""" all_muscles = [m.lower() for m in (exercise.muscleGroups or []) + (exercise.secondaryMuscles or [])] # If the focus group requests full body, we can just allow exercises that are full body or major compound if "full_body" in focus_groups or "full body" in focus_groups: return True return any(fg.lower() in " ".join(all_muscles) for fg in focus_groups) # ── Main generator ───────────────────────────────────────────────────────── def generate_plan(profile: UserFitnessProfile, session: Session, selected_days: List[str] = None, applied_constraints: List[str] = None) -> ExercisePlan: """ Generate a complete weekly ExercisePlan for the given profile. Persists ExercisePlan, PlanDay, and PlanDayExercise rows. Returns the created ExercisePlan. """ # 1. Calculate & persist metabolic stats bmr = _calculate_bmr(profile.gender.value, profile.weight, profile.height, profile.age) tdee = _calculate_tdee(bmr, profile.intensity.value) macros = _calculate_macros(profile.goal.value, tdee) profile.bmr = round(bmr, 1) profile.tdee = round(tdee, 1) profile.target_daily_kcal = macros["kcal"] profile.macros_json = macros session.add(profile) # 2. Deactivate previous plans old_plans = session.exec( select(ExercisePlan).where( ExercisePlan.user_id == profile.user_id, ExercisePlan.is_active == True, ) ).all() for op in old_plans: op.is_active = False session.add(op) # 3. Create plan header DAY_INDEX = { "senin": 0, "selasa": 1, "rabu": 2, "kamis": 3, "jumat": 4, "sabtu": 5, "minggu": 6, } if selected_days: day_indices = {DAY_INDEX[d] for d in selected_days if d in DAY_INDEX} rest_indices = {i for i in range(7) if i not in day_indices} active_days = len(day_indices) else: active_days = _days_per_week(profile.intensity.value) rest_indices = set(_rest_days(active_days)) plan = ExercisePlan( user_id=profile.user_id, fitness_profile_id=profile.id, is_active=True, goal=profile.goal, days_per_week=active_days, start_date=date.today(), difficulty_level=profile.difficultyLevel, applied_constraints=applied_constraints if applied_constraints is not None else (profile.equipment or []), ) session.add(plan) session.flush() # get plan.id # 5. Load eligible exercises allowed_diff = _ALLOWED_DIFFICULTIES.get(profile.skillLevel, [ExerciseDifficulty.beginner]) all_exercises = session.exec( select(Exercise).where(Exercise.isActive == True) ).all() # Filter by difficulty eligible = [e for e in all_exercises if e.difficulty in allowed_diff] # Filter by equipment user_equipment = set(eq.lower() for eq in (profile.equipment or [])) if user_equipment: def _equip_ok(ex: Exercise) -> bool: req = set(r.lower() for r in (ex.equipmentRequired or [])) # exercise is OK if it requires no equipment, or all required equipment is available return len(req) == 0 or req.issubset(user_equipment) eligible = [e for e in eligible if _equip_ok(e)] # Index by category for quick lookup by_category: Dict[str, List[Exercise]] = {} for ex in eligible: by_category.setdefault(ex.category.value, []).append(ex) # 6. Build days focus_templates = _DAILY_FOCUS.get(profile.goal, _DAILY_FOCUS[GoalEnum.maintain]) scheme = _set_rep_scheme(profile.goal.value, profile.difficultyLevel.value) for dow in range(7): is_rest = dow in rest_indices plan_day = PlanDay( plan_id=plan.id, day_of_week=dow, is_rest_day=is_rest, ) session.add(plan_day) session.flush() if is_rest: continue # Pick focus for this training day training_day_index = sum(1 for d in range(dow) if d not in rest_indices) focus = focus_templates[training_day_index % len(focus_templates)] # Select exercises matching focus candidates = [e for e in eligible if _match_exercise_focus(e, focus)] # Fallback: if not enough candidates, use all eligible if len(candidates) < 2: candidates = eligible # Pick up to 4-6 exercises for the day (varied by intensity) n_exercises = min(6 if profile.intensity in (IntensityEnum.medium, IntensityEnum.high) else 4, len(candidates)) # Rotate through candidates deterministically based on day start_idx = dow * 2 % max(len(candidates), 1) selected = [] for i in range(n_exercises): idx = (start_idx + i) % len(candidates) selected.append(candidates[idx]) # Create PlanDayExercise entries for order, exercise in enumerate(selected, start=1): sets = scheme["sets"] reps = scheme["reps"] duration = None # Plank-type exercises use duration instead of reps if "plank" in exercise.name.lower(): reps = None duration = 30 if profile.skillLevel == SkillLevelEnum.beginner else 45 if profile.skillLevel == SkillLevelEnum.intermediate else 60 pde = PlanDayExercise( plan_day_id=plan_day.id, exercise_id=exercise.id, order=order, target_sets=sets, target_reps=reps, target_duration_seconds=duration, ) session.add(pde) session.commit() session.refresh(plan) return plan def get_active_plan(user_id: str, session: Session) -> Optional[dict]: """ Return the user's active plan as a flat dict matching the mobile app's ActiveExercisePlan.fromJson() format: { "id": str, "goal": str, "days_per_week": int, "difficulty_level": str, "days": [ { "day_of_week": int, "is_rest_day": bool, "exercises": [...] } ] } """ plan = session.exec( select(ExercisePlan).where( ExercisePlan.user_id == user_id, ExercisePlan.is_active == True, ) ).first() if not plan: return None days = session.exec( select(PlanDay).where(PlanDay.plan_id == plan.id).order_by(PlanDay.day_of_week) ).all() # Determine default rest seconds based on difficulty _rest_map = {"level_1": 45, "level_2": 30, "level_3": 20} default_rest = _rest_map.get(plan.difficulty_level.value, 30) days_data = [] for day in days: exercises_raw = session.exec( select(PlanDayExercise) .where(PlanDayExercise.plan_day_id == day.id) .order_by(PlanDayExercise.order) ).all() exercises = [] for pde in exercises_raw: ex = session.get(Exercise, pde.exercise_id) exercises.append({ "id": str(pde.exercise_id), "name": ex.name if ex else "Unknown", "description": ex.description if ex else "", "category": ex.category.value if ex else "strength", "muscleGroups": ex.muscleGroups if ex else [], "order": pde.order, # Field names matching Flutter ExerciseTarget.fromJson() "sets": pde.target_sets, "reps": pde.target_reps, "target_duration_seconds": pde.target_duration_seconds, "rest_seconds": default_rest, # Legacy field names (backward compat with workouts.py) "target_sets": pde.target_sets, "target_reps": pde.target_reps, "exerciseType": _exercise_type_from_name(ex.name if ex else ""), "poseAngle": _pose_angle_from_name(ex.name if ex else ""), }) days_data.append({ "day_of_week": day.day_of_week, "is_rest_day": day.is_rest_day, "exercises": exercises, }) profile = session.get(UserFitnessProfile, plan.fitness_profile_id) # Flat structure matching Flutter ActiveExercisePlan.fromJson() return { "id": plan.id, "goal": plan.goal.value, "days_per_week": plan.days_per_week, "start_date": plan.start_date.isoformat(), "difficulty_level": plan.difficulty_level.value, "nutrition": { "bmr": profile.bmr if profile else 0.0, "tdee": profile.tdee if profile else 0.0, "target_daily_kcal": profile.target_daily_kcal if profile else 0.0, "macros": profile.macros_json if profile else {}, }, "days": days_data, } def _exercise_type_from_name(name: str) -> str: """Map exercise name to mobile-app exerciseType string.""" lower = name.lower() if "push" in lower and "up" in lower: return "pushup" elif "sit" in lower and "up" in lower: return "situp" elif "squat" in lower: return "squat" elif "plank" in lower: return "plank" elif "lunge" in lower: return "lunge" elif "burpee" in lower: return "burpee" elif "climb" in lower or "mountain" in lower: return "mountain_climber" return "other" def _pose_angle_from_name(name: str) -> str: """Determine recommended camera angle from exercise name.""" lower = name.lower() if any(w in lower for w in ["push", "squat", "lunge", "plank", "burpee", "climb"]): return "side" if any(w in lower for w in ["sit", "crunch"]): return "side" return "front"