Rename eval/ → simulations/ and fix log-write bug

Per discussion: "test" and "eval" overlap in meaning; "simulations"
is more honest about what's actually happening — scripted plant
inputs driving a physics sim, then recorded for analysis.

Rename scope:
- eval/ → simulations/ (tracked as git renames)
- Internal references in run.js and README.md updated
- wiki/modes/mpc.md link updated

Also fixes a log-write bug noticed during the rename:
- run.js didn't mkdir simulations/logs/ before createWriteStream,
  so the stream opened into a potentially non-existent dir and the
  file never materialised. Added fs.mkdirSync(..., recursive:true).
- end() wasn't awaited, so the process could exit before the stream
  flushed. Now awaits the 'finish' event. Confirmed: 1200 records
  actually land in simulations/logs/<scenario>.jsonl.
- Added simulations/logs/.gitignore so future JSONL artefacts stay
  out of the repo but the dir remains tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-22 17:46:10 +02:00
parent 66fd3feff8
commit 3e13512a83
8 changed files with 24 additions and 19 deletions

123
simulations/README.md Normal file
View File

@@ -0,0 +1,123 @@
# Evaluation harness
Scenario-based evaluation for pumpingStation. Each scenario scripts a stream of inputs against a configured station, ticks the simulator at 1 s resolution, records every state, and prints a summary + event log + expectation check. Separate from unit tests (`test/`) — those verify individual pieces of logic in isolation; scenarios check end-to-end behaviour over time with realistic input trajectories.
## Run
```bash
# One scenario
node simulations/run.js levelbased-steady
# All scenarios at once
node simulations/run.js --all
```
Per-tick records are written to `simulations/logs/<scenario>.jsonl` for post-hoc analysis (e.g. streaming into InfluxDB for Grafana, or pandas / jq for one-off exploration).
## Scenario file shape
```js
// simulations/scenarios/<name>.js
module.exports = {
name: 'scenario-identifier',
description: 'one sentence — what the scenario is testing',
durationSec: 1200,
config: { /* PumpingStation config, same shape as nodeClass builds */ },
setup: async (ps) => {
// Optional. Wire fake MGCs, calibrate initial level, etc.
},
inputs: (t, ps) => {
// Called every tick (t in seconds). Drive inflow, mode changes,
// operator actions, etc.
ps.setManualInflow(0.005, Date.now(), 'm3/s');
},
expectations: [
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
],
};
```
## Supported expectation types
| Type | Semantics |
|---|---|
| `max_level_bounded` | max level across the run must be `≤ value` |
| `min_level_bounded` | min level across the run must be `≥ value` |
| `max_demand_bounded` | max percControl must be `≤ value` |
| `safety_trips_eq` | total ticks with `safetyActive` must equal `value` |
| `safety_trips_gt` | total ticks with `safetyActive` must be `> value` |
| `end_state_eq` | final record's `field` must equal `value` |
| `threshold_issues_eq` | startup guardrail issue count must equal `value` |
Add new expectation types in `run.js` (`evalExpectation`).
## Output
Example run:
```
═══ Scenario: levelbased-steady ═══
Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.
Duration: 1200s, 1s ticks
─── Samples (every 10%) ───
t(s) level(m) vol(m3) dir netFlow(m3/s) src demand safe
────────────────────────────────────────────────────────────────────────────────────────
0 2.00 20.00 steady 0 — 0% ·
120 2.64 26.40 draining -0.0026 predicted 62% ·
240 2.30 23.00 draining -0.0004 predicted 68% ·
...
─── Events (3) ───
t= 15s direction steady → filling
t= 134s direction filling → draining
─── Metrics ───
level min=2.00 max=2.73 end=2.33 m
percControl min=0% max=73% end=66%
safety trips=0 ticks
threshold issues=0 at startup
─── Expectations ───
✓ no safety trips: 0 ticks with safetyActive (expected 0)
✓ level stays below overflow: max level = 2.73 m (bound: ≤ 4.5)
✓ level stays above outflow: min level = 2.00 m (bound: ≥ 0.2)
✓ no threshold issues on init: 0 threshold issues at startup (expected 0)
Log: simulations/logs/levelbased-steady.jsonl (1200 records)
✅ PASS
```
## Why separate from `test/`?
| | `test/` | `simulations/` |
|---|---|---|
| runner | `node --test` | `node simulations/run.js` |
| scope | one function / small behaviour | end-to-end scenario over time |
| duration | milliseconds | seconds to minutes (simulated) |
| assertion style | tight, exact (`assert.equal`) | tolerance / bounds / event counts |
| output | TAP | summary table + JSONL for analysis |
| purpose | catch regressions | analyse how the system responds to input |
Unit tests live under `test/basic/`, `test/integration/`, `test/edge/`. Scenarios live here under `simulations/scenarios/`.
## Sending logs to Grafana (optional)
The JSONL output has one record per tick. To stream into InfluxDB for Grafana viewing, adapt a small consumer:
```bash
jq -c '{
measurement: "pumping_station_eval",
tags: { scenario: "'$SCENARIO'" },
fields: { level: .level, volume: .volume, demand: .percControl, safety: (.safetyActive|if . then 1 else 0 end) },
timestamp: (.t | tonumber | . * 1000000000)
}' simulations/logs/$SCENARIO.jsonl \
| influx write --bucket=telemetry ...
```
The `t` field is seconds from the scenario start (not wall-clock), so point the Grafana time range at `now() - $duration` after running.

