diff --git a/rotatingMachine.html b/rotatingMachine.html
index 10fbc90..b15fcb6 100644
--- a/rotatingMachine.html
+++ b/rotatingMachine.html
@@ -67,11 +67,15 @@
oneditprepare: function() {
// wait for the menu scripts to load
+ let menuRetries = 0;
+ const maxMenuRetries = 100; // 5 seconds at 50ms intervals
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
window.EVOLV.nodes.rotatingMachine.initEditor(this);
- } else {
+ } else if (++menuRetries < maxMenuRetries) {
setTimeout(waitForMenuData, 50);
+ } else {
+ console.warn("rotatingMachine: menu scripts failed to load within 5 seconds");
}
};
waitForMenuData();
diff --git a/src/nodeClass.js b/src/nodeClass.js
index 54d5941..c428437 100644
--- a/src/nodeClass.js
+++ b/src/nodeClass.js
@@ -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();
});
}
diff --git a/src/specificClass.js b/src/specificClass.js
index 7dd9b4e..490fd28 100644
--- a/src/specificClass.js
+++ b/src/specificClass.js
@@ -438,7 +438,10 @@ _callMeasurementHandler(measurementType, value, position, context) {
_normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName) {
const normalized = {};
- for (const [pressureKey, pair] of Object.entries(section || {})) {
+ const pressureEntries = Object.entries(section || {});
+ let prevMedianY = null;
+
+ for (const [pressureKey, pair] of pressureEntries) {
const canonicalPressure = this._convertUnitValue(
Number(pressureKey),
fromPressureUnit,
@@ -450,6 +453,21 @@ _callMeasurementHandler(measurementType, value, position, context) {
if (!xArray.length || !yArray.length || xArray.length !== yArray.length) {
throw new Error(`Invalid ${sectionName} section at pressure '${pressureKey}'.`);
}
+
+ // Cross-pressure anomaly detection: flag sudden jumps in median y between adjacent pressure levels
+ const sortedY = [...yArray].sort((a, b) => a - b);
+ const medianY = sortedY[Math.floor(sortedY.length / 2)];
+ if (prevMedianY != null && prevMedianY > 0) {
+ const ratio = medianY / prevMedianY;
+ if (ratio > 3 || ratio < 0.33) {
+ this.logger.warn(
+ `Curve anomaly in ${sectionName} at pressure ${pressureKey}: median y=${medianY.toFixed(2)} ` +
+ `deviates ${(ratio).toFixed(1)}x from adjacent level (${prevMedianY.toFixed(2)}). Check curve data.`
+ );
+ }
+ }
+ prevMedianY = medianY;
+
normalized[String(canonicalPressure)] = {
x: xArray,
y: yArray,
@@ -772,7 +790,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
case "emergencystop":
this.logger.warn(`Emergency stop activated by '${source}'.`);
- return await this.executeSequence("emergencyStop");
+ return await this.executeSequence("emergencystop");
case "statuscheck":
this.logger.info(`Status Check: Mode = '${this.currentMode}', Source = '${source}'.`);
@@ -972,7 +990,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
// returns the best available pressure measurement to use in the prediction calculation
// this will be either the differential pressure, downstream or upstream pressure
getMeasuredPressure() {
- if(this.hasCurve === false){
+ if(!this.hasCurve || !this.predictFlow || !this.predictPower || !this.predictCtrl){
this.logger.error(`No valid curve available to calculate prediction using last known pressure`);
return 0;
}
@@ -1321,13 +1339,33 @@ _callMeasurementHandler(measurementType, value, position, context) {
calcRelativeDistanceFromPeak(currentEfficiency,maxEfficiency,minEfficiency){
let distance = 1;
- if(currentEfficiency != null){
+ if(currentEfficiency != null && maxEfficiency !== minEfficiency){
distance = this.interpolation.interpolate_lin_single_point(currentEfficiency,maxEfficiency, minEfficiency, 0, 1);
}
return distance;
}
+ showCoG() {
+ if (!this.hasCurve) {
+ return { error: 'No curve data available', cog: 0, NCog: 0, cogIndex: 0 };
+ }
+ const { cog, cogIndex, NCog, minEfficiency } = this.calcCog();
+ return {
+ cog,
+ cogIndex,
+ NCog,
+ NCogPercent: Math.round(NCog * 100 * 100) / 100,
+ minEfficiency,
+ currentEfficiencyCurve: this.currentEfficiencyCurve,
+ absDistFromPeak: this.absDistFromPeak,
+ relDistFromPeak: this.relDistFromPeak,
+ };
+ }
+
showWorkingCurves() {
+ if (!this.hasCurve) {
+ return { error: 'No curve data available' };
+ }
// Show the current curves for debugging
const { powerCurve, flowCurve } = this.getCurrentCurves();
return {
@@ -1345,6 +1383,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
// Calculate the center of gravity for current pressure
calcCog() {
+ if (!this.hasCurve || !this.predictFlow || !this.predictPower) {
+ return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
+ }
//fetch current curve data for power and flow
const { powerCurve, flowCurve } = this.getCurrentCurves();
@@ -1370,24 +1411,32 @@ _callMeasurementHandler(measurementType, value, position, context) {
const efficiencyCurve = [];
let peak = 0;
let peakIndex = 0;
- let minEfficiency = 0;
+ let minEfficiency = Infinity;
- // Calculate efficiency curve based on power and flow curves
+ if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
+ return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
+ }
+
+ // Specific flow ratio (Q/P): for variable-speed centrifugal pumps this is
+ // monotonically decreasing (P scales ~Q³ by affinity laws), so the peak is
+ // always at minimum flow and NCog = 0. The MGC BEP-Gravitation algorithm
+ // compensates via slope-based redistribution which IS sensitive to curve shape.
powerCurve.y.forEach((power, index) => {
-
- // Get flow for the current power
const flow = flowCurve.y[index];
+ const eff = (power > 0 && flow >= 0) ? flow / power : 0;
+ efficiencyCurve.push(eff);
- // higher efficiency is better
- efficiencyCurve.push( Math.round( ( flow / power ) * 100 ) / 100);
-
- // Keep track of peak efficiency
- peak = Math.max(peak, efficiencyCurve[index]);
- peakIndex = peak == efficiencyCurve[index] ? index : peakIndex;
- minEfficiency = Math.min(...efficiencyCurve);
-
+ if (eff > peak) {
+ peak = eff;
+ peakIndex = index;
+ }
+ if (eff < minEfficiency) {
+ minEfficiency = eff;
+ }
});
+ if (!Number.isFinite(minEfficiency)) minEfficiency = 0;
+
return { efficiencyCurve, peak, peakIndex, minEfficiency };
}
@@ -1424,11 +1473,11 @@ _callMeasurementHandler(measurementType, value, position, context) {
this.logger.debug(`temp: ${temp} atmPressure : ${atmPressure} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
- const flowM3s = this.measurements.type('flow').variant('predicted').position('atEquipment').getCurrentValue('m3/s');
- const powerWatt = this.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('W');
+ const flowM3s = this.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
+ const powerWatt = this.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
this.logger.debug(`Flow : ${flowM3s} power: ${powerWatt}`);
- if (power != 0 && flow != 0) {
+ if (power > 0 && flow > 0) {
const specificFlow = flow / power;
const specificEnergyConsumption = power / flow;
@@ -1470,18 +1519,31 @@ _callMeasurementHandler(measurementType, value, position, context) {
this.config = this.configUtils.updateConfig(this.config, newConfig);
//After we passed validation load the curves into their predictors
- this.predictFlow.updateCurve(this.config.asset.machineCurve.nq);
- this.predictPower.updateCurve(this.config.asset.machineCurve.np);
- this.predictCtrl.updateCurve(this.reverseCurve(this.config.asset.machineCurve.nq));
+ if (!this.predictFlow || !this.predictPower || !this.predictCtrl) {
+ this.predictFlow = new predict({ curve: this.config.asset.machineCurve.nq });
+ this.predictPower = new predict({ curve: this.config.asset.machineCurve.np });
+ this.predictCtrl = new predict({ curve: this.reverseCurve(this.config.asset.machineCurve.nq) });
+ this.hasCurve = true;
+ } else {
+ this.predictFlow.updateCurve(this.config.asset.machineCurve.nq);
+ this.predictPower.updateCurve(this.config.asset.machineCurve.np);
+ this.predictCtrl.updateCurve(this.reverseCurve(this.config.asset.machineCurve.nq));
+ }
}
getCompleteCurve() {
+ if (!this.hasCurve || !this.predictPower || !this.predictFlow) {
+ return { powerCurve: null, flowCurve: null };
+ }
const powerCurve = this.predictPower.inputCurveData;
const flowCurve = this.predictFlow.inputCurveData;
return { powerCurve, flowCurve };
}
getCurrentCurves() {
+ if (!this.hasCurve || !this.predictPower || !this.predictFlow) {
+ return { powerCurve: { x: [], y: [] }, flowCurve: { x: [], y: [] } };
+ }
const powerCurve = this.predictPower.currentFxyCurve[this.predictPower.currentF];
const flowCurve = this.predictFlow.currentFxyCurve[this.predictFlow.currentF];
diff --git a/test/edge/listener-cleanup.edge.test.js b/test/edge/listener-cleanup.edge.test.js
new file mode 100644
index 0000000..46d7da4
--- /dev/null
+++ b/test/edge/listener-cleanup.edge.test.js
@@ -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');
+});
diff --git a/test/edge/negative-zero-guards.edge.test.js b/test/edge/negative-zero-guards.edge.test.js
new file mode 100644
index 0000000..3f1c1ea
--- /dev/null
+++ b/test/edge/negative-zero-guards.edge.test.js
@@ -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);
+});
diff --git a/test/edge/output-format.edge.test.js b/test/edge/output-format.edge.test.js
new file mode 100644
index 0000000..723c2f2
--- /dev/null
+++ b/test/edge/output-format.edge.test.js
@@ -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');
+});
diff --git a/test/integration/efficiency-cog.integration.test.js b/test/integration/efficiency-cog.integration.test.js
new file mode 100644
index 0000000..1735e79
--- /dev/null
+++ b/test/integration/efficiency-cog.integration.test.js
@@ -0,0 +1,147 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const Machine = require('../../src/specificClass');
+const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
+
+function makePressurizedOperationalMachine() {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
+ machine.updateMeasuredPressure(800, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
+ machine.updateMeasuredPressure(1200, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
+ return machine;
+}
+
+test('calcCog returns valid peak efficiency and index', () => {
+ const machine = makePressurizedOperationalMachine();
+
+ const result = machine.calcCog();
+
+ assert.ok(Number.isFinite(result.cog), 'cog should be finite');
+ assert.ok(result.cog > 0, 'peak efficiency should be positive');
+ assert.ok(Number.isFinite(result.cogIndex), 'cogIndex should be finite');
+ assert.ok(result.cogIndex >= 0, 'cogIndex should be non-negative');
+ assert.ok(Number.isFinite(result.NCog), 'NCog should be finite');
+ assert.ok(result.NCog >= 0 && result.NCog <= 1, 'NCog should be between 0 and 1');
+ assert.ok(Number.isFinite(result.minEfficiency), 'minEfficiency should be finite');
+ assert.ok(result.minEfficiency >= 0, 'minEfficiency should be non-negative');
+});
+
+test('calcCog peak is always >= minEfficiency', () => {
+ const machine = makePressurizedOperationalMachine();
+
+ const result = machine.calcCog();
+ assert.ok(result.cog >= result.minEfficiency, 'Peak must be >= min');
+});
+
+test('calcEfficiencyCurve produces correct specific flow ratio', () => {
+ const machine = makePressurizedOperationalMachine();
+ const { powerCurve, flowCurve } = machine.getCurrentCurves();
+
+ const { efficiencyCurve, peak, peakIndex, minEfficiency } = machine.calcEfficiencyCurve(powerCurve, flowCurve);
+
+ assert.ok(efficiencyCurve.length > 0, 'Efficiency curve should not be empty');
+ assert.equal(efficiencyCurve.length, powerCurve.y.length, 'Should match curve length');
+
+ // Verify each point: efficiency = flow / power (unrounded, canonical units)
+ for (let i = 0; i < efficiencyCurve.length; i++) {
+ const power = powerCurve.y[i];
+ const flow = flowCurve.y[i];
+ if (power > 0 && flow >= 0) {
+ const expected = flow / power;
+ assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}`);
+ }
+ }
+
+ // Peak should be the max
+ const actualMax = Math.max(...efficiencyCurve);
+ assert.equal(peak, actualMax, 'Peak should match max of efficiency curve');
+ assert.equal(efficiencyCurve[peakIndex], peak, 'peakIndex should point to peak value');
+ assert.equal(minEfficiency, Math.min(...efficiencyCurve), 'minEfficiency should match min');
+});
+
+test('calcEfficiencyCurve handles empty curves gracefully', () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
+
+ const result = machine.calcEfficiencyCurve({ x: [], y: [] }, { x: [], y: [] });
+
+ assert.deepEqual(result.efficiencyCurve, []);
+ assert.equal(result.peak, 0);
+ assert.equal(result.peakIndex, 0);
+ assert.equal(result.minEfficiency, 0);
+});
+
+test('calcDistanceBEP returns absolute and relative distances', () => {
+ const machine = makePressurizedOperationalMachine();
+
+ const efficiency = 5;
+ const maxEfficiency = 10;
+ const minEfficiency = 2;
+
+ const result = machine.calcDistanceBEP(efficiency, maxEfficiency, minEfficiency);
+
+ assert.ok(Number.isFinite(result.absDistFromPeak), 'abs distance should be finite');
+ assert.equal(result.absDistFromPeak, Math.abs(efficiency - maxEfficiency));
+ assert.ok(Number.isFinite(result.relDistFromPeak), 'rel distance should be finite');
+});
+
+test('calcRelativeDistanceFromPeak returns 1 when maxEfficiency equals minEfficiency', () => {
+ const machine = makePressurizedOperationalMachine();
+
+ const result = machine.calcRelativeDistanceFromPeak(5, 5, 5);
+ assert.equal(result, 1, 'Should return default distance when max==min (division by zero guard)');
+});
+
+test('showCoG returns structured data with curve guards', () => {
+ const machine = makePressurizedOperationalMachine();
+
+ const result = machine.showCoG();
+
+ assert.ok('cog' in result);
+ assert.ok('cogIndex' in result);
+ assert.ok('NCog' in result);
+ assert.ok('NCogPercent' in result);
+ assert.ok('minEfficiency' in result);
+ assert.ok('currentEfficiencyCurve' in result);
+ assert.ok(result.cog > 0);
+ assert.equal(result.NCogPercent, Math.round(result.NCog * 100 * 100) / 100);
+});
+
+test('showCoG returns safe fallback when no curve is available', () => {
+ const machine = new Machine(
+ makeMachineConfig({ asset: { model: null } }),
+ makeStateConfig()
+ );
+
+ const result = machine.showCoG();
+ assert.equal(result.cog, 0);
+ assert.ok('error' in result);
+});
+
+test('showWorkingCurves returns safe fallback when no curve is available', () => {
+ const machine = new Machine(
+ makeMachineConfig({ asset: { model: null } }),
+ makeStateConfig()
+ );
+
+ const result = machine.showWorkingCurves();
+ assert.ok('error' in result);
+});
+
+test('efficiency output fields are present in getOutput', () => {
+ const machine = makePressurizedOperationalMachine();
+
+ // Move to a position so predictions produce values
+ machine.state.transitionToState('operational');
+ machine.updatePosition();
+
+ const output = machine.getOutput();
+
+ 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);
+ assert.ok('predictionQuality' in output);
+ assert.ok('predictionConfidence' in output);
+ assert.ok('predictionPressureSource' in output);
+});
diff --git a/test/integration/emergency-stop.integration.test.js b/test/integration/emergency-stop.integration.test.js
new file mode 100644
index 0000000..78fd105
--- /dev/null
+++ b/test/integration/emergency-stop.integration.test.js
@@ -0,0 +1,59 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const Machine = require('../../src/specificClass');
+const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
+
+test('emergencystop sequence reaches off state from operational', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+
+ // First start the machine
+ await machine.handleInput('parent', 'execSequence', 'startup');
+ assert.equal(machine.state.getCurrentState(), 'operational');
+
+ // Execute emergency stop
+ await machine.handleInput('GUI', 'emergencystop');
+ assert.equal(machine.state.getCurrentState(), 'off');
+});
+
+test('emergencystop sequence reaches off state from idle', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+ assert.equal(machine.state.getCurrentState(), 'idle');
+
+ await machine.handleInput('GUI', 'emergencystop');
+ assert.equal(machine.state.getCurrentState(), 'off');
+});
+
+test('emergencystop clears predicted flow and power to zero', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+
+ // Start and set a position so predictions are non-zero
+ await machine.handleInput('parent', 'execSequence', 'startup');
+ machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
+ await machine.handleInput('parent', 'execMovement', 50);
+
+ const flowBefore = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
+ assert.ok(flowBefore > 0, 'Flow should be positive before emergency stop');
+
+ // Emergency stop
+ await machine.handleInput('GUI', 'emergencystop');
+
+ const flowAfter = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
+ const powerAfter = machine.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue();
+ assert.equal(flowAfter, 0, 'Flow should be zero after emergency stop');
+ assert.equal(powerAfter, 0, 'Power should be zero after emergency stop');
+});
+
+test('emergencystop is rejected when source is not allowed in current mode', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+
+ // In auto mode, only 'parent' source is typically allowed for sequences
+ machine.setMode('auto');
+ await machine.handleInput('parent', 'execSequence', 'startup');
+ assert.equal(machine.state.getCurrentState(), 'operational');
+
+ // GUI source attempting emergency stop in auto mode — should still work
+ // because emergencystop is allowed from all sources in config
+ await machine.handleInput('GUI', 'emergencystop');
+ // If we get here without throwing, action was either accepted or safely rejected
+});
diff --git a/test/integration/movement-lifecycle.integration.test.js b/test/integration/movement-lifecycle.integration.test.js
new file mode 100644
index 0000000..9d6bffd
--- /dev/null
+++ b/test/integration/movement-lifecycle.integration.test.js
@@ -0,0 +1,75 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const Machine = require('../../src/specificClass');
+const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
+
+test('movement from 0 to 50% updates position and predictions', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+
+ await machine.handleInput('parent', 'execSequence', 'startup');
+ assert.equal(machine.state.getCurrentState(), 'operational');
+
+ machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
+
+ await machine.handleInput('parent', 'execMovement', 50);
+
+ const pos = machine.state.getCurrentPosition();
+ const { min, max } = machine._resolveSetpointBounds();
+ // Position should be constrained to bounds
+ assert.ok(pos >= min && pos <= max, `Position ${pos} should be within [${min}, ${max}]`);
+
+ const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
+ assert.ok(flow > 0, 'Predicted flow should be positive at non-zero position');
+});
+
+test('flowmovement sets position based on flow setpoint', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+
+ await machine.handleInput('parent', 'execSequence', 'startup');
+ machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
+
+ // Request 100 m3/h flow — the machine should calculate the control position
+ await machine.handleInput('parent', 'flowMovement', 100);
+
+ const pos = machine.state.getCurrentPosition();
+ assert.ok(pos > 0, 'Position should be non-zero for a non-zero flow setpoint');
+});
+
+test('sequential movements update position correctly', 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', 30);
+ const pos30 = machine.state.getCurrentPosition();
+
+ await machine.handleInput('parent', 'execMovement', 60);
+ const pos60 = machine.state.getCurrentPosition();
+
+ assert.ok(pos60 > pos30, 'Position at 60 should be greater than at 30');
+});
+
+test('movement to 0 sets flow and power predictions to minimum curve values', 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', 0);
+
+ const pos = machine.state.getCurrentPosition();
+ assert.equal(pos, 0, 'Position should be at 0');
+});
+
+test('movement is rejected in non-operational state', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+ assert.equal(machine.state.getCurrentState(), 'idle');
+
+ // Attempt movement in idle state — handleInput should process but no movement happens
+ await machine.handleInput('parent', 'execMovement', 50);
+
+ // Machine should still be idle (movement requires operational state via sequence first)
+ assert.equal(machine.state.getCurrentState(), 'idle');
+});
diff --git a/test/integration/shutdown-sequence.integration.test.js b/test/integration/shutdown-sequence.integration.test.js
new file mode 100644
index 0000000..5ec07ad
--- /dev/null
+++ b/test/integration/shutdown-sequence.integration.test.js
@@ -0,0 +1,72 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const Machine = require('../../src/specificClass');
+const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
+
+test('shutdown sequence from operational reaches idle', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+
+ await machine.handleInput('parent', 'execSequence', 'startup');
+ assert.equal(machine.state.getCurrentState(), 'operational');
+
+ await machine.handleInput('parent', 'execSequence', 'shutdown');
+ assert.equal(machine.state.getCurrentState(), 'idle');
+});
+
+test('shutdown from operational ramps down position before stopping', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+
+ await machine.handleInput('parent', 'execSequence', 'startup');
+ await machine.handleInput('parent', 'execMovement', 50);
+
+ const posBefore = machine.state.getCurrentPosition();
+ assert.ok(posBefore > 0, 'Machine should be at non-zero position');
+
+ await machine.handleInput('parent', 'execSequence', 'shutdown');
+
+ const posAfter = machine.state.getCurrentPosition();
+ assert.ok(posAfter <= posBefore, 'Position should have decreased after shutdown');
+ assert.equal(machine.state.getCurrentState(), 'idle');
+});
+
+test('shutdown clears predicted flow and power', 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);
+
+ await machine.handleInput('parent', 'execSequence', 'shutdown');
+
+ const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
+ const power = machine.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue();
+ assert.equal(flow, 0, 'Flow should be zero after shutdown');
+ assert.equal(power, 0, 'Power should be zero after shutdown');
+});
+
+test('entermaintenance sequence from operational reaches maintenance state', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+
+ await machine.handleInput('parent', 'execSequence', 'startup');
+ assert.equal(machine.state.getCurrentState(), 'operational');
+
+ await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance');
+ assert.equal(machine.state.getCurrentState(), 'maintenance');
+});
+
+test('exitmaintenance requires mode with exitmaintenance action allowed', async () => {
+ const machine = new Machine(makeMachineConfig(), makeStateConfig());
+
+ // Use auto mode (has execsequence + entermaintenance) to reach maintenance
+ await machine.handleInput('parent', 'execSequence', 'startup');
+ assert.equal(machine.state.getCurrentState(), 'operational');
+
+ await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance');
+ assert.equal(machine.state.getCurrentState(), 'maintenance');
+
+ // Switch to fysicalControl which allows exitmaintenance
+ machine.setMode('fysicalControl');
+ await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance');
+ assert.equal(machine.state.getCurrentState(), 'idle');
+});