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

@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig, makeChildMeasurement } = require('../helpers/factories');
test('childMeasurementListeners are cleared and state emitter cleaned on simulated close', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
// Register a child measurement — this adds listeners
const child = makeChildMeasurement({ id: 'pt-1', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
machine.registerChild(child, 'measurement');
assert.ok(machine.childMeasurementListeners.size > 0, 'Should have listeners after registration');
const stateEmitterListenerCount = machine.state.emitter.listenerCount('positionChange') +
machine.state.emitter.listenerCount('stateChange');
assert.ok(stateEmitterListenerCount > 0, 'State emitter should have listeners');
// Simulate the cleanup that nodeClass close handler does
for (const [, entry] of machine.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);
}
}
machine.childMeasurementListeners.clear();
machine.state.emitter.removeAllListeners();
assert.equal(machine.childMeasurementListeners.size, 0, 'Listeners map should be empty after cleanup');
assert.equal(machine.state.emitter.listenerCount('positionChange'), 0);
assert.equal(machine.state.emitter.listenerCount('stateChange'), 0);
});
test('re-registration does not accumulate listeners', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
const child = makeChildMeasurement({ id: 'pt-1', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
// Register 3 times
machine.registerChild(child, 'measurement');
machine.registerChild(child, 'measurement');
machine.registerChild(child, 'measurement');
// Should only have 1 listener entry per child+event combo
const eventName = 'pressure.measured.downstream';
const listenerCount = child.measurements.emitter.listenerCount(eventName);
assert.equal(listenerCount, 1, `Should have exactly 1 listener, got ${listenerCount}`);
});
test('virtual pressure children have their listeners managed', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
// Virtual children are created in constructor — verify listeners exist
const upstreamChild = machine.virtualPressureChildren.upstream;
const downstreamChild = machine.virtualPressureChildren.downstream;
assert.ok(upstreamChild, 'Upstream virtual child should exist');
assert.ok(downstreamChild, 'Downstream virtual child should exist');
assert.ok(upstreamChild.measurements, 'Upstream should have measurements container');
assert.ok(downstreamChild.measurements, 'Downstream should have measurements container');
});

View File

@@ -0,0 +1,132 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
test('calcEfficiency with zero power and flow does not produce efficiency value', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.measurements.type('pressure').variant('measured').position('downstream').value(1000, Date.now(), 'mbar');
machine.measurements.type('pressure').variant('measured').position('upstream').value(800, Date.now(), 'mbar');
machine.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), 'm3/h');
machine.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), 'kW');
// Should not throw
assert.doesNotThrow(() => machine.calcEfficiency(0, 0, 'predicted'));
});
test('calcEfficiency with negative power does not produce corrupt efficiency', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.measurements.type('pressure').variant('measured').position('downstream').value(1000, Date.now(), 'mbar');
machine.measurements.type('pressure').variant('measured').position('upstream').value(800, Date.now(), 'mbar');
machine.measurements.type('flow').variant('predicted').position('atEquipment').value(100, Date.now(), 'm3/h');
machine.measurements.type('power').variant('predicted').position('atEquipment').value(-5, Date.now(), 'kW');
// Should not crash or produce negative efficiency
assert.doesNotThrow(() => machine.calcEfficiency(-5, 100, 'predicted'));
const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
// Efficiency should not have been updated with negative power (guard: power > 0)
assert.ok(eff === undefined || eff === null || eff >= 0, 'Efficiency should not be negative');
});
test('calcCog returns safe defaults when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const result = machine.calcCog();
assert.equal(result.cog, 0);
assert.equal(result.cogIndex, 0);
assert.equal(result.NCog, 0);
assert.equal(result.minEfficiency, 0);
});
test('getCurrentCurves returns empty arrays when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const { powerCurve, flowCurve } = machine.getCurrentCurves();
assert.deepEqual(powerCurve, { x: [], y: [] });
assert.deepEqual(flowCurve, { x: [], y: [] });
});
test('getCompleteCurve returns null when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const { powerCurve, flowCurve } = machine.getCompleteCurve();
assert.equal(powerCurve, null);
assert.equal(flowCurve, null);
});
test('calcFlow returns 0 when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig({ state: { current: 'operational' } })
);
const flow = machine.calcFlow(50);
assert.equal(flow, 0);
});
test('calcPower returns 0 when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig({ state: { current: 'operational' } })
);
const power = machine.calcPower(50);
assert.equal(power, 0);
});
test('inputFlowCalcPower returns 0 when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig({ state: { current: 'operational' } })
);
const power = machine.inputFlowCalcPower(100);
assert.equal(power, 0);
});
test('getMeasuredPressure returns 0 when no curve data available', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
const pressure = machine.getMeasuredPressure();
assert.equal(pressure, 0);
});
test('updateCurve bootstraps predictors when they were null', () => {
const machine = new Machine(
makeMachineConfig({ asset: { model: null } }),
makeStateConfig()
);
assert.equal(machine.hasCurve, false);
assert.equal(machine.predictFlow, null);
// Load a real curve into a machine that started without one
const { loadCurve } = require('generalFunctions');
const realCurve = loadCurve('hidrostal-H05K-S03R');
assert.doesNotThrow(() => machine.updateCurve(realCurve));
assert.equal(machine.hasCurve, true);
assert.ok(machine.predictFlow !== null);
assert.ok(machine.predictPower !== null);
assert.ok(machine.predictCtrl !== null);
});

