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>
198 lines
7.7 KiB
JavaScript
198 lines
7.7 KiB
JavaScript
#!/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();
|