- shared/ ports the worksheet's ledger math, time parsing, and reference tables (cultivars/processes/roast-levels/machine bands) as browser-safe ESM, imported by both the server and the browser so the arithmetic can't drift between them. - server/prefill.js runs a zero-tool Pi Coding Agent SDK turn to extract page facts from a bean product URL, then derives worksheet field IDs deterministically from reference-data.js — the model never invents a first-crack anchor or a modifier. - server/alog.js ports the Python alog_parser.py's format handling, including the tokenizer-based Python-dict-literal-to-JSON conversion the real files need. - public/ is the worksheet reproduced as a live HTML form: worksheet.css is a verbatim copy of the paper worksheet's print CSS, print.js bakes values into the print layout so the same DOM renders both on screen and on paper. - Ledger math verified against both of the paper worksheet's worked examples; alog parser verified against all 14 real logs in ref/roasts/. Co-Authored-By: Claude Sonnet 5 <[email protected]>
120 lines
6.2 KiB
JavaScript
120 lines
6.2 KiB
JavaScript
// Browser-safe. Ported from /Users/shane/dev/hope_roaster/roast-planner/manual-roast-planner.html
|
|
// (Step 1 cultivar table, Step 3 processing table, Step 4 roast-level table, Step 8 machine bands,
|
|
// Step 7 sanity bands). Keep this the single source of truth — don't retype these numbers elsewhere.
|
|
|
|
import { parseRangeMidpoint } from "./time.js";
|
|
|
|
export const YELLOW_RATIO = 0.56;
|
|
const DEFAULT_DEV_THIRD_S = 90; // "1:30", the modal third-profile-number most cultivar rows share
|
|
|
|
export const GROUPS = [
|
|
{ key: "typica", label: "Typica", fcAnchor: "7:30-8:00" },
|
|
{ key: "bourbon", label: "Bourbon", fcAnchor: "8:30-9:30" },
|
|
{ key: "ethiopian", label: "Ethiopian", fcAnchor: "7:20-7:30" },
|
|
{ key: "hybrid", label: "Timor Hybrid", fcAnchor: "follows parent" },
|
|
];
|
|
|
|
// profile = [charge->yellow, yellow->FC, FC->drop], for a light roast. devModS is derived below.
|
|
export const CULTIVARS = [
|
|
{ name: "Typica", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
|
{ name: "Maragogipe", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
|
{ name: "Pache", group: "typica", fcAnchor: "7:30-8:00", profile: ["4:15", "3:15", "1:30"] },
|
|
{ name: "Pacamara", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15", "3:15-3:45", "1:45"] },
|
|
{ name: "SL-34", group: "typica", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
|
{ name: "Catuai", group: "typica", fcAnchor: "7:30-9:00", profile: ["4:15-5:00", "3:15-4:00", "1:30"] },
|
|
|
|
{ name: "Bourbon", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"] },
|
|
{ name: "Caturra", group: "bourbon", fcAnchor: "8:30-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:25"] },
|
|
{ name: "Pacas", group: "bourbon", fcAnchor: "8:45", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"] },
|
|
{ name: "SL-28", group: "bourbon", fcAnchor: "8:00-9:00", profile: ["4:45-5:00", "3:45-4:00", "1:20-1:25"] },
|
|
|
|
{ name: "Ethiopian Heirloom", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
|
{ name: "Gesha", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
|
{ name: "Pink Bourbon", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:20"] },
|
|
{ name: "Sidra", group: "ethiopian", fcAnchor: "7:30", profile: ["4:15", "3:15", "1:30"] },
|
|
{ name: "Papayo", group: "ethiopian", fcAnchor: "7:20", profile: ["4:20", "3:00", "1:20"] },
|
|
|
|
{ name: "Castillo", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:00", "1:30"] },
|
|
{ name: "Tabi", group: "hybrid", fcAnchor: "8:00", profile: ["4:15", "3:45", "1:30"] },
|
|
{ name: "H1 / Centroamericano", group: "hybrid", fcAnchor: "7:00", profile: ["4:15", "2:45", "1:30"] },
|
|
{ name: "Catimor", group: "hybrid", fcAnchor: "8:30", profile: ["4:45", "4:15", "1:30-2:00"] },
|
|
{ name: "Sarchimor", group: "hybrid", fcAnchor: "8:30-9:00", profile: ["4:45", "4:15", "1:30-2:00"] },
|
|
].map((c) => ({
|
|
...c,
|
|
devModS: parseRangeMidpoint(c.profile[2]) - DEFAULT_DEV_THIRD_S,
|
|
}));
|
|
|
|
export function findCultivar(name) {
|
|
if (!name) return null;
|
|
const norm = String(name).trim().toLowerCase().replace(/["'.]/g, "");
|
|
return (
|
|
CULTIVARS.find((c) => c.name.toLowerCase().replace(/["'.]/g, "") === norm) ??
|
|
CULTIVARS.find((c) => norm.includes(c.name.toLowerCase().split(" / ")[0])) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
export function findGroup(key) {
|
|
return GROUPS.find((g) => g.key === key) ?? null;
|
|
}
|
|
|
|
export const PROCESSES = [
|
|
{ key: "washed", label: "Washed", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline" },
|
|
{ key: "yellow-honey", label: "Yellow / white honey", devBand: "1:20-1:45", devModS: 0, dropDeltaC: "baseline" },
|
|
{ key: "red-black-honey", label: "Red / black honey", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4" },
|
|
{ key: "natural", label: "Natural", devBand: "1:00-1:30", devModS: -20, dropDeltaC: "-2 to -4" },
|
|
{ key: "anaerobic", label: "Anaerobic / CM", devBand: "-0:10 to -0:15 further", devModS: -30, dropDeltaC: "lower still" },
|
|
];
|
|
|
|
export function findProcess(key) {
|
|
return PROCESSES.find((p) => p.key === key) ?? null;
|
|
}
|
|
|
|
export const ROAST_LEVELS = [
|
|
{ key: "light", label: "Light", weightLossBand: "11-13%", devBand: "1:15-2:00" },
|
|
{ key: "light-medium", label: "Light-medium", weightLossBand: "13-14%", devBand: "2:00" },
|
|
{ key: "medium", label: "Medium", weightLossBand: "14-16%", devBand: "2:00-3:00" },
|
|
{ key: "dark", label: "Dark", weightLossBand: "17-18%", devBand: "3:00-4:00" },
|
|
{ key: "very-dark", label: "Very dark", weightLossBand: "19-21%", devBand: "3:30-4:30" },
|
|
];
|
|
|
|
export function findRoastLevel(key) {
|
|
return ROAST_LEVELS.find((r) => r.key === key) ?? null;
|
|
}
|
|
|
|
// Step 8 — from the operator's own 14 logged roasts.
|
|
export const MACHINE = {
|
|
charge: { medianC: 163, rangeC: [133, 195] },
|
|
turningPoint: { medianC: 85, medianTime: "0:52", rangeC: [77, 100], rangeTime: "0:47-1:15" },
|
|
yellow: { medianC: 158, rangeC: [148, 166] },
|
|
firstCrack: { medianC: 183, rangeC: [176, 187] },
|
|
drop: { medianC: 192, rangeC: [183, 197] },
|
|
ror: {
|
|
tpToYellow: [17, 25],
|
|
yellowToFc: [6, 10],
|
|
fcToDrop: [3, 9],
|
|
},
|
|
deadTimeS: 60,
|
|
fanEffectCPerMinPer10Pct: -1.3,
|
|
};
|
|
|
|
// Step 7 sanity bands.
|
|
export const SANITY_BANDS = {
|
|
drying: { lo: 43, hi: 51 },
|
|
maillard: { lo: 33, hi: 39 },
|
|
dtr: { lo: 12, hi: 20 },
|
|
ceilingS: 270, // 4:30
|
|
};
|
|
|
|
// Step 9 taper template — starting shape for box 9's actuator schedule.
|
|
export const TAPER_TEMPLATE = [
|
|
{ point: "Charge", heat: "90-100%", fan: "30-50%", intent: "Recover from the charge dip and drive to the turning point." },
|
|
{ point: "Hold, then first cut", heat: "-> 90-95%", fan: "hold", intent: "When you make this cut is your main first-crack lever." },
|
|
{ point: "Mid-drying", heat: "85-90%", fan: "50-60%", intent: "Begin the decline; steps of 3-5% beat single large ones." },
|
|
{ point: "At yellow", heat: "77-82%", fan: "60-70%", intent: "Airflow rising as chaff releases; RoR around 8-10 C/min." },
|
|
{ point: "Late Maillard", heat: "70-75%", fan: "70-80%", intent: "Get ahead of the exotherm here, not after it." },
|
|
{ point: "At first crack", heat: "60-70%", fan: "70-80%", intent: "Cut before the exotherm, gently." },
|
|
{ point: "Development", heat: "55-65%", fan: "80%", intent: "Small trims only." },
|
|
{ point: "Drop", heat: "0%", fan: "100%", intent: "Cool fast; log the drop time." },
|
|
];
|