// 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. (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(); } });