fix: production hardening — safety fixes, prediction accuracy, test coverage

Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close

Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null

Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-07 13:41:00 +02:00
parent ea33b3bba3
commit 07af7cef40
10 changed files with 786 additions and 32 deletions

View File

@@ -254,10 +254,11 @@ class nodeClass {
* Start the periodic tick loop.
*/
_startTickLoop() {
setTimeout(() => {
this._startupTimeout = setTimeout(() => {
this._startupTimeout = null;
this._tickInterval = setInterval(() => this._tick(), 1000);
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
// Update node status on nodered screen every second
this._statusInterval = setInterval(() => {
const status = this._updateNodeStatus();
this.node.status(status);
@@ -284,15 +285,13 @@ class nodeClass {
* Attach the node's input handler, routing control messages to the class.
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
/* Update to complete event based node by putting the tick function after an input event */
this.node.on('input', async (msg, send, done) => {
const m = this.source;
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
try {
switch(msg.topic) {
case 'registerChild': {
// Register this node as a child of the parent node
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
@@ -307,22 +306,22 @@ class nodeClass {
break;
case 'execSequence': {
const { source, action, parameter } = msg.payload;
m.handleInput(source, action, parameter);
await m.handleInput(source, action, parameter);
break;
}
case 'execMovement': {
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
m.handleInput(mvSource, mvAction, Number(setpoint));
await m.handleInput(mvSource, mvAction, Number(setpoint));
break;
}
case 'flowMovement': {
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
m.handleInput(fmSource, fmAction, Number(fmSetpoint));
await m.handleInput(fmSource, fmAction, Number(fmSetpoint));
break;
}
case 'emergencystop': {
const { source: esSource, action: esAction } = msg.payload;
m.handleInput(esSource, esAction);
await m.handleInput(esSource, esAction);
break;
}
case 'simulateMeasurement':
@@ -403,8 +402,28 @@ class nodeClass {
*/
_attachCloseHandler() {
this.node.on('close', (done) => {
clearTimeout(this._startupTimeout);
clearInterval(this._tickInterval);
clearInterval(this._statusInterval);
// Clean up child measurement listeners
const m = this.source;
if (m?.childMeasurementListeners) {
for (const [, entry] of m.childMeasurementListeners) {
if (typeof entry.emitter?.off === 'function') {
entry.emitter.off(entry.eventName, entry.handler);
} else if (typeof entry.emitter?.removeListener === 'function') {
entry.emitter.removeListener(entry.eventName, entry.handler);
}
}
m.childMeasurementListeners.clear();
}
// Clean up state emitter listeners
if (m?.state?.emitter) {
m.state.emitter.removeAllListeners();
}
if (typeof done === 'function') done();
});
}