nx_thermal_profile.nx source
↩ module page · 177 lines · 6574 B
1// nx_thermal_profile.nx -- FOOD-SCIENCE SUITE / PROCESS INTEGRATION rung.
2// nx_thermal_process evaluates a single hold at a single temperature. A real
3// retort or HTST run is a CURVE: it comes up to temperature, holds, and cools
4// back down, and the product is being killed the whole time. This integrates
5// lethality over that whole profile -- the General Method.
6//
7// F = SUM over segments of dt_i * 10^((T_i - T_ref)/z)
8//
9// WHY IT MATTERS, AND IT IS NOT A ROUNDING CORRECTION. Treating a process
10// as hold-only is conservative, which sounds harmless -- but the come-up on
11// a typical retort contributes lethality COMPARABLE TO THE ENTIRE HOLD. Ten
12// minutes ramping from 102 C to 120 C delivers roughly 2 minutes of F0 on
13// its own, next to a 2.52 minute 12D cook. A process authority that ignores
14// it does not get a safer product; it gets an OVER-processed one, and
15// over-processing is exactly how a food is made safe and inedible at the
16// same time. So the honest direction of the hold-only error is "wasteful",
17// not "fine".
18//
19// SEGMENTS LIVE IN MMAP'D ARRAYS, NOT BSS. A module-level `static [N]i64`
20// silently kills a handler-module process on startup in this toolchain, so
21// the arrays are lazily mmap'd and reached through pointers -- the standard
22// workaround here, not a stylistic choice.
23//
24// EVERY BOUND IS FAIL-CLOSED. A segment past capacity is REFUSED rather
25// than dropped, because a silently-truncated profile reports LESS lethality
26// than the process really delivers, and an operator would respond by
27// cooking longer -- the failure would hide behind a plausible number.
28//
29// Units match nx_thermal_process exactly: milli-Celsius, milliseconds.
30//
31// Grounding (cited; researcher-groundable):
32// bigelow_general_method_thermal_process_integration
33// stumbo_thermobacteriology_d_z_f0_model
34// c_botulinum_proteolytic_d121_0_21min_z10
35//
36// genealogy_id: thermal_process_authority + nishi_food_science_suite
37
38import "nx_syscalls.nx"
39import "nx_pow10.nx"
40import "nx_thermal_process.nx"
41
42const TPR_MAX_SEGMENTS: i64 = 256
43const TPR_REFUSED: i64 = 0 - 1
44
45struct NxThermalProfile {
46 temps: *i64,
47 durs: *i64,
48 n: i64,
49 cap: i64,
50 valid: i64,
51}
52
53func nx_thermal_profile_new(cap: i64) -> *NxThermalProfile {
54 let p: *NxThermalProfile = (sys_mmap(64)) as *NxThermalProfile
55 var c: i64 = cap
56 if c <= 0 { c = 1 }
57 if c > TPR_MAX_SEGMENTS { c = TPR_MAX_SEGMENTS }
58 p.temps = (sys_mmap(c * 8)) as *i64
59 p.durs = (sys_mmap(c * 8)) as *i64
60 p.n = 0
61 p.cap = c
62 p.valid = 1
63 return p
64}
65
66// Append one isothermal segment. A zero or negative duration is REFUSED
67// rather than ignored: it means the caller's profile is malformed, and
68// quietly skipping it would under-report lethality.
69func tpr_add_segment(p: *NxThermalProfile, temp_milli: i64, dur_ms: i64) -> i64 {
70 if p.valid != 1 { return TPR_REFUSED }
71 if dur_ms <= 0 { p.valid = 0; return TPR_REFUSED }
72 if p.n >= p.cap { p.valid = 0; return TPR_REFUSED }
73 let t: *i64 = p.temps
74 let d: *i64 = p.durs
75 t[p.n] = temp_milli
76 d[p.n] = dur_ms
77 p.n = p.n + 1
78 return p.n
79}
80
81// Approximate a linear ramp with n_steps isothermal segments, each held at
82// the MIDPOINT of its interval. Midpoint rather than endpoint because
83// lethality is convex in temperature: sampling at the start understates it
84// and at the end overstates it, while the midpoint is the standard
85// compromise and is what the General Method assumes.
86func tpr_add_ramp(p: *NxThermalProfile, t_start_milli: i64, t_end_milli: i64, dur_ms: i64, n_steps: i64) -> i64 {
87 if n_steps <= 0 { p.valid = 0; return TPR_REFUSED }
88 if dur_ms <= 0 { p.valid = 0; return TPR_REFUSED }
89 let seg_ms: i64 = dur_ms / n_steps
90 if seg_ms <= 0 { p.valid = 0; return TPR_REFUSED }
91 let span: i64 = t_end_milli - t_start_milli
92 var i: i64 = 0
93 while i < n_steps {
94 let num: i64 = span * (2 * i + 1)
95 let mid: i64 = t_start_milli + num / (2 * n_steps)
96 tpr_add_segment(p, mid, seg_ms)
97 i = i + 1
98 }
99 return p.n
100}
101
102func tpr_total_time_ms(p: *NxThermalProfile) -> i64 {
103 if p.valid != 1 { return 0 }
104 let d: *i64 = p.durs
105 var i: i64 = 0
106 var sum: i64 = 0
107 while i < p.n {
108 sum = sum + d[i]
109 i = i + 1
110 }
111 return sum
112}
113
114func tpr_peak_temp_milli(p: *NxThermalProfile) -> i64 {
115 if p.valid != 1 { return 0 }
116 let t: *i64 = p.temps
117 var i: i64 = 0
118 var hi: i64 = 0
119 while i < p.n {
120 if t[i] > hi { hi = t[i] }
121 i = i + 1
122 }
123 return hi
124}
125
126// ===== The integral ===================================================
127//
128// Accumulated equivalent time at the reference temperature. Each segment is
129// converted by nx_thermal_process's own equivalence routine, so the
130// precision discipline there (divide by the positive power for a negative
131// exponent, never multiply a Q3 reciprocal) is inherited rather than
132// re-derived.
133
134func tpr_lethality_ms(p: *NxThermalProfile, t_ref_milli: i64, z_milli: i64) -> i64 {
135 if p.valid != 1 { return TPR_REFUSED }
136 if p.n <= 0 { return TPR_REFUSED }
137 let t: *i64 = p.temps
138 let d: *i64 = p.durs
139 var i: i64 = 0
140 var acc: i64 = 0
141 while i < p.n {
142 let e: i64 = tp_equiv_time_at_ref_ms(d[i], t[i], t_ref_milli, z_milli)
143 acc = acc + e
144 i = i + 1
145 }
146 return acc
147}
148
149// F0 of the whole profile: 121.1 C, z = 10 C.
150func tpr_f0_ms(p: *NxThermalProfile) -> i64 {
151 return tpr_lethality_ms(p, TP_REF_STERIL_MILLI_C, TP_BOT_Z_MILLI)
152}
153
154func tpr_bot_cook_adequate(p: *NxThermalProfile) -> i64 {
155 let f0: i64 = tpr_f0_ms(p)
156 if f0 == TPR_REFUSED { return 0 }
157 return tp_bot_cook_adequate(f0)
158}
159
160// How much lethality a hold-only calculation THROWS AWAY: the profile's
161// true F0 minus the F0 credited to its peak-temperature hold alone.
162// Positive means the ramps are doing real work that hold-only ignores.
163func tpr_ramp_credit_ms(p: *NxThermalProfile, hold_ms: i64) -> i64 {
164 let full: i64 = tpr_f0_ms(p)
165 if full == TPR_REFUSED { return 0 }
166 let peak: i64 = tpr_peak_temp_milli(p)
167 let holdonly: i64 = tp_f0_ms(hold_ms, peak)
168 return full - holdonly
169}
170
171// Log reductions the whole profile delivers against an organism given by its
172// reference D-value at the reference temperature.
173func tpr_log_reductions_q3(p: *NxThermalProfile, d_ref_ms: i64, t_ref_milli: i64, z_milli: i64) -> i64 {
174 let f: i64 = tpr_lethality_ms(p, t_ref_milli, z_milli)
175 if f == TPR_REFUSED { return 0 }
176 return tp_log_reductions_q3(f, d_ref_ms)
177}