Once a rendezvous plan is committed it now runs to completion untouched: an ordinary new setpoint arriving while the group is 'working' is remembered (latest wins) and dispatched sequentially when the group reaches 'ready', instead of aborting + re-planning. A re-plan mid-flight dropped the in-flight schedule and re-deferred a pump that was mid-sequence, parking starting pumps at minimum flow. Only an EMERGENCY pre-empts the lock: a stop (≤0) or a pressure excursion. _isUrgentDemand (which pre-empted on any large step) is replaced by _isEmergencyDemand; the large-step pre-emption is gone — large operator steps now defer like any other setpoint. _pressureEmergency() reads planner.emergencyPressurePa and is INERT until that threshold is configured; handlePressureChange fires a latched bypass dispatch when it breaches. Verified live on the E2E Isolated MGC rig: a 1→2 pump staging transition ramps the added pump straight through (no wait-at-minimum, no start-then-stop) and the group total climbs monotonically. (The Pump-tab node's hunting is a separate demand-feedback-loop issue in that flow's wiring, not the rendezvous.) Integration tests now settle to 'ready' between demands (waitReady) since the lock defers setpoints arriving mid-move. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
6.2 KiB
JavaScript
139 lines
6.2 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 };
|
||
}
|
||
|
||
// Settle to 'ready' between demands. The rendezvous lock defers a new setpoint
|
||
// that arrives while the group is still 'working', so each sweep step must wait
|
||
// for the previous move to land before issuing (and reading) the next.
|
||
async function waitReady(mgc, timeoutMs = 6000) {
|
||
const t0 = Date.now();
|
||
while (Date.now() - t0 < timeoutMs) {
|
||
if (mgc.getMovementState?.() === 'ready') return true;
|
||
try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ }
|
||
await new Promise(r => setTimeout(r, 40));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
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 waitReady(mgc);
|
||
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).`);
|
||
});
|