This commit is contained in:
znetsixe
2026-03-11 11:13:26 +01:00
parent 33f3c2ef61
commit 6b2a8239f2
16 changed files with 2850 additions and 146 deletions

View File

@@ -0,0 +1,109 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const { makeNodeStub } = require('../helpers/factories');
function makeUiConfig(overrides = {}) {
return {
unit: 'm3/h',
enableLog: true,
logLevel: 'debug',
supplier: 'hidrostal',
category: 'machine',
assetType: 'pump',
model: 'hidrostal-H05K-S03R',
curvePressureUnit: 'mbar',
curveFlowUnit: 'm3/h',
curvePowerUnit: 'kW',
curveControlUnit: '%',
positionVsParent: 'atEquipment',
speed: 1,
movementMode: 'staticspeed',
startup: 0,
warmup: 0,
shutdown: 0,
cooldown: 0,
...overrides,
};
}
test('_loadConfig maps legacy editor fields for asset identity', () => {
const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub();
inst.name = 'rotatingMachine';
inst._loadConfig(
makeUiConfig({
uuid: 'uuid-from-editor',
assetTagNumber: 'TAG-123',
}),
inst.node
);
assert.equal(inst.config.asset.uuid, 'uuid-from-editor');
assert.equal(inst.config.asset.tagCode, 'TAG-123');
assert.equal(inst.config.asset.tagNumber, 'TAG-123');
});
test('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => {
const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub();
inst.name = 'rotatingMachine';
inst._loadConfig(
makeUiConfig({
uuid: 'legacy-uuid',
assetUuid: 'explicit-uuid',
assetTagNumber: 'legacy-tag',
assetTagCode: 'explicit-tag',
}),
inst.node
);
assert.equal(inst.config.asset.uuid, 'explicit-uuid');
assert.equal(inst.config.asset.tagCode, 'explicit-tag');
});
test('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub();
inst.name = 'rotatingMachine';
inst._loadConfig(
makeUiConfig({
unit: 'not-a-unit',
curvePressureUnit: 'mbar',
curveFlowUnit: 'm3/h',
curvePowerUnit: 'kW',
curveControlUnit: '%',
}),
inst.node
);
assert.equal(inst.config.general.unit, 'm3/h');
assert.equal(inst.config.asset.unit, 'm3/h');
assert.equal(inst.config.asset.curveUnits.pressure, 'mbar');
assert.equal(inst.config.asset.curveUnits.flow, 'm3/h');
assert.equal(inst.config.asset.curveUnits.power, 'kW');
assert.equal(inst.config.asset.curveUnits.control, '%');
assert.ok(inst.node._warns.length >= 1);
});
test('_setupSpecificClass propagates logging settings into state config', () => {
const inst = Object.create(NodeClass.prototype);
inst.node = makeNodeStub();
inst.name = 'rotatingMachine';
const uiConfig = makeUiConfig({
enableLog: true,
logLevel: 'warn',
uuid: 'uuid-test',
assetTagNumber: 'TAG-9',
});
inst._loadConfig(uiConfig, inst.node);
inst._setupSpecificClass(uiConfig);
assert.equal(inst.source.state.config.general.logging.enabled, true);
assert.equal(inst.source.state.config.general.logging.logLevel, 'warn');
});

View File

@@ -12,6 +12,28 @@ test('setpoint rejects negative inputs without throwing', async () => {
});
});
test('setpoint is constrained to safe movement/curve bounds', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
const requested = [];
machine.state.moveTo = async (target) => {
requested.push(target);
};
const stateMin = machine.state.movementManager.minPosition;
const stateMax = machine.state.movementManager.maxPosition;
const curveMin = machine.predictFlow.currentFxyXMin;
const curveMax = machine.predictFlow.currentFxyXMax;
const min = Math.max(stateMin, curveMin);
const max = Math.min(stateMax, curveMax);
await machine.setpoint(min - 100);
await machine.setpoint(max + 100);
assert.equal(requested.length, 2);
assert.equal(requested[0], min);
assert.equal(requested[1], max);
});
test('nodeClass _updateNodeStatus returns error status on internal failure', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
@@ -29,3 +51,24 @@ test('nodeClass _updateNodeStatus returns error status on internal failure', ()
assert.equal(status.text, 'Status Error');
assert.equal(node._errors.length, 1);
});
test('measurement handlers reject incompatible units', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
assert.equal(machine.isUnitValidForType('flow', 'm3/h'), true);
assert.equal(machine.isUnitValidForType('flow', 'mbar'), false);
machine.updateMeasuredFlow(100, 'downstream', {
timestamp: Date.now(),
unit: 'mbar',
childName: 'bad-ft',
});
const measuredFlow = machine.measurements
.type('flow')
.variant('measured')
.position('downstream')
.getCurrentValue();
assert.equal(measuredFlow, null);
});

View File

