116 lines
4.0 KiB
JavaScript
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.",
|
|
]
|
|
: [];
|
|
}
|