nx_icecream.nx source
↩ module page · 568 lines · 22627 B
1// nx_icecream.nx -- FOOD-SCIENCE SUITE / FROZEN DESSERT rung. Computes what
2// a frozen dessert mix will actually DO -- where it starts to freeze, how
3// much of its water is ice at any serving temperature, whether it will go
4// sandy, and whether it is legally ice cream -- from composition and
5// physical chemistry, instead of trusting a recipe's "churn until thick".
6//
7// THE CORE IS ONE EQUATION, HONESTLY APPLIED. Freezing-point depression is
8// colligative: dT = Kf * molality. Everything expensive follows from it:
9//
10// - As water freezes OUT, the remaining solution CONCENTRATES, so the
11// freezing point keeps dropping. At equilibrium at temperature T, the
12// unfrozen water is exactly the amount whose freezing point IS T.
13// Since depression is inversely proportional to water for fixed solute:
14// frozen_fraction = 1 - FPD_initial / |T|
15// That is derived, not tabulated -- ic_frozen_water_permil.
16// - Inverting it answers the question a recipe cannot: what cabinet
17// temperature gives the scoopable ~72% ice I want, FOR THIS MIX?
18// -- ic_serve_temp_for_frozen_permil.
19//
20// MEASURED against published freezing curves for a typical mix
21// (FPD ~2.5 C): this model gives 51% frozen at -5 C (lit ~50%), 81% at
22// -13 C (lit ~80%), 86% at -18 C (lit ~90%). It tracks well through the
23// scooping range and runs ~4 points LOW in the deep-freeze tail, where
24// ideal-solution theory stops holding (activity coefficients depart from 1
25// in a highly concentrated serum). Stated, not hidden.
26//
27// PAC IS DERIVED, POD CANNOT BE. The gelato trade's anti-freezing power
28// index (PAC, sucrose = 100) is just a molecular-weight ratio, so this
29// COMPUTES it -- ic_pac_from_mw reproduces the trade table exactly for the
30// crystalline sugars (sucrose 100, dextrose 190, fructose 190, lactose 100).
31// Sweetening power (POD) is psychophysical -- a measured human response, not
32// a colligative property -- so it stays a cited table. Confusing the two is
33// the classic formulation error: dextrose has ~1.9x the freezing power of
34// sucrose but only ~0.7x the sweetness, which is exactly why it is the lever
35// for a softer product without a sweeter one.
36//
37// All INTEGER. Suffixes: _q1 = x10, _q2 = x100, _q3 = x1000,
38// _permil = x1000 (fraction), _milli_c = temperature x1000 in Celsius.
39//
40// Grounding (cited; researcher-groundable):
41// colligative_freezing_point_depression_kf_1_86 (the core equation)
42// goff_hartel_ice_cream_freezing_curve (validation points)
43// lactose_solubility_and_sandiness_defect (MSNF ceiling)
44// fda_21cfr135_110_ice_cream_standard_of_identity (legal minima)
45// pac_pod_gelato_balance_indices (trade indices)
46//
47// genealogy_id: frozen_dessert_science + nishi_food_science_suite
48
49import "nx_syscalls.nx"
50
51// ===== Physical constants =============================================
52
53// Cryoscopic constant of water, 1.86 C*kg/mol, x1000.
54const IC_KF_WATER_Q3: i64 = 1860
55
56// Molar masses x100 (g/mol).
57const IC_MW_SUCROSE_Q2: i64 = 34230
58const IC_MW_LACTOSE_Q2: i64 = 34230
59const IC_MW_MALTOSE_Q2: i64 = 34230
60const IC_MW_DEXTROSE_Q2: i64 = 18016
61const IC_MW_FRUCTOSE_Q2: i64 = 18016
62const IC_MW_INVERT_Q2: i64 = 18016 // 50/50 glucose+fructose, both 180.16
63const IC_MW_SORBITOL_Q2: i64 = 18217
64const IC_MW_NACL_Q2: i64 = 5844
65
66// Glucan polymer chemistry, for deriving syrup MW from Dextrose Equivalent:
67// a chain of DP n is (C6H10O5)n * H2O = 162.14n + 18.02, and DP_n = 100/DE.
68const IC_ANHYDROGLUCOSE_Q2: i64 = 16214
69const IC_WATER_MW_Q2: i64 = 1802
70
71// ===== Milk solids composition (MSNF) =================================
72
73const IC_MSNF_LACTOSE_PERMIL: i64 = 545 // 54.5% of MSNF is lactose
74const IC_MSNF_ASH_PERMIL: i64 = 80 // 8.0% of MSNF is mineral ash
75
76// Milk salts are ionic and DISSOCIATE, so what matters colligatively is
77// grams per osmotically-active particle = mean salt MW / van't Hoff factor.
78// 40 g/mol is the calibrated effective value for milk ash; it is the one
79// empirically-fitted number in this file and is called out as such.
80const IC_MILK_SALT_EFF_MW_Q2: i64 = 4000
81
82// Lactose solubility working limit, g per 100 g water x10. Above this,
83// lactose crystallises during hardening and the product turns SANDY.
84const IC_LACTOSE_SOL_Q1: i64 = 90
85
86// ===== FDA 21 CFR 135.110 standard of identity ========================
87
88const IC_FDA_MIN_MILKFAT_PERMIL: i64 = 100 // 10.0% milkfat
89const IC_FDA_MIN_MILK_SOLIDS_PERMIL: i64 = 200 // 20.0% total milk solids
90const IC_FDA_MIN_LB_PER_GAL_Q2: i64 = 450 // 4.5 lb/gal finished weight
91const IC_FDA_MIN_FOOD_SOLIDS_Q2: i64 = 160 // 1.6 lb food solids/gal
92
93const IC_ML_PER_GAL: i64 = 3785 // 1 US gallon = 3785.41 mL
94const IC_G_PER_LB: i64 = 454 // 1 lb = 453.59 g
95
96// ===== Style classes ==================================================
97
98const IC_STYLE_UNCLASSIFIED: i64 = 0
99const IC_STYLE_SORBET: i64 = 1
100const IC_STYLE_GELATO: i64 = 2
101const IC_STYLE_ICE_CREAM: i64 = 3
102const IC_STYLE_LOWFAT: i64 = 4
103
104const IC_GELATO_MAX_OVERRUN_PCT: i64 = 50
105const IC_SORBET_MAX_FAT_PERMIL: i64 = 10 // under 1% fat = not a dairy ice
106const IC_LOWFAT_MIN_FAT_PERMIL: i64 = 20
107
108// Scoopable target: about 72% of the water frozen.
109const IC_SCOOPABLE_FROZEN_PERMIL: i64 = 720
110
111// ===== The mix ========================================================
112//
113// One batch, in grams. sugar_mw_q2 is the number-average molar mass of the
114// WHOLE sweetener blend (use ic_blend_mw_q2 to build it), which is what the
115// colligative maths needs; sugar_pod is the blend's sweetening power.
116
117struct NxIceMix {
118 fat_g: i64,
119 msnf_g: i64,
120 sugar_g: i64,
121 sugar_mw_q2: i64,
122 sugar_pod: i64,
123 other_solids_g: i64,
124 total_g: i64,
125 density_q3: i64,
126}
127
128func nx_ice_mix_new() -> *NxIceMix {
129 let m: *NxIceMix = (sys_mmap(80)) as *NxIceMix
130 m.fat_g = 0
131 m.msnf_g = 0
132 m.sugar_g = 0
133 m.sugar_mw_q2 = IC_MW_SUCROSE_Q2
134 m.sugar_pod = 100
135 m.other_solids_g = 0
136 m.total_g = 1000
137 m.density_q3 = 1100
138 return m
139}
140
141// ===== Composition ====================================================
142
143func ic_total_solids_g(m: *NxIceMix) -> i64 {
144 let a: i64 = m.fat_g + m.msnf_g
145 let b: i64 = m.sugar_g + m.other_solids_g
146 return a + b
147}
148
149// Water is what is left. A mix whose solids exceed its mass is INCOHERENT;
150// return 0 so every downstream division fails closed rather than going
151// negative and producing a confident, wrong freezing point.
152func ic_water_g(m: *NxIceMix) -> i64 {
153 let solids: i64 = ic_total_solids_g(m)
154 let w: i64 = m.total_g - solids
155 if w <= 0 { return 0 }
156 return w
157}
158
159func ic_permil(part_g: i64, total_g: i64) -> i64 {
160 if total_g <= 0 { return 0 }
161 return part_g * 1000 / total_g
162}
163
164func ic_fat_permil(m: *NxIceMix) -> i64 { return ic_permil(m.fat_g, m.total_g) }
165func ic_msnf_permil(m: *NxIceMix) -> i64 { return ic_permil(m.msnf_g, m.total_g) }
166func ic_sugar_permil(m: *NxIceMix) -> i64 { return ic_permil(m.sugar_g, m.total_g) }
167
168func ic_total_solids_permil(m: *NxIceMix) -> i64 {
169 let s: i64 = ic_total_solids_g(m)
170 return ic_permil(s, m.total_g)
171}
172
173// Total MILK solids = milkfat + MSNF (the FDA quantity; sugar does not count).
174func ic_milk_solids_permil(m: *NxIceMix) -> i64 {
175 let ms: i64 = m.fat_g + m.msnf_g
176 return ic_permil(ms, m.total_g)
177}
178
179// ===== Sweetener chemistry ============================================
180
181// PAC (anti-freezing power, sucrose = 100) IS a molar-mass ratio: equal
182// masses depress the freezing point in inverse proportion to molar mass.
183// ROUNDED to nearest, not truncated: dextrose is exactly 190.009, and
184// truncation reported it as 189 -- a whole PAC unit lost to integer floor
185// on a value the trade table gives as 190.
186func ic_pac_from_mw(mw_q2: i64) -> i64 {
187 if mw_q2 <= 0 { return 0 }
188 let num: i64 = IC_MW_SUCROSE_Q2 * 100
189 let half: i64 = mw_q2 / 2
190 return (num + half) / mw_q2
191}
192
193// Number-average molar mass of a glucose syrup from its Dextrose
194// Equivalent: DP = 100/DE, MW = 162.14*DP + 18.02. Dry-solids basis.
195func ic_syrup_mw_q2(de: i64) -> i64 {
196 if de <= 0 { return 0 }
197 let chain: i64 = IC_ANHYDROGLUCOSE_Q2 * 100 / de
198 return chain + IC_WATER_MW_Q2
199}
200
201// Number-average molar mass of a two-sweetener blend: total mass over total
202// moles. This is the quantity the colligative equation consumes, so a
203// blend is handled exactly, not approximated by picking one sugar.
204func ic_blend_mw_q2(g1: i64, mw1_q2: i64, g2: i64, mw2_q2: i64) -> i64 {
205 if mw1_q2 <= 0 { return 0 }
206 if mw2_q2 <= 0 { return 0 }
207 let u1: i64 = g1 * 100000000 / mw1_q2
208 let u2: i64 = g2 * 100000000 / mw2_q2
209 let utot: i64 = u1 + u2
210 if utot <= 0 { return 0 }
211 let gtot: i64 = g1 + g2
212 return gtot * 100000000 / utot
213}
214
215// Mass-weighted sweetening power of a blend (POD, sucrose = 100).
216func ic_blend_pod(g1: i64, pod1: i64, g2: i64, pod2: i64) -> i64 {
217 let gtot: i64 = g1 + g2
218 if gtot <= 0 { return 0 }
219 let w: i64 = g1 * pod1 + g2 * pod2
220 return w / gtot
221}
222
223// Trade balance indices for the finished mix, expressed per 1000 g of mix
224// on the sucrose = 100 scale (so 150 g of straight sucrose per kg reads
225// PAC 150 / POD 150). A gelato aiming to scoop at cabinet temperature
226// wants PAC around 250-290; getting there with sucrose alone would take a
227// cloying 270 g/kg, which is why the blend levers below exist.
228func ic_pac_index(m: *NxIceMix) -> i64 {
229 if m.total_g <= 0 { return 0 }
230 let pac: i64 = ic_pac_from_mw(m.sugar_mw_q2)
231 let contrib: i64 = m.sugar_g * pac * 10
232 return contrib / m.total_g
233}
234
235func ic_pod_index(m: *NxIceMix) -> i64 {
236 if m.total_g <= 0 { return 0 }
237 let contrib: i64 = m.sugar_g * m.sugar_pod * 10
238 return contrib / m.total_g
239}
240
241// ===== Freezing point depression ======================================
242//
243// Micromoles of osmotically active solute, from three sources:
244// 1. added sweetener (mass / blend MW)
245// 2. lactose from MSNF (54.5% of MSNF, MW 342.30)
246// 3. milk salts from MSNF ash (ionic; effective MW carries van't Hoff i)
247// Fat, protein and stabiliser are NOT counted: they are colloidal, not
248// dissolved, and contribute essentially nothing colligatively. Counting
249// them is the most common way a spreadsheet gets this wrong.
250
251func ic_sugar_umol(m: *NxIceMix) -> i64 {
252 if m.sugar_mw_q2 <= 0 { return 0 }
253 return m.sugar_g * 100000000 / m.sugar_mw_q2
254}
255
256func ic_lactose_g(m: *NxIceMix) -> i64 {
257 return m.msnf_g * IC_MSNF_LACTOSE_PERMIL / 1000
258}
259
260func ic_lactose_umol(m: *NxIceMix) -> i64 {
261 let num: i64 = m.msnf_g * IC_MSNF_LACTOSE_PERMIL * 100000
262 return num / IC_MW_LACTOSE_Q2
263}
264
265func ic_milk_salt_umol(m: *NxIceMix) -> i64 {
266 let num: i64 = m.msnf_g * IC_MSNF_ASH_PERMIL * 100000
267 return num / IC_MILK_SALT_EFF_MW_Q2
268}
269
270func ic_total_solute_umol(m: *NxIceMix) -> i64 {
271 let a: i64 = ic_sugar_umol(m)
272 let b: i64 = ic_lactose_umol(m)
273 let c: i64 = ic_milk_salt_umol(m)
274 return a + b + c
275}
276
277// Initial freezing point depression, in milli-degrees Celsius.
278// dT = Kf * mol_solute / kg_water.
279func ic_fpd_milli_c(m: *NxIceMix) -> i64 {
280 let water: i64 = ic_water_g(m)
281 if water <= 0 { return 0 }
282 let umol: i64 = ic_total_solute_umol(m)
283 let num: i64 = IC_KF_WATER_Q3 * umol
284 let den: i64 = 1000 * water
285 return num / den
286}
287
288// The temperature at which the mix STARTS to freeze (negative, milli-C).
289func ic_freezing_point_milli_c(m: *NxIceMix) -> i64 {
290 let d: i64 = ic_fpd_milli_c(m)
291 return 0 - d
292}
293
294// ===== The freezing curve =============================================
295//
296// Fraction of the water that is ICE at temperature temp_milli_c (negative),
297// in per-mil. Above the freezing point NOTHING is frozen -- returned as 0
298// rather than a negative fraction, so a warm serving temperature can never
299// produce a nonsense "negative ice".
300
301func ic_frozen_water_permil(m: *NxIceMix, temp_milli_c: i64) -> i64 {
302 let fpd: i64 = ic_fpd_milli_c(m)
303 if fpd <= 0 { return 0 }
304 var t: i64 = temp_milli_c
305 if t > 0 { t = 0 - t }
306 let mag: i64 = 0 - t
307 if mag <= fpd { return 0 }
308 let unfrozen: i64 = fpd * 1000 / mag
309 return 1000 - unfrozen
310}
311
312// INVERSE of the curve: the serving temperature (milli-C, negative) that
313// puts target_permil of the water into ice FOR THIS MIX. This is the
314// question a recipe cannot answer, because the answer depends on the
315// sugar blend, not on the dessert's name.
316func ic_serve_temp_for_frozen_permil(m: *NxIceMix, target_permil: i64) -> i64 {
317 let fpd: i64 = ic_fpd_milli_c(m)
318 if fpd <= 0 { return 0 }
319 if target_permil <= 0 { return 0 }
320 if target_permil >= 1000 { return 0 }
321 let unfrozen: i64 = 1000 - target_permil
322 let mag: i64 = fpd * 1000 / unfrozen
323 return 0 - mag
324}
325
326// Serving temperature for a scoopable product.
327func ic_scoop_temp_milli_c(m: *NxIceMix) -> i64 {
328 return ic_serve_temp_for_frozen_permil(m, IC_SCOOPABLE_FROZEN_PERMIL)
329}
330
331// Hardness proxy: more ice = harder. Directly the frozen fraction, so it
332// is comparable between mixes at the same temperature.
333func ic_hardness_index(m: *NxIceMix, temp_milli_c: i64) -> i64 {
334 return ic_frozen_water_permil(m, temp_milli_c)
335}
336
337// ===== Lactose / sandiness ============================================
338
339// Lactose concentration in the SERUM (water) phase, g per 100 g water x10.
340func ic_lactose_per_100_water_q1(m: *NxIceMix) -> i64 {
341 let water: i64 = ic_water_g(m)
342 if water <= 0 { return 0 }
343 let lac: i64 = ic_lactose_g(m)
344 return lac * 1000 / water
345}
346
347func ic_sandy_risk(m: *NxIceMix) -> i64 {
348 let c: i64 = ic_lactose_per_100_water_q1(m)
349 if c > IC_LACTOSE_SOL_Q1 { return 1 }
350 return 0
351}
352
353// The MSNF ceiling, DERIVED from lactose solubility rather than quoted:
354// max_lactose = water * sol/1000, and MSNF is IC_MSNF_LACTOSE_PERMIL/1000
355// lactose, so max_MSNF = water * sol / lactose_permil. With the constants
356// above this reproduces the trade rule "MSNF must not exceed one sixth of
357// the water" -- but it moves correctly if the solubility figure does.
358func ic_max_msnf_g(water_g: i64) -> i64 {
359 if water_g <= 0 { return 0 }
360 return water_g * IC_LACTOSE_SOL_Q1 / IC_MSNF_LACTOSE_PERMIL
361}
362
363// ===== Overrun ========================================================
364//
365// Overrun is the volume of air whipped in, as a percentage of the mix.
366// 100% overrun = the mix doubled in volume = half the density.
367
368func ic_overrun_pct(vol_mix: i64, vol_frozen: i64) -> i64 {
369 if vol_mix <= 0 { return 0 }
370 let gain: i64 = vol_frozen - vol_mix
371 return gain * 100 / vol_mix
372}
373
374// Same measurement done on a scale: fill one container with mix, then with
375// finished product, and compare weights.
376func ic_overrun_pct_by_weight(wt_mix: i64, wt_frozen: i64) -> i64 {
377 if wt_frozen <= 0 { return 0 }
378 let gain: i64 = wt_mix - wt_frozen
379 return gain * 100 / wt_frozen
380}
381
382// Finished density after whipping in air, g/mL x1000.
383func ic_product_density_q3(density_q3: i64, overrun_pct: i64) -> i64 {
384 let den: i64 = 100 + overrun_pct
385 if den <= 0 { return 0 }
386 return density_q3 * 100 / den
387}
388
389// Finished weight in lb per US gallon x100 -- the FDA's actual yardstick.
390func ic_weight_lb_per_gal_q2(density_q3: i64, overrun_pct: i64) -> i64 {
391 let rho: i64 = ic_product_density_q3(density_q3, overrun_pct)
392 let g_per_gal: i64 = rho * IC_ML_PER_GAL / 1000
393 return g_per_gal * 100 / IC_G_PER_LB
394}
395
396// The overrun at which the product would fall below 4.5 lb/gal -- i.e. the
397// LEGAL ceiling on air, derived from the mix's own density.
398//
399// !!DEPRECATED, AND THE DIRECTION OF THE ERROR IS THE UNSAFE ONE. This
400// honours only ONE of the two weight-side floors in 21 CFR 135.110. Use
401// ic_max_legal_overrun_pct2 below. Retained UNCHANGED because callers may
402// exist and rule 19 forbids moving a contract underneath them; the migration
403// is filed as debt, not assumed done.
404func ic_max_legal_overrun_pct(density_q3: i64) -> i64 {
405 let num: i64 = density_q3 * IC_ML_PER_GAL * 10000
406 let den: i64 = IC_FDA_MIN_LB_PER_GAL_Q2 * 1000 * IC_G_PER_LB
407 if den <= 0 { return 0 }
408 let ratio: i64 = num / den
409 return ratio - 100
410}
411
412// ===== THE SECOND LEGAL FLOOR, WIRED AT LAST ==========================
413//
414// 21 CFR 135.110 imposes FOUR floors and this file enforced three. It has
415// declared IC_FDA_MIN_FOOD_SOLIDS_Q2 = 160 since the day it was written and
416// never once read it -- a dead constant, so the fourth floor was documented
417// and unenforced. Writing a limit down is not the same as applying it.
418//
419// **THE TWO WEIGHT-SIDE FLOORS BIND IN DIFFERENT REGIMES:
420//
421// rho_min_weight = 4.5 lb/gal (constant)
422// rho_min_solids = 1.6 lb/gal / TS_fraction (rises as solids fall)
423//
424// They cross at 356 permil total solids. ABOVE that the weight floor binds
425// and the one-floor law above is right by luck. BELOW it the SOLIDS floor
426// binds and the one-floor law OVER-PERMITS: at 32% total solids it licenses
427// 100% overrun where the standard allows 80%. It permits MORE air than the
428// law does, so its failure mode is a mislabelled product, not a cautious one.
429//
430// Measured and bite-proven in nx_icecream_bench_gate T9 and T11.
431
432// Total FOOD solids as a fraction of the batch, permil -- everything that is
433// not water. Distinct from milk solids, which ic_milk_solids_permil reports.
434func ic_food_solids_permil(m: *NxIceMix) -> i64 {
435 if m.total_g <= 0 { return 0 }
436 let solids: i64 = m.fat_g + m.msnf_g + m.sugar_g + m.other_solids_g
437 return solids * 1000 / m.total_g
438}
439
440// Minimum finished density set by the 1.6 lb-solids/gal floor, g/mL x1000.
441func ic_min_density_solids_q3(ts_permil: i64) -> i64 {
442 if ts_permil <= 0 { return 0 }
443 let num: i64 = IC_FDA_MIN_FOOD_SOLIDS_Q2 * IC_G_PER_LB * 1000 * 1000
444 let den: i64 = 100 * IC_ML_PER_GAL * ts_permil
445 if den <= 0 { return 0 }
446 return num / den
447}
448
449// Minimum finished density set by the 4.5 lb/gal floor, g/mL x1000.
450func ic_min_density_weight_q3() -> i64 {
451 let num: i64 = IC_FDA_MIN_LB_PER_GAL_Q2 * IC_G_PER_LB * 1000
452 let den: i64 = 100 * IC_ML_PER_GAL
453 return num / den
454}
455
456// Whichever floor binds first at this solids level.
457func ic_legal_floor_density_q3(ts_permil: i64) -> i64 {
458 let w: i64 = ic_min_density_weight_q3()
459 let s: i64 = ic_min_density_solids_q3(ts_permil)
460 if s <= 0 { return w }
461 if s > w { return s }
462 return w
463}
464
465// **The corrected air ceiling: honours BOTH floors, in PERCENT.
466//
467// !!ROUNDING ORDER IS A SAFETY PROPERTY HERE. The first version of this
468// function divided by ic_legal_floor_density_q3, but that floor is 539.76
469// g/L and integer division had already truncated it to 539. Dividing by a
470// floor that was rounded DOWN permits MORE air -- about a full percentage
471// point of it -- so truncating a legal minimum loosens the constraint the
472// minimum exists to impose.
473//
474// **A ROUNDED-DOWN DIVISOR IS A RELAXED CONSTRAINT WHEN THE DIVISOR IS A
475// LEGAL FLOOR. The floor is therefore never materialised: each ceiling is a
476// single expression, and the binding one is the lower. The floor functions
477// above are kept for REPORTING, where a tenth of a g/L decides nothing.
478func ic_ceiling_weight_pct(density_q3: i64) -> i64 {
479 if density_q3 <= 0 { return 0 }
480 let den: i64 = IC_FDA_MIN_LB_PER_GAL_Q2 * IC_G_PER_LB
481 if den <= 0 { return 0 }
482 return density_q3 * 10 * IC_ML_PER_GAL / den - 100
483}
484
485func ic_ceiling_solids_pct(density_q3: i64, ts_permil: i64) -> i64 {
486 if density_q3 <= 0 { return 0 }
487 if ts_permil <= 0 { return 0 }
488 let den: i64 = IC_FDA_MIN_FOOD_SOLIDS_Q2 * IC_G_PER_LB * 1000
489 if den <= 0 { return 0 }
490 return density_q3 * 10 * IC_ML_PER_GAL * ts_permil / den - 100
491}
492
493func ic_max_legal_overrun_pct2(density_q3: i64, ts_permil: i64) -> i64 {
494 let w: i64 = ic_ceiling_weight_pct(density_q3)
495 let s: i64 = ic_ceiling_solids_pct(density_q3, ts_permil)
496 if s < w { return s }
497 return w
498}
499
500// The fourth compliance clause, as a check rather than an unread constant.
501func ic_fda_food_solids_ok(m: *NxIceMix, overrun_pct: i64) -> i64 {
502 let rho: i64 = ic_product_density_q3(m.density_q3, overrun_pct)
503 let ts: i64 = ic_food_solids_permil(m)
504 if rho <= 0 { return 0 }
505 if ts <= 0 { return 0 }
506 let g_per_gal: i64 = rho * IC_ML_PER_GAL / 1000
507 let solids_g: i64 = g_per_gal * ts / 1000
508 let lb_q2: i64 = solids_g * 100 / IC_G_PER_LB
509 if lb_q2 >= IC_FDA_MIN_FOOD_SOLIDS_Q2 { return 1 }
510 return 0
511}
512
513// All FOUR clauses. ic_fda_is_ice_cream checks three and is retained
514// unchanged for its existing callers; this is the complete test.
515func ic_fda_is_ice_cream2(m: *NxIceMix, overrun_pct: i64) -> i64 {
516 if ic_fda_milkfat_ok(m) != 1 { return 0 }
517 if ic_fda_milk_solids_ok(m) != 1 { return 0 }
518 if ic_fda_weight_ok(m, overrun_pct) != 1 { return 0 }
519 if ic_fda_food_solids_ok(m, overrun_pct) != 1 { return 0 }
520 return 1
521}
522
523// ===== FDA compliance =================================================
524
525func ic_fda_milkfat_ok(m: *NxIceMix) -> i64 {
526 let f: i64 = ic_fat_permil(m)
527 if f >= IC_FDA_MIN_MILKFAT_PERMIL { return 1 }
528 return 0
529}
530
531func ic_fda_milk_solids_ok(m: *NxIceMix) -> i64 {
532 let s: i64 = ic_milk_solids_permil(m)
533 if s >= IC_FDA_MIN_MILK_SOLIDS_PERMIL { return 1 }
534 return 0
535}
536
537func ic_fda_weight_ok(m: *NxIceMix, overrun_pct: i64) -> i64 {
538 let w: i64 = ic_weight_lb_per_gal_q2(m.density_q3, overrun_pct)
539 if w >= IC_FDA_MIN_LB_PER_GAL_Q2 { return 1 }
540 return 0
541}
542
543// May this be LABELLED "ice cream" in the United States? All three
544// conditions must hold; any one failing means a different label.
545func ic_fda_is_ice_cream(m: *NxIceMix, overrun_pct: i64) -> i64 {
546 if ic_fda_milkfat_ok(m) != 1 { return 0 }
547 if ic_fda_milk_solids_ok(m) != 1 { return 0 }
548 if ic_fda_weight_ok(m, overrun_pct) != 1 { return 0 }
549 return 1
550}
551
552// ===== Style classification ===========================================
553
554func ic_classify(m: *NxIceMix, overrun_pct: i64) -> i64 {
555 let fat: i64 = ic_fat_permil(m)
556 if fat < IC_SORBET_MAX_FAT_PERMIL {
557 if m.msnf_g <= 0 { return IC_STYLE_SORBET }
558 }
559 if fat >= IC_FDA_MIN_MILKFAT_PERMIL {
560 if overrun_pct > IC_GELATO_MAX_OVERRUN_PCT { return IC_STYLE_ICE_CREAM }
561 return IC_STYLE_GELATO
562 }
563 if fat >= IC_LOWFAT_MIN_FAT_PERMIL {
564 if overrun_pct <= IC_GELATO_MAX_OVERRUN_PCT { return IC_STYLE_GELATO }
565 return IC_STYLE_LOWFAT
566 }
567 return IC_STYLE_UNCLASSIFIED
568}