Predicted-volume overflow clamp + spill tracking
Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow]
in _updatePredictedVolume — the integrator can no longer drift above
the weir crest (only a real measurement can show level > overflow,
e.g. inflow exceeding pump+weir capacity). Excess is recorded as:
- overflowVolume.predicted.atequipment.default — cumulative spill (m3)
- flow.predicted.out.overflow — instantaneous spill rate (m3/s),
registered as a synthetic outflow so net-flow balance reads ~0
while pinned. The integrator subtracts the prior tick's synthetic
flow before integrating so it never feeds back into volume math.
Lower clamp at dryRunSafetyVol fires only on the transition — a low
seed/calibration is left alone; inflow is what brings it back up.
_selectBestNetFlow holds the last non-zero level-rate net flow when
level pins at overflowLevel and dL/dt collapses to 0, so dashboards
keep showing roughly what's coming in. Auto-refreshes once level
drops.
getOutput() exposes predictedOverflowVolume + predictedOverflowRate
as top-level convenience keys; the underlying measurements flow to
InfluxDB via the standard MeasurementContainer flatten path.
9 new test assertions cover the upper-clamp + spill increment, stable
spill across ticks, net-flow ~0 while pinned, spill clearing when
inflow stops, low-seed left alone, drain-across-threshold clamp, and
the new top-level output keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -447,3 +447,105 @@ 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('out').child('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('out').child('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('out').child('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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user