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>
This commit is contained in:
Rene De Ren
2026-05-08 11:19:47 +02:00
parent 9c79dac4e3
commit 9916527790
4 changed files with 851 additions and 106 deletions

View File

@@ -0,0 +1,211 @@
// 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).`);
});

View File

@@ -0,0 +1,247 @@
// MGC + idle pumps under realistic startup times — three scenarios that
// pin down WHERE the live deadlock is happening when PS sends 100% but
// pumps "show on" without adopting the control value.
//
// All three scenarios start with idle pumps (NOT pre-started) and use
// non-zero state.time values so startup is observable. Each scenario
// prints the per-pump snapshot at the end. The asserts state what we
// EXPECT to happen — failures point at the exact codepath that breaks.
//
// Compare to demand-cycle-walkthrough.integration.test.js which
// pre-starts every pump to 'operational' and therefore CANNOT exercise
// the idle-during-rapid-retarget paths described here.
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const HEAD_MBAR_UP = 0;
const HEAD_MBAR_DOWN = 1100;
const N_PUMPS = 3;
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
// Production-realistic-but-shrunk: starting=1s, warmingup=2s. Total
// startup ~3s. Long enough for rapid retargeting (every 200ms) to land
// 10+ extra calls during the transient, short enough to keep the test
// well under 30s.
const stateConfig = {
general: { logging: logCfg },
state: { current: 'idle' },
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
time: { starting: 1, warmingup: 2, stopping: 1, coolingdown: 2 },
};
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' },
mode: { current: 'optimalcontrol' },
};
}
function buildGroup({ withPressure = true } = {}) {
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) {
if (withPressure) {
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));
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;
const power = running ? Number(pump.predictPower?.outputY ?? 0) / 1000 : 0;
return { state, ctrl, flow, power, delayedMove: pump.state.delayedMove };
}
function printSnapshots(label, pumps) {
console.log(`\n --- ${label} ---`);
console.log(' ' + ['id'.padEnd(8), 'state'.padEnd(14), 'ctrl%'.padStart(6), 'Q m³/h'.padStart(8), 'kW'.padStart(6), 'delayedMove'.padStart(12)].join(' '));
console.log(' ' + '-'.repeat(60));
for (const p of pumps) {
const s = snapshot(p);
console.log(' ' + [
p.config.general.id.padEnd(8),
s.state.padEnd(14),
s.ctrl.toFixed(1).padStart(6),
s.flow.toFixed(1).padStart(8),
s.power.toFixed(1).padStart(6),
String(s.delayedMove).padStart(12),
].join(' '));
}
}
function expectAllRunningAt100(pumps, label) {
// After settle every pump should be operational with high ctrl% and
// measurable flow. "high" is conservative — at 100% normalized demand,
// 3-pump split puts each pump near 100% ctrl. Allow >70% as the floor
// (accommodates BEP-Gravitation's slight asymmetry at the curve edges).
for (const p of pumps) {
const s = snapshot(p);
assert.equal(s.state, 'operational',
`${label}: pump ${p.config.general.id} expected operational, got '${s.state}' (ctrl=${s.ctrl.toFixed(1)}, delayedMove=${s.delayedMove})`);
assert.ok(s.ctrl > 70,
`${label}: pump ${p.config.general.id} expected ctrl% > 70 at 100% demand, got ${s.ctrl.toFixed(2)} (state=${s.state}, delayedMove=${s.delayedMove})`);
assert.ok(s.flow > 100,
`${label}: pump ${p.config.general.id} expected flow > 100 m³/h, got ${s.flow.toFixed(2)} (state=${s.state}, ctrl=${s.ctrl.toFixed(1)})`);
}
}
// ---------------------------------------------------------------------------
test('Scenario 1 — single-shot 100% demand to idle pumps', async () => {
// Hypothesis A: a SINGLE handleInput call to MGC with all pumps idle is
// enough to surface the bug. If pumps end up at 100% ctrl, the bug is
// elsewhere (rapid retargeting OR pressure plumbing). If pumps stay at
// 0%, the dispatch loop itself doesn't follow through on
// execsequence-startup → flowmovement.
const { mgc, pumps } = buildGroup();
console.log(`\n[Scenario 1] head=${HEAD_MBAR_DOWN} mbar, time.starting=${stateConfig.time.starting}s, time.warmingup=${stateConfig.time.warmingup}s`);
printSnapshots('before handleInput', pumps);
await mgc.handleInput('parent', 100);
printSnapshots('immediately after handleInput returns', pumps);
// Wait for full startup (3s) + movement (~0.5s) + slack
await sleep(6000);
printSnapshots('after 6s settle', pumps);
expectAllRunningAt100(pumps, 'Scenario 1');
});
// ---------------------------------------------------------------------------
test('Scenario 2 — rapid 100% retargeting during startup window', async () => {
// Hypothesis B: PS fires _applyMachineGroupLevelControl on every level
// tick (every few hundred ms). While pumps are in 'starting' /
// 'warmingup', MGC's optimalControl loop snapshots them, hits NONE of
// its three branches (idle / operational / flow<=0), and dispatches
// nothing. The only reason pumps eventually move is the FIRST call's
// queued `await flowmovement` after `await execsequence startup` —
// unless a subsequent call's abortActiveMovements aborts that move
// mid-flight, parking it in 'accelerating'/'decelerating'.
const { mgc, pumps } = buildGroup();
console.log(`\n[Scenario 2] firing mgc.handleInput('parent', 100) every 200ms for 5s`);
printSnapshots('before any handleInput', pumps);
// First call (kicks off startup); not awaited so retargets can layer on.
mgc.handleInput('parent', 100).catch(e => console.log(`first call rejected: ${e.message}`));
// Spam additional retargets every 200ms for 5s — covers the 3s startup
// window with 25 extra retargeting calls.
const interval = setInterval(() => {
mgc.handleInput('parent', 100).catch(e => console.log(`retarget rejected: ${e.message}`));
}, 200);
await sleep(5000);
clearInterval(interval);
printSnapshots('right after retarget barrage stops', pumps);
// Drain: let any pending moves finish and let the FSM settle.
await sleep(3000);
printSnapshots('after 3s drain', pumps);
expectAllRunningAt100(pumps, 'Scenario 2');
});
// ---------------------------------------------------------------------------
test('Scenario 3 — pumps with NO pressure measurements injected', async () => {
// Hypothesis C: in production, MGC may receive a demand BEFORE the
// first pressure measurement has propagated. Without head, the curve's
// operating point is at fDimension=defaults, and currentFxyYMin/Max
// may not correspond to a usable envelope. If MGC's distributor then
// hands every pump flow≤0, the dispatch loop falls into the 'flow<=0
// → shutdown' branch and pumps go straight to idle.
const { mgc, pumps } = buildGroup({ withPressure: false });
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
const minQ = sample.currentFxyYMin * 3600;
const maxQ = sample.currentFxyYMax * 3600;
const dyn = mgc.calcDynamicTotals();
console.log(`\n[Scenario 3] no pressure injected. per-pump curve envelope: ${minQ.toFixed(1)} .. ${maxQ.toFixed(1)} m³/h, station: ${(dyn.flow.min*3600).toFixed(1)} .. ${(dyn.flow.max*3600).toFixed(1)} m³/h`);
printSnapshots('before handleInput', pumps);
await mgc.handleInput('parent', 100);
await sleep(6000);
printSnapshots('after 6s settle (no pressure)', pumps);
// We don't assert success here — this scenario is exploratory. Just
// log what happens. If pumps DO ramp despite no pressure, MGC is
// resilient. If they stay idle, that's a meaningful failure mode for
// the live system because a redeploy may rebuild the world before
// sensors republish.
console.log(' (Scenario 3 is exploratory — no asserts; review the snapshot above.)');
});
// ---------------------------------------------------------------------------
test('Scenario 4 — varying demand during startup (combo flips)', async () => {
// Hypothesis D: in production the demand is NOT constant — as basin
// level rises, percControl ramps from startLevel→maxLevel over the
// basin model. Demand can flip between 1-pump / 2-pump / 3-pump
// combinations every PS tick. Each flip in optimalControl tells some
// pumps to start, others to shutdown, others nothing. If a pump that
// was just told "startup" is told "shutdown" 1s later (still in
// 'starting' state — neither idle nor operational), nothing happens
// for that pump in this snapshot. The execsequence shutdown branch
// requires state to be operational/accelerating/decelerating — a
// 'starting'/'warmingup' pump is silently passed over for shutdown
// too. The pump then proceeds to operational AND obeys its queued
// flowmovement, even though MGC's intent has since changed.
const { mgc, pumps } = buildGroup();
const sequence = [25, 75, 50, 100, 30, 90, 60, 100];
console.log(`\n[Scenario 4] varying demand sequence: ${sequence.join(' → ')} (each held 400ms)`);
printSnapshots('before any handleInput', pumps);
for (const pct of sequence) {
console.log(` → demand ${pct}%`);
mgc.handleInput('parent', pct).catch(e => console.log(`call ${pct}% rejected: ${e.message}`));
await sleep(400);
}
printSnapshots('right after sequence ends', pumps);
// Final demand was 100% — drain and verify pumps converged.
await sleep(4000);
printSnapshots('after 4s drain (demand was last set to 100%)', pumps);
expectAllRunningAt100(pumps, 'Scenario 4');
});

