Rebuild printable roast plans and remove machine-based adjustments
Test and deploy / test-and-deploy (push) Successful in 2m28s

This commit is contained in:
snowspeeder committed 2026-09-05 14:56:11 -04:00
1 parent e9f2393c96
commit 824a964f9d
30 files changed
+1721 -908

No files matched your search

+107 -52
View File
@@ -1,60 +1,115 @@
// Browser-safe. Geometry for the roast-curve SVG grid (viewBox 0 0 714 334), matching
// manual-roast-planner.html's worksheet box 10 grid exactly.
import { MACHINE } from "./reference-data.js?v=__ASSET_VERSION__";
// 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;
export function tToX(seconds) {
const clamped = Math.max(0, Math.min(PLOT.tMaxS, seconds));
return PLOT.x0 + (clamped / PLOT.tMaxS) * (PLOT.x1 - PLOT.x0);
// 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 }));
}
// 60C -> y294 (bottom axis), 200C label sits at y42 (confirmed by the FC band rect y=65.4,h=19.8 = 176-187C)
export function tempToY(degC) {
return PLOT.y1 - (degC - 60) * 1.8;
}
export function rorToY(rorCPerMin) {
return PLOT.y1 - rorCPerMin * 9;
}
/** Build the five plan-curve points from the ledger + box-8 temps. Blank temps fall back to
* the user's own learned roaster behavior (median milestone temps from their uploaded .alogs,
* via /api/roaster-profile) when available, else the reference machine medians. */
export function buildPlanCurve(plan, ledger, roasterProfile = null) {
const temps = plan.temps ?? {};
const num = (v) => {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : 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 },
};
}),
};
const learned = roasterProfile?.n > 0 ? roasterProfile.medians : {};
const fallback = (learnedValue, referenceValue) =>
Number.isFinite(learnedValue) ? learnedValue : referenceValue;
const points = [
{ key: "charge", label: "Charge", timeS: 0, tempC: num(temps.charge?.tempC) ?? fallback(learned.chargeTempC, MACHINE.charge.medianC) },
{ key: "tp", label: "Turning point", timeS: parseTpTime(temps.tp?.time, learned.turningPointS), tempC: num(temps.tp?.tempC) ?? fallback(learned.turningPointTempC, MACHINE.turningPoint.medianC) },
{ key: "yellow", label: "Yellow", timeS: ledger.yellow, tempC: num(temps.yellow?.tempC) ?? fallback(learned.yellowTempC, MACHINE.yellow.medianC) },
{ key: "fc", label: "First crack", timeS: ledger.A, tempC: num(temps.fc?.tempC) ?? fallback(learned.firstCrackTempC, MACHINE.firstCrack.medianC) },
{ key: "drop", label: "Drop", timeS: ledger.D, tempC: num(temps.drop?.tempC) ?? fallback(learned.dropTempC, MACHINE.drop.medianC) },
];
return points.filter((p) => p.timeS !== null && p.timeS !== undefined);
}
function parseTpTime(str, learnedS = null) {
if (!str) return Number.isFinite(learnedS) ? learnedS : 52; // 52 = MACHINE.turningPoint.medianTime "0:52"
const m = String(str).match(/^(\d+):(\d{1,2})$/);
if (!m) return null;
return Number(m[1]) * 60 + Number(m[2]);
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(" ")
: "";
}
/** Build an SVG <path> "d" string through points, sorted by time, plain polyline (no kink invented). */
export function pointsToPathD(points) {
const sorted = [...points].sort((a, b) => a.timeS - b.timeS);
if (sorted.length === 0) return "";
return sorted
.map((p, i) => `${i === 0 ? "M" : "L"}${tToX(p.timeS).toFixed(1)},${tempToY(p.tempC).toFixed(1)}`)
.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.",
]
: [];
}