Calculating Theoretical Food Cost from BOMs
This page shows a food-tech developer or culinary systems engineer how to derive a single, defensible theoretical food cost for a menu item from its structured Bill of Materials (BOM) — the specific task of walking a nested recipe graph down to raw procurement SKUs, normalizing units, applying edible yield, and aggregating in exact arithmetic. It sits under Variance Mapping Methodologies, supplying the clean theoretical baseline that the mapping engine subtracts actual depletion against; without a trustworthy number here, every downstream variance is noise.
Theoretical cost is the deterministic anchor of the whole theoretical vs actual food cost calculation architecture: it is what the recipes say a plate should cost, computed the same way every run so that any gap against reality is attributable to execution, not to arithmetic drift. Get this roll-up right and a machine can isolate over-portioning from trim loss from supplier price creep; get it wrong and a controller is back to reconciling spreadsheets by hand.
Prerequisites and Data Contract
Pin these versions and provision the four reference inputs before the steps apply. The pipeline is only deterministic if the BOM graph, the conversion constants, and the yield table are themselves canonical and version-locked.
- Runtime: Python 3.11+,
pandas==2.2.*,pydantic==2.7.*. Every monetary and weight value uses the standard-librarydecimalmodule — never binary floats. - Environment: read access to the recipe BOM store, the procurement cost table, and the version-locked unit-conversion and yield reference tables. BOM edge weights are assumed already captured against resolved ingredient identities from your POS taxonomy mapping; this pipeline governs cost, not item identity.
- Upstream contract: raw purchase weights are translated into usable, edible portions by the yield factor calculation frameworks, and the recipe graph itself is stored per the schema in how to structure recipe BOMs in PostgreSQL.
The BOM edge contract — one row per parent-to-child link in the recipe graph:
| Field | Type | Meaning |
|---|---|---|
parent |
text | Menu item or sub-recipe that contains the child |
child |
text | Component: a nested sub-recipe or a raw procurement SKU |
qty |
numeric | Quantity of child in one batch of parent |
uom |
text | Unit of qty (g, kg, lb, oz, ml, ea) |
The procurement cost contract is one row per raw SKU carrying cost_per_base_unit (a Decimal per canonical gram or milliliter); the yield table carries yield_factor per SKU (edible fraction, 0 < f <= 1); the conversion matrix maps every uom to its canonical-base multiplier. The output guarantee: every resolvable root returns exactly one Decimal theoretical cost, and any missing cost, unmapped unit, zero yield, or circular reference raises a structured exception rather than silently producing a wrong number.
Step-by-Step Implementation
Each step is a self-contained block. Compose them in order inside one batch worker, partitioned by menu item.
Step 1 — Freeze the canonical reference tables
Procurement invoices in cases, kitchens prep in pounds, recipes specify grams — the three vocabularies only reconcile after a conversion layer collapses them to one canonical base unit. This is the same unit canonicalization discipline the cost architecture depends on: define the matrix once, version-lock it per fiscal period, and anchor every constant to Decimal.
from decimal import Decimal
# Every UOM mapped to canonical base units (grams / milliliters)
UNIT_MATRIX: dict[str, Decimal] = {
"g": Decimal("1.0"),
"kg": Decimal("1000.0"),
"lb": Decimal("453.59237"),
"oz": Decimal("28.349523125"),
"ml": Decimal("1.0"),
"ea": Decimal("1.0"), # requires a per-SKU weight override
}
# Edible fraction per raw SKU; DEFAULT only for pre-portioned items
YIELD_FACTORS: dict[str, Decimal] = {
"WHOLE_CHICKEN": Decimal("0.65"),
"PRIME_RIB_ROAST": Decimal("0.72"),
"LETTUCE_HEAD": Decimal("0.85"),
"DEFAULT": Decimal("1.0"),
}
Step 2 — Model the BOM edges with a strict schema
Validate every edge through a Pydantic v2 model on ingestion so a malformed unit or a non-positive quantity is rejected at the boundary, not discovered as phantom cost three stages later.
import pandas as pd
from pydantic import BaseModel, field_validator
class BomEdge(BaseModel):
parent: str
child: str
qty: Decimal
uom: str
@field_validator("qty")
@classmethod
def positive_qty(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError("BOM quantity must be positive")
return v
def load_edges(raw: list[tuple]) -> pd.DataFrame:
rows = [BomEdge(parent=p, child=c, qty=Decimal(str(q)), uom=u)
for p, c, q, u in raw]
return pd.DataFrame([r.model_dump() for r in rows])
BOM_EDGES = load_edges([
("MENU_GRILLED_CHICKEN", "WHOLE_CHICKEN", "1.5", "lb"),
("MENU_GRILLED_CHICKEN", "SEASONING_BLEND", "15", "g"),
("MENU_GRILLED_CHICKEN", "SUB_GRAVY", "1", "ea"), # nested sub-recipe
("SUB_GRAVY", "BUTTER", "50", "g"),
("SUB_GRAVY", "FLOUR", "30", "g"),
("SUB_GRAVY", "STOCK_BASE", "200", "ml"),
])
Step 3 — Build the adjacency map without row-by-row iteration
Collapse the edge frame into a parent → children lookup with a single vectorized groupby, avoiding iterrows. The recursion in Step 4 reads this map; keeping construction vectorized keeps the hot path fast across thousands of SKUs.
def build_adjacency(edges: pd.DataFrame) -> dict[str, list[tuple]]:
grouped = (
edges.groupby("parent")[["child", "qty", "uom"]]
.apply(lambda g: list(g.itertuples(index=False, name=None)))
)
return grouped.to_dict()
PROCUREMENT_COSTS: dict[str, Decimal] = {
"WHOLE_CHICKEN": Decimal("2.45"), # cost per canonical gram
"SEASONING_BLEND": Decimal("0.08"),
"BUTTER": Decimal("0.012"),
"FLOUR": Decimal("0.003"),
"STOCK_BASE": Decimal("0.004"),
}
Step 4 — Resolve the graph with cycle-safe Decimal recursion
Walk the menu item down to raw SKUs. At each edge, normalize the quantity to canonical units; at each leaf, divide raw cost by its yield factor; aggregate upward in Decimal. A visited-node stack aborts on any circular reference (A → B → A) instead of recursing forever, and a memo cache means a shared sub-recipe is priced once.
def resolve_bom_cost(
root: str,
adj: dict[str, list[tuple]],
costs: dict[str, Decimal],
unit_matrix: dict[str, Decimal],
yields: dict[str, Decimal],
) -> Decimal:
cache: dict[str, Decimal] = {}
stack: set[str] = set()
def traverse(node: str) -> Decimal:
if node in stack:
raise ValueError(f"Circular dependency detected at: {node}")
if node in cache:
return cache[node]
stack.add(node)
children = adj.get(node)
if not children: # leaf: raw procurement SKU
raw = costs.get(node)
if raw is None:
raise KeyError(f"Missing procurement cost for SKU: {node}")
yf = yields.get(node, yields["DEFAULT"])
if yf == 0:
raise ValueError(f"Invalid zero yield factor for SKU: {node}")
cache[node] = raw / yf
else: # internal node: sub-recipe or menu item
total = Decimal("0.0")
for child, qty, uom in children:
conv = unit_matrix.get(uom)
if conv is None:
raise ValueError(f"Unmapped unit of measure: {uom}")
total += (qty * conv) * traverse(child)
cache[node] = total
stack.discard(node)
return cache[node]
return traverse(root)
Step 5 — Scale to a batch and round only at the report layer
Production runs price a full batch, not a single plate, and any yield degradation must scale with it. Multiply the resolved cost by the batch multiplier, then quantize to four places exactly once — never during intermediate aggregation.
from decimal import ROUND_HALF_UP
def theoretical_cost(root: str, batch_multiplier: Decimal = Decimal("1")) -> Decimal:
adj = build_adjacency(BOM_EDGES)
per_batch = resolve_bom_cost(
root, adj, PROCUREMENT_COSTS, UNIT_MATRIX, YIELD_FACTORS
)
scaled = per_batch * batch_multiplier
return scaled.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if __name__ == "__main__":
try:
cost = theoretical_cost("MENU_GRILLED_CHICKEN")
print(f"Theoretical cost: ${cost}")
except (ValueError, KeyError) as exc:
print(f"Pipeline aborted: {exc}")
Verification and Validation
Confirm the resolver behaves before you trust it to feed a variance report.
-
Deterministic and reproducible. The same graph and reference tables must return an identical
Decimalevery run:assert theoretical_cost("MENU_GRILLED_CHICKEN") == theoretical_cost("MENU_GRILLED_CHICKEN") -
Cycles abort, never hang. Inject a back-edge and assert the guard fires rather than the process spinning:
cyclic = load_edges([("A", "B", "1", "g"), ("B", "A", "1", "g")]) try: resolve_bom_cost("A", build_adjacency(cyclic), {}, UNIT_MATRIX, YIELD_FACTORS) assert False, "cycle not detected" except ValueError as e: assert "Circular dependency" in str(e) -
Batch scaling is linear. A doubled batch must exactly double the cost, proving no rounding leaked into the middle of the roll-up:
base = theoretical_cost("MENU_GRILLED_CHICKEN") assert theoretical_cost("MENU_GRILLED_CHICKEN", Decimal("2")) == (base * 2).quantize(Decimal("0.0001")) -
Missing data is loud. Drop
BUTTERfromPROCUREMENT_COSTSand confirm aKeyErrornames the offending SKU rather than the cost silently landing low.
A healthy run ends with a stable four-place Decimal, a ValueError on the cyclic fixture, exact linear batch scaling, and a named exception for any absent cost or unit.
Gotchas and Edge Cases
IEEE-754 drift turning a clean roll-up into phantom variance
Multiplying quantities by float conversion factors and summing across thousands of line items lets binary-float error accumulate into cents of drift — indistinguishable downstream from real shrinkage. Every constant in Steps 1–4 is a Decimal, aggregation stays in Decimal, and the single quantize in Step 5 is the only rounding. Never cast to float mid-pipeline.
yield_factor = 0 causing a divide-by-zero
A yield row that arrives as 0 — a data-entry slip or an empty reference cell — makes the leaf division in Step 4 explode. The guard treats a zero yield as a reference-data defect and raises, because a zero edible fraction is impossible, not a number to divide by. Missing yield falls back to DEFAULT, and high-variance proteins (poultry, seafood, prime cuts) should carry a mandatory non-default entry so they never silently price at 100% yield.
Circular sub-recipe references from copy-paste recipe authoring
Recipe editors occasionally wire a stock that calls a sauce that calls the same stock. Without the visited-node stack the traversal recurses until the interpreter’s stack overflows. The guard aborts on the first re-entry and names the node, converting an infinite loop into an actionable authoring fix. Detect these in bulk with a topological sort before the nightly run.
`ea` units with no per-SKU weight override
An ea (each) line maps to a multiplier of 1.0, which is only correct when the SKU’s cost is already stated per unit. If an ea component is really “one whole chicken” priced per gram, its cost collapses. Carry an explicit per-SKU weight override for every ea line, or reject ea at ingestion for weight-priced SKUs so the ambiguity surfaces before the roll-up.
Regional unit aliases and fractional quantity strings
The same measure ships under different labels per region, and some exports render one-and-a-half as 1,50. An unmapped alias hits the Unmapped unit of measure guard rather than being coerced; extend UNIT_MATRIX with the regional key instead of loosening the check, and normalize decimal separators upstream so every qty reaches Decimal(str(q)) as a clean numeric string.
Related
- Up: Variance Mapping Methodologies — the parent module this theoretical baseline feeds.
- Theoretical vs Actual Food Cost Calculation — the full variance architecture built on this number.
- How to Structure Recipe BOMs in PostgreSQL — the storage schema the recipe graph is read from.
- Yield Factor Calculation Frameworks — how the edible fractions divided out in Step 4 are derived.
- Multi-Location Cost Center Architecture — applying location-specific prices to the same BOM graph across a fleet.
For deeper reference, consult the official Python decimal documentation on rounding contexts and the pandas documentation on vectorized groupby.