Merge origin/basin-docs-update: per-mode SVG + stopLevel hysteresis + shifted ramp
Reconciles the 7-commit basin-docs-update feature branch (which never
landed on main before the platform refactor) with the post-refactor
architecture on development. Each basin-docs feature ported into the
relevant concern module:
control/levelBased.js
- stopLevel Schmitt-trigger + dead-band keep-alive
- Shifted ramp (arm % → hold @ 100% → ramp down to shiftLevel)
- Linear vs log up-curve (curveType + logCurveFactor)
measurement/flowAggregator.js
- Predicted-volume overflow clamp + spill flow stream
- Cumulative overflowVolume + underflowVolume
- Hard floor at 0 + dry-run-on-transition handling
basin/thresholdValidator.js
- computeSafetyPoints exposes dryRunLevel + highVolumeSafetyLevel
- startLevel ≤ inflowLevel invariant added
measurement/calibration.js + commands/
- Manual q_out path (set.outflow / q_out alias)
safety/safetyController.js
- Accepts both legacy + new high-volume threshold names
UI:
pumpingStation.html — restored the side-panel + SVG mode-preview block,
added defaults for stopLevel/shiftLevel/shiftArmPercent/levelCurveType/
logCurveFactor/enableShiftedRamp.
src/editor/* — basin-docs' 7-file modular editor (replaces single
src/editor.js, which is deleted).
pumpingStation.js — admin endpoint serves editor/:file.
Tests: 130/130 pass (125 basic + 5 integration). Two basin-docs test
files added: nodeClass-config.test.js, basic-dashboard-flow.test.js,
shifted-ramp-end-to-end.test.js. One pre-refactor control-levelBased
test adapted to match basin-docs canonical "no-shutdown in dead zone"
behaviour.
Human-review items (see commit context):
- rampFoot = inflowLevel (matches basin-docs test); basin-docs source
used rampFoot = startLevel. Domain owner: confirm intent.
- Naming kept dual (overfillLevel + highVolumeSafetyLevel).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,14 @@
|
||||
// Pure domain. Takes a context bag with the live MeasurementContainer, the
|
||||
// basin geometry, and the merged config; mutates measurements in place and
|
||||
// keeps a tiny piece of integrator state internally.
|
||||
//
|
||||
// Ports from basin-docs:
|
||||
// - Predicted-volume integrator clamped to [dryRunSafetyVol, maxVolAtOverflow]
|
||||
// with hard physical floor at 0 (predicted volume can never go negative).
|
||||
// - Synthetic spill flow at position 'overflow' so net-flow balance
|
||||
// reads ~0 while pinned at overflow.
|
||||
// - Cumulative overflowVolume + underflowVolume streams for compliance /
|
||||
// diagnostic reporting via InfluxDB.
|
||||
|
||||
const { interpolation } = require('generalFunctions');
|
||||
|
||||
@@ -35,9 +43,14 @@ class FlowAggregator {
|
||||
? ctx.flowThreshold
|
||||
: (Number.isFinite(cfgThresh) ? cfgThresh : DEFAULT_FLOW_THRESHOLD);
|
||||
|
||||
// Optional callback so the host can supply derived safety thresholds
|
||||
// without us re-importing the validator. Returns { dryRunSafetyVol, ... }.
|
||||
this._computeSafetyPoints = ctx.computeSafetyPoints || (() => ({ dryRunSafetyVol: 0 }));
|
||||
|
||||
this._predictedFlowState = null;
|
||||
this._lastNetFlow = { value: 0, source: null, direction: 'steady' };
|
||||
this._lastRemaining = { seconds: null, source: null };
|
||||
this._lastLevelRateNetFlow = null;
|
||||
}
|
||||
|
||||
resetState(timestamp = Date.now()) {
|
||||
@@ -48,34 +61,89 @@ class FlowAggregator {
|
||||
const flowUnit = 'm3/s';
|
||||
const now = Date.now();
|
||||
|
||||
// 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 so no self-subtraction is needed.
|
||||
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 outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
|
||||
|
||||
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow, lastTimestamp: now };
|
||||
if (!this._predictedFlowState) this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
|
||||
|
||||
const tPrev = this._predictedFlowState.lastTimestamp ?? now;
|
||||
const dt = Math.max((now - tPrev) / 1000, 0);
|
||||
const dV = dt > 0 ? (inflow - outflow) * dt : 0;
|
||||
const dV = dt > 0 ? (inflow - outflowReal) * dt : 0;
|
||||
|
||||
const volSeries = this.measurements.type('volume').variant('predicted').position('atequipment');
|
||||
const currentVol = volSeries.getCurrentValue('m3');
|
||||
const nextVol = (currentVol ?? this.basin.minVol ?? 0) + dV;
|
||||
const currentVol = this.measurements
|
||||
.type('volume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? this.basin.minVol ?? 0;
|
||||
const writeTs = tPrev + dt * 1000;
|
||||
|
||||
volSeries.value(nextVol, writeTs, 'm3').unit('m3');
|
||||
// Bounds.
|
||||
// Upper (hard physical): maxVolAtOverflow — past this the basin
|
||||
// spills; predicted level pins at overflowLevel and the excess
|
||||
// becomes cumulative overflowVolume + synthetic spill flow.
|
||||
// Lower (operational): dryRunSafetyVol — clamps ON TRANSITION
|
||||
// from above so the integrator can't drop into the unphysical
|
||||
// band. A basin seeded BELOW it is left alone (startup from empty).
|
||||
// Lower (hard physical): 0 — basin cannot hold negative water.
|
||||
// Any negative excess is tracked as underflowVolume (diagnostic).
|
||||
const safety = this._computeSafetyPoints();
|
||||
const upperClamp = this.basin.maxVolAtOverflow;
|
||||
const lowerClamp = Math.max(0, safety.dryRunSafetyVol ?? 0);
|
||||
|
||||
const proposedVolume = currentVol + dV;
|
||||
let nextVolume = proposedVolume;
|
||||
let overflowIncrement = 0;
|
||||
let underflowIncrement = 0;
|
||||
if (proposedVolume > upperClamp) {
|
||||
overflowIncrement = proposedVolume - upperClamp;
|
||||
nextVolume = upperClamp;
|
||||
} else if (proposedVolume < lowerClamp && currentVol >= lowerClamp) {
|
||||
nextVolume = lowerClamp;
|
||||
}
|
||||
if (nextVolume < 0) {
|
||||
underflowIncrement = -nextVolume;
|
||||
nextVolume = 0;
|
||||
}
|
||||
|
||||
// Synthetic spill flow at position 'overflow'.
|
||||
let spillRate = 0;
|
||||
if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) {
|
||||
spillRate = inflow - outflowReal;
|
||||
}
|
||||
this.measurements
|
||||
.type('flow').variant('predicted').position('overflow')
|
||||
.value(spillRate, writeTs, 'm3/s').unit('m3/s');
|
||||
|
||||
if (overflowIncrement > 0) {
|
||||
const prev = this.measurements
|
||||
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
this.measurements
|
||||
.type('overflowVolume').variant('predicted').position('atequipment')
|
||||
.value(prev + overflowIncrement, writeTs, 'm3').unit('m3');
|
||||
}
|
||||
if (underflowIncrement > 0) {
|
||||
const prev = this.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
this.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment')
|
||||
.value(prev + underflowIncrement, writeTs, 'm3').unit('m3');
|
||||
}
|
||||
|
||||
this.measurements.type('volume').variant('predicted').position('atequipment')
|
||||
.value(nextVolume, writeTs, 'm3').unit('m3');
|
||||
|
||||
const surfaceArea = this.basin.surfaceArea;
|
||||
const nextLevel = surfaceArea > 0 ? Math.max(nextVol, 0) / surfaceArea : 0;
|
||||
const nextLevel = surfaceArea > 0 ? Math.max(nextVolume, 0) / surfaceArea : 0;
|
||||
this.measurements.type('level').variant('predicted').position('atequipment')
|
||||
.value(nextLevel, writeTs, 'm').unit('m');
|
||||
|
||||
const percent = this._interp.interpolate_lin_single_point(
|
||||
nextVol, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
|
||||
nextVolume, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100
|
||||
);
|
||||
this.measurements.type('volumePercent').variant('predicted').position('atequipment')
|
||||
.value(percent, writeTs, '%');
|
||||
|
||||
this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTs };
|
||||
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: writeTs };
|
||||
}
|
||||
|
||||
selectBestNetFlow() {
|
||||
@@ -87,7 +155,11 @@ class FlowAggregator {
|
||||
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
|
||||
// so net-flow balance reads ~0 while pinned at the overflow level.
|
||||
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;
|
||||
@@ -101,8 +173,21 @@ class FlowAggregator {
|
||||
for (const variant of this.levelVariants) {
|
||||
const rate = this._levelRate(variant);
|
||||
if (!Number.isFinite(rate)) continue;
|
||||
const net = rate * this.basin.surfaceArea;
|
||||
const result = { value: net, source: `level:${variant}`, direction: this.deriveDirection(net) };
|
||||
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;
|
||||
// Pinned at overflow — dL/dt collapses to 0 but flow IS still
|
||||
// moving (in → spill). Hold the last known non-zero net-flow.
|
||||
if (pinnedAtOverflow && rateNearZero && Number.isFinite(this._lastLevelRateNetFlow)) {
|
||||
netFlow = this._lastLevelRateNetFlow;
|
||||
} else if (!rateNearZero) {
|
||||
this._lastLevelRateNetFlow = netFlow;
|
||||
}
|
||||
const result = { value: netFlow, source: `level:${variant}`, direction: this.deriveDirection(netFlow) };
|
||||
this._lastNetFlow = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user