View File

@@ -0,0 +1,121 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
test('getOutput contains all required fields in idle state', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
const output = machine.getOutput();
// Core state fields
assert.equal(output.state, 'idle');
assert.ok('runtime' in output);
assert.ok('ctrl' in output);
assert.ok('moveTimeleft' in output);
assert.ok('mode' in output);
assert.ok('maintenanceTime' in output);
// Efficiency fields
assert.ok('cog' in output);
assert.ok('NCog' in output);
assert.ok('NCogPercent' in output);
assert.ok('effDistFromPeak' in output);
assert.ok('effRelDistFromPeak' in output);
// Prediction health fields
assert.ok('predictionQuality' in output);
assert.ok('predictionConfidence' in output);
assert.ok('predictionPressureSource' in output);
assert.ok('predictionFlags' in output);
// Pressure drift fields
assert.ok('pressureDriftLevel' in output);
assert.ok('pressureDriftSource' in output);
assert.ok('pressureDriftFlags' in output);
});
test('getOutput flow drift fields appear after sufficient measured flow samples', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
await machine.handleInput('parent', 'execSequence', 'startup');
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
await machine.handleInput('parent', 'execMovement', 50);
// Provide multiple measured flow samples to trigger valid drift assessment
const baseTime = Date.now();
for (let i = 0; i < 12; i++) {
machine.updateMeasuredFlow(100 + i, 'downstream', {
timestamp: baseTime + (i * 1000),
unit: 'm3/h',
childId: 'flow-sensor',
childName: 'FT-1',
});
}
const output = machine.getOutput();
// Drift fields should appear once enough samples provide a valid assessment
if ('flowNrmse' in output) {
assert.ok(typeof output.flowNrmse === 'number');
assert.ok('flowDriftValid' in output);
}
// At minimum, prediction health fields should always be present
assert.ok('predictionQuality' in output);
assert.ok('predictionConfidence' in output);
});
test('getOutput prediction confidence is 0 in non-operational state', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
const output = machine.getOutput();
assert.equal(output.predictionConfidence, 0);
});
test('getOutput prediction confidence reflects differential pressure', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
// Differential pressure → high confidence
machine.updateMeasuredPressure(800, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
machine.updateMeasuredPressure(1200, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
const output = machine.getOutput();
assert.ok(output.predictionConfidence >= 0.8, `Confidence ${output.predictionConfidence} should be >= 0.8 with differential pressure`);
assert.equal(output.predictionPressureSource, 'differential');
});
test('getOutput values are in configured output units not canonical', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
machine.updatePosition();
const output = machine.getOutput();
// Flow keys should contain values in m3/h (configured), not m3/s (canonical)
// Predicted flow at minimum pressure should be in a reasonable m3/h range, not ~0.003 m3/s
const flowKey = Object.keys(output).find(k => k.startsWith('flow.predicted.downstream'));
if (flowKey) {
const flowVal = output[flowKey];
assert.ok(typeof flowVal === 'number', 'Flow output should be a number');
// m3/h values are typically 0-300, m3/s values are 0-0.08
// If in canonical units it would be very small
if (flowVal > 0) {
assert.ok(flowVal > 0.1, `Flow value ${flowVal} looks like canonical m3/s, should be m3/h`);
}
}
});
test('getOutput NCogPercent is correctly derived from NCog', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
machine.updatePosition();
const output = machine.getOutput();
const expected = Math.round(output.NCog * 100 * 100) / 100;
assert.equal(output.NCogPercent, expected, 'NCogPercent should be NCog * 100, rounded to 2 decimals');
});