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