170 lines
8.0 KiB
JavaScript
170 lines
8.0 KiB
JavaScript
|
|
// 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`);
|
|||
|
|
});
|