Files
Home_assistant/backend/app/daily/metrics.py
T
2026-07-01 11:20:13 +03:00

289 lines
11 KiB
Python

"""Bulk day-level fitness metrics without per-day get_daily_summary loops."""
from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db.models import FitnessProfile, FoodLog, StepLog, WaterLog, WorkoutLog
from app.fitness.activity_budget import estimate_workout_active_kcal
from app.fitness.calculators import compute_daily_targets, targets_to_api
from app.fitness.service import FitnessService
def plan_hit(totals: dict[str, float], targets: dict[str, float], goal: str, *, has_meals: bool) -> bool:
if not has_meals:
return False
actual = float(totals.get("calories", 0))
target = float(targets.get("calories", 0))
if target <= 0:
return False
if goal == "lose":
return actual <= target * 1.05
if goal == "gain":
return actual >= target * 0.95
return abs(actual - target) <= target * 0.1
def signed_balance(totals: dict[str, float], targets: dict[str, float], goal: str) -> float:
actual = float(totals.get("calories", 0))
target = float(targets.get("calories", 0))
if goal == "gain":
return actual - target
return target - actual
def streak_from_flags(flags_by_day: dict[date, bool], end: date) -> int:
streak = 0
cursor = end
while flags_by_day.get(cursor):
streak += 1
cursor -= timedelta(days=1)
return streak
def best_streak(flags_by_day: dict[date, bool]) -> int:
if not flags_by_day:
return 0
days = sorted(flags_by_day.keys())
best = 0
run = 0
prev: date | None = None
for d in days:
if not flags_by_day[d]:
run = 0
prev = d
continue
if prev and (d - prev).days == 1:
run += 1
else:
run = 1
best = max(best, run)
prev = d
return best
class DayMetricsService:
def __init__(self, db: Session, user_id: int):
self.db = db
self.user_id = user_id
self._fitness = FitnessService(db, user_id)
def first_activity_date(self) -> date | None:
candidates: list[date] = []
for model, dt_col in (
(FoodLog, FoodLog.logged_at),
(WaterLog, WaterLog.logged_at),
(StepLog, StepLog.logged_at),
(WorkoutLog, WorkoutLog.logged_at),
):
ts = self.db.scalar(select(func.min(dt_col)).where(model.user_id == self.user_id))
if ts is not None:
candidates.append(ts.date() if isinstance(ts, datetime) else ts)
return min(candidates) if candidates else None
def days_with_data_count(self, end: date) -> int:
first = self.first_activity_date()
if first is None:
return 0
return self._count_days_with_data(first, end)
def _count_days_with_data(self, start: date, end: date) -> int:
start_dt = datetime.combine(start, time.min, tzinfo=timezone.utc)
end_dt = datetime.combine(end, time.max, tzinfo=timezone.utc)
days: set[date] = set()
for model, dt_col in (
(FoodLog, FoodLog.logged_at),
(WaterLog, WaterLog.logged_at),
(StepLog, StepLog.logged_at),
(WorkoutLog, WorkoutLog.logged_at),
):
rows = self.db.scalars(
select(dt_col).where(
model.user_id == self.user_id,
dt_col >= start_dt,
dt_col <= end_dt,
)
).all()
for ts in rows:
days.add(ts.date() if isinstance(ts, datetime) else ts)
return len(days)
def _load_range(self, start: date, end: date) -> dict[date, dict[str, Any]]:
start_dt = datetime.combine(start, time.min, tzinfo=timezone.utc)
end_dt = datetime.combine(end, time.max, tzinfo=timezone.utc)
profile_dict = self._fitness.get_profile() or {}
profile = self._fitness._profile_for_budget(profile_dict)
weight_kg = float(profile_dict.get("weight_kg") or 70)
goal = str(profile_dict.get("goal") or "lose")
meal_count: dict[date, int] = defaultdict(int)
totals: dict[date, dict[str, float]] = defaultdict(
lambda: {
"calories": 0.0,
"protein_g": 0.0,
"fat_g": 0.0,
"carbs_g": 0.0,
"fiber_g": 0.0,
"water_ml": 0.0,
"steps": 0.0,
}
)
steps_by_day: dict[date, int] = defaultdict(int)
workout_kcal_by_day: dict[date, float] = defaultdict(float)
workout_count: dict[date, int] = defaultdict(int)
foods = self.db.scalars(
select(FoodLog).where(
FoodLog.user_id == self.user_id,
FoodLog.logged_at >= start_dt,
FoodLog.logged_at <= end_dt,
)
).all()
for row in foods:
d = row.logged_at.date()
meal_count[d] += 1
totals[d]["calories"] += row.calories
totals[d]["protein_g"] += row.protein_g
totals[d]["fat_g"] += row.fat_g
totals[d]["carbs_g"] += row.carbs_g
totals[d]["fiber_g"] += row.fiber_g
for row in self.db.scalars(
select(WaterLog).where(
WaterLog.user_id == self.user_id,
WaterLog.logged_at >= start_dt,
WaterLog.logged_at <= end_dt,
)
).all():
totals[row.logged_at.date()]["water_ml"] += float(row.amount_ml)
for row in self.db.scalars(
select(StepLog).where(
StepLog.user_id == self.user_id,
StepLog.logged_at >= start_dt,
StepLog.logged_at <= end_dt,
)
).all():
d = row.logged_at.date()
steps_by_day[d] += row.steps
totals[d]["steps"] += float(row.steps)
for row in self.db.scalars(
select(WorkoutLog).where(
WorkoutLog.user_id == self.user_id,
WorkoutLog.logged_at >= start_dt,
WorkoutLog.logged_at <= end_dt,
)
).all():
d = row.logged_at.date()
workout_count[d] += 1
kcal = estimate_workout_active_kcal(
FitnessService._workout_to_dict(row),
weight_kg=weight_kg,
)
workout_kcal_by_day[d] += kcal
result: dict[date, dict[str, Any]] = {}
cursor = start
while cursor <= end:
day_totals = dict(totals[cursor])
steps_total = int(steps_by_day.get(cursor, 0))
workouts = (
[{"active_calories": workout_kcal_by_day[cursor]}]
if workout_kcal_by_day.get(cursor, 0) > 0
else []
)
daily_targets = compute_daily_targets(
profile,
steps_total=steps_total,
workouts=workouts,
)
targets = targets_to_api(daily_targets)
has_meals = meal_count.get(cursor, 0) > 0
result[cursor] = {
"date": cursor.isoformat(),
"totals": day_totals,
"targets": targets,
"meal_count": meal_count.get(cursor, 0),
"workout_count": workout_count.get(cursor, 0),
"steps_total": steps_total,
"has_meals": has_meals,
"plan_hit": plan_hit(day_totals, targets, goal, has_meals=has_meals),
"balance": signed_balance(day_totals, targets, goal) if has_meals else 0.0,
}
cursor += timedelta(days=1)
return result
def day_summaries(self, start: date, end: date) -> dict[date, dict[str, Any]]:
return self._load_range(start, end)
def metrics_bundle(self, end: date) -> dict[str, Any]:
first = self.first_activity_date()
start = first if first is not None else end - timedelta(days=364)
if start > end:
start = end
days = self._load_range(start, end)
meal_flags = {d: info["meal_count"] > 0 for d, info in days.items()}
plan_flags = {d: info["plan_hit"] for d, info in days.items()}
balance_by_day = {d: info["balance"] for d, info in days.items() if info["meal_count"] > 0}
week_start = end - timedelta(days=6)
month_start = end - timedelta(days=29)
return {
"start": start,
"end": end,
"days": days,
"meal_flags": meal_flags,
"plan_flags": plan_flags,
"balance_by_day": balance_by_day,
"meal_log_streak": streak_from_flags(meal_flags, end),
"meal_log_best_streak": best_streak(meal_flags),
"plan_hit_streak": streak_from_flags(plan_flags, end),
"plan_hit_total": sum(1 for hit in plan_flags.values() if hit),
"plan_hit_week": sum(1 for d, hit in plan_flags.items() if hit and d >= week_start),
"deficit_week": round(sum(v for d, v in balance_by_day.items() if d >= week_start)),
"deficit_month": round(sum(v for d, v in balance_by_day.items() if d >= month_start)),
"deficit_all": round(sum(balance_by_day.values())),
}
def day_fitness_context(self, day: date) -> dict[str, Any]:
info = self._load_range(day, day).get(day)
if not info:
return {
"date": day.isoformat(),
"meals_count": 0,
"workouts_count": 0,
"steps_total": 0,
"calories": 0,
"calorie_target": 0,
"calorie_delta": 0,
"protein_g": 0,
"protein_target": 0,
"water_l": 0.0,
"has_fitness_data": False,
}
totals = info["totals"]
targets = info["targets"]
calorie_delta = float(totals.get("calories", 0)) - float(targets.get("calories", 0))
has_data = info["meal_count"] > 0 or info["workout_count"] > 0 or info["steps_total"] > 0
return {
"date": day.isoformat(),
"meals_count": info["meal_count"],
"workouts_count": info["workout_count"],
"steps_total": info["steps_total"],
"calories": round(float(totals.get("calories", 0))),
"calorie_target": round(float(targets.get("calories", 0))),
"calorie_delta": round(calorie_delta),
"protein_g": round(float(totals.get("protein_g", 0))),
"protein_target": round(float(targets.get("protein_g", 0))),
"water_l": round(float(totals.get("water_ml", 0)) / 1000, 1),
"has_fitness_data": has_data,
}