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