nx_icecream_balance.nx source
↩ module page · 198 lines · 8926 B
1// nx_icecream_balance.nx -- FOOD-SCIENCE SUITE / MIX BALANCING rung.
2// nx_icecream ANALYSES a mix that already exists. This SOLVES for the mix:
3// given targets, it returns the grams of each ingredient to weigh out.
4// That inversion is the formulator's actual job, and it is the difference
5// between a calculator and a design tool.
6//
7// THREE SOLVERS, EACH EXACT -- no iteration, no search, no float.
8//
9// 1. THE DAIRY BALANCE is a TRIANGULAR system, so it solves in one pass.
10// Fat comes only from cream, so cream is determined first; cream drags
11// MSNF in with it, so the powder only makes up the shortfall; sugar and
12// stabiliser are direct; added water is whatever is left. Solving it as
13// a general matrix would be wrong-headed -- the dependency order IS the
14// dairy chemistry.
15//
16// 2. THE SUGAR BLEND is a 2x2 with a closed form. Two sugars, two
17// constraints (total mass, target PAC), so
18// g_lo = (PAC_hi * S - P) / (PAC_hi - PAC_lo)
19// and the PAC figures come from ic_pac_from_mw, not from constants, so
20// the solver works for ANY sugar pair rather than the one it was written
21// against.
22//
23// 3. THE SERVE-TEMPERATURE INVERSION runs the freezing curve backwards:
24// pick the temperature you want to scoop at and the ice fraction you
25// want there, and it returns the grams of sweetener that produce it.
26// This is where the dextrose lever becomes a number instead of an
27// opinion -- scooping at -12 C takes ~248 g of sucrose or ~130 g of
28// dextrose per kg, because PAC is inverse in molar mass.
29//
30// EVERY SOLVER REFUSES RATHER THAN RETURNS A BAD MIX. Cream that already
31// overshoots the MSNF target, a PAC target outside what the two sugars can
32// reach, negative added water -- all set feasible = 0. A formulation that
33// cannot exist must not come back as numbers a person would then weigh out.
34//
35// ROUNDING IS TO NEAREST, NOT TRUNCATED. Truncation cost a whole PAC unit
36// in nx_icecream (dextrose 190.009 read as 189); here it would silently
37// miss the MSNF target by a gram per stage.
38//
39// Grounding (cited; researcher-groundable):
40// goff_hartel_ice_cream_mix_calculation_serum_point
41// usda_dairy_ingredient_composition (ingredient table)
42// colligative_freezing_point_depression_kf_1_86 (via nx_icecream)
43//
44// genealogy_id: frozen_dessert_science + nishi_food_science_suite
45
46import "nx_syscalls.nx"
47import "nx_icecream.nx"
48
49// ===== Ingredient composition (per 1000 parts) ========================
50
51const ICB_CREAM_FAT_PERMIL: i64 = 400 // 40% heavy cream
52const ICB_CREAM_MSNF_PERMIL: i64 = 54 // 5.4% MSNF rides along with it
53const ICB_SMP_MSNF_PERMIL: i64 = 960 // 96% skim milk powder
54const ICB_MILK_FAT_PERMIL: i64 = 35 // 3.5% whole milk
55const ICB_MILK_MSNF_PERMIL: i64 = 87
56
57const ICB_INFEASIBLE: i64 = 0 - 1
58
59// ===== Solution ========================================================
60//
61// added_water_g is ADDED water, NOT the mix's total water: cream and powder
62// bring their own, and confusing the two would put a formulation out by
63// roughly a quarter of its water. ic_water_g on the resulting mix is the
64// total; this field is what goes in the jug.
65
66struct NxMixSolution {
67 cream_g: i64,
68 smp_g: i64,
69 sugar_g: i64,
70 stab_g: i64,
71 added_water_g: i64,
72 feasible: i64,
73}
74
75func nx_mix_solution_new() -> *NxMixSolution {
76 let s: *NxMixSolution = (sys_mmap(64)) as *NxMixSolution
77 s.cream_g = 0
78 s.smp_g = 0
79 s.sugar_g = 0
80 s.stab_g = 0
81 s.added_water_g = 0
82 s.feasible = 0
83 return s
84}
85
86// Round-to-nearest integer division for positive operands.
87func icb_div_round(num: i64, den: i64) -> i64 {
88 if den <= 0 { return 0 }
89 let half: i64 = den / 2
90 return (num + half) / den
91}
92
93// ===== Solver 1: the dairy balance ====================================
94
95func icb_solve_dairy(batch_g: i64, fat_permil: i64, msnf_permil: i64, sugar_permil: i64, stab_g: i64) -> *NxMixSolution {
96 let s: *NxMixSolution = nx_mix_solution_new()
97 if batch_g <= 0 { return s }
98 s.stab_g = stab_g
99 s.sugar_g = icb_div_round(sugar_permil * batch_g, 1000)
100 let fat_needed: i64 = icb_div_round(fat_permil * batch_g, 1000)
101 s.cream_g = icb_div_round(fat_needed * 1000, ICB_CREAM_FAT_PERMIL)
102 let msnf_from_cream: i64 = icb_div_round(s.cream_g * ICB_CREAM_MSNF_PERMIL, 1000)
103 let msnf_needed: i64 = icb_div_round(msnf_permil * batch_g, 1000)
104 let msnf_short: i64 = msnf_needed - msnf_from_cream
105 // Cream alone already exceeds the MSNF target: no amount of powder can
106 // subtract, so the target pair is unreachable with this cream.
107 if msnf_short < 0 { return s }
108 s.smp_g = icb_div_round(msnf_short * 1000, ICB_SMP_MSNF_PERMIL)
109 let used: i64 = s.cream_g + s.smp_g + s.sugar_g + s.stab_g
110 let water: i64 = batch_g - used
111 if water <= 0 { return s }
112 s.added_water_g = water
113 s.feasible = 1
114 return s
115}
116
117// Assemble the analysable mix the solution describes, so the result can be
118// fed straight back to nx_icecream. Returns a mix with feasible=0 solutions
119// left at zero solids, which nx_icecream's own guards then refuse.
120func icb_solution_to_mix(s: *NxMixSolution, batch_g: i64, sugar_mw_q2: i64, sugar_pod: i64, density_q3: i64) -> *NxIceMix {
121 let m: *NxIceMix = nx_ice_mix_new()
122 m.total_g = batch_g
123 m.sugar_mw_q2 = sugar_mw_q2
124 m.sugar_pod = sugar_pod
125 m.density_q3 = density_q3
126 if s.feasible != 1 { return m }
127 m.fat_g = icb_div_round(s.cream_g * ICB_CREAM_FAT_PERMIL, 1000)
128 let msnf_cream: i64 = icb_div_round(s.cream_g * ICB_CREAM_MSNF_PERMIL, 1000)
129 let msnf_smp: i64 = icb_div_round(s.smp_g * ICB_SMP_MSNF_PERMIL, 1000)
130 m.msnf_g = msnf_cream + msnf_smp
131 m.sugar_g = s.sugar_g
132 m.other_solids_g = s.stab_g
133 return m
134}
135
136// ===== Solver 2: the sugar blend for a PAC target =====================
137//
138// Returns grams of the LOW-PAC sugar; the balance is the high-PAC one.
139// ICB_INFEASIBLE when the target lies outside what the pair can reach --
140// which is the common case for a naive target, and must be said, not
141// clamped to the nearest edge and presented as a solution.
142
143func icb_pac_index_of(sugar_g: i64, batch_g: i64, mw_q2: i64) -> i64 {
144 if batch_g <= 0 { return 0 }
145 let pac: i64 = ic_pac_from_mw(mw_q2)
146 return sugar_g * pac * 10 / batch_g
147}
148
149func icb_solve_sugar_blend(total_sugar_g: i64, batch_g: i64, pac_target: i64, mw_lo_q2: i64, mw_hi_q2: i64) -> i64 {
150 if batch_g <= 0 { return ICB_INFEASIBLE }
151 if total_sugar_g <= 0 { return ICB_INFEASIBLE }
152 let pac_lo: i64 = ic_pac_from_mw(mw_lo_q2)
153 let pac_hi: i64 = ic_pac_from_mw(mw_hi_q2)
154 if pac_hi <= pac_lo { return ICB_INFEASIBLE }
155 // Target expressed in the same units the index uses, then de-scaled.
156 let p: i64 = icb_div_round(pac_target * batch_g, 10)
157 let reach_lo: i64 = total_sugar_g * pac_lo
158 let reach_hi: i64 = total_sugar_g * pac_hi
159 if p < reach_lo { return ICB_INFEASIBLE }
160 if p > reach_hi { return ICB_INFEASIBLE }
161 let num: i64 = reach_hi - p
162 let den: i64 = pac_hi - pac_lo
163 return icb_div_round(num, den)
164}
165
166// ===== Solver 3: sweetener for a target serving temperature ===========
167//
168// Runs ic_frozen_water_permil backwards. At equilibrium the unfrozen water
169// is whatever gives a freezing point of T, so a target ice fraction at a
170// target temperature fixes the required depression, which fixes the total
171// dissolved moles, from which the MSNF contribution is subtracted -- the
172// lactose and milk salts are already there and must not be paid for twice.
173
174func icb_fpd_needed_milli(serve_temp_milli: i64, target_frozen_permil: i64) -> i64 {
175 if target_frozen_permil <= 0 { return 0 }
176 if target_frozen_permil >= 1000 { return 0 }
177 var mag: i64 = serve_temp_milli
178 if mag < 0 { mag = 0 - mag }
179 let unfrozen: i64 = 1000 - target_frozen_permil
180 return icb_div_round(mag * unfrozen, 1000)
181}
182
183func icb_sugar_for_serve_temp(msnf_g: i64, water_g: i64, sugar_mw_q2: i64, serve_temp_milli: i64, target_frozen_permil: i64) -> i64 {
184 if water_g <= 0 { return ICB_INFEASIBLE }
185 if sugar_mw_q2 <= 0 { return ICB_INFEASIBLE }
186 let fpd: i64 = icb_fpd_needed_milli(serve_temp_milli, target_frozen_permil)
187 if fpd <= 0 { return ICB_INFEASIBLE }
188 let umol_total: i64 = icb_div_round(fpd * 1000 * water_g, IC_KF_WATER_Q3)
189 let lac_num: i64 = msnf_g * IC_MSNF_LACTOSE_PERMIL * 100000
190 let umol_lac: i64 = lac_num / IC_MW_LACTOSE_Q2
191 let salt_num: i64 = msnf_g * IC_MSNF_ASH_PERMIL * 100000
192 let umol_salt: i64 = salt_num / IC_MILK_SALT_EFF_MW_Q2
193 let umol_sugar: i64 = umol_total - umol_lac - umol_salt
194 // The milk solids alone already depress past the target: adding sugar
195 // can only go further, so there is no solution.
196 if umol_sugar <= 0 { return ICB_INFEASIBLE }
197 return icb_div_round(umol_sugar * sugar_mw_q2, 100000000)
198}