Files

120 lines
4.6 KiB
Python
Raw Permalink 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)
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