nx_ferment_thermal.nx source
↩ module page · 79 lines · 3732 B
1// nx_ferment_thermal.nx -- R1 hardware rung: vessel temperature hold.
2//
3// Composes nx_pid (control loop) + nx_ferment_safety (the never-poison
4// envelope CLAMPS every commanded setpoint, so the loop can NEVER
5// target a temperature above culture-kill). Heat-only actuator model
6// (a yogurt/cheese vessel heats; cooling is passive loss to ambient) --
7// the cheapest real hardware.
8//
9// TWO independent safety layers, so no PID mistuning can cook a culture:
10// 1. setpoint CLAMP at construction (kill - margin), and
11// 2. a hard thermal INTERLOCK in every step: at/above the ceiling the
12// heater command is forced to 0 regardless of the controller.
13// With a bounded per-step heat (heater_max * heat_gain) smaller than the
14// margin, the vessel can never cross culture-kill in a single step.
15//
16// Proven by the gate: unsafe setpoints clamp; the command is heat-only
17// and bounded; a room-temperature vessel is driven up into the setpoint
18// neighborhood; and temperature NEVER reaches culture-kill across the
19// whole run (the ceiling holds DYNAMICALLY, not just at config time).
20//
21// genealogy_id: ziegler_nichols_1942_pid (via nx_pid)
22// + nishi_ferment_safety_envelope_2026 (via nx_ferment_safety)
23
24import "nx_syscalls.nx"
25import "nx_pid.nx"
26import "nx_ferment_safety.nx"
27
28const NX_FT_SAFE_MARGIN_MILLI_C: i64 = 1000 // hold setpoint + interlock 1 C below kill
29
30struct NxFermentThermal {
31 pid: *PID,
32 setpoint_milli_c: i64, // clamped-safe setpoint
33 kill_milli_c: i64, // culture-kill ceiling from the envelope
34 heater_max: i64,
35}
36
37// Clamp a requested setpoint so it never reaches culture-kill.
38func nx_ferment_thermal_safe_setpoint(env: *NxFermentSafetyEnvelope, requested_milli_c: i64) -> i64 {
39 let kill: i64 = env.culture_kill_temp_milli_c as i64
40 let ceiling: i64 = kill - NX_FT_SAFE_MARGIN_MILLI_C
41 if requested_milli_c > ceiling { return ceiling }
42 return requested_milli_c
43}
44
45func nx_ferment_thermal_new(env: *NxFermentSafetyEnvelope,
46 requested_setpoint_milli_c: i64,
47 kp: i64, ki: i64, kd: i64, i_limit: i64,
48 heater_max: i64) -> *NxFermentThermal {
49 let t: *NxFermentThermal = (sys_mmap(32)) as *NxFermentThermal
50 let p: *PID = (sys_mmap(56)) as *PID
51 nx_pid_init(p, kp, ki, kd, i_limit)
52 t.pid = p
53 t.setpoint_milli_c = nx_ferment_thermal_safe_setpoint(env, requested_setpoint_milli_c)
54 t.kill_milli_c = env.culture_kill_temp_milli_c as i64
55 t.heater_max = heater_max
56 return t
57}
58
59// One control step: measured temperature -> clamped heater command.
60// - HARD INTERLOCK: at/above (kill - margin) force 0 (never add heat).
61// - heat-only: a negative PID output (vessel above setpoint) -> 0.
62// - never exceeds heater_max.
63func nx_ferment_thermal_step(t: *NxFermentThermal, measured_milli_c: i64) -> i64 {
64 if measured_milli_c >= t.kill_milli_c - NX_FT_SAFE_MARGIN_MILLI_C { return 0 }
65 var cmd: i64 = nx_pid_update(t.pid, t.setpoint_milli_c, measured_milli_c)
66 if cmd < 0 { cmd = 0 }
67 if cmd > t.heater_max { cmd = t.heater_max }
68 return cmd
69}
70
71// First-order thermal plant (Newton cooling): the vessel gains heat from
72// the heater command and loses heat to ambient. Q14 coefficients. Used
73// by the gate to exercise the closed loop; also a real predictive model.
74func nx_ferment_plant_step(temp_milli_c: i64, heater_cmd: i64, ambient_milli_c: i64,
75 heat_gain_q14: i64, loss_q14: i64) -> i64 {
76 let heat: i64 = (heater_cmd * heat_gain_q14) / NX_PID_Q
77 let loss: i64 = ((temp_milli_c - ambient_milli_c) * loss_q14) / NX_PID_Q
78 return temp_milli_c + heat - loss
79}