fix: interruptible shutdown/emergencystop + dual-curve test coverage

Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
  orchestrators that use 'emergencyStop' (capital S) route correctly to
  the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
  not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
  in accelerating/decelerating, the active movement is aborted via
  state.abortCurrentMovement() and the sequence waits (up to 2s) for the
  FSM to return to 'operational' before proceeding. New helper
  _waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
  actionable.

Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
  shutdown during accelerating -> idle; emergencystop during accelerating
  -> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
  parametrized across both shipped pump curves (hidrostal-H05K-S03R and
  hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
  sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
  finiteness, and reverse-predictor round-trip.

E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
  benchmark that deploys one rotatingMachine per curve and runs a per-pump
  (pressure x ctrl) sweep inside each curve's envelope. Reports envelope
  compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
  pressure below the curve's minimum slice extrapolates wildly
  (defended by upstream measurement-node clamping in production).

UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
  Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
  help panel with a topic reference, port documentation, state diagram,
  and prediction rules.

Docs:
- README.md rewritten (was a single line) with install, quick start,
  topic/port reference, state machine, predictions, testing, production
  status.

Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-13 13:21:48 +02:00
parent 07af7cef40
commit 17b88870bb
7 changed files with 984 additions and 13 deletions

View File

@@ -0,0 +1,93 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
/**
* Regression tests for the FSM interruptible-movement fix (2026-04-13).
*
* Before the fix, `executeSequence("shutdown")` was silently rejected by the
* state manager if the machine was mid-move (accelerating/decelerating),
* because allowedTransitions for those states only permits returning to
* `operational` or `emergencystop`. Operators pressing Stop during a ramp
* would see the transition error-logged but no actual stop.
*
* The fix aborts the active movement, waits for the FSM to return to
* `operational`, then runs the normal shutdown / emergency-stop sequence.
*/
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function makeSlowMoveMachine() {
// Slow movement so the test can reliably interrupt during accelerating.
// speed=20%/s, interval=10ms -> 80% setpoint takes ~4s of real movement.
return new Machine(
makeMachineConfig(),
makeStateConfig({
movement: { mode: 'staticspeed', speed: 20, maxSpeed: 1000, interval: 10 },
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
})
);
}
test('shutdown during accelerating aborts the move and reaches idle', async () => {
const machine = makeSlowMoveMachine();
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
machine.updateMeasuredPressure(200, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
// Fire a setpoint that needs ~4 seconds. Do NOT await it.
const movePromise = machine.handleInput('parent', 'execMovement', 80);
// Wait a moment for the FSM to enter accelerating.
await sleep(100);
assert.equal(machine.state.getCurrentState(), 'accelerating');
// Issue shutdown while the move is still accelerating.
await machine.handleInput('GUI', 'execSequence', 'shutdown');
// Let the aborted move unwind.
await movePromise.catch(() => {});
assert.equal(
machine.state.getCurrentState(),
'idle',
'shutdown issued mid-ramp must still drive FSM back to idle',
);
});
test('emergency stop during accelerating reaches off', async () => {
const machine = makeSlowMoveMachine();
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
const movePromise = machine.handleInput('parent', 'execMovement', 80);
await sleep(100);
assert.equal(machine.state.getCurrentState(), 'accelerating');
await machine.handleInput('GUI', 'emergencystop');
await movePromise.catch(() => {});
assert.equal(
machine.state.getCurrentState(),
'off',
'emergency stop issued mid-ramp must still drive FSM to off',
);
});
test('executeSequence accepts mixed-case sequence names', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
assert.equal(machine.state.getCurrentState(), 'operational');
// Parent orchestrators (e.g. machineGroupControl) use "emergencyStop" with
// a capital S in their configs. The sequence key in rotatingMachine.json
// is lowercase. Normalization must bridge that gap without a warn.
await machine.executeSequence('EmergencyStop');
assert.equal(machine.state.getCurrentState(), 'off');
});