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:
180
test/integration/curve-prediction.integration.test.js
Normal file
180
test/integration/curve-prediction.integration.test.js
Normal file
@@ -0,0 +1,180 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
const { loadCurve } = require('generalFunctions');
|
||||
|
||||
/**
|
||||
* Prediction benchmarks across all rotatingMachine curves currently shipped
|
||||
* with generalFunctions. This guards the curve-backed prediction path against
|
||||
* regressions in the loader, the reverse-nq inversion, and the pressure
|
||||
* slicing logic — across machines of very different sizes.
|
||||
*
|
||||
* Ranges are derived from the curve data itself (loaded at test time) plus
|
||||
* physical sanity properties (monotonicity in ctrl, inverse-monotonicity in
|
||||
* pressure for flow, non-negative power, curve-backed CoG non-zero).
|
||||
*/
|
||||
|
||||
// Curves the node is expected to support. Add new entries here as soon as a
|
||||
// new curve file lands in generalFunctions/datasets/assetData/curves/.
|
||||
const PUMP_CURVES = [
|
||||
{ model: 'hidrostal-H05K-S03R', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
|
||||
{ model: 'hidrostal-C5-D03R-SHN1', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
|
||||
];
|
||||
|
||||
function curveExtents(curveData) {
|
||||
const pressures = Object.keys(curveData.nq)
|
||||
.filter((k) => /^-?\d+$/.test(k))
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const slice = (set, p) => curveData[set][String(p)];
|
||||
const lowP = pressures[0];
|
||||
const midP = pressures[Math.floor(pressures.length / 2)];
|
||||
const highP = pressures[pressures.length - 1];
|
||||
const allFlowY = pressures.flatMap((p) => slice('nq', p).y);
|
||||
const allPowerY = pressures.flatMap((p) => slice('np', p).y);
|
||||
return {
|
||||
pressures,
|
||||
lowP, midP, highP,
|
||||
flowMin: Math.min(...allFlowY), flowMax: Math.max(...allFlowY),
|
||||
powerMin: Math.min(...allPowerY), powerMax: Math.max(...allPowerY),
|
||||
};
|
||||
}
|
||||
|
||||
async function makeRunningMachine({ model, unit }) {
|
||||
const cfg = makeMachineConfig({
|
||||
general: { id: `rm-${model}`, name: model, unit, logging: { enabled: false, logLevel: 'error' } },
|
||||
asset: {
|
||||
supplier: 'hidrostal', category: 'pump', type: 'Centrifugal', model, unit,
|
||||
curveUnits: { pressure: 'mbar', flow: unit, power: 'kW', control: '%' },
|
||||
},
|
||||
});
|
||||
const m = new Machine(cfg, makeStateConfig());
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(m.state.getCurrentState(), 'operational', `${model}: should reach operational`);
|
||||
return m;
|
||||
}
|
||||
|
||||
for (const curve of PUMP_CURVES) {
|
||||
const { model, unit, pUnit, powUnit } = curve;
|
||||
|
||||
test(`[${model}] curve loads and has both nq and np slices`, () => {
|
||||
const raw = loadCurve(model);
|
||||
assert.ok(raw, `loadCurve('${model}') must return data`);
|
||||
assert.ok(raw.nq && Object.keys(raw.nq).length > 0, `${model}: nq has pressure slices`);
|
||||
assert.ok(raw.np && Object.keys(raw.np).length > 0, `${model}: np has pressure slices`);
|
||||
// Same pressure slices in both
|
||||
const nqP = Object.keys(raw.nq).filter((k) => /^-?\d+$/.test(k)).sort();
|
||||
const npP = Object.keys(raw.np).filter((k) => /^-?\d+$/.test(k)).sort();
|
||||
assert.deepEqual(nqP, npP, `${model}: nq and np must share pressure slices`);
|
||||
});
|
||||
|
||||
test(`[${model}] predicted flow and power at mid-pressure, mid-ctrl are finite and in-range`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
|
||||
// Feed differential pressure = midP (upstream 0, downstream = midP)
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
|
||||
await m.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||
const power = m.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue(powUnit);
|
||||
|
||||
assert.ok(Number.isFinite(flow), `${model}: flow must be finite`);
|
||||
assert.ok(Number.isFinite(power), `${model}: power must be finite`);
|
||||
// Flow can be negative at the low-end slice of some curves due to spline extrapolation,
|
||||
// but at mid-pressure mid-ctrl it must be positive.
|
||||
assert.ok(flow > 0, `${model}: flow ${flow} ${unit} must be > 0 at mid-pressure mid-ctrl`);
|
||||
assert.ok(power >= 0, `${model}: power ${power} ${powUnit} must be >= 0`);
|
||||
// Loose bracket against curve envelope (2x margin accommodates interpolation overshoot)
|
||||
assert.ok(flow <= ext.flowMax * 2, `${model}: flow ${flow} exceeds curve envelope ${ext.flowMax}`);
|
||||
assert.ok(power <= ext.powerMax * 2, `${model}: power ${power} exceeds curve envelope ${ext.powerMax}`);
|
||||
});
|
||||
|
||||
test(`[${model}] flow is monotonically non-decreasing in ctrl at fixed pressure`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
|
||||
const samples = [];
|
||||
for (const setpoint of [10, 30, 50, 70, 90]) {
|
||||
await m.handleInput('parent', 'execMovement', setpoint);
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||
samples.push({ setpoint, flow });
|
||||
}
|
||||
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
// Allow 1% tolerance for spline wiggle but reject any clear regression.
|
||||
assert.ok(
|
||||
samples[i].flow >= samples[i - 1].flow - Math.abs(samples[i - 1].flow) * 0.01,
|
||||
`${model}: flow not monotonic across ctrl sweep: ${JSON.stringify(samples)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`[${model}] flow decreases (or stays level) when pressure rises at fixed ctrl`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
|
||||
const samples = [];
|
||||
for (const p of [ext.lowP, ext.midP, ext.highP]) {
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(p, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
await m.handleInput('parent', 'execMovement', 60);
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||
samples.push({ pressure: p, flow });
|
||||
}
|
||||
|
||||
// Highest pressure must not exceed lowest pressure flow by more than 1%.
|
||||
// (Centrifugal pump: head up -> flow down at a given speed.)
|
||||
const first = samples[0].flow;
|
||||
const last = samples[samples.length - 1].flow;
|
||||
assert.ok(
|
||||
last <= first * 1.01,
|
||||
`${model}: flow at p=${samples[samples.length - 1].pressure} (${last}) exceeds flow at p=${samples[0].pressure} (${first}); samples=${JSON.stringify(samples)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`[${model}] cog and NCog are computed and finite after an operational move`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
await m.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
assert.ok(Number.isFinite(m.cog), `${model}: cog must be finite, got ${m.cog}`);
|
||||
assert.ok(Number.isFinite(m.NCog), `${model}: NCog must be finite, got ${m.NCog}`);
|
||||
// CoG is a controller-% location of peak efficiency; must fall inside the ctrl range of the curve.
|
||||
assert.ok(m.cog >= 0 && m.cog <= 100, `${model}: cog=${m.cog} must be within [0,100]`);
|
||||
});
|
||||
|
||||
test(`[${model}] reverse predictor (ctrl for requested flow) round-trips within tolerance`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
|
||||
// Move to a known controller position and read the flow.
|
||||
await m.handleInput('parent', 'execMovement', 60);
|
||||
const observedFlow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||
assert.ok(observedFlow > 0, `${model}: need non-zero flow to invert`);
|
||||
|
||||
// Convert flow back to ctrl via calcCtrl (uses reversed nq internally) —
|
||||
// note calcCtrl takes canonical flow (m3/s), so convert.
|
||||
const canonicalFlow = observedFlow / 3600; // m3/h -> m3/s
|
||||
const predictedCtrl = m.calcCtrl(canonicalFlow);
|
||||
assert.ok(
|
||||
Number.isFinite(predictedCtrl) && Math.abs(predictedCtrl - 60) <= 10,
|
||||
`${model}: reverse predictor ctrl=${predictedCtrl} should be within 10 of 60 for flow=${observedFlow}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
93
test/integration/interruptible-movement.integration.test.js
Normal file
93
test/integration/interruptible-movement.integration.test.js
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user