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}