Files
Meal-Planner/backend/app/utils/units.py
T
admin fd8ba3c4d2 feat(backend): implement unit conversion for cost calculation
- Add UnitConverter (normalization, within-family, density tables)
- Update cost.py to convert recipe qty to grocery price unit
- Update generate.py _load_match_index to fetch ingredient name + unit
- Fix orchestrator email/shopping-list cost loops to use conversion
- Fix missing Ingredient import in generate.py
- Add 19 unit tests
2026-05-24 13:46:50 -07:00

274 lines
9.0 KiB
Python
Raw 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.
"""Unit conversion between recipe quantities and grocery store prices.
Handles:
1. Unit normalization (synonyms → canonical)
2. Within-family linear conversion (lb ↔ oz, cup ↔ tbsp, etc.)
3. Cross-family conversion via ingredient density lookups
4. Fallback to dimensionless qty if conversion is impossible
"""
from __future__ import annotations
from decimal import Decimal
from typing import Dict, Optional
# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------
_NORMALIZE: Dict[str, str] = {
# Weight
"pound": "lb",
"pounds": "lb",
"lbs": "lb",
"ounce": "oz",
"ounces": "oz",
"gram": "g",
"grams": "g",
"kg": "g",
"kilogram": "g",
"kilograms": "g",
# Volume
"cups": "cup",
"tablespoon": "tbsp",
"tablespoons": "tbsp",
"tbs": "tbsp",
"teaspoon": "tsp",
"teaspoons": "tsp",
"gallon": "gal",
"gallons": "gal",
"pint": "pt",
"pints": "pt",
"quart": "qt",
"quarts": "qt",
"fluid ounce": "fl oz",
"fluid ounces": "fl oz",
"fl oz": "fl oz",
"milliliter": "ml",
"milliliters": "ml",
"liter": "l",
"liters": "l",
# Count / discrete
"each": "ea",
"piece": "ea",
"pieces": "ea",
"item": "ea",
"items": "ea",
"doz": "dozen",
"dozen": "dozen",
# Container / pack
"jar": "jar",
"pack": "pack",
"container": "container",
"carton": "carton",
"bag": "bag",
"loaf": "loaf",
"head": "head",
"bunch": "bunch",
"stalk": "stalk",
"clove": "clove",
"can": "can",
# Leave these alone
"lb": "lb",
"oz": "oz",
"g": "g",
"cup": "cup",
"tbsp": "tbsp",
"tsp": "tsp",
"ea": "ea",
"dozen": "dozen",
"gal": "gal",
"pt": "pt",
"qt": "qt",
"ml": "ml",
"l": "l",
"fl oz": "fl oz",
}
# ---------------------------------------------------------------------------
# Linear conversion tables (canonical unit → factor to base unit in family)
# ---------------------------------------------------------------------------
_WEIGHT = {"lb": Decimal("1"), "oz": Decimal("0.0625"), "g": Decimal("0.00220462")}
_VOLUME = {
"cup": Decimal("1"),
"tbsp": Decimal("0.0625"),
"tsp": Decimal("0.0208333"),
"gal": Decimal("16"),
"qt": Decimal("4"),
"pt": Decimal("2"),
"fl oz": Decimal("0.125"),
"ml": Decimal("0.00422675"),
"l": Decimal("4.22675"),
}
_COUNT = {"ea": Decimal("1"), "dozen": Decimal("12")}
# ---------------------------------------------------------------------------
# Ingredient density table (grams per canonical unit used in recipes)
# ---------------------------------------------------------------------------
_DENSITY_G_PER_UNIT: Dict[str, Dict[str, Decimal]] = {
# ingredient_name_lower → {canonical_unit: grams_per_unit}
"olive oil": {"tbsp": Decimal("13.5"), "cup": Decimal("216")},
"butter, unsalted": {"tbsp": Decimal("14.2"), "cup": Decimal("227")},
"greek yogurt, plain": {"cup": Decimal("227")},
"milk, whole": {"cup": Decimal("244")},
"sour cream": {"cup": Decimal("230")},
"chicken broth": {"cup": Decimal("240")},
"rice, long-grain white": {"cup": Decimal("185")},
"pasta, penne": {"lb": Decimal("454"), "cup": Decimal("100")}, # dry
"pasta, spaghetti": {"lb": Decimal("454"), "cup": Decimal("100")}, # dry
"salt, kosher": {"tsp": Decimal("6"), "tbsp": Decimal("18")},
"black pepper": {"tsp": Decimal("2.3")},
"cumin, ground": {"tsp": Decimal("2.1")},
"italian seasoning": {"tsp": Decimal("1.5")},
"curry powder": {"tsp": Decimal("2.4")},
"paprika, smoked": {"tsp": Decimal("2.3")},
"soy sauce": {"tbsp": Decimal("16"), "cup": Decimal("256")},
"salsa, jarred": {"tbsp": Decimal("16")},
"cheddar cheese, sharp": {"oz": Decimal("28.35")},
"mozzarella, shredded": {"oz": Decimal("28.35")},
"parmesan, grated": {"tbsp": Decimal("5")},
"coconut milk, canned": {"can": Decimal("400"), "cup": Decimal("240")},
"black beans, canned": {"can": Decimal("439"), "cup": Decimal("240")},
"chickpeas, canned": {"can": Decimal("439"), "cup": Decimal("240")},
"diced tomatoes, canned": {"can": Decimal("411"), "cup": Decimal("240")},
}
# Fallbacks: when an ingredient isn't in density table, but we know typical
# pack sizes from seed data (makes approximate cross-unit possible)
_SEED_SIZE_G: Dict[str, Decimal] = {
"eggs, large": Decimal("50"),
"avocado": Decimal("170"),
"lemon": Decimal("58"),
"lime": Decimal("44"),
"tomato, roma": Decimal("62"),
"yellow onion": Decimal("110"),
"red bell pepper": Decimal("119"),
"green bell pepper": Decimal("109"),
"sweet potato": Decimal("114"),
"zucchini": Decimal("196"),
"carrot": Decimal("61"),
"broccoli": Decimal("454"),
"spinach, fresh": Decimal("28.35"), # per oz
"cilantro": Decimal("15"),
"parsley, italian": Decimal("15"),
}
class UnitConverter:
"""Convert recipe qty+unit to a store price unit (dimensionless fallback)."""
def __init__(self) -> None:
# Build fast lookup: alias → density row
self._density: Dict[str, Dict[str, Decimal]] = {}
for name, dmap in _DENSITY_G_PER_UNIT.items():
entry: Dict[str, Decimal] = dict(dmap)
# Standard weight units are always grams-per-weight-unit
entry.setdefault("lb", Decimal("453.592"))
entry.setdefault("oz", Decimal("28.3495"))
entry.setdefault("g", Decimal("1"))
self._density[name] = entry
for alias in name.split(", "):
self._density.setdefault(alias, entry)
for name, grams in _SEED_SIZE_G.items():
entry = self._density.setdefault(name, {})
entry.setdefault("ea", grams)
entry.setdefault("lb", Decimal("453.592"))
entry.setdefault("oz", Decimal("28.3495"))
entry.setdefault("g", Decimal("1"))
for alias in name.split(", "):
self._density.setdefault(alias, entry)
# -- public API ---------------------------------------------------------
def normalize(self, unit: Optional[str]) -> str:
"""Return canonical unit string or 'unknown'."""
if not unit:
return "unknown"
s = unit.strip().lower()
# Exact match first
canon = _NORMALIZE.get(s)
if canon:
return canon
# De-pluralize fallback
return _NORMALIZE.get(s.rstrip("s"), s)
def convert(
self,
qty: float,
from_unit: Optional[str],
to_unit: Optional[str],
ingredient_name_lower: Optional[str] = None,
) -> Decimal:
"""Return a dimensionless scalar to multiply store price by.
If conversion is impossible, returns the original qty unchanged
(preserves the old monotonic ranking signal).
"""
if not from_unit or not to_unit:
return Decimal(str(qty))
fu = self.normalize(from_unit)
tu = self.normalize(to_unit)
if fu == tu:
return Decimal(str(qty))
# 1. Same family → linear ratio
ratio = self._same_family_ratio(fu, tu)
if ratio is not None:
return (Decimal(str(qty)) * ratio).quantize(Decimal("0.0001"))
# 2. Cross-family with density
if ingredient_name_lower:
ratio = self._density_ratio(fu, tu, ingredient_name_lower)
if ratio is not None:
return (Decimal(str(qty)) * ratio).quantize(Decimal("0.0001"))
# 3. Fallback: treat as dimensionless
return Decimal(str(qty))
# -- helpers -----------------------------------------------------------
def _same_family_ratio(self, fu: str, tu: str) -> Optional[Decimal]:
for family in (_WEIGHT, _VOLUME, _COUNT):
if fu in family and tu in family:
base_fu = family[fu]
base_tu = family[tu]
# fu →* base, tu →* base → qty_fu × (base_fu / base_tu) = qty_tu
return base_fu / base_tu
return None
def _density_ratio(
self,
fu: str,
tu: str,
ing_name: str,
) -> Optional[Decimal]:
dmap = self._density.get(ing_name.lower())
if not dmap:
return None
g_fu = dmap.get(fu)
g_tu = dmap.get(tu)
if g_fu is None or g_tu is None:
return None
# qty_fu × g_fu grams = qty_tu × g_tu grams
# → qty_tu = qty_fu × g_fu / g_tu
return g_fu / g_tu
# Module-level singleton for convenience
_DEFAULT_CONVERTER = UnitConverter()
# Convenience free functions so callers don't need an instance
def normalize_unit(unit: Optional[str]) -> str:
return _DEFAULT_CONVERTER.normalize(unit)
def convert_qty(
qty: float,
from_unit: Optional[str],
to_unit: Optional[str],
ingredient_name_lower: Optional[str] = None,
) -> Decimal:
return _DEFAULT_CONVERTER.convert(qty, from_unit, to_unit, ingredient_name_lower)