// 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; } export function parseAlogRaw(content) { try { return JSON.parse(content); } catch { return JSON.parse(pyLiteralToJson(content)); } } /** Serializes a JS value as a Python dict/list literal — the format Artisan itself writes * and re-reads with ast.literal_eval. JSON is NOT safe for re-import (true/false/null are * not Python literals), so the supplemental "updated" .alog must go out in this form. */ export function jsToPyLiteral(value) { if (value === null || value === undefined) return "None"; if (value === true) return "True"; if (value === false) return "False"; if (typeof value === "number") return Number.isFinite(value) ? String(value) : "None"; if (typeof value === "string") { // Python string literal with double quotes; escape backslashes, quotes, newlines. return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`; } if (Array.isArray(value)) return `[${value.map(jsToPyLiteral).join(", ")}]`; if (typeof value === "object") return `{${Object.entries(value) .map(([k, v]) => `${jsToPyLiteral(String(k))}: ${jsToPyLiteral(v)}`) .join(", ")}}`; return "None"; } /** The supplemental .alog: the original file re-serialized with `updates` merged in (only * keys whose value is not undefined are touched — everything else is preserved verbatim * from the parse). Returns a Python-literal string Artisan can import. */ export function buildUpdatedAlog(originalContent, updates) { const data = parseAlogRaw(originalContent); for (const [key, value] of Object.entries(updates)) { if (value !== undefined) data[key] = value; } return jsToPyLiteral(data); } /** * @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; }