diff --git a/src/configs/pumpingStation.json b/src/configs/pumpingStation.json index 59371c6..9f98f08 100644 --- a/src/configs/pumpingStation.json +++ b/src/configs/pumpingStation.json @@ -498,7 +498,16 @@ "rules": { "type": "number", "min": 0, - "description": "Lower shifted ramp point used while draining. While filling, demand is held at 0 % from startLevel through inletLevel, then scales from inletLevel to maxLevel." + "description": "Pump-on threshold and ramp foot. Below this level demand is 0 %; at or above it demand scales 0 → 100 % across [startLevel, maxLevel] using the configured curve (linear or log). When enableShiftedRamp is on, this also serves as the bottom of the held-then-ramp curve during draining." + } + }, + "stopLevel": { + "default": null, + "rules": { + "type": "number", + "nullable": true, + "min": 0, + "description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Independent of the ramp scaling — does NOT shift where the ramp starts. Pair with a startLevel above stopLevel to get hysteresis (pumps engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel." } }, "maxLevel": { diff --git a/src/predict/predict_class.js b/src/predict/predict_class.js index b33c07d..829a842 100644 --- a/src/predict/predict_class.js +++ b/src/predict/predict_class.js @@ -68,6 +68,13 @@ const Interpolation = require('./interpolation'); class Predict { constructor(config = {}) { + // Capture share-source BEFORE config validation strips it (ConfigUtils + // mutates the input config to drop unknown keys, which would remove + // shareInputsFrom because it's not in predictConfig.json's schema). + const _sharedSource = (config && config.shareInputsFrom instanceof Predict) + ? config.shareInputsFrom + : null; + // Initialize dependencies this.emitter = new EventEmitter(); // Own EventEmitter this.configUtils = new ConfigUtils(defaultConfig); @@ -107,8 +114,29 @@ class Predict { this.calculationPoints = this.config.normalization.parameters.curvePoints; this.interpolationType = this.config.interpolation.type; - // Load curve if provided - if (config.curve) { + // Load curve if provided. + // shareInputsFrom: an existing Predict instance whose pre-built input + // curves and splines we adopt by reference. Used to create a parallel + // "view" of the same source curves (e.g. an MGC group-scope predict + // that mirrors a pump's individual predict). Per-instance state — + // currentF / currentX / currentFxyCurve / currentFxySplines / + // currentFxyY/X Min/Max / outputY — stays freshly initialised so the + // two views have independent operating points. Curve mutations on the + // source via updateCurve() are propagated through the source's + // "curveUpdated" emitter (see updateCurve below). + if (_sharedSource) { + this._adoptInputsFrom(_sharedSource); + this._sharedInputsSource = _sharedSource; + this._sharedInputsHandler = (newCurve) => { + this._adoptInputsFrom(this._sharedInputsSource); + // Keep our currentF in range; constrain re-uses the new fValues. + this.fDimension = this.constrain(this.currentF, this.fValues.min, this.fValues.max); + }; + this._sharedInputsSource.emitter.on('curveUpdated', this._sharedInputsHandler); + // Initialise our own operating point to the source's min, same as + // the standard buildAllFxyCurves flow does at end of curve load. + this.fDimension = this.fValues.min; + } else if (config.curve) { this.inputCurveData = config.curve; } else { this.logger.warn("No curve data provided. Please set curve data using setCurveData method. Using default"); @@ -116,6 +144,31 @@ class Predict { } } + + // Adopt another Predict's input curves and splines by reference. Used by + // the shareInputsFrom constructor option and by the curveUpdated emitter + // handler to re-sync after the source's curves change. Does NOT touch + // per-instance state (currentF, currentX, currentFxy* etc.). + // + // Also copies the scalar parameters (calculationPoints, normMin/Max, + // interpolationType) so the clone uses the SAME pointsCount the source + // built fSplines with — otherwise buildSingleFxyCurve can iterate past + // the end of the shared fSplines. + _adoptInputsFrom(source) { + this.inputCurve = source.inputCurve; + this.normalizedCurve = source.normalizedCurve; + this.calculatedCurve = source.calculatedCurve; + this.fCurve = source.fCurve; + this.fSplines = source.fSplines; + this.normalizedSplines = source.normalizedSplines; + this.xValues = source.xValues; + this.fValues = source.fValues; + this.yValues = source.yValues; + this.calculationPoints = source.calculationPoints; + this.normMin = source.normMin; + this.normMax = source.normMax; + this.interpolationType = source.interpolationType; + } // Improved function to get a local peak in an array by starting in the middle. // It also handles the case of a tie by preferring the left side (arbitrary choice) @@ -348,6 +401,9 @@ class Predict { this.buildAllFxyCurves(validatedCurve); + // Notify shared-input clones (see shareInputsFrom in the constructor). + // They re-adopt our inputs and clamp their own operating point. + this.emitter.emit('curveUpdated', validatedCurve); } constrain(value,min,max) { diff --git a/src/state/state.js b/src/state/state.js index e2dc103..c5bd755 100644 --- a/src/state/state.js +++ b/src/state/state.js @@ -66,15 +66,41 @@ class state{ } if (this.stateManager.getCurrentState() !== "operational") { - if (this.config.mode.current === "auto") { - this.delayedMove = targetPosition; - this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`); + // 'accelerating' / 'decelerating' here is post-abort residue — + // the previous moveTo was aborted (e.g. MGC's per-tick + // abortActiveMovements) and the catch block intentionally + // doesn't auto-return to operational (avoids a bounce loop). + // BUT a new setpoint just arrived, so there's nothing for the + // anti-bounce policy to protect: the caller IS asking for a + // move. Fall through to operational and execute it. Without + // this the FSM gets parked, all subsequent setpoints land in + // delayedMove which never fires, and currentPosition freezes — + // see test/integration/abort-deadlock.integration.test.js for + // the exact deadlock scenario. + const movementResidueStates = ['accelerating', 'decelerating']; + if (movementResidueStates.includes(this.stateManager.getCurrentState())) { + this.logger.debug(`moveTo(${targetPosition}) arrived while parked in '${this.stateManager.getCurrentState()}' (post-abort). Returning to operational to service the new setpoint.`); + try { + await this.transitionToState("operational"); + } catch (e) { + this.logger.warn(`Could not transition out of '${this.stateManager.getCurrentState()}': ${e?.message || e}`); + return; + } + // Fall through — state is now operational, proceed with new move. + } else { + // Genuine non-operational state (starting, warmingup, stopping, + // coolingdown, idle, off, emergencystop, maintenance) — these + // are sequence steps the caller can't legitimately interrupt + // with a setpoint. Save for later, exactly as before. + if (this.config.mode.current === "auto") { + this.delayedMove = targetPosition; + this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`); + } + else{ + this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`); + } + return; } - else{ - this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`); - } - //return early - return; } this.abortController = new AbortController(); const { signal } = this.abortController;