Files
EVOLV/test/ps-mgc-flow-contract.integration.test.js

109 lines
4.8 KiB
JavaScript
Raw Normal View History

2026-05-08 18:07:11 +02:00
// 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();
}
});