Files
roast_command_center/shared/curve.js
T
snowspeeder 824a964f9d
Test and deploy / test-and-deploy (push) Successful in 2m28s
Rebuild printable roast plans and remove machine-based adjustments
2026-09-05 14:56:11 -04:00

116 lines
4.0 KiB
JavaScript

// Canonical temperatures remain Celsius internally; reports render Fahrenheit.
import { parseDuration } from "./time.js?v=__ASSET_VERSION__";
export const PLOT = { x0: 52, x1: 652, y0: 24, y1: 294, tMaxS: 900 };
export const tToX = (s) => PLOT.x0 + (s / PLOT.tMaxS) * (PLOT.x1 - PLOT.x0);
export const tempToY = (c) => PLOT.y1 - (c - 60) * 1.8;
export const rorToY = (r) => PLOT.y1 - r * 9;
const numeric = (v) =>
v != null && String(v).trim() !== "" && Number.isFinite(Number(v))
? Number(v)
: null;
// No roaster selection, learned median, or override changes these illustrative points.
export function buildPlanCurve(plan, ledger) {
const t = plan.temps ?? {};
const fc = numeric(t.fc?.tempC) ?? 200;
const yellow = numeric(t.yellow?.tempC) ?? 160;
const maillardRate =
ledger.maillard > 0 ? Math.max(0, (fc - yellow) / ledger.maillard) : 0;
// Development's mean rise is lower than Maillard's, even for a short development.
const drop = fc + maillardRate * (ledger.C ?? 0) * 0.65;
return [
{
key: "charge",
label: "Charge",
timeS: 0,
tempC: numeric(t.charge?.tempC) ?? 200,
},
{
key: "tp",
label: "Turning point",
timeS: t.tp?.time ? parseDuration(t.tp.time) : 60,
tempC: numeric(t.tp?.tempC) ?? 95,
},
{
key: "yellow",
label: "Yellow / dry end",
timeS: ledger.yellow,
tempC: yellow,
},
{ key: "fc", label: "First crack", timeS: ledger.A, tempC: fc },
{
key: "drop",
label: "Drop",
timeS: ledger.D,
tempC: numeric(t.drop?.tempC) ?? drop,
},
]
.filter((p) => Number.isFinite(p.timeS) && p.timeS >= 0)
.map((p) => ({ ...p, illustrative: numeric(t[p.key]?.tempC) === null }));
}
// Shape-preserving cubic Hermite interpolation. At TP the slope is zero; on the
// rising side harmonic slopes avoid overshoot and keep phase transitions smooth.
export function curveSegments(points) {
const p = [...points]
.filter((p) => Number.isFinite(p.timeS) && Number.isFinite(p.tempC))
.sort((a, b) => a.timeS - b.timeS)
.filter((p, i, a) => !i || p.timeS > a[i - 1].timeS);
if (p.length < 2) return { points: p, segments: [] };
const d = p
.slice(1)
.map((b, i) => (b.tempC - p[i].tempC) / (b.timeS - p[i].timeS));
const m = p.map((_, i) => {
if (!i) return d[0];
if (i === p.length - 1) return d.at(-1) * 0.75;
if (d[i - 1] * d[i] <= 0) return 0;
const h0 = p[i].timeS - p[i - 1].timeS,
h1 = p[i + 1].timeS - p[i].timeS;
return (3 * (h0 + h1)) / ((2 * h1 + h0) / d[i - 1] + (h1 + 2 * h0) / d[i]);
});
return {
points: p,
segments: p.slice(1).map((b, i) => {
const a = p[i],
h = (b.timeS - a.timeS) / 3;
return {
a,
b,
c1: { timeS: a.timeS + h, tempC: a.tempC + m[i] * h },
c2: { timeS: b.timeS - h, tempC: b.tempC - m[i + 1] * h },
};
}),
};
}
export function pointsToPathD(points, x = tToX, y = tempToY) {
const { points: p, segments } = curveSegments(points);
const coord = (p) => `${x(p.timeS).toFixed(2)},${y(p.tempC).toFixed(2)}`;
return p.length
? `M${coord(p[0])} ` +
segments
.map((s) => `C${coord(s.c1)} ${coord(s.c2)} ${coord(s.b)}`)
.join(" ")
: "";
}
export function curveWarnings(points) {
const byKey = Object.fromEntries(points.map((p) => [p.key, p]));
const p = ["tp", "yellow", "fc", "drop"].map((k) => byKey[k]);
if (p.some((v) => !v)) return [];
if (
p.some(
(v, i) => i && (v.timeS <= p[i - 1].timeS || v.tempC <= p[i - 1].tempC),
)
)
return [
"Milestones must progress in time and temperature after turning point. Review your entries.",
];
const rates = p
.slice(1)
.map((b, i) => (b.tempC - p[i].tempC) / (b.timeS - p[i].timeS));
return rates.some((r, i) => i && r > rates[i - 1])
? [
"Entered milestones imply an increasing phase-average rate of rise. Review temperatures and timing; smoothing cannot correct inconsistent targets.",
]
: [];
}