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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user