#!/usr/bin/env node // Scenario runner for pumpingStation. Usage: // // node simulations/run.js # run one // node simulations/run.js --all # run all scenarios // // Each scenario lives in simulations/scenarios/.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/.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 | --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();