View File

@@ -0,0 +1,40 @@
// ASCII table summary of scenario samples.
// Used by simulations/run.js.
function pad(s, n, left = false) {
s = String(s ?? '');
if (s.length >= n) return s.slice(0, n);
return left ? s.padStart(n) : s.padEnd(n);
}
function num(x, digits = 2) {
return Number.isFinite(x) ? x.toFixed(digits) : '—';
}
function formatTable(records, sampleEvery = 1) {
if (!records.length) return ' (no records)';
const header = ['t(s)', 'level(m)', 'vol(m3)', 'dir', 'netFlow(m3/s)', 'src', 'demand', 'safe'];
const rows = [];
for (let i = 0; i < records.length; i += sampleEvery) rows.push(records[i]);
if (rows[rows.length - 1] !== records[records.length - 1]) rows.push(records[records.length - 1]);
const widths = [6, 9, 9, 10, 14, 14, 8, 5];
const lines = [];
lines.push(header.map((h, i) => pad(h, widths[i], true)).join(' '));
lines.push(widths.map((w) => '─'.repeat(w)).join(' '));
for (const r of rows) {
lines.push([
pad(r.t, widths[0], true),
pad(num(r.level, 2), widths[1], true),
pad(num(r.volume, 2), widths[2], true),
pad(r.direction ?? '—', widths[3], true),
pad(num(r.netFlow, 5), widths[4], true),
pad(r.flowSource ?? '—', widths[5], true),
pad(num(r.percControl, 0) + '%', widths[6], true),
pad(r.safetyActive ? '⚠' : '·', widths[7], true),
].join(' '));
}
return lines.map((l) => ' ' + l).join('\n');
}
module.exports = { formatTable };

2
simulations/logs/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.jsonl
!.gitignore

