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

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

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

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

170 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MGC 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`);
});