fixed dynamic TDEE

This commit is contained in:
2026-06-16 08:04:15 +03:00
parent a3f01cd850
commit 0f2827030b
11 changed files with 603 additions and 18 deletions
+4
View File
@@ -24,6 +24,10 @@ class ProfileUpdate(BaseModel):
goal: str | None = None
target_weight_kg: float | None = None
neat_base_kcal: float | None = Field(default=None, ge=200, le=300)
activity_level: str | None = None
weekly_workouts: int | None = Field(default=None, ge=0, le=14)
baseline_steps: int | None = Field(default=None, ge=0)
baseline_workout_kcal: float | None = Field(default=None, ge=0)
class MealCreate(BaseModel):
+107
View File
@@ -21,6 +21,17 @@ PROTEIN_G_PER_KG = {
}
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
@@ -166,3 +177,99 @@ def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
"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),
}
+153 -1
View File
@@ -9,12 +9,20 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import BodyMetric, FoodLog, StepLog, WaterLog
from app.db.models import BodyMetric, FitnessProfile, FoodLog, StepLog, WaterLog, WorkoutLog
from app.fitness.activity_budget import estimate_workout_active_kcal
from app.fitness.calculators import (
EXPECTED_LOOKBACK_DAYS,
compute_daily_targets,
compute_expected_targets,
)
METRIC_DEFS: dict[str, dict[str, str]] = {
"weight_kg": {"label": "Вес", "unit": "кг"},
"body_fat_pct": {"label": "Жир", "unit": "%"},
"calories": {"label": "Калории", "unit": "ккал/день"},
"tdee": {"label": "TDEE факт", "unit": "ккал/день"},
"tdee_expected": {"label": "TDEE план", "unit": "ккал/день"},
"protein_g": {"label": "Белок", "unit": "г/день"},
"water_l": {"label": "Вода", "unit": "л/день"},
"steps": {"label": "Шаги", "unit": "шаг/день"},
@@ -55,6 +63,106 @@ def _last(values: list[tuple[date, float]]) -> float | None:
return values[-1][1]
def _profile_for_charts(row: FitnessProfile | None) -> dict[str, float | int | str | None] | None:
if row is None:
return None
return {
"sex": row.sex,
"age": row.age,
"height_cm": row.height_cm,
"weight_kg": row.weight_kg,
"goal": row.goal,
"neat_base_kcal": row.neat_base_kcal,
"activity_level": row.activity_level,
"weekly_workouts": row.weekly_workouts,
"baseline_steps": row.baseline_steps,
"baseline_workout_kcal": row.baseline_workout_kcal,
}
def _load_activity_maps(
db: Session,
user_id: int,
range_start: datetime,
range_end: datetime,
weight_kg: float,
) -> tuple[dict[date, int], dict[date, float]]:
steps_by_day: dict[date, int] = defaultdict(int)
workout_kcal_by_day: dict[date, float] = defaultdict(float)
steps_rows = db.scalars(
select(StepLog).where(
StepLog.user_id == user_id,
StepLog.logged_at >= range_start,
StepLog.logged_at <= range_end,
)
).all()
for row in steps_rows:
steps_by_day[row.logged_at.date()] += row.steps
workouts_rows = db.scalars(
select(WorkoutLog).where(
WorkoutLog.user_id == user_id,
WorkoutLog.logged_at >= range_start,
WorkoutLog.logged_at <= range_end,
)
).all()
for row in workouts_rows:
d = row.logged_at.date()
workout_kcal_by_day[d] += estimate_workout_active_kcal(
{
"title": row.title,
"duration_min": row.duration_min,
"active_calories": row.active_calories,
},
weight_kg=weight_kg,
)
return steps_by_day, workout_kcal_by_day
def _activity_history_before(
day: date,
steps_by_day: dict[date, int],
workout_kcal_by_day: dict[date, float],
*,
days: int = EXPECTED_LOOKBACK_DAYS,
) -> list[dict[str, float | int]]:
history: list[dict[str, float | int]] = []
start = day - timedelta(days=days)
cursor = start
while cursor < day:
history.append(
{
"steps": steps_by_day.get(cursor, 0),
"workout_kcal": workout_kcal_by_day.get(cursor, 0.0),
}
)
cursor += timedelta(days=1)
return history
def _tdee_actual_for_day(
profile: dict[str, float | int | str | None],
steps_by_day: dict[date, int],
workout_kcal_by_day: dict[date, float],
day: date,
) -> float:
steps = steps_by_day.get(day, 0)
workout_kcal = workout_kcal_by_day.get(day, 0.0)
workouts = [{"active_calories": workout_kcal}] if workout_kcal > 0 else []
return float(compute_daily_targets(profile, steps_total=steps, workouts=workouts)["tdee"])
def _tdee_expected_for_day(
profile: dict[str, float | int | str | None],
steps_by_day: dict[date, int],
workout_kcal_by_day: dict[date, float],
day: date,
) -> float:
history = _activity_history_before(day, steps_by_day, workout_kcal_by_day)
return float(compute_expected_targets(profile, history=history)["tdee"])
def build_fitness_charts(
db: Session,
user_id: int,
@@ -71,6 +179,25 @@ def build_fitness_charts(
range_start = datetime.combine(first_week_start, datetime.min.time(), tzinfo=timezone.utc)
range_end = datetime.combine(end, datetime.max.time(), tzinfo=timezone.utc)
profile_row = db.scalar(
select(FitnessProfile).where(FitnessProfile.user_id == user_id).limit(1)
)
profile = _profile_for_charts(profile_row)
weight_kg = float(profile["weight_kg"]) if profile else 70.0
activity_start = datetime.combine(
first_week_start - timedelta(days=EXPECTED_LOOKBACK_DAYS),
datetime.min.time(),
tzinfo=timezone.utc,
)
steps_by_day, workout_kcal_by_day = _load_activity_maps(
db,
user_id,
activity_start,
range_end,
weight_kg,
)
daily: dict[date, dict[str, float]] = defaultdict(lambda: {
"calories": 0.0,
"protein_g": 0.0,
@@ -180,6 +307,14 @@ def build_fitness_charts(
week_daily_values.append(totals["water_ml"] / 1000.0)
elif metric == "steps" and totals and "steps" in flags:
week_daily_values.append(totals["steps"])
elif metric == "tdee" and profile is not None and day_cursor <= end:
week_daily_values.append(
_tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor)
)
elif metric == "tdee_expected" and profile is not None and day_cursor <= end:
week_daily_values.append(
_tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor)
)
day_cursor += timedelta(days=1)
value: float | None
@@ -253,6 +388,9 @@ def build_fitness_charts(
end,
trend=trend,
lookback_days=min(30, max(days_with_data, 7)),
profile=profile,
steps_by_day=steps_by_day,
workout_kcal_by_day=workout_kcal_by_day,
)
return {
@@ -276,6 +414,9 @@ def _build_daily_series(
*,
trend: bool,
lookback_days: int,
profile: dict[str, float | int | str | None] | None = None,
steps_by_day: dict[date, int] | None = None,
workout_kcal_by_day: dict[date, float] | None = None,
) -> dict[str, Any]:
start = end - timedelta(days=lookback_days - 1)
day_points: list[date] = []
@@ -314,6 +455,17 @@ def _build_daily_series(
elif key == "steps" and totals and "steps" in flags:
value = totals["steps"]
has_data = True
elif key == "tdee" and profile is not None and steps_by_day is not None and workout_kcal_by_day is not None:
value = _tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, d)
has_data = True
elif (
key == "tdee_expected"
and profile is not None
and steps_by_day is not None
and workout_kcal_by_day is not None
):
value = _tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, d)
has_data = True
points.append(
{
+39 -8
View File
@@ -36,30 +36,60 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
totals = today.get("totals") or {}
targets = today.get("targets") or {}
breakdown = today.get("tdee_breakdown") or {}
expected = today.get("tdee_expected") or {}
targets_expected = today.get("targets_expected") or {}
steps_total = today.get("steps_total") or 0
water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000
if breakdown:
lines.append(
f"TDEE за день: BMR {breakdown.get('bmr')} + NEAT {breakdown.get('neat_kcal')} + "
f"TDEE факт: BMR {breakdown.get('bmr')} + NEAT {breakdown.get('neat_kcal')} + "
f"шаги {breakdown.get('steps_kcal')} ({steps_total} шаг.) + "
f"тренировки {breakdown.get('workout_kcal')} = {breakdown.get('tdee')} ккал → "
f"цель {breakdown.get('calorie_target')} ккал"
)
elif steps_total == 0:
lines.append(
"Шаги/тренировки не внесены — TDEE считается как BMR + NEAT. "
"Шаги/тренировки не внесены — TDEE факт = BMR + NEAT. "
"log_steps / log_workout для точной дневной цели."
)
if expected:
source = expected.get("source", "?")
source_labels = {
"weekly_avg": "среднее за неделю",
"baseline": "baseline профиля",
"defaults": "по activity_level",
}
source_label = source_labels.get(str(source), str(source))
days_data = expected.get("days_with_data", 0)
lookback = expected.get("lookback_days", 7)
extra = f", {days_data} дн. с данными за {lookback} дн." if source == "weekly_avg" else ""
lines.append(
f"TDEE план ({source_label}{extra}): BMR {expected.get('bmr')} + NEAT {expected.get('neat_kcal')} + "
f"шаги {expected.get('steps_kcal')} (~{expected.get('steps', 0)} шаг.) + "
f"тренировки {expected.get('workout_kcal')} = {expected.get('tdee')} ккал → "
f"цель {expected.get('calorie_target')} ккал"
)
lines.append("")
lines.append(
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
)
if targets_expected and targets_expected.get("carbs_g") != targets.get("carbs_g"):
lines.append(
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал "
f"(план {targets_expected.get('calories', 0):.0f}) · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} "
f"(план {targets_expected.get('carbs_g', 0):.0f}) г"
)
else:
lines.append(
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
)
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
workouts = today.get("workouts") or []
@@ -99,6 +129,7 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
"calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. "
"TDEE факт — по залогированной активности; TDEE план — среднее за неделю (или baseline) для утреннего бюджета углеводов. "
"БЖУ: белок 2.2 г/кг (сушка) / 1.8 г/кг (поддержание/набор), жир 1.0 г/кг, угли — остаток от целевых ккал. "
"Скриншоты Mi Fitness: vision уже извлекла данные в блок [Скриншот] с fitness_hints — используй их, не говори что не видишь картинку. "
"Еда — оценка LLM (≈)."
+118 -3
View File
@@ -1,4 +1,5 @@
import json
from collections import defaultdict
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
@@ -17,10 +18,13 @@ from app.db.models import (
from app.fitness.activity_budget import estimate_workout_active_kcal
from app.fitness.calculators import (
compute_daily_targets,
compute_expected_targets,
compute_targets,
one_rep_max,
targets_to_api,
tdee_breakdown_to_api,
tdee_expected_to_api,
EXPECTED_LOOKBACK_DAYS,
)
from app.fitness.body_composition import compute_body_composition
@@ -54,6 +58,10 @@ class FitnessService:
"weight_kg": row.weight_kg,
"goal": row.goal,
"neat_base_kcal": row.neat_base_kcal,
"activity_level": row.activity_level,
"weekly_workouts": row.weekly_workouts,
"baseline_steps": row.baseline_steps,
"baseline_workout_kcal": row.baseline_workout_kcal,
}
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
@@ -66,6 +74,10 @@ class FitnessService:
"goal": row.goal,
"target_weight_kg": row.target_weight_kg,
"neat_base_kcal": row.neat_base_kcal,
"activity_level": row.activity_level,
"weekly_workouts": row.weekly_workouts,
"baseline_steps": row.baseline_steps,
"baseline_workout_kcal": row.baseline_workout_kcal,
"calorie_target": row.calorie_target,
"protein_g": row.protein_g,
"fat_g": row.fat_g,
@@ -86,6 +98,8 @@ class FitnessService:
for key in (
"sex", "age", "height_cm", "weight_kg",
"goal", "target_weight_kg", "neat_base_kcal",
"activity_level", "weekly_workouts",
"baseline_steps", "baseline_workout_kcal",
):
if key in updates and updates[key] is not None:
setattr(row, key, updates[key])
@@ -188,8 +202,95 @@ class FitnessService:
"sex": "male",
"goal": "maintain",
"neat_base_kcal": 200,
"activity_level": "moderate",
"weekly_workouts": 3,
"baseline_steps": None,
"baseline_workout_kcal": None,
}
def _activity_history(
self,
end_day: date,
*,
days: int = EXPECTED_LOOKBACK_DAYS,
) -> list[dict[str, Any]]:
"""Daily steps and workout kcal for `days` calendar days before end_day (exclusive)."""
if days <= 0:
return []
start = end_day - timedelta(days=days)
range_start = datetime.combine(start, time.min, tzinfo=timezone.utc)
range_end = datetime.combine(end_day - timedelta(days=1), time.max, tzinfo=timezone.utc)
profile_row = self._get_profile_row()
weight_kg = float(profile_row.weight_kg) if profile_row else 70.0
steps_by_day: dict[date, int] = defaultdict(int)
workout_kcal_by_day: dict[date, float] = defaultdict(float)
steps_rows = self.db.scalars(
select(StepLog).where(
StepLog.user_id == self.user_id,
StepLog.logged_at >= range_start,
StepLog.logged_at <= range_end,
)
).all()
for row in steps_rows:
steps_by_day[row.logged_at.date()] += row.steps
workouts_rows = self.db.scalars(
select(WorkoutLog).where(
WorkoutLog.user_id == self.user_id,
WorkoutLog.logged_at >= range_start,
WorkoutLog.logged_at <= range_end,
)
).all()
for row in workouts_rows:
d = row.logged_at.date()
workout_kcal_by_day[d] += estimate_workout_active_kcal(
self._workout_to_dict(row),
weight_kg=weight_kg,
)
history: list[dict[str, Any]] = []
cursor = start
while cursor < end_day:
history.append(
{
"date": cursor.isoformat(),
"steps": steps_by_day.get(cursor, 0),
"workout_kcal": round(workout_kcal_by_day.get(cursor, 0.0), 1),
}
)
cursor += timedelta(days=1)
return history
def _maybe_update_baseline(self, profile_row: FitnessProfile | None, expected: dict[str, Any]) -> None:
if profile_row is None:
return
if expected.get("source") != "weekly_avg":
return
if int(expected.get("days_with_data") or 0) < 5:
return
profile_row.baseline_steps = int(expected.get("expected_steps") or 0)
profile_row.baseline_workout_kcal = round(
float(expected.get("expected_workout_kcal") or 0) * 7,
1,
)
def _expected_payload(
self,
profile: dict[str, Any],
day: date,
*,
profile_row: FitnessProfile | None = None,
update_baseline: bool = False,
) -> tuple[dict[str, Any], dict[str, float]]:
history = self._activity_history(day, days=EXPECTED_LOOKBACK_DAYS)
expected_daily = compute_expected_targets(profile, history=history)
if update_baseline:
self._maybe_update_baseline(profile_row, expected_daily)
return tdee_expected_to_api(expected_daily), targets_to_api(expected_daily)
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
d = day or datetime.now(timezone.utc).date()
@@ -199,8 +300,9 @@ class FitnessService:
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
start, end = self._day_bounds(day)
profile_row = self.get_profile()
profile = self._profile_for_budget(profile_row)
profile_row = self._get_profile_row()
profile_dict = self.get_profile()
profile = self._profile_for_budget(profile_dict)
foods = self.db.scalars(
select(FoodLog)
@@ -241,13 +343,24 @@ class FitnessService:
workouts=workouts,
)
targets = targets_to_api(daily)
target_day = day or datetime.now(timezone.utc).date()
tdee_expected, targets_expected = self._expected_payload(
profile,
target_day,
profile_row=profile_row,
update_baseline=target_day == datetime.now(timezone.utc).date(),
)
if profile_row is not None and target_day == datetime.now(timezone.utc).date():
self.db.commit()
return {
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
"date": target_day.isoformat(),
"profile_configured": profile_row is not None,
"totals": totals,
"targets": targets,
"targets_expected": targets_expected,
"tdee_breakdown": tdee_breakdown_to_api(daily),
"tdee_expected": tdee_expected,
"meals": [self._food_to_dict(f) for f in foods],
"water": [self._water_to_dict(w) for w in waters],
"workouts": workouts,
@@ -603,11 +716,13 @@ class FitnessService:
"has_data": has_data,
"totals": totals,
"targets": full["targets"],
"targets_expected": full.get("targets_expected"),
"meal_count": len(full["meals"]),
"workout_count": len(full["workouts"]),
}
if include_tdee_breakdown:
item["tdee_breakdown"] = full.get("tdee_breakdown")
item["tdee_expected"] = full.get("tdee_expected")
summaries.append(item)
return {
+18
View File
@@ -350,6 +350,22 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "number",
"description": "NEAT-база 200–300 ккал, по умолчанию 200",
},
"activity_level": {
"type": "string",
"description": "sedentary/moderate/active/very_active — fallback для TDEE план",
},
"weekly_workouts": {
"type": "integer",
"description": "Тренировок в неделю для fallback TDEE план",
},
"baseline_steps": {
"type": "integer",
"description": "Ожидаемые шаги/день (fallback TDEE план)",
},
"baseline_workout_kcal": {
"type": "number",
"description": "Ожидаемые ккал тренировок в неделю (fallback TDEE план)",
},
},
"required": [],
},
@@ -923,6 +939,8 @@ async def execute_tool(
for k in (
"sex", "age", "height_cm", "weight_kg",
"goal", "target_weight_kg", "neat_base_kcal",
"activity_level", "weekly_workouts",
"baseline_steps", "baseline_workout_kcal",
)
if k in arguments and arguments[k] is not None
}