#!/usr/bin/env node // The Ember Heat gate reader (mission item 7, docs/plans/ember-heat.md section 6): reads the HEAT lines the engine // writes every 30 s (src/heat.rs log_line) from an app log and says whether the set temperature was held within one // degree for the window while the hash followed the duty cycle. The runbook for the project lead's desk (PC 1) ends with this // command; the tool never touches a machine, it reads a file. // // node tools/heat-gate.mjs [--hours 4] [--band 1.0] PASS or FAIL with the numbers, exit 0 or 1 // node tools/heat-gate.mjs --self-test a known hold passes, a known drift and a hash // that ran while resting fail (the watcher rule) // // A line: HEAT t=1759800000 phase=heating set=20.0 room=19.6 src=typed duty=0.60 heat_w=180 hash=74.2 until=312 import { readFileSync } from 'node:fs'; const RE = /HEAT t=(\d+) phase=(\w+) set=([-\d.]+) room=([-\d.]+|-) src=(\w+) duty=([\d.]+) heat_w=([\d.]+) hash=([\d.]+) until=(\d+)/; // a worker needs this long after a phase change to load its program or to quit const SETTLE_S = 90; export function parseLines(text) { const out = []; for (const line of text.split('\n')) { const m = RE.exec(line); if (!m) continue; out.push({ t: +m[1], phase: m[2], set: +m[3], room: m[4] === '-' ? null : +m[4], src: m[5], duty: +m[6], heatW: +m[7], hash: +m[8], until: +m[9] }); } return out; } // The verdict over the last `hours` of samples: {pass, reasons[], ...numbers} export function judge(samples, hours = 4, band = 1.0) { const r = { pass: false, reasons: [], samples: samples.length, spanH: 0, maxDev: null, coverage: 0, mismatch: 0, dutyPlanned: 0, dutyMeasured: 0, heatW: 0 }; if (!samples.length) { r.reasons.push('no HEAT lines in the log'); return r; } const end = samples[samples.length - 1].t, start = end - hours * 3600; const w = samples.filter((s) => s.t >= start); r.spanH = (w[w.length - 1].t - w[0].t) / 3600; if (r.spanH < hours - 0.1) r.reasons.push(`the log covers ${r.spanH.toFixed(2)} h, the gate needs ${hours}`); const read = w.filter((s) => s.room !== null && s.src !== 'none'); r.coverage = read.length / w.length; if (r.coverage < 0.9) r.reasons.push(`only ${(r.coverage * 100).toFixed(0)}% of samples carry a room reading`); if (read.length) { r.maxDev = Math.max(...read.map((s) => Math.abs(s.room - s.set))); if (r.maxDev > band) r.reasons.push(`the room left the band: ${r.maxDev.toFixed(2)} degrees from the set point (band ${band})`); } // the hash follows the slice: on while heating, off while resting, SETTLE_S after each change forgiven let bad = 0, judged = 0, lastPhase = null, changedAt = -Infinity; for (const s of w) { if (s.phase !== lastPhase) { lastPhase = s.phase; changedAt = s.t; } if (s.t - changedAt < SETTLE_S) continue; if (s.phase !== 'heating' && s.phase !== 'resting') continue; judged++; if ((s.phase === 'heating') !== (s.hash > 0)) bad++; } r.mismatch = judged ? bad / judged : 1; if (!judged) r.reasons.push('no sample settled into heating or resting'); else if (r.mismatch > 0.05) r.reasons.push(`the hash did not follow the slice in ${(r.mismatch * 100).toFixed(0)}% of settled samples`); const heating = w.filter((s) => s.phase === 'heating').length; r.dutyMeasured = heating / w.length; r.dutyPlanned = w.reduce((a, s) => a + s.duty, 0) / w.length; if (Math.abs(r.dutyMeasured - r.dutyPlanned) > 0.15) r.reasons.push(`the measured heating share ${(r.dutyMeasured * 100).toFixed(0)}% is not the planned duty ${(r.dutyPlanned * 100).toFixed(0)}%`); const hw = w.filter((s) => s.phase === 'heating' && s.heatW > 0); r.heatW = hw.length ? hw.reduce((a, s) => a + s.heatW, 0) / hw.length : 0; r.pass = r.reasons.length === 0; return r; } export function report(r, hours, band) { const lines = [ `${r.pass ? 'PASS' : 'FAIL'}: heat gate over the last ${hours} h (band ${band} degree${band === 1 ? '' : 's'})`, ` samples ${r.samples}, span ${r.spanH.toFixed(2)} h, room readings on ${(r.coverage * 100).toFixed(0)}% of samples`, ` worst deviation from the set point: ${r.maxDev === null ? 'no reading' : r.maxDev.toFixed(2) + ' degrees'}`, ` hash followed the slice: ${((1 - r.mismatch) * 100).toFixed(0)}% of settled samples; heating share ${(r.dutyMeasured * 100).toFixed(0)}% against planned duty ${(r.dutyPlanned * 100).toFixed(0)}%`, ` heat while heating: ${r.heatW ? r.heatW.toFixed(0) + ' W' : 'no draw reading'}`, ]; for (const why of r.reasons) lines.push(` RED: ${why}`); return lines.join('\n'); } // A synthetic log: `hours` of 10-minute periods at `duty`, the room wandering `wobble` degrees around the set point, // the hash on exactly while heating unless `hashLeak` keeps it on through the rest. function synth({ hours = 4.5, duty = 0.6, set = 20, wobble = 0.4, hashLeak = false, drift = 0 }) { const out = []; const t0 = 1_759_800_000; for (let t = 0; t <= hours * 3600; t += 30) { const inPeriod = t % 600, heating = inPeriod < duty * 600; const room = set + wobble * Math.sin(t / 900) + drift * (t / (hours * 3600)); const hash = heating || hashLeak ? 183.7 : 0; out.push(`12:00:00 app HEAT t=${t0 + t} phase=${heating ? 'heating' : 'resting'} set=${set.toFixed(1)} room=${room.toFixed(1)} src=typed duty=${duty.toFixed(2)} heat_w=${heating ? 448 : 0} hash=${hash.toFixed(1)} until=${heating ? Math.round(duty * 600 - inPeriod) : 600 - inPeriod}`); } return out.join('\n'); } function selfTest() { const fails = []; const good = judge(parseLines(synth({})), 4, 1.0); if (!good.pass) fails.push('a known hold did not pass: ' + good.reasons.join('; ')); const drift = judge(parseLines(synth({ drift: 1.8 })), 4, 1.0); if (drift.pass || !drift.reasons.some((w) => w.includes('left the band'))) fails.push('a room that drifted 1.8 degrees passed'); const leak = judge(parseLines(synth({ hashLeak: true })), 4, 1.0); if (leak.pass || !leak.reasons.some((w) => w.includes('did not follow the slice'))) fails.push('a hash that ran through the rest passed'); const short = judge(parseLines(synth({ hours: 2 })), 4, 1.0); if (short.pass || !short.reasons.some((w) => w.includes('covers'))) fails.push('a two-hour log passed a four-hour gate'); const empty = judge(parseLines('nothing here\n'), 4, 1.0); if (empty.pass) fails.push('an empty log passed'); const noRead = judge(parseLines(synth({}).replace(/room=[-\d.]+ src=typed/g, 'room=- src=none')), 4, 1.0); if (noRead.pass || !noRead.reasons.some((w) => w.includes('room reading'))) fails.push('a log without room readings passed'); if (fails.length) { console.error('self-test failed:\n ' + fails.join('\n ')); process.exit(1); } console.log('self-test passed: a known hold passes; a drift of 1.8 degrees, a hash through the rest, a short log, an empty log and a log without readings fail'); } if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); if (args.includes('--self-test')) { selfTest(); process.exit(0); } const file = args.find((a) => !a.startsWith('--')); if (!file) { console.error('usage: node tools/heat-gate.mjs [--hours 4] [--band 1.0] | --self-test'); process.exit(2); } const opt = (name, d) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] ? parseFloat(args[i + 1]) : d; }; const hours = opt('--hours', 4), band = opt('--band', 1.0); const r = judge(parseLines(readFileSync(file, 'utf8')), hours, band); console.log(report(r, hours, band)); process.exit(r.pass ? 0 : 1); }