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