code wiki / (root) / nx_icecream.nx

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}