Files
machineGroupControl/test/integration/optimizer-combination-choice.integration.test.js

169 lines
7.9 KiB
JavaScript
Raw Normal View History

// 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: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
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' },
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`);
});