3 Commits

Author SHA1 Message Date
Rene De Ren
8f9150e160 fix: shutdown clears delayedMove so abort+autoPickup can't re-engage pump
When PS commanded turnOffAllMachines, executeSequence's interruptible
abort path triggered transitionToState('operational'), which auto-picked
up the queued delayedMove and re-started the pump. Pump bounced
accelerating ↔ decelerating forever and never reached idle.

Clear state.delayedMove at the top of shutdown/emergencystop sequences
so a user-commanded stop cancels any pending move.

Observed live: in pumpingstation-complete-example the basin drained
past stopLevel and equilibrated at ~0.3 m with one pump stuck at min
flow. With this fix pumps shut down cleanly at stopLevel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:17:45 +02:00
Rene De Ren
5a8113a9d1 Test: abort-deadlock regression guard
Two reproducers for the post-abort residue deadlock fixed in
generalFunctions state.js. The direct test forces the FSM into
'accelerating' (mimicking MGC's per-tick abortActiveMovements that
intentionally leaves the pump parked to avoid a bounce loop) and
issues a fresh setpoint — without the fix, currentPosition freezes
and delayedMove holds the new target forever; with the fix, residue
unparks and the move executes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:20:17 +02:00
Rene De Ren
ecd5a4864b Group-scope predicts for MGC combination optimization
Adds a parallel set of Predict instances (groupPredictFlow / Power / Ctrl)
that share input curves with the pump's individual predicts but maintain
their own operating point. MGC drives these via setGroupOperatingPoint()
to evaluate every pump curve at one shared manifold differential during
combination optimization, without corrupting each pump's own diagnostic
outputs (which track that pump's local sensors).

Created lazily on first use so pumps without an MGC parent pay nothing.
Pairs with generalFunctions Predict.shareInputsFrom plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:20:17 +02:00
3 changed files with 326 additions and 3 deletions

View File

@@ -87,6 +87,18 @@ class Machine {
} }
} }
// Group-scope predicts. These are parallel "views" of the same source
// curves used by an MGC parent for combination optimization. Created
// lazily on the first setGroupOperatingPoint() call so pumps that
// never have an MGC parent pay nothing. They share input-curve refs
// with the individual predicts (see Predict.shareInputsFrom) but
// maintain independent operating-point state, so the pump's own
// sensor stream and the MGC's group operating point can coexist.
this.groupPredictFlow = null;
this.groupPredictPower = null;
this.groupPredictCtrl = null;
this.groupNCog = 0;
this.state = new state(stateConfig, this.logger); // Init State manager and pass logger this.state = new state(stateConfig, this.logger); // Init State manager and pass logger
this.errorMetrics = new nrmse(errorMetricsConfig, this.logger); this.errorMetrics = new nrmse(errorMetricsConfig, this.logger);
@@ -873,13 +885,22 @@ _callMeasurementHandler(measurementType, value, position, context) {
return; return;
} }
// A shutdown/emergency-stop must cancel any pending move. Without this,
// the abort path below (returnToOperational=true) lets state.transitionToState
// auto-pick up state.delayedMove as soon as it lands in 'operational',
// which re-engages the pump on every shutdown attempt — pump bounces
// forever between accelerating and decelerating and never reaches idle.
const interruptible = new Set(["shutdown", "emergencystop"]);
if (interruptible.has(sequenceName)) {
this.state.delayedMove = null;
}
// Interruptible movement: if a shutdown or emergency-stop is requested // Interruptible movement: if a shutdown or emergency-stop is requested
// while a setpoint move is mid-flight (accelerating/decelerating), abort // while a setpoint move is mid-flight (accelerating/decelerating), abort
// the move first and wait briefly for the FSM to return to 'operational'. // the move first and wait briefly for the FSM to return to 'operational'.
// Without this, transitions like accelerating->stopping are rejected by // Without this, transitions like accelerating->stopping are rejected by
// stateManager.isValidTransition, leaving the machine running. // stateManager.isValidTransition, leaving the machine running.
const currentState = this.state.getCurrentState(); const currentState = this.state.getCurrentState();
const interruptible = new Set(["shutdown", "emergencystop"]);
if (interruptible.has(sequenceName) && if (interruptible.has(sequenceName) &&
(currentState === "accelerating" || currentState === "decelerating")) { (currentState === "accelerating" || currentState === "decelerating")) {
this.logger.warn(`Sequence '${sequenceName}' requested during '${currentState}'. Aborting active movement.`); this.logger.warn(`Sequence '${sequenceName}' requested during '${currentState}'. Aborting active movement.`);
@@ -1021,6 +1042,70 @@ _callMeasurementHandler(measurementType, value, position, context) {
} }
// ---------- Group-scope operating point (MGC parent uses this) ----------
//
// The pump's individual predicts (predictFlow / predictPower / predictCtrl)
// are driven by THIS pump's own pressure sensors via getMeasuredPressure().
// For combination optimization an MGC parent needs every pump curve
// evaluated at ONE shared operating point (the manifold differential).
// Doing that on the individual predicts would corrupt the pump's own
// diagnostic outputs. So we keep a parallel set of predicts here that
// ONLY the MGC drives via setGroupOperatingPoint(). Pump's individual
// outputs are unaffected.
// Lazily create group-scope predicts that share input curves with the
// individual ones. Safe to call multiple times.
_ensureGroupPredicts() {
if (!this.hasCurve || !this.predictFlow || !this.predictPower || !this.predictCtrl) return;
if (this.groupPredictFlow && this.groupPredictPower && this.groupPredictCtrl) return;
this.groupPredictFlow = new predict({ shareInputsFrom: this.predictFlow });
this.groupPredictPower = new predict({ shareInputsFrom: this.predictPower });
this.groupPredictCtrl = new predict({ shareInputsFrom: this.predictCtrl });
}
// External (MGC) API: set the group operating point. Recomputes the
// group predicts at the new differential pressure and updates groupNCog.
// Does NOT touch this.predictFlow / predictPower / predictCtrl /
// this.NCog / this.measurements.
setGroupOperatingPoint(downstreamPa, upstreamPa) {
this._ensureGroupPredicts();
if (!this.groupPredictFlow || !this.groupPredictPower) return;
if (!Number.isFinite(downstreamPa) || !Number.isFinite(upstreamPa)) return;
const diff = downstreamPa - upstreamPa;
if (diff <= 0) return;
this.groupPredictFlow.fDimension = diff;
this.groupPredictPower.fDimension = diff;
if (this.groupPredictCtrl) this.groupPredictCtrl.fDimension = diff;
this.groupNCog = this._calcGroupCog();
}
// Power consumption at flow on the group operating point (used by
// MGC's marginal-cost refinement). Falls back to the individual
// calculation if the group predicts haven't been initialised.
groupCalcPower(flow) {
if (!this.groupPredictFlow || !this.groupPredictPower || !this.groupPredictCtrl) {
return this.inputFlowCalcPower(flow);
}
this.groupPredictCtrl.currentX = flow;
const cCtrl = this.groupPredictCtrl.y(flow);
this.groupPredictPower.currentX = cCtrl;
return this.groupPredictPower.y(cCtrl);
}
// Mirrors calcCog() but reads from group predicts. Returns the
// normalised cog (0..1) — the MGC optimizer uses this for BEP-Gravitation.
_calcGroupCog() {
if (!this.groupPredictFlow || !this.groupPredictPower) return 0;
const powerCurve = this.groupPredictPower.currentFxyCurve[this.groupPredictPower.currentF];
const flowCurve = this.groupPredictFlow.currentFxyCurve[this.groupPredictFlow.currentF];
if (!powerCurve?.y?.length || !flowCurve?.y?.length) return 0;
const { peakIndex } = this.calcEfficiencyCurve(powerCurve, flowCurve);
const yMin = this.groupPredictFlow.currentFxyYMin;
const yMax = this.groupPredictFlow.currentFxyYMax;
if (yMax <= yMin) return 0;
return (flowCurve.y[peakIndex] - yMin) / (yMax - yMin);
}
// Function to predict control value for a desired flow // Function to predict control value for a desired flow
calcCtrl(x) { calcCtrl(x) {
if(this.hasCurve) { if(this.hasCurve) {

View File

@@ -0,0 +1,164 @@
// Reproducer: pump's state machine deadlocks in 'accelerating' under
// rapid setpoint retargeting.
//
// The demo flow drives MGC to call `abortActiveMovements` on every
// handleInput. If a movement aborts mid-flight, state.moveTo's catch
// block keeps the FSM in 'accelerating' (avoids a bounce loop). Any
// NEXT setpoint then hits state.moveTo's early-return at the top:
//
// if (this.stateManager.getCurrentState() !== "operational") {
// this.delayedMove = targetPosition;
// return; // ← never moves
// }
//
// `delayedMove` only fires from the SUCCESS branch of an active
// moveTo, which can't run because state is stuck. Result: pump's
// currentPosition freezes; ctrl.predicted keeps updating (set inside
// calcCtrl regardless of whether setpoint actually moves) so the
// dashboard shows non-zero ctrl% but the editor badge stays at 0.
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { POSITIONS } = require('generalFunctions');
const stateConfig = {
general: { logging: { enabled: false, logLevel: 'error' } },
state: { current: 'idle' },
movement: { mode: 'staticspeed', speed: 10, maxSpeed: 100, interval: 50 },
// Match demo's slow ramp.
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
};
function machineConfig() {
return {
general: { id: 'p1', name: 'p1', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal',
model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
allowedSources: { auto: ['parent', 'GUI'] },
},
sequences: {
startup: ['starting', 'warmingup', 'operational'],
shutdown: ['stopping', 'coolingdown', 'idle'],
emergencystop: ['emergencystop', 'off'],
},
};
}
function makeMachineOperational() {
const m = new Machine(machineConfig(), stateConfig);
m.updateMeasuredPressure(0, 'upstream',
{ timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: 'up-1' });
m.updateMeasuredPressure(1100, 'downstream',
{ timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: 'dn-1' });
return m;
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
test('parking deadlock: state stuck in accelerating swallows new setpoints', async () => {
// Direct reproducer of state.moveTo's early-return path. Force the
// FSM into 'accelerating' (the post-abort residue), then issue a new
// setpoint. The early-return at state.js:68 saves delayedMove and
// returns; delayedMove never fires because nothing transitions back
// to operational.
const m = makeMachineOperational();
await m.handleInput('parent', 'execsequence', 'startup');
for (let i = 0; i < 50 && m.state.getCurrentState() !== 'operational'; i++) await sleep(20);
assert.equal(m.state.getCurrentState(), 'operational');
// Force state to 'accelerating' (mimic the post-abort residue) by
// poking the underlying stateManager directly. This bypasses the
// race conditions and isolates the early-return branch.
await m.state.stateManager.transitionTo('accelerating');
assert.equal(m.state.getCurrentState(), 'accelerating');
const positionBefore = m.state.getCurrentPosition();
// Issue a fresh setpoint (what MGC's optimalControl would do).
await m.handleInput('parent', 'flowmovement', 200);
await sleep(800); // generous — at speed=10 u/s, 8 units in 0.8s.
const positionAfter = m.state.getCurrentPosition();
const stateFinal = m.state.getCurrentState();
console.log({
positionBefore, positionAfter,
stateFinal,
delayedMove: m.state.delayedMove,
delta: (positionAfter - positionBefore).toFixed(3),
});
assert.ok(positionAfter - positionBefore > 1,
`[BUG] currentPosition stuck at ${positionBefore.toFixed(2)} — moveTo's early-return at state.js:68 swallowed the setpoint. ` +
`delayedMove=${m.state.delayedMove} state=${stateFinal}`);
});
test('chain deadlock: aborted move + new setpoint freezes position (race-condition path)', async () => {
// Deterministic reproducer of the deadlock the user observed live in
// Node-RED. Key invariant being asserted: AFTER a routine abort, a
// subsequent setpoint MUST eventually move the pump toward the new
// target. Today it freezes because state.moveTo's early-return at
// the top stores the target in `delayedMove` but `delayedMove` only
// fires from inside an active moveTo's success branch — and there
// is none, since state stays in 'accelerating'.
const m = makeMachineOperational();
await m.handleInput('parent', 'execsequence', 'startup');
for (let i = 0; i < 50 && m.state.getCurrentState() !== 'operational'; i++) await sleep(20);
assert.equal(m.state.getCurrentState(), 'operational');
// Step 1: kick off a long traversal to position 80. Speed=10, so this
// takes ~8 s. We need it to be reliably in 'accelerating' when we abort.
m.setpoint(80); // not awaited
// movementManager interval is 50ms; wait two ticks so position has
// demonstrably advanced and state is firmly in 'accelerating'.
await sleep(150);
assert.equal(m.state.getCurrentState(), 'accelerating',
`precondition: pump should be accelerating mid-traversal; got ${m.state.getCurrentState()}`);
const positionDuringMove = m.state.getCurrentPosition();
assert.ok(positionDuringMove > 0 && positionDuringMove < 80,
`precondition: pump should be mid-traversal, got ${positionDuringMove}`);
// Step 2: routine abort, exactly what MGC's abortActiveMovements does.
m.abortMovement('routine retarget');
// Wait for the abort signal to propagate through the setInterval.
await sleep(120);
const stateAfterAbort = m.state.getCurrentState();
const positionAfterAbort = m.state.getCurrentPosition();
// Step 3: a fresh setpoint — what MGC's optimalControl issues next.
// Use a target DIFFERENT from current position so the early-return
// `targetPosition === currentPosition` doesn't apply.
await m.handleInput('parent', 'flowmovement', 200); // m³/h → distinct ctrl%
// Give it half a second, plenty of time for movement to advance at
// speed=10 u/s if it actually proceeds.
await sleep(500);
const stateFinal = m.state.getCurrentState();
const positionFinal = m.state.getCurrentPosition();
console.log({
positionDuringMove,
stateAfterAbort, positionAfterAbort,
stateFinal, positionFinal,
delayedMove: m.state?.delayedMove,
delta: (positionFinal - positionAfterAbort).toFixed(3),
});
// The bug: position stays parked exactly where the abort left it.
// Either the FSM is still in 'accelerating' (so moveTo's top-level
// early-return stored the new setpoint in delayedMove and bailed), or
// both — state stuck AND delayedMove holding the new target. After
// the fix, position should advance toward the new setpoint.
assert.ok(positionFinal - positionAfterAbort > 1,
`[BUG] currentPosition frozen at ${positionAfterAbort.toFixed(2)} — moveTo's early-return swallowed the new setpoint, ` +
`delayedMove=${m.state?.delayedMove}, finalState=${stateFinal}`);
});

View File

@@ -70,3 +70,77 @@ test('exitmaintenance requires mode with exitmaintenance action allowed', async
await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance'); await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance');
assert.equal(machine.state.getCurrentState(), 'idle'); assert.equal(machine.state.getCurrentState(), 'idle');
}); });
test('shutdown clears delayedMove synchronously, before the abort/await path runs', async () => {
// Regression: when MGC parks a setpoint in state.delayedMove during a
// dead-zone keep-alive, then PS commands shutdown via turnOffAllMachines,
// the shutdown's interruptible-abort path triggers transitionToState
// ('operational'), which auto-picks up delayedMove and re-starts the
// pump. Pump bounces accelerating ↔ decelerating forever and the
// shutdown sequence never reaches idle. Observed live in the
// pumpingstation-complete-example demo: basin drained past stopLevel
// with one pump stuck at minimum flow.
//
// Fix: executeSequence clears state.delayedMove for shutdown/emergencystop
// BEFORE the abort+await path. Asserting synchronously (race the first
// microtask) is the precise behavioural check — without the fix, the
// auto-pickup could still re-engage the pump on the way to idle even if
// the value is null after the call returns.
const slowMove = makeStateConfig({
movement: { mode: 'staticspeed', speed: 50, maxSpeed: 100, interval: 10 },
});
const machine = new Machine(makeMachineConfig(), slowMove);
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
machine.setpoint(80);
await new Promise((r) => setTimeout(r, 50));
assert.equal(machine.state.getCurrentState(), 'accelerating');
machine.state.delayedMove = 75;
// Kick off the shutdown but do not await — capture state before the
// abort path's await yields.
const shutdownPromise = machine.handleInput('parent', 'execSequence', 'shutdown');
// Yield once to allow the synchronous prelude of executeSequence to run
// (lookup, lowercase, the new delayedMove=null assignment) without
// letting any await resolve.
await Promise.resolve();
assert.equal(machine.state.delayedMove, null,
'delayedMove must be cleared synchronously by the shutdown prelude — otherwise the abort path will auto-pick it up');
await shutdownPromise;
assert.equal(machine.state.getCurrentState(), 'idle');
});
test('emergencystop also clears queued delayedMove', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
await machine.handleInput('parent', 'execMovement', 30);
machine.state.delayedMove = 60;
await machine.handleInput('parent', 'execSequence', 'emergencystop');
assert.equal(machine.state.delayedMove, null,
'emergency-stop must clear delayedMove');
});
test('startup does NOT clear delayedMove (only shutdown/emergencystop does)', async () => {
// delayedMove serves a legitimate purpose for non-stop sequences — e.g.
// setpoints arriving while the pump is in 'starting' get queued and
// auto-picked-up when state lands in 'operational'. The fix must be
// narrowly scoped to interruptible (stop) sequences.
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.state.delayedMove = 42;
// Re-running startup from operational is a no-op for state, but the
// delayedMove must still be there afterwards for the auto-pickup to fire.
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.delayedMove, 42,
'non-stop sequences must preserve delayedMove for the auto-pickup');
});