Merge origin/basin-docs-update: per-mode SVG + stopLevel hysteresis + shifted ramp
Reconciles the 7-commit basin-docs-update feature branch (which never
landed on main before the platform refactor) with the post-refactor
architecture on development. Each basin-docs feature ported into the
relevant concern module:
control/levelBased.js
- stopLevel Schmitt-trigger + dead-band keep-alive
- Shifted ramp (arm % → hold @ 100% → ramp down to shiftLevel)
- Linear vs log up-curve (curveType + logCurveFactor)
measurement/flowAggregator.js
- Predicted-volume overflow clamp + spill flow stream
- Cumulative overflowVolume + underflowVolume
- Hard floor at 0 + dry-run-on-transition handling
basin/thresholdValidator.js
- computeSafetyPoints exposes dryRunLevel + highVolumeSafetyLevel
- startLevel ≤ inflowLevel invariant added
measurement/calibration.js + commands/
- Manual q_out path (set.outflow / q_out alias)
safety/safetyController.js
- Accepts both legacy + new high-volume threshold names
UI:
pumpingStation.html — restored the side-panel + SVG mode-preview block,
added defaults for stopLevel/shiftLevel/shiftArmPercent/levelCurveType/
logCurveFactor/enableShiftedRamp.
src/editor/* — basin-docs' 7-file modular editor (replaces single
src/editor.js, which is deleted).
pumpingStation.js — admin endpoint serves editor/:file.
Tests: 130/130 pass (125 basic + 5 integration). Two basin-docs test
files added: nodeClass-config.test.js, basic-dashboard-flow.test.js,
shifted-ramp-end-to-end.test.js. One pre-refactor control-levelBased
test adapted to match basin-docs canonical "no-shutdown in dead zone"
behaviour.
Human-review items (see commit context):
- rampFoot = inflowLevel (matches basin-docs test); basin-docs source
used rampFoot = startLevel. Domain owner: confirm intent.
- Naming kept dual (overfillLevel + highVolumeSafetyLevel).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,15 +63,19 @@ test('level < minLevel → STOP: turnOffAllMachines on every group, percControl
|
||||
}
|
||||
});
|
||||
|
||||
test('minLevel ≤ level < startLevel → DEAD ZONE: no calls, percControl unchanged', async () => {
|
||||
// basin-docs behavior: between minLevel and the active ramp foot, demand
|
||||
// is commanded to 0 % (not "unchanged"). MGC still receives the command;
|
||||
// only the explicit minLevel hard-stop path skips handleInput.
|
||||
test('minLevel ≤ level < ramp foot → commands 0 % without shutdown', async () => {
|
||||
const ctx = makeCtx(1.5);
|
||||
const state = { percControl: 17 };
|
||||
await levelBased.run(ctx, state);
|
||||
|
||||
assert.equal(state.percControl, 17, 'percControl untouched in dead zone');
|
||||
assert.equal(state.percControl, 0, 'percControl driven to 0 in the hold zone');
|
||||
for (const g of Object.values(ctx.machineGroups)) {
|
||||
assert.equal(g._calls.turnOff, 0);
|
||||
assert.equal(g._calls.handleInput.length, 0);
|
||||
assert.equal(g._calls.handleInput.length, 1, 'one demand=0 forward per group');
|
||||
assert.deepEqual(g._calls.handleInput[0], ['parent', 0]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
74
test/basic/nodeClass-config.test.js
Normal file
74
test/basic/nodeClass-config.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const NodeClass = require('../../src/nodeClass');
|
||||
|
||||
function loadConfig(uiConfig = {}) {
|
||||
const ctx = { name: 'pumpingStation' };
|
||||
NodeClass.prototype._loadConfig.call(ctx, {
|
||||
name: 'PS Config Test',
|
||||
basinVolume: 80,
|
||||
basinHeight: 8,
|
||||
inflowLevel: 3.2,
|
||||
outflowLevel: 0.4,
|
||||
overflowLevel: 7.4,
|
||||
inletPipeDiameter: 0.5,
|
||||
outletPipeDiameter: 0.35,
|
||||
refHeight: 'NAP',
|
||||
minHeightBasedOn: 'outlet',
|
||||
basinBottomRef: -1.2,
|
||||
maxInflowRate: 300,
|
||||
staticHead: 11,
|
||||
maxDischargeHead: 22,
|
||||
pipelineLength: 120,
|
||||
defaultFluid: 'wastewater',
|
||||
temperatureReferenceDegC: 16,
|
||||
controlMode: 'levelbased',
|
||||
minLevel: 0.8,
|
||||
startLevel: 2,
|
||||
maxLevel: 6.5,
|
||||
levelCurveType: 'log',
|
||||
logCurveFactor: 7,
|
||||
enableDryRunProtection: true,
|
||||
dryRunThresholdPercent: 3,
|
||||
enableHighVolumeSafety: true,
|
||||
highVolumeSafetyThresholdPercent: 96,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 60,
|
||||
processOutputFormat: 'process',
|
||||
dbaseOutputFormat: 'influxdb',
|
||||
...uiConfig,
|
||||
}, { id: 'node-1' });
|
||||
return ctx.config;
|
||||
}
|
||||
|
||||
test('nodeClass config mapping — basin, hydraulics, mode and safety fields', () => {
|
||||
const cfg = loadConfig();
|
||||
|
||||
assert.equal(cfg.basin.inletPipeDiameter, 0.5);
|
||||
assert.equal(cfg.basin.outletPipeDiameter, 0.35);
|
||||
assert.equal(cfg.hydraulics.maxInflowRate, 300);
|
||||
assert.equal(cfg.hydraulics.staticHead, 11);
|
||||
assert.equal(cfg.hydraulics.maxDischargeHead, 22);
|
||||
assert.equal(cfg.hydraulics.pipelineLength, 120);
|
||||
assert.equal(cfg.hydraulics.defaultFluid, 'wastewater');
|
||||
assert.equal(cfg.hydraulics.temperatureReferenceDegC, 16);
|
||||
assert.equal(cfg.control.mode, 'levelbased');
|
||||
assert.equal(cfg.control.levelbased.curveType, 'log');
|
||||
assert.equal(cfg.control.levelbased.logCurveFactor, 7);
|
||||
assert.equal(cfg.safety.enableHighVolumeSafety, true);
|
||||
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 96);
|
||||
assert.equal(cfg.output.process, 'process');
|
||||
assert.equal(cfg.output.dbase, 'influxdb');
|
||||
});
|
||||
|
||||
test('nodeClass config mapping — accepts deprecated overfill UI fields', () => {
|
||||
const cfg = loadConfig({
|
||||
enableHighVolumeSafety: undefined,
|
||||
highVolumeSafetyThresholdPercent: undefined,
|
||||
enableOverfillProtection: false,
|
||||
overfillThresholdPercent: 91,
|
||||
});
|
||||
|
||||
assert.equal(cfg.safety.enableHighVolumeSafety, false);
|
||||
assert.equal(cfg.safety.highVolumeSafetyThresholdPercent, 91);
|
||||
});
|
||||
@@ -27,6 +27,8 @@ function makeConfig(overrides = {}) {
|
||||
inflowLevel: 3,
|
||||
outflowLevel: 0.2,
|
||||
overflowLevel: 4.5,
|
||||
inletPipeDiameter: 0.4,
|
||||
outletPipeDiameter: 0.3,
|
||||
},
|
||||
hydraulics: {
|
||||
refHeight: 'NAP',
|
||||
@@ -36,12 +38,13 @@ function makeConfig(overrides = {}) {
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased', 'manual']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4 },
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||||
},
|
||||
safety: {
|
||||
enableDryRunProtection: false,
|
||||
enableOverfillProtection: false,
|
||||
dryRunThresholdPercent: 2,
|
||||
highVolumeSafetyThresholdPercent: 98,
|
||||
overfillThresholdPercent: 98,
|
||||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||||
},
|
||||
@@ -80,6 +83,10 @@ test('Basin geometry — derived values', async (t) => {
|
||||
const ps2 = new PumpingStation(makeConfig({ hydraulics: { minHeightBasedOn: 'inlet' } }));
|
||||
assert.equal(ps2.basin.minVol, 30);
|
||||
});
|
||||
await t.test('pipe diameters are part of basin contract', () => {
|
||||
assert.equal(ps.basin.inletPipeDiameter, 0.4);
|
||||
assert.equal(ps.basin.outletPipeDiameter, 0.3);
|
||||
});
|
||||
});
|
||||
|
||||
test('Level ↔ volume roundtrip', async (t) => {
|
||||
@@ -131,6 +138,17 @@ test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel'));
|
||||
});
|
||||
|
||||
await t.test('startLevel > inflowLevel flagged for levelbased rising hold zone', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 3.5, maxLevel: 4, curveType: 'linear' },
|
||||
},
|
||||
}));
|
||||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'startLevel' && i.bName === 'inflowLevel'));
|
||||
});
|
||||
|
||||
await t.test('outflowLevel >= inflowLevel flagged', () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
basin: { volume: 50, height: 5, inflowLevel: 0.1, outflowLevel: 0.5, overflowLevel: 4.5 },
|
||||
@@ -223,20 +241,22 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
assert.equal(turnOffCalls, 1);
|
||||
});
|
||||
|
||||
await t.test('minLevel ≤ level < startLevel → dead zone, percControl unchanged', async () => {
|
||||
await t.test('minLevel ≤ level < active ramp start → commands 0% without shutdown', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.percControl = 42; // simulated previous demand
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => { throw new Error('should not be called in dead zone'); },
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||||
await ps._controlLevelBased();
|
||||
assert.equal(ps.percControl, 42); // unchanged
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(demands[0], 0);
|
||||
});
|
||||
|
||||
await t.test('level ≥ startLevel → percControl linearly scaled to [0,100]', async () => {
|
||||
await t.test('filling: level between startLevel and inflowLevel commands 0%', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
@@ -244,14 +264,144 @@ test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
ps.calibratePredictedLevel(3); // midpoint of startLevel=2 and maxLevel=4
|
||||
await ps._controlLevelBased();
|
||||
// lerp(3, [2,4], [0,100]) = 50
|
||||
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(demands[0], 0);
|
||||
});
|
||||
|
||||
await t.test('filling: level ≥ inflowLevel → percControl linearly scaled to [0,100]', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
const demands = [];
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async (_src, d) => { demands.push(d); },
|
||||
};
|
||||
ps.calibratePredictedLevel(3.5); // midpoint of inflowLevel=3 and maxLevel=4
|
||||
await ps._controlLevelBased('filling');
|
||||
// lerp(3.5, [3,4], [0,100]) = 50
|
||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||
assert.equal(demands.length, 1);
|
||||
assert.ok(Math.abs(demands[0] - 50) < 1e-9);
|
||||
});
|
||||
|
||||
await t.test('shift disabled (default): foot stays at inflowLevel even after fall', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
// Climb past inflowLevel and beyond, then fall to a level inside [start..inflow].
|
||||
ps.calibratePredictedLevel(3.8);
|
||||
await ps._controlLevelBased();
|
||||
assert.ok(ps.percControl > 0);
|
||||
ps.calibratePredictedLevel(2.5); // between startLevel=2 and inflowLevel=3
|
||||
await ps._controlLevelBased();
|
||||
// Without shift the foot is inflowLevel → 0% in the hold zone.
|
||||
assert.equal(ps.percControl, 0);
|
||||
});
|
||||
|
||||
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining', async () => {
|
||||
// Geometry: inflow=3, max=4 → up curve goes 0%@3 to 100%@4.
|
||||
// shiftArmPercent=80 ⇒ arms when up curve ≥ 80 % i.e. level ≥ 3.8.
|
||||
// shiftLevel=3.5 ⇒ held output starts ramping down at this level.
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
}));
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
// Filling at level=3.5 ⇒ up curve = 50 %, below arm threshold ⇒ not armed.
|
||||
ps.calibratePredictedLevel(3.5);
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps._shiftArmed, false);
|
||||
assert.ok(Math.abs(ps.percControl - 50) < 1e-9);
|
||||
// Filling at level=3.85 ⇒ up curve = 85 % ≥ arm threshold ⇒ ARM.
|
||||
ps.calibratePredictedLevel(3.85);
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps._shiftArmed, true);
|
||||
assert.ok(Math.abs(ps.percControl - 85) < 1e-9); // still up curve while filling
|
||||
// Direction flips to draining at the same level ⇒ capture hold ≈ 85 %.
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
|
||||
// While draining and level ≥ shiftLevel ⇒ output stays at hold (≈85 %).
|
||||
ps.calibratePredictedLevel(3.6);
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps.percControl - 85) < 1e-6);
|
||||
// Below shiftLevel: ramp [shift, hold] → [start, 0]. At level=2.75
|
||||
// (midpoint of [2, 3.5]), x=0.5, output ≈ 85 × 0.5 = 42.5 %.
|
||||
ps.calibratePredictedLevel(2.75);
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps.percControl - 42.5) < 1e-6);
|
||||
// Below startLevel ⇒ output 0 % AND disarm.
|
||||
ps.calibratePredictedLevel(1.9);
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.equal(ps.percControl, 0);
|
||||
assert.equal(ps._shiftArmed, false);
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
});
|
||||
|
||||
await t.test('shift enabled: returning to filling clears hold; new hold captured on next drain', async () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: {
|
||||
minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
|
||||
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
|
||||
},
|
||||
},
|
||||
}));
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(3.85);
|
||||
await ps._controlLevelBased('filling');
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps._shiftHoldValue - 85) < 1e-6);
|
||||
// Direction back to filling ⇒ up curve, hold cleared, still armed.
|
||||
ps.calibratePredictedLevel(3.9);
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.equal(ps._shiftHoldValue, null);
|
||||
assert.equal(ps._shiftArmed, true);
|
||||
assert.ok(Math.abs(ps.percControl - 90) < 1e-6); // up curve at 3.9 = 90 %
|
||||
// Flip to draining again at higher level ⇒ new hold ≈ 90 %.
|
||||
await ps._controlLevelBased('draining');
|
||||
assert.ok(Math.abs(ps._shiftHoldValue - 90) < 1e-6);
|
||||
});
|
||||
|
||||
await t.test('log curve has fast early response', async () => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
control: {
|
||||
mode: 'levelbased',
|
||||
allowedModes: new Set(['levelbased']),
|
||||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
|
||||
},
|
||||
}));
|
||||
ps.machineGroups['mgc1'] = {
|
||||
config: { general: { name: 'mgc1' } },
|
||||
turnOffAllMachines: () => {},
|
||||
handleInput: async () => {},
|
||||
};
|
||||
ps.calibratePredictedLevel(3.5); // x=0.5 on filling ramp [3,4]
|
||||
await ps._controlLevelBased('filling');
|
||||
assert.ok(ps.percControl > 50);
|
||||
assert.ok(ps.percControl < 100);
|
||||
});
|
||||
|
||||
await t.test('level > maxLevel → percControl ≥ 100 (MGC clamps internally)', async () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
ps.machineGroups['mgc1'] = {
|
||||
@@ -275,6 +425,10 @@ test('getOutput — flattens basin + state + demand', async (t) => {
|
||||
assert.equal(out.maxVolAtOverflow, 45);
|
||||
assert.equal(out.minVolAtInflow, 30);
|
||||
assert.ok(Math.abs(out.minVolAtOutflow - 2) < 1e-9);
|
||||
assert.equal(out.inletPipeDiameter, 0.4);
|
||||
assert.equal(out.outletPipeDiameter, 0.3);
|
||||
assert.ok(Math.abs(out.highVolumeSafetyLevel - 4.41) < 1e-9);
|
||||
assert.ok(Math.abs(out.dryRunLevel - 0.204) < 1e-9);
|
||||
});
|
||||
await t.test('includes state fields (direction, flowSource, timeleft)', () => {
|
||||
const out = ps.getOutput();
|
||||
@@ -293,3 +447,155 @@ test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
|
||||
const v = ps.measurements.type('flow').variant('predicted').position('in').child('manual-qin').getCurrentValue('m3/s');
|
||||
assert.ok(Math.abs(v - 0.05) < 1e-9);
|
||||
});
|
||||
|
||||
// _updatePredictedVolume now clamps [dryRunSafetyVol, maxVolAtOverflow] and
|
||||
// tracks any excess as cumulative `overflowVolume` plus a synthetic
|
||||
// `flow.predicted.out.overflow` rate so net-flow balance stays at ~0 while
|
||||
// pinned. We drive ticks manually with monotonic timestamps to keep tests
|
||||
// deterministic (Date.now() in the integrator can step by 0 ms in fast loops).
|
||||
test('Predicted volume — overflow clamp and spill tracking', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
safety: { enableDryRunProtection: false, enableHighVolumeSafety: false, dryRunThresholdPercent: 0 },
|
||||
}));
|
||||
// Seed predicted volume just below the spill point.
|
||||
// maxVolAtOverflow = overflowLevel × area = 4.5 × 10 = 45 m³.
|
||||
const t0 = 1_700_000_000_000;
|
||||
ps.calibratePredictedVolume(44, t0);
|
||||
// Heavy inflow, no real outflow (no pumps wired).
|
||||
ps.setManualInflow(2, t0, 'm3/s'); // 2 m³/s, dt=1s → 2 m³/tick
|
||||
|
||||
await t.test('first overflow tick clamps volume and records spill increment', () => {
|
||||
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 45); // pinned at overflow
|
||||
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(cumulative, 1); // proposed=44+2=46, excess=1 m³ this tick
|
||||
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||
assert.equal(spill, 2); // instantaneous balance: inflow − outflowReal
|
||||
});
|
||||
|
||||
await t.test('subsequent ticks accumulate full inflow as spill (stable)', () => {
|
||||
Date.now = () => t0 + 2000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 45);
|
||||
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(cumulative, 3); // 1 + 2
|
||||
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||
assert.equal(spill, 2);
|
||||
});
|
||||
|
||||
await t.test('predicted net flow reads ~0 while pinned at overflow', () => {
|
||||
const net = ps._selectBestNetFlow();
|
||||
// inflow=2, outflow_total=2 (synthetic spill), net = 0
|
||||
assert.ok(Math.abs(net.value) < 1e-9);
|
||||
assert.equal(net.source, 'predicted');
|
||||
});
|
||||
|
||||
await t.test('once inflow stops, spill flow clears and clamp releases', () => {
|
||||
ps.setManualInflow(0, t0 + 2000, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 + 2000 };
|
||||
Date.now = () => t0 + 3000;
|
||||
ps._updatePredictedVolume();
|
||||
const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s');
|
||||
assert.equal(spill, 0);
|
||||
// Volume stays at 45 (no draining force) but is no longer "pinned".
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 45);
|
||||
});
|
||||
});
|
||||
|
||||
test('Predicted volume — dry-run lower clamp', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
// dryRunSafetyVol = minVolAtOutflow × (1 + 5/100) = 2 × 1.05 = 2.1 m³
|
||||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
|
||||
}));
|
||||
const t0 = 1_700_000_000_000;
|
||||
|
||||
await t.test('initial seed below dryRunSafetyVol is left alone (no upward bump)', () => {
|
||||
// Seed defaults to minVol=2 (below dryRunSafetyVol=2.1).
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 2); // unchanged — clamp doesn't fire because we started below it
|
||||
});
|
||||
|
||||
await t.test('drain across dryRunSafetyVol clamps at the threshold', () => {
|
||||
// Calibrate well above, then push outflow that would cross the threshold.
|
||||
ps.calibratePredictedVolume(3, t0 + 1000);
|
||||
// outflow=2 m³/s for 1s → would drop to 1; clamp catches at 2.1.
|
||||
ps.setManualOutflow(2, t0 + 1000, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
|
||||
Date.now = () => t0 + 2000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(vol - 2.1) < 1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
test('getOutput — exposes predictedOverflowVolume / predictedOverflowRate', () => {
|
||||
const ps = new PumpingStation(makeConfig());
|
||||
// Seed an overflow scenario.
|
||||
const t0 = 1_700_000_000_000;
|
||||
ps.calibratePredictedVolume(44, t0);
|
||||
ps.setManualInflow(2, t0, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 2, outflow: 0, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const out = ps.getOutput();
|
||||
assert.equal(out.predictedOverflowVolume, 1);
|
||||
assert.equal(out.predictedOverflowRate, 2);
|
||||
});
|
||||
|
||||
// Hard physical floor at 0. The dryRunSafetyVol clamp only fires on transition
|
||||
// from above, so a basin seeded below + continued outflow used to integrate
|
||||
// the volume arbitrarily negative. The level helper masked this by flooring
|
||||
// at 0 in _calcLevelFromVolume — fix is to floor the integrator itself.
|
||||
test('Predicted volume — physical floor at 0 (underflow track)', async (t) => {
|
||||
const ps = new PumpingStation(makeConfig({
|
||||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 5 },
|
||||
}));
|
||||
const t0 = 1_700_000_000_000;
|
||||
|
||||
await t.test('seeded below dryRun + continued outflow does NOT go negative', () => {
|
||||
ps.calibratePredictedVolume(0.5, t0); // below dryRunSafetyVol (2.1)
|
||||
ps.setManualOutflow(2, t0, 'm3/s'); // 2 m³/s for 1s → would drop to -1.5
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 };
|
||||
Date.now = () => t0 + 1000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 0); // floored at 0, not -1.5
|
||||
const underflow = ps.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(underflow, 1.5); // tracked as diagnostic
|
||||
});
|
||||
|
||||
await t.test('subsequent ticks accumulate underflow while outflow continues', () => {
|
||||
Date.now = () => t0 + 2000;
|
||||
ps._predictedFlowState = { inflow: 0, outflow: 2, lastTimestamp: t0 + 1000 };
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(vol, 0);
|
||||
const underflow = ps.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.equal(underflow, 3.5); // 1.5 + 2.0
|
||||
});
|
||||
|
||||
await t.test('getOutput exposes predictedUnderflowVolume', () => {
|
||||
const out = ps.getOutput();
|
||||
assert.equal(out.predictedUnderflowVolume, 3.5);
|
||||
});
|
||||
|
||||
await t.test('inflow returns and basin refills from 0 (no jump to dryRunSafetyVol)', () => {
|
||||
ps.setManualInflow(1, t0 + 2000, 'm3/s');
|
||||
ps.setManualOutflow(0, t0 + 2000, 'm3/s');
|
||||
ps._predictedFlowState = { inflow: 1, outflow: 0, lastTimestamp: t0 + 2000 };
|
||||
Date.now = () => t0 + 3000;
|
||||
ps._updatePredictedVolume();
|
||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||
assert.ok(Math.abs(vol - 1) < 1e-9); // 0 + 1 = 1, NOT pinned to 2.1
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user