Files
machineGroupControl/test/integration/demand-cycle-walkthrough.integration.test.js
Rene De Ren 9916527790 optimalControl: dispatch setpoint to non-operational pumps too
Previously the dispatch loop only fired flowmovement for pumps in
'operational' or transitioned 'idle' pumps via execsequence-startup-then-flowmovement.
Pumps mid-startup (starting/warmingup) were silently skipped. With PS
sending demand every tick, intermediate setpoints during the startup
window never reached the pump — it locked onto the very first
snapshot's flowmovement and froze there.

Now flowmovement is sent regardless of state and rotatingMachine's
state.moveTo handles the queueing (delayedMove for transients, unpark
for residue, immediate for operational). Crucially, flowmovement runs
BEFORE execsequence-startup so the FIRST call's stale setpoint can't
land on an already-operational pump and overwrite the latest
delayedMove that fires at end of startup.

Adds three integration tests:
- demand-cycle-walkthrough: 0..100% sweep with clean per-step table
- idle-startup-deadlock: four scenarios that pin the dispatch behaviour
  including the regression guard for varying-demand-during-startup
- optimizer-combination-choice: physical-validity invariants

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:19:47 +02:00

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