Files
pumpingStation/src/specificClass.js

1397 lines
57 KiB
JavaScript
Raw Normal View History

2025-10-07 18:05:54 +02:00
const EventEmitter = require('events');
2025-11-30 09:24:18 +01:00
const {
logger,
configUtils,
configManager,
childRegistrationUtils,
MeasurementContainer,
coolprop,
interpolation,
POSITIONS
2025-11-30 09:24:18 +01:00
} = require('generalFunctions');
2025-11-03 07:42:51 +01:00
class PumpingStation {
/**
* PumpingStation S88 Process Cell.
*
* Models a wet-well basin with inflow/outflow and orchestrates child
* equipment (pumps via rotatingMachine, pump groups via MGC, nested
* stations) to keep the water level within safe bounds.
*
* Full behaviour, threshold semantics, control modes, and the basin
* diagram are documented in the wiki:
* wiki/functional-description.md + wiki/modes/*.md
*
* Tick loop (1 s): predicted volume net flow safety control.
*/
2025-10-28 17:04:26 +01:00
constructor(config = {}) {
// --- Dependency injection & config merge ---
2025-10-28 17:04:26 +01:00
this.emitter = new EventEmitter();
this.configManager = new configManager();
2025-10-14 08:36:45 +02:00
this.defaultConfig = this.configManager.getConfig('pumpingStation');
2025-10-07 18:05:54 +02:00
this.configUtils = new configUtils(this.defaultConfig);
// initConfig deep-merges user config over schema defaults so every
// field is guaranteed present even if the caller omits it.
2025-10-07 18:05:54 +02:00
this.config = this.configUtils.initConfig(config);
2025-10-21 12:45:19 +02:00
this.interpolate = new interpolation();
this.logger = new logger(this.config.general.logging.enabled, this.config.general.logging.logLevel, this.config.general.name);
// --- Measurement store ---
// autoConvert: incoming values in any unit are stored in their
// original unit but getCurrentValue(targetUnit) converts on read.
// preferredUnits: the canonical units used for ALL internal math.
// Flow and netFlowRate MUST be m3/s because the volume integrator
// multiplies flow × seconds to get m3. Level in m and volume in m3
// keep the basin geometry math unit-consistent.
2025-11-28 09:59:16 +01:00
this.measurements = new MeasurementContainer({
autoConvert: true,
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3', overflowVolume: 'm3' }
2025-11-28 09:59:16 +01:00
});
// --- Child registries ---
// Children register via Port 2 handshake. Each dict is keyed by
// the child's config.general.id.
// machines : rotatingMachine instances (direct pumps, no MGC)
// stations : nested pumpingStation instances (cascaded basins)
// machineGroups : MGC instances (each manages its own pump pool)
2025-10-28 17:04:26 +01:00
this.childRegistrationUtils = new childRegistrationUtils(this);
2025-11-30 09:24:18 +01:00
this.machines = {};
this.stations = {};
this.machineGroups = {};
// predictedFlowChildren tracks predicted flow subscriptions per child.
// Key = childId, value = { in: <last m3/s>, out: <last m3/s> }.
// Only the highest-level aggregator is subscribed (MGC if present,
// otherwise individual machines) to avoid double-counting.
2025-11-30 09:24:18 +01:00
this.predictedFlowChildren = new Map();
2025-11-10 16:20:23 +01:00
// --- Variant priority ---
// Order determines which variant is used for CONTROL decisions:
// 'measured' is preferred; 'predicted' is the fallback.
//
// IMPORTANT — both variants are ALWAYS computed regardless of which
// one drives control. The output exposes both values plus a flag
// indicating which variant is currently driving control decisions.
// This lets operators see the difference between measured and
// predicted, which is valuable for:
// - Detecting sensor drift (measured diverges from predicted)
// - Validating the volume integrator (predicted tracks measured?)
// - Diagnosing control issues (was the wrong source active?)
//
// Implementation: _selectBestNetFlow computes both and stores both
// in MeasurementContainer; it returns the winning variant as the
// control source. getOutput() exposes all variants.
2025-10-28 17:04:26 +01:00
this.flowVariants = ['measured', 'predicted'];
this.levelVariants = ['measured', 'predicted'];
this.volVariants = ['measured', 'predicted'];
// Position aliases — two naming conventions coexist because:
// - Measurement children (sensors) store their raw
// positionVsParent from config: 'upstream' / 'downstream'
// - Predicted-flow children (MGC, machines) map positions to
// shorthand: 'in' / 'out' (see _registerPredictedFlowChild)
//
// The .sum() helper aggregates across an array of position names,
// so this map gives each logical direction ALL its aliases. This
// way sum('flow', 'predicted', flowPositions.outflow) catches both
// a measurement stored under 'downstream' AND a prediction stored
// under 'out'.
2025-10-28 17:04:26 +01:00
this.flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] };
2025-10-07 18:05:54 +02:00
// --- Runtime state ---
2025-11-30 09:24:18 +01:00
this.mode = this.config.control.mode;
// state is the public snapshot updated at the end of each tick().
// Consumers (nodeClass, dashboard) read this for display/telemetry.
2025-11-30 09:24:18 +01:00
this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null };
// percControl: the 0-100% demand sent to MGC / direct machines in
// levelbased mode. Exposed in getOutput() for dashboards.
this.percControl = 0;
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
// --- Level-armed hysteresis state (see _controlLevelBased) ---
// _shiftArmed: true once up-curve output % crosses shiftArmPercent on
// the way up. Cleared when level drops to startLevel.
// _shiftHoldValue: captured on every filling→draining transition while
// armed. The output stays at this value while level drops from the
// flip point to shiftLevel; below shiftLevel it ramps to 0 % at
// startLevel (linear or log shape).
// _lastDirection: tracks the previous tick's direction so we can
// detect filling→draining transitions. We don't update it on
// 'steady' ticks so transitions through the dead-band are preserved.
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>
2026-05-05 19:29:34 +02:00
this._shiftArmed = false;
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
this._shiftHoldValue = null;
this._lastDirection = null;
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>
2026-05-05 19:29:34 +02:00
// --- stopLevel hysteresis (Schmitt trigger) ---
// Levelbased control uses two thresholds:
// - startLevel: ramp foot AND rising-edge engage point. Demand
// scales 0..100 % over [startLevel, maxLevel].
// - stopLevel: falling-edge disengage point. Pumps stay engaged
// (running at minimum flow) while level drains through
// [stopLevel, startLevel]; below stopLevel they're turned off.
//
// _stopHystRunning is the engaged-state flag: flips TRUE when level
// crosses startLevel on the way up, FALSE when level crosses stopLevel
// on the way down. While engaged AND level < startLevel (i.e. the
// basin is draining through the dead band) the controller emits a
// small keep-alive percControl so MGC keeps a single pump running
// until level reaches stopLevel. Without this, percControl=0 in the
// dead band would let MGC turn the pump off, the basin would refill,
// and the pump would oscillate at startLevel instead of running for
// a full drain stroke.
//
// Editor preview also reads _stopHystRunning to shade the hysteresis
// band; runtime semantics are now explicit (no longer "bookkeeping").
this._stopHystRunning = 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
// treated as 'steady' (no filling, no draining).
2025-10-28 17:04:26 +01:00
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
// Geometry + threshold ordering check. initBasinProperties seeds
// predicted volume at minVol; _validateThresholdOrdering warns if
// any physical/control invariant is violated. Non-fatal — prefer
// continuity over refusal to start (availability-first).
2025-10-28 17:04:26 +01:00
this.initBasinProperties();
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
this.thresholdIssues = this._validateThresholdOrdering();
2025-11-30 09:24:18 +01:00
this.logger.debug('PumpingStation initialized');
2025-10-07 18:05:54 +02:00
}
2025-11-30 09:24:18 +01:00
/* --------------------------- Registration --------------------------- */
2025-10-07 18:05:54 +02:00
registerChild(child, softwareType) {
2025-10-28 17:04:26 +01:00
this.logger.debug(`Registering child (${softwareType}) "${child.config.general.name}"`);
if (softwareType === 'measurement') {
this._registerMeasurementChild(child);
return;
2025-10-07 18:05:54 +02:00
}
2025-10-21 13:44:31 +02:00
2025-11-30 09:24:18 +01:00
if (softwareType === 'machine') {
this.machines[child.config.general.id] = child;
} else if (softwareType === 'pumpingstation') {
this.stations[child.config.general.id] = child;
} else if (softwareType === 'machinegroup') {
this.machineGroups[child.config.general.id] = child;
}
// Register predicted-flow subscription. Only register the HIGHEST-
// level aggregator: if a machinegroup is present, subscribe to IT
// (its flow.predicted already aggregates all child machines). Do NOT
// also subscribe to individual machines — that would double-count
// because each pump's flow is included in the group total.
//
// Individual machines (softwareType='machine') are only subscribed
// when there is NO machinegroup parent — i.e., pumps wired directly
// to the pumping station without an MGC in between.
if (softwareType === 'machinegroup' || softwareType === 'pumpingstation') {
this._registerPredictedFlowChild(child);
} else if (softwareType === 'machine' && Object.keys(this.machineGroups).length === 0) {
// Direct-child machine, no group above it — register its flow.
2025-10-28 17:04:26 +01:00
this._registerPredictedFlowChild(child);
}
}
2025-10-23 09:51:54 +02:00
2025-11-30 09:24:18 +01:00
_registerMeasurementChild(child) {
const position = child.config.functionality.positionVsParent;
const measurementType = child.config.asset.type;
const eventName = `${measurementType}.measured.${position}`;
2025-11-30 09:24:18 +01:00
child.measurements.emitter.on(eventName, (eventData = {}) => {
this.logger.debug(
`Measurement update ${eventName} <- ${eventData.childName || child.config.general.name}: ${eventData.value} ${eventData.unit}`
);
2025-11-25 14:57:39 +01:00
2025-11-30 09:24:18 +01:00
this.measurements
.type(measurementType)
.variant('measured')
.position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
2025-11-10 16:20:23 +01:00
2025-11-30 09:24:18 +01:00
this._handleMeasurement(measurementType, eventData.value, position, eventData);
});
2025-11-27 17:46:24 +01:00
}
2025-11-30 09:24:18 +01:00
_registerPredictedFlowChild(child) {
const position = (child.config.functionality.positionVsParent || '').toLowerCase();
const childName = child.config.general.name;
const childId = child.config.general.id ?? childName;
2025-11-30 09:24:18 +01:00
let posKey;
let eventName;
2025-11-30 09:24:18 +01:00
switch (position) {
case 'downstream':
case 'out':
case 'atequipment':
posKey = 'out';
// Subscribe to ONE event only. 'downstream' is the most specific
// — avoids double-counting from 'atequipment' which carries the
// same total flow on a different event name.
eventName = 'flow.predicted.downstream';
2025-11-30 09:24:18 +01:00
break;
case 'upstream':
case 'in':
posKey = 'in';
eventName = 'flow.predicted.upstream';
2025-11-30 09:24:18 +01:00
break;
default:
this.logger.warn(`Unsupported predicted flow position "${position}" from ${childName}`);
return;
}
2025-11-30 09:24:18 +01:00
if (!this.predictedFlowChildren.has(childId)) {
this.predictedFlowChildren.set(childId, { in: 0, out: 0 });
2025-11-28 09:59:16 +01:00
}
2025-11-30 09:24:18 +01:00
const handler = (eventData = {}) => {
const unit = eventData.unit || child.config?.general?.unit;
const ts = eventData.timestamp || Date.now();
2025-11-30 09:24:18 +01:00
this.logger.debug(`Emitting for child ${unit} `);
this.measurements
.type('flow')
.variant('predicted')
.position(posKey)
.child(childId)
.value(eventData.value, ts, unit);
};
child.measurements.emitter.on(eventName, handler);
}
2025-11-30 09:24:18 +01:00
/* --------------------------- Calibration --------------------------- */
2025-11-30 09:24:18 +01:00
calibratePredictedVolume(calibratedVol, timestamp = Date.now()) {
2025-11-30 20:13:21 +01:00
const volume = this.measurements.type('volume').variant('predicted').position('atequipment').get();
const level = this.measurements.type('level').variant('predicted').position('atequipment').get();
2025-11-30 20:13:21 +01:00
if (volume) {
volume.values = [];
volume.timestamps = [];
}
2025-11-30 20:13:21 +01:00
if (level) {
level.values = [];
level.timestamps = [];
2025-11-30 09:24:18 +01:00
}
2025-11-28 16:29:05 +01:00
2025-11-30 20:13:21 +01:00
this.measurements.type('volume').variant('predicted').position('atequipment').value(calibratedVol, timestamp, 'm3').unit('m3');
this.measurements.type('level').variant('predicted').position('atequipment').value(this._calcLevelFromVolume(calibratedVol), timestamp, 'm');
2025-11-28 16:29:05 +01:00
2025-11-30 09:24:18 +01:00
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
2025-11-10 16:20:23 +01:00
}
2025-11-30 09:24:18 +01:00
calibratePredictedLevel(val, timestamp = Date.now(), unit = 'm') {
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
// Rebuild the chain each time — MeasurementContainer is stateful
// (its type/variant/position methods mutate the container itself,
// so cached chain references share one cursor).
const volMeas = this.measurements.type('volume').variant('predicted').position('atequipment');
if (volMeas.exists()) {
const m = volMeas.get();
m.values = []; m.timestamps = [];
2025-11-30 09:24:18 +01:00
}
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
const lvlMeas = this.measurements.type('level').variant('predicted').position('atequipment');
if (lvlMeas.exists()) {
const m = lvlMeas.get();
m.values = []; m.timestamps = [];
2025-11-10 16:20:23 +01:00
}
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
this.measurements.type('level').variant('predicted').position('atequipment').value(val, timestamp, unit);
this.measurements.type('volume').variant('predicted').position('atequipment').value(this._calcVolumeFromLevel(val), timestamp, 'm3');
2025-11-30 09:24:18 +01:00
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
2025-11-20 12:15:46 +01:00
}
2025-11-30 09:24:18 +01:00
setManualInflow(value, timestamp = Date.now(), unit) {
const num = Number(value);
this.measurements.type('flow').variant('predicted').position('in').child('manual-qin').value(num, timestamp, unit);
2025-11-27 17:46:24 +01:00
}
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>
2026-05-05 19:29:34 +02:00
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);
}
2025-11-30 09:24:18 +01:00
/* --------------------------- Tick / Control --------------------------- */
2025-10-28 17:04:26 +01:00
tick() {
2025-11-30 09:24:18 +01:00
this._updatePredictedVolume();
2025-10-23 09:51:54 +02:00
2025-11-30 09:24:18 +01:00
const netFlow = this._selectBestNetFlow();
2025-11-28 16:29:05 +01:00
const remaining = this._computeRemainingTime(netFlow);
2025-10-28 17:04:26 +01:00
2025-11-30 09:24:18 +01:00
this._safetyController(remaining.seconds, netFlow.direction);
if (this.safetyControllerActive) return;
2025-11-10 16:20:23 +01:00
2025-11-30 09:24:18 +01:00
this._controlLogic(netFlow.direction);
2025-10-28 17:04:26 +01:00
this.state = {
direction: netFlow.direction,
netFlow: netFlow.value,
flowSource: netFlow.source,
seconds: remaining.seconds,
remainingSource: remaining.source
};
this.logger.debug(`netflow = ${JSON.stringify(netFlow)}`);
2025-11-30 09:24:18 +01:00
this.logger.debug(
`Height : ${this.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m')} m`
);
2025-10-28 17:04:26 +01:00
}
2025-10-23 09:51:54 +02:00
changeMode(newMode){
if ( this.config.control.allowedModes.has(newMode) ){
const currentMode = this.mode;
this.logger.info(`Control mode changing from ${currentMode} to ${newMode}`);
this.mode = newMode;
}
else{
this.logger.warn(`Attempted to change to unsupported control mode: ${newMode}`);
}
}
2025-11-30 09:24:18 +01:00
_controlLogic(direction) {
switch (this.mode) {
case 'levelbased':
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>
2026-05-05 19:29:34 +02:00
this._controlLevelBased(direction);
break;
2025-11-30 09:24:18 +01:00
case 'flowbased':
this._controlFlowBased?.();
break;
case 'manual':
break;
default:
2025-11-30 09:24:18 +01:00
this.logger.warn(`Unsupported control mode: ${this.mode}`);
2025-10-28 17:04:26 +01:00
}
2025-11-30 09:24:18 +01:00
}
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
async _controlLevelBased(direction) {
const cfg = this.config.control.levelbased;
const { startLevel, minLevel } = cfg;
2025-11-30 09:24:18 +01:00
const levelUnit = this.measurements.getUnit('level');
2025-11-30 09:24:18 +01:00
const level = this._pickVariant('level', this.levelVariants, 'atequipment', levelUnit);
if (level == null) {
this.logger.warn('No valid level found');
return;
}
2025-11-28 09:59:16 +01:00
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
// Level-based pump control via MGC. See wiki/modes/levelbased.md.
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>
2026-05-05 19:29:34 +02:00
//
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
// Always:
// level < minLevel → STOP (unconditional MGC shutdown)
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
// level < inflowLevel → 0 % (HOLD zone, pumps idle)
// level in [inflow..max] → up curve 0..100 % (linear or log)
// level > maxLevel → 100 % (MGC clamps internally)
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>
2026-05-05 19:29:34 +02:00
//
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
// With enableShiftedRamp (hysteresis):
// When up-curve % rises past shiftArmPercent → ARMED.
// On the next filling→draining transition while armed → capture
// hold = current up-curve %.
// While armed AND draining:
// level >= shiftLevel → output = hold (held)
// level in [start..shift] → output ramps hold→0 % over the range
// level < startLevel → output = 0 %
// While armed AND filling/steady → output = up curve (resets hold).
// Disarms only when level <= startLevel.
2025-11-28 09:59:16 +01:00
if (level < minLevel) {
this.percControl = 0;
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
this._shiftHoldValue = null;
this._shiftArmed = false;
this._stopHystRunning = false;
this._lastDirection = direction;
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return;
}
// stopLevel hysteresis (Schmitt trigger).
// _stopHystRunning becomes TRUE on rising edge at startLevel
// FALSE on falling edge at stopLevel
// While engaged AND level < startLevel (basin draining through the
// dead band), the controller emits a small keep-alive percControl so
// a single pump keeps running until level reaches stopLevel. Without
// hysteresis the pump would oscillate at startLevel because the
// up-curve goes through 0 there.
const stopLvl = Number(cfg.stopLevel);
const stopThresholdActive = Number.isFinite(stopLvl) && stopLvl >= 0 && stopLvl < cfg.maxLevel;
if (stopThresholdActive && level <= stopLvl) {
// Hard off: drained past stopLevel.
this.percControl = 0;
this._stopHystRunning = false;
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
this._lastDirection = direction;
2025-11-30 09:24:18 +01:00
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
return;
}
// Update Schmitt-trigger engaged state.
if (stopThresholdActive) {
if (!this._stopHystRunning && level >= startLevel) this._stopHystRunning = true;
// disengage on falling edge is handled by the `level <= stopLvl` block above.
} else {
// No stopLevel configured → no hysteresis; engaged only while level >= startLevel.
this._stopHystRunning = level >= startLevel;
}
// Up-curve value. Foot stays at startLevel (per the user-set demand
// ramp), top is maxLevel. Below startLevel the curve gives 0 %; above
// maxLevel it saturates at 100 %.
const rampFoot = startLevel;
const upPct = this._scaleLevelToFlowPercent(level, rampFoot, cfg.maxLevel);
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>
2026-05-05 19:29:34 +02:00
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
// Update arming flag.
if (cfg.enableShiftedRamp) {
const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
if (!this._shiftArmed && upPct >= armPct) {
this._shiftArmed = true;
this.logger.debug(`Shift armed: upPct=${upPct} >= ${armPct}`);
}
} else {
this._shiftArmed = false;
}
if (level <= startLevel) {
this._shiftArmed = false;
this._shiftHoldValue = null;
}
// Capture hold on filling→draining transition while armed.
if (cfg.enableShiftedRamp && this._shiftArmed) {
if (this._lastDirection !== 'draining' && direction === 'draining') {
this._shiftHoldValue = upPct;
this.logger.debug(`Shift hold captured: ${upPct} % at level=${level}`);
} else if (direction === 'filling') {
// Returning to filling clears any captured hold; the next drain
// transition will recapture from the up curve.
this._shiftHoldValue = null;
}
}
if (direction === 'filling' || direction === 'draining') {
this._lastDirection = direction;
}
// Compute output.
let percControl;
const inDrainingHold = cfg.enableShiftedRamp && this._shiftArmed
&& direction === 'draining' && this._shiftHoldValue != null;
if (!inDrainingHold) {
// Up curve: 0 % below the ramp foot (startLevel), scaled
// startLevel..maxLevel → 0..100 %, saturates above maxLevel.
// While engaged via the stopLevel Schmitt trigger AND level is
// inside the dead band [stopLevel, startLevel], emit a small
// keep-alive value so MGC's normalized scaling resolves to flow.min
// (a single pump at minimum stable speed) and the basin actually
// drains. Configurable via levelbased.deadZoneKeepAlivePercent
// (default 1%). Ramp foot stays at startLevel — keep-alive is a
// separate "engaged in dead band" signal, not a shifted ramp.
if (level < rampFoot) {
if (stopThresholdActive && this._stopHystRunning) {
const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
? Number(cfg.deadZoneKeepAlivePercent) : 1;
percControl = Math.max(0, keepAlive);
} else {
percControl = 0;
}
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
} else {
percControl = Math.max(0, upPct);
}
} else {
const hold = this._shiftHoldValue;
const shift = cfg.shiftLevel;
if (!Number.isFinite(shift) || shift <= startLevel) {
// Bad config — fall back to up curve.
percControl = Math.max(0, upPct);
} else if (level >= shift) {
percControl = hold;
} else if (level > startLevel) {
// Ramp from (shiftLevel, hold) down to (startLevel, 0).
// Use the same curve shape (linear/log) as the up curve, scaled to
// peak at hold% at level=shiftLevel.
const x = (level - startLevel) / (shift - startLevel);
const shaped = this._curveShape(x);
percControl = Math.max(0, hold * shaped);
} else {
percControl = 0;
}
2025-11-30 09:24:18 +01:00
}
this.percControl = percControl;
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
this.logger.debug(
`Level-based: level=${level} dir=${direction} armed=${this._shiftArmed} hold=${this._shiftHoldValue} pct=${percControl}`
);
await this._applyMachineGroupLevelControl(percControl);
2025-11-30 09:24:18 +01:00
}
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
// Apply the configured curve shape to a normalized x in [0,1].
// Returns shaped value in [0,1]. Linear by default; log when curveType
// is 'log' (with logCurveFactor).
_curveShape(x) {
const { curveType = 'linear', logCurveFactor = 9 } = this.config.control.levelbased;
const clamped = Math.max(0, Math.min(1, x));
if (curveType === 'log') {
const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
? Number(logCurveFactor) : 9;
return Math.log1p(factor * clamped) / Math.log1p(factor);
}
return clamped;
}
2025-11-30 09:24:18 +01:00
_controlFlowBased() {
// placeholder for flow-based logic
}
/**
* Forward a manual demand value to all child machine groups + direct
* machines. Called from the 'Qd' topic handler when PS is in manual
* mode mirrors how rotatingMachine gates commands by mode.
* @param {number} demand - the operator-set demand (interpretation
* depends on MGC scaling: 'absolute' = /h, 'normalized' = 0-100%)
*/
async forwardDemandToChildren(demand) {
this.logger.info(`Manual demand forwarded: ${demand}`);
// Manual-mode explicit stop: MGC's handleInput now treats demand=0 as
// "hold current pump states" so the levelbased stopLevel hysteresis
// works. In manual mode the operator setting Qd=0 should still mean
// "stop now", so we issue an explicit turnOff and short-circuit.
if (Number(demand) <= 0) {
if (this.machineGroups && Object.keys(this.machineGroups).length > 0) {
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
}
return;
}
// Forward to machine groups (MGC)
if (this.machineGroups && Object.keys(this.machineGroups).length > 0) {
await Promise.all(
Object.values(this.machineGroups).map((group) =>
group.handleInput('parent', demand).catch((err) => {
this.logger.error(`Failed to forward demand to group: ${err.message}`);
})
)
);
}
// Forward to direct machines (if any)
if (this.machines && Object.keys(this.machines).length > 0) {
const perMachine = demand / Object.keys(this.machines).length;
for (const machine of Object.values(this.machines)) {
try {
await machine.handleInput('parent', 'execMovement', perMachine);
} catch (err) {
this.logger.error(`Failed to forward demand to machine: ${err.message}`);
}
}
}
}
2025-11-30 09:24:18 +01:00
async _applyMachineGroupLevelControl(percentControl) {
if (!this.machineGroups || Object.keys(this.machineGroups).length === 0) return;
await Promise.all(
Object.values(this.machineGroups).map((group) =>
group.handleInput('parent', percentControl).catch((err) => {
this.logger.error(`Failed to send level control to group "${group.config.general.name}": ${err.message}`);
})
)
);
}
2025-11-27 17:46:24 +01:00
2025-11-30 09:24:18 +01:00
async _applyMachineLevelControl(percentControl) {
const machines = Object.values(this.machines).filter((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
return (pos === 'downstream' || pos === 'atequipment');
});
2025-11-27 17:46:24 +01:00
2025-11-30 09:24:18 +01:00
if (!machines.length) return;
2025-11-27 17:46:24 +01:00
2025-11-30 09:24:18 +01:00
const perMachine = percentControl / machines.length;
for (const machine of machines) {
try {
await machine.handleInput('parent', 'execSequence', 'startup');
await machine.handleInput('parent', 'execMovement', perMachine);
} catch (err) {
this.logger.error(`Failed to start machine "${machine.config.general.name}": ${err.message}`);
}
}
2025-10-28 17:04:26 +01:00
}
2025-11-30 09:24:18 +01:00
/* --------------------------- Measurements --------------------------- */
2025-10-28 17:04:26 +01:00
_handleMeasurement(measurementType, value, position, context) {
switch (measurementType) {
case 'level':
this._onLevelMeasurement(position, value, context);
break;
2025-10-28 17:04:26 +01:00
case 'pressure':
this._onPressureMeasurement(position, value, context);
break;
default:
2025-10-28 17:04:26 +01:00
break;
}
}
2025-10-23 09:51:54 +02:00
2025-10-28 17:04:26 +01:00
_onLevelMeasurement(position, value, context = {}) {
2025-11-25 14:57:39 +01:00
this.measurements.type('level').variant('measured').position(position).value(value).unit(context.unit);
2025-10-28 17:04:26 +01:00
const levelSeries = this.measurements.type('level').variant('measured').position(position);
const levelMeters = levelSeries.getCurrentValue('m');
if (levelMeters == null) return;
const volume = this._calcVolumeFromLevel(levelMeters);
const percent = this.interpolate.interpolate_lin_single_point(
volume,
this.basin.minVol,
this.basin.maxVolAtOverflow,
2025-10-28 17:04:26 +01:00
0,
100
);
2025-10-23 09:51:54 +02:00
2025-11-30 09:24:18 +01:00
this.measurements.type('volume').variant('measured').position('atequipment').value(volume, context.timestamp, 'm3');
2025-10-28 17:04:26 +01:00
this.measurements
2025-11-03 07:42:51 +01:00
.type('volumePercent')
.variant('measured')
2025-10-28 17:04:26 +01:00
.position('atequipment')
.value(percent, context.timestamp, '%');
2025-10-23 09:51:54 +02:00
}
2025-10-28 17:04:26 +01:00
_onPressureMeasurement(position, value, context = {}) {
let kelvinTemp =
2025-11-30 09:24:18 +01:00
this.measurements.type('temperature').variant('measured').position('atequipment').getCurrentValue('K') ?? null;
2025-10-28 17:04:26 +01:00
if (kelvinTemp === null) {
this.logger.warn('No temperature measurement; assuming 15C for pressure to level conversion.');
2025-11-30 09:24:18 +01:00
this.measurements.type('temperature').variant('assumed').position('atequipment').value(15, Date.now(), 'C');
kelvinTemp = this.measurements.type('temperature').variant('assumed').position('atequipment').getCurrentValue('K');
2025-10-28 17:04:26 +01:00
}
2025-10-23 09:51:54 +02:00
2025-10-28 17:04:26 +01:00
if (kelvinTemp == null) return;
2025-10-23 09:51:54 +02:00
2025-10-28 17:04:26 +01:00
const density = coolprop.PropsSI('D', 'T', kelvinTemp, 'P', 101325, 'Water');
2025-11-30 09:24:18 +01:00
const pressurePa = this.measurements.type('pressure').variant('measured').position(position).getCurrentValue('Pa');
2025-10-28 17:04:26 +01:00
if (!Number.isFinite(pressurePa) || !Number.isFinite(density)) return;
2025-10-21 13:44:31 +02:00
2025-10-28 17:04:26 +01:00
const g = 9.80665;
const level = pressurePa / (density * g);
this.measurements.type('level').variant('predicted').position(position).value(level, context.timestamp, 'm');
2025-10-21 13:44:31 +02:00
}
2025-11-30 09:24:18 +01:00
/* --------------------------- Core Calculations --------------------------- */
2025-10-28 17:04:26 +01:00
2025-11-30 09:24:18 +01:00
_pickVariant(type, variants, position, unit) {
for (const variant of variants) {
const val = this.measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
if (!Number.isFinite(val)) continue;
return val;
2025-10-28 17:04:26 +01:00
}
2025-11-30 09:24:18 +01:00
return null;
2025-10-07 18:05:54 +02:00
}
Hold-then-ramp shift semantics + shiftArmPercent + e2e tests Runtime (specificClass.js): - Replace the "shift left both ramp ends" geometry with a true hold-then-ramp hysteresis driven by output %, not level: • Up-curve % crosses shiftArmPercent on the way up → ARM. • Filling→draining transition while armed → capture the up-curve % at that moment as _shiftHoldValue. • Draining + level ≥ shiftLevel → output stays at _shiftHoldValue (horizontal hold, matching the dashed segment in the SVG). • Draining + level in [start, shift] → output ramps holdValue → 0 % along the same curve shape (linear or log) as the up curve. • Draining + level < startLevel → 0 % AND disarm. • Returning to filling clears holdValue, stays armed; next drain transition captures a fresh hold so bouncing fills rearm cleanly. • Disarm only when level ≤ startLevel. - New _curveShape(x) helper for shared linear/log shaping. - Removed legacy _levelBasedRampStart / _levelBasedRampTop / _updateShiftArmed in favour of the inline state machine. Adapter (nodeClass.js): - Pipe shiftArmPercent through to control.levelbased. Editor (pumpingStation.html + src/editor/): - Add shiftArmPercent input row (% with unit) to the mode side panel (only shown when shifted ramp is enabled). Default 95 %. - Add the horizontal arming-% line + label inside the mode SVG — this is the "% Threshold triggering shifted ramp down" line from the original drawing that had been missing. - Redraw the shifted-down curve to match the SVG geometry literally: 100 % flat from maxLevel → shiftLevel, then ramp shiftLevel → startLevel down to 0 %, OFF below startLevel. Preview shows the worst-case envelope (hold = 100 %); runtime hold is captured live. - Validation extended: 0 < shiftArmPercent ≤ 100; ordering rules preserved (start < shift ≤ max etc.). - Auto-default shiftArmPercent to 95 when shift is enabled and the current value is missing or out of range. Dashboard example (examples/basic-dashboard.flow.json): - Parser now reads `level.predicted.atequipment.default` etc. The MeasurementContainer flatten format includes the implicit 'default' childId; consumers must include it. Comment in the parser points at the documenting source in generalFunctions. Tests: - test/basic: replace old level-armed-shift tests with two new ones that exercise the hold-then-ramp arming, capture, hold, ramp-down, disarm, and the bounce case (filling→draining→filling→draining captures a fresh hold each time). - test/integration/shifted-ramp-end-to-end.test.js: new file. Drives Q_IN/Q_OUT through the full runtime tick with a controllable clock, asserting the same hysteresis path the dashboard exercises. - test/integration/basic-dashboard-flow.test.js: fixture keys updated to the .default-suffixed form so they match the real flatten output. 56/56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:46 +02:00
// (legacy _levelBasedRampStart/_levelBasedRampTop/_updateShiftArmed
// helpers were removed in favour of the inline state machine in
// _controlLevelBased — see that method's doc block.)
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>
2026-05-05 19:29:34 +02:00
_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;
}
2025-11-30 09:24:18 +01:00
_levelRate(variant) {
const chain = this.measurements.type('level').variant(variant).position('atequipment');
if (!chain.exists({ requireValues: true })) return null;
const m = chain.get();
const current = m?.getLaggedSample?.(0);
const previous = m?.getLaggedSample?.(1);
if (!current || !previous || previous.timestamp == null) return null;
const dt = (current.timestamp - previous.timestamp) / 1000;
if (!Number.isFinite(dt) || dt <= 0) return null;
return (current.value - previous.value) / dt;
2025-10-28 17:04:26 +01:00
}
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
_updatePredictedVolume() {
const flowUnit = 'm3/s'; // this has to be in m3/s for the actions below
const now = Date.now();
2025-10-27 16:39:06 +01:00
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
// The 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 (which only sees pumps + downstream measurements), so no
// self-subtraction is needed. _selectBestNetFlow folds it back in for
// net-flow balance while pinned at overflow.
2025-11-30 09:24:18 +01:00
const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0;
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
const outflowReal = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0;
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
if (!this._predictedFlowState) {
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: now };
2025-10-28 17:04:26 +01:00
}
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
const timestampPrev = this._predictedFlowState.lastTimestamp ?? now;
const deltaSeconds = Math.max((now - timestampPrev) / 1000, 0);
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
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');
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
const writeTimestamp = timestampPrev + deltaSeconds * 1000;
2025-10-27 16:39:06 +01:00
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
// Predicted-volume bounds.
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
// Upper (hard physical): maxVolAtOverflow — past this the basin spills
// over the weir; predicted level pins at overflowLevel and the
// excess is tracked as overflow volume + spill flow.
// Lower (operational): dryRunSafetyVol — where pumps must stop. Only
// clamps on transition from above; a basin seeded below (e.g.
// startup-from-empty) is left alone so it can fill from 0.
// Lower (hard physical): 0 — basin cannot hold negative water. Always
// clamps. Without this, a seeded-low basin under continued
// net-outflow integrates volume arbitrarily negative (the level
// output looks fine because _calcLevelFromVolume floors at 0,
// masking the underlying drift).
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
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;
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
let underflowIncrement = 0;
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
if (proposedVolume > upperClamp) {
overflowIncrement = proposedVolume - upperClamp;
nextVolume = upperClamp;
} else if (proposedVolume < lowerClamp && currentVolume >= lowerClamp) {
nextVolume = lowerClamp;
}
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
if (nextVolume < 0) {
underflowIncrement = -nextVolume;
nextVolume = 0;
}
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
// Synthetic spill flow at position 'overflow'.
// While pinned at upper bound with continuing net-positive inflow, the
// weir is carrying away (inflow outflowReal). _selectBestNetFlow folds
// this into the outflow side so the predicted net-flow balance reads ~0
// (matches the level-pinned reality).
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
let spillRate = 0;
if (nextVolume >= upperClamp - 1e-9 && (inflow - outflowReal) > this.flowThreshold) {
spillRate = inflow - outflowReal;
}
this.measurements
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
.type('flow').variant('predicted').position('overflow')
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
.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');
}
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
// Cumulative integrator underflow — diagnostic, NOT compliance.
// A nonzero value means the predicted-volume integrator tried to go
// below the physical floor (negative water). Root causes are usually
// upstream: outflow over-reported (sensor drift, pump curve too
// optimistic) or an inflow source missing from the measurement set.
if (underflowIncrement > 0) {
const prevUnderflow = this.measurements
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
this.measurements
.type('underflowVolume').variant('predicted').position('atequipment')
.value(prevUnderflow + underflowIncrement, writeTimestamp, 'm3').unit('m3');
}
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
this.measurements
.type('volume').variant('predicted').position('atequipment')
.value(nextVolume, writeTimestamp, 'm3').unit('m3');
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
const nextLevel = this._calcLevelFromVolume(nextVolume);
this.measurements
.type('level')
.variant('predicted')
.position('atequipment')
.value(nextLevel, writeTimestamp, 'm')
.unit('m');
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
const percent = this.interpolate.interpolate_lin_single_point(
nextVolume,
this.basin.minVol,
this.basin.maxVolAtOverflow,
2025-11-30 09:24:18 +01:00
0,
100
);
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
this.measurements
.type('volumePercent')
.variant('predicted')
.position('atequipment')
.value(percent, writeTimestamp, '%');
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
this._predictedFlowState = { inflow, outflow: outflowReal, lastTimestamp: writeTimestamp };
2025-10-23 09:51:54 +02:00
}
2025-10-21 13:44:31 +02:00
2025-11-30 09:24:18 +01:00
_selectBestNetFlow() {
2025-11-28 16:29:05 +01:00
const type = 'flow';
const unit = this.measurements.getUnit(type) || 'm3/s';
2025-10-28 17:04:26 +01:00
for (const variant of this.flowVariants) {
2025-11-28 16:29:05 +01:00
const bucket = this.measurements.measurements?.[type]?.[variant];
2025-11-30 09:24:18 +01:00
if (!bucket || Object.keys(bucket).length === 0) continue;
2025-11-28 16:29:05 +01:00
2025-11-30 09:24:18 +01:00
const inflow = this.measurements.sum(type, variant, this.flowPositions.inflow, unit) || 0;
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
const outflowReal = this.measurements.sum(type, variant, this.flowPositions.outflow, unit) || 0;
// Fold synthetic spill (position 'overflow') into the outflow side.
// It only exists for the predicted variant and only while pinned, so
// for measured this is 0.
const spill = this.measurements.sum(type, variant, ['overflow'], unit) || 0;
const outflow = outflowReal + spill;
2025-11-30 09:24:18 +01:00
if (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue;
2025-11-03 09:17:22 +01:00
2025-11-30 09:24:18 +01:00
const net = inflow - outflow;
this.measurements.type('netFlowRate').variant(variant).position('atequipment').value(net, Date.now(), unit);
return { value: net, source: variant, direction: this._deriveDirection(net) };
2025-10-28 17:04:26 +01:00
}
2025-10-07 18:05:54 +02:00
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
// 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.
2025-10-28 17:04:26 +01:00
for (const variant of this.levelVariants) {
2025-11-30 09:24:18 +01:00
const rate = this._levelRate(variant);
if (!Number.isFinite(rate)) continue;
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
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;
}
2025-11-30 09:24:18 +01:00
return { value: netFlow, source: `level:${variant}`, direction: this._deriveDirection(netFlow) };
2025-10-28 17:04:26 +01:00
}
2025-10-28 17:04:26 +01:00
this.logger.warn('No usable measurements to compute net flow; assuming steady.');
return { value: 0, source: null, direction: 'steady' };
}
2025-11-28 16:29:05 +01:00
_computeRemainingTime(netFlow) {
2025-11-30 09:24:18 +01:00
if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) return { seconds: null, source: null };
const { overflowLevel, outflowLevel, surfaceArea } = this.basin;
2025-11-30 09:24:18 +01:00
if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) return { seconds: null, source: null };
2025-10-28 17:04:26 +01:00
for (const variant of this.levelVariants) {
2025-11-30 09:24:18 +01:00
const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m');
2025-11-28 16:29:05 +01:00
if (!Number.isFinite(lvl)) continue;
const remainingHeight = netFlow.value > 0 ? Math.max(overflowLevel - lvl, 0) : Math.max(lvl - outflowLevel, 0);
2025-10-28 17:04:26 +01:00
const seconds = (remainingHeight * surfaceArea) / Math.abs(netFlow.value);
if (!Number.isFinite(seconds)) continue;
2025-10-28 17:04:26 +01:00
return { seconds, source: `${netFlow.source}/${variant}` };
}
2025-10-28 17:04:26 +01:00
return { seconds: null, source: netFlow.source };
2025-10-27 16:39:06 +01:00
}
2025-11-30 09:24:18 +01:00
_deriveDirection(netFlow) {
if (netFlow > this.flowThreshold) return 'filling';
if (netFlow < -this.flowThreshold) return 'draining';
return 'steady';
}
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
/* --------------------------- Safety --------------------------- */
2025-10-27 16:39:06 +01:00
/**
* Safety controller two hard rules:
*
* 1. BELOW minLevel (dry-run): pumps CANNOT start.
* Shuts down all downstream machines + machine groups.
* Only a manual override or emergency can restart them.
* safetyControllerActive = true blocks _controlLogic.
*
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>
2026-05-05 19:29:34 +02:00
* 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
* level-based control keeps running so pumps stay at the demand
* 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
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>
2026-05-05 19:29:34 +02:00
* a high-volume or overflowing event.
*/
2025-11-30 09:24:18 +01:00
_safetyController(remainingTime, direction) {
this.safetyControllerActive = false;
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
const volUnit = this.measurements.getUnit('volume');
const vol = this._pickVariant('volume', this.volVariants, 'atequipment', volUnit);
if (vol == null) {
Object.values(this.machines).forEach((machine) => machine.handleInput('parent', 'execSequence', 'shutdown'));
this.logger.warn('No volume data available to safe guard system; shutting down all machines.');
this.safetyControllerActive = true;
return;
}
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
const {
enableDryRunProtection,
dryRunThresholdPercent,
enableOverfillProtection,
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>
2026-05-05 19:29:34 +02:00
enableHighVolumeSafety,
2025-11-30 09:24:18 +01:00
timeleftToFullOrEmptyThresholdSeconds
} = this.config.safety || {};
2025-10-27 16:39:06 +01:00
2025-11-30 09:24:18 +01:00
const dryRunEnabled = Boolean(enableDryRunProtection);
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>
2026-05-05 19:29:34 +02:00
const highVolumeSafetyEnabled = Boolean(enableHighVolumeSafety ?? enableOverfillProtection);
2025-11-30 09:24:18 +01:00
const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0;
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>
2026-05-05 19:29:34 +02:00
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
};
2025-10-27 16:39:06 +01:00
// Rule 1: DRY-RUN — below minLevel, pumps cannot run.
2025-11-30 09:24:18 +01:00
if (direction === 'draining') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
const dryRunTriggered = dryRunEnabled && vol < triggerLowVol;
if (timeTriggered || dryRunTriggered) {
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>
2026-05-05 19:29:34 +02:00
this.safetyState.dryRunActive = true;
// Shut down all downstream equipment — pumps must stop.
2025-11-30 09:24:18 +01:00
Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
if ((pos === 'downstream' || pos === 'atequipment') && machine._isOperationalState()) {
machine.handleInput('parent', 'execSequence', 'shutdown');
}
});
Object.values(this.stations).forEach((station) => station.handleInput('parent', 'execSequence', 'shutdown'));
Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines());
this.logger.warn(
`Dry-run safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down downstream equipment`
2025-11-30 09:24:18 +01:00
);
// Block _controlLogic so level-based control can't restart pumps.
2025-11-30 09:24:18 +01:00
this.safetyControllerActive = true;
}
2025-10-28 17:04:26 +01:00
}
// Rule 2: OVERFILL — above overflow level, pumps cannot stop.
// Only shut down UPSTREAM equipment. Downstream pumps + machine
// groups keep running at whatever the level control demands
// (which will be >100% near overflow = all pumps at max).
// Do NOT set safetyControllerActive — _controlLogic must keep
// running to maintain pump demand.
2025-11-30 09:24:18 +01:00
if (direction === 'filling') {
const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds;
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>
2026-05-05 19:29:34 +02:00
const highVolumeTriggered = highVolumeSafetyEnabled && vol > triggerHighVol;
if (timeTriggered || highVolumeTriggered) {
this.safetyState.highVolumeActive = true;
// Shut down UPSTREAM only — stop more water coming in.
2025-11-30 09:24:18 +01:00
Object.values(this.machines).forEach((machine) => {
const pos = machine?.config?.functionality?.positionVsParent;
if (pos === 'upstream' && machine._isOperationalState()) {
machine.handleInput('parent', 'execSequence', 'shutdown');
}
});
Object.values(this.stations).forEach((station) => station.handleInput('parent', 'execSequence', 'shutdown'));
// NOTE: machine groups (downstream pumps) are NOT shut down.
// They must keep draining to prevent overflow from worsening.
2025-11-30 09:24:18 +01:00
this.logger.warn(
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>
2026-05-05 19:29:34 +02:00
`High-volume safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running`
2025-11-30 09:24:18 +01:00
);
// NOTE: safetyControllerActive is NOT set — level control
// keeps commanding pumps at maximum demand.
2025-11-30 09:24:18 +01:00
}
2025-10-28 17:04:26 +01:00
}
}
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>
2026-05-05 19:29:34 +02:00
_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
};
}
2025-11-30 09:24:18 +01:00
/* --------------------------- Basin --------------------------- */
2025-10-28 17:04:26 +01:00
/**
* Compute basin geometry from config and seed the initial predicted
* volume at the operational floor.
*
* Basin is modelled as a rectangular prism (constant cross-section),
* so `volume = level × surfaceArea`. See the wiki's basin-model
* diagram for the full threshold layout and naming conventions:
* wiki/functional-description.md#basin-model
*
* `minHeightBasedOn` ('inlet' | 'outlet') selects which pipe height
* defines `minVol` the 0 % point of fill-percent and the default
* dry-run reference.
*/
2025-10-07 18:05:54 +02:00
initBasinProperties() {
2025-11-20 12:15:46 +01:00
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
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>
2026-05-05 19:29:34 +02:00
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
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>
2026-05-05 19:29:34 +02:00
const inletPipeDiameter = this.config.basin.inletPipeDiameter;
const outletPipeDiameter = this.config.basin.outletPipeDiameter;
2025-11-30 09:24:18 +01:00
// Constant cross-section assumption: volume = level × area
2025-11-30 09:24:18 +01:00
const surfaceArea = volEmptyBasin / heightBasin;
// Volume at each critical height
const maxVol = heightBasin * surfaceArea; // ≡ volEmptyBasin (see note above)
const maxVolAtOverflow = overflowLevel * surfaceArea; // spill threshold
const minVolAtOutflow = outflowLevel * surfaceArea; // dry-run threshold
const minVolAtInflow = inflowLevel * surfaceArea; // gravity-feed threshold
// Operational floor: which pipe defines "basin too low"
const minVol = minHeightBasedOn === 'inlet' ? minVolAtInflow : minVolAtOutflow;
2025-10-28 17:04:26 +01:00
this.basin = {
volEmptyBasin,
heightBasin,
inflowLevel,
outflowLevel,
overflowLevel,
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>
2026-05-05 19:29:34 +02:00
inletPipeDiameter,
outletPipeDiameter,
2025-10-28 17:04:26 +01:00
surfaceArea,
maxVol,
maxVolAtOverflow,
minVolAtInflow,
minVolAtOutflow,
2025-10-28 17:04:26 +01:00
minVol,
2025-11-20 12:15:46 +01:00
minHeightBasedOn
2025-10-28 17:04:26 +01:00
};
// Seed predicted volume at operational floor — the station assumes
// the basin is at minimum until calibrated by a real measurement.
2025-11-30 09:24:18 +01:00
this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3');
2025-10-07 18:05:54 +02:00
}
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
/**
* Validate basin + control threshold ordering.
*
* Every pair is a strict physical or control invariant. Violations
* don't throw they log a warning and return the list so callers
* (tests, node-status, the eval harness) can surface them. Returning
* [] means "all invariants hold".
*
* Strict invariants (bottom top):
* 0 < outflowLevel < inflowLevel < overflowLevel basinHeight
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>
2026-05-05 19:29:34 +02:00
* dryRunLevel minLevel startLevel inflowLevel < maxLevel highVolumeSafetyLevel < overflowLevel
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
*
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>
2026-05-05 19:29:34 +02:00
* dryRunLevel and highVolumeSafetyLevel are DERIVED computed
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
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>
2026-05-05 19:29:34 +02:00
* highVolumeSafetyThresholdPercent/100 in the safety layer. Validating those
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
* 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 || {};
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>
2026-05-05 19:29:34 +02:00
const safetyPoints = this._computeSafetyPoints();
const dryRunLevel = safetyPoints.dryRunLevel;
const highVolumeSafetyLevel = safetyPoints.highVolumeSafetyLevel;
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
const checks = [
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
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>
2026-05-05 19:29:34 +02:00
['startLevel', lvl.startLevel, '<=', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'highVolumeSafetyLevel', highVolumeSafetyLevel],
['highVolumeSafetyLevel', highVolumeSafetyLevel, '<', 'overflowLevel', basin.overflowLevel],
Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests ### Guardrails (specificClass.js) New _validateThresholdOrdering() runs in the constructor. Checks every ordered pair of basin + control + derived-safety levels and logs a warning for each violation; returns the list as this.thresholdIssues so tests and the eval harness can inspect. Non-fatal — we prefer a running-but-warned station to a refusal-to-start (availability-first). Strict invariants (bottom → top): 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel Uses a list-of-checks pattern rather than a switch — easier to add new invariants without reflowing cases, and the list itself is readable documentation. ### Bug fix (specificClass.js) calibratePredictedLevel was writing the volume value into the LEVEL slot. Root cause: MeasurementContainer is stateful — its type()/ variant()/position() calls mutate the container's own cursor, so caching chain references (const levelChain = ...; const volumeChain = ...) doesn't isolate them. The second cached chain ended up sharing the state of the last type() call. Rebuilt chains fresh each time, matching the calibratePredictedVolume pattern that already worked. ### Tests (test/basic/specificClass.test.js) Ported from Jest to node:test + node:assert — the project's standard per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js (tests referenced methods that no longer exist post-rename). New coverage, 42 passing subtests: - Basin geometry derivations + minHeightBasedOn - Level/volume roundtrip - Threshold guardrails (5 violation cases) - Direction derivation - Mode change accept/reject - Calibration (volume and level paths — catches the bug above) - Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate) - getOutput flattening - setManualInflow Run with: node --test test/basic/*.test.js Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:38:41 +02:00
];
const issues = [];
for (const [aName, a, op, bName, b] of checks) {
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
const ok = op === '<' ? a < b : a <= b;
if (!ok) {
const msg = `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`;
issues.push({ aName, a, op, bName, b, msg });
this.logger.warn(msg);
}
}
return issues;
}
/** Convert level (m from floor) → volume (m3). Clamps to 0. */
2025-10-28 17:04:26 +01:00
_calcVolumeFromLevel(level) {
return Math.max(level, 0) * this.basin.surfaceArea;
}
2025-10-14 16:32:44 +02:00
/** Convert volume (m3) → level (m from floor). Clamps to 0. */
2025-10-28 17:04:26 +01:00
_calcLevelFromVolume(volume) {
return Math.max(volume, 0) / this.basin.surfaceArea;
}
2025-10-23 09:51:54 +02:00
2025-11-30 09:24:18 +01:00
/* --------------------------- Output --------------------------- */
2025-10-07 18:05:54 +02:00
2025-10-28 17:04:26 +01:00
getOutput() {
2025-11-28 09:59:16 +01:00
const output = this.measurements.getFlattenedOutput();
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>
2026-05-05 19:29:34 +02:00
const safety = this._computeSafetyPoints();
2025-11-06 11:19:20 +01:00
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;
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>
2026-05-05 19:29:34 +02:00
output.outflowLevel = this.basin.outflowLevel;
output.overflowLevel = this.basin.overflowLevel;
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>
2026-05-05 19:29:34 +02:00
output.inletPipeDiameter = this.basin.inletPipeDiameter;
output.outletPipeDiameter = this.basin.outletPipeDiameter;
2025-11-06 11:19:20 +01:00
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;
2025-11-20 12:15:46 +01:00
output.minHeightBasedOn = this.basin.minHeightBasedOn;
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>
2026-05-05 19:29:34 +02:00
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;
Predicted-volume overflow clamp + spill tracking Predicted volume is now clamped to [dryRunSafetyVol, maxVolAtOverflow] in _updatePredictedVolume — the integrator can no longer drift above the weir crest (only a real measurement can show level > overflow, e.g. inflow exceeding pump+weir capacity). Excess is recorded as: - overflowVolume.predicted.atequipment.default — cumulative spill (m3) - flow.predicted.out.overflow — instantaneous spill rate (m3/s), registered as a synthetic outflow so net-flow balance reads ~0 while pinned. The integrator subtracts the prior tick's synthetic flow before integrating so it never feeds back into volume math. Lower clamp at dryRunSafetyVol fires only on the transition — a low seed/calibration is left alone; inflow is what brings it back up. _selectBestNetFlow holds the last non-zero level-rate net flow when level pins at overflowLevel and dL/dt collapses to 0, so dashboards keep showing roughly what's coming in. Auto-refreshes once level drops. getOutput() exposes predictedOverflowVolume + predictedOverflowRate as top-level convenience keys; the underlying measurements flow to InfluxDB via the standard MeasurementContainer flatten path. 9 new test assertions cover the upper-clamp + spill increment, stable spill across ticks, net-flow ~0 while pinned, spill clearing when inflow stops, low-seed left alone, drain-across-threshold clamp, and the new top-level output keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:47:46 +02:00
output.predictedOverflowVolume = this.measurements
.type('overflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
output.predictedOverflowRate = this.measurements
Predicted-volume hard-floor at 0 + spill flow position refactor Volume integrator changes: - Hard physical floor at 0 added to _updatePredictedVolume. Without it, a basin seeded below dryRunSafetyVol (calibration / startup / low seed) under continued net-outflow drifted volume arbitrarily negative; the level output looked clamped only because _calcLevelFromVolume floors at 0, masking the underlying drift. - New cumulative diagnostic: underflowVolume.predicted.atequipment (m³) + getOutput().predictedUnderflowVolume. Non-zero indicates a flow-balance error (over-reported outflow / missing inflow). - The transition-only dryRunSafetyVol clamp is preserved so startup-from-empty doesn't snap to 2.1 m³ on tick 1. Spill flow refactor (taxonomic + bug fix): - Synthetic spill moved from flow.predicted.out.<child='overflow'> to its own position flow.predicted.overflow.<default>. The spill is a derived quantity, not a physical sub-source sharing a position with pumps — .child() was the wrong knob. - Removes the spillPrev self-subtraction in the integrator (no longer needed: outflowTotal at ['out','downstream'] cleanly excludes spill). - Closes a latent fall-through bug exposed during this work: .child('overflow').getCurrentValue() returned the value of any available sibling child when overflow itself didn't yet exist. Hardened separately in generalFunctions@a516c2b. - _selectBestNetFlow folds the overflow position into the outflow side so the predicted net-flow balance still reads ~0 while pinned. Tests: 70/70 pass. 4 new subtests cover the 0-floor, accumulated underflow tracking, getOutput surface, and refill-from-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:23 +02:00
.type('flow').variant('predicted').position('overflow').getCurrentValue('m3/s') ?? 0;
output.predictedUnderflowVolume = this.measurements
.type('underflowVolume').variant('predicted').position('atequipment').getCurrentValue('m3') ?? 0;
return output;
2025-10-28 17:04:26 +01:00
}
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>
2026-05-05 19:29:34 +02:00
_deriveSafetyState() {
if (this.safetyState?.isOverflowing) return 'overflowing';
if (this.safetyState?.highVolumeActive) return 'highVolume';
if (this.safetyState?.dryRunActive) return 'dryRun';
return 'normal';
}
2025-10-07 18:05:54 +02:00
}
2025-11-03 07:42:51 +01:00
module.exports = PumpingStation;
2025-10-23 09:51:54 +02:00
/* ------------------------------------------------------------------------- */
2025-10-28 17:04:26 +01:00
/* Example usage */
2025-10-23 09:51:54 +02:00
/* ------------------------------------------------------------------------- */
2025-11-30 20:13:21 +01:00
2025-10-28 17:04:26 +01:00
if (require.main === module) {
const Measurement = require('../../measurement/src/specificClass');
const RotatingMachine = require('../../rotatingMachine/src/specificClass');
function createPumpingStationConfig(name) {
return {
general: {
logging: { enabled: true, logLevel: 'debug' },
name,
id: `${name}-${Date.now()}`,
flowThreshold: 1e-4
},
functionality: {
softwareType: 'pumpingStation',
role: 'stationcontroller'
},
basin: {
volume: 43.75,
2025-11-03 07:42:51 +01:00
height: 10,
inflowLevel: 3,
outflowLevel: 0.2,
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>
2026-05-05 19:29:34 +02:00
overflowLevel: 3.2,
inletPipeDiameter: 0.4,
outletPipeDiameter: 0.3
2025-10-28 17:04:26 +01:00
},
hydraulics: {
refHeight: 'NAP',
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>
2026-05-05 19:29:34 +02:00
basinBottomRef: 0,
minHeightBasedOn: 'outlet'
2025-11-30 09:24:18 +01:00
},
safety: {
enableDryRunProtection:false,
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>
2026-05-05 19:29:34 +02:00
enableHighVolumeSafety:false,
highVolumeSafetyThresholdPercent: 98
2025-10-28 17:04:26 +01:00
}
};
}
2025-10-23 09:51:54 +02:00
2025-10-28 17:04:26 +01:00
function createLevelMeasurementConfig(name) {
return {
general: {
logging: { enabled: true, logLevel: 'debug' },
name,
id: `${name}-${Date.now()}`,
unit: 'm'
},
functionality: {
softwareType: 'measurement',
role: 'sensor',
positionVsParent: 'atequipment'
},
asset: {
category: 'sensor',
type: 'level',
model: 'demo-level',
supplier: 'demoCo',
unit: 'm'
},
scaling: { enabled: false },
smoothing: { smoothWindow: 5, smoothMethod: 'none' }
};
}
2025-10-23 09:51:54 +02:00
2025-10-28 17:04:26 +01:00
function createFlowMeasurementConfig(name, position) {
return {
general: {
logging: { enabled: true, logLevel: 'debug' },
name,
id: `${name}-${Date.now()}`,
unit: 'm3/s'
},
functionality: {
softwareType: 'measurement',
role: 'sensor',
positionVsParent: position
},
asset: {
category: 'sensor',
type: 'flow',
model: 'demo-flow',
supplier: 'demoCo',
unit: 'm3/s'
},
scaling: { enabled: false },
smoothing: { smoothWindow: 5, smoothMethod: 'none' }
};
}
2025-10-07 18:05:54 +02:00
2025-10-28 17:04:26 +01:00
function createMachineConfig(name,position) {
return {
general: {
name,
logging: { enabled: false, logLevel: 'debug' }
},
functionality: {
softwareType: "machine",
positionVsParent: position
},
asset: {
supplier: 'Hydrostal',
type: 'pump',
category: 'centrifugal',
model: 'hidrostal-H05K-S03R'
}
};
}
2025-10-23 09:51:54 +02:00
2025-10-28 17:04:26 +01:00
function createMachineStateConfig() {
return {
general: {
2025-10-23 09:51:54 +02:00
logging: {
2025-10-28 17:04:26 +01:00
enabled: true,
logLevel: 'debug'
2025-10-23 09:51:54 +02:00
}
2025-10-28 17:04:26 +01:00
},
movement: { speed: 1 },
time: {
starting: 2,
warmingup: 3,
stopping: 2,
coolingdown: 3
}
};
2025-10-23 09:51:54 +02:00
}
2025-10-28 17:04:26 +01:00
function seedSample(measurement, type, value, unit) {
const pos = measurement.config.functionality.positionVsParent;
measurement.measurements.type(type).variant('measured').position(pos).value(value, Date.now(), unit);
2025-10-23 09:51:54 +02:00
}
2025-10-07 18:05:54 +02:00
2025-10-28 17:04:26 +01:00
(async function demo() {
2025-11-03 07:42:51 +01:00
const station = new PumpingStation(createPumpingStationConfig('PumpingStationDemo'));
2025-10-28 17:04:26 +01:00
const pump1 = new RotatingMachine(createMachineConfig('Pump1','downstream'), createMachineStateConfig());
2025-11-30 09:24:18 +01:00
//const pump2 = new RotatingMachine(createMachineConfig('Pump2','upstream'), createMachineStateConfig());
2025-10-28 17:04:26 +01:00
2025-11-30 09:24:18 +01:00
//const levelSensor = new Measurement(createLevelMeasurementConfig('WetWellLevel'));
//const inflowSensor = new Measurement(createFlowMeasurementConfig('InfluentFlow', 'in'));
//const outflowSensor = new Measurement(createFlowMeasurementConfig('PumpDischargeFlow', 'out'));
2025-10-28 17:04:26 +01:00
2025-11-30 09:24:18 +01:00
//station.childRegistrationUtils.registerChild(levelSensor, levelSensor.config.functionality.softwareType);
2025-11-03 07:42:51 +01:00
//station.childRegistrationUtils.registerChild(inflowSensor, inflowSensor.config.functionality.softwareType);
//station.childRegistrationUtils.registerChild(outflowSensor, outflowSensor.config.functionality.softwareType);
2025-10-28 17:04:26 +01:00
station.childRegistrationUtils.registerChild(pump1, 'machine');
2025-11-30 09:24:18 +01:00
//station.childRegistrationUtils.registerChild(pump2, 'machine');
2025-10-28 17:04:26 +01:00
// Seed initial measurements
2025-11-03 07:42:51 +01:00
2025-11-30 09:24:18 +01:00
//seedSample(levelSensor, 'level', 1.8, 'm');
2025-11-03 07:42:51 +01:00
//seedSample(inflowSensor, 'flow', 0.35, 'm3/s');
//seedSample(outflowSensor, 'flow', 0.20, 'm3/s');
2025-11-28 16:29:05 +01:00
2025-10-28 17:04:26 +01:00
setInterval(
() => station.tick(), 1000);
2025-10-23 09:51:54 +02:00
2025-11-28 16:29:05 +01:00
await new Promise((resolve) => setTimeout(resolve, 10));
2025-10-07 18:05:54 +02:00
2025-11-28 16:29:05 +01:00
console.log('Initial state:', station.state);
2025-11-30 09:24:18 +01:00
station.setManualInflow(300,Date.now(),'l/s');
2025-11-30 20:13:21 +01:00
station.calibratePredictedVolume(3.4);
2025-11-30 09:24:18 +01:00
//await pump1.handleInput('parent', 'execSequence', 'startup');
//await pump1.handleInput('parent', 'execMovement', 10);
//
//await pump2.handleInput('parent', 'execSequence', 'startup');
//await pump2.handleInput('parent', 'execMovement', 10);
2025-10-28 17:04:26 +01:00
2025-11-28 16:29:05 +01:00
console.log('Station state:', station.state);
console.log('Station output:', station.getOutput());
})().catch((err) => {
console.error('Demo failed:', err);
});
2025-10-28 17:04:26 +01:00
}
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>
2026-05-05 19:29:34 +02:00
//*/