Files
snowspeederandClaude Sonnet 5 fedaf29847 Initial roast planner webapp: fillable worksheet, live ledger, curve, URL prefill, .alog reference curve
- 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]>
2026-07-29 15:41:35 -04:00

216 lines
7.7 KiB
JavaScript

// Parser for Artisan .alog roast logs, ported from
// /Users/shane/dev/hope_roaster/sidecar/alog_parser.py (parse_alog + alog_to_target_curve).
// .alog files are usually JSON, but the ones actually produced on this machine are Python
// dict-literal reprs (single-quoted strings, True/False/None) — JSON.parse fails on all 14
// files under ref/roasts/, confirmed empirically. pyLiteralToJson() below tokenizes and
// converts before parsing. Do not replace this with a plain regex substitution — that
// corrupts any apostrophe inside a quoted string (e.g. a roast title).
const TIMEINDEX_LABELS = ["CHARGE", "DRY_END", "FCs", "FCe", "SCs", "SCe", "DROP", "COOL_END"];
const TIMEINDEX_TO_MILESTONE_KEY = { CHARGE: "charge", DRY_END: "yellow", FCs: "fc", DROP: "drop" };
const MAX_CURVE_POINTS = 200;
const TP_SEARCH_WINDOW_S = 150; // turning point = min BT within this many seconds after charge
function fToC(f) {
return ((f - 32) * 5) / 9;
}
/** Converts a Python dict-literal string to a JSON string. Tokenizer-based, not regex-based —
* a naive global replace of `'` -> `"` corrupts apostrophes inside quoted string values. */
export function pyLiteralToJson(text) {
let out = "";
let i = 0;
const n = text.length;
while (i < n) {
const ch = text[i];
if (ch === "'" || ch === '"') {
const quote = ch;
let raw = "";
i++;
while (i < n && text[i] !== quote) {
if (text[i] === "\\" && i + 1 < n) {
const next = text[i + 1];
const map = { n: "\n", t: "\t", r: "\r", "\\": "\\", "'": "'", '"': '"' };
raw += map[next] !== undefined ? map[next] : next;
i += 2;
} else {
raw += text[i];
i++;
}
}
i++; // consume closing quote
out += JSON.stringify(raw);
continue;
}
if (ch === "-" && /^(inf|Infinity)\b/.test(text.slice(i + 1))) {
const m = text.slice(i + 1).match(/^(inf|Infinity)/);
out += "null";
i += 1 + m[0].length;
continue;
}
if (/[0-9]/.test(ch) || (ch === "-" && /[0-9]/.test(text[i + 1] ?? ""))) {
const m = text.slice(i).match(/^-?\d+(\.\d+)?([eE][+-]?\d+)?/);
out += m[0];
i += m[0].length;
continue;
}
if (/[A-Za-z_]/.test(ch)) {
const m = text.slice(i).match(/^[A-Za-z_][A-Za-z0-9_]*/);
const word = m[0];
if (word === "True") out += "true";
else if (word === "False") out += "false";
else if (word === "None") out += "null";
else if (/^(nan|NaN|inf|Infinity)$/.test(word)) out += "null";
else out += JSON.stringify(word); // shouldn't occur in well-formed .alog data
i += word.length;
continue;
}
out += ch;
i++;
}
return out;
}
function parseAlogRaw(content) {
try {
return JSON.parse(content);
} catch {
return JSON.parse(pyLiteralToJson(content));
}
}
/**
* @param {string} content raw file text
* @param {string} filename for the title fallback
*/
export function parseAlog(content, filename) {
const data = parseAlogRaw(content);
const warnings = [];
const mode = String(data.mode ?? "C").toUpperCase();
const toC = mode === "F" ? fToC : (x) => x;
const title = data.title || filename.replace(/\.alog$/i, "");
const roastDate = data.roastdate ?? "";
const roasterType = data.roastertype ?? "Unknown";
const weight = Array.isArray(data.weight) ? data.weight : [0, 0];
const weightInG = Number(weight[0]) || 0;
const weightOutG = Number(weight[1]) || 0;
const timex = Array.isArray(data.timex) ? data.timex : [];
const temp1 = Array.isArray(data.temp1) ? data.temp1 : []; // ET
const temp2 = Array.isArray(data.temp2) ? data.temp2 : []; // BT
const timeindex = Array.isArray(data.timeindex) ? data.timeindex : [];
const n = Math.min(timex.length, temp1.length, temp2.length);
let chargeOffsetS = 0;
if (timeindex.length > 0) {
const chargeIdx = Number(timeindex[0]);
if (chargeIdx > 0 && chargeIdx < timex.length) chargeOffsetS = Number(timex[chargeIdx]);
}
const telemetry = [];
for (let i = 0; i < n; i++) {
const et = temp1[i];
const bt = temp2[i];
if (bt === null || bt === -1 || et === null || et === -1) continue;
telemetry.push({ timeS: Number(timex[i]) - chargeOffsetS, bt: toC(Number(bt)), et: toC(Number(et)) });
}
const milestones = [];
for (let pos = 0; pos < TIMEINDEX_LABELS.length && pos < timeindex.length; pos++) {
const label = TIMEINDEX_LABELS[pos];
const idx = Number(timeindex[pos]);
if (idx <= 0 || idx >= timex.length) continue;
const key = TIMEINDEX_TO_MILESTONE_KEY[label];
if (!key) continue; // FCe/SCs/SCe not used by this worksheet
const btAt = idx < temp2.length && temp2[idx] !== -1 && temp2[idx] !== null ? toC(Number(temp2[idx])) : null;
milestones.push({ key, label: readableLabel(key), timeS: Number(timex[idx]) - chargeOffsetS, tempC: btAt });
}
if (!milestones.some((m) => m.key === "yellow")) warnings.push("No DRY_END (yellow) marked in this log.");
if (!milestones.some((m) => m.key === "charge")) warnings.push("No CHARGE marked in this log — times are relative to the recording start, not charge.");
// Turning point isn't in timeindex — the worksheet defines it as the lowest BT reading
// shortly after charge.
let turningPoint = null;
const windowPoints = telemetry.filter((p) => p.timeS >= 0 && p.timeS <= TP_SEARCH_WINDOW_S);
if (windowPoints.length > 0) {
const min = windowPoints.reduce((a, b) => (b.bt < a.bt ? b : a));
turningPoint = { timeS: min.timeS, tempC: min.bt };
}
const curve = downsampleCurve(telemetry, milestones, MAX_CURVE_POINTS);
const chargeM = milestones.find((m) => m.key === "charge");
const yellowM = milestones.find((m) => m.key === "yellow");
const fcM = milestones.find((m) => m.key === "fc");
const dropM = milestones.find((m) => m.key === "drop");
let derived = null;
if (fcM && dropM) {
const firstCrackS = fcM.timeS;
const developmentS = dropM.timeS - fcM.timeS;
const dropS = dropM.timeS;
derived = {
firstCrackS,
developmentS,
dropS,
dryingSharePct: yellowM ? round1((yellowM.timeS / dropS) * 100) : null,
maillardSharePct: yellowM ? round1(((firstCrackS - yellowM.timeS) / dropS) * 100) : null,
dtrPct: round1((developmentS / dropS) * 100),
};
} else {
warnings.push("First crack and/or drop not marked — cannot compute development/DTR for this log.");
}
return {
roast: {
title,
roastDate,
roasterType,
weightInG,
weightOutG,
weightLossPct: weightInG > 0 && weightOutG > 0 ? round1(((weightInG - weightOutG) / weightInG) * 100) : null,
tempUnitInFile: mode,
},
milestones,
turningPoint,
curve,
derived,
warnings,
};
}
function readableLabel(key) {
return { charge: "Charge", yellow: "Yellow", fc: "First crack", drop: "Drop" }[key] ?? key;
}
function round1(n) {
return n === null || n === undefined ? null : Math.round(n * 10) / 10;
}
/** Downsample to <= maxPoints, always keeping milestone-adjacent samples. */
function downsampleCurve(telemetry, milestones, maxPoints) {
if (telemetry.length <= maxPoints) return telemetry.map((p) => ({ t: p.timeS, bt: round1(p.bt) }));
const milestoneTimes = new Set(milestones.map((m) => Math.round(m.timeS)));
const step = telemetry.length / maxPoints;
const out = [];
const seen = new Set();
for (let i = 0; i < telemetry.length; i += 1) {
const keepByStride = Math.floor(i / step) !== Math.floor((i - 1) / step);
const isMilestone = milestoneTimes.has(Math.round(telemetry[i].timeS));
if ((keepByStride || isMilestone) && !seen.has(i)) {
seen.add(i);
out.push({ t: telemetry[i].timeS, bt: round1(telemetry[i].bt) });
}
}
return out;
}