Files
EVOLV/test/lib/wiring.js
Rene De Ren 0cab98c196
Some checks failed
CI / lint-and-test (push) Has been cancelled
Pumping-station demo overhaul + cross-node test harness + bumps
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>
2026-05-08 11:21:21 +02:00

153 lines
6.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};