diff --git a/public/app.css b/public/app.css index 73e8537..f8028a5 100644 --- a/public/app.css +++ b/public/app.css @@ -4463,3 +4463,7 @@ select.f { .calendar-toolbar button { padding-left:8px; padding-right:8px; } .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; } } diff --git a/public/js/planner-workspace.js b/public/js/planner-workspace.js index 99e9f1c..6753cbc 100644 --- a/public/js/planner-workspace.js +++ b/public/js/planner-workspace.js @@ -115,6 +115,20 @@ export function initPlannerWorkspace({ "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"); overview.append(n("span", "YOUR NEXT BATCH", "eyebrow")); const stats = n("div", "", "overview-stats"); diff --git a/server/plan-chat.js b/server/plan-chat.js index 2633bcb..20c9950 100644 --- a/server/plan-chat.js +++ b/server/plan-chat.js @@ -10,7 +10,7 @@ import { } from "@earendil-works/pi-coding-agent"; import { getModelRuntime, noModelError, pickModel } from "./llm.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"; 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, 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 -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. 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 @@ -86,6 +89,7 @@ export function buildPlanChatContext(plan = {}, evidence = null) { blendComponents: plan.blendComponents ?? [], afterRoast: plan.afterRoast ?? {}, }, + temperatureOffsetF: planTemperatureOffsetF(plan), displayedMilestones: points.map((point) => ({ event: point.key, label: point.label, diff --git a/shared/curve.js b/shared/curve.js index dce8fc2..f768999 100644 --- a/shared/curve.js +++ b/shared/curve.js @@ -9,11 +9,14 @@ const numeric = (v) => ? Number(v) : 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) { const t = plan.temps ?? {}; - const fc = numeric(t.fc?.tempC) ?? 200; - const yellow = numeric(t.yellow?.tempC) ?? 160; + const offsetC = planTemperatureOffsetF(plan) / 1.8; + const fc = numeric(t.fc?.tempC) ?? (200 + offsetC); + const yellow = numeric(t.yellow?.tempC) ?? (160 + offsetC); 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. @@ -23,13 +26,13 @@ export function buildPlanCurve(plan, ledger) { key: "charge", label: "Charge", timeS: 0, - tempC: numeric(t.charge?.tempC) ?? 200, + tempC: numeric(t.charge?.tempC) ?? (200 + offsetC), }, { key: "tp", label: "Turning point", timeS: t.tp?.time ? parseDuration(t.tp.time) : 60, - tempC: numeric(t.tp?.tempC) ?? 95, + tempC: numeric(t.tp?.tempC) ?? (95 + offsetC), }, { key: "yellow", diff --git a/shared/fields.js b/shared/fields.js index 838bbf0..c3afb35 100644 --- a/shared/fields.js +++ b/shared/fields.js @@ -19,6 +19,7 @@ export function blankPlan() { return { schemaVersion: 1, sourceUrl: "", + temperatureOffsetF: "", fields, blendComponents: [ { cultivar: "", group: "", process: "", sharePct: "", fcAnchor: "" }, diff --git a/shared/report.js b/shared/report.js index 8a1a5fc..8d99e5d 100644 --- a/shared/report.js +++ b/shared/report.js @@ -1,6 +1,7 @@ import { computeLedger } from "./ledger.js?v=__ASSET_VERSION__"; import { buildPlanCurve, + planTemperatureOffsetF, pointsToPathD, curveWarnings, } from "./curve.js?v=__ASSET_VERSION__"; @@ -121,6 +122,7 @@ export function renderReportBody(plan, name = "") {
Origin: ${e(fields["1.3"] || "—")} · Cultivar: ${e(fields["1.1"] || "—")} · Density: ${e(fields["5.2"] || "—")} g/L
Weight-loss target: ${e(fields["4.2"] || "—")}% · Development: ${time(l.C)} · DTR: ${l.checks.dtr.pct?.toFixed(1) ?? "—"}%
${e(w)}
`).join("")}Gauge temperature offset: ${planTemperatureOffsetF(plan) > 0 ? "+" : ""}${e(planTemperatureOffsetF(plan))}°F. Applied to illustrative temperature references; drop follows adjusted milestones. Entered targets, preheat and actual readings are unchanged.
` : ""}| Milestone | Time | BT (°F) | What to look for |
|---|
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.