// 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." 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, };