# SPDX-License-Identifier: MIT # food — state when a dish is unsafe or holds an allergen, and calculate a # recipe. # # THIS MODULE IS NOT FOOD-SAFETY OR MEDICAL ADVICE. It states published # guidance as data and rules over the facts you record, and arithmetic with # published conversions. A temperature below is a figure from a chart: whether # a food is safe to eat is decided by the cook, with a thermometer that reads # the food, and whether an ingredient is safe for a person with an allergy is # decided by that person and their doctor. A dish this module does not mark # unsafe is not thereby safe, and a dish it does not mark as holding an # allergen holds none only of the ingredients you recorded. It gives no # diagnosis. Ask a clinician about an allergy. # # Two halves that share nothing but the subject. # # STRUCTURE. You supply four kinds of record as facts. # # Standards (`food.Standard`), named by a kind of food, each with the minimum # internal temperature in Celsius. `standard()` takes the chart's Fahrenheit and # converts it exactly; `chart()` writes the chart's rows. Dishes # (`food.Dish`), each a kind, the temperature its core reached in Celsius and the # minutes it spent in the danger zone. The rules conclude, each with its proof, # a dish `undercooked` when its kind has a standard and its core is below it, # `held_too_long` when it spent more than two hours in the danger zone, and # `unsafe` for either cause. A dish whose kind has no standard recorded is # `unknown`, and is never read as safe: an empty `unsafe` means "no rule # concluded it", and `unknown` says that no standard was there to conclude from. # An unsafe dish is marked and kept, never dropped: # # import food # food.chart() # food.dish("d1", "poultry", 70.0, 30.0) # food.dish("d2", "salmon", 70.0, 30.0) # print(food.unsafe_dishes(), food.unknown_dishes()) # n = why_not(food.Dish.d2.undercooked) # # Items (`food.Item`) with the allergens they hold, and lines (`food.Line`) # that put an item in a dish. A dish holds an allergen when a line of it uses an # item that holds it: one rule for each of the nine major allergens named below. # `item()` writes all nine facts of an item, true for those you name and false # for the rest, so that an item you described is "free from" what you did not # name and an item you never described is unknown, not free from anything: # # food.item("flour", ["wheat"]) # food.item("butter", ["milk"]) # food.line("l1", "cake", "flour") # food.line("l2", "cake", "butter") # print(food.allergens_of("cake")) # # Each conclusion answers `why`, and `why_not` names the premise that failed. A # corrected fact withdraws what rested on it: a core temperature written again # above its minimum withdraws `unsafe`, and an ingredient replaced withdraws the # allergen it brought. # # Where two sources disagree about a fact, such as a dish's core temperature, # the rules reason about both values and the readers hold both verdicts, so a # dish is unsafe if either value makes it so. `unsettled()` names every fact # you write that two sources give different values. # # The test that a kind HAS a standard is written as a join with a read of the # standard's own field (`d.kind == s and s.minimum > -1000.0`). A join on its # own ranges the standard over the subject the kind names whether or not # anything is recorded for it, so a join alone would never find a kind missing. # # CALCULATION. Value scripts that return a value and write nothing: # Fahrenheit to Celsius, a recipe scaled by servings, a baker's percentage, a # volume in cups turned into a mass by a density you supply, food energy from # protein, carbohydrate, fat and alcohol, and kilocalories to kilojoules. Each # result is bit-identical to CPython's. A density is a number you supply: the # module holds none. # # The rest time of a chart row (three minutes for a steak, a roast or a chop) # is a second fact beside the core temperature and is not a rule here, and a # row that gives no temperature (shrimp, lobster, crab and scallops until # opaque, clams, oysters and mussels until the shells open, raw eggs until # firm) is not in `chart()`. A cooling curve, a rest, nutrients beyond the four # below, cooking losses and yields are not covered. # # Sources, read 2026-11-09: # - Minimum internal temperatures: FoodSafety.gov, "Cook to a Safe Minimum # Internal Temperature" # (https://www.foodsafety.gov/food-safety-charts/safe-minimum-internal-temperatures), # whose page reads "Date Last Reviewed November 21, 2024". THE FIGURES ARE # READ FROM A COPY OF THAT PAGE THE OPERATOR DOWNLOADED ON 2026-11-09; an # automated fetch of the agency's sites was refused with an HTTP 403. Beef, # bison, veal, goat and lamb steaks, roasts and chops 145 F (63 C) with a # rest of 3 minutes; those meats ground, and sausage, 160 F (71 C); poultry, # whole, in parts or ground, 165 F (74 C); pork steaks, roasts and chops # 145 F with a rest of 3 minutes and pork ground or sausage 160 F; raw ham # 145 F with a rest of 3 minutes; precooked ham to reheat 165 F, with the # note that cooked hams packaged in USDA-inspected plants are reheated to # 140 F (60 C); egg dishes 160 F (71 C); casseroles, meat and meatless, 165 F # (74 C), and casseroles containing meat and poultry 165 F; leftovers of any # type 165 F (74 C); rabbit and venison 160 F; fish 145 F (63 C) or until the # flesh is no longer translucent and separates easily with a fork. The chart # rounds the Celsius figures; the module converts the Fahrenheit ones # exactly (145 F is 62.77777777777778 C, where the chart prints 63). # - The danger zone is roughly 4 to 60 C (40 to 140 F) as the USDA defines it, # and food that remains in the zone for more than two hours should not be # consumed; in the United Kingdom and Northern Ireland it is 8 to 63 C (46 to # 145 F); the page says agencies define it differently # (https://en.wikipedia.org/wiki/Danger_zone_(food_safety)). Both ranges are # held by `danger_zone()` and named. The two hours is the rule's limit. # - The major food allergens in the United States are nine: milk, eggs, fish, # crustacean shellfish, tree nuts, peanuts, wheat, soybeans and sesame. The # Food Allergen Labeling and Consumer Protection Act of 2004 set the # original eight, and the FASTER Act, signed on 23 April 2021, made sesame # the ninth, effective on 1 January 2023 # (https://www.fda.gov/food/nutrition-food-labeling-and-critical-foods/food-allergies). # - Food energy by labelling table: fat 9 kcal/g (37 kJ), ethanol 7 (29), # protein 4 (17), carbohydrate 4 (17), and fibre 2 (8) in the table, which the # UK does not count; one kilocalorie is 4.184 kJ. The figures are the # table's and are approximate; the module uses protein, carbohydrate, fat and # alcohol only (https://en.wikipedia.org/wiki/Food_energy). The system of such # factors is named for Wilbur Olin Atwater, whose studies at Wesleyan # University in Middletown, Connecticut developed it # (https://en.wikipedia.org/wiki/Atwater_system). # - Baker's percentage expresses each ingredient's weight as a percentage of # the flour's weight, flour always 100 percent, so the sum always exceeds # 100; the water's is the hydration; 10 pounds of flour and 5 of water are # 100 and 50 percent (https://en.wikipedia.org/wiki/Baker_percentage). # - The US customary cup is 236.5882365 mL exactly, half a US liquid pint; the # cup for US nutrition labelling is defined in law as 240 mL; Australia, # Canada and New Zealand use a metric cup of 250 mL # (https://en.wikipedia.org/wiki/Cup_(unit)). The module's volume uses the # customary cup. import math # ── Standards and dishes as facts ──────────────────────────────────────────── class Standard: # A minimum internal temperature in Celsius, named by a kind of food. The # rules read the standard whose name is a dish's kind. minimum: real class Dish: # A dish, by a name you choose. `kind` names its standard, `core` is the # temperature its core reached in Celsius and `minutes` the time it spent in # the danger zone. The rules conclude `undercooked`, `held_too_long`, # `unsafe`, `unknown` and the nine allergens, each True. kind: string core: real minutes: real undercooked: bool held_too_long: bool unsafe: bool unknown: bool milk: bool egg: bool fish: bool shellfish: bool tree_nuts: bool peanuts: bool wheat: bool soybeans: bool sesame: bool class Item: # An ingredient, by a name you choose, with a True or False for each of the # nine allergens, written by `item()`. milk: bool egg: bool fish: bool shellfish: bool tree_nuts: bool peanuts: bool wheat: bool soybeans: bool sesame: bool class Line: # An item used in a dish, by a name you choose. dish: string item: string @rule def undercooked_dish(): for d in Dish, s in Standard: if d.kind == s and d.core < s.minimum: d.undercooked = True @rule def held_too_long_dish(): for d in Dish: if d.minutes > 120.0: d.held_too_long = True @rule def unsafe_for_cooking(): for d in Dish: if d.undercooked == True: d.unsafe = True @rule def unsafe_for_holding(): for d in Dish: if d.held_too_long == True: d.unsafe = True @rule def unknown_kind(): for d in Dish: if none(s in Standard: d.kind == s and s.minimum > -1000.0): d.unknown = True @rule def holds_milk(): for d in Dish, l in Line, i in Item: if l.dish == d and l.item == i and i.milk == True: d.milk = True @rule def holds_egg(): for d in Dish, l in Line, i in Item: if l.dish == d and l.item == i and i.egg == True: d.egg = True @rule def holds_fish(): for d in Dish, l in Line, i in Item: if l.dish == d and l.item == i and i.fish == True: d.fish = True @rule def holds_shellfish(): for d in Dish, l in Line, i in Item: if l.dish == d and l.item == i and i.shellfish == True: d.shellfish = True @rule def holds_tree_nuts(): for d in Dish, l in Line, i in Item: if l.dish == d and l.item == i and i.tree_nuts == True: d.tree_nuts = True @rule def holds_peanuts(): for d in Dish, l in Line, i in Item: if l.dish == d and l.item == i and i.peanuts == True: d.peanuts = True @rule def holds_wheat(): for d in Dish, l in Line, i in Item: if l.dish == d and l.item == i and i.wheat == True: d.wheat = True @rule def holds_soybeans(): for d in Dish, l in Line, i in Item: if l.dish == d and l.item == i and i.soybeans == True: d.soybeans = True @rule def holds_sesame(): for d in Dish, l in Line, i in Item: if l.dish == d and l.item == i and i.sesame == True: d.sesame = True # ── Writing facts ──────────────────────────────────────────────────────────── def fahrenheit_to_celsius(fahrenheit: float) -> float: # (f - 32) x 5 / 9, in that order; where (f - 32) x 5 passes the largest # real, (f - 32) / 9 x 5. c = (fahrenheit - 32.0) * 5.0 / 9.0 if math.isinf(c) and math.isfinite(fahrenheit): return (fahrenheit - 32.0) / 9.0 * 5.0 return c def standard(kind: string, minimum_fahrenheit: float): # A standard from the chart's Fahrenheit, converted exactly. update(Standard(kind).minimum, fahrenheit_to_celsius(minimum_fahrenheit), "standard") def chart(): # The rows of the chart that give a temperature, as FoodSafety.gov's # "Cook to a Safe Minimum Internal Temperature" gives them, read from a copy # of the page that was reviewed on November 21, 2024. Kinds: "steak" for # steaks, roasts and chops of beef, bison, veal, goat, lamb and pork; # "ground" for those meats ground and sausage; "poultry"; "egg-dish"; # "casserole" for one with meat or poultry, or meatless; "ham-raw"; # "ham-precooked" reheated; "ham-packaged" for cooked ham packaged in a # USDA-inspected plant, reheated; "leftovers"; "rabbit-venison"; "fish". # Eleven standards. The chart rounds Celsius; `standard()` converts exactly. standard("steak", 145.0) standard("ground", 160.0) standard("poultry", 165.0) standard("egg-dish", 160.0) standard("casserole", 165.0) standard("ham-raw", 145.0) standard("ham-precooked", 165.0) standard("ham-packaged", 140.0) standard("leftovers", 165.0) standard("rabbit-venison", 160.0) standard("fish", 145.0) def danger_zone(agency: string) -> list: # The danger zone as an agency defines it, [low, high] in Celsius: "usda" # is 4 to 60 (40 to 140 F) and "uk" is 8 to 63 (46 to 145 F). Any other # name refuses; the agencies differ, and the module holds both. if agency == "usda": return [4.0, 60.0] if agency == "uk": return [8.0, 63.0] raise ValueError("the danger zone is held for usda and uk") def dish(name: string, kind: string, core_celsius: float, minutes_in_zone: float): # A dish with its kind, the temperature its core reached and the minutes it # spent in the danger zone. Write it again to correct it. update(Dish(name).kind, kind, "dish") update(Dish(name).core, core_celsius, "dish") update(Dish(name).minutes, minutes_in_zone, "dish") def item(name: string, allergens: list): # An ingredient with the allergens it holds, from "milk", "egg", "fish", # "shellfish", "tree_nuts", "peanuts", "wheat", "soybeans" and "sesame". # All nine facts are written, True for those named and False for the # rest. Another name refuses. names = ["milk", "egg", "fish", "shellfish", "tree_nuts", "peanuts", "wheat", "soybeans", "sesame"] for a in allergens: if a not in names: raise ValueError("an allergen is one of milk, egg, fish, shellfish, tree_nuts, peanuts, wheat, soybeans, sesame") update(Item(name).milk, "milk" in allergens, "item") update(Item(name).egg, "egg" in allergens, "item") update(Item(name).fish, "fish" in allergens, "item") update(Item(name).shellfish, "shellfish" in allergens, "item") update(Item(name).tree_nuts, "tree_nuts" in allergens, "item") update(Item(name).peanuts, "peanuts" in allergens, "item") update(Item(name).wheat, "wheat" in allergens, "item") update(Item(name).soybeans, "soybeans" in allergens, "item") update(Item(name).sesame, "sesame" in allergens, "item") def line(name: string, dish_name: string, item_name: string): # An item used in a dish. update(Line(name).dish, dish_name, "line") update(Line(name).item, item_name, "line") # ── Reading what the rules concluded ───────────────────────────────────────── # # Each reader reads every value in force, so a fact two sources disagree # about gives each of its values a verdict and is reported with both rather # than refused. `unsettled()` names the facts that hold two. def holds(values: claim_list, name: string) -> bool: for v in values: parts = v.path.split(".") if parts[len(parts) - 2] == name and v.value == True: return True return False def names_of(values: claim_list) -> list: out = [] for v in values: parts = v.path.split(".") x = parts[len(parts) - 2] if v.value == True and x not in out: out = out + [x] return sorted(out) def unsafe_dishes() -> list: # The dishes the rules conclude unsafe, for either cause, sorted. vs = list_values(Dish.unsafe) return names_of(vs) def unknown_dishes() -> list: # The dishes whose kind has no standard recorded, sorted. They are not # safe: nothing was there to conclude from. vs = list_values(Dish.unknown) return names_of(vs) def allergens_of(name: string) -> list: # The allergens the rules conclude a dish holds, in the order of the nine. out = [] va = list_values(Dish.milk) vb = list_values(Dish.egg) vc = list_values(Dish.fish) vd = list_values(Dish.shellfish) ve = list_values(Dish.tree_nuts) vf = list_values(Dish.peanuts) vg = list_values(Dish.wheat) vh = list_values(Dish.soybeans) vk = list_values(Dish.sesame) if holds(va, name): out = out + ["milk"] if holds(vb, name): out = out + ["egg"] if holds(vc, name): out = out + ["fish"] if holds(vd, name): out = out + ["shellfish"] if holds(ve, name): out = out + ["tree_nuts"] if holds(vf, name): out = out + ["peanuts"] if holds(vg, name): out = out + ["wheat"] if holds(vh, name): out = out + ["soybeans"] if holds(vk, name): out = out + ["sesame"] return out def unsettled() -> list: # Each standard, dish, item and line that holds two different values for a # fact you write, as "dish x core", "item x milk" and so on, sorted. A # fact is named only when its values differ. out = [] for f in ["s.minimum", "d.kind", "d.core", "d.minutes", "i.milk", "i.egg", "i.fish", "i.shellfish", "i.tree_nuts", "i.peanuts", "i.wheat", "i.soybeans", "i.sesame", "l.dish", "l.item"]: if f == "s.minimum": vs = list_values(Standard.minimum) noun = "standard " elif f == "d.kind": vs = list_values(Dish.kind) noun = "dish " elif f == "d.core": vs = list_values(Dish.core) noun = "dish " elif f == "d.minutes": vs = list_values(Dish.minutes) noun = "dish " elif f == "i.milk": vs = list_values(Item.milk) noun = "item " elif f == "i.egg": vs = list_values(Item.egg) noun = "item " elif f == "i.fish": vs = list_values(Item.fish) noun = "item " elif f == "i.shellfish": vs = list_values(Item.shellfish) noun = "item " elif f == "i.tree_nuts": vs = list_values(Item.tree_nuts) noun = "item " elif f == "i.peanuts": vs = list_values(Item.peanuts) noun = "item " elif f == "i.wheat": vs = list_values(Item.wheat) noun = "item " elif f == "i.soybeans": vs = list_values(Item.soybeans) noun = "item " elif f == "i.sesame": vs = list_values(Item.sesame) noun = "item " elif f == "l.dish": vs = list_values(Line.dish) noun = "line " else: vs = list_values(Line.item) noun = "line " seen = {} for v in vs: parts = v.path.split(".") s = noun + parts[len(parts) - 2] + " " + parts[len(parts) - 1] x = str(v.value) if s in seen and seen[s] != x and s not in out: out = out + [s] seen[s] = x return sorted(out) # ── Calculation: a recipe ──────────────────────────────────────────────────── def scale_by_servings(quantity: float, from_servings: float, to_servings: float) -> float: # A quantity for one number of servings, for another: quantity x to / from. # Where that is not a normal real, it is worked on the mantissas and the # powers of two apart, as math.frexp gives them, which leaves no step to # pass the range: a different answer only where the first had lost digits. v = quantity * to_servings / from_servings if math.isfinite(v) and abs(v) >= 2.2250738585072014e-308: return v q = math.frexp(quantity) t = math.frexp(to_servings) f = math.frexp(from_servings) return math.ldexp(q[0] * t[0] / f[0], q[1] + t[1] - f[1]) def bakers_percentage(part: float, flour: float) -> float: # An ingredient's weight as a percentage of the flour's, 100 x part / # flour; the flour is 100, and the water's is the hydration. A flour of zero # refuses with a division by zero. Where 100 x part / flour is not a normal # real, it is worked on the mantissas and the powers of two apart, as in # scale_by_servings. v = 100.0 * part / flour if math.isfinite(v) and abs(v) >= 2.2250738585072014e-308: return v a = math.frexp(part) b = math.frexp(flour) return math.ldexp(100.0 * a[0] / b[0], a[1] - b[1]) def volume_to_mass(cups: float, density_grams_per_ml: float) -> float: # Grams for a volume in US customary cups of 236.5882365 mL, by a density in # grams per millilitre that you supply. Where cups x 236.5882365 x density # is not a normal real, it is worked on the mantissas and the powers of two # apart, as in scale_by_servings. v = cups * 236.5882365 * density_grams_per_ml if math.isfinite(v) and abs(v) >= 2.2250738585072014e-308: return v a = math.frexp(cups) b = math.frexp(density_grams_per_ml) return math.ldexp(a[0] * 236.5882365 * b[0], a[1] + b[1]) def food_energy(protein_g: float, carbohydrate_g: float, fat_g: float, alcohol_g: float) -> float: # Kilocalories by the labelling table's approximate factors, 4 for protein, # 4 for carbohydrate, 9 for fat and 7 for alcohol. return 4.0 * protein_g + 4.0 * carbohydrate_g + 9.0 * fat_g + 7.0 * alcohol_g def kilocalories_to_kilojoules(kilocalories: float) -> float: # One kilocalorie is 4.184 kilojoules. return kilocalories * 4.184