126 lines
5.6 KiB
JavaScript
126 lines
5.6 KiB
JavaScript
|
|
// Empirical answer: does absDistFromPeak / relDistFromPeak move with demand?
|
|||
|
|
// Drives the live MGC + 3 identical pumps (same model as the dashboard demo)
|
|||
|
|
// across a demand sweep and records what each metric actually does. The test
|
|||
|
|
// asserts the expected qualitative shape, so any future change that
|
|||
|
|
// regresses BEP-distance sensitivity will fail loudly.
|
|||
|
|
|
|||
|
|
const test = require('node:test');
|
|||
|
|
const assert = require('node:assert/strict');
|
|||
|
|
|
|||
|
|
const RM = require('../../../rotatingMachine/src/specificClass');
|
|||
|
|
const MGC = require('../../src/specificClass');
|
|||
|
|
const { getOutput } = require('../../src/io/output');
|
|||
|
|
|
|||
|
|
const PUMP_MODEL = 'hidrostal-H05K-S03R';
|
|||
|
|
const HEADER_DP_MBAR = 1100;
|
|||
|
|
|
|||
|
|
// stateConfig.time = 0 for every transition so warmup/cooldown don't add real
|
|||
|
|
// seconds — without this the 4-demand sweep × 3 pumps takes >120s and the test
|
|||
|
|
// runner kills it.
|
|||
|
|
const INSTANT_STATE = {
|
|||
|
|
time: { starting: 0, warmingup: 0, operational: 0, accelerating: 0,
|
|||
|
|
decelerating: 0, stopping: 0, coolingdown: 0, idle: 0,
|
|||
|
|
maintenance: 0, emergencystop: 0, off: 0 },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function mkPump(id) {
|
|||
|
|
return new RM({
|
|||
|
|
general: { id, name: id },
|
|||
|
|
asset: { model: PUMP_MODEL, unit: 'm3/h' },
|
|||
|
|
}, INSTANT_STATE);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function buildGroupWithPressure() {
|
|||
|
|
const mgc = new MGC({
|
|||
|
|
general: { id: 'mgc', name: 'mgc' },
|
|||
|
|
functionality: { mode: { current: 'optimalControl' }, positionVsParent: 'atEquipment' },
|
|||
|
|
});
|
|||
|
|
const pumps = ['A','B','C'].map(l => mkPump(`pump-${l}`));
|
|||
|
|
for (const p of pumps) {
|
|||
|
|
mgc.childRegistrationUtils?.registerChild?.(p, 'atEquipment');
|
|||
|
|
}
|
|||
|
|
for (const p of pumps) {
|
|||
|
|
p.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-up' });
|
|||
|
|
p.updateMeasuredPressure(HEADER_DP_MBAR, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-dn' });
|
|||
|
|
}
|
|||
|
|
// Let pressure events propagate through the emitter chain.
|
|||
|
|
await new Promise(r => setTimeout(r, 50));
|
|||
|
|
return { mgc, pumps };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function sweepDemand(mgc, demands_m3h) {
|
|||
|
|
const rows = [];
|
|||
|
|
for (const Qd_m3h of demands_m3h) {
|
|||
|
|
const Qd = Qd_m3h / 3600; // m3/h → m3/s
|
|||
|
|
try { await mgc.handleInput('parent', Qd); }
|
|||
|
|
catch (e) { /* turnOff or no-combination paths are part of the contract */ }
|
|||
|
|
await new Promise(r => setTimeout(r, 30));
|
|||
|
|
const out = getOutput(mgc);
|
|||
|
|
rows.push({
|
|||
|
|
demand: Qd_m3h,
|
|||
|
|
flow: out.atEquipment_predicted_flow,
|
|||
|
|
eta: out.atEquipment_predicted_efficiency,
|
|||
|
|
absDist: out.absDistFromPeak,
|
|||
|
|
relDist: out.relDistFromPeak,
|
|||
|
|
ncog: out.atEquipment_predicted_Ncog,
|
|||
|
|
nAct: out.machineCountActive,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return rows;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
test('absDistFromPeak rises when demand pushes pumps off BEP', async () => {
|
|||
|
|
const { mgc } = await buildGroupWithPressure();
|
|||
|
|
// Sweep covers "comfortably within combined BEP" (low/mid) and "over the
|
|||
|
|
// group's BEP envelope, pumps must push" (high). For hidrostal-H05K-S03R
|
|||
|
|
// at 1100 mbar, single-pump max ≈ 230 m³/h, 3-pump max ≈ 680 m³/h. Demand
|
|||
|
|
// 600 m³/h forces each pump well past BEP.
|
|||
|
|
const rows = await sweepDemand(mgc, [100, 200, 300, 600]);
|
|||
|
|
|
|||
|
|
// Sanity: pumps actually accepted the demand and flow is rising.
|
|||
|
|
assert.ok(rows[3].flow > rows[0].flow + 100,
|
|||
|
|
`flow should rise with demand, got ${JSON.stringify(rows.map(r => r.flow))}`);
|
|||
|
|
|
|||
|
|
// absDist should be larger at over-capacity demand than at within-capacity.
|
|||
|
|
// Use a generous tolerance — the test asserts the QUALITATIVE shape, not
|
|||
|
|
// exact numbers (which depend on curve interpolation).
|
|||
|
|
const lowAbs = Math.min(rows[0].absDist, rows[1].absDist, rows[2].absDist);
|
|||
|
|
const highAbs = rows[3].absDist;
|
|||
|
|
assert.ok(highAbs > lowAbs + 0.005,
|
|||
|
|
`absDistFromPeak should be larger off-BEP than on-BEP. ` +
|
|||
|
|
`low (Qd∈{100,200,300}): min=${lowAbs}, high (Qd=600): ${highAbs}. ` +
|
|||
|
|
`Full rows: ${JSON.stringify(rows, null, 2)}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('absDistFromPeak ≈ 0 across the within-BEP demand range (working as designed)', async () => {
|
|||
|
|
const { mgc } = await buildGroupWithPressure();
|
|||
|
|
const rows = await sweepDemand(mgc, [100, 200, 300]);
|
|||
|
|
// The BEP-Gravitation optimizer is supposed to KEEP us at BEP for demands
|
|||
|
|
// the group can absorb at BEP. So absDist staying near zero across the
|
|||
|
|
// "easy" range is the correct outcome — NOT a bug. This test pins that
|
|||
|
|
// behaviour so any future "fix" that introduces drift here fails.
|
|||
|
|
for (const r of rows) {
|
|||
|
|
assert.ok(r.absDist != null && r.absDist < 0.02,
|
|||
|
|
`at demand ${r.demand} m³/h, absDist=${r.absDist} should be near zero ` +
|
|||
|
|
`(optimizer holds BEP); only off-BEP demand should produce noticeable drift`);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('relDistFromPeak is structurally ill-defined for homogeneous pump groups', async () => {
|
|||
|
|
const { mgc } = await buildGroupWithPressure();
|
|||
|
|
const rows = await sweepDemand(mgc, [100, 200, 300, 600]);
|
|||
|
|
// 3 identical pumps → all cogs equal → max=mean=min in calcDistanceBEP.
|
|||
|
|
// The interpolation [max..min] → [0..1] collapses; the metric is
|
|||
|
|
// mathematically undefined here. Whatever value comes out is float-noise
|
|||
|
|
// dependent and MUST NOT be interpreted as "BEP distance percentage".
|
|||
|
|
// This test documents the limitation as a contract; it deliberately does
|
|||
|
|
// not assert a specific value — it asserts the metric does NOT move
|
|||
|
|
// monotonically with demand (which it shouldn't for identical pumps).
|
|||
|
|
const uniqueRel = new Set(rows.map(r => r.relDist));
|
|||
|
|
assert.ok(uniqueRel.size <= 2,
|
|||
|
|
`relDistFromPeak is expected to be effectively constant for identical pumps. ` +
|
|||
|
|
`Distinct values across sweep: ${[...uniqueRel].join(', ')}. ` +
|
|||
|
|
`If you want this metric to track demand, configure pumps with different ` +
|
|||
|
|
`peak η (different models or different curve scaling).`);
|
|||
|
|
});
|