Files
machineGroupControl/test/integration/demand-cycle-walkthrough.integration.test.js

212 lines
9.2 KiB
JavaScript
Raw Normal View History

// 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).`);
});