109 lines
4.8 KiB
JavaScript
109 lines
4.8 KiB
JavaScript
|
|
// Cross-node contract test: PS's view of MGC outflow MUST track the
|
||
|
|
// actual aggregate pump flow at all times — not the optimizer's bestFlow
|
||
|
|
// target, not a cached value, not a value lagging by a tick.
|
||
|
|
//
|
||
|
|
// Closes the gap that let the "PS sees stale 25 m³/h while pumps deliver
|
||
|
|
// 575 m³/h" bug ship to production. Drives a demand sweep through several
|
||
|
|
// regimes (low / mid / high / dropdown) and asserts at every tick that
|
||
|
|
// sum(pump.predictFlow.outputY) ≈ ps.flow.predicted.out.mgc
|
||
|
|
// within a small tolerance. Any future regression that decouples MGC's
|
||
|
|
// emitted flow.predicted.downstream from the live aggregate fails here.
|
||
|
|
|
||
|
|
const test = require('node:test');
|
||
|
|
const assert = require('node:assert/strict');
|
||
|
|
const { buildPlant, injectPumpPressure } = require('./lib/wiring');
|
||
|
|
|
||
|
|
const TICK_MS = 1000;
|
||
|
|
|
||
|
|
function aggregatePumpFlow_m3h(pumps) {
|
||
|
|
// Sum each pump's PUBLISHED predicted-flow measurement, NOT
|
||
|
|
// predictFlow.outputY directly. Production code paths (MGC's
|
||
|
|
// calcDynamicTotals, PS's net-flow calc) all read from the
|
||
|
|
// measurement bus — so that's the value the contract is about.
|
||
|
|
// predictFlow.outputY can drift away from the measurement when a
|
||
|
|
// pump's state turns non-operational (the predict still has a curve
|
||
|
|
// value at the last ctrl, but the measurement is forced to 0).
|
||
|
|
let s = 0;
|
||
|
|
for (const p of pumps) {
|
||
|
|
const v = p.measurements
|
||
|
|
.type('flow').variant('predicted').position('downstream')
|
||
|
|
.getCurrentValue('m3/h');
|
||
|
|
if (Number.isFinite(Number(v))) s += Number(v);
|
||
|
|
}
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
|
||
|
|
function psOutflow_m3h(ps) {
|
||
|
|
// PS stores MGC's outflow as flow.predicted.out.<mgcId> (childId='mgc'
|
||
|
|
// in our wiring). _selectBestNetFlow sums all 'out' children, but for
|
||
|
|
// this contract we want JUST the MGC contribution to assert the bridge.
|
||
|
|
const v = ps.measurements.type('flow').variant('predicted').position('out')
|
||
|
|
.child('mgc').getCurrentValue('m3/h');
|
||
|
|
return Number.isFinite(Number(v)) ? Number(v) : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runDemandSweep(plant, demands, opts = {}) {
|
||
|
|
const { ps, mgc, pumps, advance } = plant;
|
||
|
|
const dwellTicks = opts.dwellTicks ?? 3;
|
||
|
|
const violations = [];
|
||
|
|
|
||
|
|
for (const pct of demands) {
|
||
|
|
// Issue demand directly to MGC (mirrors PS._applyMachineGroupLevelControl)
|
||
|
|
await mgc.handleInput('parent', pct);
|
||
|
|
|
||
|
|
for (let t = 0; t < dwellTicks; t++) {
|
||
|
|
// Refresh pump pressures so predictFlow stays in valid range.
|
||
|
|
for (const p of pumps) injectPumpPressure(p, 19620, 117720);
|
||
|
|
advance(TICK_MS);
|
||
|
|
ps.tick();
|
||
|
|
// Let the event loop drain queued measurement events.
|
||
|
|
await new Promise((r) => setImmediate(r));
|
||
|
|
|
||
|
|
const aggregate = aggregatePumpFlow_m3h(pumps);
|
||
|
|
const psView = psOutflow_m3h(ps);
|
||
|
|
const delta = Math.abs(aggregate - psView);
|
||
|
|
// Tolerance: 5 m³/h OR 5 % of aggregate, whichever is larger. The
|
||
|
|
// aggregate is what the pumps' predictFlow currently holds; PS reads
|
||
|
|
// it via the MGC handlePressureChange mirror. The two should be
|
||
|
|
// within one event-loop tick.
|
||
|
|
const tol = Math.max(5, aggregate * 0.05);
|
||
|
|
if (delta > tol) {
|
||
|
|
violations.push({ pct, t, aggregate: aggregate.toFixed(1), psView: psView.toFixed(1), delta: delta.toFixed(1) });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return violations;
|
||
|
|
}
|
||
|
|
|
||
|
|
test('PS↔MGC flow contract — psOutflow tracks aggregate pump flow across demand sweep', async () => {
|
||
|
|
// Realistic state.time so transients are observable. Pumps start idle.
|
||
|
|
const plant = buildPlant({ initialBasinLevel: 2.6 });
|
||
|
|
const { ps, mgc, pumps, restore } = plant;
|
||
|
|
try {
|
||
|
|
// Bring the chain to a known operational state first so the contract
|
||
|
|
// applies during the steady-state portion of the sweep too.
|
||
|
|
for (const p of pumps) await p.handleInput('parent', 'execsequence', 'startup');
|
||
|
|
|
||
|
|
// Demand sweep covers all the regimes:
|
||
|
|
// - high (3-pump combo) → big aggregate, must match
|
||
|
|
// - mid (2-pump combo) → some pumps idle at 0
|
||
|
|
// - low (1-pump combo) → 2 pumps idle, 1 running
|
||
|
|
// - 0% (all off) → both sides should read 0
|
||
|
|
// - jump back to 100% → recovery from off
|
||
|
|
// - drop from 100% to 5% → the exact transient the bug lived in
|
||
|
|
const demands = [100, 70, 50, 30, 15, 0, 100, 5, 100, 0];
|
||
|
|
const violations = await runDemandSweep(plant, demands, { dwellTicks: 4 });
|
||
|
|
|
||
|
|
if (violations.length) {
|
||
|
|
console.log('\n[PS↔MGC contract VIOLATIONS]');
|
||
|
|
for (const v of violations) {
|
||
|
|
console.log(` cmd=${v.pct}% t=${v.t}: aggregate=${v.aggregate} m³/h, PS view=${v.psView} m³/h, delta=${v.delta} m³/h`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
assert.equal(violations.length, 0,
|
||
|
|
`${violations.length} contract violations across the sweep — PS's view of outflow drifted from the actual aggregate. See log above.`);
|
||
|
|
} finally {
|
||
|
|
restore();
|
||
|
|
}
|
||
|
|
});
|