Files
pumpingStation/test/basic/specificClass.test.js
znetsixe a3536b7b7f fix(level): pass timestamp on level samples for level-rate fallback
MeasurementRouter.onLevelMeasurement was writing level samples via
.value(value).unit(context.unit), which dropped the timestamp. The
level-rate fallback in FlowAggregator derives netFlow from dlevel/dt,
so without a timestamp on each sample it had nothing to differentiate.

Switch to the positional .value(value, timestamp, unit) form so the
fallback works. Add a basic test that drives two level samples 2 s
apart and asserts the aggregator produces direction=filling with a
finite dlevel/dt-derived netFlow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:29:56 +02:00

657 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { MeasurementContainer } = require('generalFunctions');
const PumpingStation = require('../../src/specificClass');
// machineGroups is a registry-backed getter (declareChildGetter) — direct
// assignment is no longer possible. Tests inject mock groups through the
// real registration handshake so the registry remains the source of truth.
function registerMockGroup(ps, id, behavior = {}) {
const calls = { setDemand: [], handleInput: [], turnOff: 0 };
const mock = {
config: {
general: { id, name: id },
functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
asset: { category: 'controller' },
},
measurements: {
emitter: { on: () => {} },
setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
},
setDemand: behavior.setDemand
|| (async (value, unit) => { calls.setDemand.push([value, unit]); }),
handleInput: behavior.handleInput
|| (async (...args) => { calls.handleInput.push(args); }),
turnOffAllMachines: behavior.turnOffAllMachines
|| (() => { calls.turnOff += 1; }),
_calls: calls,
};
ps.childRegistrationUtils.registerChild(mock, 'atEquipment');
return mock;
}
// 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;
}
function makeMeasurementChild({ type = 'level', position = 'atequipment', name = 'child-level' } = {}) {
return {
config: {
general: { id: name, name },
functionality: { positionVsParent: position },
asset: { type },
},
measurements: new MeasurementContainer({
autoConvert: true,
preferredUnits: { level: 'm', flow: 'm3/s', pressure: 'Pa' },
}),
};
}
test('level child subscription records one sample per event for level-rate fallback', async () => {
const ps = new PumpingStation(makeConfig());
const child = makeMeasurementChild();
ps._subscribeMeasurement(child);
child.measurements.type('level').variant('measured').position('atequipment')
.value(1.0, 1000, 'm');
child.measurements.type('level').variant('measured').position('atequipment')
.value(1.1, 3000, 'm');
const series = ps.measurements.type('level').variant('measured').position('atequipment').get();
assert.deepEqual(series.values, [1.0, 1.1]);
const net = ps.flowAggregator.selectBestNetFlow();
assert.equal(net.source, 'level:measured');
assert.equal(net.direction, 'filling');
assert.ok(Math.abs(net.value - 0.5) < 1e-9, `net flow was ${net.value}`);
});
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 is allowed (sewer-buffer mode), no issue raised', () => {
// Inflow gravity point at 3, startLevel pushed to 3.5 → basin is allowed
// to fill past the inlet before pumps engage. levelBased shifts the ramp
// foot to startLevel; the validator no longer flags the ordering.
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'),
'startLevel vs inflowLevel ordering must not raise an issue');
});
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());
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(0.5); // below minLevel=1
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
assert.equal(mock._calls.turnOff, 1);
});
await t.test('minLevel ≤ level < active ramp start → soft turnOff (pct=0 no longer dispatched)', async () => {
const ps = new PumpingStation(makeConfig());
ps.percControl = 42; // simulated previous demand
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(1.5); // between minLevel=1 and startLevel=2
await ps._controlLevelBased();
assert.equal(ps.percControl, 0);
// pct=0 → turnOff, no setDemand call (avoids MGC interpolating 0 % to dt.flow.min).
assert.equal(mock._calls.turnOff, 1);
assert.equal(mock._calls.setDemand.length, 0);
});
await t.test('filling: level between startLevel and inflowLevel ramps from startLevel (no implicit hold zone)', async () => {
const ps = new PumpingStation(makeConfig());
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(2.5); // startLevel=2, inflowLevel=3, maxLevel=4
await ps._controlLevelBased('filling');
// Ramp foot = startLevel (NOT inflowLevel). lerp(2.5, [2, 4], [0, 100]) = 25.
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected ~25 %, got ${ps.percControl}`);
assert.equal(mock._calls.turnOff, 0, 'engaged — pumps must not be turned off in the ramp');
assert.equal(mock._calls.setDemand.length, 1);
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 25) < 1e-9);
});
await t.test('filling: level ≥ maxLevel → percControl clamped at 100, routed via setDemand', async () => {
const ps = new PumpingStation(makeConfig());
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(3.5); // 3/4 of the [2,4] ramp → 75 %.
await ps._controlLevelBased('filling');
assert.ok(Math.abs(ps.percControl - 75) < 1e-9, `expected ~75 %, got ${ps.percControl}`);
assert.equal(mock._calls.setDemand.length, 1);
assert.equal(mock._calls.setDemand[0][1], '%');
assert.ok(Math.abs(mock._calls.setDemand[0][0] - 75) < 1e-9);
});
await t.test('filling: holdLevel raises the ramp foot — explicit hold band [startLevel, holdLevel] sits at 0 %', async () => {
const ps = new PumpingStation(makeConfig({
control: {
mode: 'levelbased',
allowedModes: new Set(['levelbased']),
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9 },
},
}));
const mock = registerMockGroup(ps, 'mgc1');
ps.calibratePredictedLevel(2.5); // inside [startLevel, holdLevel]
await ps._controlLevelBased('filling');
assert.equal(ps.percControl, 0);
assert.equal(mock._calls.turnOff, 0, 'engaged — hold band runs at MGC flow.min, not off');
assert.deepEqual(mock._calls.setDemand[0], [0, '%']);
});
await t.test('shift disabled (default): foot stays at startLevel — falling levels track the ramp down to startLevel', async () => {
const ps = new PumpingStation(makeConfig());
registerMockGroup(ps, 'mgc1');
// Climb above startLevel, then fall to a level inside [start, inflow]. With
// the new semantics (ramp foot = startLevel, NOT inflowLevel) the falling
// level still produces a positive demand on the way down.
ps.calibratePredictedLevel(3.8);
await ps._controlLevelBased();
assert.ok(ps.percControl > 0);
ps.calibratePredictedLevel(2.5); // startLevel=2, maxLevel=4 → 25 %
await ps._controlLevelBased();
assert.ok(Math.abs(ps.percControl - 25) < 1e-9, `expected 25 % on the down ramp, got ${ps.percControl}`);
});
await t.test('shift enabled: arming on % threshold + hold-then-ramp on draining (with holdLevel pinning the foot)', async () => {
// The original shifted-ramp test was authored against the legacy ramp
// foot = inflowLevel (=3). With the new defaults the foot moves to
// startLevel (=2), which changes every percentage in the trace. Pin
// the foot back to 3 by setting holdLevel = 3 — that keeps this test's
// arithmetic self-consistent: 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, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
registerMockGroup(ps, 'mgc1');
// 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: {
// Pin the ramp foot at 3 via holdLevel — keeps legacy arithmetic
// self-consistent with the original test (up curve 0 %@3 → 100 %@4).
minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'linear', logCurveFactor: 9,
enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
},
},
}));
registerMockGroup(ps, 'mgc1');
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']),
// holdLevel=3 keeps ramp foot at 3 so x=0.5 means level=3.5, matching
// the legacy assertion bracket.
levelbased: { minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4, curveType: 'log', logCurveFactor: 9 },
},
}));
registerMockGroup(ps, 'mgc1');
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());
registerMockGroup(ps, 'mgc1');
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
});
});