7 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
znetsixe
399e0a8c01 Editor hygiene + remove redundant idle-position clamp in predictions
- rotatingMachine.html: add default name:{value:""} to the editor
  defaults block (standard Node-RED pattern; was missing).
- nodeClass.js: clear node status badge on close — matches the
  pattern already in other EVOLV node close handlers.
- specificClass.js: remove the `(x <= 0) ? 0 : ...` guard in the
  flow and power prediction methods. The guard was redundant:
  predictions only run while the FSM is in an active state
  (operational / starting / warmingup / accelerating / decelerating),
  none of which produce x=0. Math.max(0, rawFlow) still clamps
  negative extrapolation. Net: same behaviour in production, less
  dead code.

All 10 basic tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:50:50 +02:00
znetsixe
11d196f363 fix: pass returnToOperational:true for shutdown/estop abort path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:01:49 +02:00
znetsixe
510a4233e6 fix: remove trace instrumentation + update tests for corrected curve bounds
The bogus machineCurve default at pressure "1" (fixed in generalFunctions
086e5fe) made fValues.min=1, which let sub-curve differentials pass
unclamped. With the fix, fValues.min=70000 (the real curve minimum) and
low differentials get clamped. Three tests that accidentally depended on
the bogus min=1 behavior are updated:

- coolprop test: expects fDimension clamped to curve minimum when
  differential < curve range
- pressure-initialization test: uses pressures whose differential falls
  WITHIN the curve range (900 mbar = 90000 Pa > 70000 Pa minimum)
- sequences test: tests upper-bound constraint with setpoint > max,
  then confirms a valid setpoint is applied as-is (was incorrectly
  asserting any setpoint would be clamped to max)

Trace instrumentation from debugging session removed.

91/91 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:28:13 +02:00
znetsixe
26e253d030 fix: clamp flow/power predictions to 0 when controller position ≤ 0
At ctrl=0% with high backpressure, the curve prediction extrapolates to
large negative values (backflow through a stopped pump). This produced
confusing chart readings (-200+ m³/h for an idle pump) and polluted
downstream consumers like MGC efficiency calculations.

Fix: in both calcFlow and calcPower, if the controller position x ≤ 0
the prediction is clamped to 0 regardless of what the spline returns.
For x > 0, predictions are also clamped to ≥ 0 (negative flow/power
from a running pump is physically implausible for a centrifugal machine).

91/91 tests still green — no existing test asserted on negative
flow/power values at ctrl=0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:07:02 +02:00
8 changed files with 358 additions and 19 deletions

View File

