Level-armed shift, derived dryRunLevel, side-panel editor + manual q_out

Runtime (specificClass.js):
- Replace direction-based hysteresis with level-armed _shiftArmed state.
  Arms when level rises past shiftLevel; disarms when level drops below
  startLevel. While armed, ramp foot moves to startLevel and ramp top
  to shiftLevel — both ends shift left, then saturate at 100 % up to
  maxLevel.
- _scaleLevelToFlowPercent now takes (rampStartLevel, rampTopLevel) so
  the saturation point follows the shift state.
- New setManualOutflow mirroring setManualInflow.

Adapter (nodeClass.js):
- Pipe enableShiftedRamp / shiftLevel through to control.levelbased.
- New q_out topic handler.

Editor (pumpingStation.html + new src/editor/ modules):
- Split monolithic <script> into modules: index.js (helpers),
  basin-diagram.js, mode-preview.js, hover-couple.js, oneditprepare.js,
  oneditsave.js — served via /pumpingStation/editor/:file.
- Mode preview redrawn per the SVG diagrams: OFF tier below 0 %, 0 %
  flat from start→inlet, ramp inlet→max, optional shifted-down curve
  start→shift with 100 % saturation past shift.
- Mode preview gains zone bands (dryRun / safetyLow / safe / safetyHigh /
  overflow), level markers (dryRun derived, start, inlet, max, shift,
  overflow), validation ribbon that blocks save on bad ordering.
- Auto-default shiftLevel to 0.9 × maxLevel on enable so the marker is
  always visible.
- All level inputs moved to a side panel left of each diagram, color-
  coded to match line strokes; hover-couple highlights the paired SVG
  line on input focus / mouseover.
- Removed UI for non-static parameters: minHeightBasedOn,
  pipelineLength, maxDischargeHead, staticHead, defaultFluid,
  maxInflowRate, temperatureReferenceDegC,
  timeleftToFullOrEmptyThresholdSeconds, inletPipeDiameter,
  outletPipeDiameter, minLevel (now derived = dryRunLevel).
- foreignObject inputs in basin SVG removed (single source of truth in
  side panel).

Dashboard example (examples/basic-dashboard.flow.json):
- Add manual Q_OUT slider + q_out builder mirroring the existing q_in
  trio so the basin can be exercised end-to-end without a connected
  rotating-machine downstream.

Tests (test/basic/specificClass.test.js):
- Replace direction-shift test with two new cases covering shift-disabled
  hold-zone behaviour and shift-armed/disarmed transitions through
  shiftLevel and startLevel boundaries. 53/53 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-05 19:29:34 +02:00
parent da50403c76
commit 8a6ca1baeb
12 changed files with 1877 additions and 487 deletions

View File

