"""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 }