Compare commits
20 Commits
dev-Rene
...
07af7cef40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07af7cef40 | ||
|
|
ea33b3bba3 | ||
|
|
f363ee53ef | ||
|
|
4cf46f33c9 | ||
|
|
7b9fdd7342 | ||
|
|
bb986c2dc8 | ||
|
|
46dd2ca37a | ||
|
|
ccfa90394b | ||
|
|
e236cccfd6 | ||
|
|
99b45c87e4 | ||
|
|
0a98b12224 | ||
|
|
b6d268659a | ||
|
|
303dfc477d | ||
|
|
ac40a93ef1 | ||
|
|
a8fb56bfb8 | ||
|
|
d7cc6a4a8b | ||
|
|
37e6523c55 | ||
| 5a14f44fdd | |||
|
|
c081acae4e | ||
| 08185243bc |
@@ -26,6 +26,8 @@
|
||||
cooldown: { value: 0 },
|
||||
movementMode : { value: "staticspeed" }, // static or dynamic
|
||||
machineCurve : { value: {}},
|
||||
processOutputFormat: { value: "process" },
|
||||
dbaseOutputFormat: { value: "influxdb" },
|
||||
|
||||
//define asset properties
|
||||
uuid: { value: "" },
|
||||
@@ -65,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();
|
||||
@@ -148,6 +154,24 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h3>Output Formats</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||
<option value="process">process</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||
<option value="influxdb">influxdb</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Asset fields injected here -->
|
||||
<div id="asset-fields-placeholder"></div>
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class nodeClass {
|
||||
* @param {object} uiConfig - Raw config from Node-RED UI.
|
||||
*/
|
||||
_loadConfig(uiConfig,node) {
|
||||
const cfgMgr = new configManager();
|
||||
const resolvedAssetUuid = uiConfig.assetUuid || uiConfig.uuid || null;
|
||||
const resolvedAssetTagCode = uiConfig.assetTagCode || uiConfig.assetTagNumber || null;
|
||||
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
|
||||
@@ -52,33 +53,24 @@ class nodeClass {
|
||||
control: this._resolveControlUnitOrFallback(uiConfig.curveControlUnit, '%'),
|
||||
};
|
||||
|
||||
// Merge UI config over defaults
|
||||
this.config = {
|
||||
general: {
|
||||
name: this.name,
|
||||
id: node.id, // node.id is for the child registration process
|
||||
unit: flowUnit,
|
||||
logging: {
|
||||
enabled: uiConfig.enableLog,
|
||||
logLevel: uiConfig.logLevel
|
||||
}
|
||||
},
|
||||
asset: {
|
||||
uuid: resolvedAssetUuid, // support both legacy and current editor field names
|
||||
tagCode: resolvedAssetTagCode, // support both legacy and current editor field names
|
||||
// Build config: base sections + rotatingMachine-specific domain config
|
||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
||||
flowNumber: uiConfig.flowNumber
|
||||
});
|
||||
|
||||
// Override asset with rotatingMachine-specific fields
|
||||
this.config.asset = {
|
||||
...this.config.asset,
|
||||
uuid: resolvedAssetUuid,
|
||||
tagCode: resolvedAssetTagCode,
|
||||
tagNumber: uiConfig.assetTagNumber || null,
|
||||
supplier: uiConfig.supplier,
|
||||
category: uiConfig.category, //add later to define as the software type
|
||||
type: uiConfig.assetType,
|
||||
model: uiConfig.model,
|
||||
unit: flowUnit,
|
||||
curveUnits
|
||||
},
|
||||
functionality: {
|
||||
positionVsParent: uiConfig.positionVsParent
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure general unit uses resolved flow unit
|
||||
this.config.general.unit = flowUnit;
|
||||
|
||||
// Utility for formatting outputs
|
||||
this._output = new outputUtils();
|
||||
}
|
||||
@@ -253,7 +245,7 @@ class nodeClass {
|
||||
this.node.send([
|
||||
null,
|
||||
null,
|
||||
{ topic: 'registerChild', payload: this.config.general.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' },
|
||||
{ topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' },
|
||||
]);
|
||||
}, 100);
|
||||
}
|
||||
@@ -262,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);
|
||||
@@ -292,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
|
||||
case 'registerChild': {
|
||||
const childId = msg.payload;
|
||||
const childObj = this.RED.nodes.getNode(childId);
|
||||
if (!childObj || !childObj.source) {
|
||||
@@ -309,26 +300,30 @@ class nodeClass {
|
||||
}
|
||||
m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
|
||||
break;
|
||||
}
|
||||
case 'setMode':
|
||||
m.setMode(msg.payload);
|
||||
break;
|
||||
case 'execSequence':
|
||||
case 'execSequence': {
|
||||
const { source, action, parameter } = msg.payload;
|
||||
m.handleInput(source, action, parameter);
|
||||
await m.handleInput(source, action, parameter);
|
||||
break;
|
||||
case 'execMovement':
|
||||
}
|
||||
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':
|
||||
}
|
||||
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':
|
||||
}
|
||||
case 'emergencystop': {
|
||||
const { source: esSource, action: esAction } = msg.payload;
|
||||
m.handleInput(esSource, esAction);
|
||||
await m.handleInput(esSource, esAction);
|
||||
break;
|
||||
}
|
||||
case 'simulateMeasurement':
|
||||
{
|
||||
const payload = msg.payload || {};
|
||||
@@ -407,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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const EventEmitter = require('events');
|
||||
const {loadCurve,gravity,logger,configUtils,configManager,state, nrmse, MeasurementContainer, predict, interpolation , childRegistrationUtils,coolprop, convert} = require('generalFunctions');
|
||||
const {loadCurve,gravity,logger,configUtils,configManager,state, nrmse, MeasurementContainer, predict, interpolation , childRegistrationUtils,coolprop, convert, POSITIONS} = require('generalFunctions');
|
||||
|
||||
const CANONICAL_UNITS = Object.freeze({
|
||||
pressure: 'Pa',
|
||||
@@ -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
|
||||
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];
|
||||
|
||||
|
||||
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