120 lines
4.1 KiB
JavaScript
120 lines
4.1 KiB
JavaScript
// Shared planning math. Machine profiles are accepted for legacy callers but ignored.
|
||
import {
|
||
parseDuration,
|
||
parseRangeMidpoint,
|
||
} from "./time.js?v=__ASSET_VERSION__";
|
||
import {
|
||
YELLOW_RATIO,
|
||
SANITY_BANDS,
|
||
} from "./reference-data.js?v=__ASSET_VERSION__";
|
||
|
||
/** All durations are seconds; null means the plan cannot yet be computed.
|
||
* The optional legacy profile argument is intentionally ignored. */
|
||
export function computeLedger(plan, machineProfile = null) {
|
||
const f = plan.fields ?? {};
|
||
const isBlend = f["2.1"] === "blend";
|
||
const warnings = [];
|
||
const pace = 1; // Roaster history and overrides never modify a plan.
|
||
|
||
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 && baseA > 0 ? baseA : null;
|
||
if (baseA !== null && A === null)
|
||
warnings.push(
|
||
"First crack must be after charge; check timing adjustments.",
|
||
);
|
||
|
||
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 rawC = l7 === null ? null : l7 + l8 + l9;
|
||
const explicitC = parseDuration(f["4.4"]);
|
||
const suggestedC =
|
||
rawC !== null && f["4.1"] === "light" ? Math.max(75, rawC) : rawC;
|
||
const invalidOverride = f["4.4"] && explicitC === null;
|
||
if (invalidOverride)
|
||
warnings.push(
|
||
"Development override is invalid; use m:ss with seconds below 60.",
|
||
);
|
||
const candidateC = invalidOverride ? null : (explicitC ?? suggestedC);
|
||
const C = candidateC !== null && candidateC > 0 ? candidateC : null;
|
||
if (explicitC === null && rawC !== suggestedC)
|
||
warnings.push(
|
||
"Development modifiers reduced the light-roast suggestion below 1:15. Using a 1:15 starting target; enter a development override to change it.",
|
||
);
|
||
if (candidateC !== null && C === null)
|
||
warnings.push("Development must be positive."); // 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 && explicitC === 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);
|
||
|
||
if (pct(C) !== null && (pct(C) < 12 || pct(C) > 20))
|
||
warnings.push(
|
||
"Development ratio is outside the 12–20% planning range. Review color, aroma and the cup; DTR alone does not determine roast quality.",
|
||
);
|
||
const density = Number(f["5.2"]);
|
||
if (f["5.2"] && Number.isFinite(density) && (density < 300 || density > 1000))
|
||
warnings.push(
|
||
"Check density units: enter g/L, not g/mL. For example, 0.7614 g/mL = 761.4 g/L. No automatic correction was made.",
|
||
);
|
||
|
||
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,
|
||
rawC,
|
||
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,
|
||
};
|
||
}
|