Compare commits
2 Commits
a2189457f6
...
66fd3feff8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66fd3feff8 | ||
|
|
016433abe6 |
123
eval/README.md
Normal file
123
eval/README.md
Normal 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 eval/run.js levelbased-steady
|
||||||
|
|
||||||
|
# All scenarios at once
|
||||||
|
node eval/run.js --all
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-tick records are written to `eval/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
|
||||||
|
// eval/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: 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.
|
||||||
40
eval/formatters/table.js
Normal file
40
eval/formatters/table.js
Normal file
@@ -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 };
|
||||||
194
eval/run.js
Normal file
194
eval/run.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Scenario runner for pumpingStation. Usage:
|
||||||
|
//
|
||||||
|
// node eval/run.js <scenario> # run one
|
||||||
|
// node eval/run.js --all # run all scenarios
|
||||||
|
//
|
||||||
|
// Each scenario lives in eval/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 eval/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 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 <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();
|
||||||
60
eval/scenarios/levelbased-steady.js
Normal file
60
eval/scenarios/levelbased-steady.js
Normal 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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
60
eval/scenarios/levelbased-storm.js
Normal file
60
eval/scenarios/levelbased-storm.js
Normal 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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
66
eval/scenarios/safety-dry-run-trip.js
Normal file
66
eval/scenarios/safety-dry-run-trip.js
Normal 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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -112,10 +112,12 @@ class PumpingStation {
|
|||||||
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
|
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
|
||||||
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
|
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
|
||||||
|
|
||||||
// Compute basin geometry from config and seed the predicted volume
|
// Geometry + threshold ordering check. initBasinProperties seeds
|
||||||
// at the basin's minimum volume (outflowLevel or inflowLevel based
|
// predicted volume at minVol; _validateThresholdOrdering warns if
|
||||||
// on config.hydraulics.minHeightBasedOn).
|
// any physical/control invariant is violated. Non-fatal — prefer
|
||||||
|
// continuity over refusal to start (availability-first).
|
||||||
this.initBasinProperties();
|
this.initBasinProperties();
|
||||||
|
this.thresholdIssues = this._validateThresholdOrdering();
|
||||||
this.logger.debug('PumpingStation initialized');
|
this.logger.debug('PumpingStation initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,23 +246,22 @@ class PumpingStation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
calibratePredictedLevel(val, timestamp = Date.now(), unit = 'm') {
|
calibratePredictedLevel(val, timestamp = Date.now(), unit = 'm') {
|
||||||
const volumeChain = this.measurements.type('volume').variant('predicted').position('atequipment');
|
// Rebuild the chain each time — MeasurementContainer is stateful
|
||||||
const levelChain = this.measurements.type('level').variant('predicted').position('atequipment');
|
// (its type/variant/position methods mutate the container itself,
|
||||||
|
// so cached chain references share one cursor).
|
||||||
const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null;
|
const volMeas = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||||||
if (volumeMeasurement) {
|
if (volMeas.exists()) {
|
||||||
volumeMeasurement.values = [];
|
const m = volMeas.get();
|
||||||
volumeMeasurement.timestamps = [];
|
m.values = []; m.timestamps = [];
|
||||||
|
}
|
||||||
|
const lvlMeas = this.measurements.type('level').variant('predicted').position('atequipment');
|
||||||
|
if (lvlMeas.exists()) {
|
||||||
|
const m = lvlMeas.get();
|
||||||
|
m.values = []; m.timestamps = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelMeasurement = levelChain.exists() ? levelChain.get() : null;
|
this.measurements.type('level').variant('predicted').position('atequipment').value(val, timestamp, unit);
|
||||||
if (levelMeasurement) {
|
this.measurements.type('volume').variant('predicted').position('atequipment').value(this._calcVolumeFromLevel(val), timestamp, 'm3');
|
||||||
levelMeasurement.values = [];
|
|
||||||
levelMeasurement.timestamps = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
levelChain.value(val, timestamp).unit(unit);
|
|
||||||
volumeChain.value(this._calcVolumeFromLevel(val), timestamp, 'm3');
|
|
||||||
|
|
||||||
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
||||||
}
|
}
|
||||||
@@ -775,6 +776,60 @@ class PumpingStation {
|
|||||||
this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3');
|
this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate basin + control threshold ordering.
|
||||||
|
*
|
||||||
|
* Every pair is a strict physical or control invariant. Violations
|
||||||
|
* don't throw — they log a warning and return the list so callers
|
||||||
|
* (tests, node-status, the eval harness) can surface them. Returning
|
||||||
|
* [] means "all invariants hold".
|
||||||
|
*
|
||||||
|
* Strict invariants (bottom → top):
|
||||||
|
* 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
|
||||||
|
* dryRunTriggerLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overflowLevel
|
||||||
|
*
|
||||||
|
* dryRunTriggerLevel and the overfill trigger are DERIVED — computed
|
||||||
|
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
|
||||||
|
* overfillThresholdPercent/100 in the safety layer. Validating those
|
||||||
|
* catches config that would let minLevel sit below where safety has
|
||||||
|
* already force-stopped the pumps (no-op control band).
|
||||||
|
*/
|
||||||
|
_validateThresholdOrdering() {
|
||||||
|
const basin = this.basin;
|
||||||
|
const lvl = this.config.control?.levelbased || {};
|
||||||
|
const safety = this.config.safety || {};
|
||||||
|
|
||||||
|
// Derived safety trigger levels (level-space equivalents of what
|
||||||
|
// _safetyController does in volume-space).
|
||||||
|
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
|
||||||
|
const overfillPct = Number(safety.overfillThresholdPercent) || 100;
|
||||||
|
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
|
||||||
|
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
|
||||||
|
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
|
||||||
|
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
|
||||||
|
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
|
||||||
|
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
|
||||||
|
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
|
||||||
|
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
|
||||||
|
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
for (const [aName, a, op, bName, b] of checks) {
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
|
||||||
|
const ok = op === '<' ? a < b : a <= b;
|
||||||
|
if (!ok) {
|
||||||
|
const msg = `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`;
|
||||||
|
issues.push({ aName, a, op, bName, b, msg });
|
||||||
|
this.logger.warn(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
/** Convert level (m from floor) → volume (m3). Clamps to 0. */
|
/** Convert level (m from floor) → volume (m3). Clamps to 0. */
|
||||||
_calcVolumeFromLevel(level) {
|
_calcVolumeFromLevel(level) {
|
||||||
return Math.max(level, 0) * this.basin.surfaceArea;
|
return Math.max(level, 0) * this.basin.surfaceArea;
|
||||||
|
|||||||
295
test/basic/specificClass.test.js
Normal file
295
test/basic/specificClass.test.js
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
// Basic unit tests for PumpingStation (domain logic, no Node-RED).
|
||||||
|
// Run with: node --test test/basic/specificClass.test.js
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const PumpingStation = require('../../src/specificClass');
|
||||||
|
|
||||||
|
// Standard config shape. Override any section by passing { section: {...} }.
|
||||||
|
function makeConfig(overrides = {}) {
|
||||||
|
const base = {
|
||||||
|
general: {
|
||||||
|
name: 'TestStation',
|
||||||
|
id: 'ps-test',
|
||||||
|
unit: 'm3/h',
|
||||||
|
logging: { enabled: false, logLevel: 'error' },
|
||||||
|
flowThreshold: 1e-4,
|
||||||
|
},
|
||||||
|
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', 'manual']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||||
|
},
|
||||||
|
safety: {
|
||||||
|
enableDryRunProtection: false,
|
||||||
|
enableOverfillProtection: false,
|
||||||
|
dryRunThresholdPercent: 2,
|
||||||
|
overfillThresholdPercent: 98,
|
||||||
|
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const k of Object.keys(overrides)) {
|
||||||
|
base[k] = typeof overrides[k] === 'object' && !Array.isArray(overrides[k])
|
||||||
|
? { ...base[k], ...overrides[k] }
|
||||||
|
: overrides[k];
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Basin geometry — derived values', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
|
await t.test('surfaceArea = volume / height', () => {
|
||||||
|
assert.equal(ps.basin.surfaceArea, 10); // 50 / 5
|
||||||
|
});
|
||||||
|
await t.test('maxVol = height × area ≡ volEmptyBasin', () => {
|
||||||
|
assert.equal(ps.basin.maxVol, 50);
|
||||||
|
assert.equal(ps.basin.maxVol, ps.basin.volEmptyBasin);
|
||||||
|
});
|
||||||
|
await t.test('maxVolAtOverflow = overflowLevel × area', () => {
|
||||||
|
assert.equal(ps.basin.maxVolAtOverflow, 45); // 4.5 × 10
|
||||||
|
});
|
||||||
|
await t.test('minVolAtInflow = inflowLevel × area', () => {
|
||||||
|
assert.equal(ps.basin.minVolAtInflow, 30); // 3 × 10
|
||||||
|
});
|
||||||
|
await t.test('minVolAtOutflow = outflowLevel × area', () => {
|
||||||
|
assert.ok(Math.abs(ps.basin.minVolAtOutflow - 2) < 1e-9); // 0.2 × 10
|
||||||
|
});
|
||||||
|
await t.test('minVol honours minHeightBasedOn=outlet', () => {
|
||||||
|
assert.ok(Math.abs(ps.basin.minVol - 2) < 1e-9);
|
||||||
|
});
|
||||||
|
await t.test('minVol honours minHeightBasedOn=inlet', () => {
|
||||||
|
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
|
||||||
|
assert.equal(ps2.basin.minVol, 30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Level ↔ volume roundtrip', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
|
await t.test('_calcVolumeFromLevel multiplies by area', () => {
|
||||||
|
assert.equal(ps._calcVolumeFromLevel(2), 20);
|
||||||
|
});
|
||||||
|
await t.test('_calcVolumeFromLevel clamps negatives to 0', () => {
|
||||||
|
assert.equal(ps._calcVolumeFromLevel(-3), 0);
|
||||||
|
});
|
||||||
|
await t.test('_calcLevelFromVolume divides by area', () => {
|
||||||
|
assert.equal(ps._calcLevelFromVolume(20), 2);
|
||||||
|
});
|
||||||
|
await t.test('_calcLevelFromVolume clamps negatives to 0', () => {
|
||||||
|
assert.equal(ps._calcLevelFromVolume(-10), 0);
|
||||||
|
});
|
||||||
|
await t.test('roundtrip preserves level', () => {
|
||||||
|
const v = ps._calcVolumeFromLevel(2.7);
|
||||||
|
assert.ok(Math.abs(ps._calcLevelFromVolume(v) - 2.7) < 1e-10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||||
|
await t.test('valid config returns no issues', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
assert.equal(ps.thresholdIssues.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('minLevel > startLevel flagged', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 3, startLevel: 2, maxLevel: 4 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'minLevel'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('startLevel == maxLevel flagged (must be strict <)', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
control: {
|
||||||
|
mode: 'levelbased',
|
||||||
|
allowedModes: new Set(['levelbased']),
|
||||||
|
levelbased: { minLevel: 1, startLevel: 4, maxLevel: 4 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('outflowLevel >= inflowLevel flagged', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 },
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'outflowLevel'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('overflowLevel > basinHeight flagged', () => {
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 6 },
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'overflowLevel'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('dryRunLevel > minLevel flagged (safety band inverted)', () => {
|
||||||
|
// With minHeightBasedOn=inlet, refLowLevel=inflowLevel=3.
|
||||||
|
// dryRunLevel = 3 × (1 + 100/100) = 6; minLevel=1 → 6 ≤ 1 fails.
|
||||||
|
const ps = new PumpingStation(makeConfig({
|
||||||
|
hydraulics: { minHeightBasedOn: 'inlet' },
|
||||||
|
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 100 },
|
||||||
|
}));
|
||||||
|
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'dryRunLevel'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Direction derivation — _deriveDirection', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
|
await t.test('positive flow above dead-band → filling', () => {
|
||||||
|
assert.equal(ps._deriveDirection(0.01), 'filling');
|
||||||
|
});
|
||||||
|
await t.test('negative flow below dead-band → draining', () => {
|
||||||
|
assert.equal(ps._deriveDirection(-0.01), 'draining');
|
||||||
|
});
|
||||||
|
await t.test('flow inside dead-band → steady', () => {
|
||||||
|
assert.equal(ps._deriveDirection(0), 'steady');
|
||||||
|
assert.equal(ps._deriveDirection(1e-5), 'steady');
|
||||||
|
assert.equal(ps._deriveDirection(-1e-5), 'steady');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mode change — changeMode', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
|
await t.test('valid mode swap updates this.mode', () => {
|
||||||
|
ps.changeMode('manual');
|
||||||
|
assert.equal(ps.mode, 'manual');
|
||||||
|
});
|
||||||
|
await t.test('rejected mode leaves this.mode unchanged', () => {
|
||||||
|
ps.changeMode('manual');
|
||||||
|
ps.changeMode('notamode');
|
||||||
|
assert.equal(ps.mode, 'manual');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Calibration — predicted volume and level', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
|
||||||
|
await t.test('calibratePredictedVolume rewrites volume series', () => {
|
||||||
|
ps.calibratePredictedVolume(25);
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(vol - 25) < 1e-9);
|
||||||
|
});
|
||||||
|
await t.test('calibratePredictedVolume also writes level (= vol / area)', () => {
|
||||||
|
ps.calibratePredictedVolume(30);
|
||||||
|
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||||
|
assert.ok(Math.abs(lvl - 3) < 1e-9); // 30 / 10
|
||||||
|
});
|
||||||
|
await t.test('calibratePredictedLevel writes level + volume = level × area', () => {
|
||||||
|
ps.calibratePredictedLevel(2.5);
|
||||||
|
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||||
|
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
|
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
|
||||||
|
assert.ok(Math.abs(vol - 25) < 1e-9); // 2.5 × 10
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||||
|
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
let turnOffCalls = 0;
|
||||||
|
ps.machineGroups['mgc1'] = {
|
||||||
|
config: { general: { name: 'mgc1' } },
|
||||||
|
turnOffAllMachines: () => { turnOffCalls++; },
|
||||||
|
handleInput: async () => {},
|
||||||
|
};
|
||||||
|
ps.calibratePredictedLevel(0.5); // below minLevel=1
|
||||||
|
await ps._controlLevelBased();
|
||||||
|
assert.equal(ps.percControl, 0);
|
||||||
|
assert.equal(turnOffCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
ps.percControl = 42; // simulated previous demand
|
||||||
|
ps.machineGroups['mgc1'] = {
|
||||||
|
config: { general: { name: 'mgc1' } },
|
||||||
|
turnOffAllMachines: () => {},
|
||||||
|
handleInput: async () => { throw new Error('should not be called in dead zone'); },
|
||||||
|
};
|
||||||
|
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||||
|
await ps._controlLevelBased();
|
||||||
|
assert.equal(ps.percControl, 42); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
const demands = [];
|
||||||
|
ps.machineGroups['mgc1'] = {
|
||||||
|
config: { general: { name: 'mgc1' } },
|
||||||
|
turnOffAllMachines: () => {},
|
||||||
|
handleInput: async (_src, d) => { demands.push(d); },
|
||||||
|
};
|
||||||
|
ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4
|
||||||
|
await ps._controlLevelBased();
|
||||||
|
// lerp(3, [2,4], [0,100]) = 50
|
||||||
|
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||||
|
assert.equal(demands.length, 1);
|
||||||
|
assert.ok(Math.abs(demands[0] - 50) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
ps.machineGroups['mgc1'] = {
|
||||||
|
config: { general: { name: 'mgc1' } },
|
||||||
|
turnOffAllMachines: () => {},
|
||||||
|
handleInput: async () => {},
|
||||||
|
};
|
||||||
|
ps.calibratePredictedLevel(4.5); // above maxLevel=4
|
||||||
|
await ps._controlLevelBased();
|
||||||
|
assert.ok(ps.percControl >= 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOutput — flattens basin + state + demand', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
ps.percControl = 37;
|
||||||
|
|
||||||
|
await t.test('includes basin geometry fields', () => {
|
||||||
|
const out = ps.getOutput();
|
||||||
|
assert.equal(out.volEmptyBasin, 50);
|
||||||
|
assert.equal(out.maxVolAtOverflow, 45);
|
||||||
|
assert.equal(out.minVolAtInflow, 30);
|
||||||
|
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
|
||||||
|
});
|
||||||
|
await t.test('includes state fields (direction, flowSource, timeleft)', () => {
|
||||||
|
const out = ps.getOutput();
|
||||||
|
assert.ok('direction' in out);
|
||||||
|
assert.ok('flowSource' in out);
|
||||||
|
assert.ok('timeleft' in out);
|
||||||
|
});
|
||||||
|
await t.test('includes percControl', () => {
|
||||||
|
assert.equal(ps.getOutput().percControl, 37);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
|
||||||
|
const ps = new PumpingStation(makeConfig());
|
||||||
|
ps.setManualInflow(0.05, Date.now(), 'm3/s'); // 0.05 m³/s
|
||||||
|
const v = ps.measurements.type('flow').variant('predicted').position('in').child('manual-qin').getCurrentValue('m3/s');
|
||||||
|
assert.ok(Math.abs(v - 0.05) < 1e-9);
|
||||||
|
});
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for pumpingStation specificClass (domain logic).
|
|
||||||
*
|
|
||||||
* The pumpingStation class manages a basin (wet well):
|
|
||||||
* - initBasinProperties: derives surface area, volumes from config
|
|
||||||
* - _calcVolumeFromLevel / _calcLevelFromVolume: linear geometry
|
|
||||||
* - _calcDirection: filling / draining / stable from flow diff
|
|
||||||
* - _callMeasurementHandler: dispatches to type-specific handlers
|
|
||||||
* - getOutput: builds an output snapshot
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PumpingStation = require('../src/specificClass');
|
|
||||||
|
|
||||||
// --------------- helpers ---------------
|
|
||||||
|
|
||||||
function makeConfig(overrides = {}) {
|
|
||||||
const base = {
|
|
||||||
general: {
|
|
||||||
name: 'TestStation',
|
|
||||||
id: 'ps-test-1',
|
|
||||||
unit: 'm3/h',
|
|
||||||
logging: { enabled: false, logLevel: 'error' },
|
|
||||||
},
|
|
||||||
functionality: {
|
|
||||||
softwareType: 'pumpingStation',
|
|
||||||
role: 'stationcontroller',
|
|
||||||
positionVsParent: 'atEquipment',
|
|
||||||
},
|
|
||||||
basin: {
|
|
||||||
volume: 50, // m3 (empty basin volume)
|
|
||||||
height: 5, // m
|
|
||||||
inflowLevel: 0.3, // m
|
|
||||||
outflowLevel: 0.2, // m
|
|
||||||
overflowLevel: 4.0, // m
|
|
||||||
},
|
|
||||||
hydraulics: {
|
|
||||||
refHeight: 'NAP',
|
|
||||||
basinBottomRef: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const key of Object.keys(overrides)) {
|
|
||||||
if (typeof overrides[key] === 'object' && !Array.isArray(overrides[key]) && base[key]) {
|
|
||||||
base[key] = { ...base[key], ...overrides[key] };
|
|
||||||
} else {
|
|
||||||
base[key] = overrides[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------- tests ---------------
|
|
||||||
|
|
||||||
describe('pumpingStation specificClass', () => {
|
|
||||||
|
|
||||||
describe('constructor / initialization', () => {
|
|
||||||
it('should create an instance with the given config', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps).toBeDefined();
|
|
||||||
expect(ps.config.general.name).toBe('teststation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize state object with default values', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps.state).toEqual({ direction: '', netDownstream: 0, netUpstream: 0, seconds: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize empty machines, stations, child, parent objects', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps.machines).toEqual({});
|
|
||||||
expect(ps.stations).toEqual({});
|
|
||||||
expect(ps.child).toEqual({});
|
|
||||||
expect(ps.parent).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initBasinProperties()', () => {
|
|
||||||
it('should calculate surfaceArea = volume / height', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 50 / 5 = 10 m2
|
|
||||||
expect(ps.basin.surfaceArea).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate maxVol = height * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 5 * 10 = 50
|
|
||||||
expect(ps.basin.maxVol).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate maxVolAtOverflow = overflowLevel * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 4.0 * 10 = 40
|
|
||||||
expect(ps.basin.maxVolAtOverflow).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate minVol = outflowLevel * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 0.2 * 10 = 2
|
|
||||||
expect(ps.basin.minVol).toBeCloseTo(2, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate minVolAtOutflow = inflowLevel * surfaceArea', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// 0.3 * 10 = 3
|
|
||||||
expect(ps.basin.minVolAtOutflow).toBeCloseTo(3, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store the raw config values on basin', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
expect(ps.basin.volEmptyBasin).toBe(50);
|
|
||||||
expect(ps.basin.heightBasin).toBe(5);
|
|
||||||
expect(ps.basin.inflowLevel).toBe(0.3);
|
|
||||||
expect(ps.basin.outflowLevel).toBe(0.2);
|
|
||||||
expect(ps.basin.overflowLevel).toBe(4.0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcVolumeFromLevel()', () => {
|
|
||||||
let ps;
|
|
||||||
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
|
|
||||||
|
|
||||||
it('should return level * surfaceArea', () => {
|
|
||||||
// surfaceArea = 10, level = 2 => 20
|
|
||||||
expect(ps._calcVolumeFromLevel(2)).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 for level = 0', () => {
|
|
||||||
expect(ps._calcVolumeFromLevel(0)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp negative levels to 0', () => {
|
|
||||||
expect(ps._calcVolumeFromLevel(-3)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcLevelFromVolume()', () => {
|
|
||||||
let ps;
|
|
||||||
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
|
|
||||||
|
|
||||||
it('should return volume / surfaceArea', () => {
|
|
||||||
// surfaceArea = 10, vol = 20 => 2
|
|
||||||
expect(ps._calcLevelFromVolume(20)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 for volume = 0', () => {
|
|
||||||
expect(ps._calcLevelFromVolume(0)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp negative volumes to 0', () => {
|
|
||||||
expect(ps._calcLevelFromVolume(-10)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('volume/level roundtrip', () => {
|
|
||||||
it('should roundtrip level -> volume -> level', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const level = 2.7;
|
|
||||||
const vol = ps._calcVolumeFromLevel(level);
|
|
||||||
const levelBack = ps._calcLevelFromVolume(vol);
|
|
||||||
expect(levelBack).toBeCloseTo(level, 10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcDirection()', () => {
|
|
||||||
let ps;
|
|
||||||
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
|
|
||||||
|
|
||||||
it('should return "filling" for positive flow above threshold', () => {
|
|
||||||
expect(ps._calcDirection(0.01)).toBe('filling');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "draining" for negative flow below negative threshold', () => {
|
|
||||||
expect(ps._calcDirection(-0.01)).toBe('draining');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "stable" for flow near zero (within threshold)', () => {
|
|
||||||
expect(ps._calcDirection(0.0005)).toBe('stable');
|
|
||||||
expect(ps._calcDirection(-0.0005)).toBe('stable');
|
|
||||||
expect(ps._calcDirection(0)).toBe('stable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_callMeasurementHandler()', () => {
|
|
||||||
it('should not throw for flow and temperature measurement types', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// flow and temperature handlers are empty stubs, safe to call
|
|
||||||
expect(() => ps._callMeasurementHandler('flow', 0.5, 'downstream', {})).not.toThrow();
|
|
||||||
expect(() => ps._callMeasurementHandler('temperature', 15, 'atEquipment', {})).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should dispatch to the correct handler based on measurement type', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// Verify the switch dispatches by checking it does not warn for known types
|
|
||||||
// pressure handler stores values and attempts coolprop calculation
|
|
||||||
// level handler stores values and computes volume
|
|
||||||
// We verify the dispatch logic by calling with type and checking no unhandled error
|
|
||||||
const spy = jest.spyOn(ps, 'updateMeasuredFlow');
|
|
||||||
ps._callMeasurementHandler('flow', 0.5, 'downstream', {});
|
|
||||||
expect(spy).toHaveBeenCalledWith(0.5, 'downstream', {});
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getOutput()', () => {
|
|
||||||
it('should return an object containing state and basin', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const out = ps.getOutput();
|
|
||||||
expect(out).toHaveProperty('state');
|
|
||||||
expect(out).toHaveProperty('basin');
|
|
||||||
expect(out.state).toBe(ps.state);
|
|
||||||
expect(out.basin).toBe(ps.basin);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include measurement keys in the output', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const out = ps.getOutput();
|
|
||||||
// After initialization the predicted volume is set
|
|
||||||
expect(typeof out).toBe('object');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_calcRemainingTime()', () => {
|
|
||||||
it('should not throw when called with a level and variant', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
// Should not throw even with no measurement data; it will just find null diffs
|
|
||||||
expect(() => ps._calcRemainingTime(2, 'predicted')).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('tick()', () => {
|
|
||||||
it('should call _updateVolumePrediction and _calcNetFlow', () => {
|
|
||||||
const ps = new PumpingStation(makeConfig());
|
|
||||||
const spyVol = jest.spyOn(ps, '_updateVolumePrediction');
|
|
||||||
const spyNet = jest.spyOn(ps, '_calcNetFlow');
|
|
||||||
// stub _calcRemainingTime to avoid needing full measurement data
|
|
||||||
ps._calcRemainingTime = jest.fn();
|
|
||||||
ps.tick();
|
|
||||||
expect(spyVol).toHaveBeenCalledWith('out');
|
|
||||||
expect(spyVol).toHaveBeenCalledWith('in');
|
|
||||||
expect(spyNet).toHaveBeenCalled();
|
|
||||||
spyVol.mockRestore();
|
|
||||||
spyNet.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle basin with zero height gracefully', () => {
|
|
||||||
// surfaceArea = volume / height => division by 0 gives Infinity
|
|
||||||
const config = makeConfig({ basin: { volume: 50, height: 0, inflowLevel: 0, outflowLevel: 0, overflowLevel: 0 } });
|
|
||||||
const ps = new PumpingStation(config);
|
|
||||||
expect(ps.basin.surfaceArea).toBe(Infinity);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle basin with very small dimensions', () => {
|
|
||||||
const config = makeConfig({ basin: { volume: 0.001, height: 0.001, inflowLevel: 0, outflowLevel: 0, overflowLevel: 0.0005 } });
|
|
||||||
const ps = new PumpingStation(config);
|
|
||||||
expect(ps.basin.surfaceArea).toBeCloseTo(1, 5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Control modes
|
# 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.
|
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:
|
Every mode page follows the same structure:
|
||||||
|
|
||||||
1. **At a glance** — one sentence + small fact table (inputs, output, status)
|
1. **At a glance** — one sentence + small fact table (inputs, output, status)
|
||||||
2. **Diagram** — reference to `../diagrams/modes/<mode>.drawio.svg`
|
2. **Diagram** — one or more, per tier (see below)
|
||||||
3. **Inputs** — what signals the mode reads
|
3. **Inputs** — what signals the mode reads
|
||||||
4. **Threshold policy** — how it sets/adjusts `minLevel`, `startLevel`, `maxLevel`
|
4. **Threshold policy** — how it uses / adjusts `minLevel`, `startLevel`, `maxLevel`
|
||||||
5. **Demand formula** — how it turns inputs into a 0-100 % demand for the MGC
|
5. **Demand formula** — pseudocode for Tier 1/2, objective function for Tier 3
|
||||||
6. **Edge cases** — cold start, sensor dropout, interaction with safety layer
|
6. **Edge cases** — cold start, sensor dropout, interaction with safety layer
|
||||||
7. **Related** — links to other modes + functional description
|
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
|
## Implementation status
|
||||||
|
|
||||||
| Mode | Status | Page |
|
| Mode | Tier | Status | Page |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `levelbased` | ✅ implemented | [levelbased.md](levelbased.md) |
|
| `levelbased` | 1 | ✅ implemented | [levelbased.md](levelbased.md) |
|
||||||
| `flowbased` | 🚧 placeholder in code | — |
|
| `manual` | 1 | ✅ implemented (via `Qd` topic) | — |
|
||||||
| `pressureBased` | 🚧 placeholder in code | — |
|
| `flowbased` | 2 | 🚧 code placeholder, template | [flowbased.md](flowbased.md) |
|
||||||
| `percentageBased` | 🚧 placeholder in code | — |
|
| `pressureBased` | 2 | 🚧 code placeholder | — |
|
||||||
| `powerBased` | 🚧 placeholder in code | — |
|
| `percentageBased` | 2 | 🚧 code placeholder | — |
|
||||||
| `hybrid` | 🚧 placeholder in code | — |
|
| `powerBased` | 2 | 🚧 code placeholder, template | [powerbased.md](powerbased.md) |
|
||||||
| `manual` | ✅ implemented (Qd topic) | — |
|
| `hybrid` | 3 | 🚧 code placeholder | — |
|
||||||
|
| `mpc` | 3 | 🚧 not in code yet, template | [mpc.md](mpc.md) |
|
||||||
|
|||||||
83
wiki/modes/flowbased.md
Normal file
83
wiki/modes/flowbased.md
Normal file
@@ -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
|
||||||
149
wiki/modes/mpc.md
Normal file
149
wiki/modes/mpc.md
Normal file
@@ -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
|
||||||
83
wiki/modes/powerbased.md
Normal file
83
wiki/modes/powerbased.md
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user