Compare commits

...

15 Commits

Author SHA1 Message Date
Rene De Ren
ea2857fb25 fix: serialize per-pump shutdown + cancel deferred dispatch in turnOffAllMachines
PS calls turnOffAllMachines on every tick once level < stopLevel. Two
ways the pump could re-engage after we shut it down:

1. _delayedCall: a 1% dead-zone keep-alive parked in MGC's deferred
   dispatch fires from the in-flight handleInput's finally block AFTER
   the shutdown completes, dispatching flow + startup to a fresh pump.
   Clear _delayedCall at the top of turnOff.

2. Concurrent shutdown calls on the same pump interrupt each other
   before the sequence can transition past stopping. Track shutdown-
   in-flight per pump and skip if one is already underway.

Together with the rotatingMachine delayedMove-clearing fix, this lets
the level-based hysteresis cycle complete: pumps shut off cleanly at
stopLevel, basin reverses direction, refills to startLevel, repeat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:17:55 +02:00
Rene De Ren
2651aaf409 abortActiveMovements: only WARN when actually aborting an in-flight move
In normal operation the _dispatchInFlight gate (handleInput)
guarantees no pump movement is in flight when a new dispatch starts,
so the per-machine abort call is a no-op. The previous unconditional
WARN flooded the log with one line per pump per tick (~3/s) for what
was actually a normal-path no-op.

Now the WARN fires ONLY when a pump's state is accelerating or
decelerating — i.e. the gate has been bypassed and we're force-
aborting an in-flight ramp. The wording reflects that:

  Force-aborting in-flight movement on pump_a (state=accelerating)
  due to: new demand received — _dispatchInFlight gate bypassed.

If you ever see this in production logs, the gate has a hole and
needs investigating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:43:12 +02:00
Rene De Ren
df74ea0fac Serialize handleInput dispatches via _dispatchInFlight gate
Mirrors rotatingMachine state.delayedMove. PS ticks demand into MGC at
1 Hz but a real pump ramp takes several seconds; before this gate every
PS tick aborted the in-flight optimalControl and started a new one, so
pumps never reached their setpoint. Live observation: 120 aborts / 2
min, pump_a drifting to 138 m³/h while pump_b stayed clamped at minFlow
60 m³/h ("near_curve_edge").

While a dispatch is in flight, the latest {source, demand, powerCap,
priorityList} is parked in _delayedCall and the new call returns.
The in-flight dispatch's finally block picks up the latest delayed
value when it settles. Latest-wins — intermediate demands are stomped
because they were obsolete by the time the pumps were ready for them.

Regression test in superproject:
test/mgc-overactive-demand-serialization.integration.test.js
30 concurrent demand calls now produce ≤ 5 aborts (was 30).

