Compare commits
1 Commits
62bc73f2f9
...
6b46a8a8f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b46a8a8f0 |
@@ -45,7 +45,7 @@ class PumpingStation {
|
|||||||
// keep the basin geometry math unit-consistent.
|
// keep the basin geometry math unit-consistent.
|
||||||
this.measurements = new MeasurementContainer({
|
this.measurements = new MeasurementContainer({
|
||||||
autoConvert: true,
|
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 ---
|
// --- Child registries ---
|
||||||
@@ -646,23 +646,81 @@ class PumpingStation {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
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 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) {
|
if (!this._predictedFlowState) {
|
||||||
this._predictedFlowState = { inflow, outflow, lastTimestamp: now };
|
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestampPrev = this._predictedFlowState.lastTimestamp ?? now;
|
const timestampPrev = this._predictedFlowState.lastTimestamp ?? now;
|
||||||
const deltaSeconds = Math.max((now - timestampPrev) / 1000, 0);
|
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;
|
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);
|
const nextLevel = this._calcLevelFromVolume(nextVolume);
|
||||||
this.measurements
|
this.measurements
|
||||||
@@ -686,7 +744,7 @@ class PumpingStation {
|
|||||||
.position('atequipment')
|
.position('atequipment')
|
||||||
.value(percent, writeTimestamp, '%');
|
.value(percent, writeTimestamp, '%');
|
||||||
|
|
||||||
this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTimestamp };
|
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: writeTimestamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
_selectBestNetFlow() {
|
_selectBestNetFlow() {
|
||||||
@@ -706,11 +764,28 @@ class PumpingStation {
|
|||||||
return { value: net, source: variant, direction: this._deriveDirection(net) };
|
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) {
|
for (const variant of this.levelVariants) {
|
||||||
const rate = this._levelRate(variant);
|
const rate = this._levelRate(variant);
|
||||||
if (!Number.isFinite(rate)) continue;
|
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) };
|
return { value: netFlow, source: `level:${variant}`, direction: this._deriveDirection(netFlow) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,6 +1096,10 @@ class PumpingStation {
|
|||||||
output.isOverflowing = Boolean(this.safetyState?.isOverflowing);
|
output.isOverflowing = Boolean(this.safetyState?.isOverflowing);
|
||||||
output.safetyState = this._deriveSafetyState();
|
output.safetyState = this._deriveSafetyState();
|
||||||
output.percControl = this.percControl;
|
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;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
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);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user