tools/heat-gate.mjs reads the HEAT lines of an app log and passes a hold within 1 degree for 4 hours with the hash following the slice; its self-test fires on a known hold, a drift, a hash through the rest, a short log, an empty log and a log without readings, and sits on the one gate beside heat-region.test.mjs. docs/plans/ember-heat.md carries the words, the loop, the sources, the per-tier table and the runbook for the project lead's desk (this lane never touches PC 1). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 0d5fc6d258dee4774ebe7ea760736c9f05026d06)
119 lines
7.5 KiB
JavaScript
119 lines
7.5 KiB
JavaScript
#!/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 <app log> [--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 <app log> [--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);
|
|
}
|