Compare commits
15 Commits
b337bf9eb7
...
ea2857fb25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea2857fb25 | ||
|
|
2651aaf409 | ||
|
|
df74ea0fac | ||
|
|
96b84d3124 | ||
|
|
a14aa0dab8 | ||
|
|
69bdf11fc4 | ||
|
|
dc27a569d9 | ||
|
|
b7c40b0ddc | ||
|
|
8e684203a8 | ||
|
|
9916527790 | ||
|
|
9c79dac4e3 | ||
|
|
7eafd89f4e | ||
|
|
d55f401ab3 | ||
|
|
ffb2072baa | ||
|
|
85797b5b8b |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# machineGroupControl — Claude Code context
|
||||
|
||||
Coordinates multiple rotatingMachine or valve children.
|
||||
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||
|
||||
## S88 classification
|
||||
|
||||
| Level | Colour | Placement lane |
|
||||
|---|---|---|
|
||||
| **Unit** | `#50a8d9` | L4 |
|
||||
|
||||
## Flow layout rules
|
||||
|
||||
When wiring this node into a multi-node demo or production flow, follow the
|
||||
placement rule set in the **EVOLV superproject**:
|
||||
|
||||
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||
|
||||
Key points for this node:
|
||||
- Place on lane **L4** (x-position per the lane table in the rule).
|
||||
- Stack same-level siblings vertically.
|
||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||
- Wrap in a Node-RED group box coloured `#50a8d9` (Unit).
|
||||
28
mgc.html
28
mgc.html
@@ -18,6 +18,16 @@
|
||||
defaults: {
|
||||
// Define default properties
|
||||
name: { value: "" },
|
||||
processOutputFormat: { value: "process" },
|
||||
dbaseOutputFormat: { value: "influxdb" },
|
||||
|
||||
//define asset properties
|
||||
uuid: { value: "" },
|
||||
supplier: { value: "" },
|
||||
category: { value: "" },
|
||||
assetType: { value: "" },
|
||||
model: { value: "" },
|
||||
unit: { value: "" },
|
||||
|
||||
// Logger properties
|
||||
enableLog: { value: false },
|
||||
@@ -74,6 +84,24 @@
|
||||
|
||||
<script type="text/html" data-template-name="machineGroupControl">
|
||||
|
||||
<h3>Output Formats</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||
<option value="process">process</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||
<option value="influxdb">influxdb</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Logger fields injected here -->
|
||||
<div id="logger-fields-placeholder"></div>
|
||||
|
||||
|
||||
@@ -39,21 +39,9 @@ class nodeClass {
|
||||
this.defaultConfig = cfgMgr.getConfig(this.name);
|
||||
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
|
||||
|
||||
// Merge UI config over defaults
|
||||
this.config = {
|
||||
general: {
|
||||
name: uiConfig.name,
|
||||
id: node.id, // node.id is for the child registration process
|
||||
unit: flowUnit,
|
||||
logging: {
|
||||
enabled: uiConfig.enableLog,
|
||||
logLevel: uiConfig.logLevel,
|
||||
},
|
||||
},
|
||||
functionality: {
|
||||
positionVsParent: uiConfig.positionVsParent || "atEquipment", // Default to 'atEquipment' if not set
|
||||
},
|
||||
};
|
||||
// Build config: base sections (no domain-specific config for group controller)
|
||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id);
|
||||
|
||||
// Utility for formatting outputs
|
||||
this._output = new outputUtils();
|
||||
}
|
||||
@@ -81,14 +69,14 @@ class nodeClass {
|
||||
const mg = this.source;
|
||||
const mode = mg.mode;
|
||||
const scaling = mg.scaling;
|
||||
|
||||
|
||||
// Add safety checks for measurements
|
||||
const totalFlow = mg.measurements
|
||||
?.type("flow")
|
||||
?.variant("predicted")
|
||||
?.position("atequipment")
|
||||
?.getCurrentValue(mg?.unitPolicy?.output?.flow || 'm3/h') || 0;
|
||||
|
||||
|
||||
const totalPower = mg.measurements
|
||||
?.type("power")
|
||||
?.variant("predicted")
|
||||
@@ -102,7 +90,7 @@ class nodeClass {
|
||||
mg.logger?.warn(`Machine missing or invalid: ${machine?.config?.general?.id || 'unknown'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
const state = machine.state.getCurrentState();
|
||||
const mode = machine.currentMode;
|
||||
return !(
|
||||
@@ -225,15 +213,27 @@ class nodeClass {
|
||||
mg.logger.warn(`registerChild skipped: missing child/source for id=${childId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
mg.logger.debug(`Registering child: ${childId}, found: ${!!childObj}, source: ${!!childObj?.source}`);
|
||||
|
||||
mg.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
|
||||
|
||||
mg.logger.debug(`Total machines after registration: ${Object.keys(mg.machines || {}).length}`);
|
||||
break;
|
||||
}
|
||||
case "setMode":
|
||||
mg.setMode(msg.payload);
|
||||
|
||||
case "setMode": {
|
||||
const mode = msg.payload;
|
||||
mg.setMode(mode);
|
||||
break;
|
||||
case "setScaling":
|
||||
mg.setScaling(msg.payload);
|
||||
}
|
||||
|
||||
case "setScaling": {
|
||||
const scaling = msg.payload;
|
||||
mg.setScaling(scaling);
|
||||
break;
|
||||
}
|
||||
|
||||
case "Qd": {
|
||||
const Qd = parseFloat(msg.payload);
|
||||
const sourceQd = "parent";
|
||||
@@ -251,6 +251,7 @@ class nodeClass {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
mg.logger.warn(`Unknown topic: ${msg.topic}`);
|
||||
break;
|
||||
@@ -270,6 +271,7 @@ class nodeClass {
|
||||
this.node.on("close", (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
clearInterval(this._statusInterval);
|
||||
this.node.status({}); // clear node status badge
|
||||
if (typeof done === 'function') done();
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
211
test/integration/demand-cycle-walkthrough.integration.test.js
Normal file
211
test/integration/demand-cycle-walkthrough.integration.test.js
Normal 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).`);
|
||||
});
|
||||
227
test/integration/distribution-power-table.integration.test.js
Normal file
227
test/integration/distribution-power-table.integration.test.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* machineGroupControl vs naive strategies — real pump curves
|
||||
*
|
||||
* Station: 2× hidrostal H05K-S03R + 1× hidrostal C5-D03R-SHN1
|
||||
* ΔP = 2000 mbar
|
||||
*
|
||||
* Compares the ACTUAL machineGroupControl optimalControl algorithm against
|
||||
* naive baselines. All strategies must deliver exactly Qd.
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const DIFF_MBAR = 2000;
|
||||
const UP_MBAR = 500;
|
||||
const DOWN_MBAR = UP_MBAR + DIFF_MBAR;
|
||||
|
||||
const stateConfig = {
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 }
|
||||
};
|
||||
|
||||
function machineConfig(id, model) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model, 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: 'station' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
scaling: { current: 'absolute' },
|
||||
mode: { current: 'optimalcontrol' }
|
||||
};
|
||||
}
|
||||
|
||||
function injectPressure(m) {
|
||||
m.updateMeasuredPressure(UP_MBAR, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
|
||||
m.updateMeasuredPressure(DOWN_MBAR, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
|
||||
}
|
||||
|
||||
/* ---- naive baselines (pumps OFF = 0 flow, 0 power) ---- */
|
||||
|
||||
function distribute(machines, running, rawDist, Qd) {
|
||||
const dist = {};
|
||||
for (const id of Object.keys(machines)) dist[id] = 0;
|
||||
for (const id of running) {
|
||||
const m = machines[id];
|
||||
dist[id] = Math.min(m.predictFlow.currentFxyYMax, Math.max(m.predictFlow.currentFxyYMin, rawDist[id] || 0));
|
||||
}
|
||||
for (let pass = 0; pass < 20; pass++) {
|
||||
let rem = Qd - running.reduce((s, id) => s + dist[id], 0);
|
||||
if (Math.abs(rem) < 1e-9) break;
|
||||
for (const id of running) {
|
||||
if (Math.abs(rem) < 1e-9) break;
|
||||
const m = machines[id];
|
||||
const cap = rem > 0 ? m.predictFlow.currentFxyYMax - dist[id] : dist[id] - m.predictFlow.currentFxyYMin;
|
||||
if (cap > 1e-9) { const t = Math.min(Math.abs(rem), cap); dist[id] += rem > 0 ? t : -t; rem += rem > 0 ? -t : t; }
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
function spillover(machines, Qd) {
|
||||
const sorted = Object.keys(machines).sort((a, b) => machines[a].predictFlow.currentFxyYMax - machines[b].predictFlow.currentFxyYMax);
|
||||
let running = [], maxCap = 0;
|
||||
for (const id of sorted) { running.push(id); maxCap += machines[id].predictFlow.currentFxyYMax; if (maxCap >= Qd) break; }
|
||||
const raw = {}; let rem = Qd;
|
||||
for (const id of running) { raw[id] = rem; rem = Math.max(0, rem - machines[id].predictFlow.currentFxyYMax); }
|
||||
const dist = distribute(machines, running, raw, Qd);
|
||||
let p = 0, f = 0;
|
||||
for (const id of running) { p += machines[id].inputFlowCalcPower(dist[id]); f += dist[id]; }
|
||||
return { dist, power: p, flow: f, combo: running };
|
||||
}
|
||||
|
||||
function equalAllOn(machines, Qd) {
|
||||
const ids = Object.keys(machines);
|
||||
const raw = {}; for (const id of ids) raw[id] = Qd / ids.length;
|
||||
const dist = distribute(machines, ids, raw, Qd);
|
||||
let p = 0, f = 0;
|
||||
for (const id of ids) { p += machines[id].inputFlowCalcPower(dist[id]); f += dist[id]; }
|
||||
return { dist, power: p, flow: f, combo: ids };
|
||||
}
|
||||
|
||||
/* ---- test ---- */
|
||||
|
||||
test('machineGroupControl vs naive baselines — real curves, verified flow', async () => {
|
||||
const mg = new MachineGroup(groupConfig());
|
||||
const machines = {};
|
||||
|
||||
for (const [id, model] of [['H05K-1','hidrostal-H05K-S03R'],['H05K-2','hidrostal-H05K-S03R'],['C5','hidrostal-C5-D03R-SHN1']]) {
|
||||
const m = new Machine(machineConfig(id, model), stateConfig);
|
||||
injectPressure(m);
|
||||
mg.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
machines[id] = m;
|
||||
}
|
||||
|
||||
const toH = (v) => +(v * 3600).toFixed(1);
|
||||
const CANON_FLOW = 'm3/s';
|
||||
const CANON_POWER = 'W';
|
||||
|
||||
console.log(`\n=== STATION: 2×H05K + 1×C5 @ ΔP=${DIFF_MBAR} mbar ===`);
|
||||
console.table(Object.entries(machines).map(([id, m]) => ({
|
||||
id,
|
||||
'min (m³/h)': toH(m.predictFlow.currentFxyYMin),
|
||||
'max (m³/h)': toH(m.predictFlow.currentFxyYMax),
|
||||
'BEP (m³/h)': toH(m.predictFlow.currentFxyYMin + (m.predictFlow.currentFxyYMax - m.predictFlow.currentFxyYMin) * m.NCog),
|
||||
NCog: +m.NCog.toFixed(3),
|
||||
})));
|
||||
|
||||
const minQ = Math.max(...Object.values(machines).map(m => m.predictFlow.currentFxyYMin));
|
||||
const maxQ = Object.values(machines).reduce((s, m) => s + m.predictFlow.currentFxyYMax, 0);
|
||||
const demandPcts = [0.10, 0.25, 0.50, 0.75, 0.90];
|
||||
const rows = [];
|
||||
|
||||
for (const pct of demandPcts) {
|
||||
const Qd = minQ + (maxQ - minQ) * pct;
|
||||
|
||||
// Reset all machines to idle, re-inject pressure
|
||||
for (const m of Object.values(machines)) {
|
||||
if (m.state.getCurrentState() !== 'idle') await m.handleInput('parent', 'execSequence', 'shutdown');
|
||||
injectPressure(m);
|
||||
}
|
||||
|
||||
// Run machineGroupControl optimalControl with absolute scaling
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('absolute');
|
||||
mg.calcAbsoluteTotals();
|
||||
mg.calcDynamicTotals();
|
||||
await mg.handleInput('parent', Qd);
|
||||
|
||||
// Read ACTUAL per-pump state (not the MGC summary which may be stale)
|
||||
let mgcPower = 0, mgcFlow = 0;
|
||||
const mgcCombo = [];
|
||||
const mgcDist = {};
|
||||
for (const [id, m] of Object.entries(machines)) {
|
||||
const state = m.state.getCurrentState();
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(CANON_FLOW) || 0;
|
||||
const power = m.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue(CANON_POWER) || 0;
|
||||
mgcDist[id] = { flow, power, state };
|
||||
if (state === 'operational' || state === 'warmingup' || state === 'accelerating') {
|
||||
mgcCombo.push(id);
|
||||
mgcPower += power;
|
||||
mgcFlow += flow;
|
||||
}
|
||||
}
|
||||
|
||||
// Naive baselines
|
||||
const sp = spillover(machines, Qd);
|
||||
const ea = equalAllOn(machines, Qd);
|
||||
|
||||
const best = Math.min(mgcPower, sp.power, ea.power);
|
||||
const delta = (v) => best > 0 ? `${(((v - best) / best) * 100).toFixed(1)}%` : '';
|
||||
|
||||
rows.push({
|
||||
demand: `${(pct * 100)}%`,
|
||||
'Qd (m³/h)': toH(Qd),
|
||||
'MGC kW': +(mgcPower / 1000).toFixed(1),
|
||||
'MGC flow': toH(mgcFlow),
|
||||
'MGC pumps': mgcCombo.join('+') || 'none',
|
||||
'Spill kW': +(sp.power / 1000).toFixed(1),
|
||||
'Spill flow': toH(sp.flow),
|
||||
'Spill pumps': sp.combo.join('+'),
|
||||
'EqAll kW': +(ea.power / 1000).toFixed(1),
|
||||
'EqAll flow': toH(ea.flow),
|
||||
'MGC Δ': delta(mgcPower),
|
||||
'Spill Δ': delta(sp.power),
|
||||
'EqAll Δ': delta(ea.power),
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== POWER + FLOW COMPARISON (★ = best, all must deliver Qd) ===');
|
||||
console.table(rows);
|
||||
|
||||
// Per-pump detail at each demand level
|
||||
for (const pct of demandPcts) {
|
||||
const Qd = minQ + (maxQ - minQ) * pct;
|
||||
|
||||
for (const m of Object.values(machines)) {
|
||||
if (m.state.getCurrentState() !== 'idle') await m.handleInput('parent', 'execSequence', 'shutdown');
|
||||
injectPressure(m);
|
||||
}
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('absolute');
|
||||
mg.calcAbsoluteTotals();
|
||||
mg.calcDynamicTotals();
|
||||
await mg.handleInput('parent', Qd);
|
||||
|
||||
const detail = Object.entries(machines).map(([id, m]) => {
|
||||
const state = m.state.getCurrentState();
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(CANON_FLOW) || 0;
|
||||
const power = m.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue(CANON_POWER) || 0;
|
||||
return {
|
||||
pump: id,
|
||||
state,
|
||||
'flow (m³/h)': toH(flow),
|
||||
'power (kW)': +(power / 1000).toFixed(1),
|
||||
};
|
||||
});
|
||||
console.log(`\n--- MGC per-pump @ ${(pct*100)}% (${toH(Qd)} m³/h) ---`);
|
||||
console.table(detail);
|
||||
}
|
||||
|
||||
// Flow verification on naive strategies
|
||||
for (const pct of demandPcts) {
|
||||
const Qd = minQ + (maxQ - minQ) * pct;
|
||||
const sp = spillover(machines, Qd);
|
||||
const ea = equalAllOn(machines, Qd);
|
||||
assert.ok(Math.abs(sp.flow - Qd) < Qd * 0.005, `Spillover flow mismatch at ${(pct*100)}%`);
|
||||
assert.ok(Math.abs(ea.flow - Qd) < Qd * 0.005, `Equal-all flow mismatch at ${(pct*100)}%`);
|
||||
}
|
||||
});
|
||||
354
test/integration/idle-startup-deadlock.integration.test.js
Normal file
354
test/integration/idle-startup-deadlock.integration.test.js
Normal file
@@ -0,0 +1,354 @@
|
||||
// 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 5 — full up/down/up cycle through shutdown', async () => {
|
||||
// Hypothesis E: when demand goes 100% → 0% → 100% (basin fills, drains
|
||||
// past stopLevel, then refills), pumps pass through stopping →
|
||||
// coolingdown → idle. If a fresh flow>0 demand arrives while a pump is
|
||||
// mid-shutdown, the current MGC dispatch saves flowmovement to
|
||||
// delayedMove (good) but doesn't issue execsequence-startup because
|
||||
// state !== 'idle' (bug). The pump completes shutdown, reaches 'idle',
|
||||
// and stays there because transitionToState('idle') doesn't fire
|
||||
// delayedMove — only the transition INTO 'operational' does. Pump is
|
||||
// stuck with delayedMove orphaned.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
console.log('\n[Scenario 5] cycle: 100% → 0% → 100% with mid-shutdown re-engage');
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
// Phase 1: drive up to 100% from idle.
|
||||
await mgc.handleInput('parent', 100);
|
||||
await sleep(5000); // full startup + ramp
|
||||
printSnapshots('after settle at 100%', pumps);
|
||||
for (const p of pumps) {
|
||||
assert.equal(p.state.getCurrentState(), 'operational',
|
||||
`Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`);
|
||||
}
|
||||
|
||||
// Phase 2: demand drops to 0% — pumps begin shutdown sequence.
|
||||
// FIRE-AND-FORGET: handleInput(0) awaits turnOffAllMachines which
|
||||
// awaits the full per-pump shutdown sequence. We need the next 100%
|
||||
// demand to arrive WHILE pumps are still in stopping/coolingdown,
|
||||
// not after they've reached idle.
|
||||
mgc.handleInput('parent', 0).catch(e => console.log(`0% rejected: ${e.message}`));
|
||||
// Wait briefly so the shutdown sequence enters but does NOT complete.
|
||||
// shutdown=['stopping','coolingdown','idle'] with stopping=1s,
|
||||
// coolingdown=2s. 500ms puts us solidly inside 'stopping'.
|
||||
await sleep(500);
|
||||
printSnapshots('mid-shutdown (pumps should be in stopping/coolingdown)', pumps);
|
||||
|
||||
const midShutdownStates = pumps.map(p => p.state.getCurrentState());
|
||||
console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`);
|
||||
|
||||
// Phase 3: demand returns to 100% while pumps are mid-shutdown.
|
||||
await mgc.handleInput('parent', 100);
|
||||
// Generous: full coolingdown remaining + full startup + ramp.
|
||||
await sleep(8000);
|
||||
printSnapshots('after re-engage to 100%', pumps);
|
||||
|
||||
expectAllRunningAt100(pumps, 'Scenario 5');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Scenario 6 — full up sweep then full down sweep', async () => {
|
||||
// Hypothesis F: the user observed "going up stuck ~60%, going down
|
||||
// not reacting". Mirror that with an explicit up-then-down monotonic
|
||||
// sweep, every step holding 600 ms (slightly longer than DWELL on
|
||||
// production basin model). After the sweep, we expect the LATEST
|
||||
// demand (the final value of the down-sweep, which is 10%) to be
|
||||
// honoured: pumps either at 1-pump combo's split or all idle if that
|
||||
// demand falls below the per-pump minimum.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
console.log('\n[Scenario 6] up-sweep 10%→100% then down-sweep 100%→10%, each step 600 ms');
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
const upSteps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
||||
const downSteps = [90, 80, 70, 60, 50, 40, 30, 20, 10];
|
||||
|
||||
console.log(' --- up sweep ---');
|
||||
for (const pct of upSteps) {
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`up ${pct}% rejected: ${e.message}`));
|
||||
await sleep(600);
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`);
|
||||
}
|
||||
printSnapshots('top of up-sweep (cmd=100%) after full settle', pumps);
|
||||
await sleep(2000);
|
||||
printSnapshots('top of up-sweep + 2s drain', pumps);
|
||||
|
||||
console.log(' --- down sweep ---');
|
||||
for (const pct of downSteps) {
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`down ${pct}% rejected: ${e.message}`));
|
||||
await sleep(600);
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
console.log(` cmd=${pct.toFixed(0).padStart(3)}% states=[${snaps.map(s=>s.state.padEnd(13)).join(', ')}] ctrl=[${snaps.map(s=>s.ctrl.toFixed(1).padStart(5)).join(', ')}] ΣQ=${totalQ.toFixed(1)}`);
|
||||
}
|
||||
printSnapshots('bottom of down-sweep (cmd=10%) after sequence', pumps);
|
||||
await sleep(3000);
|
||||
printSnapshots('bottom of down-sweep + 3s drain', pumps);
|
||||
|
||||
// Final demand was 10% (≈ 148 m³/h). At head 1100 mbar with per-pump
|
||||
// min ≈ 89.5, this is solvable by a 1-pump combo near 148 m³/h.
|
||||
// Optimizer typically picks the 1-pump combo. Either way, pumps are
|
||||
// NOT supposed to be stuck at the prior up-sweep's 100% setpoint.
|
||||
const flowMin_m3h = mgc.calcDynamicTotals().flow.min * 3600;
|
||||
const flowMax_m3h = mgc.calcDynamicTotals().flow.max * 3600;
|
||||
const expectedQ_m3h = flowMin_m3h + (flowMax_m3h - flowMin_m3h) * 0.10; // 10% scaled
|
||||
console.log(` expected total flow at 10%: ~${expectedQ_m3h.toFixed(1)} m³/h`);
|
||||
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
// Loose: total within 30 m³/h of expectation. Catches the obvious
|
||||
// stuck-at-old-position regression.
|
||||
assert.ok(Math.abs(totalQ - expectedQ_m3h) < 30,
|
||||
`Scenario 6: total flow ${totalQ.toFixed(1)} m³/h diverged from expected ${expectedQ_m3h.toFixed(1)} after down-sweep — pumps did not adopt latest demand. Per-pump: ${snaps.map(s => `${s.state}@${s.ctrl.toFixed(0)}%`).join(', ')}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
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');
|
||||
});
|
||||
442
test/integration/ncog-distribution.integration.test.js
Normal file
442
test/integration/ncog-distribution.integration.test.js
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Group Distribution Strategy Comparison Test
|
||||
*
|
||||
* Compares three flow distribution strategies for a group of pumps:
|
||||
* 1. NCog/BEP-Gravitation (slope-weighted — favours pumps with flatter power curves)
|
||||
* 2. Equal distribution (same flow to every pump)
|
||||
* 3. Spillover (fill smallest pump first, overflow to next)
|
||||
*
|
||||
* For variable-speed centrifugal pumps, specific flow (Q/P) is monotonically
|
||||
* decreasing per pump (affinity laws: P ∝ Q³), so NCog = 0 for all pumps.
|
||||
* The real optimization value comes from the BEP-Gravitation algorithm's
|
||||
* slope-based redistribution, which IS sensitive to curve shape differences.
|
||||
*
|
||||
* These tests verify that:
|
||||
* - Asymmetric pumps produce different power slopes (the basis for optimization)
|
||||
* - BEP-Gravitation uses less total power than naive strategies for mixed pumps
|
||||
* - Equal pumps receive equal treatment under all strategies
|
||||
* - Spillover creates a visibly different distribution than BEP-weighted
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');
|
||||
|
||||
/* ---- helpers ---- */
|
||||
|
||||
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||
|
||||
function distortSeries(series, scale = 1, tilt = 0) {
|
||||
const last = series.length - 1;
|
||||
return series.map((v, i) => {
|
||||
const gradient = last === 0 ? 0 : i / last - 0.5;
|
||||
return Math.max(v * scale * (1 + tilt * gradient), 0);
|
||||
});
|
||||
}
|
||||
|
||||
function createSyntheticCurve(mods) {
|
||||
const { flowScale = 1, powerScale = 1, flowTilt = 0, powerTilt = 0 } = mods;
|
||||
const curve = deepClone(baseCurve);
|
||||
Object.values(curve.nq).forEach(s => { s.y = distortSeries(s.y, flowScale, flowTilt); });
|
||||
Object.values(curve.np).forEach(s => { s.y = distortSeries(s.y, powerScale, powerTilt); });
|
||||
return curve;
|
||||
}
|
||||
|
||||
const stateConfig = {
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 }
|
||||
};
|
||||
|
||||
function createMachineConfig(id, label) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: label, 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 createGroupConfig(name) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap with differential pressure (upstream + downstream) so the predict
|
||||
* engine resolves a realistic fDimension and calcEfficiencyCurve produces
|
||||
* a proper BEP peak — not a monotonic Q/P curve.
|
||||
*/
|
||||
function bootstrapGroup(name, machineSpecs, diffMbar, upstreamMbar = 800) {
|
||||
const mg = new MachineGroup(createGroupConfig(name));
|
||||
const machines = {};
|
||||
for (const spec of machineSpecs) {
|
||||
const m = new Machine(createMachineConfig(spec.id, spec.label), stateConfig);
|
||||
if (spec.curveMods) m.updateCurve(createSyntheticCurve(spec.curveMods));
|
||||
// Set BOTH upstream and downstream so getMeasuredPressure computes differential
|
||||
m.updateMeasuredPressure(upstreamMbar, 'upstream', {
|
||||
timestamp: Date.now(), unit: 'mbar', childName: `pt-up-${spec.id}`, childId: `pt-up-${spec.id}`
|
||||
});
|
||||
m.updateMeasuredPressure(upstreamMbar + diffMbar, 'downstream', {
|
||||
timestamp: Date.now(), unit: 'mbar', childName: `pt-dn-${spec.id}`, childId: `pt-dn-${spec.id}`
|
||||
});
|
||||
mg.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
machines[spec.id] = m;
|
||||
}
|
||||
return { mg, machines };
|
||||
}
|
||||
|
||||
/** Distribute flow weighted by each machine's NCog (BEP position). */
|
||||
function distributeByNCog(machines, Qd) {
|
||||
const entries = Object.entries(machines);
|
||||
let totalNCog = entries.reduce((s, [, m]) => s + (m.NCog || 0), 0);
|
||||
|
||||
const distribution = {};
|
||||
for (const [id, m] of entries) {
|
||||
const min = m.predictFlow.currentFxyYMin;
|
||||
const max = m.predictFlow.currentFxyYMax;
|
||||
const flow = totalNCog > 0
|
||||
? ((m.NCog || 0) / totalNCog) * Qd
|
||||
: Qd / entries.length;
|
||||
distribution[id] = Math.min(max, Math.max(min, flow));
|
||||
}
|
||||
|
||||
let totalPower = 0;
|
||||
for (const [id, m] of entries) {
|
||||
totalPower += m.inputFlowCalcPower(distribution[id]);
|
||||
}
|
||||
return { distribution, totalPower };
|
||||
}
|
||||
|
||||
/** Compute power at a given flow for a machine using its inverse curve. */
|
||||
function powerAtFlow(machine, flow) {
|
||||
return machine.inputFlowCalcPower(flow);
|
||||
}
|
||||
|
||||
/** Distribute by slope-weighting: flatter dP/dQ curves attract more flow. */
|
||||
function distributeBySlopeWeight(machines, Qd) {
|
||||
const entries = Object.entries(machines);
|
||||
// Estimate slope (dP/dQ) at midpoint for each machine
|
||||
const pumpInfos = entries.map(([id, m]) => {
|
||||
const min = m.predictFlow.currentFxyYMin;
|
||||
const max = m.predictFlow.currentFxyYMax;
|
||||
const mid = (min + max) / 2;
|
||||
const delta = Math.max((max - min) * 0.05, 0.001);
|
||||
const pMid = powerAtFlow(m, mid);
|
||||
const pRight = powerAtFlow(m, Math.min(max, mid + delta));
|
||||
const slope = Math.abs((pRight - pMid) / delta);
|
||||
return { id, m, min, max, slope: Math.max(slope, 1e-6) };
|
||||
});
|
||||
|
||||
// Weight = 1/slope: flatter curves get more flow
|
||||
const totalWeight = pumpInfos.reduce((s, p) => s + (1 / p.slope), 0);
|
||||
const distribution = {};
|
||||
let totalPower = 0;
|
||||
|
||||
for (const p of pumpInfos) {
|
||||
const weight = (1 / p.slope) / totalWeight;
|
||||
const flow = Math.min(p.max, Math.max(p.min, Qd * weight));
|
||||
distribution[p.id] = flow;
|
||||
totalPower += powerAtFlow(p.m, flow);
|
||||
}
|
||||
|
||||
return { distribution, totalPower };
|
||||
}
|
||||
|
||||
/** Distribute equally. */
|
||||
function distributeEqual(machines, Qd) {
|
||||
const entries = Object.entries(machines);
|
||||
const flowEach = Qd / entries.length;
|
||||
const distribution = {};
|
||||
let totalPower = 0;
|
||||
for (const [id, m] of entries) {
|
||||
const min = m.predictFlow.currentFxyYMin;
|
||||
const max = m.predictFlow.currentFxyYMax;
|
||||
const clamped = Math.min(max, Math.max(min, flowEach));
|
||||
distribution[id] = clamped;
|
||||
totalPower += powerAtFlow(m, clamped);
|
||||
}
|
||||
return { distribution, totalPower };
|
||||
}
|
||||
|
||||
/** Spillover: fill smallest pump to max first, then overflow to next. */
|
||||
function distributeSpillover(machines, Qd) {
|
||||
const entries = Object.entries(machines)
|
||||
.sort(([, a], [, b]) => a.predictFlow.currentFxyYMax - b.predictFlow.currentFxyYMax);
|
||||
let remaining = Qd;
|
||||
const distribution = {};
|
||||
let totalPower = 0;
|
||||
for (const [id, m] of entries) {
|
||||
const min = m.predictFlow.currentFxyYMin;
|
||||
const max = m.predictFlow.currentFxyYMax;
|
||||
const assigned = Math.min(max, Math.max(min, remaining));
|
||||
distribution[id] = assigned;
|
||||
remaining = Math.max(0, remaining - assigned);
|
||||
}
|
||||
for (const [id, m] of entries) {
|
||||
totalPower += powerAtFlow(m, distribution[id]);
|
||||
}
|
||||
return { distribution, totalPower };
|
||||
}
|
||||
|
||||
/* ---- tests ---- */
|
||||
|
||||
test('NCog = 0 for centrifugal pumps (Q/P is monotonically decreasing with speed)', () => {
|
||||
// For variable-speed centrifugal pumps, P ∝ n³ and Q ∝ n, so Q/P ∝ 1/n²
|
||||
// which is always decreasing. Peak efficiency (Q/P) is always at index 0
|
||||
// (minimum speed), giving NCog = 0. This is physically correct — the MGC
|
||||
// compensates via slope-based redistribution instead.
|
||||
const { machines } = bootstrapGroup('ncog-basic', [
|
||||
{ id: 'A', label: 'pump-A', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
], 400); // 400 mbar differential
|
||||
|
||||
const m = machines['A'];
|
||||
assert.ok(Number.isFinite(m.NCog), `NCog should be finite, got ${m.NCog}`);
|
||||
assert.strictEqual(m.NCog, 0, `NCog should be 0 for centrifugal pump (Q/P monotonically decreasing)`);
|
||||
assert.ok(m.cog > 0, `cog (peak specific flow) should be positive, got ${m.cog}`);
|
||||
assert.strictEqual(m.cogIndex, 0, `Peak Q/P should be at index 0 (minimum speed)`);
|
||||
});
|
||||
|
||||
test('different curve shapes still yield NCog = 0 (Q/P limitation)', () => {
|
||||
// Even with powerTilt distortion, Q/P remains monotonically decreasing for
|
||||
// centrifugal pump curves because P grows much faster than Q with speed.
|
||||
// NCog = 0 for all shapes — the slope-based redistribution (tests 4-6)
|
||||
// is what actually differentiates asymmetric pumps.
|
||||
const { machines } = bootstrapGroup('ncog-shapes', [
|
||||
{ id: 'early', label: 'early-BEP', curveMods: { flowScale: 1, powerScale: 1, powerTilt: 0.4 } },
|
||||
{ id: 'late', label: 'late-BEP', curveMods: { flowScale: 1, powerScale: 1, powerTilt: -0.3 } },
|
||||
], 400);
|
||||
|
||||
const ncogEarly = machines['early'].NCog;
|
||||
const ncogLate = machines['late'].NCog;
|
||||
|
||||
assert.strictEqual(ncogEarly, 0, `Early BEP NCog should be 0 (Q/P monotonic), got ${ncogEarly.toFixed(4)}`);
|
||||
assert.strictEqual(ncogLate, 0, `Late BEP NCog should be 0 (Q/P monotonic), got ${ncogLate.toFixed(4)}`);
|
||||
// Both cog values should still be computable and positive (peak Q/P at min speed)
|
||||
assert.ok(machines['early'].cog > 0, 'early cog should be positive');
|
||||
assert.ok(machines['late'].cog > 0, 'late cog should be positive');
|
||||
});
|
||||
|
||||
test('NCog = 0 falls back to equal distribution (same as equal split)', () => {
|
||||
// When NCog = 0 for all pumps (centrifugal pump limitation), the
|
||||
// distributeByNCog helper falls back to equal distribution. This verifies
|
||||
// the fallback works correctly and produces the same result as explicit
|
||||
// equal distribution.
|
||||
const { machines } = bootstrapGroup('ncog-vs-equal', [
|
||||
{ id: 'early', label: 'early-BEP', curveMods: { flowScale: 1, powerScale: 1, powerTilt: 0.4 } },
|
||||
{ id: 'late', label: 'late-BEP', curveMods: { flowScale: 1, powerScale: 1, powerTilt: -0.3 } },
|
||||
], 400);
|
||||
|
||||
// Both NCog = 0 (confirmed by tests 1-2)
|
||||
assert.strictEqual(machines['early'].NCog, 0, 'early NCog should be 0');
|
||||
assert.strictEqual(machines['late'].NCog, 0, 'late NCog should be 0');
|
||||
|
||||
const totalMax = machines['early'].predictFlow.currentFxyYMax + machines['late'].predictFlow.currentFxyYMax;
|
||||
const Qd = totalMax * 0.5;
|
||||
|
||||
const ncogResult = distributeByNCog(machines, Qd);
|
||||
const equalResult = distributeEqual(machines, Qd);
|
||||
|
||||
// With NCog = 0 for both, distributeByNCog falls back to equal split
|
||||
const ncogDiff = Math.abs(ncogResult.distribution['early'] - ncogResult.distribution['late']);
|
||||
const equalDiff = Math.abs(equalResult.distribution['early'] - equalResult.distribution['late']);
|
||||
assert.ok(
|
||||
Math.abs(ncogDiff - equalDiff) < Qd * 0.01,
|
||||
`NCog fallback should produce same distribution as equal split. ` +
|
||||
`ncogDiff=${ncogDiff.toFixed(4)}, equalDiff=${equalDiff.toFixed(4)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('asymmetric pumps have different power curve slopes', () => {
|
||||
// A pump with low powerScale has a flatter power curve
|
||||
const { machines } = bootstrapGroup('slope-check', [
|
||||
{ id: 'flat', label: 'flat-power', curveMods: { flowScale: 1.2, powerScale: 0.7, flowTilt: 0.1 } },
|
||||
{ id: 'steep', label: 'steep-power', curveMods: { flowScale: 0.8, powerScale: 1.4, flowTilt: -0.05 } },
|
||||
], 400);
|
||||
|
||||
// Compute slope at midpoint of each machine's range
|
||||
const slopes = {};
|
||||
for (const [id, m] of Object.entries(machines)) {
|
||||
const mid = (m.predictFlow.currentFxyYMin + m.predictFlow.currentFxyYMax) / 2;
|
||||
const delta = (m.predictFlow.currentFxyYMax - m.predictFlow.currentFxyYMin) * 0.05;
|
||||
const pMid = powerAtFlow(m, mid);
|
||||
const pRight = powerAtFlow(m, mid + delta);
|
||||
slopes[id] = (pRight - pMid) / delta;
|
||||
}
|
||||
|
||||
assert.ok(slopes['flat'] > 0 && slopes['steep'] > 0, 'Both slopes should be positive');
|
||||
assert.ok(
|
||||
slopes['steep'] > slopes['flat'] * 1.3,
|
||||
`Steep pump should have notably higher slope. flat=${slopes['flat'].toFixed(0)}, steep=${slopes['steep'].toFixed(0)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('slope-weighted distribution routes more flow to flatter pump', () => {
|
||||
const { machines } = bootstrapGroup('slope-routing', [
|
||||
{ id: 'flat', label: 'flat-power', curveMods: { flowScale: 1.2, powerScale: 0.7 } },
|
||||
{ id: 'steep', label: 'steep-power', curveMods: { flowScale: 0.8, powerScale: 1.4 } },
|
||||
], 400);
|
||||
|
||||
const totalMax = machines['flat'].predictFlow.currentFxyYMax + machines['steep'].predictFlow.currentFxyYMax;
|
||||
const Qd = totalMax * 0.5;
|
||||
|
||||
const slopeResult = distributeBySlopeWeight(machines, Qd);
|
||||
|
||||
assert.ok(
|
||||
slopeResult.distribution['flat'] > slopeResult.distribution['steep'],
|
||||
`Flat pump should get more flow. flat=${slopeResult.distribution['flat'].toFixed(2)}, steep=${slopeResult.distribution['steep'].toFixed(2)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('slope-weighted uses less power than equal split for asymmetric pumps', () => {
|
||||
const { machines } = bootstrapGroup('power-compare', [
|
||||
{ id: 'eff', label: 'efficient', curveMods: { flowScale: 1.2, powerScale: 0.7, flowTilt: 0.12 } },
|
||||
{ id: 'std', label: 'standard', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
], 400);
|
||||
|
||||
const totalMax = machines['eff'].predictFlow.currentFxyYMax + machines['std'].predictFlow.currentFxyYMax;
|
||||
const demandLevels = [0.3, 0.5, 0.7].map(p => {
|
||||
const min = Math.max(machines['eff'].predictFlow.currentFxyYMin, machines['std'].predictFlow.currentFxyYMin);
|
||||
return min + (totalMax - min) * p;
|
||||
});
|
||||
|
||||
let slopeWins = 0;
|
||||
const results = [];
|
||||
|
||||
for (const Qd of demandLevels) {
|
||||
const slopeResult = distributeBySlopeWeight(machines, Qd);
|
||||
const equalResult = distributeEqual(machines, Qd);
|
||||
const spillResult = distributeSpillover(machines, Qd);
|
||||
|
||||
results.push({
|
||||
demand: Qd,
|
||||
slopePower: slopeResult.totalPower,
|
||||
equalPower: equalResult.totalPower,
|
||||
spillPower: spillResult.totalPower,
|
||||
});
|
||||
|
||||
if (slopeResult.totalPower <= equalResult.totalPower + 1) slopeWins++;
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
slopeWins >= 2,
|
||||
`Slope-weighted should use ≤ power than equal in ≥ 2/3 cases.\n` +
|
||||
results.map(r =>
|
||||
` Qd=${r.demand.toFixed(1)}: slope=${r.slopePower.toFixed(1)}W, equal=${r.equalPower.toFixed(1)}W, spill=${r.spillPower.toFixed(1)}W`
|
||||
).join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
test('spillover produces visibly different distribution than slope-weighted for mixed sizes', () => {
|
||||
const { machines } = bootstrapGroup('spillover-vs-slope', [
|
||||
{ id: 'small', label: 'small-pump', curveMods: { flowScale: 0.6, powerScale: 0.55 } },
|
||||
{ id: 'large', label: 'large-pump', curveMods: { flowScale: 1.5, powerScale: 1.2 } },
|
||||
], 400);
|
||||
|
||||
const totalMax = machines['small'].predictFlow.currentFxyYMax + machines['large'].predictFlow.currentFxyYMax;
|
||||
const Qd = totalMax * 0.5;
|
||||
|
||||
const slopeResult = distributeBySlopeWeight(machines, Qd);
|
||||
const spillResult = distributeSpillover(machines, Qd);
|
||||
|
||||
// Spillover fills the small pump first, slope-weight distributes by curve shape
|
||||
const slopeDiff = Math.abs(slopeResult.distribution['small'] - spillResult.distribution['small']);
|
||||
const percentDiff = (slopeDiff / Qd) * 100;
|
||||
|
||||
assert.ok(
|
||||
percentDiff > 1,
|
||||
`Strategies should produce different distributions. ` +
|
||||
`Slope small=${slopeResult.distribution['small'].toFixed(2)}, ` +
|
||||
`Spill small=${spillResult.distribution['small'].toFixed(2)} (${percentDiff.toFixed(1)}% diff)`
|
||||
);
|
||||
});
|
||||
|
||||
test('equal pumps get equal flow under all strategies', () => {
|
||||
const { machines } = bootstrapGroup('equal-pumps', [
|
||||
{ id: 'A', label: 'pump-A', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
{ id: 'B', label: 'pump-B', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
], 400);
|
||||
|
||||
const totalMax = machines['A'].predictFlow.currentFxyYMax + machines['B'].predictFlow.currentFxyYMax;
|
||||
const Qd = totalMax * 0.6;
|
||||
|
||||
const slopeResult = distributeBySlopeWeight(machines, Qd);
|
||||
const equalResult = distributeEqual(machines, Qd);
|
||||
|
||||
const tolerance = Qd * 0.01;
|
||||
|
||||
assert.ok(
|
||||
Math.abs(slopeResult.distribution['A'] - slopeResult.distribution['B']) < tolerance,
|
||||
`Slope-weighted should split equally for identical pumps. A=${slopeResult.distribution['A'].toFixed(2)}, B=${slopeResult.distribution['B'].toFixed(2)}`
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(equalResult.distribution['A'] - equalResult.distribution['B']) < tolerance,
|
||||
`Equal should split equally. A=${equalResult.distribution['A'].toFixed(2)}, B=${equalResult.distribution['B'].toFixed(2)}`
|
||||
);
|
||||
|
||||
// Power should be identical too
|
||||
assert.ok(
|
||||
Math.abs(slopeResult.totalPower - equalResult.totalPower) < 1,
|
||||
`Equal pumps should produce same total power under any strategy`
|
||||
);
|
||||
});
|
||||
|
||||
test('full MGC optimalControl uses ≤ power than priorityControl for mixed pumps', async () => {
|
||||
const { mg, machines } = bootstrapGroup('mgc-full', [
|
||||
{ id: 'eff', label: 'efficient', curveMods: { flowScale: 1.2, powerScale: 0.7, flowTilt: 0.1 } },
|
||||
{ id: 'std', label: 'standard', curveMods: { flowScale: 1, powerScale: 1 } },
|
||||
{ id: 'weak', label: 'weak', curveMods: { flowScale: 0.8, powerScale: 1.3, flowTilt: -0.08 } },
|
||||
], 400);
|
||||
|
||||
for (const m of Object.values(machines)) {
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
}
|
||||
|
||||
// Run optimalControl
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('normalized');
|
||||
await mg.handleInput('parent', 50, Infinity);
|
||||
const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
// Reset machines
|
||||
for (const m of Object.values(machines)) {
|
||||
await m.handleInput('parent', 'execSequence', 'shutdown');
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
}
|
||||
|
||||
// Run priorityControl
|
||||
mg.setMode('prioritycontrol');
|
||||
await mg.handleInput('parent', 50, Infinity, ['eff', 'std', 'weak']);
|
||||
const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
assert.ok(optFlow > 0, `Optimal should deliver flow, got ${optFlow}`);
|
||||
assert.ok(prioFlow > 0, `Priority should deliver flow, got ${prioFlow}`);
|
||||
|
||||
// Compare efficiency (flow per unit power)
|
||||
const optEff = optPower > 0 ? optFlow / optPower : 0;
|
||||
const prioEff = prioPower > 0 ? prioFlow / prioPower : 0;
|
||||
|
||||
assert.ok(
|
||||
optEff >= prioEff * 0.95,
|
||||
`Optimal efficiency should be ≥ priority (within 5% tolerance). ` +
|
||||
`Opt: ${optFlow.toFixed(1)}/${optPower.toFixed(1)}=${optEff.toFixed(6)} | ` +
|
||||
`Prio: ${prioFlow.toFixed(1)}/${prioPower.toFixed(1)}=${prioEff.toFixed(6)}`
|
||||
);
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
131
test/integration/turnoff-deadlock.integration.test.js
Normal file
131
test/integration/turnoff-deadlock.integration.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// Regression: pump A in pumpingstation-complete-example demo got stuck
|
||||
// running at minimum flow while basin level dropped past stopLevel and
|
||||
// kept dropping all the way to dry-run threshold.
|
||||
//
|
||||
// Root cause (two parts):
|
||||
//
|
||||
// 1. rotatingMachine.executeSequence on shutdown went through an
|
||||
// interruptible-abort path that returned the FSM to 'operational',
|
||||
// triggering state.transitionToState's auto-pickup of the queued
|
||||
// delayedMove — re-engaging the pump before the shutdown sequence
|
||||
// could reach stopping/coolingdown/idle. Fix: clear delayedMove at
|
||||
// the top of shutdown/emergencystop sequences.
|
||||
//
|
||||
// 2. PS calls turnOffAllMachines on every tick (every 2 s) while
|
||||
// level < stopLevel. Each call interrupted the still-running prior
|
||||
// shutdown's transitions, resetting the FSM to 'accelerating'. The
|
||||
// pump bounced accelerating ↔ decelerating forever and the actual
|
||||
// shutdown sequence transitions never ran. Fix: serialize per-pump
|
||||
// shutdown calls in turnOffAllMachines so concurrent invocations
|
||||
// are no-ops while a shutdown is already in flight.
|
||||
//
|
||||
// This test exercises part 2 — the per-pump serialization at the MGC
|
||||
// level — by hammering turnOffAllMachines from a tight loop, mirroring
|
||||
// the live tick cadence.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const logCfg = { enabled: false, logLevel: 'error' };
|
||||
|
||||
const stateConfig = {
|
||||
general: { logging: logCfg },
|
||||
state: { current: 'idle' },
|
||||
movement: { mode: 'staticspeed', speed: 50, maxSpeed: 100, interval: 10 },
|
||||
// Non-zero shutdown timing so a shutdown takes long enough that a
|
||||
// concurrent turnOff call lands mid-sequence — exactly the live race.
|
||||
time: { starting: 0, warmingup: 0, stopping: 1, coolingdown: 1 },
|
||||
};
|
||||
|
||||
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() {
|
||||
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) {
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
|
||||
m.updateMeasuredPressure(1100, '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));
|
||||
|
||||
test('repeated turnOffAllMachines reaches idle (serializes concurrent shutdowns)', async () => {
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const pumpA = pumps[0];
|
||||
|
||||
// Start pump A and queue a delayedMove the way MGC's optimalControl
|
||||
// would when PS sends a 1% dead-zone keep-alive.
|
||||
await pumpA.handleInput('parent', 'execsequence', 'startup');
|
||||
assert.equal(pumpA.state.getCurrentState(), 'operational');
|
||||
pumpA.setpoint(80); // start a slow move (not awaited)
|
||||
await sleep(50);
|
||||
assert.equal(pumpA.state.getCurrentState(), 'accelerating');
|
||||
pumpA.state.delayedMove = 75;
|
||||
|
||||
// Mimic PS's tick loop: fire turnOffAllMachines on a tight cadence
|
||||
// without awaiting. Without the per-pump serialization in
|
||||
// turnOffAllMachines, each call hits the still-running prior shutdown
|
||||
// and bounces the pump back to accelerating — the live deadlock.
|
||||
const ticks = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
ticks.push(mgc.turnOffAllMachines());
|
||||
await sleep(80); // half the realtime tick — tighter race
|
||||
}
|
||||
await Promise.all(ticks);
|
||||
// Allow the (single) in-flight shutdown to finish its 1+1 s timed
|
||||
// transitions through stopping → coolingdown → idle.
|
||||
await sleep(2500);
|
||||
|
||||
assert.equal(pumpA.state.getCurrentState(), 'idle',
|
||||
`pump must reach idle under repeated turnOff calls; got ${pumpA.state.getCurrentState()} (delayedMove=${pumpA.state.delayedMove})`);
|
||||
assert.equal(pumpA.state.delayedMove, null,
|
||||
'delayedMove must be cleared after shutdown');
|
||||
});
|
||||
|
||||
test('turnOffAllMachines clears MGC._delayedCall to cancel any deferred dispatch', async () => {
|
||||
// PS sends a 1% keep-alive while MGC is mid-dispatch. MGC parks it in
|
||||
// _delayedCall. PS then crosses stopLevel and calls turnOffAllMachines.
|
||||
// Without clearing _delayedCall, MGC's finally block fires the parked
|
||||
// 1% call AFTER the shutdown — re-engaging the pump.
|
||||
const { mgc } = buildGroup();
|
||||
mgc._delayedCall = { source: 'parent', demand: 1, powerCap: Infinity, priorityList: null };
|
||||
|
||||
await mgc.turnOffAllMachines();
|
||||
|
||||
assert.equal(mgc._delayedCall, null,
|
||||
'turnOff must cancel any deferred dispatch so it cannot re-engage pumps post-shutdown');
|
||||
});
|
||||
Reference in New Issue
Block a user