Files
calcount/backend/services/nutrition.py
T
Craig 5372e8cbca TICKET-004: Day summary endpoint
- GET /api/log/summary?date= with totals vs historical target
- Nutrition math consolidated in services/nutrition.py (weight vs count)
- Null nutrition contributes 0; meals contribute 0 (TODO TICKET-007)
- Full suite green (104 passed)
2026-07-26 13:27:03 +01:00

73 lines
2.7 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.
"""Nutrition math — the ONLY place nutrition is computed (spec §8.1 rule 1).
Routers never compute nutrition; the frontend never re-derives it.
`quantity` is interpreted by context (spec §2.1):
- unit_type "weight": quantity is grams; nutrition = (quantity / 100) × per_unit
- unit_type "count": quantity is item count; nutrition = quantity × per_unit
- meal: quantity is a scaling factor (1.0 = one full meal)
"""
from models import Food
# All nutrition fields tracked on foods. Each has a corresponding *_per_unit
# column on the Food model. Used by entry_nutrition() to iterate over fields.
NUTRITION_FIELDS = [
"calories", "protein_g", "carbs_g", "fat_g",
"fiber_g", "saturated_fat_g", "sugars_g", "sodium_g",
]
# Maps each NUTRITION_FIELDS key to the Food model's per_unit column name.
# Naming is slightly irregular (e.g. "protein_g" → "protein_per_unit",
# not "protein_g_per_unit").
_FIELD_TO_PER_UNIT_COL = {
"calories": "calories_per_unit",
"protein_g": "protein_per_unit",
"carbs_g": "carbs_per_unit",
"fat_g": "fat_per_unit",
"fiber_g": "fiber_per_unit",
"saturated_fat_g": "saturated_fat_per_unit",
"sugars_g": "sugars_per_unit",
"sodium_g": "sodium_per_unit",
}
def scale_to_quantity(per_unit: float | None, quantity: float, unit_type: str) -> float:
"""Scale a per-unit nutrition value to a logged quantity. Missing values count as 0."""
if per_unit is None:
return 0.0
if unit_type == "weight":
return per_unit * quantity / 100.0
if unit_type == "count":
return per_unit * quantity
raise ValueError(f"unknown unit_type: {unit_type!r}")
def entry_calories(food: Food, quantity: float) -> float:
"""Calories for a single (non-meal) food at a logged quantity.
TODO: handle is_meal foods by summing scaled component nutrition
(recursively, with cycle detection — spec §2.2).
"""
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type)
def entry_nutrition(food: Food, quantity: float) -> dict[str, float]:
"""Return all nutrition fields scaled to a logged quantity.
Each field is resolved via scale_to_quantity using the food's unit_type.
NULL per-unit values contribute 0.0, not an error.
Meal foods (is_meal=True) have null per_unit fields per the CHECK
constraint, so they naturally contribute 0 for all fields.
TODO: TICKET-007 — real meal nutrition will sum scaled component
foods recursively. Until then, meal entries contribute 0.
"""
return {
field: scale_to_quantity(
getattr(food, _FIELD_TO_PER_UNIT_COL[field], None),
quantity,
food.unit_type,
)
for field in NUTRITION_FIELDS
}