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>
(cherry picked from commit 0d5fc6d258dee4774ebe7ea760736c9f05026d06)
This commit is contained in:
igneum-labs 2026-10-07 09:31:55 +00:00
parent 20796156e1
commit 1b912385ad
12 changed files with 263 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

143
docs/plans/ember-heat.md Normal file
View 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 under miner-ui-5's IGN-first rows; retaken after the rebase onto miner-ui-5 e9fe1106), `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 |

View file

@ -80,6 +80,7 @@ tree_checks() {
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 "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.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 app/igneum-app/ui/ui-ota.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
View 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);
}