197
simulations/run.js Normal file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env node
// Scenario runner for pumpingStation. Usage:
//
// node simulations/run.js <scenario> # run one
// node simulations/run.js --all # run all scenarios
//
// Each scenario lives in simulations/scenarios/<name>.js and exports:
// { name, description, durationSec, config, setup?, inputs, expectations? }
//
// The runner ticks the station once per simulated second, records every
// state into simulations/logs/<name>.jsonl, prints a summary table + event log,
// and checks expectations.
const path = require('path');
const fs = require('fs');
const PumpingStation = require('../src/specificClass');
const { formatTable } = require('./formatters/table');
function loadScenario(name) {
return require(path.join(__dirname, 'scenarios', name));
}
function snapshot(t, ps) {
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
return {
t,
level: lvl,
volume: vol,
direction: ps.state?.direction ?? null,
netFlow: ps.state?.netFlow ?? null,
flowSource: ps.state?.flowSource ?? null,
timeleft: ps.state?.seconds ?? null,
percControl: ps.percControl,
mode: ps.mode,
safetyActive: !!ps.safetyControllerActive,
};
}
function evalExpectation(ex, records) {
const levels = records.map((r) => r.level).filter(Number.isFinite);
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
const last = records[records.length - 1] || {};
switch (ex.type) {
case 'max_level_bounded': {
const v = Math.max(...levels);
return { ok: v <= ex.value, msg: `max level = ${v.toFixed(2)} m (bound: ≤ ${ex.value})` };
}
case 'min_level_bounded': {
const v = Math.min(...levels);
return { ok: v >= ex.value, msg: `min level = ${v.toFixed(2)} m (bound: ≥ ${ex.value})` };
}
case 'max_demand_bounded': {
const v = Math.max(...demands);
return { ok: v <= ex.value, msg: `max demand = ${v.toFixed(0)} % (bound: ≤ ${ex.value})` };
}
case 'safety_trips_eq': {
const n = records.filter((r) => r.safetyActive).length;
return { ok: n === ex.value, msg: `${n} ticks with safetyActive (expected ${ex.value})` };
}
case 'safety_trips_gt': {
const n = records.filter((r) => r.safetyActive).length;
return { ok: n > ex.value, msg: `${n} ticks with safetyActive (expected > ${ex.value})` };
}
case 'end_state_eq': {
return { ok: last[ex.field] === ex.value, msg: `end ${ex.field} = ${last[ex.field]} (expected ${ex.value})` };
}
case 'threshold_issues_eq': {
const n = (records[0] && records[0].thresholdIssues) || 0;
return { ok: n === ex.value, msg: `${n} threshold issues at startup (expected ${ex.value})` };
}
default:
return { ok: false, msg: `unknown expectation type: ${ex.type}` };
}
}
function events(records) {
const out = [];
let prev = null;
for (const r of records) {
if (!prev) { prev = r; continue; }
if (r.direction !== prev.direction) out.push({ t: r.t, kind: 'direction', from: prev.direction, to: r.direction });
if (r.safetyActive !== prev.safetyActive) out.push({ t: r.t, kind: 'safety', active: r.safetyActive });
if (r.mode !== prev.mode) out.push({ t: r.t, kind: 'mode', from: prev.mode, to: r.mode });
prev = r;
}
return out;
}
async function runScenario(name) {
const scenario = loadScenario(name);
// Use simulated time so the volume integrator sees 1 s per tick.
// The class reads Date.now() internally; monkey-patching lets it
// advance at scenario pace rather than wall-clock.
const realNow = Date.now;
let simTime = realNow();
Date.now = () => simTime;
try {
const ps = new PumpingStation(scenario.config);
if (scenario.setup) await scenario.setup(ps);
const duration = scenario.durationSec ?? 600;
const logDir = path.join(__dirname, 'logs');
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, `${scenario.name}.jsonl`);
const log = fs.createWriteStream(logPath);
const records = [];
for (let t = 0; t < duration; t += 1) {
simTime += 1000; // advance 1 simulated second
if (scenario.inputs) scenario.inputs(t, ps);
ps.tick();
const snap = snapshot(t, ps);
snap.thresholdIssues = ps.thresholdIssues?.length ?? 0;
records.push(snap);
log.write(JSON.stringify(snap) + '\n');
}
// Drain so the file is fully written before we return.
await new Promise((resolve, reject) => { log.end(); log.on('finish', resolve); log.on('error', reject); });
return { ps, records, scenario, duration, logPath };
} finally {
Date.now = realNow;
}
}
async function runAndReport(name) {
const { ps, records, scenario, duration, logPath } = await runScenario(name);
// Output
console.log(`\n═══ Scenario: ${scenario.name} ═══`);
console.log(scenario.description);
console.log(`Duration: ${duration}s, 1s ticks`);
console.log('\n─── Samples (every 10%) ───');
console.log(formatTable(records, Math.max(1, Math.floor(duration / 10))));
const evts = events(records);
console.log(`\n─── Events (${evts.length}) ───`);
if (!evts.length) console.log(' (none)');
for (const e of evts) {
if (e.kind === 'direction') console.log(` t=${String(e.t).padStart(4)}s direction ${e.from}${e.to}`);
else if (e.kind === 'safety') console.log(` t=${String(e.t).padStart(4)}s safety ${e.active ? 'ACTIVE ⚠' : 'cleared'}`);
else if (e.kind === 'mode') console.log(` t=${String(e.t).padStart(4)}s mode ${e.from}${e.to}`);
}
console.log('\n─── Metrics ───');
const levels = records.map((r) => r.level).filter(Number.isFinite);
const demands = records.map((r) => r.percControl).filter(Number.isFinite);
const trips = records.filter((r) => r.safetyActive).length;
if (levels.length) {
console.log(` level min=${Math.min(...levels).toFixed(2)} max=${Math.max(...levels).toFixed(2)} end=${levels[levels.length-1].toFixed(2)} m`);
}
if (demands.length) {
console.log(` percControl min=${Math.min(...demands).toFixed(0)}% max=${Math.max(...demands).toFixed(0)}% end=${demands[demands.length-1].toFixed(0)}%`);
}
console.log(` safety trips=${trips} ticks`);
console.log(` threshold issues=${ps.thresholdIssues?.length ?? 0} at startup`);
let allOk = true;
if (scenario.expectations?.length) {
console.log('\n─── Expectations ───');
for (const ex of scenario.expectations) {
const { ok, msg } = evalExpectation(ex, records);
allOk = allOk && ok;
console.log(` ${ok ? '✓' : '✗'} ${ex.name}: ${msg}`);
}
}
console.log(`\nLog: ${path.relative(process.cwd(), logPath)} (${records.length} records)`);
console.log(allOk ? '✅ PASS' : '❌ FAIL');
return allOk;
}
async function main() {
const arg = process.argv[2];
if (!arg) {
console.error('Usage: node simulations/run.js <scenario> | --all');
console.error('Available:', fs.readdirSync(path.join(__dirname, 'scenarios')).map((f) => f.replace(/\.js$/, '')).join(', '));
process.exit(1);
}
if (arg === '--all') {
const names = fs.readdirSync(path.join(__dirname, 'scenarios')).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, ''));
let allOk = true;
for (const name of names) {
try { allOk = (await runAndReport(name)) && allOk; }
catch (err) { console.error(`ERROR in ${name}:`, err.message); allOk = false; }
}
process.exit(allOk ? 0 : 1);
}
try { process.exit((await runAndReport(arg)) ? 0 : 1); }
catch (err) { console.error('ERROR:', err.message, '\n', err.stack); process.exit(1); }
}
main();

