Files
Shane MaynardandClaude Fable 5 4a3d192c2c
Test and deploy / test-and-deploy (push) Successful in 56s
Move After-the-Roast onto actual roasts; add Artisan-importable updated .alog download
One system: post-roast observations (weights, colour, DTR, cup notes,
'one change next batch', disproof) now live on the uploaded roast
(actual_roasts.after, migration 011), edited on the roast detail view
with weights/DTR prefilled from the .alog itself. The planner's screen
section is removed (the print worksheet keeps its hand-fill copy), and
the lot 'last refine' suggestion reads the note from both the new home
and legacy plans, newest wins.

Every roast now downloads two ways: the untouched original .alog, and
an updated supplemental copy with the app's data written back as valid
Artisan fields (weight, beans, roastingnotes incl. the LLM review,
cuppingnotes incl. linked cupping score/flavors) — serialized as a
Python literal so Artisan's ast.literal_eval re-imports it.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-09 08:35:11 -04:00

247 lines
9.2 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;
}
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;
}