Lucas
Improve parser runtime config and check-in state
06feb89
Raw
History Blame Contribute Delete
24.1 kB
from datetime import date
import logging
from math import ceil
import re
from collections.abc import Sequence
from training_coach.models import (
CheckIn,
CompletedSession,
CompletedSet,
Muscle,
PlannedExercise,
PrescribedSet,
SessionPlan,
)
MVP_LOAD_INCREMENT_KG = 1.0
MVP_BASE_TARGET_RIR = 2
MVP_REP_STEP = 2
logger = logging.getLogger(__name__)
INJURY_KEYWORDS = (
"ache",
"aches",
"hurt",
"hurts",
"injured",
"injury",
"pain",
"painful",
"pulled",
"strain",
"strained",
"tear",
"tearing",
"tore",
"torn",
)
MUSCLE_TEXT_MAP = {
"bicep": Muscle.BICEPS_BRACHII,
"biceps": Muscle.BICEPS_BRACHII,
"tricep": Muscle.TRICEPS_BRACHII,
"triceps": Muscle.TRICEPS_BRACHII,
"hamstring": Muscle.HAMSTRINGS,
"hamstrings": Muscle.HAMSTRINGS,
"calf": Muscle.GASTROCNEMIUS,
"calves": Muscle.GASTROCNEMIUS,
"front delt": Muscle.FRONT_DELTOID,
"front shoulder": Muscle.FRONT_DELTOID,
}
EXERCISE_MUSCLE_MAP = {
"dumbbell-pullover": {
Muscle.LATISSIMUS_DORSI,
Muscle.PECTORALIS_MAJOR,
Muscle.TRICEPS_BRACHII,
},
"dumbbell-row": {
Muscle.LATISSIMUS_DORSI,
Muscle.RHOMBOIDS,
Muscle.TRAPEZIUS,
Muscle.REAR_DELTOID,
Muscle.BICEPS_BRACHII,
},
"barbell-incline-bench-press": {
Muscle.PECTORALIS_MAJOR,
Muscle.FRONT_DELTOID,
Muscle.TRICEPS_BRACHII,
},
"dumbbell-incline-chest-fly": {
Muscle.PECTORALIS_MAJOR,
Muscle.FRONT_DELTOID,
},
"goblet-squat": {
Muscle.QUADRICEPS,
Muscle.GLUTEUS_MAXIMUS,
Muscle.ADDUCTORS,
},
"barbell-skullcrusher": {Muscle.TRICEPS_BRACHII},
"dumbbell-lateral-raise": {Muscle.SIDE_DELTOID},
"barbell-triceps-extension": {Muscle.TRICEPS_BRACHII},
"cable-lateral-raise": {Muscle.SIDE_DELTOID},
"barbell-bicep-curl": {
Muscle.BICEPS_BRACHII,
Muscle.BRACHIALIS,
Muscle.FOREARM_FLEXORS,
},
"barbell-hip-thrust": {
Muscle.GLUTEUS_MAXIMUS,
Muscle.HAMSTRINGS,
},
"barbell-standing-calf-raise": {
Muscle.GASTROCNEMIUS,
Muscle.SOLEUS,
},
"barbell-romanian-deadlift": {
Muscle.HAMSTRINGS,
Muscle.GLUTEUS_MAXIMUS,
Muscle.SPINAL_ERECTORS,
},
"ez-bar-biceps-curl": {
Muscle.BICEPS_BRACHII,
Muscle.BRACHIALIS,
Muscle.FOREARM_FLEXORS,
},
"cross-body-hammer-curl": {
Muscle.BRACHIALIS,
Muscle.BICEPS_BRACHII,
Muscle.FOREARM_FLEXORS,
},
}
def _sets(
count: int,
reps_low: int,
reps_high: int | None = None,
target_reps: int | None = None,
) -> list[PrescribedSet]:
high = reps_high if reps_high is not None else reps_low
return [
PrescribedSet(
set_number=set_number,
target_reps_low=reps_low,
target_reps_high=high,
target_reps=target_reps,
)
for set_number in range(1, count + 1)
]
def _template_reps(item: dict) -> tuple[int, int, int | None]:
if "reps" in item:
target_reps = item["reps"]
return max(1, target_reps - MVP_REP_STEP), target_reps + MVP_REP_STEP, target_reps
reps_low = item["reps_low"]
reps_high = item["reps_high"]
return reps_low, reps_high, None
MVP_4_DAY_TEMPLATE = {
1: [
{
"exercise_id": "dumbbell-pullover",
"sets": 3,
"reps": 13,
"rest_seconds": 60,
},
{
"exercise_id": "dumbbell-row",
"sets": 3,
"reps": 16,
"rest_seconds": 60,
},
{
"exercise_id": "barbell-incline-bench-press",
"sets": 3,
"reps": 10,
"rest_seconds": 60,
},
{
"exercise_id": "dumbbell-incline-chest-fly",
"sets": 3,
"reps": 12,
"rest_seconds": 60,
},
{
"exercise_id": "goblet-squat",
"sets": 4,
"reps": 12,
"rest_seconds": 60,
},
],
2: [
{
"exercise_id": "barbell-skullcrusher",
"sets": 3,
"reps": 8,
"rest_seconds": 60,
},
{
"exercise_id": "dumbbell-lateral-raise",
"sets": 3,
"reps": 20,
"rest_seconds": 60,
"notes": "Lean-away DB lateral raise.",
},
{
"exercise_id": "barbell-triceps-extension",
"sets": 3,
"reps_low": 10,
"reps_high": 12,
"rest_seconds": 60,
},
{
"exercise_id": "cable-lateral-raise",
"sets": 3,
"reps": 15,
"rest_seconds": 60,
},
{
"exercise_id": "barbell-bicep-curl",
"sets": 5,
"reps": 10,
"rest_seconds": 60,
},
{
"exercise_id": "barbell-hip-thrust",
"sets": 5,
"reps": 10,
"rest_seconds": 60,
},
{
"exercise_id": "barbell-standing-calf-raise",
"sets": 4,
"reps": 10,
"rest_seconds": 60,
},
],
3: [
{
"exercise_id": "barbell-incline-bench-press",
"sets": 3,
"reps": 8,
"rest_seconds": 60,
"notes": "Wide grip.",
},
{
"exercise_id": "dumbbell-incline-chest-fly",
"sets": 3,
"reps": 10,
"rest_seconds": 60,
},
{
"exercise_id": "dumbbell-row",
"sets": 3,
"reps": 24,
"rest_seconds": 60,
},
{
"exercise_id": "dumbbell-pullover",
"sets": 3,
"reps": 15,
"rest_seconds": 60,
},
{
"exercise_id": "barbell-romanian-deadlift",
"sets": 4,
"reps": 10,
"rest_seconds": 60,
},
],
4: [
{
"exercise_id": "ez-bar-biceps-curl",
"sets": 3,
"reps": 12,
"rest_seconds": 60,
},
{
"exercise_id": "dumbbell-lateral-raise",
"sets": 3,
"reps": 20,
"rest_seconds": 60,
"notes": "Lean away.",
},
{
"exercise_id": "cross-body-hammer-curl",
"sets": 3,
"reps": 18,
"rest_seconds": 60,
},
{
"exercise_id": "cable-lateral-raise",
"sets": 3,
"reps": 18,
"rest_seconds": 60,
},
{
"exercise_id": "barbell-triceps-extension",
"sets": 3,
"reps": 12,
"rest_seconds": 60,
},
{
"exercise_id": "goblet-squat",
"sets": 4,
"reps": 12,
"rest_seconds": 60,
},
{
"exercise_id": "barbell-standing-calf-raise",
"sets": 4,
"reps": 10,
"rest_seconds": 60,
},
],
}
def build_session_for_day(
day_number: int,
session_date: date,
check_in: CheckIn,
completed_sessions: Sequence[CompletedSession] | None = None,
) -> SessionPlan:
if day_number not in MVP_4_DAY_TEMPLATE:
raise ValueError("day_number must be between 1 and 4")
logger.info(
"event=engine_build_start day_number=%s history_sessions=%s "
"time_minutes=%s pain_or_injury=%s pain_issues=%s",
day_number,
len(completed_sessions or []),
check_in.time_available_minutes,
check_in.pain_or_injury,
len(check_in.pain_issues),
)
planned_exercises = []
for order, item in enumerate(MVP_4_DAY_TEMPLATE[day_number], start=1):
reps_low, reps_high, target_reps = _template_reps(item)
planned_exercises.append(
PlannedExercise(
exercise_id=item["exercise_id"],
order=order,
prescribed_sets=_sets(item["sets"], reps_low, reps_high, target_reps),
rest_seconds=item["rest_seconds"],
notes=item.get("notes", ""),
)
)
template_set_count = sum(
len(exercise.prescribed_sets)
for exercise in planned_exercises
)
logger.info(
"event=engine_template_loaded day_number=%s exercises=%s sets=%s",
day_number,
len(planned_exercises),
template_set_count,
)
if completed_sessions:
planned_exercises = [
apply_double_progression(exercise, completed_sessions)
for exercise in planned_exercises
]
logger.info(
"event=engine_progression_applied day_number=%s exercises=%s",
day_number,
len(planned_exercises),
)
before_pain_count = len(planned_exercises)
planned_exercises, pain_filter_notes = apply_pain_filter(
planned_exercises,
check_in,
)
if pain_filter_notes:
logger.info(
"event=engine_pain_filter_applied before_exercises=%s after_exercises=%s",
before_pain_count,
len(planned_exercises),
)
session_notes = [
f"MVP fixed day {day_number} template.",
"Double progression applies +1 kg after all sets hit the top rep target.",
]
if pain_filter_notes:
session_notes.extend(pain_filter_notes)
before_time_sets = sum(
len(exercise.prescribed_sets)
for exercise in planned_exercises
)
planned_exercises, time_compression_notes = apply_time_compression(
planned_exercises,
check_in,
)
if time_compression_notes:
session_notes.extend(time_compression_notes)
after_time_sets = sum(
len(exercise.prescribed_sets)
for exercise in planned_exercises
)
logger.info(
"event=engine_time_compression_applied before_sets=%s after_sets=%s time_minutes=%s",
before_time_sets,
after_time_sets,
check_in.time_available_minutes,
)
before_readiness_sets = sum(
len(exercise.prescribed_sets)
for exercise in planned_exercises
)
planned_exercises, readiness_notes = apply_readiness_modifier(
planned_exercises,
check_in,
)
if readiness_notes:
session_notes.extend(readiness_notes)
after_readiness_sets = sum(
len(exercise.prescribed_sets)
for exercise in planned_exercises
)
logger.info(
"event=engine_readiness_applied before_sets=%s after_sets=%s",
before_readiness_sets,
after_readiness_sets,
)
final_set_count = sum(
len(exercise.prescribed_sets)
for exercise in planned_exercises
)
logger.info(
"event=engine_build_complete day_number=%s exercises=%s sets=%s notes=%s",
day_number,
len(planned_exercises),
final_set_count,
len(session_notes),
)
return SessionPlan(
date=session_date,
check_in=check_in,
planned_exercises=planned_exercises,
notes=" ".join(session_notes),
)
def next_day_after(day_number: int) -> int:
if day_number not in MVP_4_DAY_TEMPLATE:
raise ValueError("day_number must be between 1 and 4")
return 1 if day_number == 4 else day_number + 1
def suggest_next_training_day(completed_sessions: Sequence[CompletedSession]) -> int:
if not completed_sessions:
return 1
return next_day_after(completed_sessions[-1].day_number)
def _latest_completed_sets_for_exercise(
completed_sessions: Sequence[CompletedSession],
exercise_id: str,
) -> list[CompletedSet]:
for session in reversed(completed_sessions):
sets = [
completed_set
for completed_set in session.completed_sets
if completed_set.exercise_id == exercise_id
]
if sets:
return sorted(sets, key=lambda completed_set: completed_set.set_number)
return []
def _hit_top_reps(
planned_exercise: PlannedExercise,
completed_sets: Sequence[CompletedSet],
) -> bool:
completed_by_number = {
completed_set.set_number: completed_set for completed_set in completed_sets
}
for prescribed_set in planned_exercise.prescribed_sets:
completed_set = completed_by_number.get(prescribed_set.set_number)
if completed_set is None:
return False
if completed_set.actual_reps < prescribed_set.target_reps_high:
return False
return True
def _next_target_reps(
prescribed_set: PrescribedSet,
completed_set: CompletedSet | None,
should_increase_load: bool,
) -> int | None:
if completed_set is None:
return prescribed_set.target_reps
if should_increase_load:
return prescribed_set.target_reps_low
return min(
prescribed_set.target_reps_high,
max(prescribed_set.target_reps_low, completed_set.actual_reps + MVP_REP_STEP),
)
def apply_double_progression(
planned_exercise: PlannedExercise,
completed_sessions: Sequence[CompletedSession],
load_increment_kg: float = MVP_LOAD_INCREMENT_KG,
) -> PlannedExercise:
completed_sets = _latest_completed_sets_for_exercise(
completed_sessions,
planned_exercise.exercise_id,
)
if not completed_sets:
return planned_exercise
completed_by_number = {
completed_set.set_number: completed_set for completed_set in completed_sets
}
should_increase_load = _hit_top_reps(planned_exercise, completed_sets)
prescribed_sets = []
for prescribed_set in planned_exercise.prescribed_sets:
completed_set = completed_by_number.get(prescribed_set.set_number)
target_load = None
target_reps = _next_target_reps(
prescribed_set,
completed_set,
should_increase_load,
)
if completed_set is not None:
target_load = completed_set.actual_load
if should_increase_load:
target_load += load_increment_kg
prescribed_sets.append(
prescribed_set.model_copy(
update={
"target_load": target_load,
"target_reps": target_reps,
}
)
)
progression_note = (
"Progression: top of range hit; add 1 kg and reset reps to the low end."
if should_increase_load
else f"Progression: repeat latest load and add up to {MVP_REP_STEP} reps."
)
notes = (
f"{planned_exercise.notes} {progression_note}".strip()
if planned_exercise.notes
else progression_note
)
return planned_exercise.model_copy(
update={"prescribed_sets": prescribed_sets, "notes": notes}
)
def _pain_muscles(check_in: CheckIn) -> set[Muscle]:
explicit_muscles = {
issue.affected_muscle
for issue in check_in.pain_issues
if issue.affected_muscle is not None
}
manual_text = f"{check_in.raw_text} {check_in.soreness}"
inferred_muscles = set()
if check_in.pain_or_injury == "yes" or _mentions_injury(manual_text):
inferred_muscles = _infer_muscles_from_text(manual_text)
if check_in.pain_or_injury != "yes" and not _mentions_injury(manual_text):
return set()
return explicit_muscles.union(inferred_muscles)
def _contains_word(text: str, word: str) -> bool:
return re.search(rf"\b{re.escape(word)}\b", text.lower()) is not None
def _mentions_injury(text: str) -> bool:
return any(_contains_word(text, keyword) for keyword in INJURY_KEYWORDS)
def _infer_muscles_from_text(text: str) -> set[Muscle]:
normalized = text.lower()
return {
muscle
for phrase, muscle in MUSCLE_TEXT_MAP.items()
if phrase in normalized
}
def apply_pain_filter(
planned_exercises: Sequence[PlannedExercise],
check_in: CheckIn,
) -> tuple[list[PlannedExercise], list[str]]:
painful_muscles = _pain_muscles(check_in)
if not painful_muscles:
return list(planned_exercises), []
kept_exercises = []
removed_exercise_ids = []
for planned_exercise in planned_exercises:
exercise_muscles = EXERCISE_MUSCLE_MAP.get(planned_exercise.exercise_id, set())
if exercise_muscles.intersection(painful_muscles):
removed_exercise_ids.append(planned_exercise.exercise_id)
continue
kept_exercises.append(planned_exercise)
reordered_exercises = [
exercise.model_copy(update={"order": order})
for order, exercise in enumerate(kept_exercises, start=1)
]
if not removed_exercise_ids:
return reordered_exercises, []
removed_text = ", ".join(removed_exercise_ids)
muscles_text = ", ".join(sorted(muscle.value for muscle in painful_muscles))
return reordered_exercises, [
f"Pain filter removed {removed_text} because of affected muscle(s): {muscles_text}."
]
def _copy_first_sets(planned_exercise: PlannedExercise, set_count: int) -> PlannedExercise:
prescribed_sets = [
prescribed_set.model_copy(update={"set_number": set_number})
for set_number, prescribed_set in enumerate(
planned_exercise.prescribed_sets[:set_count],
start=1,
)
]
return planned_exercise.model_copy(update={"prescribed_sets": prescribed_sets})
def _compress_to_target_sets(
planned_exercises: Sequence[PlannedExercise],
target_set_count: int,
minimum_sets_per_kept_exercise: int,
) -> list[PlannedExercise]:
set_counts = [len(exercise.prescribed_sets) for exercise in planned_exercises]
current_set_count = sum(set_counts)
for index in range(len(set_counts) - 1, -1, -1):
while (
current_set_count > target_set_count
and set_counts[index] > minimum_sets_per_kept_exercise
):
set_counts[index] -= 1
current_set_count -= 1
for index in range(len(set_counts) - 1, -1, -1):
while current_set_count > target_set_count and set_counts[index] > 0:
set_counts[index] -= 1
current_set_count -= 1
compressed = []
for exercise, set_count in zip(planned_exercises, set_counts):
if set_count <= 0:
continue
compressed.append(_copy_first_sets(exercise, set_count))
return [
exercise.model_copy(update={"order": order})
for order, exercise in enumerate(compressed, start=1)
]
def apply_time_compression(
planned_exercises: Sequence[PlannedExercise],
check_in: CheckIn,
) -> tuple[list[PlannedExercise], list[str]]:
available_minutes = check_in.time_available_minutes
if available_minutes is None or available_minutes >= 60:
return list(planned_exercises), []
total_sets = sum(len(exercise.prescribed_sets) for exercise in planned_exercises)
if available_minutes < 30:
candidate_exercises = list(planned_exercises[:4])
target_set_count = min(8, sum(len(exercise.prescribed_sets) for exercise in candidate_exercises))
compressed = _compress_to_target_sets(
candidate_exercises,
target_set_count=target_set_count,
minimum_sets_per_kept_exercise=1,
)
return compressed, [
"Time compression: under 30 minutes, kept the first four exercises and capped work at 8 sets."
]
if available_minutes < 45:
target_set_count = max(1, ceil(total_sets * 0.60))
compressed = _compress_to_target_sets(
planned_exercises,
target_set_count=target_set_count,
minimum_sets_per_kept_exercise=1,
)
return compressed, [
f"Time compression: {available_minutes} minutes, reduced planned sets from {total_sets} to {target_set_count}."
]
target_set_count = max(1, ceil(total_sets * 0.75))
compressed = _compress_to_target_sets(
planned_exercises,
target_set_count=target_set_count,
minimum_sets_per_kept_exercise=2,
)
return compressed, [
f"Time compression: {available_minutes} minutes, reduced planned sets from {total_sets} to {target_set_count}."
]
def _sleep_quality_score(check_in: CheckIn) -> int:
return {
"poor": 1,
"okay": 3,
"good": 5,
None: 3,
}[check_in.sleep_quality]
def _sleep_duration_score(check_in: CheckIn) -> int:
hours = check_in.sleep_hours
if hours is None:
return 3
if hours < 5:
return 1
if hours < 6:
return 2
if hours < 7:
return 3
if hours <= 8.5:
return 4
return 5
def _energy_score(check_in: CheckIn) -> int:
return {
"low": 1,
"medium": 3,
"high": 5,
None: 3,
}[check_in.energy_level]
def _soreness_score(check_in: CheckIn) -> int:
soreness = check_in.soreness.lower()
if any(word in soreness for word in ("severe", "extreme", "very sore")):
return 1
if any(word in soreness for word in ("sore", "tight", "stiff", "ache")):
return 2
if any(phrase in soreness for phrase in ("no soreness", "not sore", "none")):
return 5
return 3
def _mood_stress_score(check_in: CheckIn) -> int:
return {
"stressed": 1,
"neutral": 3,
"ready": 5,
None: 3,
}[check_in.mood_stress]
def readiness_score(check_in: CheckIn) -> float:
return (
_sleep_quality_score(check_in) * 0.20
+ _sleep_duration_score(check_in) * 0.15
+ _energy_score(check_in) * 0.25
+ _soreness_score(check_in) * 0.15
+ _mood_stress_score(check_in) * 0.15
+ _mood_stress_score(check_in) * 0.10
)
def _readiness_modifier(check_in: CheckIn) -> tuple[float, int, str]:
score = readiness_score(check_in)
if score < 2.5:
return 0.50, 2, f"Readiness: very low ({score:.1f}/5), reduced sets by about 50% and added +2 RIR."
if score < 3.0:
return 0.80, 1, f"Readiness: low ({score:.1f}/5), reduced sets by about 20% and added +1 RIR."
if score > 4.2:
return 1.00, 0, f"Readiness: high ({score:.1f}/5), MVP keeps the plan unchanged."
return 1.00, 0, f"Readiness: normal ({score:.1f}/5), plan unchanged."
def _with_target_rir(planned_exercises: Sequence[PlannedExercise], target_rir: int) -> list[PlannedExercise]:
exercises = []
for exercise in planned_exercises:
prescribed_sets = [
prescribed_set.model_copy(update={"target_rir": target_rir})
for prescribed_set in exercise.prescribed_sets
]
exercises.append(exercise.model_copy(update={"prescribed_sets": prescribed_sets}))
return exercises
def apply_readiness_modifier(
planned_exercises: Sequence[PlannedExercise],
check_in: CheckIn,
) -> tuple[list[PlannedExercise], list[str]]:
set_multiplier, rir_delta, note = _readiness_modifier(check_in)
target_rir = MVP_BASE_TARGET_RIR + rir_delta
if check_in.sleep_hours is not None and check_in.sleep_hours < 5:
set_multiplier = min(set_multiplier, 0.75)
target_rir = max(target_rir, 3)
note = f"{note} Sleep override: under 5 hours, capped stress at minimum RIR 3."
total_sets = sum(len(exercise.prescribed_sets) for exercise in planned_exercises)
if set_multiplier < 1:
target_set_count = max(1, ceil(total_sets * set_multiplier))
planned_exercises = _compress_to_target_sets(
planned_exercises,
target_set_count=target_set_count,
minimum_sets_per_kept_exercise=1,
)
planned_exercises = _with_target_rir(planned_exercises, target_rir)
return planned_exercises, [note]