Predicted-volume hard-floor at 0 + spill flow position refactor

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>
This commit is contained in:
Rene De Ren
2026-05-06 17:18:23 +02:00
parent 6b46a8a8f0
commit d8490aa949
2 changed files with 103 additions and 28 deletions

View File

@@ -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;
}