"""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)