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'); +});