nx_ferment_kinetics.nx source
↩ module page · 120 lines · 5534 B
1// nx_ferment_kinetics.nx -- R3 science rung: predictive fermentation model.
2//
3// "All the science": predict a ferment's acidification curve pH(t) from
4// first principles -- culture population growth + temperature-scaled
5// lactic-acid production -- so a recipe can be SIMULATED before a drop
6// of milk is warmed, and so the generative rung (R5) can predict whether
7// a NOVEL ferment will reach a safe pH.
8//
9// THE MODEL (fixed-point, integer-exact, deterministic):
10// 1. Population N(t): logistic growth dN = r*N*(K-N)/K
11// r = r_base * temp_factor(T) -- warmer culture grows faster
12// 2. Acid A(t): produced in proportion to population AND metabolic
13// rate dA = acid_yield * temp_factor(T) * N/K
14// (the lactose -> lactic-acid flux; warmer bacteria metabolise
15// faster, so the temperature effect COMPOUNDS through both N and A)
16// 3. pH(t) = pH0 - A (acid accumulation drops pH from milk's 6.5)
17//
18// TEMPERATURE -> RATE via the biological Q10 coefficient (van't Hoff):
19// rate(T) / rate(T_ref) = Q10 ^ ((T - T_ref)/10)
20// computed as exp( ln(Q10)/10 * (T - T_ref) ) using the canonical
21// nx_exp substrate. Q10 ~ 2.5 for lactic-acid bacteria.
22//
23// NOTE: nx_arrhenius's reciprocal-of-Kelvin form was evaluated and is
24// UNUSABLE here -- 1/T in its Q10(1024) fixed-point rounds a 13 C span
25// to delta 0 (1048576/310426 = 3). The biological-Q10 exponential is
26// both the correct microbiology model for this range AND precision-
27// safe, so we compose nx_exp directly.
28//
29// The measured exceed: a $40 yogurt maker holds 43 C but PREDICTS
30// nothing. This rung predicts the set-time curve and proves, by
31// construction, that warmer ferments (within range) acidify faster --
32// the quantitative Q10 claim, gate-checked, not asserted.
33//
34// genealogy_id: vant_hoff_1884_q10 + verhulst_1838_logistic
35// + monod_1949_growth + nishi_exp_q10_substrate_2026
36
37import "nx_exp.nx"
38
39const NX_FKIN_K: i64 = 1000000 // carrying capacity (relative pop x1e6)
40const NX_FKIN_N0: i64 = 1000 // inoculum
41const NX_FKIN_R_BASE_MILLI: i64 = 300 // growth rate 0.300/hr at T_ref
42const NX_FKIN_ACID_YIELD_MILLI:i64 = 150 // acid flux at full pop, T_ref (milli-pH/hr)
43const NX_FKIN_T_REF_C: i64 = 30 // reference temperature (C)
44const NX_FKIN_Q10_K_Q10: i64 = 94 // ln(2.5)/10 * 1024 (LAB Q10 ~ 2.5)
45const NX_FKIN_PH0_MILLI: i64 = 6500 // milk pH 6.5
46const NX_FKIN_PH_FLOOR_MILLI: i64 = 3900 // practical ferment floor ~3.9
47const NX_FKIN_SET_PH_MILLI: i64 = 4600 // "set"/safe acidification target
48const NX_FKIN_MAX_HOURS: i64 = 600 // simulation horizon
49const NX_FKIN_Q10_ONE: i64 = 1024
50
51// Temperature -> rate multiplier (Q10 fixed-point, 1024 = 1.0x).
52// At T_ref returns 1024 exactly; warmer > 1024, cooler < 1024.
53func nx_ferment_kinetics_temp_factor_q10(t_c: i64) -> i64 {
54 let dt: i64 = t_c - NX_FKIN_T_REF_C
55 let x_q10: i64 = NX_FKIN_Q10_K_Q10 * dt
56 return nx_exp_q10(x_q10)
57}
58
59// Hours until the ferment reaches the safe set pH (<= 4.6) at the given
60// hold temperature; -1 if it never sets within the horizon (too cold /
61// stalled). This IS the predicted set-time.
62func nx_ferment_kinetics_set_time(t_c: i64) -> i64 {
63 let tf: i64 = nx_ferment_kinetics_temp_factor_q10(t_c)
64 let r_eff: i64 = (NX_FKIN_R_BASE_MILLI * tf) / NX_FKIN_Q10_ONE
65 let acid_rate: i64 = (NX_FKIN_ACID_YIELD_MILLI * tf) / NX_FKIN_Q10_ONE
66 var n: i64 = NX_FKIN_N0
67 var acid: i64 = 0
68 var ph: i64 = NX_FKIN_PH0_MILLI
69 var hour: i64 = 0
70 while hour < NX_FKIN_MAX_HOURS {
71 let grow: i64 = ((r_eff * n / 1000) * (NX_FKIN_K - n)) / NX_FKIN_K
72 n = n + grow
73 if n > NX_FKIN_K { n = NX_FKIN_K }
74 let dacid: i64 = (acid_rate * n) / NX_FKIN_K
75 acid = acid + dacid
76 ph = NX_FKIN_PH0_MILLI - acid
77 if ph < NX_FKIN_PH_FLOOR_MILLI { ph = NX_FKIN_PH_FLOOR_MILLI }
78 hour = hour + 1
79 if ph <= NX_FKIN_SET_PH_MILLI { return hour }
80 }
81 return 0 - 1
82}
83
84// Predicted pH at a given hour at the given hold temperature (milli-pH).
85func nx_ferment_kinetics_ph_at(t_c: i64, target_hour: i64) -> i64 {
86 let tf: i64 = nx_ferment_kinetics_temp_factor_q10(t_c)
87 let r_eff: i64 = (NX_FKIN_R_BASE_MILLI * tf) / NX_FKIN_Q10_ONE
88 let acid_rate: i64 = (NX_FKIN_ACID_YIELD_MILLI * tf) / NX_FKIN_Q10_ONE
89 var n: i64 = NX_FKIN_N0
90 var acid: i64 = 0
91 var ph: i64 = NX_FKIN_PH0_MILLI
92 var hour: i64 = 0
93 while hour < target_hour {
94 let grow: i64 = ((r_eff * n / 1000) * (NX_FKIN_K - n)) / NX_FKIN_K
95 n = n + grow
96 if n > NX_FKIN_K { n = NX_FKIN_K }
97 let dacid: i64 = (acid_rate * n) / NX_FKIN_K
98 acid = acid + dacid
99 ph = NX_FKIN_PH0_MILLI - acid
100 if ph < NX_FKIN_PH_FLOOR_MILLI { ph = NX_FKIN_PH_FLOOR_MILLI }
101 hour = hour + 1
102 }
103 return ph
104}
105
106// Predicted relative population (x1e6) at a given hour -- exposes the
107// growth curve for the planner / generative rung.
108func nx_ferment_kinetics_pop_at(t_c: i64, target_hour: i64) -> i64 {
109 let tf: i64 = nx_ferment_kinetics_temp_factor_q10(t_c)
110 let r_eff: i64 = (NX_FKIN_R_BASE_MILLI * tf) / NX_FKIN_Q10_ONE
111 var n: i64 = NX_FKIN_N0
112 var hour: i64 = 0
113 while hour < target_hour {
114 let grow: i64 = ((r_eff * n / 1000) * (NX_FKIN_K - n)) / NX_FKIN_K
115 n = n + grow
116 if n > NX_FKIN_K { n = NX_FKIN_K }
117 hour = hour + 1
118 }
119 return n
120}