Compare commits
1 Commits
6b46a8a8f0
...
d8490aa949
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8490aa949 |
@@ -645,15 +645,13 @@ class PumpingStation {
|
|||||||
const flowUnit = 'm3/s'; // this has to be in m3/s for the actions below
|
const flowUnit = 'm3/s'; // this has to be in m3/s for the actions below
|
||||||
const now = Date.now();
|
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 inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
|
||||||
const outflowTotal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
const outflowReal = 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) {
|
if (!this._predictedFlowState) {
|
||||||
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
||||||
@@ -673,12 +671,17 @@ class PumpingStation {
|
|||||||
const writeTimestamp = timestampPrev + deltaSeconds * 1000;
|
const writeTimestamp = timestampPrev + deltaSeconds * 1000;
|
||||||
|
|
||||||
// Predicted-volume bounds.
|
// Predicted-volume bounds.
|
||||||
// Upper: maxVolAtOverflow — past this the basin is physically spilling
|
// Upper (hard physical): maxVolAtOverflow — past this the basin spills
|
||||||
// over the weir, so predicted level pins at overflowLevel and
|
// over the weir; predicted level pins at overflowLevel and the
|
||||||
// the excess is tracked as overflow volume + spill flow.
|
// excess is tracked as overflow volume + spill flow.
|
||||||
// Lower: dryRunSafetyVol — pumps physically can't pump below this.
|
// Lower (operational): dryRunSafetyVol — where pumps must stop. Only
|
||||||
// Only a measured level can show level outside this range (e.g. inflow
|
// clamps on transition from above; a basin seeded below (e.g.
|
||||||
// exceeds pump+weir capacity → ceiling-pressure case).
|
// 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 safety = this._computeSafetyPoints();
|
||||||
const upperClamp = this.basin.maxVolAtOverflow;
|
const upperClamp = this.basin.maxVolAtOverflow;
|
||||||
const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0);
|
const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0);
|
||||||
@@ -686,27 +689,29 @@ class PumpingStation {
|
|||||||
const proposedVolume = currentVolume + netVolumeChange;
|
const proposedVolume = currentVolume + netVolumeChange;
|
||||||
let nextVolume = proposedVolume;
|
let nextVolume = proposedVolume;
|
||||||
let overflowIncrement = 0;
|
let overflowIncrement = 0;
|
||||||
|
let underflowIncrement = 0;
|
||||||
if (proposedVolume > upperClamp) {
|
if (proposedVolume > upperClamp) {
|
||||||
overflowIncrement = proposedVolume - upperClamp;
|
overflowIncrement = proposedVolume - upperClamp;
|
||||||
nextVolume = upperClamp;
|
nextVolume = upperClamp;
|
||||||
} else if (proposedVolume < lowerClamp && currentVolume >= lowerClamp) {
|
} 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;
|
nextVolume = lowerClamp;
|
||||||
}
|
}
|
||||||
|
if (nextVolume < 0) {
|
||||||
|
underflowIncrement = -nextVolume;
|
||||||
|
nextVolume = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Synthetic spill flow.
|
// Synthetic spill flow at position 'overflow'.
|
||||||
// While pinned at overflow with continuing net-positive inflow, the
|
// While pinned at upper bound with continuing net-positive inflow, the
|
||||||
// weir is carrying away (inflow − outflowReal). Registering this as
|
// weir is carrying away (inflow − outflowReal). _selectBestNetFlow folds
|
||||||
// an 'out' flow keeps the predicted net-flow balance at ~0 (matches
|
// this into the outflow side so the predicted net-flow balance reads ~0
|
||||||
// the level-pinned reality).
|
// (matches the level-pinned reality).
|
||||||
let spillRate = 0;
|
let spillRate = 0;
|
||||||
if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) {
|
if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) {
|
||||||
spillRate = inflow - outflowReal;
|
spillRate = inflow - outflowReal;
|
||||||
}
|
}
|
||||||
this.measurements
|
this.measurements
|
||||||
.type('flow').variant('predicted').position('out').child('overflow')
|
.type('flow').variant('predicted').position('overflow')
|
||||||
.value(spillRate, writeTimestamp, 'm3/s').unit('m3/s');
|
.value(spillRate, writeTimestamp, 'm3/s').unit('m3/s');
|
||||||
|
|
||||||
// Cumulative overflow volume — for compliance reporting via InfluxDB.
|
// Cumulative overflow volume — for compliance reporting via InfluxDB.
|
||||||
@@ -718,6 +723,19 @@ class PumpingStation {
|
|||||||
.value(prevCumulative + overflowIncrement, writeTimestamp, 'm3').unit('m3');
|
.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
|
this.measurements
|
||||||
.type('volume').variant('predicted').position('atequipment')
|
.type('volume').variant('predicted').position('atequipment')
|
||||||
.value(nextVolume, writeTimestamp, 'm3').unit('m3');
|
.value(nextVolume, writeTimestamp, 'm3').unit('m3');
|
||||||
@@ -756,7 +774,12 @@ class PumpingStation {
|
|||||||
if (!bucket || Object.keys(bucket).length === 0) continue;
|
if (!bucket || Object.keys(bucket).length === 0) continue;
|
||||||
|
|
||||||
const inflow = this.measurements.sum(type, variant, this.flowPositions.inflow, unit) || 0;
|
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;
|
if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue;
|
||||||
|
|
||||||
const net = inflow - outflow;
|
const net = inflow - outflow;
|
||||||
@@ -1099,7 +1122,9 @@ class PumpingStation {
|
|||||||
output.predictedOverflowVolume = this.measurements
|
output.predictedOverflowVolume = this.measurements
|
||||||
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||||
output.predictedOverflowRate = this.measurements
|
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;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ test('Predicted volume — overflow clamp and spill tracking', async (t) => {
|
|||||||
assert.equal(vol, 45); // pinned at overflow
|
assert.equal(vol, 45); // pinned at overflow
|
||||||
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
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
|
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
|
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);
|
assert.equal(vol, 45);
|
||||||
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
const cumulative = ps.measurements.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
||||||
assert.equal(cumulative, 3); // 1 + 2
|
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);
|
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 };
|
ps._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: t0 + 2000 };
|
||||||
Date.now = () => t0 + 3000;
|
Date.now = () => t0 + 3000;
|
||||||
ps._updatePredictedVolume();
|
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);
|
assert.equal(spill, 0);
|
||||||
// Volume stays at 45 (no draining force) but is no longer "pinned".
|
// Volume stays at 45 (no draining force) but is no longer "pinned".
|
||||||
const vol = ps.measurements.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3');
|
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.predictedOverflowVolume, 1);
|
||||||
assert.equal(out.predictedOverflowRate, 2);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user