From 6b46a8a8f0ca2035edb069c96eab884ab15525b5 Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Wed, 6 May 2026 14:47:46 +0200 Subject: [PATCH] Predicted-volume overflow clamp + spill tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/specificClass.js | 103 +++++++++++++++++++++++++++---- test/basic/specificClass.test.js | 102 ++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 12 deletions(-) diff --git a/src/specificClass.js b/src/specificClass.js index 8caf9d6..d21f262 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -45,7 +45,7 @@ class PumpingStation { // keep the basin geometry math unit-consistent. this.measurements = new MeasurementContainer({ autoConvert: true, - preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' } + preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3', overflowVolume: 'm3' } }); // --- Child registries --- @@ -646,23 +646,81 @@ class PumpingStation { const now = Date.now(); const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0; - const outflow = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0; + const outflowTotal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0; + // Subtract the previous tick's synthetic spill so it doesn't feed back into the integrator. + // The spill is registered as a 'predicted out' flow (child='overflow') so _selectBestNetFlow + // sees it for net-flow balance, but the volume math here must use REAL outflow only. + const spillPrev = this.measurements + .type('flow').variant('predicted').position('out').child('overflow') + .getCurrentValue(flowUnit) || 0; + const outflowReal = outflowTotal - spillPrev; if (!this._predictedFlowState) { - this._predictedFlowState = { inflow, outflow, lastTimestamp: now }; + this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now }; } const timestampPrev = this._predictedFlowState.lastTimestamp ?? now; const deltaSeconds = Math.max((now - timestampPrev) / 1000, 0); - const netVolumeChange = deltaSeconds > 0 ? (inflow - outflow) * deltaSeconds : 0; + const netVolumeChange = deltaSeconds > 0 ? (inflow - outflowReal) * deltaSeconds : 0; + + // Read currentVolume via a fresh chain — MeasurementContainer's chain + // methods mutate a shared cursor, so any later chain into a different + // type/variant invalidates a saved reference. We re-resolve every read + // and write below for the same reason. + const currentVolume = this.measurements + .type('volume').variant('predicted').position('atequipment').getCurrentValue('m3'); - const volumeSeries = this.measurements.type('volume').variant('predicted').position('atequipment'); - const currentVolume = volumeSeries.getCurrentValue('m3'); - - const nextVolume = currentVolume + netVolumeChange; const writeTimestamp = timestampPrev + deltaSeconds * 1000; - volumeSeries.value(nextVolume, writeTimestamp, 'm3').unit('m3'); //olifant + // Predicted-volume bounds. + // Upper: maxVolAtOverflow — past this the basin is physically spilling + // over the weir, so predicted level pins at overflowLevel and + // the excess is tracked as overflow volume + spill flow. + // Lower: dryRunSafetyVol — pumps physically can't pump below this. + // Only a measured level can show level outside this range (e.g. inflow + // exceeds pump+weir capacity → ceiling-pressure case). + const safety = this._computeSafetyPoints(); + const upperClamp = this.basin.maxVolAtOverflow; + const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0); + + const proposedVolume = currentVolume + netVolumeChange; + let nextVolume = proposedVolume; + let overflowIncrement = 0; + if (proposedVolume > upperClamp) { + overflowIncrement = proposedVolume - upperClamp; + nextVolume = upperClamp; + } else if (proposedVolume < lowerClamp && currentVolume >= lowerClamp) { + // Drained across the dry-run threshold — pumps would have stopped here. + // If we were already below (via calibration / low seed), leave the + // integrator alone so it follows the physics it's been told. + nextVolume = lowerClamp; + } + + // Synthetic spill flow. + // While pinned at overflow with continuing net-positive inflow, the + // weir is carrying away (inflow − outflowReal). Registering this as + // an 'out' flow keeps the predicted net-flow balance at ~0 (matches + // the level-pinned reality). + let spillRate = 0; + if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) { + spillRate = inflow - outflowReal; + } + this.measurements + .type('flow').variant('predicted').position('out').child('overflow') + .value(spillRate, writeTimestamp, 'm3/s').unit('m3/s'); + + // Cumulative overflow volume — for compliance reporting via InfluxDB. + if (overflowIncrement > 0) { + const prevCumulative = this.measurements + .type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0; + this.measurements + .type('overflowVolume').variant('predicted').position('atequipment') + .value(prevCumulative + overflowIncrement, writeTimestamp, 'm3').unit('m3'); + } + + this.measurements + .type('volume').variant('predicted').position('atequipment') + .value(nextVolume, writeTimestamp, 'm3').unit('m3'); const nextLevel = this._calcLevelFromVolume(nextVolume); this.measurements @@ -686,7 +744,7 @@ class PumpingStation { .position('atequipment') .value(percent, writeTimestamp, '%'); - this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTimestamp }; + this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: writeTimestamp }; } _selectBestNetFlow() { @@ -706,11 +764,28 @@ class PumpingStation { return { value: net, source: variant, direction: this._deriveDirection(net) }; } - // Fallback: level trend + // Fallback: level trend. + // When level pins at overflow, dL/dt collapses to 0 and the level-rate + // method loses the inflow signal — but flow IS still moving (in → spill). + // In that case we hold the last known non-zero net-flow so dashboards + // keep showing roughly what's coming in until level starts dropping. for (const variant of this.levelVariants) { const rate = this._levelRate(variant); if (!Number.isFinite(rate)) continue; - const netFlow = rate * this.basin.surfaceArea; + + const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m'); + const pinnedAtOverflow = Number.isFinite(lvl) + && Number.isFinite(this.basin.overflowLevel) + && lvl >= this.basin.overflowLevel - 1e-9; + const rateNearZero = Math.abs(rate) < 1e-9; + + let netFlow = rate * this.basin.surfaceArea; + if (pinnedAtOverflow && rateNearZero && Number.isFinite(this._lastLevelRateNetFlow)) { + netFlow = this._lastLevelRateNetFlow; + } else if (!rateNearZero) { + this._lastLevelRateNetFlow = netFlow; + } + return { value: netFlow, source: `level:${variant}`, direction: this._deriveDirection(netFlow) }; } @@ -1021,6 +1096,10 @@ class PumpingStation { output.isOverflowing = Boolean(this.safetyState?.isOverflowing); output.safetyState = this._deriveSafetyState(); output.percControl = this.percControl; + output.predictedOverflowVolume = this.measurements + .type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0; + output.predictedOverflowRate = this.measurements + .type('flow').variant('predicted').position('out').child('overflow').getCurrentValue('m3/s') ?? 0; return output; } diff --git a/test/basic/specificClass.test.js b/test/basic/specificClass.test.js index e09856a..49ff270 100644 --- a/test/basic/specificClass.test.js +++ b/test/basic/specificClass.test.js @@ -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); +});