Add explicit per-plan gauge temperature offset
Test and deploy / test-and-deploy (push) Successful in 1m23s
Test and deploy / test-and-deploy (push) Successful in 1m23s
This commit is contained in:
1 parent
d96ed22de1
commit
eda3d8e77f
8 files changed
+90
-7
No files matched your search
@@ -4463,3 +4463,7 @@ select.f {
|
|||||||
.calendar-toolbar button { padding-left:8px; padding-right:8px; }
|
.calendar-toolbar button { padding-left:8px; padding-right:8px; }
|
||||||
.calendar-toolbar select { grid-column:1 / -1; width:100%; max-width:none; }
|
.calendar-toolbar select { grid-column:1 / -1; width:100%; max-width:none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-temperature-offset { display:grid; grid-template-columns:minmax(180px,240px) 1fr; gap:16px; align-items:center; margin:16px 0; padding:16px; border:1px solid var(--line); border-radius:10px; background:#faf8f5; }
|
||||||
|
.plan-temperature-offset .field-note { margin:0; line-height:1.5; }
|
||||||
|
@media(max-width:700px) { .plan-temperature-offset { grid-template-columns:1fr; gap:10px; } }
|
||||||
@@ -115,6 +115,20 @@ export function initPlannerWorkspace({
|
|||||||
"field-note",
|
"field-note",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const calibration = n("div", "", "plan-temperature-offset");
|
||||||
|
const offsetLabel = n("label", "", "field");
|
||||||
|
offsetLabel.append(n("span", "Gauge temperature offset (°F)", "field-label"));
|
||||||
|
const offsetInput = n("input", "", "field-input");
|
||||||
|
offsetInput.name = "temperatureOffsetF";
|
||||||
|
offsetInput.type = "number";
|
||||||
|
offsetInput.step = "any";
|
||||||
|
offsetInput.placeholder = "0";
|
||||||
|
offsetInput.setAttribute("aria-describedby", "temperature-offset-help");
|
||||||
|
offsetLabel.append(offsetInput);
|
||||||
|
const offsetHelp = n("p", "Gauge reads 50°F lower? Enter −50. Shifts blank event-temperature references; drop follows the adjusted milestones. Entered temperatures, preheat and recorded roasts stay as entered.", "field-note");
|
||||||
|
offsetHelp.id = "temperature-offset-help";
|
||||||
|
calibration.append(offsetLabel, offsetHelp);
|
||||||
|
curve.append(calibration);
|
||||||
const overview = n("section", "", "plan-overview");
|
const overview = n("section", "", "plan-overview");
|
||||||
overview.append(n("span", "YOUR NEXT BATCH", "eyebrow"));
|
overview.append(n("span", "YOUR NEXT BATCH", "eyebrow"));
|
||||||
const stats = n("div", "", "overview-stats");
|
const stats = n("div", "", "overview-stats");
|
||||||
|
|||||||
+6
-2
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import { getModelRuntime, noModelError, pickModel } from "./llm.js";
|
import { getModelRuntime, noModelError, pickModel } from "./llm.js";
|
||||||
import { computeLedger } from "../shared/ledger.js";
|
import { computeLedger } from "../shared/ledger.js";
|
||||||
import { buildPlanCurve, curveWarnings } from "../shared/curve.js";
|
import { buildPlanCurve, curveWarnings, planTemperatureOffsetF } from "../shared/curve.js";
|
||||||
import { formatDuration } from "../shared/time.js";
|
import { formatDuration } from "../shared/time.js";
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are an experienced specialty-coffee roasting coach embedded in a roast-planning app,
|
const SYSTEM_PROMPT = `You are an experienced specialty-coffee roasting coach embedded in a roast-planning app,
|
||||||
@@ -23,7 +23,10 @@ because the raw field is blank. When asked about an event temperature, state its
|
|||||||
value and provenance first, then explain limitations. "illustrative" means a planning reference,
|
value and provenance first, then explain limitations. "illustrative" means a planning reference,
|
||||||
not a measured or calibrated prediction; "entered" means a user-entered target, not an actual reading.
|
not a measured or calibrated prediction; "entered" means a user-entered target, not an actual reading.
|
||||||
Use recordedMilestones and tastingEvidence separately for actual roast measurements. Prefer the
|
Use recordedMilestones and tastingEvidence separately for actual roast measurements. Prefer the
|
||||||
current computed context over stale claims in earlier conversation. Use the supplied timingBreakdown:
|
current computed context over stale claims in earlier conversation. temperatureOffsetF is the user's
|
||||||
|
explicit per-plan gauge offset. displayedMilestones already includes it: never apply it twice.
|
||||||
|
It adjusts illustrative references, with drop derived from adjusted milestones; entered targets,
|
||||||
|
preheat setpoint and actual readings are not offset. Explain it when discussing these temperatures. Use the supplied timingBreakdown:
|
||||||
process field 3.2 changes DEVELOPMENT duration, not the first-crack anchor.
|
process field 3.2 changes DEVELOPMENT duration, not the first-crack anchor.
|
||||||
Never infer worksheet field meanings from their numeric IDs. Answer in Fahrenheit and grams. Never adjust plans based on roaster identity,
|
Never infer worksheet field meanings from their numeric IDs. Answer in Fahrenheit and grams. Never adjust plans based on roaster identity,
|
||||||
learned machine pace, or historical machine temperature medians. Distinguish preheat setpoint
|
learned machine pace, or historical machine temperature medians. Distinguish preheat setpoint
|
||||||
@@ -86,6 +89,7 @@ export function buildPlanChatContext(plan = {}, evidence = null) {
|
|||||||
blendComponents: plan.blendComponents ?? [],
|
blendComponents: plan.blendComponents ?? [],
|
||||||
afterRoast: plan.afterRoast ?? {},
|
afterRoast: plan.afterRoast ?? {},
|
||||||
},
|
},
|
||||||
|
temperatureOffsetF: planTemperatureOffsetF(plan),
|
||||||
displayedMilestones: points.map((point) => ({
|
displayedMilestones: points.map((point) => ({
|
||||||
event: point.key,
|
event: point.key,
|
||||||
label: point.label,
|
label: point.label,
|
||||||
|
|||||||
+8
-5
@@ -9,11 +9,14 @@ const numeric = (v) =>
|
|||||||
? Number(v)
|
? Number(v)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// No roaster selection, learned median, or override changes these illustrative points.
|
// An explicit per-plan gauge offset is a temperature difference: no +32 conversion.
|
||||||
|
// Legacy roaster selection, learned medians, and machine overrides remain ignored.
|
||||||
|
export const planTemperatureOffsetF = (plan) => numeric(plan?.temperatureOffsetF) ?? 0;
|
||||||
export function buildPlanCurve(plan, ledger) {
|
export function buildPlanCurve(plan, ledger) {
|
||||||
const t = plan.temps ?? {};
|
const t = plan.temps ?? {};
|
||||||
const fc = numeric(t.fc?.tempC) ?? 200;
|
const offsetC = planTemperatureOffsetF(plan) / 1.8;
|
||||||
const yellow = numeric(t.yellow?.tempC) ?? 160;
|
const fc = numeric(t.fc?.tempC) ?? (200 + offsetC);
|
||||||
|
const yellow = numeric(t.yellow?.tempC) ?? (160 + offsetC);
|
||||||
const maillardRate =
|
const maillardRate =
|
||||||
ledger.maillard > 0 ? Math.max(0, (fc - yellow) / ledger.maillard) : 0;
|
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.
|
// Development's mean rise is lower than Maillard's, even for a short development.
|
||||||
@@ -23,13 +26,13 @@ export function buildPlanCurve(plan, ledger) {
|
|||||||
key: "charge",
|
key: "charge",
|
||||||
label: "Charge",
|
label: "Charge",
|
||||||
timeS: 0,
|
timeS: 0,
|
||||||
tempC: numeric(t.charge?.tempC) ?? 200,
|
tempC: numeric(t.charge?.tempC) ?? (200 + offsetC),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tp",
|
key: "tp",
|
||||||
label: "Turning point",
|
label: "Turning point",
|
||||||
timeS: t.tp?.time ? parseDuration(t.tp.time) : 60,
|
timeS: t.tp?.time ? parseDuration(t.tp.time) : 60,
|
||||||
tempC: numeric(t.tp?.tempC) ?? 95,
|
tempC: numeric(t.tp?.tempC) ?? (95 + offsetC),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "yellow",
|
key: "yellow",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function blankPlan() {
|
|||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
sourceUrl: "",
|
sourceUrl: "",
|
||||||
|
temperatureOffsetF: "",
|
||||||
fields,
|
fields,
|
||||||
blendComponents: [
|
blendComponents: [
|
||||||
{ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" },
|
{ cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computeLedger } from "./ledger.js?v=__ASSET_VERSION__";
|
import { computeLedger } from "./ledger.js?v=__ASSET_VERSION__";
|
||||||
import {
|
import {
|
||||||
buildPlanCurve,
|
buildPlanCurve,
|
||||||
|
planTemperatureOffsetF,
|
||||||
pointsToPathD,
|
pointsToPathD,
|
||||||
curveWarnings,
|
curveWarnings,
|
||||||
} from "./curve.js?v=__ASSET_VERSION__";
|
} from "./curve.js?v=__ASSET_VERSION__";
|
||||||
@@ -121,6 +122,7 @@ export function renderReportBody(plan, name = "") {
|
|||||||
<h2>Coffee & roast intent</h2><p><b>Origin:</b> ${e(fields["1.3"] || "—")} · <b>Cultivar:</b> ${e(fields["1.1"] || "—")} · <b>Density:</b> ${e(fields["5.2"] || "—")} g/L</p><p><b>Weight-loss target:</b> ${e(fields["4.2"] || "—")}% · <b>Development:</b> ${time(l.C)} · <b>DTR:</b> ${l.checks.dtr.pct?.toFixed(1) ?? "—"}%</p>
|
<h2>Coffee & roast intent</h2><p><b>Origin:</b> ${e(fields["1.3"] || "—")} · <b>Cultivar:</b> ${e(fields["1.1"] || "—")} · <b>Density:</b> ${e(fields["5.2"] || "—")} g/L</p><p><b>Weight-loss target:</b> ${e(fields["4.2"] || "—")}% · <b>Development:</b> ${time(l.C)} · <b>DTR:</b> ${l.checks.dtr.pct?.toFixed(1) ?? "—"}%</p>
|
||||||
<div class="callout">Use this as a starting hypothesis. Light, fruity coffee depends on the green coffee, roast progression and cup results. Machine selection never changes this plan. * Blank temperatures use an illustrative reference curve, not measured or calibrated targets.</div>
|
<div class="callout">Use this as a starting hypothesis. Light, fruity coffee depends on the green coffee, roast progression and cup results. Machine selection never changes this plan. * Blank temperatures use an illustrative reference curve, not measured or calibrated targets.</div>
|
||||||
${warnings.length ? `<div class="callout">${warnings.map((w) => `<p>${e(w)}</p>`).join("")}</div>` : ""}
|
${warnings.length ? `<div class="callout">${warnings.map((w) => `<p>${e(w)}</p>`).join("")}</div>` : ""}
|
||||||
|
${planTemperatureOffsetF(plan) ? `<p><b>Gauge temperature offset:</b> ${planTemperatureOffsetF(plan) > 0 ? "+" : ""}${e(planTemperatureOffsetF(plan))}°F. Applied to illustrative temperature references; drop follows adjusted milestones. Entered targets, preheat and actual readings are unchanged.</p>` : ""}
|
||||||
<h2>Milestone plan</h2><table><thead><tr><th>Milestone</th><th>Time</th><th>BT (°F)</th><th>What to look for</th></tr></thead><tbody>${rows}</tbody></table>
|
<h2>Milestone plan</h2><table><thead><tr><th>Milestone</th><th>Time</th><th>BT (°F)</th><th>What to look for</th></tr></thead><tbody>${rows}</tbody></table>
|
||||||
<h2>Roast curve</h2><div class="chart">${renderReportCurve(p, l)}</div><p>The smooth line interpolates milestones; it is not a predicted sensor trace. Aim for controlled progression after recovery. Phase averages cannot rule out a brief crash or flick.</p><div class="footer">Roast plan · Grams & Fahrenheit</div></section>
|
<h2>Roast curve</h2><div class="chart">${renderReportCurve(p, l)}</div><p>The smooth line interpolates milestones; it is not a predicted sensor trace. Aim for controlled progression after recovery. Phase averages cannot rule out a brief crash or flick.</p><div class="footer">Roast plan · Grams & Fahrenheit</div></section>
|
||||||
<section class="sheet"><div class="record-intro"><h1>Roast schedule & record</h1><div class="subtitle">${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster</div>
|
<section class="sheet"><div class="record-intro"><h1>Roast schedule & record</h1><div class="subtitle">${e(name || fields["0.1"] || "Roast plan")} · Use at the roaster</div>
|
||||||
|
|||||||
@@ -34,3 +34,25 @@ test("streaming JSON decoder exposes only reply text and handles split escapes",
|
|||||||
assert.equal(partialReply('{"reply":"392\\u00'),"392");
|
assert.equal(partialReply('{"reply":"392\\u00'),"392");
|
||||||
assert.equal(partialReply('{"reply":"392\\u00b0F"}'),"392°F");
|
assert.equal(partialReply('{"reply":"392\\u00b0F"}'),"392°F");
|
||||||
});
|
});
|
||||||
|
test("a manual gauge offset shifts illustrative events exactly once and reaches Bean Bro",()=>{
|
||||||
|
const p=blankPlan();p.fields["1.4"]="8:45";p.fields["4.4"]="1:20";
|
||||||
|
const original=buildPlanCurve(p,computeLedger(p));
|
||||||
|
p.temperatureOffsetF="-50";
|
||||||
|
const shifted=buildPlanCurve(p,computeLedger(p));
|
||||||
|
for(let i=0;i<original.length;i++){
|
||||||
|
assert.ok(Math.abs((shifted[i].tempC-original[i].tempC)*1.8+50)<1e-8);
|
||||||
|
assert.equal(shifted[i].timeS,original[i].timeS);
|
||||||
|
}
|
||||||
|
const ctx=buildPlanChatContext(p);
|
||||||
|
assert.equal(ctx.temperatureOffsetF,-50);
|
||||||
|
assert.equal(ctx.displayedMilestones.find(p=>p.event==="fc").tempF,342);
|
||||||
|
p.temps.fc.tempC="190";p.preheat.tempC="175";p.planActual.fc.actualBt="171.111111";
|
||||||
|
const entered=buildPlanChatContext(p);
|
||||||
|
assert.equal(entered.displayedMilestones.find(p=>p.event==="fc").tempF,374);
|
||||||
|
assert.equal(entered.preheatSetpointF,347);
|
||||||
|
assert.equal(entered.recordedMilestones.find(p=>p.event==="fc").actualBtF,340);
|
||||||
|
p.temperatureOffsetF="";delete p.temps.fc.tempC;
|
||||||
|
assert.deepEqual(buildPlanCurve(p,computeLedger(p)),original);
|
||||||
|
p.temperatureOffsetF="25";
|
||||||
|
assert.equal(buildPlanChatContext(p).displayedMilestones.find(p=>p.event==="fc").tempF,417);
|
||||||
|
});
|
||||||
@@ -815,3 +815,36 @@ test("browser: an interrupted Bean Bro stream keeps partial text, restores the q
|
|||||||
assert.equal(await page.locator("#chat-thread .user").count(),1);
|
assert.equal(await page.locator("#chat-thread .user").count(),1);
|
||||||
assert.deepEqual(errors,[]);
|
assert.deepEqual(errors,[]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("browser: a per-plan gauge offset updates the graph, persists, clones and prints",browserOptions,async(t)=>{
|
||||||
|
const {page,browser,base,create,csrfToken,errors}=await session(t);
|
||||||
|
const saved=await create(warningPlan());
|
||||||
|
await page.goto(base+"/app?plan="+saved.id);
|
||||||
|
await page.getByRole("button",{name:"Preview report",exact:true}).waitFor();
|
||||||
|
const input=page.getByRole("spinbutton",{name:"Gauge temperature offset (°F)"});
|
||||||
|
await input.fill("-50");
|
||||||
|
await page.waitForFunction(()=>document.querySelector("#curve-svg-live").textContent.includes("342°F"));
|
||||||
|
await input.scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({path:"/tmp/gauge-offset-desktop.png",animations:"disabled"});
|
||||||
|
await page.getByRole("link",{name:"← Your plans",exact:true}).click();
|
||||||
|
const loaded=(await (await page.request.get(base+"/api/plans/"+saved.id)).json()).plan.plan;
|
||||||
|
assert.equal(loaded.temperatureOffsetF,"-50");
|
||||||
|
const cloned=await (await page.request.post(base+"/api/plans/"+saved.id+"/clone",{headers:{"x-csrf-token":csrfToken}})).json();
|
||||||
|
assert.equal(cloned.plan.plan.temperatureOffsetF,"-50");
|
||||||
|
await page.setViewportSize({width:390,height:844});
|
||||||
|
await page.goto(base+"/app?plan="+saved.id);
|
||||||
|
await input.waitFor();
|
||||||
|
assert.equal(await input.inputValue(),"-50");
|
||||||
|
await input.scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({path:"/tmp/gauge-offset-mobile.png",animations:"disabled"});
|
||||||
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
|
||||||
|
const report=await browser.newPage();
|
||||||
|
const html=renderReportHtml(loaded);
|
||||||
|
assert.match(html,/Gauge temperature offset/);assert.match(html,/-50°F/);assert.match(html,/342°F/);
|
||||||
|
await report.setContent(html);
|
||||||
|
const {PDFDocument}=await import("pdf-lib");
|
||||||
|
const pdf=await PDFDocument.load(await report.pdf({format:"Letter",preferCSSPageSize:true}));
|
||||||
|
assert.equal(pdf.getPageCount(),2);
|
||||||
|
await report.close();
|
||||||
|
assert.deepEqual(errors,[]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user