2 Commits

Author SHA1 Message Date
Rene De Ren
9a998191cd state.moveTo: unpark post-abort residue on new setpoint
When MGC's per-tick abortActiveMovements parks the FSM in
'accelerating'/'decelerating' to avoid a bounce loop, a subsequent
moveTo previously fell into the early-return path and saved the new
setpoint to delayedMove — which never fired because nothing transitioned
back to 'operational'. Now distinguish residue states from genuine
non-operational states (starting/warmingup/...) and force-transition
out of residue so the new setpoint actually executes. Also picks up
in-flight predict shareInputsFrom plumbing and pumpingStation.json
stopLevel doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:19:34 +02:00
Rene De Ren
94bcc90b4b Ignore local package-lock.json stub
generalFunctions has no production deps of its own, so any
package-lock.json found here is a stub from a stray `npm install`
inside the submodule directory. Don't track it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:24:22 +02:00
4 changed files with 107 additions and 11 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
# Local stub generated by `npm install` in the submodule directory.
# generalFunctions has no production deps of its own.
package-lock.json

View File

@@ -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": {

View File

@@ -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) {

View File

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