View File

@@ -0,0 +1,60 @@
// Steady sewer inflow, level-based control, pumps should settle.
//
// Expectation: with a stable inflow of 0.008 m³/s and a pump bank with
// max capacity 0.012 m³/s, the level settles in the RAMP zone (between
// startLevel and maxLevel) at roughly the point where demand matches
// inflow. No safety trips should fire.
module.exports = {
name: 'levelbased-steady',
description: 'Constant sewer inflow below pump capacity; level converges inside the RAMP zone with demand matching inflow.',
durationSec: 1200,
config: {
general: { name: 'EvalSteady', id: 'eval-steady', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 2,
enableOverfillProtection: true,
overfillThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
},
setup: async (ps) => {
// Stub MGC: its pumps collectively deliver (demand/100) × MAX_OUTFLOW.
const MAX_OUTFLOW = 0.012; // m³/s
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
},
handleInput: async (_source, demand) => {
const d = Math.max(0, Math.min(100, Number(demand) || 0));
const outflow = (d / 100) * MAX_OUTFLOW;
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
},
};
ps.calibratePredictedLevel(2.0); // start at the bottom of the RAMP zone
},
inputs: (t, ps) => {
ps.setManualInflow(0.008, Date.now(), 'm3/s'); // ≈ 29 m³/h
},
expectations: [
{ name: 'no safety trips', type: 'safety_trips_eq', value: 0 },
{ name: 'level stays below overflow', type: 'max_level_bounded', value: 4.5 },
{ name: 'level stays above outflow', type: 'min_level_bounded', value: 0.2 },
{ name: 'no threshold issues on init', type: 'threshold_issues_eq', value: 0 },
],
};

View File

