import re from typing import List, Sequence, Tuple from .hospitality_recipe_scale_types import RecipeQuantity from .units_convert import convert_units _DECIMAL = re.compile(r"[0-9]+(\.[0-9]+)?") _METRIC = { "mg": ("g", "kg"), "g": ("g", "kg"), "kg": ("g", "kg"), "mL": ("mL", "L"), "L": ("mL", "L"), } def _parse_decimal(text: str) -> Tuple[int, int]: whole, _, fraction = text.partition(".") return int(whole + fraction), 10 ** len(fraction) def _half_up(numerator: int, denominator: int) -> int: return (numerator * 2 + denominator) // (denominator * 2) def _format_scaled(value: int, decimals: int) -> str: unit = 10**decimals whole = str(value // unit) if decimals == 0: return whole fraction = str(value % unit).rjust(decimals, "0").rstrip("0") return whole if fraction == "" else "%s.%s" % (whole, fraction) def _check_portions(name: str, value: object) -> None: if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 10000: raise ValueError("%s must be a whole number from 1 to 10000, received %s" % (name, value)) def scale_recipe(ingredients: Sequence[RecipeQuantity], from_portions: int, to_portions: int) -> List[RecipeQuantity]: """Scale a recipe from one number of portions to another. The scaling is exact (decimal text times a fraction), and each quantity is rounded once, to the precision a kitchen weighs to: whole grams or millilitres from 10 up, one decimal place from 1 to 10, two below 1. Metric amounts move between g and kg, mL and L, at 1000. Counted items (each) round up, since half an egg short is short. Any other unit keeps its name and is rounded to two decimal places. """ _check_portions("fromPortions", from_portions) _check_portions("toPortions", to_portions) result: List[RecipeQuantity] = [] for ingredient in ingredients: name, quantity, unit = ingredient.name, ingredient.quantity, ingredient.unit if not isinstance(quantity, str) or not _DECIMAL.fullmatch(quantity): raise ValueError('quantity must be a non-negative decimal like "12.5", received "%s" for "%s"' % (quantity, name)) whole, _, fraction = quantity.partition(".") trimmed_fraction = fraction.rstrip("0") if len((whole + trimmed_fraction).lstrip("0")) > 15 or len(trimmed_fraction) > 15: raise ValueError( 'quantity "%s" for "%s" has too many digits: at most 15 significant digits and 15 decimal places' % (quantity, name) ) if unit == "": raise ValueError('unit must not be empty for "%s"' % (name,)) if unit in _METRIC: small, large = _METRIC[unit] n, scale = _parse_decimal(convert_units(quantity, unit, small, 12)) num = n * to_portions den = scale * from_portions decimals = 2 if num < den else 1 if num < den * 10 else 0 rounded = _half_up(num * 10**decimals, den) if decimals == 0 and rounded >= 1000: result.append(RecipeQuantity(name=name, quantity=convert_units(str(rounded), small, large, 3), unit=large)) else: result.append(RecipeQuantity(name=name, quantity=_format_scaled(rounded, decimals), unit=small)) continue # Trailing zeros dropped, so "1.000000000000000000000" stays small. n, scale = _parse_decimal(whole if trimmed_fraction == "" else "%s.%s" % (whole, trimmed_fraction)) num = n * to_portions den = scale * from_portions if unit == "each": result.append(RecipeQuantity(name=name, quantity=str((num + den - 1) // den), unit=unit)) else: result.append(RecipeQuantity(name=name, quantity=_format_scaled(_half_up(num * 100, den), 2), unit=unit)) return result