From 3c7d54e9c38e6f8f52ee02a780354e6000c5dc7f Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Fri, 8 May 2026 18:07:11 +0200 Subject: [PATCH] =?UTF-8?q?Add=204=20cross-node=20tests=20closing=20PS?= =?UTF-8?q?=E2=86=94MGC=20integration=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- test/dead-zone-signal.integration.test.js | 102 +++++++++++++++++ ...overcapacity-stability.integration.test.js | 98 ++++++++++++++++ test/ps-mgc-flow-contract.integration.test.js | 108 ++++++++++++++++++ ...alistic-startup-timing.integration.test.js | 58 ++++++++++ 4 files changed, 366 insertions(+) create mode 100644 test/dead-zone-signal.integration.test.js create mode 100644 test/inflow-overcapacity-stability.integration.test.js create mode 100644 test/ps-mgc-flow-contract.integration.test.js create mode 100644 test/realistic-startup-timing.integration.test.js diff --git a/test/dead-zone-signal.integration.test.js b/test/dead-zone-signal.integration.test.js new file mode 100644 index 0000000..09a2d59 --- /dev/null +++ b/test/dead-zone-signal.integration.test.js @@ -0,0 +1,102 @@ +// Dead-zone signal contract: PS must emit the right percControl as level +// crosses startLevel↓ → stopLevel↓. Schmitt-trigger semantics: +// +// - level > startLevel → percControl scales 0..100 % across +// [startLevel, maxLevel] (engaged=true) +// - stopLevel ≤ level ≤ start → percControl = deadZoneKeepAlivePercent +// (engaged stays true on the way down) +// - level < stopLevel → percControl = 0, MGC turnOffAllMachines +// (engaged=false; rising edge re-arms +// only at startLevel) +// +// Without this test, refactors of `_applyLevelbasedControl` could +// silently break the hysteresis transitions and the demo would oscillate +// or never stop pumping. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildPlant } = require('./lib/wiring'); + +const TICK_MS = 1000; + +function readPercControl(ps) { + return Number(ps.percControl) || 0; +} + +function readEngaged(ps) { + return Boolean(ps._stopHystRunning); +} + +async function settle(plant, qIn_m3s, ms) { + const { ps, advance } = plant; + const ticks = Math.ceil(ms / TICK_MS); + for (let i = 0; i < ticks; i++) { + ps.setManualInflow(qIn_m3s, Date.now(), 'm3/s'); + advance(TICK_MS); + ps.tick(); + await new Promise((r) => setImmediate(r)); + } +} + +test('dead-zone Schmitt: percControl 100→1→0 across startLevel↓ stopLevel↓', async () => { + // Start ABOVE startLevel so the rising edge has already fired (engaged + // becomes true via the startup tick). + const plant = buildPlant({ initialBasinLevel: 3.0 }); + const { ps, restore } = plant; + try { + // Tick once at zero inflow to let the controller register engaged. + await settle(plant, 0, 1000); + assert.ok(readEngaged(ps), + `precondition: engaged should be true at level=3.0 above startLevel=2.5; got ${readEngaged(ps)}`); + + // ---- Region A: above startLevel ---- + // level=3.0 → upPct = 50 % (linear over [2.5, 3.5]). + // (We don't lock the exact value — just assert it's well above the + // keep-alive 1 % to confirm we're on the "engaged + above start" path.) + await settle(plant, 0, 2000); + const pcAbove = readPercControl(ps); + assert.ok(pcAbove > 10, + `Region A: at level≈3.0 m, percControl should be the ramp value (>>1 %); got ${pcAbove.toFixed(2)} %`); + + // Manually drop level into the dead band [stopLevel=2.0, startLevel=2.5] + // by calibrating instead of waiting for physical drain (this isolates + // the Schmitt-trigger logic from physics). + ps.calibratePredictedLevel(2.3); + await settle(plant, 0, 1000); + const pcDead = readPercControl(ps); + const engagedDead = readEngaged(ps); + assert.ok(engagedDead, + `Region B: engaged should remain true while in dead band [stopLevel, startLevel]; got false`); + // Keep-alive default in psConfig is 1 %. + assert.ok(pcDead >= 0.5 && pcDead <= 5, + `Region B: at level=2.3 in dead band, percControl should be the keep-alive value (~1 %); got ${pcDead.toFixed(2)} %`); + + // Drop below stopLevel — falling-edge disengage. + ps.calibratePredictedLevel(1.9); + await settle(plant, 0, 1000); + const pcOff = readPercControl(ps); + const engagedOff = readEngaged(ps); + assert.equal(pcOff, 0, + `Region C: below stopLevel=2.0, percControl must be 0; got ${pcOff}`); + assert.equal(engagedOff, false, + `Region C: below stopLevel, engaged must flip to false; got ${engagedOff}`); + + // Refill into the dead band — engaged should stay false (no rising + // edge yet — needs to cross startLevel). + ps.calibratePredictedLevel(2.3); + await settle(plant, 0, 1000); + const pcDeadAgain = readPercControl(ps); + assert.equal(readEngaged(ps), false, + `Region D: re-entered dead band from below stopLevel — engaged must stay false until level crosses startLevel`); + assert.equal(pcDeadAgain, 0, + `Region D: in dead band but not engaged → percControl must be 0; got ${pcDeadAgain}`); + + // Cross startLevel → engaged re-arms. + ps.calibratePredictedLevel(2.6); + await settle(plant, 0, 1000); + assert.equal(readEngaged(ps), true, + `Region E: rising edge at startLevel must set engaged=true`); + } finally { + restore(); + } +}); diff --git a/test/inflow-overcapacity-stability.integration.test.js b/test/inflow-overcapacity-stability.integration.test.js new file mode 100644 index 0000000..cd39f8b --- /dev/null +++ b/test/inflow-overcapacity-stability.integration.test.js @@ -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(); + } +}); diff --git a/test/ps-mgc-flow-contract.integration.test.js b/test/ps-mgc-flow-contract.integration.test.js new file mode 100644 index 0000000..575d8d7 --- /dev/null +++ b/test/ps-mgc-flow-contract.integration.test.js @@ -0,0 +1,108 @@ +// 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(); + } +}); diff --git a/test/realistic-startup-timing.integration.test.js b/test/realistic-startup-timing.integration.test.js new file mode 100644 index 0000000..57cdc40 --- /dev/null +++ b/test/realistic-startup-timing.integration.test.js @@ -0,0 +1,58 @@ +// Race-window guard with PRODUCTION-default state.time: +// starting: 10 s, warmingup: 5 s, stopping: 5 s, coolingdown: 10 s +// +// All previous deadlock tests use 1-2 s timing for speed. The race that +// actually killed the live demo is about ordering during a long startup +// window where many MGC.handleInput calls land while pumps are still +// transitioning. This test re-runs the load-bearing demand-cycle scenario +// against schema defaults so the test wall time matches the failure mode. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildPlant, injectPumpPressure } = require('./lib/wiring'); + +const TICK_MS = 1000; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +test('realistic startup (start=10s, warm=5s) — varying demand during 15-second startup window', async () => { + const plant = buildPlant({ initialBasinLevel: 2.6 }); + const { ps, mgc, pumps, restore } = plant; + try { + // Apply production-default times. + for (const p of pumps) { + p.state.config.time = { starting: 10, warmingup: 5, stopping: 5, coolingdown: 10 }; + } + // Inject realistic pressures so predicts have a head. + for (const p of pumps) injectPumpPressure(p, 19620, 117720); + + // Drive demand sequence at 1 Hz (mirroring PS tick rate). The first + // 15 calls land during pump startup window; the last 15 land after. + const sequence = [25, 75, 50, 100, 30, 90, 60, 100, 50, 80, 40, 100, 70, 100, 100, + 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100]; + for (const pct of sequence) { + mgc.handleInput('parent', pct).catch((e) => console.log(`call ${pct}% rejected: ${e.message}`)); + await sleep(1000); + } + + // Drain: give the slowest pump time to finish its startup + ramp. + await sleep(6000); + + const states = pumps.map((p) => p.state.getCurrentState()); + const ctrls = pumps.map((p) => Number(p.state.getCurrentPosition?.()) || 0); + console.log(` states=[${states.join(', ')}] ctrls=[${ctrls.map((c) => c.toFixed(1)).join(', ')}]`); + console.log(` delayedMove=[${pumps.map((p) => String(p.state.delayedMove)).join(', ')}]`); + + // After settling, the LAST demand was 100 % so all 3 pumps must be + // high. This is the same invariant idle-startup-deadlock Scenario 4 + // checks, but with production timing. + for (let i = 0; i < pumps.length; i++) { + const id = pumps[i].config.general.id; + assert.equal(states[i], 'operational', + `${id}: expected operational, got '${states[i]}' (delayedMove=${pumps[i].state.delayedMove})`); + assert.ok(ctrls[i] > 70, + `${id}: expected ctrl > 70 % at final demand 100 %, got ${ctrls[i].toFixed(1)} % — startup race regression with production timing`); + } + } finally { + restore(); + } +});