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
+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(
{