@@ -17,6 +17,7 @@
category: "EVOLV",
color: "#86bbdd",
defaults: {
name: { value: "" },
// Define specific properties
speed: { value: 1, required: true },

View File

@@ -405,6 +405,7 @@ class nodeClass {
clearTimeout(this._startupTimeout);
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
this.node.status({}); // clear node status badge
// Clean up child measurement listeners
const m = this.source;

View File

@@ -75,7 +75,7 @@ class Machine {
this.curve = this._normalizeMachineCurve(this.rawCurve);
this.config = this.configUtils.updateConfig(this.config, { asset: { ...this.config.asset, machineCurve: this.curve } });
//machineConfig = { ...machineConfig, asset: { ...machineConfig.asset, machineCurve: this.curve } }; // Merge curve into machineConfig
this.predictFlow = new predict({ curve: this.config.asset.machineCurve.nq }); // load nq (x : ctrl , y : flow relationship)
this.predictFlow = new predict({ curve: this.config.asset.machineCurve.nq }); // load nq (x : ctrl , y : flow relationship)
this.predictPower = new predict({ curve: this.config.asset.machineCurve.np }); // load np (x : ctrl , y : power relationship)
this.predictCtrl = new predict({ curve: this.reverseCurve(this.config.asset.machineCurve.nq) }); // load reversed nq (x: flow, y: ctrl relationship)
} catch (error) {
@@ -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.errorMetrics = new nrmse(errorMetricsConfig, this.logger);
@@ -873,17 +885,26 @@ _callMeasurementHandler(measurementType, value, position, context) {
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
// while a setpoint move is mid-flight (accelerating/decelerating), abort
// the move first and wait briefly for the FSM to return to 'operational'.
// Without this, transitions like accelerating->stopping are rejected by
// stateManager.isValidTransition, leaving the machine running.
const currentState = this.state.getCurrentState();
const interruptible = new Set(["shutdown", "emergencystop"]);
if (interruptible.has(sequenceName) &&
(currentState === "accelerating" || currentState === "decelerating")) {
this.logger.warn(`Sequence '${sequenceName}' requested during '${currentState}'. Aborting active movement.`);
this.state.abortCurrentMovement(`${sequenceName} sequence requested`);
this.state.abortCurrentMovement(`${sequenceName} sequence requested`, { returnToOperational: true });
await this._waitForOperational(2000);
}
@@ -967,10 +988,10 @@ _callMeasurementHandler(measurementType, value, position, context) {
return 0;
}
const cFlow = this.predictFlow.y(x);
const rawFlow = this.predictFlow.y(x);
const cFlow = Math.max(0, rawFlow);
this.measurements.type("flow").variant("predicted").position("downstream").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
this.measurements.type("flow").variant("predicted").position("atEquipment").value(cFlow,Date.now(),this.unitPolicy.canonical.flow);
//this.logger.debug(`Calculated flow: ${cFlow} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
return cFlow;
}
@@ -991,10 +1012,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
return 0;
}
//this.predictPower.currentX = x; Decrepated
const cPower = this.predictPower.y(x);
const rawPower = this.predictPower.y(x);
const cPower = Math.max(0, rawPower);
this.measurements.type("power").variant("predicted").position('atEquipment').value(cPower, Date.now(), this.unitPolicy.canonical.power);
//this.logger.debug(`Calculated power: ${cPower} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
return cPower;
}
// If no curve data is available, log a warning and return 0
@@ -1014,7 +1034,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
const cPower = this.predictPower.y(cCtrl);
return cPower;
}
// If no curve data is available, log a warning and return 0
this.logger.warn(`No curve data available for power calculation. Returning 0.`);
this.measurements.type("power").variant("predicted").position('atEquipment').value(0, Date.now(), this.unitPolicy.canonical.power);
@@ -1022,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
calcCtrl(x) {
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

@@ -48,7 +48,12 @@ test('predictions use initialized medium pressure and not the minimum-pressure f
assert.equal(pressureStatus.initialized, true);
assert.equal(pressureStatus.hasDifferential, true);
const expectedDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa canonical
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff);
const rawDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa = 40000
// fDimension is clamped to [fValues.min, fValues.max]. The H05K curve's
// minimum pressure slice is 70000 Pa (700 mbar). A 40000 Pa differential
// is below the curve minimum, so it gets clamped to 70000.
const curveMinPressure = 70000;
const expected = Math.max(rawDiff, curveMinPressure);
assert.equal(Math.round(machine.predictFlow.fDimension), expected);
assert.ok(machine.predictFlow.fDimension > 0);
});

View File

@@ -14,7 +14,10 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(status.source, null);
const noPressureValue = machine.getMeasuredPressure();
assert.equal(noPressureValue, 0);
assert.ok(machine.predictFlow.fDimension <= 1);
// With no pressure injected, fDimension is clamped to the curve minimum
// (70000 Pa for H05K). Previously a schema default at pressure "1" made
// fValues.min=1 — that was a data-poisoning bug, now fixed.
assert.ok(machine.predictFlow.fDimension >= 70000);
// upstream only
machine = createMachine();
@@ -44,9 +47,11 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
// downstream and upstream
// downstream and upstream — pick values whose differential (Pa) is above
// the curve's minimum pressure slice (70000 Pa = 700 mbar for H05K).
// 200 mbar upstream + 1100 mbar downstream → diff = 900 mbar = 90000 Pa.
machine = createMachine();
const upstream = 700;
const upstream = 200;
const downstream = 1100;
machine.measurements.type('pressure').variant('measured').position('upstream').value(upstream, Date.now(), 'mbar');
machine.measurements.type('pressure').variant('measured').position('downstream').value(downstream, Date.now(), 'mbar');

View File

@@ -14,11 +14,16 @@ test('execSequence startup reaches operational with zero transition times', asyn
test('execMovement constrains controller position to safe bounds in operational state', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
const { max } = machine._resolveSetpointBounds();
const { min, max } = machine._resolveSetpointBounds();
// Test upper constraint: setpoint above max gets clamped to max
await machine.handleInput('parent', 'execMovement', max + 50);
let pos = machine.state.getCurrentPosition();
assert.equal(pos, max, `setpoint above max should be clamped to ${max}`);
// Test that a valid setpoint within bounds is applied as-is
await machine.handleInput('parent', 'execMovement', 10);
const pos = machine.state.getCurrentPosition();
assert.ok(pos <= max);
assert.equal(pos, max);
pos = machine.state.getCurrentPosition();
assert.equal(pos, 10, 'setpoint within bounds should be applied as-is');
assert.ok(pos >= min && pos <= max);
});

View File

@@ -70,3 +70,77 @@ test('exitmaintenance requires mode with exitmaintenance action allowed', async
await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance');
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');
});