// Browser-safe. THE ONLY implementation of the worksheet's time-ledger math. // Mirrors manual-roast-planner.html's worksheet box 6 (lines 1-9, totals A/C/D) // and box 7 (the four sanity checks) exactly. Imported by both server and browser. // // Two changes from the original worksheet, made 2026-07-31 after a methodology review (see // shared/learn.js's header): first crack is now scaled by a multiplicative machine-pace factor — // the operator's own 14-roast data showed a ~13% batch-size increase stretching first crack ~40%, // a scaling relationship a flat added-seconds guess can't represent. Pace is applied to the WHOLE // pre-pace sum, INCLUDING line 4 (± batch-size correction), not just lines 1-3: shared/learn.js // fits pace from each historical roast's l1+l2+l3+l4 against what actually happened, so applying // it only to l1+l2+l3 here and adding l4 back afterwards would double-count whatever that // historical l4 was already compensating for. And the drying/Maillard sanity checks are now // marked `informational`: because yellow is DERIVED as YELLOW_RATIO × A rather than entered // independently, drying% and Maillard% are algebraic consequences of the DTR check, not // independent validations — whenever DTR passes, both are mathematically guaranteed to also pass. // Only DTR and the development ceiling test anything a plan could actually fail. import { parseDuration, parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__"; import { YELLOW_RATIO, SANITY_BANDS } from "./reference-data.js?v=__ASSET_VERSION__"; /** * @param {import("./fields.js").Plan} plan * @param {import("./learn.js").computeMachineProfile extends (...a:any)=>infer R ? R : never} [machineProfile] * optional learned per-user profile from shared/learn.js; omitted or a profile with no learned * data behaves exactly as before (pace 1.0 — the reference/original worksheet behavior). * @returns ledger result — every time value in seconds, null when not computable. */ export function computeLedger(plan, machineProfile = null) { const f = plan.fields ?? {}; const isBlend = f["2.1"] === "blend"; const warnings = []; const pace = machineProfile?.pace?.value ?? 1; const l1 = parseRangeMidpoint(isBlend ? f["2.4"] : f["1.4"]); const l2 = parseDuration(f["1.6"]) ?? 0; const l3 = parseDuration(f["5.6"]) ?? 0; const l4 = parseDuration(f["6.4"]) ?? 0; const baseA = l1 === null ? null : l1 + l2 + l3 + l4; const A = baseA === null ? null : Math.round(baseA * pace); const yellow = A === null ? null : Math.round(A * YELLOW_RATIO); const maillard = A === null ? null : A - yellow; const l7 = parseRangeMidpoint(f["4.3"]); const l8 = parseDuration(isBlend ? f["2.5"] : f["3.2"]) ?? 0; const l9 = parseDuration(f["1.7"]) ?? 0; const C = l7 === null ? null : l7 + l8 + l9; // rows 5 and 6 (yellow/Maillard) are NEVER summed here const D = A === null || C === null ? null : A + C; if (l1 === null) warnings.push("No cultivar/blend anchor (field 1.4 or 2.4) — first crack cannot be computed."); if (l7 === null) warnings.push("No development base (field 4.3) — development cannot be computed."); const pct = (part) => (D && D > 0 && part !== null ? (part / D) * 100 : null); const checks = { drying: { ...passCheck(pct(yellow), SANITY_BANDS.drying), informational: true }, maillard: { ...passCheck(pct(maillard), SANITY_BANDS.maillard), informational: true }, dtr: passCheck(pct(C), SANITY_BANDS.dtr), ceiling: { valueS: C, maxS: SANITY_BANDS.ceilingS, pass: C === null ? null : C < SANITY_BANDS.ceilingS, }, }; return { isBlend, lines: { l1, l2, l3, l4, l7, l8, l9 }, pace, subtotalA: baseA, // lines 1-4 summed, before pace — exposed so the UI/print sheet can show // the multiplication step explicitly instead of jumping straight to A with no visible way to // reconstruct it from the printed lines (see the header comment above). A, yellow, maillard, C, D, checks, warnings, }; } function passCheck(pct, band) { return { pct, lo: band.lo, hi: band.hi, pass: pct === null ? null : pct >= band.lo && pct <= band.hi, }; }