@@ -105,6 +105,14 @@ class PumpingStation {
// levelbased mode. Exposed in getOutput() for dashboards.
this.percControl = 0;
// --- Level-armed hysteresis state ---
// _shiftArmed flips true when level rises past shiftLevel (with
// enableShiftedRamp). While armed, the demand ramp's lower foot
// is startLevel instead of inflowLevel — so on the way down the
// pumps stay aggressive until level falls below startLevel, at
// which point the arm clears.
this._shiftArmed = false;
// --- Flow dead-band ---
// flowThreshold (m3/s) prevents control actions on noise.
// Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is
@@ -271,6 +279,11 @@ class PumpingStation {
this.measurements.type('flow').variant('predicted').position('in').child('manual-qin').value(num, timestamp, unit);
}
setManualOutflow(value, timestamp = Date.now(), unit) {
const num = Number(value);
this.measurements.type('flow').variant('predicted').position('out').child('manual-qout').value(num, timestamp, unit);
}
/* --------------------------- Tick / Control --------------------------- */
tick() {
@@ -314,7 +327,7 @@ class PumpingStation {
_controlLogic(direction) {
switch (this.mode) {
case 'levelbased':
this._controlLevelBased();
this._controlLevelBased(direction);
break;
case 'flowbased':
this._controlFlowBased?.();
@@ -326,7 +339,7 @@ class PumpingStation {
}
}
async _controlLevelBased() {
async _controlLevelBased(_direction) {
const { startLevel, minLevel } = this.config.control.levelbased;
const levelUnit = this.measurements.getUnit('level');
@@ -336,33 +349,45 @@ class PumpingStation {
return;
}
// Level-based pump control via MGC — three zones:
// Level-based pump control via MGC (see wiki/modes/levelbased.md).
//
// level < minLevel → STOP (unconditional MGC shutdown)
// minLevel ≤ level < startLevel → DEAD ZONE (hysteresis; keep last cmd)
// level ≥ startLevel → RUN (linear [startLevel..maxLevel] → [0..100 %])
// See wiki/modes/levelbased.md for the full transfer-function diagram.
// level < startLevel → 0 % (pumps held off)
// level in [startLevel..rampStart] → 0 % (HOLD zone)
// level in [rampStart..maxLevel] → 0..100 % (linear or log curve)
// level > maxLevel → ≥100 % (MGC clamps internally)
//
// With enableShiftedRamp:
// rampStart = inflowLevel by default
// when level rises past shiftLevel → arm → rampStart = startLevel
// when level drops below startLevel → disarm → rampStart = inflowLevel
// Without enableShiftedRamp: rampStart = inflowLevel always.
// STOP — below minLevel, always shut down regardless of direction.
if (level < minLevel) {
this.percControl = 0;
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return;
}
// DEAD ZONE — between minLevel and startLevel, do nothing.
// Pumps that are running keep their last command; pumps that
// are off stay off. This prevents rapid on/off cycling.
if (level < startLevel) {
this._updateShiftArmed(level);
const rampStartLevel = this._levelBasedRampStart();
const rampTopLevel = this._levelBasedRampTop();
// HOLD/MINIMUM DEMAND — below the active ramp start, command 0 %
// without latching dry-run. Dry-run remains the safety layer's job.
if (level < rampStartLevel) {
this.percControl = 0;
await this._applyMachineGroupLevelControl(0);
return;
}
// RUN — above startLevel, compute demand and forward to MGC.
// _scaleLevelToFlowPercent maps [startLevel..maxLevel] → [0..100].
// Above maxLevel the MGC clamps internally.
const rawPercControl = this._scaleLevelToFlowPercent(level);
// RUN — above rampStartLevel, compute demand and forward to MGC.
// _scaleLevelToFlowPercent maps [rampStartLevel..rampTopLevel] → [0..100].
// Above rampTopLevel demand saturates at 100 %.
const rawPercControl = this._scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel);
const percControl = Math.max(0, rawPercControl);
this.percControl = percControl;
this.logger.debug(`Level-based control: level=${level} percControl=${percControl}`);
this.logger.debug(`Level-based control: level=${level} armed=${this._shiftArmed} foot=${rampStartLevel} top=${rampTopLevel} percControl=${percControl}`);
await this._applyMachineGroupLevelControl(percControl);
}
@@ -503,10 +528,60 @@ class PumpingStation {
return null;
}
_scaleLevelToFlowPercent(level) {
const { startLevel, maxLevel } = this.config.control.levelbased;
this.logger.debug(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`);
return this.interpolate.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100);
_levelBasedRampStart() {
const { startLevel, enableShiftedRamp } = this.config.control.levelbased;
const inflowLevel = this.basin?.inflowLevel;
if (enableShiftedRamp && this._shiftArmed) return startLevel;
if (Number.isFinite(inflowLevel)) return inflowLevel;
return startLevel;
}
_levelBasedRampTop() {
// Returns the upper level at which demand saturates at 100 %.
// While the shift is armed, top moves left from maxLevel to shiftLevel
// so output reaches 100 % earlier and stays at 100 % until level
// falls back through shiftLevel on the way down.
const { maxLevel, enableShiftedRamp, shiftLevel } = this.config.control.levelbased;
if (enableShiftedRamp && this._shiftArmed
&& Number.isFinite(shiftLevel) && shiftLevel > 0
&& shiftLevel <= maxLevel) {
return shiftLevel;
}
return maxLevel;
}
_updateShiftArmed(level) {
const { enableShiftedRamp, shiftLevel, startLevel } = this.config.control.levelbased;
if (!enableShiftedRamp) {
this._shiftArmed = false;
return;
}
const trigger = Number.isFinite(shiftLevel) && shiftLevel > 0 ? shiftLevel : null;
if (!this._shiftArmed && trigger != null && level >= trigger) {
this._shiftArmed = true;
this.logger.debug(`Shift armed at level=${level} (shiftLevel=${trigger})`);
} else if (this._shiftArmed && Number.isFinite(startLevel) && level < startLevel) {
this._shiftArmed = false;
this.logger.debug(`Shift disarmed at level=${level} (startLevel=${startLevel})`);
}
}
_scaleLevelToFlowPercent(level, rampStartLevel, rampTopLevel) {
const { maxLevel, curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
const start = Number.isFinite(rampStartLevel) ? rampStartLevel : this.config.control.levelbased.startLevel;
const top = Number.isFinite(rampTopLevel) ? rampTopLevel : maxLevel;
if (!Number.isFinite(level) || !Number.isFinite(start) || !Number.isFinite(top)) return 0;
if (top <= start) return level >= top ? 100 : 0;
const x = Math.max(0, Math.min(1, (level - start) / (top - start)));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor)
: 9;
return 100 * (Math.log1p(factor * x) / Math.log1p(factor));
}
return x * 100;
}
_levelRate(variant) {
@@ -634,7 +709,7 @@ class PumpingStation {
* Only a manual override or emergency can restart them.
* safetyControllerActive = true → blocks _controlLogic.
*
* 2. ABOVE overflow level (overfill): pumps CANNOT stop.
* 2. ABOVE high-volume safety level: pumps CANNOT stop.
* Shuts down UPSTREAM equipment only (stop more water coming in).
* Does NOT shut down downstream pumps or machine groups — they
* must keep draining. Does NOT set safetyControllerActive — the
@@ -642,7 +717,7 @@ class PumpingStation {
* dictated by the current level (which will be >100% near overflow,
* meaning all pumps at maximum via the normal demand curve).
* Only a manual override or emergency stop can shut pumps during
* an overfill event.
* a high-volume or overflowing event.
*/
_safetyController(remainingTime, direction) {
this.safetyControllerActive = false;
@@ -660,21 +735,34 @@ class PumpingStation {
enableDryRunProtection,
dryRunThresholdPercent,
enableOverfillProtection,
overfillThresholdPercent,
enableHighVolumeSafety,
timeleftToFullOrEmptyThresholdSeconds
} = this.config.safety || {};
const dryRunEnabled = Boolean(enableDryRunProtection);
const overfillEnabled = Boolean(enableOverfillProtection);
const highVolumeSafetyEnabled = Boolean(enableHighVolumeSafety ?? enableOverfillProtection);
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
const triggerHighVol = this.basin.maxVolAtOverflow * ((Number(overfillThresholdPercent) || 0) / 100);
const triggerLowVol = this.basin.minVol * (1 + ((Number(dryRunThresholdPercent) || 0) / 100));
const safety = this._computeSafetyPoints();
const triggerHighVol = safety.highVolumeSafetyVol;
const triggerLowVol = safety.dryRunSafetyVol;
const currentLevel = this._pickVariant('level', this.levelVariants, 'atequipment', 'm');
this.safetyState = {
dryRunActive: false,
highVolumeActive: false,
isOverflowing: Number.isFinite(currentLevel) && currentLevel >= this.basin.overflowLevel,
dryRunLevel: safety.dryRunLevel,
highVolumeSafetyLevel: safety.highVolumeSafetyLevel,
dryRunSafetyVol: safety.dryRunSafetyVol,
highVolumeSafetyVol: safety.highVolumeSafetyVol
};
// Rule 1: DRY-RUN — below minLevel, pumps cannot run.
if (direction === 'draining') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
const dryRunTriggered = dryRunEnabled && vol < triggerLowVol;
if (timeTriggered || dryRunTriggered) {
this.safetyState.dryRunActive = true;
// Shut down all downstream equipment — pumps must stop.
Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
@@ -700,8 +788,9 @@ class PumpingStation {
// running to maintain pump demand.
if (direction === 'filling') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
const overfillTriggered = overfillEnabled && vol > triggerHighVol;
if (timeTriggered || overfillTriggered) {
const highVolumeTriggered = highVolumeSafetyEnabled && vol > triggerHighVol;
if (timeTriggered || highVolumeTriggered) {
this.safetyState.highVolumeActive = true;
// Shut down UPSTREAM only — stop more water coming in.
Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
@@ -713,7 +802,7 @@ class PumpingStation {
// NOTE: machine groups (downstream pumps) are NOT shut down.
// They must keep draining to prevent overflow from worsening.
this.logger.warn(
`Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
`High-volume safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
);
// NOTE: safetyControllerActive is NOT set — level control
// keeps commanding pumps at maximum demand.
@@ -721,6 +810,25 @@ class PumpingStation {
}
}
_computeSafetyPoints() {
const safety = this.config.safety || {};
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
const highPct = Number(
safety.highVolumeSafetyThresholdPercent ?? safety.overfillThresholdPercent ?? 98
) || 0;
const dryRunSafetyVol = this.basin.minVol * (1 + (dryRunPct / 100));
const dryRunLevel = this._calcLevelFromVolume(dryRunSafetyVol);
const highVolumeSafetyVol = this.basin.maxVolAtOverflow * (highPct / 100);
const highVolumeSafetyLevel = this._calcLevelFromVolume(highVolumeSafetyVol);
return {
dryRunSafetyVol,
dryRunLevel,
highVolumeSafetyVol,
highVolumeSafetyLevel
};
}
/* --------------------------- Basin --------------------------- */
/**
@@ -740,9 +848,11 @@ class PumpingStation {
const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn;
const volEmptyBasin = this.config.basin.volume; // m3 — total basin capacity
const heightBasin = this.config.basin.height; // m — floor to rim
const inflowLevel = this.config.basin.inflowLevel; // m — sewer feed pipe centre
const outflowLevel = this.config.basin.outflowLevel; // m — pump suction pipe centre
const inflowLevel = this.config.basin.inflowLevel; // m — inlet pipe bottom/invert
const outflowLevel = this.config.basin.outflowLevel; // m — outlet/pump suction pipe top
const overflowLevel = this.config.basin.overflowLevel; // m — overflow weir crest
const inletPipeDiameter = this.config.basin.inletPipeDiameter;
const outletPipeDiameter = this.config.basin.outletPipeDiameter;
// Constant cross-section assumption: volume = level × area
const surfaceArea = volEmptyBasin / heightBasin;
@@ -762,6 +872,8 @@ class PumpingStation {
inflowLevel,
outflowLevel,
overflowLevel,
inletPipeDiameter,
outletPipeDiameter,
surfaceArea,
maxVol,
maxVolAtOverflow,
@@ -786,26 +898,21 @@ class PumpingStation {
*
* Strict invariants (bottom → top):
* 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
* dryRunTriggerLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overflowLevel
* dryRunLevel ≤ minLevel ≤ startLevel ≤ inflowLevel < maxLevel ≤ highVolumeSafetyLevel < overflowLevel
*
* dryRunTriggerLevel and the overfill trigger are DERIVED — computed
* dryRunLevel and highVolumeSafetyLevel are DERIVED — computed
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
* overfillThresholdPercent/100 in the safety layer. Validating those
* highVolumeSafetyThresholdPercent/100 in the safety layer. Validating those
* catches config that would let minLevel sit below where safety has
* already force-stopped the pumps (no-op control band).
*/
_validateThresholdOrdering() {
const basin = this.basin;
const lvl = this.config.control?.levelbased || {};
const safety = this.config.safety || {};
// Derived safety trigger levels (level-space equivalents of what
// _safetyController does in volume-space).
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
const overfillPct = Number(safety.overfillThresholdPercent) || 100;
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
const safetyPoints = this._computeSafetyPoints();
const dryRunLevel = safetyPoints.dryRunLevel;
const highVolumeSafetyLevel = safetyPoints.highVolumeSafetyLevel;
const checks = [
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
@@ -813,8 +920,10 @@ class PumpingStation {
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
['highVolumeSafetyLevel', highVolumeSafetyLevel, '<', 'overflowLevel', basin.overflowLevel],
];
const issues = [];
@@ -844,21 +953,38 @@ class PumpingStation {
getOutput() {
const output = this.measurements.getFlattenedOutput();
const safety = this._computeSafetyPoints();
output.direction = this.state.direction;
output.flowSource = this.state.flowSource;
output.timeleft = this.state.seconds;
output.volEmptyBasin = this.basin.volEmptyBasin;
output.inflowLevel = this.basin.inflowLevel;
output.outflowLevel = this.basin.outflowLevel;
output.overflowLevel = this.basin.overflowLevel;
output.inletPipeDiameter = this.basin.inletPipeDiameter;
output.outletPipeDiameter = this.basin.outletPipeDiameter;
output.maxVol = this.basin.maxVol;
output.minVol = this.basin.minVol;
output.maxVolAtOverflow = this.basin.maxVolAtOverflow;
output.minVolAtOutflow = this.basin.minVolAtOutflow;
output.minVolAtInflow = this.basin.minVolAtInflow;
output.minHeightBasedOn = this.basin.minHeightBasedOn;
output.dryRunLevel = safety.dryRunLevel;
output.dryRunSafetyVol = safety.dryRunSafetyVol;
output.highVolumeSafetyLevel = safety.highVolumeSafetyLevel;
output.highVolumeSafetyVol = safety.highVolumeSafetyVol;
output.isOverflowing = Boolean(this.safetyState?.isOverflowing);
output.safetyState = this._deriveSafetyState();
output.percControl = this.percControl;
return output;
}
_deriveSafetyState() {
if (this.safetyState?.isOverflowing) return 'overflowing';
if (this.safetyState?.highVolumeActive) return 'highVolume';
if (this.safetyState?.dryRunActive) return 'dryRun';
return 'normal';
}
}
module.exports = PumpingStation;
@@ -887,15 +1013,19 @@ if (require.main === module) {
height: 10,
inflowLevel: 3,
outflowLevel: 0.2,
overflowLevel: 3.2
overflowLevel: 3.2,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3
},
hydraulics: {
refHeight: 'NAP',
basinBottomRef: 0
basinBottomRef: 0,
minHeightBasedOn: 'outlet'
},
safety: {
enableDryRunProtection:false,
enableOverfillProtection:false
enableHighVolumeSafety:false,
highVolumeSafetyThresholdPercent: 98
}
};
}
@@ -1036,4 +1166,4 @@ if (require.main === module) {
console.error('Demo failed:', err);
});
}
//*/
//*/