TICKET-007 (backend): meals — from-log, unpack, components, recursive nutrition, cycle detection
This commit is contained in:
@@ -5,6 +5,10 @@ Routers never compute nutrition; the frontend never re-derives it.
|
||||
- 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
|
||||
@@ -42,31 +46,75 @@ def scale_to_quantity(per_unit: float | None, quantity: float, unit_type: str) -
|
||||
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.
|
||||
def entry_calories(food: Food, quantity: float, visited: set[int] | None = None) -> float:
|
||||
"""Calories for a food at a logged quantity.
|
||||
|
||||
TODO: handle is_meal foods by summing scaled component nutrition
|
||||
(recursively, with cycle detection — spec §2.2).
|
||||
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).
|
||||
"""
|
||||
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type)
|
||||
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) -> dict[str, float]:
|
||||
def entry_nutrition(
|
||||
food: Food, quantity: float, visited: set[int] | None = None,
|
||||
) -> 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.
|
||||
For regular foods: each field is resolved via scale_to_quantity using
|
||||
the food's unit_type. NULL per-unit values contribute 0.0.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
return {
|
||||
field: scale_to_quantity(
|
||||
getattr(food, _FIELD_TO_PER_UNIT_COL[field], None),
|
||||
quantity,
|
||||
food.unit_type,
|
||||
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
|
||||
}
|
||||
for field in NUTRITION_FIELDS:
|
||||
totals[field] += component_nut[field]
|
||||
return totals
|
||||
Reference in New Issue
Block a user