Pumping-station demo overhaul + cross-node test harness + bumps
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:
Rene De Ren
2026-05-08 11:21:21 +02:00
parent ca0644d689
commit 0cab98c196
21 changed files with 5863 additions and 2745 deletions

116
test/lib/recorder.js Normal file
View 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
View 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,
};