code wiki / (root) / nx_thermal_process.nx

nx_thermal_process.nx source

↩ module page · 261 lines · 11782 B

1// nx_thermal_process.nx -- FOOD-SCIENCE SUITE / THERMAL PROCESS rung. The 2// heat side of food safety: how much lethality a time-temperature hold 3// actually delivers, whether a proposed process meets the legal 4// pasteurization schedule, and -- the decision that governs everything -- 5// whether a food needs a botulinum cook at all. 6// 7// This completes a triangle the ecosystem already had two corners of. 8// nx_ferment_safety refuses unsafe FERMENTS on the pH 4.6 botulinum line. 9// nx_chem_solution owns that line as chemistry (CS_HIGH_ACID_PH_MILLI). 10// Neither could say anything about HEAT, so a mix that is pasteurised 11// rather than acidified had no safety path -- and ice cream mix is exactly 12// that case, since 21 CFR 135.110 requires the mix to be pasteurised. 13// 14// ACID AND HEAT ARE ALTERNATIVE BARRIERS, NOT A CHECKLIST. That is the 15// whole logic of tp_barrier_required, and it is why this imports 16// nx_chem_solution instead of restating 4.6: 17// pH < 4.6 -> the ACID barrier holds; C. botulinum 18// cannot grow; pasteurisation suffices. 19// pH >= 4.6, shelf-stable -> nothing but heat stands between the 20// product and botulism -> full 12D cook. 21// pH >= 4.6, kept cold -> pasteurise AND hold the cold chain; 22// refrigeration is the second barrier. 23// Ice cream mix is the third case. Treating it as the second would be a 24// pointless retort; treating it as the first would be dangerous. 25// 26// WHAT IS ASSERTED VS WHAT IS DERIVED -- read this before trusting a number. 27// The only organism constant here is C. botulinum proteolytic spores, 28// D(121.1 C) = 0.21 min, z = 10 C. That pair is canonical, universally 29// published, and is the entire basis of the 12D cook and of F0. I did NOT 30// bake in D/z values for the pasteurisation organisms, because I could not 31// verify them to the precision a safety calculation deserves. 32// 33// Instead the z-value for pasteurisation is DERIVED FROM THE LAW ITSELF. 34// The PMO gives two schedules it considers equivalent -- 145 F/30 min and 35// 161 F/15 s -- and two equivalent schedules uniquely determine a z: 36// z = (T2 - T1) / log10(t1/t2) 37// tp_implied_z_milli computes it and gets 4.279 C (exact arithmetic on the 38// pair gives 4.275, so the integer path is good to 0.1%). That sits just 39// BELOW the commonly quoted 4.3-5.0 C band for the milk-pasteurisation design 40// organism -- close agreement, but stated precisely rather than rounded up 41// into the band. The number this file uses was recovered, not guessed. 42// 43// Units: temperature in milli-Celsius, time in MILLISECONDS (a 30 min vat 44// hold and a 0.01 s flash schedule must share one unit), D-values in ms. 45// 46// Grounding (cited; researcher-groundable): 47// stumbo_thermobacteriology_d_z_f0_model 48// c_botulinum_proteolytic_d121_0_21min_z10 (the 12D basis) 49// grade_a_pmo_pasteurization_time_temperature_table 50// pmo_five_degree_increase_fat_10pct_or_sweetened (ice cream mix) 51// fda_21cfr113_low_acid_canned_food_12d 52// 53// genealogy_id: thermal_process_authority + nishi_food_science_suite 54 55import "nx_syscalls.nx" 56import "nx_pow10.nx" 57import "nx_chem_solution.nx" 58 59// ===== C. botulinum reference kinetics (the one asserted organism) ===== 60 61const TP_REF_STERIL_MILLI_C: i64 = 121100 // 121.1 C = 250 F 62const TP_BOT_D_REF_MS: i64 = 12600 // D(121.1) = 0.21 min 63const TP_BOT_Z_MILLI: i64 = 10000 // z = 10 C 64const TP_BOT_TARGET_LOGS: i64 = 12 // the 12D cook 65const TP_F0_STANDARD_MS: i64 = 180000 // 3.0 min, the industry target 66 67// ===== Grade A PMO pasteurization schedules =========================== 68// 69// Base (milk) legs, and the PMO's flat five-degree-Fahrenheit increase for 70// any product at or above 10% fat OR carrying added sweetener -- which is 71// ice cream mix on both counts. The bumped temperatures are DERIVED from 72// the base plus the bump, never restated, so the two can never drift. 73 74const TP_MILK_VAT_TEMP_MILLI: i64 = 62778 // 145 F 75const TP_MILK_VAT_TIME_MS: i64 = 1800000 // 30 min 76const TP_MILK_HTST_TEMP_MILLI: i64 = 71667 // 161 F 77const TP_MILK_HTST_TIME_MS: i64 = 15000 // 15 s 78const TP_HIGHFAT_BUMP_MILLI: i64 = 2778 // +5 F 79 80const TP_SCHED_MILK_VAT: i64 = 0 81const TP_SCHED_MILK_HTST: i64 = 1 82const TP_SCHED_MIX_VAT: i64 = 2 83const TP_SCHED_MIX_HTST: i64 = 3 84 85// ===== Barrier outcomes (sealed) ====================================== 86 87const TP_BARRIER_NONE: i64 = 0 88const TP_BARRIER_ACID: i64 = 1 89const TP_BARRIER_BOT_COOK: i64 = 2 90const TP_BARRIER_REFRIGERATION: i64 = 3 91 92// ===== Schedule accessors ============================================= 93 94func tp_sched_temp_milli(id: i64) -> i64 { 95 if id == TP_SCHED_MILK_VAT { return TP_MILK_VAT_TEMP_MILLI } 96 if id == TP_SCHED_MILK_HTST { return TP_MILK_HTST_TEMP_MILLI } 97 if id == TP_SCHED_MIX_VAT { return TP_MILK_VAT_TEMP_MILLI + TP_HIGHFAT_BUMP_MILLI } 98 if id == TP_SCHED_MIX_HTST { return TP_MILK_HTST_TEMP_MILLI + TP_HIGHFAT_BUMP_MILLI } 99 return 0 100} 101 102func tp_sched_time_ms(id: i64) -> i64 { 103 if id == TP_SCHED_MILK_VAT { return TP_MILK_VAT_TIME_MS } 104 if id == TP_SCHED_MILK_HTST { return TP_MILK_HTST_TIME_MS } 105 if id == TP_SCHED_MIX_VAT { return TP_MILK_VAT_TIME_MS } 106 if id == TP_SCHED_MIX_HTST { return TP_MILK_HTST_TIME_MS } 107 return 0 108} 109 110// ===== Core kinetics ================================================== 111// 112// PRECISION NOTE, and it matters for safety: a negative exponent is applied 113// by DIVIDING by the positive power, never by multiplying by a Q3 114// reciprocal. 10^-2.077 in Q3 is the integer 8 -- one significant figure, 115// a 4% error that made a legally-equivalent vat schedule read as 116// non-compliant on the first attempt here. Dividing keeps full precision. 117 118// D-value at temperature T: D_T = D_ref * 10^((T_ref - T)/z). 119func tp_d_at_temp_ms(d_ref_ms: i64, t_ref_milli: i64, z_milli: i64, t_milli: i64) -> i64 { 120 if z_milli <= 0 { return 0 } 121 let delta: i64 = t_ref_milli - t_milli 122 let exp_milli: i64 = delta * 1000 / z_milli 123 if exp_milli >= 0 { 124 let f: i64 = ipow10_q3(exp_milli) 125 return d_ref_ms * f / 1000 126 } 127 let g: i64 = ipow10_q3(0 - exp_milli) 128 if g <= 0 { return 0 } 129 return d_ref_ms * 1000 / g 130} 131 132// Log reductions delivered by a hold, x1000. 133func tp_log_reductions_q3(hold_ms: i64, d_ms: i64) -> i64 { 134 if d_ms <= 0 { return 0 } 135 return hold_ms * 1000 / d_ms 136} 137 138// Equivalent time at a reference temperature: F = t * 10^((T - T_ref)/z). 139func tp_equiv_time_at_ref_ms(hold_ms: i64, temp_milli: i64, t_ref_milli: i64, z_milli: i64) -> i64 { 140 if z_milli <= 0 { return 0 } 141 let delta: i64 = temp_milli - t_ref_milli 142 let exp_milli: i64 = delta * 1000 / z_milli 143 if exp_milli >= 0 { 144 let f: i64 = ipow10_q3(exp_milli) 145 return hold_ms * f / 1000 146 } 147 let g: i64 = ipow10_q3(0 - exp_milli) 148 if g <= 0 { return 0 } 149 return hold_ms * 1000 / g 150} 151 152// F0: equivalent minutes at 121.1 C with z = 10 C. The standard sterilisation 153// yardstick, in ms. 154func tp_f0_ms(hold_ms: i64, temp_milli: i64) -> i64 { 155 return tp_equiv_time_at_ref_ms(hold_ms, temp_milli, TP_REF_STERIL_MILLI_C, TP_BOT_Z_MILLI) 156} 157 158// ===== The botulinum cook ============================================= 159 160func tp_bot_12d_ms() -> i64 { 161 return TP_BOT_TARGET_LOGS * TP_BOT_D_REF_MS 162} 163 164// Adequate iff the delivered F0 meets the 12D minimum. The nx_pow10 chord 165// can over-state a POSITIVE exponent by up to 0.7%; the industry F0 target 166// of 3.0 min carries ~19% margin over the 2.52 min 12D floor, so that 167// numerical bias cannot reach the verdict. Stated so it can be checked. 168// 169// ⚠This margin note read 0.3% until 2026-07-25, quoting nx_pow10's own 170// header, which had never been measured because that file had no gate. The 171// measured figure is 0.664% (nx_pow10_test T5). Re-checked at the corrected 172// value: 0.7% against ~19% margin, so the adequacy verdict is unchanged. 173// The lesson is not the number -- it is that a bound inherited from an 174// ungated dependency had been carried into a food-safety conclusion. 175func tp_bot_cook_adequate(f0_ms: i64) -> i64 { 176 let need: i64 = tp_bot_12d_ms() 177 if f0_ms >= need { return 1 } 178 return 0 179} 180 181// ===== z recovered from the law ======================================= 182// 183// Two schedules the regulator declares equivalent determine z uniquely: 184// t1 * 10^((T1-Tr)/z) = t2 * 10^((T2-Tr)/z) => z = (T2-T1)/log10(t1/t2) 185// REFUSES (0) unless schedule 1 is the longer, cooler leg -- otherwise the 186// logarithm is zero or negative and the result would be nonsense. 187func tp_implied_z_milli(t1_ms: i64, temp1_milli: i64, t2_ms: i64, temp2_milli: i64) -> i64 { 188 if t2_ms <= 0 { return 0 } 189 if t1_ms <= t2_ms { return 0 } 190 if temp2_milli <= temp1_milli { return 0 } 191 let ratio_q3: i64 = t1_ms * 1000 / t2_ms 192 let log_ratio: i64 = ilog10_milli(ratio_q3) 193 if log_ratio <= 0 { return 0 } 194 let dt: i64 = temp2_milli - temp1_milli 195 return dt * 1000 / log_ratio 196} 197 198// The pasteurisation z-value this file uses, recovered from the PMO pair. 199func tp_pasteurization_z_milli() -> i64 { 200 return tp_implied_z_milli(TP_MILK_VAT_TIME_MS, TP_MILK_VAT_TEMP_MILLI, TP_MILK_HTST_TIME_MS, TP_MILK_HTST_TEMP_MILLI) 201} 202 203// ===== Pasteurization compliance ====================================== 204// 205// Kinetic, not a lookup: any time-temperature pair is legal if it delivers 206// at least the lethality of the applicable HTST leg. That admits the whole 207// continuum of valid processes (including the flash schedules) instead of 208// only the four tabulated points. 209 210func tp_htst_sched_for(highfat_or_sweetened: i64) -> i64 { 211 if highfat_or_sweetened == 1 { return TP_SCHED_MIX_HTST } 212 return TP_SCHED_MILK_HTST 213} 214 215func tp_pasteurization_equiv_ms(temp_milli: i64, hold_ms: i64, highfat_or_sweetened: i64) -> i64 { 216 let sched: i64 = tp_htst_sched_for(highfat_or_sweetened) 217 let ref_t: i64 = tp_sched_temp_milli(sched) 218 let z: i64 = tp_pasteurization_z_milli() 219 if z <= 0 { return 0 } 220 return tp_equiv_time_at_ref_ms(hold_ms, temp_milli, ref_t, z) 221} 222 223func tp_pasteurization_compliant(temp_milli: i64, hold_ms: i64, highfat_or_sweetened: i64) -> i64 { 224 if hold_ms <= 0 { return 0 } 225 let sched: i64 = tp_htst_sched_for(highfat_or_sweetened) 226 let need: i64 = tp_sched_time_ms(sched) 227 let got: i64 = tp_pasteurization_equiv_ms(temp_milli, hold_ms, highfat_or_sweetened) 228 if got >= need { return 1 } 229 return 0 230} 231 232// Ice cream mix is high-fat AND sweetened, so it always takes the bumped 233// schedule. Named so a caller cannot forget which flag applies. 234func tp_ice_cream_mix_compliant(temp_milli: i64, hold_ms: i64) -> i64 { 235 return tp_pasteurization_compliant(temp_milli, hold_ms, 1) 236} 237 238// ===== The barrier decision =========================================== 239 240func tp_barrier_required(ph_milli: i64, ambient_stable: i64) -> i64 { 241 let acid: i64 = cs_is_high_acid(ph_milli) 242 if acid == 1 { return TP_BARRIER_ACID } 243 if ambient_stable == 1 { return TP_BARRIER_BOT_COOK } 244 return TP_BARRIER_REFRIGERATION 245} 246 247// Does this process actually satisfy the barrier the food requires? 248// Fail-closed: an unrecognised barrier returns 0. 249func tp_process_adequate(ph_milli: i64, ambient_stable: i64, f0_ms: i64, pasteurized: i64) -> i64 { 250 let barrier: i64 = tp_barrier_required(ph_milli, ambient_stable) 251 if barrier == TP_BARRIER_BOT_COOK { return tp_bot_cook_adequate(f0_ms) } 252 if barrier == TP_BARRIER_ACID { 253 if pasteurized == 1 { return 1 } 254 return 0 255 } 256 if barrier == TP_BARRIER_REFRIGERATION { 257 if pasteurized == 1 { return 1 } 258 return 0 259 } 260 return 0 261}