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