View File

@@ -0,0 +1,169 @@
// MGC optimizer combination choice — given a known operating point and
// 3 identical pumps, walk demand from below per-pump min through to
// full station capacity and assert the optimizer always returns a
// combination whose per-pump split lies within each pump's curve.
//
// This is a regression test. Earlier traces showed per-pump flow values
// that looked impossible (78 m³/h while we believed min was ~99). The
// real explanation: the curve's currentFxyYMin shifts with head — at
// 1652 mbar the per-pump min IS 49 m³/h. This test pins the optimizer's
// behaviour at a single deterministic head so the asserted ranges are
// stable.
const test = require('node:test');
const assert = require('node:assert/strict');
const MachineGroup = require('../../src/specificClass');
const Machine = require('../../../rotatingMachine/src/specificClass');
const HEAD_MBAR_DOWN = 1100;
const HEAD_MBAR_UP = 0;
const stateConfig = {
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 },
};
function machineConfig(id) {
return {
general: { logging: { enabled: false, logLevel: 'error' }, 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: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' },
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
scaling: { current: 'absolute' }, // talk to MGC in m³/h directly
mode: { current: 'optimalcontrol' },
};
}
function buildGroup() {
const mgc = new MachineGroup(groupConfig());
const ids = ['pump_a', 'pump_b', 'pump_c'];
const pumps = ids.map(id => new Machine(machineConfig(id), stateConfig));
for (const m of pumps) {
// Inject deterministic pressures so every pump sees the same head.
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 };
}
test('optimizer always returns a physically valid split (head=1100 mbar)', () => {
// The core invariant: whatever combination the optimizer picks, every
// per-pump assignment must lie inside that pump's curve envelope at
// the current operating point, and the total must equal the demand.
// This is what makes a combo "physically valid". The optimizer is
// free to pick fewer or more pumps based on efficiency — that is NOT
// a violation.
const { mgc, pumps } = buildGroup();
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
const minPerPump = sample.currentFxyYMin * 3600;
const maxPerPump = sample.currentFxyYMax * 3600;
// Guard against a curve-data change silently invalidating the asserts.
assert.ok(minPerPump > 80 && minPerPump < 100,
`unexpected curve min ${minPerPump} at 1100 mbar`);
assert.ok(maxPerPump > 220 && maxPerPump < 230,
`unexpected curve max ${maxPerPump} at 1100 mbar`);
const stationMax = maxPerPump * pumps.length; // ≈ 681
// Note: we deliberately stay 1 m³/h short of stationMax to avoid a
// floating-point edge where validPumpCombinations rejects an exact
// boundary demand. Real demand is never exactly station max anyway.
const demands = [0, 50, minPerPump - 5, minPerPump, 150, 200, 230, 250, 300, 400, 500, 600, stationMax - 1];
const rows = [];
for (const Qd_m3h of demands) {
const Qd_m3s = Qd_m3h / 3600;
const combos = mgc.validPumpCombinations(mgc.machines, Qd_m3s, Infinity);
if (combos.length === 0) {
rows.push({ Qd_m3h, picked: null, perPump: [], total: 0 });
// The validity rule rejects a combo when Qd is outside its
// [sum(min), sum(max)] envelope. With only 3 identical pumps at
// this head, that means Qd < minPerPump (no combo's min envelope
// contains it) or Qd > stationMax. Strict zero is also rejected.
assert.ok(Qd_m3h <= 0 || Qd_m3h < minPerPump,
`unexpected: no valid combo for Qd=${Qd_m3h} (per-pump ${minPerPump.toFixed(2)}..${maxPerPump.toFixed(2)}, station max ${stationMax.toFixed(2)})`);
continue;
}
const best = mgc.calcBestCombinationBEPGravitation(combos, Qd_m3s, 'BEP-Gravitation-Directional');
assert.ok(best.bestCombination, `no bestCombination for Qd=${Qd_m3h}`);
const split = best.bestCombination.map(e => e.flow * 3600);
const total = split.reduce((s, x) => s + x, 0);
rows.push({ Qd_m3h, picked: best.bestCombination.length, perPump: split, total });
// Each per-pump split must lie in [minPerPump, maxPerPump].
for (const f of split) {
assert.ok(f >= minPerPump - 1e-3,
`Qd=${Qd_m3h}: per-pump ${f.toFixed(2)} below min ${minPerPump.toFixed(2)}`);
assert.ok(f <= maxPerPump + 1e-3,
`Qd=${Qd_m3h}: per-pump ${f.toFixed(2)} above max ${maxPerPump.toFixed(2)}`);
}
assert.ok(Math.abs(total - Qd_m3h) < Math.max(1, Qd_m3h * 0.01),
`Qd=${Qd_m3h}: total ${total.toFixed(2)} ≠ demand`);
}
// Print the chosen combinations for inspection.
console.log(`\nHead = ${HEAD_MBAR_DOWN - HEAD_MBAR_UP} mbar`);
console.log(`Per-pump curve: min=${minPerPump.toFixed(2)} m³/h, max=${maxPerPump.toFixed(2)} m³/h`);
console.log(`Station max (3 pumps × max): ${stationMax.toFixed(2)} m³/h\n`);
console.log(' demand pumps per-pump split');
console.log(' ────── ───── ─────────────────────────────');
for (const r of rows) {
if (r.picked == null) {
console.log(` ${r.Qd_m3h.toFixed(1).padStart(6)} none no valid combo`);
} else {
console.log(` ${r.Qd_m3h.toFixed(1).padStart(6)} ${r.picked} [${r.perPump.map(f => f.toFixed(1)).join(', ')}] total=${r.total.toFixed(1)}`);
}
}
});
test('feasibility floor and ceiling: only 1-pump combo serves demand below 2×min', () => {
// The optimizer is allowed to pick larger combos for efficiency, but
// it CANNOT pick a combo whose [sum(min), sum(max)] doesn't contain
// the demand. This pins down the floor / ceiling rules.
const { mgc, pumps } = buildGroup();
const sample = pumps[0].groupPredictFlow ?? pumps[0].predictFlow;
const minPerPump = sample.currentFxyYMin * 3600;
const maxPerPump = sample.currentFxyYMax * 3600;
// Demand below per-pump min → no combo at all. (sum(min) ≥ minPerPump
// for every non-empty combo, and Qd < sum(min) ⇒ rejected.)
let combos = mgc.validPumpCombinations(mgc.machines, (minPerPump - 5) / 3600, Infinity);
assert.equal(combos.length, 0, `demand below per-pump min should yield 0 valid combos, got ${combos.length}`);
// Demand within [minPerPump, 2*minPerPump): only 1-pump combos pass.
// (2-pump min envelope = 2×minPerPump > Qd.)
const Qd1 = (minPerPump + 5) / 3600;
combos = mgc.validPumpCombinations(mgc.machines, Qd1, Infinity);
for (const c of combos) {
assert.equal(c.length, 1,
`demand ${minPerPump+5} m³/h: only 1-pump combos should be valid (got ${c.length}-pump)`);
}
// Demand above station max → no valid combo.
combos = mgc.validPumpCombinations(mgc.machines, (maxPerPump * 3 + 50) / 3600, Infinity);
assert.equal(combos.length, 0, `demand above station max should yield 0 valid combos`);
});