Volume integrator changes:
- Hard physical floor at 0 added to _updatePredictedVolume. Without
it, a basin seeded below dryRunSafetyVol (calibration / startup
/ low seed) under continued net-outflow drifted volume arbitrarily
negative; the level output looked clamped only because
_calcLevelFromVolume floors at 0, masking the underlying drift.
- New cumulative diagnostic: underflowVolume.predicted.atequipment
(m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a
flow-balance error (over-reported outflow / missing inflow).
- The transition-only dryRunSafetyVol clamp is preserved so
startup-from-empty doesn't snap to 2.1 m³ on tick 1.
Spill flow refactor (taxonomic + bug fix):
- Synthetic spill moved from flow.predicted.out.<child='overflow'>
to its own position flow.predicted.overflow.<default>. The spill
is a derived quantity, not a physical sub-source sharing a position
with pumps — .child() was the wrong knob.
- Removes the spillPrev self-subtraction in the integrator (no longer
needed: outflowTotal at ['out','downstream'] cleanly excludes spill).
- Closes a latent fall-through bug exposed during this work:
.child('overflow').getCurrentValue() returned the value of any
available sibling child when overflow itself didn't yet exist.
Hardened separately in generalFunctions@a516c2b.
- _selectBestNetFlow folds the overflow position into the outflow
side so the predicted net-flow balance still reads ~0 while pinned.
Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated
underflow tracking, getOutput surface, and refill-from-empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
602 lines
25 KiB
JavaScript
602 lines
25 KiB
JavaScript
// Basic unit tests for PumpingStation (domain logic, no Node-RED).
|
||
// Run with: node --test test/basic/specificClass.test.js
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const PumpingStation = require('../../src/specificClass');
|
||
|
||
// Standard config shape. Override any section by passing { section: {...} }.
|
||
function makeConfig(overrides = {}) {
|
||
const base = {
|
||
general: {
|
||
name: 'TestStation',
|
||
id: 'ps-test',
|
||
unit: 'm3/h',
|
||
logging: { enabled: false, logLevel: 'error' },
|
||
flowThreshold: 1e-4,
|
||
},
|
||
functionality: {
|
||
softwareType: 'pumpingStation',
|
||
role: 'stationcontroller',
|
||
positionVsParent: 'atEquipment',
|
||
},
|
||
basin: {
|
||
volume: 50,
|
||
height: 5,
|
||
inflowLevel: 3,
|
||
outflowLevel: 0.2,
|
||
overflowLevel: 4.5,
|
||
inletPipeDiameter: 0.4,
|
||
outletPipeDiameter: 0.3,
|
||
},
|
||
hydraulics: {
|
||
refHeight: 'NAP',
|
||
basinBottomRef: 0,
|
||
minHeightBasedOn: 'outlet',
|
||
},
|
||
control: {
|
||
mode: 'levelbased',
|
||
allowedModes: new Set(['levelbased', 'manual']),
|
||
levelbased: { minLevel: 1, startLevel: 2, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
|
||
},
|
||
safety: {
|
||
enableDryRunProtection: false,
|
||
enableOverfillProtection: false,
|
||
dryRunThresholdPercent: 2,
|
||
highVolumeSafetyThresholdPercent: 98,
|
||
overfillThresholdPercent: 98,
|
||
timeleftToFullOrEmptyThresholdSeconds: 0,
|
||
},
|
||
};
|
||
for (const k of Object.keys(overrides)) {
|
||
base[k] = typeof overrides[k] === 'object' && !Array.isArray(overrides[k])
|
||
? { ...base[k], ...overrides[k] }
|
||
: overrides[k];
|
||
}
|
||
return base;
|
||
}
|
||
|
||
test('Basin geometry — derived values', async (t) => {
|
||
const ps = new PumpingStation(makeConfig());
|
||
|
||
await t.test('surfaceArea = volume / height', () => {
|
||
assert.equal(ps.basin.surfaceArea, 10); // 50 / 5
|
||
});
|
||
await t.test('maxVol = height × area ≡ volEmptyBasin', () => {
|
||
assert.equal(ps.basin.maxVol, 50);
|
||
assert.equal(ps.basin.maxVol, ps.basin.volEmptyBasin);
|
||
});
|
||
await t.test('maxVolAtOverflow = overflowLevel × area', () => {
|
||
assert.equal(ps.basin.maxVolAtOverflow, 45); // 4.5 × 10
|
||
});
|
||
await t.test('minVolAtInflow = inflowLevel × area', () => {
|
||
assert.equal(ps.basin.minVolAtInflow, 30); // 3 × 10
|
||
});
|
||
await t.test('minVolAtOutflow = outflowLevel × area', () => {
|
||
assert.ok(Math.abs(ps.basin.minVolAtOutflow - 2) < 1e-9); // 0.2 × 10
|
||
});
|
||
await t.test('minVol honours minHeightBasedOn=outlet', () => {
|
||
assert.ok(Math.abs(ps.basin.minVol - 2) < 1e-9);
|
||
});
|
||
await t.test('minVol honours minHeightBasedOn=inlet', () => {
|
||
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) => {
|
||
const ps = new PumpingStation(makeConfig());
|
||
|
||
await t.test('_calcVolumeFromLevel multiplies by area', () => {
|
||
assert.equal(ps._calcVolumeFromLevel(2), 20);
|
||
});
|
||
await t.test('_calcVolumeFromLevel clamps negatives to 0', () => {
|
||
assert.equal(ps._calcVolumeFromLevel(-3), 0);
|
||
});
|
||
await t.test('_calcLevelFromVolume divides by area', () => {
|
||
assert.equal(ps._calcLevelFromVolume(20), 2);
|
||
});
|
||
await t.test('_calcLevelFromVolume clamps negatives to 0', () => {
|
||
assert.equal(ps._calcLevelFromVolume(-10), 0);
|
||
});
|
||
await t.test('roundtrip preserves level', () => {
|
||
const v = ps._calcVolumeFromLevel(2.7);
|
||
assert.ok(Math.abs(ps._calcLevelFromVolume(v) - 2.7) < 1e-10);
|
||
});
|
||
});
|
||
|
||
test('Threshold guardrails — _validateThresholdOrdering', async (t) => {
|
||
await t.test('valid config returns no issues', () => {
|
||
const ps = new PumpingStation(makeConfig());
|
||
assert.equal(ps.thresholdIssues.length, 0);
|
||
});
|
||
|
||
await t.test('minLevel > startLevel flagged', () => {
|
||
const ps = new PumpingStation(makeConfig({
|
||
control: {
|
||
mode: 'levelbased',
|
||
allowedModes: new Set(['levelbased']),
|
||
levelbased: { minLevel: 3, startLevel: 2, maxLevel: 4 },
|
||
},
|
||
}));
|
||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'minLevel'));
|
||
});
|
||
|
||
await t.test('startLevel == maxLevel flagged (must be strict <)', () => {
|
||
const ps = new PumpingStation(makeConfig({
|
||
control: {
|
||
mode: 'levelbased',
|
||
allowedModes: new Set(['levelbased']),
|
||
levelbased: { minLevel: 1, startLevel: 4, maxLevel: 4 },
|
||
},
|
||
}));
|
||
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 },
|
||
}));
|
||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'outflowLevel'));
|
||
});
|
||
|
||
await t.test('overflowLevel > basinHeight flagged', () => {
|
||
const ps = new PumpingStation(makeConfig({
|
||
basin: { volume: 50, height: 5, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 6 },
|
||
}));
|
||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'overflowLevel'));
|
||
});
|
||
|
||
await t.test('dryRunLevel > minLevel flagged (safety band inverted)', () => {
|
||
// With minHeightBasedOn=inlet, refLowLevel=inflowLevel=3.
|
||
// dryRunLevel = 3 × (1 + 100/100) = 6; minLevel=1 → 6 ≤ 1 fails.
|
||
const ps = new PumpingStation(makeConfig({
|
||
hydraulics: { minHeightBasedOn: 'inlet' },
|
||
safety: { enableDryRunProtection: true, dryRunThresholdPercent: 100 },
|
||
}));
|
||
assert.ok(ps.thresholdIssues.some((i) => i.aName === 'dryRunLevel'));
|
||
});
|
||
});
|
||
|
||
test('Direction derivation — _deriveDirection', async (t) => {
|
||
const ps = new PumpingStation(makeConfig());
|
||
|
||
await t.test('positive flow above dead-band → filling', () => {
|
||
assert.equal(ps._deriveDirection(0.01), 'filling');
|
||
});
|
||
await t.test('negative flow below dead-band → draining', () => {
|
||
assert.equal(ps._deriveDirection(-0.01), 'draining');
|
||
});
|
||
await t.test('flow inside dead-band → steady', () => {
|
||
assert.equal(ps._deriveDirection(0), 'steady');
|
||
assert.equal(ps._deriveDirection(1e-5), 'steady');
|
||
assert.equal(ps._deriveDirection(-1e-5), 'steady');
|
||
});
|
||
});
|
||
|
||
test('Mode change — changeMode', async (t) => {
|
||
const ps = new PumpingStation(makeConfig());
|
||
|
||
await t.test('valid mode swap updates this.mode', () => {
|
||
ps.changeMode('manual');
|
||
assert.equal(ps.mode, 'manual');
|
||
});
|
||
await t.test('rejected mode leaves this.mode unchanged', () => {
|
||
ps.changeMode('manual');
|
||
ps.changeMode('notamode');
|
||
assert.equal(ps.mode, 'manual');
|
||
});
|
||
});
|
||
|
||
test('Calibration — predicted volume and level', async (t) => {
|
||
const ps = new PumpingStation(makeConfig());
|
||
|
||
await t.test('calibratePredictedVolume rewrites volume series', () => {
|
||
ps.calibratePredictedVolume(25);
|
||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||
assert.ok(Math.abs(vol - 25) < 1e-9);
|
||
});
|
||
await t.test('calibratePredictedVolume also writes level (= vol / area)', () => {
|
||
ps.calibratePredictedVolume(30);
|
||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||
assert.ok(Math.abs(lvl - 3) < 1e-9); // 30 / 10
|
||
});
|
||
await t.test('calibratePredictedLevel writes level + volume = level × area', () => {
|
||
ps.calibratePredictedLevel(2.5);
|
||
const lvl = ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
|
||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||
assert.ok(Math.abs(lvl - 2.5) < 1e-9);
|
||
assert.ok(Math.abs(vol - 25) < 1e-9); // 2.5 × 10
|
||
});
|
||
});
|
||
|
||
test('Levelbased control zones — _controlLevelBased', async (t) => {
|
||
await t.test('level < minLevel → percControl=0 and MGC turnOff called', async () => {
|
||
const ps = new PumpingStation(makeConfig());
|
||
let turnOffCalls = 0;
|
||
ps.machineGroups['mgc1'] = {
|
||
config: { general: { name: 'mgc1' } },
|
||
turnOffAllMachines: () => { turnOffCalls++; },
|
||
handleInput: async () => {},
|
||
};
|
||
ps.calibratePredictedLevel(0.5); // below minLevel=1
|
||
await ps._controlLevelBased();
|
||
assert.equal(ps.percControl, 0);
|
||
assert.equal(turnOffCalls, 1);
|
||
});
|
||
|
||
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 (_src, d) => { demands.push(d); },
|
||
};
|
||
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
|
||
await ps._controlLevelBased();
|
||
assert.equal(ps.percControl, 0);
|
||
assert.equal(demands[0], 0);
|
||
});
|
||
|
||
await t.test('filling: level between startLevel and inflowLevel commands 0%', 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(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'] = {
|
||
config: { general: { name: 'mgc1' } },
|
||
turnOffAllMachines: () => {},
|
||
handleInput: async () => {},
|
||
};
|
||
ps.calibratePredictedLevel(4.5); // above maxLevel=4
|
||
await ps._controlLevelBased();
|
||
assert.ok(ps.percControl >= 100);
|
||
});
|
||
});
|
||
|
||
test('getOutput — flattens basin + state + demand', async (t) => {
|
||
const ps = new PumpingStation(makeConfig());
|
||
ps.percControl = 37;
|
||
|
||
await t.test('includes basin geometry fields', () => {
|
||
const out = ps.getOutput();
|
||
assert.equal(out.volEmptyBasin, 50);
|
||
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();
|
||
assert.ok('direction' in out);
|
||
assert.ok('flowSource' in out);
|
||
assert.ok('timeleft' in out);
|
||
});
|
||
await t.test('includes percControl', () => {
|
||
assert.equal(ps.getOutput().percControl, 37);
|
||
});
|
||
});
|
||
|
||
test('Manual inflow — setManualInflow stores predicted inflow', async (t) => {
|
||
const ps = new PumpingStation(makeConfig());
|
||
ps.setManualInflow(0.05, Date.now(), 'm3/s'); // 0.05 m³/s
|
||
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
|
||
});
|
||
});
|