ember-heat: the gate reader, the PC 1 four-hour runbook, the plan and the light and dark captures
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>
BIN
docs/plans/ember-heat-shots/h01-cards-dark-1200.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
docs/plans/ember-heat-shots/h02-cards-light-1200.png
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
docs/plans/ember-heat-shots/h03-cards-resting-dark-1200.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
docs/plans/ember-heat-shots/h04-settings-dark-1200.png
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
docs/plans/ember-heat-shots/h05-settings-light-1200.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
docs/plans/ember-heat-shots/h06-earnings-dark-1200.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
docs/plans/ember-heat-shots/h07-earnings-light-1200.png
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
docs/plans/ember-heat-shots/h08-region-step-dark-1200.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
docs/plans/ember-heat-shots/h09-region-step-light-1200.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
143
docs/plans/ember-heat.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Ember Heat: heat mode, the region and price prompt, the cost-against-rent line
|
||||
|
||||
Mission item 7 (docs/analysis/mission/mission.md section 2.7; invent.md section 5; future.md sections 3, 4, 8 and 10).
|
||||
the project lead ordered it built on 7 October 2026, 10:1x UK. Branch `ember-heat`, worktree `../igneum-wt-ember-heat`, based on
|
||||
the miner-ui-4 tip 5d9c55a2 in the 0.3.19 line. App only: no digest, no protocol, ships in any cut. The first-month
|
||||
lane (miner-ui-5) works beside it; this lane keeps to Ember, the Settings page and the Earnings cost line.
|
||||
|
||||
## 1. What a miner sees
|
||||
|
||||
| Where | What | Words |
|
||||
|---|---|---|
|
||||
| First run, step 2 of 3 | The region list, the price per kWh prefilled from the public table and editable, the restricted line for a listed region | "Every £ figure in the app comes from the price you type here. Nothing is fetched." |
|
||||
| A Russian region or China chosen | The restricted line, the standing sentence first | "Mining may be restricted where you are. You are responsible for checking. Mining is banned in the Moscow region from 15 August 2026 to 2032." |
|
||||
| Settings > Electricity | The same list and price, the source of the typical price | "Typical: 26.32 p per kWh (Ofgem cap, 1 October to 31 December 2026). Type what you pay, in pence per kWh. Nothing is fetched." |
|
||||
| Settings > Heat mode | The switch, the set point, the schedule, a typed room reading, the live line | "Holding 20.0 °C: room 19.6 °C from your reading 40 min ago. heating, 5 min to go. Heat 60% of the time." |
|
||||
| Cards, the Ember strip | The heat line | "Heat mode: holding 20.0 °C · room 19.6 °C · heating 58% of the time · 448 W of heat" |
|
||||
| Cards, each row's Ember line | The duty and the watts | "heat 58% · 448 W of heat", or "heat 58% · resting" |
|
||||
| A resting card | The row says why it shows 0 MH/s; the pill says Resting · heat mode; the big button keeps Stop mining | "resting: the room is 20.1 °C, holding 20.0 °C (heat mode)" |
|
||||
| Earnings | The cost-against-rent row | "0.064 p per MH/s-hour · your cost of hash at 26.32 p/kWh and 2.44 W per MH/s, about USD 0.0008. Rented hash: USD 0.0117 per MH/s-hour (RunPod community pods, 38 pods at list price, 1,748 MH/s for USD 20.44 an hour, 6 October 2026). Yours is 14x cheaper than rented hash." |
|
||||
|
||||
The honest sentence on the switch, from invent.md section 5: "In heat mode your card is an electric heater that also
|
||||
earns; whether it beats your boiler depends on the network and the price, shown live."
|
||||
|
||||
## 2. Heat mode: the loop (app/igneum-app/src/heat.rs, engine.rs `tick_heat`)
|
||||
|
||||
| Part | Rule |
|
||||
|---|---|
|
||||
| Period | 10 minutes: the heating slice first, the rest after it. The hash follows: the miners run in the slice and stop for the rest. |
|
||||
| Duty | Decided once per period on the reading at its start: duty = 0.5 per degree under the set point plus the integral; the integral moves 0.05 per degree per period and carries the steady share the room needs (starts at a half). Clamped 0 to 1. |
|
||||
| Temperature source, in order | A typed reading (the miner's own thermometer, Settings > Heat mode > Room now), fresh for two hours. Else the coolest card's sensor after 3 minutes of rest, minus the idle offset (8 degrees until learned; typing a reading while the cards rest teaches the real offset), with the cooling tail still in the sensor taken off by its slope (one 60 s time constant, approximate). Else nothing: the loop heats 70% of every period and says "no room reading yet". |
|
||||
| Card-sensor periods | The duty is capped at 70% so every period keeps the 3 minutes of rest the reading needs. A typed reading lifts the cap. |
|
||||
| Schedule | "06:00 20, 22:00 16": each entry holds until the next; before the first the last of the day holds; times in the window's clock (the offset is saved with the schedule). Blank = one set point all day. |
|
||||
| Set point | 5 to 30 degrees. A typed room reading is -20 to 50. |
|
||||
| Rest mechanics | `heat_rest`: the miners stop the way a remote job stops them (stop_miners, the rows say why, nothing restarts them); the release re-arms every slot with a fresh pack check. A tune never starts during a rest and a running tune aborts into it. Paused mining, a remote job holding the cards, or no enabled card: no rest is taken. |
|
||||
| Log | `HEAT t=<unix> phase=heating|resting set=20.0 room=19.6 src=typed duty=0.60 heat_w=448 hash=183.7 until=312` every 30 s; `heat: period duty=...` at each period start; the gate tool reads the HEAT lines. |
|
||||
| State | `state.heat` (phase, set point, room and its source and error, duty this period and over the last hour, watts now and when heating, seconds to the next change, the one line). |
|
||||
| Chain | Nothing. A heat-mode miner is a miner with a schedule (invent.md section 5); a thermostat that ramps over an hour is not a 10 percent removal (CLAUDE.md, the vote-weight rule). |
|
||||
|
||||
Model result (the tests in heat.rs, a 400 kJ/K room losing 20 W/K to 10 degrees outside, a 300 W card): with a typed
|
||||
reading refreshed every half hour the room stays within 1 degree of 20 for the four hours after the first, the hash on
|
||||
exactly while the slice heats, the integral settling near the two-thirds share the room needs; with the card sensor
|
||||
alone, the same hold, the estimate inside its stated 3 degrees. A model is not the gate; section 6 is.
|
||||
|
||||
## 3. The region and price prompt (ui/app.js `View.REGIONS`, the first-run step, Settings > Electricity)
|
||||
|
||||
| Rule | Where it comes from |
|
||||
|---|---|
|
||||
| The typical prices: UK 26.32 p (Ofgem cap Q4 2026), Ireland 40.42, Germany 38.69, Belgium 34.99, EU average 28.96, Hungary 10.82, Malta 12.82, Bulgaria 13.55 (euro cents, Eurostat H2 2025), US 17.7 c (EIA H2 2025), Kazakhstan about 4 c, Paraguay 4.4 c (4.4 to 6), Iran about 1 c, Ethiopia 2 c (2 to 5.3), Nigeria 4.8 c, the last five secondary and labelled | future.md 4.1 |
|
||||
| A typed price, never fetched: the table prefills, the miner edits, the engine stores the number; no network call for a price or a rate | mission.md 2.7 |
|
||||
| The restricted entries, and only these: Russia, Moscow region (15 August 2026 to 2032); Russia, Irkutsk, Buryatia, Zabaikalsky (seasonal bans); Russia, other region (ten regions, 1 January 2025 to 15 March 2031; the app says it does not carry the list of ten); China (illegal, the joint Notice of 6 February 2026) | future.md 4.4 and 8.3 item 7 |
|
||||
| The sentence, verbatim in substance: "Mining may be restricted where you are. You are responsible for checking." | future.md 8.3 item 7 |
|
||||
| Regions the file calls "nothing" for a home miner (Norway, Sweden, Kazakhstan, Paraguay, Iran, US states) carry no line | future.md 4.4 |
|
||||
| The currency follows the region: £ and pence, € and euro cents, $ and US cents (every £ figure on every page) | this plan |
|
||||
| The dollar rates used only to compare with the rent: USD 1.32 per pound, 1.08 per euro, the rates future.md 3.4 implies; approximate, dated 7 October 2026 | future.md 3.4 |
|
||||
|
||||
The test `ui/heat-region.test.mjs` checks the restricted list against the research file when the file is in the tree
|
||||
(it is internal, outside the public export list, so a public tree skips that one test).
|
||||
|
||||
## 4. The cost-against-rent line (Earnings; `View.rentLine`, `View.RENT`)
|
||||
|
||||
cost per MH/s-hour = watts per MH/s / 1000 × price per kWh, in the region's minor unit; the live draw and rate of the
|
||||
mining cards, else the last tune's point ("the last tune"), else nothing. The rent is the bench log's measured
|
||||
USD 0.0117 per MH/s-hour (RunPod community pods, 6 October 2026); the test reads docs/bench-log.md and fails when the
|
||||
app's number drifts from the log's latest "Rental cost of hash" entry. The verdict: "Yours is Nx cheaper than rented
|
||||
hash", "Rented hash undercuts your card by Nx", or "Level with rented hash".
|
||||
|
||||
Per tier, at 26.32 p: a 5090 at 2.4 W per MH/s is 0.064 p (USD 0.0008), 14x under the rent; a 3.27 uJ card is
|
||||
USD 0.00114, 10x (future.md 3.4's row); an 8 or 12 GB card at 1.3 to 1.7x the 5090's uJ is 6 to 8x; the line flips
|
||||
the day idle datacentre hash on 5 c power sets the rent at USD 0.00016 (future.md 3.2 and 3.4), and the Earnings
|
||||
page shows it the morning it happens. Apple silicon reports no draw, so its line says "no reading yet" until a tune
|
||||
measures it.
|
||||
|
||||
## 5. Per tier
|
||||
|
||||
| Tier | Heat mode | The prompt and the rent line |
|
||||
|---|---|---|
|
||||
| 8 GB card at 150 W | a 150 W heater for a small room; the card sensor path works (NVIDIA and AMD report a temperature) | the dearest hash of the home tiers; the rent line shows it first |
|
||||
| 12 and 16 GB cards | 160 to 220 W; the same | the same |
|
||||
| 24 and 32 GB cards (5090 at 290 to 575 W) | a large-room heater; the typed reading is the better source at full duty | 10 to 14x under the rent at UK prices |
|
||||
| A rig | a space heater that should not be in a bedroom; the whole rig heats and rests together (one room) | the rig's cost is the card's cost |
|
||||
| A pool user | the duty shows as a share-rate pattern the pool sees; nothing else changes | the same |
|
||||
| Windows, Linux | the same | the same |
|
||||
| macOS, Apple silicon | 21 to 38 W is not a heater; no power reading, so the heat line says "no draw reading"; the mode still works on time | no cost line until a tune measures the draw |
|
||||
| NVIDIA, AMD | the sensor is nvidia-smi or igneum-gpu-telemetry, read while the cards rest | the same |
|
||||
|
||||
## 6. The gate: the PC 1 four-hour hold, a runbook for the project lead's desk
|
||||
|
||||
This lane never touches PC 1 (CLAUDE.md: a job never quits, pauses or restarts the installed app). The gate is run by
|
||||
hand at the desk, on the installed app once a cut carrying this branch is on PC 1. Expected time: 4 h 20 min of
|
||||
waiting, 5 minutes of doing.
|
||||
|
||||
1. Put a room thermometer on the desk, out of the PC's exhaust. Note the room temperature.
|
||||
2. In Igneum Miner: Settings > Electricity: region United Kingdom, the price as billed. Settings > Heat mode: Hold at
|
||||
a set point 1 degree above the room now (so the loop has something to do), schedule blank, Save. Room now: type
|
||||
the thermometer's reading, Use it. Switch "Hold a room temperature" on. The line under it reads "Holding N °C:
|
||||
room M °C from your reading just now. heating, 10 min to go." or close.
|
||||
3. Every 30 minutes for 4 hours: read the thermometer and type it into Room now, Use it (a fresh reading keeps the
|
||||
typed source for the whole run). Note the Cards strip line once an hour: the share of the hour and the watts.
|
||||
4. After 4 hours: Settings > Logs > Copy the log, paste it into a file, and run from the main checkout:
|
||||
`node tools/heat-gate.mjs <that file> --hours 4`
|
||||
PASS = the room stayed within 1 degree of the set point on every sample, the hash was on exactly while the slice
|
||||
heated (90 s forgiven after each change), and the measured heating share matched the planned duty within 15
|
||||
points. The tool's self-test (`--self-test`, in the pre-push gate) fires on a known hold and on a known drift, a
|
||||
hash through the rest, a short log, an empty log and a log without readings.
|
||||
5. Switch heat mode off. The cards go back to mining whenever the node is synced.
|
||||
|
||||
What a FAIL means and what to do: a drift over 1 degree with a typed source is a gain problem (KP, KI in heat.rs; the
|
||||
model room is not the desk) and comes back here with the log; a hash on through the rest is a rest that did not
|
||||
hold (the log's `heat: release` lines say why); a missing room reading is a typed reading older than two hours.
|
||||
|
||||
The card-sensor path (no typed reading) is the second run, same steps without step 3; its gate is the same tool with
|
||||
the stated 3 degrees as the honest expectation of the estimate, and the band stays 1 degree on the thermometer.
|
||||
|
||||
## 7. Files
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `app/igneum-app/src/heat.rs` | the loop, the room estimate, the schedule, the log line, the words; 8 tests with a model room |
|
||||
| `app/igneum-app/src/engine.rs` | `tick_heat`, `heat_rest`, `heat_release`, `Cmd::Heat`, `Cmd::Region`, the holds in tick_miners, tick_sweep and sweep_drive |
|
||||
| `app/igneum-app/src/config.rs`, `state.rs` | the settings (`region`, `heat_*`) and `state.heat` |
|
||||
| `app/igneum-app/src/server.rs` | `POST /api/region {region, price_pence}`, `POST /api/heat {on, set_c, schedule, tz_min, room_c}` |
|
||||
| `app/igneum-app/ui/index.html`, `app.js`, `app.css` | the step, the two Settings cards, the strip line, the row words, the rent row, `?theme=light|dark` and `?region=<code>` for screenshots |
|
||||
| `app/igneum-app/ui/heat-region.test.mjs` | the view tests (the Russian entry, the bench-log rate, the words) |
|
||||
| `tools/heat-gate.mjs` | the gate reader and its self-test |
|
||||
| `tools/ui-mock/server.mjs` | the `heat` and `heat-rest` scenarios |
|
||||
| `tools/ci/pre-push.sh` | the new test file and the gate reader's self-test on the one gate |
|
||||
|
||||
## 8. Screenshots (the mock on port 4319, scenarios heat and heat-rest, the Mac host's snapshot mode)
|
||||
|
||||
`docs/plans/ember-heat-shots/`: `h01-cards-dark-1200.png`, `h02-cards-light-1200.png` (the strip's heat line, the rows'
|
||||
duty and watts), `h03-cards-resting-dark-1200.png` (the rest: the pill, the rows, the strip), `h04-settings-dark-1200.png`,
|
||||
`h05-settings-light-1200.png` (Electricity and Heat mode), `h06-earnings-dark-1200.png`, `h07-earnings-light-1200.png`
|
||||
(the cost-against-rent row), `h08-region-step-dark-1200.png`, `h09-region-step-light-1200.png` (step 2 of 3 with the
|
||||
Moscow region chosen and its restricted line).
|
||||
|
||||
## 9. Owed
|
||||
|
||||
| What | Who |
|
||||
|---|---|
|
||||
| The PC 1 four-hour hold (section 6), on the first cut carrying this branch | the project lead, at the desk |
|
||||
| The card-sensor path's idle offset and cooling tail on a real 5090 and a 9070 XT (the model's 8 degrees and 60 s are approximate) | the first run's log |
|
||||
| A curtailment input (a webhook, a schedule, a price threshold; future.md 4.3) | a later lane |
|
||||
| The 2025/422 sustainability indicators (future.md 8.3 item 2) | the site, not the app |
|
||||
|
|
@ -78,8 +78,9 @@ tree_checks() {
|
|||
run "faucet unit tests" node --test site/api/faucet.test.mjs
|
||||
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
|
||||
run "ship tool self-test" node tools/ship-app.mjs --self-test
|
||||
run "heat gate reader self-test (a known hold passes, a known drift fails)" node tools/heat-gate.mjs --self-test
|
||||
run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
|
||||
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
|
||||
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs app/igneum-app/ui/heat-region.test.mjs
|
||||
run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh'
|
||||
}
|
||||
|
||||
|
|
|
|||
119
tools/heat-gate.mjs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#!/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);
|
||||
}
|
||||