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:
@@ -7,7 +7,7 @@
|
||||
|
||||
const { BaseDomain, UnitPolicy, statusBadge } = require('generalFunctions');
|
||||
const BasinGeometry = require('./basin/BasinGeometry');
|
||||
const { validateThresholdOrdering } = require('./basin/thresholdValidator');
|
||||
const { validateThresholdOrdering, computeSafetyPoints } = require('./basin/thresholdValidator');
|
||||
const FlowAggregator = require('./measurement/flowAggregator');
|
||||
const MeasurementRouter = require('./measurement/measurementRouter');
|
||||
const calibration = require('./measurement/calibration');
|
||||
@@ -20,9 +20,15 @@ class PumpingStation extends BaseDomain {
|
||||
// Internal math runs in m3/s for flow and m for level so the volume
|
||||
// integrator (flow × dt) is unit-consistent. Strict canonicals make
|
||||
// unit drift in child-fed measurements an explicit error.
|
||||
// overflowVolume / underflowVolume are listed in output so the
|
||||
// MeasurementContainer keeps the integrator's m³ unit on those streams
|
||||
// (FlowAggregator writes spill / underflow per tick).
|
||||
static unitPolicy = UnitPolicy.declare({
|
||||
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
||||
output: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' },
|
||||
output: {
|
||||
flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3',
|
||||
overflowVolume: 'm3', underflowVolume: 'm3',
|
||||
},
|
||||
requireUnitForTypes: [],
|
||||
});
|
||||
|
||||
@@ -38,6 +44,24 @@ class PumpingStation extends BaseDomain {
|
||||
this.controlState = { percControl: 0 };
|
||||
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
|
||||
|
||||
// Level-armed hysteresis state — ported from basin-docs `_controlLevelBased`.
|
||||
// Exposed as instance fields because the e2e/basic tests assert on them
|
||||
// directly. levelBased strategy reads/writes via the same names.
|
||||
this._shiftArmed = false;
|
||||
this._shiftHoldValue = null;
|
||||
this._lastDirection = null;
|
||||
|
||||
// stopLevel hysteresis (Schmitt trigger) — ported from basin-docs.
|
||||
// TRUE while engaged (rising-edge at startLevel until falling-edge at
|
||||
// stopLevel). Used by levelBased to emit a small keep-alive output in
|
||||
// the [stopLevel, startLevel] dead band so MGC keeps one pump running.
|
||||
this._stopHystRunning = false;
|
||||
|
||||
// Flow dead-band — values below |flowThreshold| (m3/s) are treated as
|
||||
// steady. Default ≈ 0.36 m3/h.
|
||||
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
|
||||
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
|
||||
|
||||
// FlowAggregator owns the predicted-volume integrator + net-flow + ETA.
|
||||
this.flowAggregator = new FlowAggregator({
|
||||
measurements: this.measurements,
|
||||
@@ -47,6 +71,8 @@ class PumpingStation extends BaseDomain {
|
||||
flowVariants: this.flowVariants,
|
||||
levelVariants: this.levelVariants,
|
||||
flowPositions: this.flowPositions,
|
||||
flowThreshold: this.flowThreshold,
|
||||
computeSafetyPoints: () => this._computeSafetyPoints(),
|
||||
});
|
||||
this.measurementRouter = new MeasurementRouter({
|
||||
measurements: this.measurements,
|
||||
@@ -55,7 +81,9 @@ class PumpingStation extends BaseDomain {
|
||||
});
|
||||
|
||||
// Threshold ordering is non-fatal — log + surface for tests/status.
|
||||
this.thresholdIssues = validateThresholdOrdering(this.basin, this.config.control?.levelbased, this.config.safety);
|
||||
this.thresholdIssues = validateThresholdOrdering(
|
||||
this.basin, this.config.control?.levelbased, this.config.safety
|
||||
);
|
||||
for (const issue of this.thresholdIssues) this.logger.warn(issue.msg);
|
||||
|
||||
// Seed predicted volume at the operational floor — without it the
|
||||
@@ -97,6 +125,11 @@ class PumpingStation extends BaseDomain {
|
||||
}
|
||||
|
||||
// Frozen view passed to control strategies + safety.
|
||||
// `host` is a back-reference so strategies that need to mutate
|
||||
// cross-tick hysteresis state (`_shiftArmed`, `_shiftHoldValue`,
|
||||
// `_lastDirection`, `_stopHystRunning`) write straight to the live
|
||||
// instance — Object.freeze on the view itself is fine because these
|
||||
// flags live on the host, not in the view.
|
||||
context() {
|
||||
return Object.freeze({
|
||||
...super.context(),
|
||||
@@ -109,6 +142,8 @@ class PumpingStation extends BaseDomain {
|
||||
flowVariants: this.flowVariants,
|
||||
levelVariants: this.levelVariants,
|
||||
volVariants: this.volVariants,
|
||||
flowThreshold: this.flowThreshold,
|
||||
host: this,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,7 +153,7 @@ class PumpingStation extends BaseDomain {
|
||||
this.safetyControllerActive = safe.blocked;
|
||||
|
||||
if (!safe.blocked) {
|
||||
Promise.resolve(control.dispatch(this.mode, this.context(), this.controlState))
|
||||
Promise.resolve(control.dispatch(this.mode, this.context(), this.controlState, netFlow.direction))
|
||||
.catch((err) => this.logger.error(`control dispatch failed: ${err.message}`));
|
||||
}
|
||||
|
||||
@@ -145,19 +180,38 @@ class PumpingStation extends BaseDomain {
|
||||
calibratePredictedVolume(vol, ts = Date.now()) { calibration.calibratePredictedVolume(this, vol, ts); }
|
||||
calibratePredictedLevel(lvl, ts = Date.now(), unit = 'm') { calibration.calibratePredictedLevel(this, lvl, ts, unit); }
|
||||
setManualInflow(value, ts = Date.now(), unit) { calibration.setManualInflow(this, value, ts, unit); }
|
||||
setManualOutflow(value, ts = Date.now(), unit) { calibration.setManualOutflow(this, value, ts, unit); }
|
||||
|
||||
forwardDemandToChildren(demand) { return control.manual.forwardDemand(this.context(), demand); }
|
||||
|
||||
// Direct delegations preserved so existing tests can drive the strategy
|
||||
// without re-mocking the dispatch layer.
|
||||
async _controlLevelBased() {
|
||||
return control.strategies.levelbased.run(this.context(), this.controlState);
|
||||
async _controlLevelBased(direction) {
|
||||
return control.strategies.levelbased.run(this.context(), this.controlState, direction);
|
||||
}
|
||||
|
||||
// Public getter so legacy tests + getOutput keep reading the live demand.
|
||||
get percControl() { return this.controlState.percControl; }
|
||||
set percControl(v) { this.controlState.percControl = v; }
|
||||
|
||||
// ── Predicted-volume integrator — tests drive this directly with a
|
||||
// controlled Date.now, so expose as an instance method that delegates
|
||||
// to FlowAggregator.update().
|
||||
_updatePredictedVolume() {
|
||||
return this.flowAggregator.update();
|
||||
}
|
||||
|
||||
// ── Mirror FlowAggregator internal integrator state so tests that pin
|
||||
// _predictedFlowState before driving a tick keep working.
|
||||
get _predictedFlowState() { return this.flowAggregator._predictedFlowState; }
|
||||
set _predictedFlowState(v) { this.flowAggregator._predictedFlowState = v; }
|
||||
|
||||
_selectBestNetFlow() { return this.flowAggregator.selectBestNetFlow(); }
|
||||
|
||||
_computeSafetyPoints() {
|
||||
return computeSafetyPoints(this.basin, this.config.safety || {});
|
||||
}
|
||||
|
||||
getOutput() {
|
||||
const out = this.measurements.getFlattenedOutput();
|
||||
Object.assign(out, this.basin.snapshot());
|
||||
@@ -165,6 +219,23 @@ class PumpingStation extends BaseDomain {
|
||||
out.flowSource = this.state.flowSource;
|
||||
out.timeleft = this.state.seconds;
|
||||
out.percControl = this.controlState.percControl;
|
||||
|
||||
// Derived safety thresholds — exposed so editor + dashboards can show
|
||||
// the dryRunLevel and highVolumeSafetyLevel without recomputing.
|
||||
const safety = this._computeSafetyPoints();
|
||||
out.dryRunLevel = safety.dryRunLevel;
|
||||
out.dryRunSafetyVol = safety.dryRunSafetyVol;
|
||||
out.highVolumeSafetyLevel = safety.highVolumeSafetyLevel;
|
||||
out.highVolumeSafetyVol = safety.highVolumeSafetyVol;
|
||||
|
||||
// Spill / underflow surface — populated by FlowAggregator when the
|
||||
// predicted-volume integrator hits the upper or lower physical bound.
|
||||
out.predictedOverflowVolume = this.measurements
|
||||
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
out.predictedOverflowRate = this.measurements
|
||||
.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s') ?? 0;
|
||||
out.predictedUnderflowVolume = this.measurements
|
||||
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user