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>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user