9 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
Rene De Ren
a516c2b2b6 MeasurementContainer.get: strict-resolve explicit .child(name)
A read chain `.child(name).getCurrentValue()` previously fell through
silently to the implicit-default child or the first available sibling
when the named child did not exist. Caller asked for X, got Y, no
warning. Surfaced via pumpingStation spillPrev: a fresh basin's
.child('overflow').getCurrentValue() returned the value of
'manual-qout' (the only existing child at that position).

Split the resolution into two strictness levels:
  _currentChildId (per-chain .child(name)) → STRICT, missing = null.
  this.childId    (persistent setChildId)  → HINT, falls back to
                                              'default' then first.

The persistent path is what registered children (rotatingMachine etc.)
rely on: they write under composed ids ('up-<id>') but expect reads
without explicit .child() to still resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:17:58 +02:00
Rene De Ren
4b6250cc42 pumpingStation schema: shiftArmPercent + MeasurementContainer .default doc
- control.levelbased.shiftArmPercent (default 95): output % threshold that
  arms the shift on the way up. Once armed, the up-curve % at the
  filling→draining transition becomes the held value, kept until level
  drops to shiftLevel; from there it ramps to 0 % at startLevel.
- shiftLevel description updated — it is no longer the arming trigger,
  it's the level at which the held output begins ramping down.
- MeasurementContainer.js: prominent doc block on the class plus a
  JSDoc on getFlattenedOutput documenting the `${type}.${variant}.
  ${position}.${childId}` flatten format and the implicit 'default'
  childId convention. This was the #1 footgun for new dashboard
  consumers — the comments now make the rule impossible to miss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:15 +02:00
Rene De Ren
35f648f64e pumpingStation schema: add flow dead-band, output formats, level-armed shift
- general.flowThreshold: configurable m3/s dead-band for steady-flow detection
- output.process / output.dbase: enum for port-0 / port-1 payload format
- control.levelbased.enableShiftedRamp: hysteresis toggle
- control.levelbased.shiftLevel: arming level for the shifted ramp
- inflowLevel description clarified as "bottom/invert of inlet pipe"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:16:20 +02:00
znetsixe
4252292ae1 pumpingStation schema: rename basin/control thresholds to wiki naming
Matches the 5-threshold convention (dryRunLevel, minLevel, startLevel,
maxLevel, overflowLevel) introduced in the pumpingStation wiki:

  basin.heightInlet              → basin.inflowLevel
  basin.heightOutlet             → basin.outflowLevel
  basin.heightOverflow           → basin.overflowLevel
  control.levelbased.stopLevel   → control.levelbased.minLevel
  control.levelbased.maxFlowLevel → control.levelbased.maxLevel
  control.levelbased.minFlowLevel → removed (redundant with startLevel)
  control.levelbased.startLevel  → unchanged

Description strings tightened to reference the semantic role instead
of generic "min level to scale flow" prose.

Breaking change for existing saved flows. Ties in with pumpingStation
commit a218945 which updates the consumer code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:14:15 +02:00
znetsixe
693517cc8f fix: conditional abort recovery — don't auto-transition on routine aborts
The unconditional transition to 'operational' after every movement abort
caused a bounce loop when MGC called abortActiveMovements on each demand
tick: abort→operational→new-flowmovement→abort→operational→... endlessly.
Pumps never reached their setpoint.

Fix: abortCurrentMovement now takes an options.returnToOperational flag
(default false). Routine MGC aborts leave the pump in accelerating/
decelerating — the pump continues its residual movement and reaches
operational naturally. Shutdown/emergency-stop paths pass
returnToOperational:true so the FSM unblocks for the stopping transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:01:41 +02:00
znetsixe
086e5fe751 fix: remove bogus machineCurve default that poisoned prediction splines
The schema default for machineCurve.nq had a dummy pressure slice at
key "1" with x=[1..5] y=[10..50]. configUtils.updateConfig deep-merges
defaults into the real config, so this fake slice survived alongside the
real pressure slices (70000, 80000, ..., 390000 Pa). The predict class
then included it in its pressure-dimension spline, pulling all
interpolated y-values toward the dummy data at low pressures and
producing NEGATIVE flow predictions (e.g. -243 m³/h) where the real
curve is strictly positive.

