Each fixture's machineConfig() now passes asset: { model, unit } only —
the supplier / category / type strings are derived at runtime via
assetResolver in rotatingMachine's _setupCurves. Six integration tests
updated. No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
9.2 KiB
JavaScript
212 lines
9.2 KiB
JavaScript
// 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: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||
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).`);
|
||
});
|