code wiki / (root) / nx_water_activity.nx

nx_water_activity.nx source

↩ module page · 193 lines · 7724 B

1// nx_water_activity.nx -- FOOD-SCIENCE SUITE / WATER ACTIVITY + HURDLE rung. 2// Water activity is the second great preservation variable after pH, and it 3// falls straight out of arithmetic this ecosystem is already doing: the same 4// dissolved-solute count that sets the freezing point sets aw, because both 5// are colligative. nx_icecream already computes those moles, so aw costs 6// one Raoult ratio rather than a new model. 7// 8// aw = n_water / (n_water + n_solute) 9// 10// THE POINT IS THE HURDLE MODEL, NOT THE NUMBER. Food is not made safe by 11// one barrier but by whichever barrier actually holds, and until now the 12// ecosystem could reason about two of them in isolation: nx_chem_solution 13// owns the pH 4.6 line, nx_thermal_process owns lethality and the cold 14// chain. Neither knew about water. wa_hurdle_verdict composes all three 15// and NAMES the barrier doing the work: 16// aw < 0.85 -> safe by WATER ACTIVITY (FDA non-PHF); honey and 17// milk powder live here, and nothing else matters 18// pH < 4.6 -> safe by ACID 19// low-acid, ambient -> only a 12D thermal process will do 20// low-acid, chilled -> pasteurisation plus the COLD CHAIN 21// Naming the hurdle matters: "safe" without knowing which barrier holds is 22// how a reformulation quietly removes the only thing that was working. 23// 24// AND IT IS WILLING TO SAY THE UNCOMFORTABLE THING. An ice cream mix comes 25// out at aw ~0.977, which is well above the 0.93 that C. botulinum needs. 26// The mix is NOT protected by water activity, and this rung says so rather 27// than reporting a reassuring number -- that is precisely why the cold chain 28// is load-bearing for frozen dessert. 29// 30// Units: aw is a 0..1 ratio carried as _q4 (x10000), because the decisions 31// sit at the third decimal (0.850 vs 0.858) and _q3 would round them away. 32// 33// Grounding (cited; researcher-groundable): 34// raoult_law_water_activity_ideal_solution 35// fda_21cfr113_114_potentially_hazardous_food_aw_0_85 36// icmsf_minimum_water_activity_for_growth_by_organism 37// beuchat_xerophilic_mould_growth_limit_0_61 38// 39// genealogy_id: hurdle_technology + nishi_food_science_suite 40 41import "nx_syscalls.nx" 42import "nx_icecream.nx" 43import "nx_chem_solution.nx" 44import "nx_thermal_process.nx" 45const WA_MAGIC_10000: i64 = 10000 46 47// Micromoles of water per gram: 1/18.015 mol/g = 0.055509 mol/g. 48const WA_UMOL_PER_G_WATER: i64 = 55509 49 50const WA_INVALID: i64 = 0 - 1 51 52// ===== Minimum water activity for growth, x10000 ====================== 53 54const WA_ORG_SALMONELLA: i64 = 0 55const WA_ORG_CBOT: i64 = 1 56const WA_ORG_STAPH: i64 = 2 57const WA_ORG_YEAST: i64 = 3 58const WA_ORG_MOLD: i64 = 4 59const WA_ORG_XEROPHILE: i64 = 5 60const WA_ORG_N: i64 = 6 61 62const WA_AW_SALMONELLA: i64 = 9400 63const WA_AW_CBOT: i64 = 9300 64const WA_AW_STAPH: i64 = 8600 // lowest of the bacterial pathogens 65const WA_AW_YEAST: i64 = 8800 66const WA_AW_MOLD: i64 = 8000 67const WA_AW_XEROPHILE: i64 = 6100 // below this NOTHING grows at all 68 69// FDA line for a food that is not potentially hazardous on water alone. 70const WA_FDA_PHF_LIMIT: i64 = 8500 71 72// ===== Hurdle verdicts (sealed) ======================================= 73 74const WA_UNSAFE: i64 = 0 75const WA_SAFE_BY_AW: i64 = 1 76const WA_SAFE_BY_ACID: i64 = 2 77const WA_SAFE_BY_THERMAL: i64 = 3 78const WA_SAFE_BY_COLD_CHAIN: i64 = 4 79 80// ===== Water activity ================================================= 81 82func wa_water_umol(water_g: i64) -> i64 { 83 if water_g <= 0 { return 0 } 84 return water_g * WA_UMOL_PER_G_WATER 85} 86 87// Raoult: aw = n_water/(n_water + n_solute), x10000. 88// REFUSES on no water -- a dry solid has no aw by this route, and returning 89// 0 would read as "bone dry and therefore safe", the most dangerous possible 90// wrong answer here. 91func wa_from_solutes_q4(water_g: i64, solute_umol: i64) -> i64 { 92 let nw: i64 = wa_water_umol(water_g) 93 if nw <= 0 { return WA_INVALID } 94 if solute_umol < 0 { return WA_INVALID } 95 let denom: i64 = nw + solute_umol 96 if denom <= 0 { return WA_INVALID } 97 return nw * WA_MAGIC_10000 / denom 98} 99 100// aw of a frozen-dessert mix, reusing the very moles that set its freezing 101// point -- one solute count, two physical consequences. 102func wa_from_mix_q4(m: *NxIceMix) -> i64 { 103 let water: i64 = ic_water_g(m) 104 if water <= 0 { return WA_INVALID } 105 let umol: i64 = ic_total_solute_umol(m) 106 return wa_from_solutes_q4(water, umol) 107} 108 109// ===== Growth thresholds ============================================== 110 111func wa_min_growth_q4(organism: i64) -> i64 { 112 if organism == WA_ORG_SALMONELLA { return WA_AW_SALMONELLA } 113 if organism == WA_ORG_CBOT { return WA_AW_CBOT } 114 if organism == WA_ORG_STAPH { return WA_AW_STAPH } 115 if organism == WA_ORG_YEAST { return WA_AW_YEAST } 116 if organism == WA_ORG_MOLD { return WA_AW_MOLD } 117 if organism == WA_ORG_XEROPHILE { return WA_AW_XEROPHILE } 118 return WA_INVALID 119} 120 121// Fail-closed: an unknown organism is treated as ABLE to grow, never as 122// absent. An unrecognised hazard is not a safe hazard. 123func wa_supports_growth(aw_q4: i64, organism: i64) -> i64 { 124 if aw_q4 == WA_INVALID { return 1 } 125 let limit: i64 = wa_min_growth_q4(organism) 126 if limit == WA_INVALID { return 1 } 127 if aw_q4 >= limit { return 1 } 128 return 0 129} 130 131// How many of the catalogued organisms this aw still permits. 132func wa_organisms_supported(aw_q4: i64) -> i64 { 133 var i: i64 = 0 134 var n: i64 = 0 135 while i < WA_ORG_N { 136 let g: i64 = wa_supports_growth(aw_q4, i) 137 n = n + g 138 i = i + 1 139 } 140 return n 141} 142 143func wa_is_potentially_hazardous(aw_q4: i64) -> i64 { 144 if aw_q4 == WA_INVALID { return 1 } 145 if aw_q4 >= WA_FDA_PHF_LIMIT { return 1 } 146 return 0 147} 148 149// ===== Inversion: solute needed for a target aw ======================= 150// 151// n_solute = n_water * (1 - aw)/aw. Useful in reverse for a powder or a 152// syrup: it will happily report a quantity that is not physically soluble, 153// which is the honest answer -- it means the target is unreachable by adding 154// solute and needs water REMOVED instead. 155func wa_solute_umol_for_target(water_g: i64, target_aw_q4: i64) -> i64 { 156 if target_aw_q4 <= 0 { return WA_INVALID } 157 if target_aw_q4 >= WA_MAGIC_10000 { return WA_INVALID } 158 let nw: i64 = wa_water_umol(water_g) 159 if nw <= 0 { return WA_INVALID } 160 let gap: i64 = WA_MAGIC_10000 - target_aw_q4 161 return nw * gap / target_aw_q4 162} 163 164// ===== The hurdle model =============================================== 165// 166// Returns WHICH barrier makes the food safe, or WA_UNSAFE. Evaluated in 167// order of sufficiency: a food dry enough needs nothing else; an acid food 168// needs only a pasteurising heat step; a low-acid ambient food has nothing 169// but the retort; a low-acid chilled food leans on the cold chain. 170 171func wa_hurdle_verdict(ph_milli: i64, aw_q4: i64, f0_ms: i64, pasteurized: i64, ambient_stable: i64) -> i64 { 172 if aw_q4 != WA_INVALID { 173 if aw_q4 < WA_FDA_PHF_LIMIT { return WA_SAFE_BY_AW } 174 } 175 let acid: i64 = cs_is_high_acid(ph_milli) 176 if acid == 1 { 177 if pasteurized == 1 { return WA_SAFE_BY_ACID } 178 return WA_UNSAFE 179 } 180 if ambient_stable == 1 { 181 let cooked: i64 = tp_bot_cook_adequate(f0_ms) 182 if cooked == 1 { return WA_SAFE_BY_THERMAL } 183 return WA_UNSAFE 184 } 185 if pasteurized == 1 { return WA_SAFE_BY_COLD_CHAIN } 186 return WA_UNSAFE 187} 188 189func wa_is_safe(ph_milli: i64, aw_q4: i64, f0_ms: i64, pasteurized: i64, ambient_stable: i64) -> i64 { 190 let v: i64 = wa_hurdle_verdict(ph_milli, aw_q4, f0_ms, pasteurized, ambient_stable) 191 if v == WA_UNSAFE { return 0 } 192 return 1 193}