// MGC demand-cycle walkthrough — drive the machine group through a // configurable demand sweep and print a clean per-step snapshot of every // pump's state, ctrl%, flow and power. This is a diagnostic test, not a // strict invariant guard: it asserts only the basics (no stuck states, // total flow tracks demand) and prints a readable table for visual // inspection. // // Knobs (env vars): // STEP_PERCENT — demand step in percent (default 10) // DWELL_MS — wait per step for movement (default 800) // HEAD_MBAR — pump head in mbar (default 1100) // N_PUMPS — number of identical pumps (default 3) // LOG_DEBUG=1 — enable verbose domain logging (default off) // // Run: // node --test nodes/machineGroupControl/test/integration/demand-cycle-walkthrough.integration.test.js // STEP_PERCENT=5 DWELL_MS=400 node --test ... // LOG_DEBUG=1 node --test ... # firehose mode const test = require('node:test'); const assert = require('node:assert/strict'); const MachineGroup = require('../../src/specificClass'); const Machine = require('../../../rotatingMachine/src/specificClass'); const STEP_PERCENT = parseFloat(process.env.STEP_PERCENT || '10'); const DWELL_MS = parseInt(process.env.DWELL_MS || '800', 10); const HEAD_MBAR = parseFloat(process.env.HEAD_MBAR || '1100'); const N_PUMPS = parseInt(process.env.N_PUMPS || '3', 10); const LOG_DEBUG = process.env.LOG_DEBUG === '1'; const HEAD_MBAR_UP = 0; const HEAD_MBAR_DOWN = HEAD_MBAR; const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' }; const stateConfig = { general: { logging: logCfg }, state: { current: 'idle' }, // Fast ramp so each step settles within DWELL_MS. movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 }, // Zero sequence-step durations — startup/shutdown are instantaneous so // the per-step delta is purely the optimizer's response, not waiting // for the FSM. time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 }, }; function machineConfig(id) { return { general: { logging: logCfg, name: id, id, unit: 'm3/h' }, functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' }, 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 groupConfig() { return { general: { logging: logCfg, name: 'mgc', id: 'mgc' }, functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' }, scaling: { current: 'normalized' }, // demand expressed as 0..100 % mode: { current: 'optimalcontrol' }, // production mode }; } function buildGroup() { const mgc = new MachineGroup(groupConfig()); const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`); const pumps = ids.map(id => new Machine(machineConfig(id), stateConfig)); for (const m of pumps) { m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` }); m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` }); mgc.childRegistrationUtils.registerChild(m, 'downstream'); } mgc.calcAbsoluteTotals(); mgc.calcDynamicTotals(); return { mgc, pumps }; } const sleep = (ms) => new Promise(r => setTimeout(r, ms)); // States where the pump is not actually producing flow/power. When the FSM // is parked in any of these, predictFlow.outputY / predictPower.outputY // still reflect the curve floor at the current operating point — that is // useful for the optimizer but misleading in this walkthrough table. Show // zeros instead so each row's per-pump column matches the optimizer's // chosen split and ΣQ matches Qd. const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']); function snapshot(pump) { const state = pump.state.getCurrentState(); const ctrl = Number(pump.state.getCurrentPosition?.() ?? 0); const running = !NON_RUNNING.has(state); const flow = running ? Number(pump.predictFlow?.outputY ?? 0) * 3600 : 0; // m³/s → m³/h const power = running ? Number(pump.predictPower?.outputY ?? 0) / 1000 : 0; // W → kW return { state, ctrl, flow, power }; } function fmt(x, w, d = 1) { return Number.isFinite(x) ? x.toFixed(d).padStart(w) : ' n/a'.padStart(w); } function printHeader(pumps) { const head = ['cmd%'.padStart(5), 'Qd m³/h'.padStart(9)]; for (const p of pumps) { head.push('|', `${p.config.general.id}`.padEnd(8), 'state'.padEnd(13), 'ctrl%'.padStart(6), 'Q m³/h'.padStart(7), 'kW'.padStart(6)); } head.push('|', 'ΣQ m³/h'.padStart(8), 'ΣkW'.padStart(6)); const line = head.join(' '); console.log(line); console.log('─'.repeat(line.length)); } function printRow(pct, demandQout_m3h, pumps) { const snaps = pumps.map(snapshot); const totalQ = snaps.reduce((s, x) => s + x.flow, 0); const totalP = snaps.reduce((s, x) => s + x.power, 0); const cells = [fmt(pct, 5), fmt(demandQout_m3h, 9)]; for (let i = 0; i < pumps.length; i++) { const s = snaps[i]; cells.push('|', ''.padEnd(8), s.state.padEnd(13), fmt(s.ctrl, 6), fmt(s.flow, 7), fmt(s.power, 6)); } cells.push('|', fmt(totalQ, 8), fmt(totalP, 6)); console.log(cells.join(' ')); return { totalQ, totalP, snaps }; } test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps, step=${STEP_PERCENT}%`, async () => { const { mgc, pumps } = buildGroup(); // Bring all pumps to operational up-front so the very first row of the // table reflects the optimizer's response, not "the FSM is still // booting". for (const m of pumps) await m.handleInput('parent', 'execsequence', 'startup'); for (let i = 0; i < 50 && pumps.some(p => p.state.getCurrentState() !== 'operational'); i++) await sleep(20); for (const p of pumps) { assert.equal(p.state.getCurrentState(), 'operational', `pre-condition: pump ${p.config.general.id} should be operational; got ${p.state.getCurrentState()}`); } const dyn = mgc.calcDynamicTotals(); const flowMin_m3h = dyn.flow.min * 3600; const flowMax_m3h = dyn.flow.max * 3600; const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow; const perPumpMin_m3h = sample.currentFxyYMin * 3600; const perPumpMax_m3h = sample.currentFxyYMax * 3600; console.log(''); console.log(`MGC station envelope at head ${HEAD_MBAR} mbar (${N_PUMPS} pumps):`); console.log(` per-pump: ${perPumpMin_m3h.toFixed(1)} .. ${perPumpMax_m3h.toFixed(1)} m³/h`); console.log(` station: ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`); console.log(` scaling=normalized: 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`); console.log(` (demand ≤ 0% turns ALL pumps off — see MGC handleInput)`); console.log(''); printHeader(pumps); // Build demand sweep: 0..100% up, then 100..0% down. const upSteps = []; for (let pct = 0; pct <= 100 + 1e-9; pct += STEP_PERCENT) upSteps.push(Math.min(pct, 100)); const downSteps = upSteps.slice(0, -1).reverse(); // skip the duplicate 100 const sequence = [...upSteps, ...downSteps]; let stuckSeen = 0; for (const pct of sequence) { await mgc.handleInput('parent', pct); await sleep(DWELL_MS); // Mirror MGC's normalized→absolute mapping for the printed Qd column. const demandQout_m3h = pct <= 0 ? 0 : (flowMax_m3h - flowMin_m3h) * (pct / 100) + flowMin_m3h; const { totalQ, snaps } = printRow(pct, demandQout_m3h, pumps); // Loose invariants: // - demand > 0% → station total flow within 10% of optimizer's chosen // Qout (allow slack: optimizer may pick a smaller combo for // efficiency, in which case totalQ falls below demand only inside // the per-pump curve envelope; we ONLY check above feasibility). // - no pump should sit in a residue state ('accelerating' / // 'decelerating') AFTER the dwell — that's the deadlock symptom // the abort-deadlock test guards against. for (const s of snaps) { if (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1; } if (pct === 0) { // Demand 0% must turn ALL pumps off (or to a non-running state). for (const s of snaps) { assert.ok(['idle', 'off', 'stopping', 'coolingdown'].includes(s.state), `demand 0% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`); } } } console.log(''); console.log(`Stuck-state observations across ${sequence.length} steps: ${stuckSeen}`); assert.equal(stuckSeen, 0, `${stuckSeen} pump×step observations parked in accelerating/decelerating after dwell — ` + `would indicate the abort-deadlock regression has returned (state.js post-abort residue).`); });