5372e8cbca
- 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)
73 lines
2.7 KiB
Python
73 lines
2.7 KiB
Python
"""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
|
||
}
|