From d8490aa94973c90b207f997da4ddef058c92e33b Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Wed, 6 May 2026 17:18:23 +0200 Subject: [PATCH] Predicted-volume hard-floor at 0 + spill flow position refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. to its own position flow.predicted.overflow.. 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) --- src/specificClass.js | 75 +++++++++++++++++++++----------- test/basic/specificClass.test.js | 56 ++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 28 deletions(-) diff --git a/src/specificClass.js b/src/specificClass.js index d21f262..4b6e76b 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -645,15 +645,13 @@ class PumpingStation { const flowUnit = 'm3/s'; // this has to be in m3/s for the actions below const now = Date.now(); + // The synthetic spill flow lives at its OWN position ('overflow') — + // not as a child of 'out'. That keeps it out of the operational-outflow + // sum here (which only sees pumps + downstream measurements), so no + // self-subtraction is needed. _selectBestNetFlow folds it back in for + // net-flow balance while pinned at overflow. const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, 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; + const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0; if (!this._predictedFlowState) { this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now }; @@ -673,12 +671,17 @@ class PumpingStation { const writeTimestamp = timestampPrev + deltaSeconds * 1000; // 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). + // Upper (hard physical): maxVolAtOverflow — past this the basin spills + // over the weir; predicted level pins at overflowLevel and the + // excess is tracked as overflow volume + spill flow. + // Lower (operational): dryRunSafetyVol — where pumps must stop. Only + // clamps on transition from above; a basin seeded below (e.g. + // startup-from-empty) is left alone so it can fill from 0. + // Lower (hard physical): 0 — basin cannot hold negative water. Always + // clamps. Without this, a seeded-low basin under continued + // net-outflow integrates volume arbitrarily negative (the level + // output looks fine because _calcLevelFromVolume floors at 0, + // masking the underlying drift). const safety = this._computeSafetyPoints(); const upperClamp = this.basin.maxVolAtOverflow; const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0); @@ -686,27 +689,29 @@ class PumpingStation { const proposedVolume = currentVolume + netVolumeChange; let nextVolume = proposedVolume; let overflowIncrement = 0; + let underflowIncrement = 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; } + if (nextVolume < 0) { + underflowIncrement = -nextVolume; + nextVolume = 0; + } - // 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). + // Synthetic spill flow at position 'overflow'. + // While pinned at upper bound with continuing net-positive inflow, the + // weir is carrying away (inflow − outflowReal). _selectBestNetFlow folds + // this into the outflow side so the predicted net-flow balance reads ~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') + .type('flow').variant('predicted').position('overflow') .value(spillRate, writeTimestamp, 'm3/s').unit('m3/s'); // Cumulative overflow volume — for compliance reporting via InfluxDB. @@ -718,6 +723,19 @@ class PumpingStation { .value(prevCumulative + overflowIncrement, writeTimestamp, 'm3').unit('m3'); } + // Cumulative integrator underflow — diagnostic, NOT compliance. + // A nonzero value means the predicted-volume integrator tried to go + // below the physical floor (negative water). Root causes are usually + // upstream: outflow over-reported (sensor drift, pump curve too + // optimistic) or an inflow source missing from the measurement set. + if (underflowIncrement > 0) { + const prevUnderflow = this.measurements + .type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0; + this.measurements + .type('underflowVolume').variant('predicted').position('atequipment') + .value(prevUnderflow + underflowIncrement, writeTimestamp, 'm3').unit('m3'); + } + this.measurements .type('volume').variant('predicted').position('atequipment') .value(nextVolume, writeTimestamp, 'm3').unit('m3'); @@ -756,7 +774,12 @@ class PumpingStation { if (!bucket || Object.keys(bucket).length === 0) continue; const inflow = this.measurements.sum(type, variant, this.flowPositions.inflow, unit) || 0; - const outflow = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0; + const outflowReal = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0; + // Fold synthetic spill (position 'overflow') into the outflow side. + // It only exists for the predicted variant and only while pinned, so + // for measured this is 0. + const spill = this.measurements.sum(type, variant, ['overflow'], unit) || 0; + const outflow = outflowReal + spill; if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue; const net = inflow - outflow; @@ -1099,7 +1122,9 @@ class PumpingStation { 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; + .type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s') ?? 0; + output.predictedUnderflowVolume = this.measurements + .type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0; return output; } diff --git a/test/basic/specificClass.test.js b/test/basic/specificClass.test.js index 49ff270..6b2a5a5 100644 --- a/test/basic/specificClass.test.js +++ b/test/basic/specificClass.test.js @@ -472,7 +472,7 @@ test('Predicted volume — overflow clamp and spill tracking', async (t) => { 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'); + const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s'); assert.equal(spill, 2); // instantaneous balance: inflow − outflowReal }); @@ -483,7 +483,7 @@ test('Predicted volume — overflow clamp and spill tracking', async (t) => { 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'); + const spill = ps.measurements.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s'); assert.equal(spill, 2); }); @@ -499,7 +499,7 @@ test('Predicted volume — overflow clamp and spill tracking', async (t) => { 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'); + 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'); @@ -549,3 +549,53 @@ test('getOutput — exposes predictedOverflowVolume / predictedOverflowRate', () 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 + }); +});