Compare commits
7 Commits
c464b66b27
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f9150e160 | ||
|
|
5a8113a9d1 | ||
|
|
ecd5a4864b | ||
|
|
399e0a8c01 | ||
|
|
11d196f363 | ||
|
|
510a4233e6 | ||
|
|
26e253d030 |
@@ -17,6 +17,7 @@
|
||||
category: "EVOLV",
|
||||
color: "#86bbdd",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
|
||||
// Define specific properties
|
||||
speed: { value: 1, required: true },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
164
test/integration/abort-deadlock.integration.test.js
Normal file
164
test/integration/abort-deadlock.integration.test.js
Normal 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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user