Fix: default to empty objects {nq: {}, np: {}} so the deep merge adds
nothing. The validateMachineCurve function already returns the whole
default if the real curve is missing or invalid, so the empty default
doesn't break the no-curve-data path — it just stops poisoning the
real curve data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:27:59 +02:00
znetsixe
29b78a3f9b fix(childRegistrationUtils): alias rotatingmachine/machinegroupcontrol so production parents see them
The MGC and pumpingStation registerChild handlers dispatch on
softwareType === 'machine' / 'machinegroup' / 'pumpingstation' /
'measurement'. But buildConfig sets functionality.softwareType to the
lowercased node name, so in production rotatingMachine reports
'rotatingmachine' and machineGroupControl reports 'machinegroupcontrol'.
Result: the MGC <-> rotatingMachine and pumpingStation <-> MGC wiring
silently never hit the right branch in production, even though every
unit test passes (tests pass an already-aliased softwareType manually).

Fix: tiny SOFTWARE_TYPE_ALIASES map at the central registerChild
dispatcher in childRegistrationUtils. Real production names get
translated to the dispatch keys parents already check for, while tests
that pass already-aliased keys are unaffected (their values aren't in
the alias map and pass through unchanged).

  rotatingmachine        -> machine
  machinegroupcontrol    -> machinegroup

Verified end-to-end on Dockerized Node-RED: MGC now reports
'3 machine(s) connected' when wired to 3 rotatingMachine ports;
pumpingStation registers MGC as a machinegroup child and listens to
its predicted-flow stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:53:21 +02:00
7 changed files with 372 additions and 97 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

