Files
Home_assistant/backend/app/fitness/calculators.py
T
2026-06-16 08:04:15 +03:00

276 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 3035 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),
}