Pumping-station demo overhaul + cross-node test harness + bumps
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
Submodule bumps land the deadlock fix (state.js residue unpark + MGC optimalControl dispatch reorder) and pumpingStation stopLevel hysteresis. - Renames examples/pumpingstation-3pumps-dashboard → pumpingstation-complete-example with regenerated flow.json. New dashboard groups, demand-broadcast wiring, S88 placement rule applied, ui-chart trend-split and link-channel naming follow .claude/rules/node-red-flow-layout.md. - New cross-node test harness under test/: end-to-end-pumpingstation drives PS + MGC + 3 pumps + physics simulator end-to-end and verifies the ~5/15 min cycle. - Adds Grafana provisioning dashboards (pumping-station.json) and a helper sync-example.sh script for export/import to live Node-RED. - Docker entrypoint + settings + compose tweaks for the persistent user dir layout used by the demo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
test/README.md
Normal file
30
test/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# EVOLV cross-node test harness
|
||||
|
||||
This folder hosts end-to-end tests that wire **multiple** EVOLV domain
|
||||
classes together the same way Node-RED would, but in pure Node.js so the
|
||||
simulation runs deterministically and every internal value is inspectable.
|
||||
|
||||
**Scope rule.** Tests that exercise a single node's behaviour live in that
|
||||
node's submodule under `nodes/<name>/test/`. Tests here cross node
|
||||
boundaries — they instantiate `pumpingStation` + `machineGroupControl` +
|
||||
multiple `rotatingMachine`s together and drive the wired graph.
|
||||
|
||||
Examples of what belongs where:
|
||||
|
||||
| Concern | Lives in |
|
||||
|---|---|
|
||||
| MGC optimizer combination choice for a given demand | `nodes/machineGroupControl/test/integration/optimizer-combination-choice.integration.test.js` |
|
||||
| Pump curve interpolation across head values | `nodes/rotatingMachine/test/integration/...` |
|
||||
| PS hysteresis logic with mocked groups | `nodes/pumpingStation/test/integration/shifted-ramp-end-to-end.test.js` |
|
||||
| **Whole plant**: PS basin level + MGC dispatch + 3 pumps + physics simulator | `test/end-to-end-pumpingstation.test.js` (this folder) |
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
node --test test/end-to-end-pumpingstation.test.js
|
||||
```
|
||||
|
||||
The harness in `lib/wiring.js` builds the parent-child relationships
|
||||
Node-RED would build via `registerChild`, lets you advance a controllable
|
||||
clock, and `lib/recorder.js` records every measurement / state / demand
|
||||
event into a flat trace.
|
||||
192
test/end-to-end-pumpingstation.test.js
Normal file
192
test/end-to-end-pumpingstation.test.js
Normal file
@@ -0,0 +1,192 @@
|
||||
// End-to-end test: PS + MGC + 3 pumps wired exactly like the
|
||||
// pumpingstation-complete-example demo, driven by a controllable clock.
|
||||
//
|
||||
// Verifies:
|
||||
// 1. Basin starts low (below stopLevel) — pumps OFF.
|
||||
// 2. Basin fills to startLevel — first pump engages.
|
||||
// 3. Basin drains through the dead band [stopLevel, startLevel] —
|
||||
// pump stays engaged at minimum flow.
|
||||
// 4. Basin reaches stopLevel — pump disengages, basin refills.
|
||||
// 5. Storm inflow → all 3 pumps engage at high flow.
|
||||
|
||||
const test = require('node:test');
|
||||
const { buildPlant, injectPumpPressure } = require('./lib/wiring');
|
||||
const { attachRecorder, snapshotFull, snapshotMachineState } = require('./lib/recorder');
|
||||
|
||||
const TICK_MS = 1000;
|
||||
const STATIC_HEAD_M = 12;
|
||||
const RHO_G = 9810;
|
||||
const DYN_HEAD_M_AT_FULL_FLOW = 12;
|
||||
const TOTAL_FLOW_MAX_M3H = 300;
|
||||
const OUTFLOW_LEVEL_M = 0.3;
|
||||
|
||||
function physics({ basinLevelM, totalPumpFlow_m3h }) {
|
||||
const headM = Math.max(0, basinLevelM - OUTFLOW_LEVEL_M);
|
||||
const upstreamPa = RHO_G * headM;
|
||||
const ratio = Math.min(1, totalPumpFlow_m3h / TOTAL_FLOW_MAX_M3H);
|
||||
const downstreamPa = RHO_G * (STATIC_HEAD_M + ratio * ratio * DYN_HEAD_M_AT_FULL_FLOW);
|
||||
return { upstreamPa, downstreamPa };
|
||||
}
|
||||
|
||||
function totalPumpFlow_m3h(pumps) {
|
||||
let s = 0;
|
||||
for (const p of pumps) {
|
||||
const f = p.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h') || 0;
|
||||
s += Number(f);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
async function tick(plant, { qIn_m3s }) {
|
||||
const { ps, pumps, advance } = plant;
|
||||
const basinLevelM = ps.measurements.type('level').variant('predicted')
|
||||
.position('atequipment').getCurrentValue('m') ?? 0;
|
||||
const tot = totalPumpFlow_m3h(pumps);
|
||||
const { upstreamPa, downstreamPa } = physics({ basinLevelM, totalPumpFlow_m3h: tot });
|
||||
for (const p of pumps) injectPumpPressure(p, upstreamPa, downstreamPa);
|
||||
ps.setManualInflow(qIn_m3s, Date.now(), 'm3/s');
|
||||
advance(TICK_MS);
|
||||
ps.tick();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
|
||||
test('PS + MGC + 3 pumps — full hysteresis cycle (5/15 nominal)', async () => {
|
||||
// Start at 2.4 m — just below startLevel(2.5) — so we see the rising
|
||||
// edge in a few minutes instead of 30. Then observe the full cycle.
|
||||
const plant = buildPlant({ initialBasinLevel: 2.4 });
|
||||
const rec = attachRecorder(plant);
|
||||
const { ps, mgc, pumps, restore } = plant;
|
||||
|
||||
try {
|
||||
console.log('\n=========================================================');
|
||||
console.log(' POST-WIRING SNAPSHOT');
|
||||
console.log('=========================================================');
|
||||
const initSnap = snapshotFull(ps, mgc, pumps);
|
||||
console.log(JSON.stringify(initSnap, null, 2));
|
||||
console.log('\nMGC absoluteTotals (m³/h):',
|
||||
`min=${(mgc.absoluteTotals.flow.min*3600).toFixed(0)}, max=${(mgc.absoluteTotals.flow.max*3600).toFixed(0)}`);
|
||||
console.log('MGC dynamicTotals (m³/h):',
|
||||
`min=${(mgc.dynamicTotals.flow.min*3600).toFixed(0)}, max=${(mgc.dynamicTotals.flow.max*3600).toFixed(0)}`);
|
||||
|
||||
// Phase 1: nominal inflow ≈ 25 m³/h → expect cycle ~5 on / ~15 off.
|
||||
const NOMINAL_QIN = 25 / 3600; // m³/s
|
||||
console.log('\n=========================================================');
|
||||
console.log(' PHASE 1: nominal inflow 25 m³/h — observe one full cycle.');
|
||||
console.log(' Expected: basin rises from 1.5 m to 2.5 m (off, ~?? min), pump kicks on, drains to 2.0 m (on, ~5 min), repeats.');
|
||||
console.log('=========================================================');
|
||||
|
||||
const phase1Trace = [];
|
||||
let firstEngageTick = null;
|
||||
let firstDisengageTick = null;
|
||||
let secondEngageTick = null;
|
||||
for (let i = 0; i < 1800; i++) { // 30 min sim
|
||||
await tick(plant, { qIn_m3s: NOMINAL_QIN });
|
||||
const snap = snapshotFull(ps, mgc, pumps);
|
||||
const tickIdx = i + 1;
|
||||
phase1Trace.push({ s: tickIdx, ...snap });
|
||||
const anyEngaged = pumps.some(p =>
|
||||
['operational', 'starting', 'warmingup', 'accelerating'].includes(p.state.getCurrentState())
|
||||
);
|
||||
if (anyEngaged && firstEngageTick == null) firstEngageTick = tickIdx;
|
||||
if (firstEngageTick != null && firstDisengageTick == null && !anyEngaged) firstDisengageTick = tickIdx;
|
||||
if (firstDisengageTick != null && secondEngageTick == null && anyEngaged) secondEngageTick = tickIdx;
|
||||
// Stop after we observe a full off→on→off→on cycle so we can measure both phases.
|
||||
if (secondEngageTick != null && tickIdx > secondEngageTick + 60) break;
|
||||
}
|
||||
printCompactTrace(decimateTrace(phase1Trace, 30));
|
||||
|
||||
console.log('\n-- cycle landmarks --');
|
||||
console.log(`First pump engage : tick ${firstEngageTick} (level=${phase1Trace[firstEngageTick - 1]?.psLevel})`);
|
||||
console.log(`First pump disengage: tick ${firstDisengageTick} (level=${phase1Trace[firstDisengageTick - 1]?.psLevel})`);
|
||||
console.log(`Second engage : tick ${secondEngageTick} (level=${phase1Trace[secondEngageTick - 1]?.psLevel})`);
|
||||
if (firstEngageTick && firstDisengageTick) {
|
||||
const onMin = (firstDisengageTick - firstEngageTick) / 60;
|
||||
console.log(`On phase duration : ${onMin.toFixed(1)} min (target ≈ 5 min)`);
|
||||
}
|
||||
if (firstDisengageTick && secondEngageTick) {
|
||||
const offMin = (secondEngageTick - firstDisengageTick) / 60;
|
||||
console.log(`Off phase duration : ${offMin.toFixed(1)} min (target ≈ 15 min)`);
|
||||
}
|
||||
|
||||
// Phase 2: storm inflow → all 3 pumps should engage.
|
||||
console.log('\n=========================================================');
|
||||
console.log(' PHASE 2: storm inflow 250 m³/h — expect all 3 pumps engaged.');
|
||||
console.log('=========================================================');
|
||||
const STORM_QIN = 250 / 3600;
|
||||
const phase2Trace = [];
|
||||
for (let i = 0; i < 600; i++) { // 10 min storm
|
||||
await tick(plant, { qIn_m3s: STORM_QIN });
|
||||
const snap = snapshotFull(ps, mgc, pumps);
|
||||
phase2Trace.push({ s: phase1Trace.length + i + 1, ...snap });
|
||||
}
|
||||
printCompactTrace(decimateTrace(phase2Trace, 30));
|
||||
|
||||
const peak = phase2Trace.reduce((acc, s) => {
|
||||
const running = Object.values(s.pumps).filter(p =>
|
||||
['operational', 'accelerating', 'warmingup', 'starting'].includes(p.state)
|
||||
).length;
|
||||
return Math.max(acc, running);
|
||||
}, 0);
|
||||
console.log(`\nPeak concurrent running pumps during storm: ${peak} / 3`);
|
||||
const maxLvl = phase2Trace.reduce((acc, s) => Math.max(acc, s.psLevel ?? 0), 0);
|
||||
console.log(`Max basin level during storm: ${maxLvl.toFixed(2)} m`);
|
||||
|
||||
// Phase 3: inflow drops back to nominal — expect graceful unwind.
|
||||
console.log('\n=========================================================');
|
||||
console.log(' PHASE 3: storm subsides → 25 m³/h. Expect graceful unwind.');
|
||||
console.log('=========================================================');
|
||||
const phase3Trace = [];
|
||||
for (let i = 0; i < 900; i++) {
|
||||
await tick(plant, { qIn_m3s: NOMINAL_QIN });
|
||||
const snap = snapshotFull(ps, mgc, pumps);
|
||||
phase3Trace.push({ s: phase1Trace.length + phase2Trace.length + i + 1, ...snap });
|
||||
const anyEngaged = pumps.some(p =>
|
||||
['operational', 'starting'].includes(p.state.getCurrentState())
|
||||
);
|
||||
if (!anyEngaged) break;
|
||||
}
|
||||
printCompactTrace(decimateTrace(phase3Trace, 30));
|
||||
|
||||
// Diagnostics summary.
|
||||
console.log('\n=========================================================');
|
||||
console.log(' SUMMARY');
|
||||
console.log('=========================================================');
|
||||
const ctrlAnomalies = phase1Trace.filter(s =>
|
||||
Object.values(s.pumps).some(p =>
|
||||
p.state === 'operational' && (p.ctrl_pct === 0 || p.ctrl_pct == null) && p.flow_m3h > 1
|
||||
)
|
||||
).length;
|
||||
console.log(`Bug 3 leftover (ctrl=0 while operational delivering flow): ${ctrlAnomalies} ticks`);
|
||||
const optimalEvents = rec.events.filter(e => e.kind === 'mgc.optimalControl.out' && e.Qd > 0);
|
||||
console.log(`MGC optimalControl invocations with Qd>0: ${optimalEvents.length}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Reduce noise by sampling every Nth tick + always include first/last.
|
||||
function decimateTrace(rows, step) {
|
||||
if (rows.length <= step * 2) return rows;
|
||||
const out = [rows[0]];
|
||||
for (let i = step; i < rows.length - 1; i += step) out.push(rows[i]);
|
||||
out.push(rows[rows.length - 1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function printCompactTrace(rows) {
|
||||
if (rows.length === 0) { console.log('(empty)'); return; }
|
||||
console.log(' s level vol dir pct d_min d_max pumpA pumpB pumpC');
|
||||
console.log(' ─ ───── ───── ──────── ─────── ───── ───── ─────────────── ─────────────── ───────────────');
|
||||
for (const r of rows) {
|
||||
const fmtPump = (p) => {
|
||||
if (!p) return ''.padEnd(15);
|
||||
return `${(p.state ?? '?').slice(0,8).padEnd(8)} c${(p.ctrl_pct ?? 0).toFixed(0).padStart(3)} f${(p.flow_m3h ?? 0).toFixed(0).padStart(3)}`.padEnd(15);
|
||||
};
|
||||
const a = fmtPump(r.pumps.pump_a);
|
||||
const b = fmtPump(r.pumps.pump_b);
|
||||
const c = fmtPump(r.pumps.pump_c);
|
||||
console.log(
|
||||
`${String(r.s).padStart(4)} ${(r.psLevel ?? 0).toFixed(3)} ${(r.psVolume ?? 0).toFixed(2).padStart(5)} ${(r.psDirection ?? '?').padEnd(8)} ${(r.psPercControl ?? 0).toFixed(2).padStart(7)} ${(r.mgc?.dynamicMin_m3h ?? 0).toFixed(0).padStart(5)} ${(r.mgc?.dynamicMax_m3h ?? 0).toFixed(0).padStart(5)} ${a} ${b} ${c}`
|
||||
);
|
||||
}
|
||||
}
|
||||
116
test/lib/recorder.js
Normal file
116
test/lib/recorder.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// Trace recorder — hooks into every emitter and timer-driven path on a
|
||||
// wired plant and records ALL events into a flat list with timestamps.
|
||||
//
|
||||
// Captures:
|
||||
// - Per-pump state transitions (state.emitter on 'state-change' or via
|
||||
// polling getCurrentState() before/after each tick).
|
||||
// - Per-pump pressure events (measurements.emitter on
|
||||
// 'pressure.measured.{upstream,downstream,differential}').
|
||||
// - Per-pump flow / power / ctrl events (predicted variants).
|
||||
// - MGC dynamic totals (after each calcDynamicTotals).
|
||||
// - PS percControl + level + volume + safetyState (after each tick).
|
||||
// - MGC bestCombination (instrument by wrapping optimalControl).
|
||||
// - Pump operating points: individual predictFlow.currentF and
|
||||
// groupPredictFlow.currentF (per tick, post-equalization).
|
||||
|
||||
const POSITIONS = ['upstream', 'downstream', 'differential'];
|
||||
|
||||
function attachRecorder({ ps, mgc, pumps }) {
|
||||
const events = [];
|
||||
const push = (kind, data) => events.push({ t: Date.now(), kind, ...data });
|
||||
|
||||
// --- pump-level: pressure events ---
|
||||
for (const pump of pumps) {
|
||||
const id = pump.config.general.id;
|
||||
for (const pos of POSITIONS) {
|
||||
const ev = `pressure.measured.${pos}`;
|
||||
pump.measurements.emitter.on(ev, (e) => push('pump.pressure', {
|
||||
pump: id, pos, value: e?.value, unit: e?.unit,
|
||||
}));
|
||||
}
|
||||
// flow / power predicted (rotatingMachine emits these on state changes
|
||||
// and movement updates).
|
||||
pump.measurements.emitter.on('flow.predicted.downstream', (e) => push('pump.flow.predicted', {
|
||||
pump: id, value: e?.value, unit: e?.unit,
|
||||
}));
|
||||
pump.measurements.emitter.on('power.predicted.atequipment', (e) => push('pump.power.predicted', {
|
||||
pump: id, value: e?.value, unit: e?.unit,
|
||||
}));
|
||||
pump.measurements.emitter.on('ctrl.predicted.atequipment', (e) => push('pump.ctrl.predicted', {
|
||||
pump: id, value: e?.value, unit: e?.unit,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- MGC bestCombination: wrap optimalControl ---
|
||||
const origOptimal = mgc.optimalControl.bind(mgc);
|
||||
mgc.optimalControl = async function (Qd, powerCap = Infinity) {
|
||||
push('mgc.optimalControl.in', { Qd, powerCap });
|
||||
const before = snapshotMachineState(pumps);
|
||||
const result = await origOptimal(Qd, powerCap);
|
||||
const after = snapshotMachineState(pumps);
|
||||
push('mgc.optimalControl.out', {
|
||||
Qd,
|
||||
headerDiffPa: pumps[0]?.groupPredictFlow?.currentF,
|
||||
indivDiffPaPerPump: Object.fromEntries(pumps.map(p => [p.config.general.id, p.predictFlow?.currentF])),
|
||||
groupDiffPaPerPump: Object.fromEntries(pumps.map(p => [p.config.general.id, p.groupPredictFlow?.currentF])),
|
||||
// capture state before/after to spot transitions caused by this optimal
|
||||
stateBefore: before, stateAfter: after,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
return { events, push };
|
||||
}
|
||||
|
||||
function snapshotMachineState(pumps) {
|
||||
return Object.fromEntries(pumps.map(p => [
|
||||
p.config.general.id,
|
||||
p.state?.getCurrentState?.() ?? '?'
|
||||
]));
|
||||
}
|
||||
|
||||
function snapshotFull(ps, mgc, pumps) {
|
||||
const level = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||||
const volume = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
return {
|
||||
psLevel: round3(level),
|
||||
psVolume: round3(volume),
|
||||
psPercControl: round3(ps.percControl),
|
||||
psSafety: ps.safetyControllerActive,
|
||||
psDirection: ps.state?.direction,
|
||||
psNetFlow_m3h: round3((ps.state?.netFlow ?? 0) * 3600),
|
||||
pumps: Object.fromEntries(pumps.map(p => {
|
||||
const id = p.config.general.id;
|
||||
const flowPred = p.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue('m3/h');
|
||||
const powerPred = p.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW');
|
||||
const ctrlPred = p.measurements.type('ctrl').variant('predicted').position('atEquipment').getCurrentValue();
|
||||
const upPred = p.measurements.type('pressure').variant('measured').position('upstream').getCurrentValue('mbar');
|
||||
const dnPred = p.measurements.type('pressure').variant('measured').position('downstream').getCurrentValue('mbar');
|
||||
return [id, {
|
||||
state: p.state?.getCurrentState?.(),
|
||||
ctrl_pct: round3(ctrlPred),
|
||||
flow_m3h: round3(flowPred),
|
||||
power_kW: round3(powerPred),
|
||||
pUp_mbar: round3(upPred),
|
||||
pDn_mbar: round3(dnPred),
|
||||
indivDiff_mbar: round3((p.predictFlow?.currentF ?? 0) / 100),
|
||||
groupDiff_mbar: round3((p.groupPredictFlow?.currentF ?? 0) / 100),
|
||||
NCog: round3(p.NCog),
|
||||
groupNCog: round3(p.groupNCog),
|
||||
}];
|
||||
})),
|
||||
mgc: {
|
||||
scaling: mgc.scaling,
|
||||
mode: mgc.mode,
|
||||
dynamicMin_m3h: round3((mgc.dynamicTotals?.flow?.min ?? 0) * 3600),
|
||||
dynamicMax_m3h: round3((mgc.dynamicTotals?.flow?.max ?? 0) * 3600),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function round3(v) {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return v;
|
||||
return Math.round(v * 1000) / 1000;
|
||||
}
|
||||
|
||||
module.exports = { attachRecorder, snapshotFull, snapshotMachineState };
|
||||
152
test/lib/wiring.js
Normal file
152
test/lib/wiring.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// Wiring helpers for cross-node end-to-end tests.
|
||||
//
|
||||
// Builds a small physical plant in pure JS:
|
||||
// - 3 rotatingMachine pumps (centrifugal, identical curves)
|
||||
// - 1 machineGroupControl coordinating them
|
||||
// - 1 pumpingStation owning a wet-well basin and the MGC
|
||||
//
|
||||
// Pumps register as children of the MGC. The MGC registers as a child of
|
||||
// the PS. This mirrors what Node-RED's registerChild messages do at runtime.
|
||||
//
|
||||
// A controllable clock replaces Date.now so _updatePredictedVolume's deltaT
|
||||
// is exact regardless of wall-clock time.
|
||||
|
||||
const PumpingStation = require('../../nodes/pumpingStation/src/specificClass');
|
||||
const MachineGroup = require('../../nodes/machineGroupControl/src/specificClass');
|
||||
const Machine = require('../../nodes/rotatingMachine/src/specificClass');
|
||||
|
||||
// ---------------- configs (mirror what the demo flow ships) ----------------
|
||||
|
||||
function pumpConfig(id) {
|
||||
return {
|
||||
general: { id, name: id, unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller',
|
||||
positionVsParent: 'atEquipment' },
|
||||
asset: { category: 'pump', type: 'centrifugal',
|
||||
model: 'hidrostal-H05K-S03R', supplier: 'hidrostal',
|
||||
curveUnits: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' } },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
allowedSources: { auto: ['parent', 'GUI'] },
|
||||
},
|
||||
sequences: {
|
||||
startup: ['starting', 'warmingup', 'operational'],
|
||||
shutdown: ['stopping', 'coolingdown', 'idle'],
|
||||
emergencystop: ['emergencystop', 'off'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pumpStateConfig() {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' } },
|
||||
state: { current: 'idle' },
|
||||
movement: { mode: 'staticspeed', speed: 1200, maxSpeed: 1800, interval: 10 },
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function mgcConfig() {
|
||||
return {
|
||||
general: { name: 'mgc', id: 'mgc', logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller',
|
||||
positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
function psConfig(overrides = {}) {
|
||||
return {
|
||||
general: { id: 'ps', name: 'ps', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' },
|
||||
flowThreshold: 1e-4 },
|
||||
functionality: { softwareType: 'pumpingstation', role: 'stationcontroller',
|
||||
positionVsParent: 'atEquipment' },
|
||||
basin: {
|
||||
// Sized so the [stopLevel,startLevel] band holds enough water that
|
||||
// a single pump at min flow (~99 m³/h) drains for ~5 min while
|
||||
// nominal inflow (~25 m³/h) refills it in ~15 min.
|
||||
// 0.5 m × 12.5 m² = 6.25 m³ (drain time = 6.25 / (99-25) m³/h ≈ 5 min)
|
||||
volume: 50, height: 4,
|
||||
inflowLevel: 2.5, outflowLevel: 0.3, overflowLevel: 3.8,
|
||||
inletPipeDiameter: 0.4, outletPipeDiameter: 0.3,
|
||||
},
|
||||
hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: {
|
||||
minLevel: 0.5, startLevel: 2.5, stopLevel: 2.0, maxLevel: 3.5,
|
||||
curveType: 'linear', logCurveFactor: 9,
|
||||
deadZoneKeepAlivePercent: 1, // % sent to MGC while engaged in [stopLvl, startLevel]
|
||||
enableShiftedRamp: false, shiftLevel: null, shiftArmPercent: 95,
|
||||
},
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: true, enableOverfillProtection: true,
|
||||
dryRunThresholdPercent: 5, highVolumeSafetyThresholdPercent: 95,
|
||||
overfillThresholdPercent: 95, timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- harness ----------------
|
||||
|
||||
function buildPlant({ initialBasinLevel = 2.0 } = {}) {
|
||||
const ps = new PumpingStation(psConfig());
|
||||
const mgc = new MachineGroup(mgcConfig());
|
||||
const pumps = ['pump_a', 'pump_b', 'pump_c'].map(id => new Machine(pumpConfig(id), pumpStateConfig()));
|
||||
|
||||
// Inject initial pressure on each pump so predictFlow / predictPower /
|
||||
// predictCtrl have a real fDimension before MGC starts asking. Real
|
||||
// values are set every tick by the physics step.
|
||||
for (const m of pumps) injectPumpPressure(m, /* upstreamPa */ 19620, /* downstreamPa */ 117720);
|
||||
|
||||
// Wire pumps → MGC.
|
||||
for (const m of pumps) mgc.childRegistrationUtils.registerChild(m, m.config.functionality.positionVsParent);
|
||||
// Wire MGC → PS.
|
||||
ps.childRegistrationUtils.registerChild(mgc, mgc.config.functionality.positionVsParent);
|
||||
|
||||
mgc.calcAbsoluteTotals();
|
||||
mgc.calcDynamicTotals();
|
||||
|
||||
// Calibrate basin level to start point.
|
||||
ps.calibratePredictedLevel(initialBasinLevel);
|
||||
|
||||
// Controllable clock — overrides Date.now ONLY for our process.
|
||||
let now = Date.now();
|
||||
const realNow = Date.now;
|
||||
Date.now = () => now;
|
||||
ps._predictedFlowState.lastTimestamp = now;
|
||||
|
||||
function advance(ms) { now += ms; }
|
||||
function restore() { Date.now = realNow; }
|
||||
|
||||
return { ps, mgc, pumps, advance, restore, get now() { return now; } };
|
||||
}
|
||||
|
||||
// Convert mbar to Pa for the rotatingMachine canonical pressure unit.
|
||||
function mbarToPa(mbar) { return mbar * 100; }
|
||||
function paToMbar(Pa) { return Pa / 100; }
|
||||
|
||||
// Inject upstream + downstream pressure measurements onto a pump as if a
|
||||
// pressure-sensor child had emitted them. updateMeasuredPressure is the
|
||||
// same path the rotatingMachine listens on for sensor children, so this
|
||||
// fires the pump's "pressure.measured.<position>" emitter — which the MGC
|
||||
// is also subscribed to, so totals recompute identically.
|
||||
function injectPumpPressure(pump, upstreamPa, downstreamPa, ts = Date.now()) {
|
||||
pump.updateMeasuredPressure(paToMbar(upstreamPa), 'upstream',
|
||||
{ timestamp: ts, unit: 'mbar', childName: 'PT-up', childId: `up-${pump.config.general.id}` });
|
||||
pump.updateMeasuredPressure(paToMbar(downstreamPa), 'downstream',
|
||||
{ timestamp: ts, unit: 'mbar', childName: 'PT-dn', childId: `dn-${pump.config.general.id}` });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildPlant,
|
||||
injectPumpPressure,
|
||||
mbarToPa, paToMbar,
|
||||
};
|
||||
Reference in New Issue
Block a user