Files
EVOLV/test/ps-mgc-flow-contract.integration.test.js
Rene De Ren 3c7d54e9c3
Some checks failed
CI / lint-and-test (push) Has been cancelled
Add 4 cross-node tests closing PS↔MGC integration gaps
- ps-mgc-flow-contract: asserts PS's view of MGC outflow equals the live
  per-pump aggregate at every tick. Currently FAILS — exposes that
  MGC's flow.predicted.downstream reverts to optimalControl's bestFlow
  target after handlePressureChange writes the correct flow.act, leaving
  PS with stale outflow values. The mirror added in dc27a56 is necessary
  but not sufficient.

- dead-zone-signal: asserts the Schmitt-trigger transitions
  (engaged 100% → keep-alive 1% → off 0%) across startLevel↓/stopLevel↓
  with proper rising-edge re-arm. Currently PASSES.

- inflow-overcapacity-stability: 45 s sim at 2× station capacity;
  asserts pumps don't thrash or park in accelerating residue. Currently
  FAILS — pumps end up at ctrl=0 in 'accelerating' state, suggesting
  the residue-unpark fix doesn't fully cover steady-state over-capacity.

- realistic-startup-timing: re-runs the varying-demand-during-startup
  scenario with PRODUCTION-default state.time (starting=10s, warm=5s)
  instead of the 1-2 s used elsewhere. Currently PASSES — confirms the
  dispatch-reorder fix holds under realistic transition windows.

Honest summary: 2 pass, 2 fail. The two failures expose genuine
remaining defects in the PS↔MGC measurement contract and the
residue-unpark policy. They're committed FAILING so the bugs are
captured under version control until the underlying fixes land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:07:11 +02:00

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