@@ -43,9 +43,15 @@ test('input handler routes topics to source methods', () => {
updateMeasuredFlow(value, position) {
calls.push(['updateMeasuredFlow', value, position]);
},
updateMeasuredPower(value, position) {
calls.push(['updateMeasuredPower', value, position]);
},
updateMeasuredTemperature(value, position) {
calls.push(['updateMeasuredTemperature', value, position]);
},
isUnitValidForType() {
return true;
},
};
inst._attachInputHandler();
@@ -53,13 +59,53 @@ test('input handler routes topics to source methods', () => {
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {});
onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
assert.deepEqual(calls[0], ['setMode', 'auto']);
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
assert.deepEqual(calls[2], ['registerChild', { id: 'child-source' }, 'downstream']);
assert.deepEqual(calls[3], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
assert.deepEqual(calls[3], ['handleInput', 'GUI', 'emergencystop', undefined]);
assert.deepEqual(calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
assert.deepEqual(calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
assert.deepEqual(calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
});
test('simulateMeasurement warns and ignores invalid payloads', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const calls = [];
inst.node = node;
inst.RED = makeREDStub();
inst.source = {
childRegistrationUtils: { registerChild() {} },
setMode() {},
handleInput() {},
showWorkingCurves() { return {}; },
showCoG() { return {}; },
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
updateMeasuredPressure() { calls.push('updateMeasuredPressure'); },
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
};
inst._attachInputHandler();
const onInput = node._handlers.input;
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
assert.equal(calls.length, 0);
assert.equal(node._warns.length, 3);
assert.match(String(node._warns[0]), /finite number/i);
assert.match(String(node._warns[1]), /payload\.unit is required/i);
assert.match(String(node._warns[2]), /unsupported simulatemeasurement type/i);
});
test('status shows warning when pressure inputs are not initialized', () => {

View File

@@ -17,6 +17,12 @@ function makeMachineConfig(overrides = {}) {
type: 'pump',
model: 'hidrostal-H05K-S03R',
unit: 'm3/h',
curveUnits: {
pressure: 'mbar',
flow: 'm3/h',
power: 'kW',
control: '%',
},
},
...overrides,
};

View File

@@ -19,6 +19,21 @@ test('calcEfficiency runs through coolprop path without mocks', () => {
const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
assert.equal(typeof eff, 'number');
assert.ok(eff > 0);
const pressureDiffPa = (1200 - 800) * 100; // mbar -> Pa
const flowM3s = 120 / 3600; // m3/h -> m3/s
const expectedHydraulicPower = pressureDiffPa * flowM3s;
const expectedHydraulicEfficiency = expectedHydraulicPower / 12000; // 12kW -> W
const hydraulicPower = machine.measurements.type('hydraulicPower').variant('predicted').position('atEquipment').getCurrentValue('W');
const hydraulicEfficiency = machine.measurements.type('nHydraulicEfficiency').variant('predicted').position('atEquipment').getCurrentValue();
const head = machine.measurements.type('pumpHead').variant('predicted').position('atEquipment').getCurrentValue('m');
assert.ok(Number.isFinite(hydraulicPower));
assert.ok(Number.isFinite(hydraulicEfficiency));
assert.ok(Number.isFinite(head));
assert.ok(Math.abs(hydraulicPower - expectedHydraulicPower) < 1);
assert.ok(Math.abs(hydraulicEfficiency - expectedHydraulicEfficiency) < 0.01);
});
test('predictions use initialized medium pressure and not the minimum-pressure fallback', () => {
@@ -33,7 +48,7 @@ test('predictions use initialized medium pressure and not the minimum-pressure f
assert.equal(pressureStatus.initialized, true);
assert.equal(pressureStatus.hasDifferential, true);
const expectedDiff = mediumDownstreamMbar - mediumUpstreamMbar;
const expectedDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa canonical
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff);
assert.ok(machine.predictFlow.fDimension > 0);
});

View 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('flow drift is assessed with NRMSE and exposed in output', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateMeasuredPressure(700, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
machine.updateMeasuredPressure(1100, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
machine.updatePosition();
const predictedFlow = machine.measurements
.type('flow')
.variant('predicted')
.position('downstream')
.getCurrentValue('m3/h');
for (let i = 0; i < 10; i += 1) {
machine.updateMeasuredFlow(predictedFlow * 0.92, 'downstream', {
timestamp: Date.now() + i,
unit: 'm3/h',
childName: 'ft-down',
});
}
const output = machine.getOutput();
assert.ok(Number.isFinite(output.flowNrmse));
assert.equal(typeof output.flowImmediateLevel, 'number');
assert.equal(typeof output.flowLongTermLevel, 'number');
assert.ok(['high', 'medium', 'low', 'invalid'].includes(output.predictionQuality));
assert.ok(Number.isFinite(output.predictionConfidence));
assert.equal(output.predictionPressureSource, 'differential');
assert.ok(Array.isArray(output.predictionFlags));
});
test('power drift is assessed when measured power is provided', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateMeasuredPressure(700, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
machine.updateMeasuredPressure(1100, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
machine.updatePosition();
const predictedPower = machine.measurements
.type('power')
.variant('predicted')
.position('atEquipment')
.getCurrentValue('kW');
for (let i = 0; i < 10; i += 1) {
machine.updateMeasuredPower(predictedPower * 1.08, 'atEquipment', {
timestamp: Date.now() + i,
unit: 'kW',
childName: 'power-meter',
});
}
const output = machine.getOutput();
assert.ok(Number.isFinite(output.powerNrmse));
assert.equal(typeof output.powerImmediateLevel, 'number');
assert.equal(typeof output.powerLongTermLevel, 'number');
});
test('single-side pressure lowers prediction confidence category', () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
machine.updateMeasuredPressure(950, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
const output = machine.getOutput();
assert.equal(output.predictionPressureSource, 'downstream');
assert.ok(output.predictionConfidence < 0.9);
assert.equal(output.pressureDriftLevel, 1);
assert.ok(Array.isArray(output.predictionFlags));
assert.ok(output.predictionFlags.includes('single_side_pressure'));
});

View File

@@ -27,8 +27,8 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(status.hasDifferential, false);
assert.equal(status.source, 'upstream');
const upstreamValue = machine.getMeasuredPressure();
assert.equal(Math.round(upstreamValue), upstreamOnly);
assert.equal(Math.round(machine.predictFlow.fDimension), upstreamOnly);
assert.equal(Math.round(upstreamValue), upstreamOnly * 100);
assert.equal(Math.round(machine.predictFlow.fDimension), upstreamOnly * 100);
// downstream only
machine = createMachine();
@@ -41,8 +41,8 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(status.hasDifferential, false);
assert.equal(status.source, 'downstream');
const downstreamValue = machine.getMeasuredPressure();
assert.equal(Math.round(downstreamValue), downstreamOnly);
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly);
assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
// downstream and upstream
machine = createMachine();
@@ -57,8 +57,8 @@ test('pressure initialization combinations are handled explicitly', () => {
assert.equal(status.hasDifferential, true);
assert.equal(status.source, 'differential');
const differentialValue = machine.getMeasuredPressure();
assert.equal(Math.round(differentialValue), downstream - upstream);
assert.equal(Math.round(machine.predictFlow.fDimension), downstream - upstream);
assert.equal(Math.round(differentialValue), (downstream - upstream) * 100);
assert.equal(Math.round(machine.predictFlow.fDimension), (downstream - upstream) * 100);
});
test('real pressure child data has priority over simulated dashboard pressure', async () => {
@@ -66,7 +66,7 @@ test('real pressure child data has priority over simulated dashboard pressure',
machine.updateSimulatedMeasurement('pressure', 'upstream', 900, { unit: 'mbar', timestamp: Date.now() });
machine.updateSimulatedMeasurement('pressure', 'downstream', 1200, { unit: 'mbar', timestamp: Date.now() });
assert.equal(Math.round(machine.getMeasuredPressure()), 300);
assert.equal(Math.round(machine.getMeasuredPressure()), 30000);
const upstreamChild = makeChildMeasurement({ id: 'pt-up-real', name: 'PT Up', positionVsParent: 'upstream', type: 'pressure', unit: 'mbar' });
const downstreamChild = makeChildMeasurement({ id: 'pt-down-real', name: 'PT Down', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
@@ -77,7 +77,7 @@ test('real pressure child data has priority over simulated dashboard pressure',
upstreamChild.measurements.type('pressure').variant('measured').position('upstream').value(700, Date.now(), 'mbar');
downstreamChild.measurements.type('pressure').variant('measured').position('downstream').value(1300, Date.now(), 'mbar');
assert.equal(Math.round(machine.getMeasuredPressure()), 600);
assert.equal(Math.round(machine.getMeasuredPressure()), 60000);
const status = machine.getPressureInitializationStatus();
assert.equal(status.source, 'differential');
assert.equal(status.initialized, true);

View File

@@ -25,3 +25,29 @@ test('registerChild listens to measurement events and stores measured pressure',
assert.equal(typeof stored, 'number');
assert.equal(Math.round(stored), 123);
});
test('registerChild deduplicates listeners on re-registration', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig());
const child = makeChildMeasurement({ id: 'pt-dup', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
const eventName = 'pressure.measured.downstream';
let handlerCalls = 0;
const originalUpdatePressure = machine.updateMeasuredPressure.bind(machine);
machine.updateMeasuredPressure = (...args) => {
handlerCalls += 1;
return originalUpdatePressure(...args);
};
machine.registerChild(child, 'measurement');
machine.registerChild(child, 'measurement');
assert.equal(child.measurements.emitter.listenerCount(eventName), 1);
child.measurements
.type('pressure')
.variant('measured')
.position('downstream')
.value(321, Date.now(), 'mbar');
assert.equal(handlerCalls, 1);
});

View File

@@ -12,11 +12,13 @@ test('execSequence startup reaches operational with zero transition times', asyn
assert.equal(machine.state.getCurrentState(), 'operational');
});
test('execMovement updates controller position in operational state', async () => {
test('execMovement constrains controller position to safe bounds in operational state', async () => {
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
const { max } = machine._resolveSetpointBounds();
await machine.handleInput('parent', 'execMovement', 10);
const pos = machine.state.getCurrentPosition();
assert.ok(pos >= 9.9 && pos <= 10);
assert.ok(pos <= max);
assert.equal(pos, max);
});