From 66fd3feff8ac4877642ba4b9d553ff7c8251b043 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Wed, 22 Apr 2026 16:49:41 +0200 Subject: [PATCH] Add eval harness + Tier 2/3 mode template pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### eval/ (scenario-based evaluation) Complements the unit tests under test/basic. Scenarios fluctuate inputs over simulated time, record every tick to JSONL, print a summary table + event log, and check expectations. Complementary to unit tests — these answer "how does the system respond to this input profile" rather than "is this function correct". - eval/run.js — driver; monkey-patches Date.now so the volume integrator ticks at 1 s/iter regardless of wall-clock - eval/scenarios/ — one file per scenario - levelbased-steady.js — constant inflow, demand converges - levelbased-storm.js — inflow surge, demand saturates - safety-dry-run-trip.js — manual mode, empty basin, safety trips - eval/formatters/table.js — ASCII summary of sampled ticks - eval/logs/ — per-scenario JSONL output (one line per tick) - eval/README.md — usage + scenario file shape + how to pipe into InfluxDB/Grafana All three starter scenarios PASS with their expectations. ### wiki/modes/ (tier template pages) The levelbased page templated Tier-1 modes (static transfer function). Added worked examples for the other two tiers so all mode pages share a common skeleton and new modes have something concrete to imitate: - flowbased.md — Tier 2 (PID on measured outflow) - powerbased.md — Tier 2 (levelbased curve clipped by grid power budget) - mpc.md — Tier 3 (optimisation + forecast; block diagram + scenario time-series instead of a fixed curve) - modes/README.md — updated with the three-tier classification table and diagram-type-per-tier guidance Co-Authored-By: Claude Opus 4.7 (1M context) --- eval/README.md | 123 ++++++++++++++++ eval/formatters/table.js | 40 ++++++ eval/run.js | 194 ++++++++++++++++++++++++++ eval/scenarios/levelbased-steady.js | 60 ++++++++ eval/scenarios/levelbased-storm.js | 60 ++++++++ eval/scenarios/safety-dry-run-trip.js | 66 +++++++++ wiki/modes/README.md | 35 +++-- wiki/modes/flowbased.md | 83 +++++++++++ wiki/modes/mpc.md | 149 ++++++++++++++++++++ wiki/modes/powerbased.md | 83 +++++++++++ 10 files changed, 880 insertions(+), 13 deletions(-) create mode 100644 eval/README.md create mode 100644 eval/formatters/table.js create mode 100644 eval/run.js create mode 100644 eval/scenarios/levelbased-steady.js create mode 100644 eval/scenarios/levelbased-storm.js create mode 100644 eval/scenarios/safety-dry-run-trip.js create mode 100644 wiki/modes/flowbased.md create mode 100644 wiki/modes/mpc.md create mode 100644 wiki/modes/powerbased.md diff --git a/eval/README.md b/eval/README.md new file mode 100644 index 0000000..6a2d4d7 --- /dev/null +++ b/eval/README.md @@ -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 eval/run.js levelbased-steady + +# All scenarios at once +node eval/run.js --all +``` + +Per-tick records are written to `eval/logs/.jsonl` for post-hoc analysis (e.g. streaming into InfluxDB for Grafana, or pandas / jq for one-off exploration). + +## Scenario file shape + +```js +// eval/scenarios/.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: eval/logs/levelbased-steady.jsonl (1200 records) +✅ PASS +``` + +## Why separate from `test/`? + +| | `test/` | `eval/` | +|---|---|---| +| runner | `node --test` | `node eval/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 `eval/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) +}' eval/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. diff --git a/eval/formatters/table.js b/eval/formatters/table.js new file mode 100644 index 0000000..7613e4d --- /dev/null +++ b/eval/formatters/table.js @@ -0,0 +1,40 @@ +// ASCII table summary of scenario samples. +// Used by eval/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 }; diff --git a/eval/run.js b/eval/run.js new file mode 100644 index 0000000..2a2a235 --- /dev/null +++ b/eval/run.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +// Scenario runner for pumpingStation. Usage: +// +// node eval/run.js # run one +// node eval/run.js --all # run all scenarios +// +// Each scenario lives in eval/scenarios/.js and exports: +// { name, description, durationSec, config, setup?, inputs, expectations? } +// +// The runner ticks the station once per simulated second, records every +// state into eval/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 logPath = path.join(__dirname, 'logs', `${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'); + } + log.end(); + + 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 eval/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(); diff --git a/eval/scenarios/levelbased-steady.js b/eval/scenarios/levelbased-steady.js new file mode 100644 index 0000000..ed8e9d9 --- /dev/null +++ b/eval/scenarios/levelbased-steady.js @@ -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 }, + ], +}; diff --git a/eval/scenarios/levelbased-storm.js b/eval/scenarios/levelbased-storm.js new file mode 100644 index 0000000..70b38a8 --- /dev/null +++ b/eval/scenarios/levelbased-storm.js @@ -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 }, + ], +}; diff --git a/eval/scenarios/safety-dry-run-trip.js b/eval/scenarios/safety-dry-run-trip.js new file mode 100644 index 0000000..052b261 --- /dev/null +++ b/eval/scenarios/safety-dry-run-trip.js @@ -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 }, + ], +}; diff --git a/wiki/modes/README.md b/wiki/modes/README.md index 2807f08..be74377 100644 --- a/wiki/modes/README.md +++ b/wiki/modes/README.md @@ -1,6 +1,6 @@ # Control modes -Each page describes one `pumpingStation` control mode and how it uses the shared [basin model](../functional-description.md#basin-model) — specifically, how it sets the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and computes the demand it sends to the MGC. +Each page describes one `pumpingStation` control mode and how it uses the shared [basin model](../functional-description.md#basin-model) — specifically, how it uses the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and computes the demand it sends to the MGC. The two **safety** thresholds (`dryRunLevel` and `overflowLevel`) are mode-independent and are enforced by the safety layer outside any mode. They never appear in a mode's policy. @@ -9,21 +9,30 @@ The two **safety** thresholds (`dryRunLevel` and `overflowLevel`) are mode-indep Every mode page follows the same structure: 1. **At a glance** — one sentence + small fact table (inputs, output, status) -2. **Diagram** — reference to `../diagrams/modes/.drawio.svg` +2. **Diagram** — one or more, per tier (see below) 3. **Inputs** — what signals the mode reads -4. **Threshold policy** — how it sets/adjusts `minLevel`, `startLevel`, `maxLevel` -5. **Demand formula** — how it turns inputs into a 0-100 % demand for the MGC +4. **Threshold policy** — how it uses / adjusts `minLevel`, `startLevel`, `maxLevel` +5. **Demand formula** — pseudocode for Tier 1/2, objective function for Tier 3 6. **Edge cases** — cold start, sensor dropout, interaction with safety layer 7. **Related** — links to other modes + functional description +The three **tiers** classify modes by how dynamic the decision surface is: + +| Tier | Curve | Example modes | Diagram type | +|---|---|---|---| +| **1** — static | Memoryless `demand = f(x)`; single curve | `levelbased`, `manual` | Single-curve transfer function | +| **2** — parameterised | Shape fixed, curve moves with θ(t) | `flowbased`, `pressureBased`, `percentageBased`, `powerBased` | Transfer function + parameter overlay / family | +| **3** — horizon-based | Optimisation, no fixed curve | `hybrid-optimal`, `mpc`, weather-aware | Block diagram of signal flow + scenario time-series | + ## Implementation status -| Mode | Status | Page | -|---|---|---| -| `levelbased` | ✅ implemented | [levelbased.md](levelbased.md) | -| `flowbased` | 🚧 placeholder in code | — | -| `pressureBased` | 🚧 placeholder in code | — | -| `percentageBased` | 🚧 placeholder in code | — | -| `powerBased` | 🚧 placeholder in code | — | -| `hybrid` | 🚧 placeholder in code | — | -| `manual` | ✅ implemented (Qd topic) | — | +| Mode | Tier | Status | Page | +|---|---|---|---| +| `levelbased` | 1 | ✅ implemented | [levelbased.md](levelbased.md) | +| `manual` | 1 | ✅ implemented (via `Qd` topic) | — | +| `flowbased` | 2 | 🚧 code placeholder, template | [flowbased.md](flowbased.md) | +| `pressureBased` | 2 | 🚧 code placeholder | — | +| `percentageBased` | 2 | 🚧 code placeholder | — | +| `powerBased` | 2 | 🚧 code placeholder, template | [powerbased.md](powerbased.md) | +| `hybrid` | 3 | 🚧 code placeholder | — | +| `mpc` | 3 | 🚧 not in code yet, template | [mpc.md](mpc.md) | diff --git a/wiki/modes/flowbased.md b/wiki/modes/flowbased.md new file mode 100644 index 0000000..c464918 --- /dev/null +++ b/wiki/modes/flowbased.md @@ -0,0 +1,83 @@ +--- +title: Flow-based mode +mode: flowbased +tier: 2 +status: placeholder +updated: 2026-04-22 +--- + +# Flow-based mode — *Tier 2 template* + +> **Status — not yet implemented.** The `flowbased` entry is a placeholder in `_controlLogic`. This page reserves the shape and documents the intended design so all Tier-2 modes share the same layout. + +## At a glance + +| Item | Value | +|---|---| +| Tier | 2 — parameterised transfer function | +| Signal driving demand | measured outflow (actual pumps) | +| Secondary inputs | integrator + derivative state (for PID) | +| Output | demand 0–100 % via PID correction | +| Thresholds adjusted at runtime? | No (but the demand can move independently of level) | +| Use when | The station has a flow sensor on the outlet and you want to hold a target outflow rate regardless of basin level | + +## Diagram + +**Primary plot.** Demand vs *outflow-error* (not level!) is the meaningful transfer function for flow-based control. The curve is a classic PID surface — proportional slope times error, plus integral + derivative terms. + +**Secondary plot.** Level still enters as gates (STOP below `minLevel`, don't overfill above `maxLevel`) — same thresholds as levelbased, but the mode doesn't *use* level to pick demand. + +``` +Placeholder image — replace with: + diagrams/modes/flowbased.drawio.svg (demand vs outflow-error, showing Kp slope) +``` + +## Inputs + +| Signal | Where from | Role | +|---|---|---| +| measured outflow | sum of `flow.measured.*` at outflow positions | error = (flowSetpoint − measuredOutflow) | +| `config.control.flowBased.flowSetpoint` | editor, static | target outflow in m³/h | +| `config.control.flowBased.flowDeadband` | editor, static | zone around setpoint where PID output holds | +| `config.control.flowBased.pid.{kp, ki, kd, ...}` | editor / schema | PID gains + rate limits | +| current level | fallback → threshold gates | only used for `minLevel`/`maxLevel` bounds | + +## Threshold policy + +The **control** thresholds (`minLevel`, `startLevel`, `maxLevel`) are still enforced but for different reasons than levelbased: + +| Threshold | Role in flowbased | +|---|---| +| `minLevel` | If level drops below, force demand=0 regardless of PID output (prevents pump undercut) | +| `startLevel` | unused — demand is driven by error, not level | +| `maxLevel` | If level climbs above, force demand=100 regardless of PID output (prevents spill) | + +## Demand formula + +```text +error = flowSetpoint − measuredOutflow + +if level < minLevel: + demand = 0 # pump-undercut guard +elif level > maxLevel: + demand = 100 # anti-spill guard +else: + # normal PID branch + P = Kp × error + I += Ki × error × dt # with anti-windup clamp + D = Kd × d(error)/dt # with low-pass filter + demand = clamp(P + I + D, 0, 100) # with rate limits Δup/Δdown +``` + +## Edge cases + +- **Cold start, no prior outflow measurement.** PID state starts at 0; first error is `flowSetpoint`. Integral term will build up — rate-limit the demand ramp to avoid over-shoot. +- **Sensor dropout on the outflow meter.** Fall back to predicted outflow (sum of pump curve predictions). Log a warning — PID on predicted-only is unreliable. +- **Setpoint step change.** PID with derivative filter + rate limits handles this gracefully; without filter, the D-kick would saturate output. +- **Safety layer interaction.** Same as levelbased — `dryRunLevel` and `overflowLevel` override the PID output. See [functional description § Safety](../functional-description.md#safety-controller). + +## Related + +- [Functional description](../functional-description.md) — basin model + shared safety layer +- [modes/README.md](README.md) — mode index + page template +- [modes/levelbased.md](levelbased.md) — Tier 1 reference implementation diff --git a/wiki/modes/mpc.md b/wiki/modes/mpc.md new file mode 100644 index 0000000..aa94715 --- /dev/null +++ b/wiki/modes/mpc.md @@ -0,0 +1,149 @@ +--- +title: MPC (Model-Predictive Control) +mode: mpc +tier: 3 +status: placeholder +updated: 2026-04-22 +--- + +# MPC mode — *Tier 3 template* + +> **Status — not yet implemented.** Not even in the schema today. This page reserves the shape for when the time comes. + +## Why this is Tier 3 + +The levelbased/flowbased/powerBased modes are all **memoryless or near-memoryless transfer functions**. You give them the current state; they give you a demand. You can draw them as 2D plots. + +MPC is different. At each tick the controller solves an optimisation over a prediction horizon: + +``` +minimise Σ cost(state(t+k), command(t+k)) for k = 0 .. N +subject to forecast, physical limits, power budget, spill penalty, ... +``` + +The *command* that's emitted at time `t` is merely the first step of that plan; next tick the forecast shifts and the optimiser re-runs. There's no fixed `demand = f(level)` curve — the curve is remade every tick. + +That's why Tier-3 modes get **block diagrams + scenario time-series**, not transfer functions. + +## At a glance + +| Item | Value | +|---|---| +| Tier | 3 — optimisation-based | +| Signal driving demand | full state (level, flow, power) + **forecasts** (inflow, grid price, weather) | +| Secondary inputs | cost weights, horizon length, solver config | +| Output | demand + planned trajectory over horizon | +| Thresholds adjusted at runtime? | Effectively yes — the optimiser treats them as soft constraints | +| Use when | Available forecasts beat reactive control, or multi-objective optimisation is needed | + +## Diagram 1 — signal flow (block diagram) + +``` +Placeholder image — replace with: + diagrams/modes/mpc-block.drawio.svg + +Blocks: + + [sensors] [inflow forecast] [grid price] [weather API] + │ │ │ │ + └─────────────┴──────────────────┴──────────────┘ + │ + ┌─────▼──────┐ + │ state + │ + │ forecast │ + │ bundle │ + └─────┬──────┘ + │ + ┌─────▼───────────────────┐ + │ MPC solver │ + │ • horizon N │ + │ • cost weights w │ + │ • constraints C │ + │ • linearised model │ + └─────┬───────────────────┘ + │ + ┌─────▼───────┐ + │ command[0] │ ── the step we act on now + │ command[1] │ + │ ... │ + │ command[N] │ ── re-planned next tick + └─────┬───────┘ + │ + ┌─────────▼─────────┐ + │ safety layer clip │ ← dryRun / overflow always apply + └─────────┬─────────┘ + │ + demand → MGC +``` + +## Diagram 2 — scenario time-series + +A much more useful way to evaluate MPC is to plot *what it did* over a simulated scenario: level, planned vs actual demand, the cost function breakdown, the active constraints. The [eval harness](../../eval/README.md) is built for exactly this — MPC will need a dedicated scenario like `mpc-storm-with-forecast.js`. + +``` +Placeholder — replace with: + diagrams/modes/mpc-scenario.drawio.svg + +Stacked time-series showing: + 1. basin level over time (with forecast shadow and horizon) + 2. demand over time (with the re-planning edges visible) + 3. cost breakdown: energy vs spill-penalty vs ramp-penalty + 4. active constraints over time (colored bands) +``` + +## Inputs + +| Signal | Where from | Role | +|---|---|---| +| current state | `measurements` container | initial condition for optimiser | +| inflow forecast | external — sewer model / weather API | drives the cost integral | +| grid-price forecast | external — market feed / schedule | weights energy cost | +| cost weights `w` | config | trades off spill vs energy vs ramp | +| horizon `N` | config | 15–60 minutes typical | +| model parameters | config / learned | basin dynamics, pump curves | + +## Threshold policy + +Levels appear in the optimiser as **soft constraints** (penalties in the cost function): + +| Threshold | Role in MPC | +|---|---| +| `dryRunLevel`, `overflowLevel` | hard constraints — if the optimiser's plan crosses them, safety layer clips | +| `minLevel`, `maxLevel` | soft constraints — penalty weight `w_level` applied to excursions | +| `startLevel` | advisory only — optimiser doesn't inherently care, but may be used in cost weights for rule-of-thumb alignment with human expectations | + +So unlike Tier-1/2 where thresholds directly gate the action, here they shape the objective. + +## Demand formula + +Not a formula — an optimisation problem: + +```text +state, forecast, constraints = gather_inputs() +plan = mpc_solver.solve( + state0 = state, + forecast = forecast, + horizon = N, + model = basin_dynamics + pump_curves, + cost = w_energy × Σ power(k) + + w_spill × Σ max(0, level(k) − overflowLevel)² + + w_undercut × Σ max(0, minLevel − level(k))² + + w_ramp × Σ (command(k) − command(k-1))², + constraints = pump_limits + power_budget + rate_limits, +) +demand = plan.command[0] +``` + +## Edge cases + +- **Solver timeout.** Fall back to the previous plan's step, or to a levelbased curve as a safe default. Log. +- **Bad forecast (persistent bias).** Optimiser can chase a wrong prediction for many ticks. Adaptive forecast bias correction, or a watchdog comparing forecast-vs-realised, is essential. +- **Infeasibility.** If constraints can't be satisfied (e.g. power budget and maxLevel simultaneously during a severe storm), relax soft constraints in priority order (ramp first, then maxLevel, then energy) — never relax dryRun/overflow. +- **Safety takeover.** The safety layer still overrides. MPC should *anticipate* safety trips in its cost function (big penalty for trajectories that invoke them), not hit them. + +## Related + +- [Functional description](../functional-description.md) — basin model + safety layer +- [modes/levelbased.md](levelbased.md) — Tier 1 — the "default" MPC falls back to +- [modes/powerbased.md](powerbased.md) — Tier 2 — MPC generalises the clip idea into full optimisation +- [eval/README.md](../../eval/README.md) — where MPC evaluation scenarios will live diff --git a/wiki/modes/powerbased.md b/wiki/modes/powerbased.md new file mode 100644 index 0000000..9142a82 --- /dev/null +++ b/wiki/modes/powerbased.md @@ -0,0 +1,83 @@ +--- +title: Power-based mode +mode: powerBased +tier: 2 +status: placeholder +updated: 2026-04-22 +--- + +# Power-based mode — *Tier 2 template* + +> **Status — not yet implemented.** Placeholder. This page documents the intended shape of a grid-aware / netcongestion-aware station. + +## At a glance + +| Item | Value | +|---|---| +| Tier | 2 — parameterised transfer function | +| Signal driving demand | basin level (primary), **max-power budget** (clip) | +| Secondary inputs | measured pump power, live grid-price / peak-hours signal | +| Output | demand 0–100 % clipped so `Σ pump power ≤ maxPowerKW(t)` | +| Thresholds adjusted at runtime? | `maxPowerKW(t)` yes — level thresholds no | +| Use when | Grid has peak-hour tariffs or net-congestion caps | + +## Diagram — the levelbased curve with a moving clip ceiling + +``` +demand % ← dashed line: levelbased curve + 100 ┤ ╱ ─────── ← solid: clip at powerBudget(t) + │ ╱ clip lowers + │ ╱ during grid peak + │ ╱ ───────── + │ ╱ ╱ + │ ╱ ╱ + │ ╱ ╱ + 0 ┼────────●───────●─────────────────────► level + startLevel maxLevel + + ↑ the family of curves: + clip=100% (grid idle), + clip=70% (shoulder), + clip=40% (peak). +``` + +The *shape* stays levelbased; the *ceiling* drops when the grid is strained. That's the Tier-2 signature: same input axis, parameter shifts the curve. + +## Inputs + +| Signal | Where from | Role | +|---|---|---| +| current level | as in levelbased | primary input | +| `config.control.powerBased.maxPowerKW` | editor, static | hard cap on station power | +| `config.control.powerBased.powerControlMode` | `limit` / `optimize` | whether to just clip or to schedule | +| live grid signal (future) | external topic or forecast | modulates the cap over time | +| measured pump power | `power.measured.*` from children | real-time feedback against the cap | + +## Threshold policy + +Level thresholds (`minLevel`, `startLevel`, `maxLevel`) are **identical to levelbased** — they define the shape of the underlying curve. What's new is a runtime-varying ceiling `demandCap(t)` derived from the power budget. + +`demandCap(t) = 100 × (maxPowerKW(t) / nominalStationPowerAtFull)` — where `maxPowerKW(t)` may come from config (static `limit` mode) or an external grid-price feed (dynamic). + +## Demand formula + +```text +rawDemand = levelbasedDemand(level) # the underlying Tier-1 curve +demandCap = min(100, 100 × maxPowerKW(t) / nominalStationPower) +demand = min(rawDemand, demandCap) +``` + +When `demandCap < rawDemand`, the mode sacrifices drainage rate to stay within power budget. Level may rise — the overfill safety layer still applies as the last line of defence. + +## Edge cases + +- **Peak hour with rising level.** demandCap drops faster than level rises → demand gets clipped; level approaches `overflowLevel`. If overfill safety trips, it overrides the clip (safety wins). +- **Power signal dropout.** Fall back to static `maxPowerKW` from config; log warning. +- **Grid exit from peak while basin is nearly full.** demandCap jumps back to 100; PID is memoryless so demand rises in one tick to match rawDemand. +- **Measured vs predicted pump power.** Cap is enforced on predicted (decisions are made before the pump responds). Reconcile against measured for logging/diagnostics. + +## Related + +- [Functional description](../functional-description.md) +- [modes/levelbased.md](levelbased.md) — Tier 1 reference (the curve that powerBased clips) +- [modes/flowbased.md](flowbased.md) — other Tier-2 example with different control variable