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:
@@ -343,6 +343,38 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
||||
|
||||
//---------------- END child stuff -------------//
|
||||
|
||||
/**
|
||||
* Wait until the state machine reaches 'operational', or until a timeout.
|
||||
* Used after an aborted movement to ensure subsequent sequence transitions
|
||||
* (stopping/emergencystop) will be accepted by the FSM.
|
||||
* @param {number} timeoutMs - maximum time to wait in milliseconds
|
||||
* @returns {Promise<string>} the state observed when the wait ends
|
||||
*/
|
||||
async _waitForOperational(timeoutMs = 2000) {
|
||||
if (this.state.getCurrentState() === "operational") {
|
||||
return "operational";
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
let done = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
this.state.emitter.off("stateChange", onChange);
|
||||
resolve(this.state.getCurrentState());
|
||||
}, timeoutMs);
|
||||
const onChange = (newState) => {
|
||||
if (done) return;
|
||||
if (newState === "operational") {
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
this.state.emitter.off("stateChange", onChange);
|
||||
resolve("operational");
|
||||
}
|
||||
};
|
||||
this.state.emitter.on("stateChange", onChange);
|
||||
});
|
||||
}
|
||||
|
||||
_buildUnitPolicy(config) {
|
||||
const flowOutputUnit = this._resolveUnitOrFallback(
|
||||
config?.general?.unit,
|
||||
@@ -828,6 +860,12 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
||||
// -------- Sequence Handlers -------- //
|
||||
async executeSequence(sequenceName) {
|
||||
|
||||
// Defensive: sequence keys in the config are lowercase. Accept any casing
|
||||
// from callers (parent orchestrators, tests, legacy flows) and normalize.
|
||||
if (typeof sequenceName === 'string') {
|
||||
sequenceName = sequenceName.toLowerCase();
|
||||
}
|
||||
|
||||
const sequence = this.config.sequences[sequenceName];
|
||||
|
||||
if (!sequence || sequence.size === 0) {
|
||||
@@ -835,6 +873,20 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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`);
|
||||
await this._waitForOperational(2000);
|
||||
}
|
||||
|
||||
if (this.state.getCurrentState() == "operational" && sequenceName == "shutdown") {
|
||||
this.logger.info(`Machine will ramp down to position 0 before performing ${sequenceName} sequence`);
|
||||
await this.setpoint(0);
|
||||
@@ -1017,7 +1069,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
||||
|
||||
// Only downstream => use it, warn that it's partial
|
||||
if (downstreamPressure != null) {
|
||||
this.logger.warn(`Using downstream pressure only for prediction: ${downstreamPressure} This is less acurate!!`);
|
||||
this.logger.warn(`Using downstream pressure only for prediction: ${downstreamPressure}. Prediction accuracy is degraded; inject upstream pressure too.`);
|
||||
this.predictFlow.fDimension = downstreamPressure;
|
||||
this.predictPower.fDimension = downstreamPressure;
|
||||
this.predictCtrl.fDimension = downstreamPressure;
|
||||
@@ -1032,7 +1084,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
||||
|
||||
// Only upstream => use it, warn that it's partial
|
||||
if (upstreamPressure != null) {
|
||||
this.logger.warn(`Using upstream pressure only for prediction: ${upstreamPressure} This is less acurate!!`);
|
||||
this.logger.warn(`Using upstream pressure only for prediction: ${upstreamPressure}. Prediction accuracy is degraded; inject downstream pressure too.`);
|
||||
this.predictFlow.fDimension = upstreamPressure;
|
||||
this.predictPower.fDimension = upstreamPressure;
|
||||
this.predictCtrl.fDimension = upstreamPressure;
|
||||
|
||||
Reference in New Issue
Block a user