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:
@@ -67,11 +67,15 @@
|
|||||||
|
|
||||||
oneditprepare: function() {
|
oneditprepare: function() {
|
||||||
// wait for the menu scripts to load
|
// wait for the menu scripts to load
|
||||||
|
let menuRetries = 0;
|
||||||
|
const maxMenuRetries = 100; // 5 seconds at 50ms intervals
|
||||||
const waitForMenuData = () => {
|
const waitForMenuData = () => {
|
||||||
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
|
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
|
||||||
window.EVOLV.nodes.rotatingMachine.initEditor(this);
|
window.EVOLV.nodes.rotatingMachine.initEditor(this);
|
||||||
} else {
|
} else if (++menuRetries < maxMenuRetries) {
|
||||||
setTimeout(waitForMenuData, 50);
|
setTimeout(waitForMenuData, 50);
|
||||||
|
} else {
|
||||||
|
console.warn("rotatingMachine: menu scripts failed to load within 5 seconds");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
waitForMenuData();
|
waitForMenuData();
|
||||||
|
|||||||
@@ -254,10 +254,11 @@ class nodeClass {
|
|||||||
* Start the periodic tick loop.
|
* Start the periodic tick loop.
|
||||||
*/
|
*/
|
||||||
_startTickLoop() {
|
_startTickLoop() {
|
||||||
setTimeout(() => {
|
this._startupTimeout = setTimeout(() => {
|
||||||
|
this._startupTimeout = null;
|
||||||
this._tickInterval = setInterval(() => this._tick(), 1000);
|
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(() => {
|
this._statusInterval = setInterval(() => {
|
||||||
const status = this._updateNodeStatus();
|
const status = this._updateNodeStatus();
|
||||||
this.node.status(status);
|
this.node.status(status);
|
||||||
@@ -284,15 +285,13 @@ class nodeClass {
|
|||||||
* Attach the node's input handler, routing control messages to the class.
|
* Attach the node's input handler, routing control messages to the class.
|
||||||
*/
|
*/
|
||||||
_attachInputHandler() {
|
_attachInputHandler() {
|
||||||
this.node.on('input', (msg, send, done) => {
|
this.node.on('input', async (msg, send, done) => {
|
||||||
/* Update to complete event based node by putting the tick function after an input event */
|
|
||||||
const m = this.source;
|
const m = this.source;
|
||||||
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
|
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch(msg.topic) {
|
switch(msg.topic) {
|
||||||
case 'registerChild': {
|
case 'registerChild': {
|
||||||
// Register this node as a child of the parent node
|
|
||||||
const childId = msg.payload;
|
const childId = msg.payload;
|
||||||
const childObj = this.RED.nodes.getNode(childId);
|
const childObj = this.RED.nodes.getNode(childId);
|
||||||
if (!childObj || !childObj.source) {
|
if (!childObj || !childObj.source) {
|
||||||
@@ -307,22 +306,22 @@ class nodeClass {
|
|||||||
break;
|
break;
|
||||||
case 'execSequence': {
|
case 'execSequence': {
|
||||||
const { source, action, parameter } = msg.payload;
|
const { source, action, parameter } = msg.payload;
|
||||||
m.handleInput(source, action, parameter);
|
await m.handleInput(source, action, parameter);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execMovement': {
|
case 'execMovement': {
|
||||||
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
|
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
|
||||||
m.handleInput(mvSource, mvAction, Number(setpoint));
|
await m.handleInput(mvSource, mvAction, Number(setpoint));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'flowMovement': {
|
case 'flowMovement': {
|
||||||
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
|
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
|
||||||
m.handleInput(fmSource, fmAction, Number(fmSetpoint));
|
await m.handleInput(fmSource, fmAction, Number(fmSetpoint));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'emergencystop': {
|
case 'emergencystop': {
|
||||||
const { source: esSource, action: esAction } = msg.payload;
|
const { source: esSource, action: esAction } = msg.payload;
|
||||||
m.handleInput(esSource, esAction);
|
await m.handleInput(esSource, esAction);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'simulateMeasurement':
|
case 'simulateMeasurement':
|
||||||
@@ -403,8 +402,28 @@ class nodeClass {
|
|||||||
*/
|
*/
|
||||||
_attachCloseHandler() {
|
_attachCloseHandler() {
|
||||||
this.node.on('close', (done) => {
|
this.node.on('close', (done) => {
|
||||||
|
clearTimeout(this._startupTimeout);
|
||||||
clearInterval(this._tickInterval);
|
clearInterval(this._tickInterval);
|
||||||
clearInterval(this._statusInterval);
|
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();
|
if (typeof done === 'function') done();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -438,7 +438,10 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
_normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName) {
|
_normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName) {
|
||||||
const normalized = {};
|
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(
|
const canonicalPressure = this._convertUnitValue(
|
||||||
Number(pressureKey),
|
Number(pressureKey),
|
||||||
fromPressureUnit,
|
fromPressureUnit,
|
||||||
@@ -450,6 +453,21 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
if (!xArray.length || !yArray.length || xArray.length !== yArray.length) {
|
if (!xArray.length || !yArray.length || xArray.length !== yArray.length) {
|
||||||
throw new Error(`Invalid ${sectionName} section at pressure '${pressureKey}'.`);
|
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)] = {
|
normalized[String(canonicalPressure)] = {
|
||||||
x: xArray,
|
x: xArray,
|
||||||
y: yArray,
|
y: yArray,
|
||||||
@@ -772,7 +790,7 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
case "emergencystop":
|
case "emergencystop":
|
||||||
this.logger.warn(`Emergency stop activated by '${source}'.`);
|
this.logger.warn(`Emergency stop activated by '${source}'.`);
|
||||||
return await this.executeSequence("emergencyStop");
|
return await this.executeSequence("emergencystop");
|
||||||
|
|
||||||
case "statuscheck":
|
case "statuscheck":
|
||||||
this.logger.info(`Status Check: Mode = '${this.currentMode}', Source = '${source}'.`);
|
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
|
// returns the best available pressure measurement to use in the prediction calculation
|
||||||
// this will be either the differential pressure, downstream or upstream pressure
|
// this will be either the differential pressure, downstream or upstream pressure
|
||||||
getMeasuredPressure() {
|
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`);
|
this.logger.error(`No valid curve available to calculate prediction using last known pressure`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1321,13 +1339,33 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
calcRelativeDistanceFromPeak(currentEfficiency,maxEfficiency,minEfficiency){
|
calcRelativeDistanceFromPeak(currentEfficiency,maxEfficiency,minEfficiency){
|
||||||
let distance = 1;
|
let distance = 1;
|
||||||
if(currentEfficiency != null){
|
if(currentEfficiency != null && maxEfficiency !== minEfficiency){
|
||||||
distance = this.interpolation.interpolate_lin_single_point(currentEfficiency,maxEfficiency, minEfficiency, 0, 1);
|
distance = this.interpolation.interpolate_lin_single_point(currentEfficiency,maxEfficiency, minEfficiency, 0, 1);
|
||||||
}
|
}
|
||||||
return distance;
|
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() {
|
showWorkingCurves() {
|
||||||
|
if (!this.hasCurve) {
|
||||||
|
return { error: 'No curve data available' };
|
||||||
|
}
|
||||||
// Show the current curves for debugging
|
// Show the current curves for debugging
|
||||||
const { powerCurve, flowCurve } = this.getCurrentCurves();
|
const { powerCurve, flowCurve } = this.getCurrentCurves();
|
||||||
return {
|
return {
|
||||||
@@ -1345,6 +1383,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
|
|
||||||
// Calculate the center of gravity for current pressure
|
// Calculate the center of gravity for current pressure
|
||||||
calcCog() {
|
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
|
//fetch current curve data for power and flow
|
||||||
const { powerCurve, flowCurve } = this.getCurrentCurves();
|
const { powerCurve, flowCurve } = this.getCurrentCurves();
|
||||||
@@ -1370,24 +1411,32 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
const efficiencyCurve = [];
|
const efficiencyCurve = [];
|
||||||
let peak = 0;
|
let peak = 0;
|
||||||
let peakIndex = 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) => {
|
powerCurve.y.forEach((power, index) => {
|
||||||
|
|
||||||
// Get flow for the current power
|
|
||||||
const flow = flowCurve.y[index];
|
const flow = flowCurve.y[index];
|
||||||
|
const eff = (power > 0 && flow >= 0) ? flow / power : 0;
|
||||||
|
efficiencyCurve.push(eff);
|
||||||
|
|
||||||
// higher efficiency is better
|
if (eff > peak) {
|
||||||
efficiencyCurve.push( Math.round( ( flow / power ) * 100 ) / 100);
|
peak = eff;
|
||||||
|
peakIndex = index;
|
||||||
// Keep track of peak efficiency
|
}
|
||||||
peak = Math.max(peak, efficiencyCurve[index]);
|
if (eff < minEfficiency) {
|
||||||
peakIndex = peak == efficiencyCurve[index] ? index : peakIndex;
|
minEfficiency = eff;
|
||||||
minEfficiency = Math.min(...efficiencyCurve);
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!Number.isFinite(minEfficiency)) minEfficiency = 0;
|
||||||
|
|
||||||
return { efficiencyCurve, peak, peakIndex, minEfficiency };
|
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}`);
|
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 flowM3s = this.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
|
||||||
const powerWatt = this.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('W');
|
const powerWatt = this.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
|
||||||
this.logger.debug(`Flow : ${flowM3s} power: ${powerWatt}`);
|
this.logger.debug(`Flow : ${flowM3s} power: ${powerWatt}`);
|
||||||
|
|
||||||
if (power != 0 && flow != 0) {
|
if (power > 0 && flow > 0) {
|
||||||
const specificFlow = flow / power;
|
const specificFlow = flow / power;
|
||||||
const specificEnergyConsumption = power / flow;
|
const specificEnergyConsumption = power / flow;
|
||||||
|
|
||||||
@@ -1470,18 +1519,31 @@ _callMeasurementHandler(measurementType, value, position, context) {
|
|||||||
this.config = this.configUtils.updateConfig(this.config, newConfig);
|
this.config = this.configUtils.updateConfig(this.config, newConfig);
|
||||||
|
|
||||||
//After we passed validation load the curves into their predictors
|
//After we passed validation load the curves into their predictors
|
||||||
|
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.predictFlow.updateCurve(this.config.asset.machineCurve.nq);
|
||||||
this.predictPower.updateCurve(this.config.asset.machineCurve.np);
|
this.predictPower.updateCurve(this.config.asset.machineCurve.np);
|
||||||
this.predictCtrl.updateCurve(this.reverseCurve(this.config.asset.machineCurve.nq));
|
this.predictCtrl.updateCurve(this.reverseCurve(this.config.asset.machineCurve.nq));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getCompleteCurve() {
|
getCompleteCurve() {
|
||||||
|
if (!this.hasCurve || !this.predictPower || !this.predictFlow) {
|
||||||
|
return { powerCurve: null, flowCurve: null };
|
||||||
|
}
|
||||||
const powerCurve = this.predictPower.inputCurveData;
|
const powerCurve = this.predictPower.inputCurveData;
|
||||||
const flowCurve = this.predictFlow.inputCurveData;
|
const flowCurve = this.predictFlow.inputCurveData;
|
||||||
return { powerCurve, flowCurve };
|
return { powerCurve, flowCurve };
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentCurves() {
|
getCurrentCurves() {
|
||||||
|
if (!this.hasCurve || !this.predictPower || !this.predictFlow) {
|
||||||
|
return { powerCurve: { x: [], y: [] }, flowCurve: { x: [], y: [] } };
|
||||||
|
}
|
||||||
const powerCurve = this.predictPower.currentFxyCurve[this.predictPower.currentF];
|
const powerCurve = this.predictPower.currentFxyCurve[this.predictPower.currentF];
|
||||||
const flowCurve = this.predictFlow.currentFxyCurve[this.predictFlow.currentF];
|
const flowCurve = this.predictFlow.currentFxyCurve[this.predictFlow.currentF];
|
||||||
|
|
||||||
|
|||||||
63
test/edge/listener-cleanup.edge.test.js
Normal file
63
test/edge/listener-cleanup.edge.test.js
Normal 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');
|
||||||
|
});
|
||||||
132
test/edge/negative-zero-guards.edge.test.js
Normal file
132
test/edge/negative-zero-guards.edge.test.js
Normal 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);
|
||||||
|
});
|
||||||
121
test/edge/output-format.edge.test.js
Normal file
121
test/edge/output-format.edge.test.js
Normal 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');
|
||||||
|
});
|
||||||
147
test/integration/efficiency-cog.integration.test.js
Normal file
147
test/integration/efficiency-cog.integration.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
59
test/integration/emergency-stop.integration.test.js
Normal file
59
test/integration/emergency-stop.integration.test.js
Normal file
@@ -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
|
||||||
|
});
|
||||||
75
test/integration/movement-lifecycle.integration.test.js
Normal file
75
test/integration/movement-lifecycle.integration.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
72
test/integration/shutdown-sequence.integration.test.js
Normal file
72
test/integration/shutdown-sequence.integration.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user