Public Access
- 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
274 lines
9.0 KiB
Python
274 lines
9.0 KiB
Python
"""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)
|