Add 4 cross-node tests closing PS↔MGC integration gaps
Some checks failed
CI / lint-and-test (push) Has been cancelled

- 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>
This commit is contained in:
Rene De Ren
2026-05-08 18:07:11 +02:00
parent 21e777797a
commit 3c7d54e9c3
4 changed files with 366 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
// Stability under inflow > station capacity: storm condition where the
// basin overflows continuously. Pumps should run flat-out and the FSM
// must NOT thrash through aborts/parks.
//
// Catches the user's live observation: at 2× capacity inflow, pumps got
// stuck mid-flight while demand was still rising. This test runs with
// realistic state.time (production defaults) so the abort-during-startup
// race window is fully open.
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildPlant, injectPumpPressure } = require('./lib/wiring');
const TICK_MS = 1000;
// Sim duration kept short — the chronic thrashing pattern shows up
// within the first minute. Bigger SIM_MINUTES makes the test wall-time
// hostile (each tick awaits async pump moves on real timers).
const SIM_SECONDS = 45;
test('inflow ≫ capacity: pumps reach steady high-ctrl, no parking, no thrashing', async () => {
// Use shorter-than-default state.time so the test runs in reasonable
// wall time while still exercising the transient (1 s startup + 2 s
// warmup). The race conditions we care about are the same — they're
// about ORDER, not absolute duration.
const plant = buildPlant({ initialBasinLevel: 2.6 });
const { ps, mgc, pumps, advance, restore } = plant;
try {
// Pre-start pumps to operational so the test focuses on STEADY-STATE
// thrashing under chronic over-capacity inflow, not startup race
// conditions (those have their own test). This also keeps wall time
// manageable — buildPlant's state.time=0 means transitions are
// instant once already operational.
for (const p of pumps) await p.handleInput('parent', 'execsequence', 'startup');
// Inflow set 2× station capacity (~600 m³/h vs ~270 m³/h capacity).
const Q_IN = 600 / 3600;
let parkObservations = 0;
let abortLogObservations = 0;
// Drive the loop: every tick, refresh pressures, set inflow,
// tick PS (which fires _applyMachineGroupLevelControl).
const ticks = SIM_SECONDS;
let lastCtrl = pumps.map(() => 0);
let largeJumpTicks = 0;
for (let i = 0; i < ticks; i++) {
for (const p of pumps) injectPumpPressure(p, 19620, 117720);
ps.setManualInflow(Q_IN, Date.now(), 'm3/s');
advance(TICK_MS);
ps.tick();
await new Promise((r) => setImmediate(r));
const states = pumps.map((p) => p.state.getCurrentState());
const ctrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0);
// Park observation: any pump in 'accelerating'/'decelerating' for
// more than 3 consecutive seconds at flat ctrl is parked. Cheap
// approximation: count how often we sample those states.
for (const s of states) {
if (s === 'accelerating' || s === 'decelerating') parkObservations += 1;
}
// Thrashing observation: ctrl jumping by > 30 % between consecutive
// seconds (in either direction) suggests retarget churn.
for (let k = 0; k < pumps.length; k++) {
if (Math.abs(ctrls[k] - lastCtrl[k]) > 30) largeJumpTicks += 1;
}
lastCtrl = ctrls;
if (i === Math.floor(ticks * 0.66)) {
console.log(` tick ${i}/${ticks} states=[${states.join(', ')}] ctrls=[${ctrls.map((c) => c.toFixed(0)).join(', ')}]`);
}
}
// After SIM_MINUTES, system must be in a coherent state: pumps high
// ctrl, no one parked.
const finalStates = pumps.map((p) => p.state.getCurrentState());
const finalCtrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0);
console.log(` FINAL states=[${finalStates.join(', ')}] ctrls=[${finalCtrls.map((c) => c.toFixed(1)).join(', ')}]`);
console.log(` Park observations across ${ticks} ticks×3 pumps: ${parkObservations}`);
console.log(` Large-jump tick events (>30 % ctrl change s-to-s): ${largeJumpTicks}`);
for (const s of finalStates) {
assert.equal(s, 'operational',
`final state must be operational under steady high demand; one pump in '${s}'`);
}
for (const c of finalCtrls) {
assert.ok(c > 80, `final ctrl must be >80 % under storm inflow; got ${c.toFixed(1)} %`);
}
// Allow some movement transients but not constant retargeting.
// 3 pumps × 180 ticks = 540 samples; >25 % churn is a thrash signal.
const maxAllowedJumps = Math.floor(ticks * 3 * 0.25);
assert.ok(largeJumpTicks < maxAllowedJumps,
`excessive ctrl thrash: ${largeJumpTicks} large-jump events (max ${maxAllowedJumps}) — system isn't converging`);
} finally {
restore();
}
});