@@ -22,6 +22,14 @@
"description": "The default flow unit used for reporting station throughput."
}
},
"flowThreshold": {
"default": 0.0001,
"rules": {
"type": "number",
"min": 0,
"description": "Flow dead-band in m3/s below which the station treats net flow as steady."
}
},
"logging": {
"logLevel": {
"default": "info",
@@ -127,6 +135,50 @@
}
}
},
"output": {
"process": {
"default": "process",
"rules": {
"type": "enum",
"values": [
{
"value": "process",
"description": "Delta-compressed process message."
},
{
"value": "json",
"description": "JSON payload."
},
{
"value": "csv",
"description": "CSV-formatted payload."
}
],
"description": "Format of the process payload emitted on output port 0."
}
},
"dbase": {
"default": "influxdb",
"rules": {
"type": "enum",
"values": [
{
"value": "influxdb",
"description": "InfluxDB telemetry payload."
},
{
"value": "json",
"description": "JSON payload."
},
{
"value": "csv",
"description": "CSV-formatted payload."
}
],
"description": "Format of the telemetry payload emitted on output port 1."
}
}
},
"asset": {
"uuid": {
"default": null,
@@ -235,23 +287,23 @@
"description": "Unit used for level related setpoints and thresholds."
}
},
"heightInlet": {
"inflowLevel": {
"default": 2,
"rules": {
"type": "number",
"min": 0,
"description": "Height of the inlet pipe measured from the basin floor (m)."
"description": "Bottom/invert height of the inlet pipe measured from the basin floor (m)."
}
},
"heightOutlet": {
"outflowLevel": {
"default": 0.2,
"rules": {
"type": "number",
"min": 0,
"description": "Height of the outlet pipe measured from the basin floor (m)."
"description": "Top height of the outlet or pump-suction pipe measured from the basin floor (m)."
}
},
"heightOverflow": {
"overflowLevel": {
"default": 2.5,
"rules": {
"type": "number",
@@ -433,36 +485,86 @@
}
},
"levelbased": {
"minLevel": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"description": "Below this level the MGC shuts down all pumps (unconditional stop). Between minLevel and the active ramp start, demand is held at 0 %."
}
},
"startLevel": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"description": "start of pump / group when level reaches this in meters starting from bottom."
"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": 1,
"default": null,
"rules": {
"type": "number",
"nullable": true,
"min": 0,
"description": "stop of pump / group when level reaches this in meters starting from bottom"
"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."
}
},
"minFlowLevel": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"description": "min level to scale the flow lineair"
}
},
"maxFlowLevel": {
"maxLevel": {
"default": 4,
"rules": {
"type": "number",
"min": 0,
"description": "max level to scale the flow lineair"
"description": "Level at which the pump demand saturates at 100 %. Above this, demand stays clamped."
}
},
"curveType": {
"default": "linear",
"rules": {
"type": "enum",
"values": [
{
"value": "linear",
"description": "Linear demand scaling between the active lower ramp level and maxLevel."
},
{
"value": "log",
"description": "Logarithmic demand scaling with fast response early in the ramp."
}
],
"description": "Demand curve used by levelbased control."
}
},
"logCurveFactor": {
"default": 9,
"rules": {
"type": "number",
"min": 0.001,
"description": "Shape factor for the levelbased log curve; higher values increase early response."
}
},
"enableShiftedRamp": {
"default": false,
"rules": {
"type": "boolean",
"description": "When true, arm a hysteresis shift: once level rises past shiftLevel the ramp foot moves left from inflowLevel to startLevel until level falls back below startLevel."
}
},
"shiftLevel": {
"default": 0,
"rules": {
"type": "number",
"min": 0,
"description": "Level (m) at which the held output starts ramping down during draining. Must be > startLevel and ≤ maxLevel. Ignored when enableShiftedRamp is false."
}
},
"shiftArmPercent": {
"default": 95,
"rules": {
"type": "number",
"min": 0,
"max": 100,
"description": "Output % threshold that arms the shift on the way up. Once armed, the output value at the moment direction flips to draining becomes the held value, and stays held until level drops to shiftLevel. Disarms when level reaches startLevel."
}
}
},
@@ -638,19 +740,18 @@
"description": "Volume percentage below which dry run protection activates."
}
},
"dryRunDebounceSeconds": {
"default": 30,
"rules": {
"type": "number",
"min": 0,
"description": "Time the low-volume condition must persist before dry-run protection engages (seconds)."
}
},
"enableOverfillProtection": {
"default": true,
"rules": {
"type": "boolean",
"description": "If true, high level alarms and shutdowns will be enforced to prevent overfilling."
"description": "Deprecated alias for enableHighVolumeSafety. If true, high level alarms and shutdowns will be enforced to preserve overflow margin."
}
},
"enableHighVolumeSafety": {
"default": true,
"rules": {
"type": "boolean",
"description": "If true, high-volume safety actions run before the basin reaches physical overflow."
}
},
"overfillThresholdPercent": {
@@ -659,15 +760,16 @@
"type": "number",
"min": 0,
"max": 100,
"description": "Volume percentage above which overfill protection activates."
"description": "Deprecated alias for highVolumeSafetyThresholdPercent."
}
},
"overfillDebounceSeconds": {
"default": 30,
"highVolumeSafetyThresholdPercent": {
"default": 98,
"rules": {
"type": "number",
"min": 0,
"description": "Time the high-volume condition must persist before overfill protection engages (seconds)."
"max": 100,
"description": "Percentage of maxVolAtOverflow where high-volume safety activates before actual overflow."
}
},
"timeleftToFullOrEmptyThresholdSeconds": {

View File

@@ -282,43 +282,9 @@
},
"machineCurve": {
"default": {
"nq": {
"1": {
"x": [
1,
2,
3,
4,
5
],
"y": [
10,
20,
30,
40,
50
]
}
},
"np": {
"1": {
"x": [
1,
2,
3,
4,
5
],
"y": [
10,
20,
30,
40,
50
]
}
}
},
"nq": {},
"np": {}
},
"rules": {
"type": "machineCurve",
"description": "All machine curves must have a 'nq' and 'np' curve. nq stands for the flow curve, np stands for the power curve. Together they form the efficiency curve."

View File

@@ -1,3 +1,17 @@
// Map a child's raw softwareType (the lowercased node name from
// buildConfig) to the "role" key that parent registerChild() handlers
// dispatch on. Without this, MGC/pumpingStation register-handlers (which
// branch on 'machine' / 'machinegroup' / 'pumpingstation' / 'measurement')
// silently miss every real production child because rotatingMachine
// reports softwareType='rotatingmachine' and machineGroupControl reports
// 'machinegroupcontrol'. Existing tests that pass already-aliased keys
// ('machine', 'machinegroup') stay green because those aren't in the
// alias map and pass through unchanged.
const SOFTWARE_TYPE_ALIASES = {
rotatingmachine: 'machine',
machinegroupcontrol: 'machinegroup',
};
class ChildRegistrationUtils {
constructor(mainClass) {
this.mainClass = mainClass;
@@ -15,7 +29,8 @@ class ChildRegistrationUtils {
return false;
}
const softwareType = (child.config.functionality.softwareType || '').toLowerCase();
const rawSoftwareType = (child.config.functionality.softwareType || '').toLowerCase();
const softwareType = SOFTWARE_TYPE_ALIASES[rawSoftwareType] || rawSoftwareType;
const name = child.config.general.name || child.config.general.id || 'unknown';
const id = child.config.general.id || name;

View File

@@ -3,6 +3,51 @@ const EventEmitter = require('events');
const convertModule = require('../convert/index');
const { POSITIONS } = require('../constants/positions');
/* ============================================================================
* MeasurementContainer — measurement storage with chainable type/variant/
* position/child addressing.
*
* INTERNAL STORAGE SHAPE
* measurements[type][variant][position][childId] = Measurement instance
*
* The childId layer is ALWAYS present, even when the caller doesn't specify
* one. _getOrCreateMeasurement defaults childId to 'default' when no
* .child(...) is in the chain. So writing
*
* mc.type('level').variant('measured').position('atequipment')
* .value(2.5, ts, 'm');
*
* stores the value at measurements.level.measured.atequipment.default.
*
* READING — the chainable getters resolve the default child transparently,
* so consumers usually don't see it:
*
* mc.type('level').variant('measured').position('atequipment')
* .getCurrentValue('m'); // returns 2.5
*
* FLATTENED OUTPUT — getFlattenedOutput() emits ONE key per child, including
* the implicit 'default' bucket:
*
* {
* 'level.measured.atequipment.default': 2.5, // implicit child
* 'flow.predicted.in.manual-qin': 0.05, // explicit .child('manual-qin')
* 'flow.predicted.in.from-pump-A': 0.03,
* …
* }
*
* ⚠ DASHBOARDS / DOWNSTREAM PARSERS MUST INCLUDE THE CHILD KEY
* The flat key format is `${type}.${variant}.${position}.${childId}`.
* When you have not used .child(), the childId is the literal string
* 'default'. Use 'level.measured.atequipment.default', NOT
* 'level.measured.atequipment'. This trips up new consumers — see the
* pumpingStation basic-dashboard parser for an example that gets it right.
*
* AGGREGATION — sum() folds all children of a position into one number:
*
* mc.sum('flow', 'predicted', ['in'], 'm3/s');
* // = manual-qin + from-pump-A + … + (default if any)
* ============================================================================
*/
class MeasurementContainer {
constructor(options = {},logger) {
this.logger = logger || null;
@@ -380,16 +425,34 @@ class MeasurementContainer {
// Legacy single measurement
if (posBucket?.getCurrentValue) return posBucket;
// Child-aware: pick requested child, otherwise fall back to default, otherwise first available
// Child-aware lookup. Two separate sources of "child-id" on the
// container, with DIFFERENT strictness:
//
// _currentChildId : transient, set by .child(name) inside a chain.
// Explicit per-call. STRICT — if the named child
// does not exist, return null. Silent fall-through
// to a sibling would mask a missing-stream read
// as a wrong-stream read (see pumpingStation
// spillPrev bug, 2026-05-06).
//
// this.childId : persistent, set by setChildId(id). HINT only —
// try it first, then fall back to 'default' then
// first available. Containers registered with a
// persistent id (rotatingMachine, etc.) write
// under composed child ids (e.g. 'up-<id>') that
// don't equal the persistent id, and reads must
// still resolve to those writes.
if (posBucket && typeof posBucket === 'object') {
const requestedKey = this._currentChildId || this.childId;
const keys = Object.keys(posBucket);
if (!keys.length) return null;
const measurement =
(requestedKey && posBucket[requestedKey]) ||
posBucket.default ||
posBucket[keys[0]];
return measurement || null;
if (this._currentChildId) {
return posBucket[this._currentChildId] || null;
}
return (this.childId && posBucket[this.childId]) ||
posBucket.default ||
posBucket[keys[0]] ||
null;
}
return null;
@@ -535,18 +598,43 @@ class MeasurementContainer {
.reduce((acc, v) => acc + v, 0);
}
/**
* Flatten the entire container to a key→value map, suitable for
* dashboards / InfluxDB / debug dumps.
*
* KEY FORMAT — child-bucketed series (the common case):
* `${type}.${variant}.${position}.${childId}`
*
* Even measurements written without an explicit `.child(...)` end up
* here under `childId === 'default'` (see _getOrCreateMeasurement).
* Examples:
* level.measured.atequipment.default // implicit child
* flow.predicted.in.manual-qin // explicit child
* flow.predicted.in.from-pump-A // explicit child
*
* Consumers (Node-RED dashboards, parsers) MUST include the trailing
* `.default` when reading default-bucket measurements. Stripping it
* silently misses the value. This is the #1 footgun for new code that
* uses MeasurementContainer.
*
* The "Legacy single series" branch below catches a pre-v2 storage
* shape where a position held a Measurement directly (no child layer);
* new code never produces that shape but old serialized state may.
*/
getFlattenedOutput(options = {}) {
const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null);
const out = {};
Object.entries(this.measurements).forEach(([type, variants]) => {
Object.entries(variants).forEach(([variant, positions]) => {
Object.entries(positions).forEach(([position, entry]) => {
// Legacy single series
// Legacy single series (no childId layer)
if (entry?.getCurrentValue) {
out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits);
return;
}
// Child-bucketed series
// Child-bucketed series — ALWAYS the case for new writes,
// including the implicit 'default' bucket when no .child() is
// used. The flat key carries the childId.
if (entry && typeof entry === 'object') {
Object.entries(entry).forEach(([childId, m]) => {
if (m?.getCurrentValue) {

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;
@@ -85,17 +111,24 @@ class state{
this.emitter.emit("movementComplete", { position: targetPosition });
await this.transitionToState("operational");
} catch (error) {
// Abort path: return to 'operational' so a subsequent shutdown/emergency
// sequence can proceed. Without this, the FSM remains stuck in
// accelerating/decelerating and blocks stopping/idle transitions.
// Abort path: only return to 'operational' when explicitly requested
// (shutdown/emergency-stop needs it to unblock the FSM). Routine MGC
// demand-update aborts must NOT auto-transition — doing so causes a
// bounce loop where every tick aborts → operational → new move →
// abort → operational → ... and the pump never reaches its setpoint.
const msg = typeof error === 'string' ? error : error?.message;
if (msg === 'Transition aborted' || msg === 'Movement aborted') {
this.logger.debug(`Movement aborted; returning to 'operational' to unblock further transitions.`);
try {
await this.transitionToState("operational");
} catch (e) {
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
if (this._returnToOperationalOnAbort) {
this.logger.debug(`Movement aborted; returning to 'operational' (requested by caller).`);
try {
await this.transitionToState("operational");
} catch (e) {
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
}
} else {
this.logger.debug(`Movement aborted; staying in current state (routine abort).`);
}
this._returnToOperationalOnAbort = false;
this.emitter.emit("movementAborted", { position: targetPosition });
} else {
this.logger.error(error);
@@ -105,9 +138,19 @@ class state{
// -------- State Transition Methods -------- //
abortCurrentMovement(reason = "group override") {
/**
* @param {string} reason - human-readable abort reason
* @param {object} [options]
* @param {boolean} [options.returnToOperational=false] - when true the FSM
* transitions back to 'operational' after the abort so a subsequent
* shutdown/emergency-stop sequence can proceed. Set to false (default)
* for routine demand updates where the caller will send a new movement
* immediately — auto-transitioning would cause a bounce loop.
*/
abortCurrentMovement(reason = "group override", options = {}) {
if (this.abortController && !this.abortController.signal.aborted) {
this.logger.warn(`Aborting movement: ${reason}`);
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
this.abortController.abort();
}
}