Public Access
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
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user