"""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) Meal nutrition is derived by recursive component summation (§2.2), with cycle detection. A cycle returns zeros for that branch (safety net — writes prevent cycles, but reads must never infinite-loop). """ 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, visited: set[int] | None = None) -> float: """Calories for a food at a logged quantity. For regular foods: scales per_unit by quantity using unit_type. For meals: recursively sums scaled component nutrition. Cycle detection prevents infinite loops — a cycled branch returns 0 (safety net; writes should prevent cycles from being created). """ if visited is None: visited = set() if food.id in visited: return 0.0 if not food.is_meal: return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type) # Meal: recurse into components visited.add(food.id) total = 0.0 for component in food.components: # component.quantity is the amount in ONE full meal; multiply by the # entry's scaling factor to get the effective quantity for this log entry. total += entry_calories(component.food, component.quantity * quantity, visited) return total def entry_nutrition( food: Food, quantity: float, visited: set[int] | None = None, ) -> dict[str, float]: """Return all nutrition fields scaled to a logged quantity. For regular foods: each field is resolved via scale_to_quantity using the food's unit_type. NULL per-unit values contribute 0.0. For meals: recursively sums scaled component nutrition (§2.2). Cycle detection prevents infinite loops — a cycled branch returns zeros for all fields (safety net; writes should prevent cycles from being created). The ``visited`` set tracks food IDs on the current recursion path. Callers should NOT pre-populate it — it defaults to an empty set and is only used internally for recursion. """ if visited is None: visited = set() if food.id in visited: # Cycle detected — safety net; return zeros for this branch. return {field: 0.0 for field in NUTRITION_FIELDS} if not food.is_meal: return { field: scale_to_quantity( getattr(food, _FIELD_TO_PER_UNIT_COL[field], None), quantity, food.unit_type, ) for field in NUTRITION_FIELDS } # Meal: recurse into components, multiplying the scaling factor down. visited.add(food.id) totals = {field: 0.0 for field in NUTRITION_FIELDS} for component in food.components: # component.quantity is the amount in ONE full meal; multiply by the # entry's scaling factor to get the effective quantity for this log entry. component_nut = entry_nutrition( component.food, component.quantity * quantity, visited, ) for field in NUTRITION_FIELDS: totals[field] += component_nut[field] return totals