code wiki / (root) / nx_icecream_balance.nx

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}