276 lines
8.6 KiB
Python
276 lines
8.6 KiB
Python
from typing import Any
|
||
|
||
from app.fitness.activity_budget import workouts_kcal_total
|
||
|
||
DEFAULT_NEAT_KCAL = 200.0
|
||
NEAT_KCAL_MIN = 200.0
|
||
NEAT_KCAL_MAX = 300.0
|
||
KCAL_PER_STEP_REF = 0.04 / 86 # ~0.04 kcal/step at 86 kg
|
||
WATER_ML_PER_KG = 33 # middle of 30–35 ml/kg range
|
||
|
||
GOAL_CALORIE_ADJUST = {
|
||
"lose": -500,
|
||
"maintain": 0,
|
||
"gain": 300,
|
||
}
|
||
|
||
PROTEIN_G_PER_KG = {
|
||
"lose": 2.2,
|
||
"maintain": 1.8,
|
||
"gain": 1.8,
|
||
}
|
||
FAT_G_PER_KG = 1.0
|
||
|
||
EXPECTED_LOOKBACK_DAYS = 7
|
||
EXPECTED_MIN_DAYS_WITH_DATA = 3
|
||
DEFAULT_SESSION_KCAL = 350.0
|
||
|
||
ACTIVITY_LEVEL_STEPS: dict[str, int] = {
|
||
"sedentary": 5000,
|
||
"moderate": 8000,
|
||
"active": 10000,
|
||
"very_active": 12000,
|
||
}
|
||
|
||
|
||
def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float:
|
||
base = 10 * weight_kg + 6.25 * height_cm - 5 * age
|
||
if sex.lower() in ("m", "male", "м", "мужской"):
|
||
return base + 5
|
||
return base - 161
|
||
|
||
|
||
def neat_base_kcal(profile: dict[str, Any]) -> float:
|
||
raw = profile.get("neat_base_kcal")
|
||
if raw is not None:
|
||
return max(NEAT_KCAL_MIN, min(NEAT_KCAL_MAX, float(raw)))
|
||
return DEFAULT_NEAT_KCAL
|
||
|
||
|
||
def steps_kcal(*, steps: int, weight_kg: float) -> float:
|
||
if steps <= 0:
|
||
return 0.0
|
||
return round(steps * weight_kg * KCAL_PER_STEP_REF, 1)
|
||
|
||
|
||
def bmi(weight_kg: float, height_cm: float) -> float:
|
||
if height_cm <= 0:
|
||
return 0.0
|
||
h = height_cm / 100
|
||
return weight_kg / (h * h)
|
||
|
||
|
||
def water_target_l(weight_kg: float) -> float:
|
||
return round(weight_kg * WATER_ML_PER_KG / 1000, 1)
|
||
|
||
|
||
def macro_targets(
|
||
calorie_target: float,
|
||
weight_kg: float,
|
||
goal: str = "maintain",
|
||
) -> dict[str, float]:
|
||
protein_g = round(weight_kg * PROTEIN_G_PER_KG.get(goal, 1.8), 0)
|
||
fat_g = round(weight_kg * FAT_G_PER_KG, 0)
|
||
protein_cal = protein_g * 4
|
||
fat_cal = fat_g * 9
|
||
carbs_g = max(0, round((calorie_target - protein_cal - fat_cal) / 4, 0))
|
||
return {"protein_g": protein_g, "fat_g": fat_g, "carbs_g": carbs_g}
|
||
|
||
|
||
def one_rep_max(weight_kg: float, reps: int) -> float:
|
||
if reps <= 0:
|
||
return weight_kg
|
||
if reps == 1:
|
||
return weight_kg
|
||
return round(weight_kg * (1 + reps / 30), 1)
|
||
|
||
|
||
def _profile_fields(profile: dict[str, Any]) -> tuple[float, float, int, str, str]:
|
||
weight = float(profile.get("weight_kg") or 70)
|
||
height = float(profile.get("height_cm") or 170)
|
||
age = int(profile.get("age") or 30)
|
||
sex = str(profile.get("sex") or "male")
|
||
goal = str(profile.get("goal") or "maintain")
|
||
return weight, height, age, sex, goal
|
||
|
||
|
||
def compute_tdee(
|
||
profile: dict[str, Any],
|
||
*,
|
||
steps_total: int = 0,
|
||
workouts: list[dict[str, Any]] | None = None,
|
||
) -> dict[str, float]:
|
||
weight, height, age, sex, _ = _profile_fields(profile)
|
||
bmr = bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age)
|
||
neat = neat_base_kcal(profile)
|
||
s_kcal = steps_kcal(steps=steps_total, weight_kg=weight)
|
||
w_kcal = workouts_kcal_total(workouts or [], weight_kg=weight)
|
||
tdee_val = bmr + neat + s_kcal + w_kcal
|
||
return {
|
||
"bmr": round(bmr, 0),
|
||
"neat_kcal": round(neat, 0),
|
||
"steps_kcal": s_kcal,
|
||
"workout_kcal": w_kcal,
|
||
"tdee": round(tdee_val, 0),
|
||
}
|
||
|
||
|
||
def compute_daily_targets(
|
||
profile: dict[str, Any],
|
||
*,
|
||
steps_total: int = 0,
|
||
workouts: list[dict[str, Any]] | None = None,
|
||
) -> dict[str, Any]:
|
||
weight, height, age, sex, goal = _profile_fields(profile)
|
||
breakdown = compute_tdee(profile, steps_total=steps_total, workouts=workouts)
|
||
calorie_target = round(breakdown["tdee"] + GOAL_CALORIE_ADJUST.get(goal, 0), 0)
|
||
macros = macro_targets(calorie_target, weight, goal)
|
||
water = water_target_l(weight)
|
||
|
||
return {
|
||
**breakdown,
|
||
"calorie_target": calorie_target,
|
||
"protein_g": macros["protein_g"],
|
||
"fat_g": macros["fat_g"],
|
||
"carbs_g": macros["carbs_g"],
|
||
"water_l": water,
|
||
"bmi": round(bmi(weight, height), 1),
|
||
"steps": steps_total,
|
||
}
|
||
|
||
|
||
def targets_to_api(daily: dict[str, Any]) -> dict[str, float]:
|
||
return {
|
||
"calories": daily["calorie_target"],
|
||
"protein_g": daily["protein_g"],
|
||
"fat_g": daily["fat_g"],
|
||
"carbs_g": daily["carbs_g"],
|
||
"water_ml": round(daily["water_l"] * 1000),
|
||
}
|
||
|
||
|
||
def tdee_breakdown_to_api(daily: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"bmr": daily["bmr"],
|
||
"neat_kcal": daily["neat_kcal"],
|
||
"steps_kcal": daily["steps_kcal"],
|
||
"workout_kcal": daily["workout_kcal"],
|
||
"tdee": daily["tdee"],
|
||
"calorie_target": daily["calorie_target"],
|
||
"steps": daily.get("steps", 0),
|
||
}
|
||
|
||
|
||
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
|
||
"""Rest-day targets (BMR + NEAT, no steps/workouts) for profile storage."""
|
||
daily = compute_daily_targets(profile, steps_total=0, workouts=[])
|
||
return {
|
||
"bmr": daily["bmr"],
|
||
"tdee": daily["tdee"],
|
||
"bmi": daily["bmi"],
|
||
"neat_kcal": daily["neat_kcal"],
|
||
"steps_kcal": 0,
|
||
"workout_kcal": 0,
|
||
"calorie_target": daily["calorie_target"],
|
||
"protein_g": daily["protein_g"],
|
||
"fat_g": daily["fat_g"],
|
||
"carbs_g": daily["carbs_g"],
|
||
"water_l": daily["water_l"],
|
||
}
|
||
|
||
|
||
def _activity_level_steps(activity_level: str | None) -> int:
|
||
key = (activity_level or "moderate").lower().replace("-", "_")
|
||
return ACTIVITY_LEVEL_STEPS.get(key, ACTIVITY_LEVEL_STEPS["moderate"])
|
||
|
||
|
||
def _history_days_with_data(history: list[dict[str, Any]]) -> int:
|
||
return sum(
|
||
1
|
||
for row in history
|
||
if int(row.get("steps") or 0) > 0 or float(row.get("workout_kcal") or 0) > 0
|
||
)
|
||
|
||
|
||
def resolve_expected_activity(
|
||
profile: dict[str, Any],
|
||
*,
|
||
history: list[dict[str, Any]],
|
||
lookback_days: int = EXPECTED_LOOKBACK_DAYS,
|
||
) -> tuple[int, float, str, int]:
|
||
"""Return expected daily steps, workout kcal, source, and days_with_data."""
|
||
days_with_data = _history_days_with_data(history)
|
||
|
||
if days_with_data >= EXPECTED_MIN_DAYS_WITH_DATA:
|
||
steps_vals = [int(row.get("steps") or 0) for row in history]
|
||
workout_vals = [float(row.get("workout_kcal") or 0) for row in history]
|
||
expected_steps = round(sum(steps_vals) / len(steps_vals))
|
||
expected_workout_kcal = round(sum(workout_vals) / len(workout_vals), 1)
|
||
return expected_steps, expected_workout_kcal, "weekly_avg", days_with_data
|
||
|
||
baseline_steps = profile.get("baseline_steps")
|
||
baseline_workout_kcal = profile.get("baseline_workout_kcal")
|
||
if baseline_steps is not None or baseline_workout_kcal is not None:
|
||
steps = int(baseline_steps) if baseline_steps is not None else _activity_level_steps(
|
||
profile.get("activity_level")
|
||
)
|
||
workout_daily = (
|
||
round(float(baseline_workout_kcal) / 7, 1)
|
||
if baseline_workout_kcal is not None
|
||
else round(
|
||
int(profile.get("weekly_workouts") or 3) * DEFAULT_SESSION_KCAL / 7,
|
||
1,
|
||
)
|
||
)
|
||
return steps, workout_daily, "baseline", days_with_data
|
||
|
||
weekly_workouts = int(profile.get("weekly_workouts") or 3)
|
||
return (
|
||
_activity_level_steps(profile.get("activity_level")),
|
||
round(weekly_workouts * DEFAULT_SESSION_KCAL / 7, 1),
|
||
"defaults",
|
||
days_with_data,
|
||
)
|
||
|
||
|
||
def compute_expected_targets(
|
||
profile: dict[str, Any],
|
||
*,
|
||
history: list[dict[str, Any]],
|
||
lookback_days: int = EXPECTED_LOOKBACK_DAYS,
|
||
) -> dict[str, Any]:
|
||
expected_steps, expected_workout_kcal, source, days_with_data = resolve_expected_activity(
|
||
profile,
|
||
history=history,
|
||
lookback_days=lookback_days,
|
||
)
|
||
workouts = [{"active_calories": expected_workout_kcal}] if expected_workout_kcal > 0 else []
|
||
daily = compute_daily_targets(
|
||
profile,
|
||
steps_total=expected_steps,
|
||
workouts=workouts,
|
||
)
|
||
return {
|
||
**daily,
|
||
"source": source,
|
||
"lookback_days": lookback_days,
|
||
"days_with_data": days_with_data,
|
||
"expected_steps": expected_steps,
|
||
"expected_workout_kcal": expected_workout_kcal,
|
||
}
|
||
|
||
|
||
def tdee_expected_to_api(daily: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"bmr": daily["bmr"],
|
||
"neat_kcal": daily["neat_kcal"],
|
||
"steps_kcal": daily["steps_kcal"],
|
||
"workout_kcal": daily["workout_kcal"],
|
||
"tdee": daily["tdee"],
|
||
"calorie_target": daily["calorie_target"],
|
||
"steps": daily.get("expected_steps", daily.get("steps", 0)),
|
||
"source": daily.get("source", "defaults"),
|
||
"lookback_days": daily.get("lookback_days", EXPECTED_LOOKBACK_DAYS),
|
||
"days_with_data": daily.get("days_with_data", 0),
|
||
}
|