103 lines
4.3 KiB
JavaScript
103 lines
4.3 KiB
JavaScript
|
|
// 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();
|
||
|
|
}
|
||
|
|
});
|