All existing tests still pass: 21 MGC integration + 7 cross-node
integration (incl. realistic-startup-timing, inflow-overcapacity-
stability, ps-mgc-flow-contract, idle-startup-deadlock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:14:59 +02:00
Rene De Ren
96b84d3124 Revert: handleInput unchanged-demand short-circuit
Reverts a14aa0d. The "skip when demand unchanged" optimisation broke
the live demo: in some real conditions (basin transitions, safety
controller activations) PS sends repeated demand=0 and the optimisation
correctly turned pumps off the first time but then declined to re-act
when conditions changed in a way the test suite didn't cover. Live
result: pumps stayed off even when basin filled to overflow.

The original symptom (pumps stuck mid-ramp under saturated demand) needs
a different approach — likely a pump-side guard rather than an MGC-side
demand filter. Investigating in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:55:41 +02:00
Rene De Ren
a14aa0dab8 handleInput: skip abort+redispatch when demand unchanged
Live trace showed PS ticking every 1 s and re-firing the SAME demand
(100% saturated under storm inflow) while the basin level evolved slowly.
Each tick was calling abortActiveMovements + optimalControl, which
aborted in-flight pump moves before they could finish (move duration
~0.4 s vs 1 s tick) and immediately re-issued the same setpoint. Pumps
got stuck ramping from the same starting position toward the same
target indefinitely — moveTimeleft stable at 0.379 s for minutes,
flow.predicted frozen.

Now early-return when |demandQ - prev| < max(0.5, prev*0.005). PS
hysteresis float jitter is filtered, real demand changes still
propagate. Pumps finish their first move and stay at the right
setpoint instead of being aborted forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:10:50 +02:00
Rene De Ren
69bdf11fc4 DOWNSTREAM is the live aggregate; AT_EQUIPMENT is the optimizer's intent
handlePressureChange writes the live aggregate (sum of every pump's
current predicted-flow measurement) to flow.predicted.downstream — that
is the channel PS subscribes to for its outflow estimate, and it must
reflect what pumps are actually delivering.

optimalControl + equalFlowControl + prioPercentageControl were also
writing to DOWNSTREAM with the optimizer's TARGET (bestFlow / totalFlow).
That's a planned setpoint, not an achieved aggregate, and it was
clobbering the live value every handleInput tick — leaving PS reading
e.g. 105 m³/h while the real aggregate was 681 m³/h. Test
ps-mgc-flow-contract caught this deterministically.

Move all the optimizer-target writes to AT_EQUIPMENT (the "what we
commanded the equipment to do" channel). DOWNSTREAM is now
single-writer (handlePressureChange) and faithfully tracks reality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:32:58 +02:00
Rene De Ren
dc27a569d9 handlePressureChange: mirror aggregate flow onto DOWNSTREAM
PS subscribes to MGC's flow.predicted.downstream and uses it as the
outflow estimate for net-flow computation. MGC was only writing to
DOWNSTREAM inside optimalControl (the optimizer's bestFlow TARGET, not
the achieved aggregate), and to AT_EQUIPMENT in handlePressureChange.

During transients — e.g. demand dropping to dead-band keep-alive while
pumps are still ramping down from full throttle — PS saw a stale 25 m³/h
target on DOWNSTREAM while pumps were physically delivering 500+ m³/h.
NetFlow looked small and stable when the basin was actually draining
fast.

flow.act = sum of every pump's current predicted output = achieved
aggregate. Mirror it onto DOWNSTREAM so PS gets a live signal on every
pump flow/pressure update, not just every MGC.handleInput.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:20:21 +02:00
Rene De Ren
b7c40b0ddc equalFlowControl: mirror the optimalControl dispatch reorder
The priority-control codepath had the same stale dispatch shape that
caused the live deadlock in optimalControl: only handling idle and
operational states, and chaining flowmovement after execsequence
startup. Aligns it with the optimalControl fix so a future mode switch
to prioritycontrol doesn't reintroduce the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:47:17 +02:00
Rene De Ren
8e684203a8 Test: add full-cycle and up/down-sweep regression scenarios
Scenario 5 covers 100% → 0% → 100% with the second 100% landing
mid-shutdown (stopping/coolingdown) — exercises the path where
delayedMove must NOT be saved on a non-idle non-residue state without
a follow-up startup, since transitionToState('idle') doesn't fire it.

Scenario 6 walks 10%→100%→10% monotonically and asserts the down-sweep's
final demand is honoured (catches the user's observed "stuck around
60% going up, no reaction going down" symptom — where pumps would
otherwise freeze at a stale setpoint from the up-sweep).

Both pass with the current MGC dispatch fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:33:56 +02:00
Rene De Ren
9916527790 optimalControl: dispatch setpoint to non-operational pumps too
Previously the dispatch loop only fired flowmovement for pumps in
'operational' or transitioned 'idle' pumps via execsequence-startup-then-flowmovement.
Pumps mid-startup (starting/warmingup) were silently skipped. With PS
sending demand every tick, intermediate setpoints during the startup
window never reached the pump — it locked onto the very first
snapshot's flowmovement and froze there.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:19:47 +02:00
znetsixe
9c79dac4e3 Fix stale flow cache on MGC shutdown; correct NCog physics tests
### Bug fix — stale flow cache on shutdown (specificClass.js)

When turnOffAllMachines() fires (negative demand, zero flow demand, or
safety trip), the MGC was only shutting pumps down. The pumps' last
emitted predicted flow / power stayed in the MeasurementContainer,
so the parent pumpingStation kept computing net flow from cached
non-zero values — reading the MGC as "still draining" when it wasn't.
Net: net-flow direction and safety triggers misfired during and
shortly after an MGC shutdown.

Fix: after shutting down all machines, write 0 to the predicted
flow (downstream + atEquipment) and predicted power (atEquipment)
slots so the cache reflects reality immediately.

### Correctness — async/await on shutdown (specificClass.js)

Two call sites invoked turnOffAllMachines() without awaiting it, so
the subsequent `return` raced the shutdown promises. Now awaited.
Also DRY'd one inline shutdown loop into a call to
turnOffAllMachines().

### Physics correction — NCog for centrifugal pumps (integration tests)

The previous tests asserted NCog > 0 for centrifugal pumps. That's
physically wrong: for variable-speed centrifugal pumps P ∝ n³ and
Q ∝ n, so Q/P ∝ 1/n² is monotonically decreasing with speed. Peak
efficiency (peak Q/P) is always at minimum speed → cogIndex = 0 →
NCog = 0 by the current formula.

Tests now:
- Assert NCog == 0 for all centrifugal configurations
- Assert distributeByNCog() falls back to equal distribution when
  NCog == 0 (confirmed by the existing tests 4-6 that slope-based
  redistribution is what actually differentiates pumps with different
  BEPs — not NCog)

This matches the actual implementation; the previous tests were
asserting an idealised COG model that doesn't apply here.

### Editor hygiene (mgc.html, nodeClass.js)

- mgc.html: add missing asset-menu defaults (uuid, supplier, category,
  assetType, model, unit) — brings MGC in line with rotatingMachine
  and pumpingStation editor shapes.
- nodeClass.js: clear node status badge on close.

All 13 tests (basic + integration) pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:51:10 +02:00
znetsixe
7eafd89f4e docs: add CLAUDE.md with S88 classification and superproject rule reference
References the flow-layout rule set in the EVOLV superproject
(.claude/rules/node-red-flow-layout.md) so Claude Code sessions working
in this repo know the S88 level, colour, and placement lane for this node.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:47:23 +02:00
znetsixe
d55f401ab3 fix: production hardening — unit mismatch, safety guards, marginal-cost refinement
- Fix flowmovement unit mismatch: MGC computed flow in canonical (m³/s)
  but rotatingMachine expects output units (m³/h). All flowmovement calls
  now convert via _canonicalToOutputFlow(). Without this fix, every pump
  stayed at minimum flow regardless of demand.
- Fix absolute scaling: demandQout vs demandQ comparison bug, reorder
  conditions so <= 0 is checked first, add else branch for valid demand.
- Fix empty Qd <= 0 block: now calls turnOffAllMachines().
- Add empty-machines guards on optimalControl and equalizePressure.
- Add null fallback (|| 0) on pressure measurement reads.
- Fix division-by-zero in calcRelativeDistanceFromPeak.
- Fix missing flowmovement after startup in equalFlowControl.
- Add marginal-cost refinement loop in BEP-Gravitation: after slope-based
  redistribution, iteratively shifts flow from highest actual dP/dQ to
  lowest using real power evaluations. Closes gap to brute-force optimum
  from 2.1% to <0.1% without affecting combination selection stability.
- Add NCog distribution comparison tests and brute-force power table test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:40:45 +02:00
znetsixe
ffb2072baa Merge commit '85797b5' into HEAD
# Conflicts:
#	src/nodeClass.js
#	src/specificClass.js
2026-03-31 18:17:41 +02:00
Rene De Ren
85797b5b8b Align machineGroupControl with current architecture 2026-03-12 16:43:29 +01:00
10 changed files with 2138 additions and 288 deletions

23
CLAUDE.md Normal file
View 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).

View File

@@ -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>

View File

@@ -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

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,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)}%`);
}
});

View 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');
});

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

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

View 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');
});