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:
znetsixe
2026-05-11 16:19:55 +02:00
40 changed files with 3035 additions and 555 deletions

View File

@@ -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;
}