Rebuild printable roast plans and remove machine-based adjustments
Test and deploy / test-and-deploy (push) Successful in 2m28s
Test and deploy / test-and-deploy (push) Successful in 2m28s
This commit is contained in:
1 parent
e9f2393c96
commit
824a964f9d
30 files changed
+1721
-908
No files matched your search
+107
-52
@@ -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.",
|
||||
]
|
||||
: [];
|
||||
}
|
||||
Reference in new issue
Block a user