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