@@ -0,0 +1,60 @@
// Storm surge — inflow triples briefly, pumps should saturate at 100%,
// level rises toward overflow then recedes.
//
// Expectation: during the surge (t=300..600), demand reaches 100% and
// level may transiently climb above maxLevel. Overflow safety should
// fire if the surge overwhelms pump capacity; dry-run should not fire.
module.exports = {
name: 'levelbased-storm',
description: 'Sewer inflow triples from 0.008 → 0.024 m³/s for 5 minutes then returns to baseline. Overfill safety may engage.',
durationSec: 1500,
config: {
general: { name: 'EvalStorm', id: 'eval-storm', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 2,
enableOverfillProtection: true,
overfillThresholdPercent: 95,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
},
setup: async (ps) => {
const MAX_OUTFLOW = 0.012; // m³/s pumps cannot keep up with 3× surge
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1' } },
turnOffAllMachines: () => {
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
},
handleInput: async (_src, demand) => {
const d = Math.max(0, Math.min(100, Number(demand) || 0));
const outflow = (d / 100) * MAX_OUTFLOW;
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(outflow, Date.now(), 'm3/s');
},
};
ps.calibratePredictedLevel(2.5);
},
inputs: (t, ps) => {
const surge = (t >= 300 && t < 600) ? 0.024 : 0.008;
ps.setManualInflow(surge, Date.now(), 'm3/s');
},
expectations: [
{ name: 'dry-run never trips', type: 'end_state_eq', field: 'safetyActive', value: false },
// Level may exceed maxLevel transiently but must stay under basinHeight
{ name: 'level never breaches physical basin', type: 'max_level_bounded', value: 5.0 },
{ name: 'demand saturates at 100% during surge', type: 'max_demand_bounded', value: 100 },
],
};

View File

@@ -0,0 +1,66 @@
// Dry-run safety trip — manual mode, fixed high demand, zero inflow.
// Levelbased control would taper demand as the level drops (its ramp),
// stalling drainage before safety fires. Manual mode holds demand
// constant so the level actually reaches the dry-run threshold.
module.exports = {
name: 'safety-dry-run-trip',
description: 'Manual mode, constant 100 % demand, zero inflow; expect safety to force-stop downstream pumps when level reaches the dry-run threshold.',
durationSec: 600,
config: {
general: { name: 'EvalDryRun', id: 'eval-dry-run', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'pumpingStation', role: 'stationcontroller', positionVsParent: 'atEquipment' },
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5 },
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
control: {
mode: 'manual',
allowedModes: new Set(['levelbased', 'manual']),
levelbased: { minLevel: 0.5, startLevel: 2, maxLevel: 4 },
},
safety: {
enableDryRunProtection: true,
dryRunThresholdPercent: 50,
enableOverfillProtection: false,
overfillThresholdPercent: 98,
timeleftToFullOrEmptyThresholdSeconds: 0,
},
},
setup: async (ps) => {
const MAX_OUTFLOW = 0.04;
let mgcRunning = true; // gets toggled by safety's shutdown call
ps.machineGroups['mgc1'] = {
config: { general: { name: 'mgc1', id: 'mgc1' }, functionality: { positionVsParent: 'downstream' } },
turnOffAllMachines: () => {
mgcRunning = false;
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value(0, Date.now(), 'm3/s');
},
handleInput: async (_src, demand) => {
if (!mgcRunning) return;
const d = Math.max(0, Math.min(100, Number(demand) || 0));
ps.measurements.type('flow').variant('predicted').position('out').child('mgc1').value((d / 100) * MAX_OUTFLOW, Date.now(), 'm3/s');
},
};
// Need a downstream machine for safety to shut down
ps.machines['pump1'] = {
config: { general: { name: 'pump1', id: 'pump1' }, functionality: { positionVsParent: 'downstream' } },
_isOperationalState: () => mgcRunning,
handleInput: async (_src, action) => {
if (action === 'execSequence') mgcRunning = false;
},
};
ps.calibratePredictedLevel(2.5);
},
inputs: (t, ps) => {
ps.setManualInflow(0, Date.now(), 'm3/s');
if (ps.mode === 'manual') ps.forwardDemandToChildren(100);
},
expectations: [
{ name: 'safety engages at some point', type: 'safety_trips_gt', value: 0 },
{ name: 'level never goes below outflow pipe', type: 'min_level_bounded', value: 0.2 },
],
};