- 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]>
216 lines
7.7 KiB
JavaScript
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;
|
|
}
|