Compare commits

..

6 Commits

Author SHA1 Message Date
znetsixe
43b5269f0b updates 2026-03-11 11:13:38 +01:00
znetsixe
c587ed9c7b working 2026-02-23 13:17:03 +01:00
znetsixe
9e0e3e3859 before functional changes by codex 2026-02-19 17:37:21 +01:00
znetsixe
f979b1ae2b updates 2026-01-29 10:22:20 +01:00
znetsixe
671eb5f5fb updates 2026-01-29 09:16:33 +01:00
znetsixe
339ae6bdde Updated naming convention for displaying 2025-11-13 19:38:25 +01:00
23 changed files with 843 additions and 495 deletions

21
examples/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Measurement Example Flows
These flows are import-ready Node-RED examples for the `measurement` node.
## Files
- `basic.flow.json`
Purpose: basic measurement injection and output inspection.
- `integration.flow.json`
Purpose: parent/child registration and periodic measurement updates.
- `edge.flow.json`
Purpose: invalid/edge payload driving for robustness checks.
## Requirements
- EVOLV `measurement` node available in Node-RED.
## Import
1. Open Node-RED import.
2. Import one `*.flow.json` file.
3. Deploy and inspect debug output.

111
examples/basic.flow.json Normal file
View File

@@ -0,0 +1,111 @@
[
{
"id": "m_tab_basic_1",
"type": "tab",
"label": "Measurement Basic",
"disabled": false,
"info": "Basic measurement flow"
},
{
"id": "m_basic_node",
"type": "measurement",
"z": "m_tab_basic_1",
"name": "M Basic",
"scaling": true,
"i_min": 0,
"i_max": 100,
"i_offset": 0,
"o_min": 0,
"o_max": 10,
"simulator": false,
"smooth_method": "mean",
"count": 5,
"uuid": "",
"supplier": "vendor",
"category": "sensor",
"assetType": "pressure",
"model": "PT-1",
"unit": "bar",
"assetTagNumber": "PT-001",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 510,
"y": 220,
"wires": [["m_basic_dbg_process"],["m_basic_dbg_influx"],["m_basic_dbg_parent"]]
},
{
"id": "m_basic_inject_measurement",
"type": "inject",
"z": "m_tab_basic_1",
"name": "measurement 42",
"props": [{"p": "topic", "vt": "str"},{"p": "payload", "vt": "num"}],
"topic": "measurement",
"payload": "42",
"payloadType": "num",
"x": 170,
"y": 220,
"wires": [["m_basic_node"]]
},
{
"id": "m_basic_inject_calibrate",
"type": "inject",
"z": "m_tab_basic_1",
"name": "calibrate",
"props": [{"p": "topic", "vt": "str"}],
"topic": "calibrate",
"x": 140,
"y": 170,
"wires": [["m_basic_node"]]
},
{
"id": "m_basic_dbg_process",
"type": "debug",
"z": "m_tab_basic_1",
"name": "M process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 750,
"y": 180,
"wires": []
},
{
"id": "m_basic_dbg_influx",
"type": "debug",
"z": "m_tab_basic_1",
"name": "M influx",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 740,
"y": 220,
"wires": []
},
{
"id": "m_basic_dbg_parent",
"type": "debug",
"z": "m_tab_basic_1",
"name": "M parent",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 740,
"y": 260,
"wires": []
}
]

120
examples/edge.flow.json Normal file
View File

@@ -0,0 +1,120 @@
[
{
"id": "m_tab_edge_1",
"type": "tab",
"label": "Measurement Edge",
"disabled": false,
"info": "Edge-case measurement flow"
},
{
"id": "m_edge_node",
"type": "measurement",
"z": "m_tab_edge_1",
"name": "M Edge",
"scaling": true,
"i_min": 0,
"i_max": 100,
"i_offset": 0,
"o_min": 0,
"o_max": 10,
"simulator": false,
"smooth_method": "mean",
"count": 5,
"supplier": "vendor",
"category": "sensor",
"assetType": "pressure",
"model": "PT-E",
"unit": "bar",
"positionVsParent": "atEquipment",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"enableLog": false,
"logLevel": "error",
"x": 510,
"y": 220,
"wires": [["m_edge_dbg_process"],["m_edge_dbg_influx"],["m_edge_dbg_parent"]]
},
{
"id": "m_edge_bad_payload",
"type": "inject",
"z": "m_tab_edge_1",
"name": "measurement bad payload",
"props": [{"p": "topic", "vt": "str"},{"p": "payload", "vt": "str"}],
"topic": "measurement",
"payload": "not-a-number",
"payloadType": "str",
"x": 170,
"y": 170,
"wires": [["m_edge_node"]]
},
{
"id": "m_edge_toggle_outlier",
"type": "inject",
"z": "m_tab_edge_1",
"name": "toggle outlier",
"props": [{"p": "topic", "vt": "str"}],
"topic": "outlierDetection",
"x": 140,
"y": 220,
"wires": [["m_edge_node"]]
},
{
"id": "m_edge_unknown_topic",
"type": "inject",
"z": "m_tab_edge_1",
"name": "unknown topic",
"props": [{"p": "topic", "vt": "str"},{"p": "payload", "vt": "num"}],
"topic": "doesNotExist",
"payload": "1",
"payloadType": "num",
"x": 150,
"y": 270,
"wires": [["m_edge_node"]]
},
{
"id": "m_edge_dbg_process",
"type": "debug",
"z": "m_tab_edge_1",
"name": "M edge process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 750,
"y": 180,
"wires": []
},
{
"id": "m_edge_dbg_influx",
"type": "debug",
"z": "m_tab_edge_1",
"name": "M edge influx",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 740,
"y": 220,
"wires": []
},
{
"id": "m_edge_dbg_parent",
"type": "debug",
"z": "m_tab_edge_1",
"name": "M edge parent",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 740,
"y": 260,
"wires": []
}
]

View File

@@ -0,0 +1,142 @@
[
{
"id": "m_tab_int_1",
"type": "tab",
"label": "Measurement Integration",
"disabled": false,
"info": "Integration-oriented measurement flow"
},
{
"id": "m_int_parent",
"type": "measurement",
"z": "m_tab_int_1",
"name": "M Parent",
"scaling": true,
"i_min": 0,
"i_max": 100,
"i_offset": 0,
"o_min": 0,
"o_max": 10,
"simulator": false,
"smooth_method": "mean",
"count": 5,
"supplier": "vendor",
"category": "sensor",
"assetType": "pressure",
"model": "PT-P",
"unit": "bar",
"positionVsParent": "atEquipment",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"enableLog": false,
"logLevel": "error",
"x": 560,
"y": 220,
"wires": [["m_int_dbg_process"],["m_int_dbg_influx"],["m_int_dbg_parent"]]
},
{
"id": "m_int_child",
"type": "measurement",
"z": "m_tab_int_1",
"name": "M Child",
"scaling": true,
"i_min": 0,
"i_max": 100,
"i_offset": 0,
"o_min": 0,
"o_max": 10,
"simulator": false,
"smooth_method": "none",
"count": 3,
"supplier": "vendor",
"category": "sensor",
"assetType": "pressure",
"model": "PT-C",
"unit": "bar",
"positionVsParent": "upstream",
"hasDistance": true,
"distance": 5,
"distanceUnit": "m",
"enableLog": false,
"logLevel": "error",
"x": 560,
"y": 360,
"wires": [[],[],[]]
},
{
"id": "m_int_register_child",
"type": "inject",
"z": "m_tab_int_1",
"name": "register child",
"props": [
{"p": "topic", "vt": "str"},
{"p": "payload", "vt": "str"},
{"p": "positionVsParent", "v": "upstream", "vt": "str"}
],
"topic": "registerChild",
"payload": "m_int_child",
"payloadType": "str",
"x": 150,
"y": 180,
"wires": [["m_int_parent"]]
},
{
"id": "m_int_measurement",
"type": "inject",
"z": "m_tab_int_1",
"name": "measurement 55",
"props": [{"p": "topic", "vt": "str"},{"p": "payload", "vt": "num"}],
"topic": "measurement",
"payload": "55",
"payloadType": "num",
"x": 150,
"y": 240,
"wires": [["m_int_parent"]]
},
{
"id": "m_int_dbg_process",
"type": "debug",
"z": "m_tab_int_1",
"name": "M int process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 810,
"y": 180,
"wires": []
},
{
"id": "m_int_dbg_influx",
"type": "debug",
"z": "m_tab_int_1",
"name": "M int influx",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 800,
"y": 220,
"wires": []
},
{
"id": "m_int_dbg_parent",
"type": "debug",
"z": "m_tab_int_1",
"name": "M int parent",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 800,
"y": 260,
"wires": []
}
]

View File

@@ -18,7 +18,7 @@
defaults: { defaults: {
// Define default properties // Define default properties
name: { value: "sensor" }, // use asset category as name name: { value: "" }, // use asset category as name
// Define specific properties // Define specific properties
scaling: { value: false }, scaling: { value: false },
@@ -30,8 +30,6 @@
simulator: { value: false }, simulator: { value: false },
smooth_method: { value: "" }, smooth_method: { value: "" },
count: { value: "10", required: true }, count: { value: "10", required: true },
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
//define asset properties //define asset properties
uuid: { value: "" }, uuid: { value: "" },
@@ -40,6 +38,7 @@
assetType: { value: "" }, assetType: { value: "" },
model: { value: "" }, model: { value: "" },
unit: { value: "" }, unit: { value: "" },
assetTagNumber: { value: "" },
//logger properties //logger properties
enableLog: { value: false }, enableLog: { value: false },
@@ -62,7 +61,7 @@
icon: "font-awesome/fa-sliders", icon: "font-awesome/fa-sliders",
label: function () { label: function () {
return this.positionIcon + " " + this.assetType || "Measurement"; return (this.positionIcon || "") + " " + (this.assetType || "Measurement");
}, },
oneditprepare: function() { oneditprepare: function() {
@@ -222,25 +221,6 @@
<div class="form-tips">Number of samples for smoothing</div> <div class="form-tips">Number of samples for smoothing</div>
</div> </div>
<hr>
<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>
<!-- Optional Extended Fields: supplier, cat, type, model, unit --> <!-- Optional Extended Fields: supplier, cat, type, model, unit -->
<!-- Asset fields will be injected here --> <!-- Asset fields will be injected here -->
<div id="asset-fields-placeholder"></div> <div id="asset-fields-placeholder"></div>

View File

@@ -1,6 +1,7 @@
const nameOfNode = 'measurement'; // this is the name of the node, it should match the file name and the node type in Node-RED const nameOfNode = 'measurement'; // this is the name of the node, it should match the file name and the node type in Node-RED
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
const { MenuManager, configManager } = require('generalFunctions'); const { MenuManager, configManager, assetApiConfig } = require('generalFunctions');
const assetUtils = require('generalFunctions/assetUtils');
// This is the main entry point for the Node-RED node, it will register the node and setup the endpoints // This is the main entry point for the Node-RED node, it will register the node and setup the endpoints
module.exports = function(RED) { module.exports = function(RED) {
@@ -37,4 +38,26 @@ module.exports = function(RED) {
} }
}); });
RED.httpAdmin.post(`/${nameOfNode}/asset-reg`, async (req, res) => {
const body = req.body || {};
const assetPayload = body.asset;
if (!assetPayload) {
return res.status(400).json({ success: false, message: 'Missing asset payload' });
}
try {
const nodeConfig = cfgMgr.getConfig(nameOfNode);
const registrationDefaults = (nodeConfig && nodeConfig.assetRegistration && nodeConfig.assetRegistration.default) || {};
const result = await assetUtils.syncAsset({
assetSelection: assetPayload,
registrationDefaults,
apiConfig: assetApiConfig,
nodeContext: { id: body.nodeId, name: body.nodeName }
});
res.json({ success: result.success, data: result.data, message: result.message });
} catch (error) {
console.error(`[${nameOfNode}] asset-reg error`, error);
res.status(500).json({ success: false, message: error.message });
}
});
}; };

View File

@@ -4,7 +4,7 @@
"description": "Control module measurement", "description": "Control module measurement",
"main": "measurement.js", "main": "measurement.js",
"scripts": { "scripts": {
"test": "node measurement.js" "test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -39,16 +39,33 @@ class nodeClass {
/** /**
* Load and merge default config with user-defined settings. * Load and merge default config with user-defined settings.
* Uses ConfigManager.buildConfig() for base sections (general, asset, functionality),
* then adds measurement-specific domain config.
* @param {object} uiConfig - Raw config from Node-RED UI. * @param {object} uiConfig - Raw config from Node-RED UI.
*/ */
_loadConfig(uiConfig,node) { _loadConfig(uiConfig,node) {
const cfgMgr = new configManager(); const cfgMgr = new configManager();
this.defaultConfig = cfgMgr.getConfig(this.name); this.defaultConfig = cfgMgr.getConfig(this.name);
// Build config: base sections + measurement-specific domain config // Merge UI config over defaults
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, { this.config = {
general: {
name: this.name,
id: node.id, // node.id is for the child registration process
unit: uiConfig.unit, // add converter options later to convert to default units (need like a model that defines this which units we are going to use and then conver to those standards)
logging: {
enabled: uiConfig.enableLog,
logLevel: uiConfig.logLevel
}
},
asset: {
uuid: uiConfig.assetUuid, //need to add this later to the asset model
tagCode: uiConfig.assetTagCode, //need to add this later to the asset model
tagNumber: uiConfig.assetTagNumber,
supplier: uiConfig.supplier,
category: uiConfig.category, //add later to define as the software type
type: uiConfig.assetType,
model: uiConfig.model,
unit: uiConfig.unit
},
scaling: { scaling: {
enabled: uiConfig.scaling, enabled: uiConfig.scaling,
inputMin: uiConfig.i_min, inputMin: uiConfig.i_min,
@@ -63,8 +80,12 @@ class nodeClass {
}, },
simulation: { simulation: {
enabled: uiConfig.simulator enabled: uiConfig.simulator
},
functionality: {
positionVsParent: uiConfig.positionVsParent,// Default to 'atEquipment' if not specified
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
} }
}); };
// Utility for formatting outputs // Utility for formatting outputs
this._output = new outputUtils(); this._output = new outputUtils();
@@ -119,8 +140,8 @@ class nodeClass {
this.source.tick(); this.source.tick();
const raw = this.source.getOutput(); const raw = this.source.getOutput();
const processMsg = this._output.formatMsg(raw, this.config, 'process'); const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb'); const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
// Send only updated outputs on ports 0 & 1 // Send only updated outputs on ports 0 & 1
this.node.send([processMsg, influxMsg]); this.node.send([processMsg, influxMsg]);
@@ -131,17 +152,34 @@ class nodeClass {
*/ */
_attachInputHandler() { _attachInputHandler() {
this.node.on('input', (msg, send, done) => { this.node.on('input', (msg, send, done) => {
switch (msg.topic) { try {
case 'simulator': this.source.toggleSimulation(); break; switch (msg.topic) {
case 'outlierDetection': this.source.toggleOutlierDetection(); break; case 'simulator':
case 'calibrate': this.source.calibrate(); break; this.source.toggleSimulation();
case 'measurement': break;
if (typeof msg.payload === 'number') { case 'outlierDetection':
this.source.inputValue = parseFloat(msg.payload); this.source.toggleOutlierDetection();
} break;
break; case 'calibrate':
this.source.calibrate();
break;
case 'measurement':
if (typeof msg.payload === 'number' || (typeof msg.payload === 'string' && msg.payload.trim() !== '')) {
const parsed = Number(msg.payload);
if (!Number.isNaN(parsed)) {
this.source.inputValue = parsed;
} else {
this.source.logger?.warn(`Invalid numeric measurement payload: ${msg.payload}`);
}
}
break;
default:
this.source.logger?.warn(`Unknown topic: ${msg.topic}`);
}
} catch (error) {
this.source.logger?.error(`Input handler failure: ${error.message}`);
} }
done(); if (typeof done === 'function') done();
}); });
} }
@@ -152,7 +190,7 @@ class nodeClass {
this.node.on('close', (done) => { this.node.on('close', (done) => {
clearInterval(this._tickInterval); clearInterval(this._tickInterval);
//clearInterval(this._statusInterval); //clearInterval(this._statusInterval);
done(); if (typeof done === 'function') done();
}); });
} }
} }

View File

@@ -1,6 +1,10 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions'); const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions');
/**
* Measurement domain model.
* Handles scaling, smoothing, outlier filtering and emits normalized measurement output.
*/
class Measurement { class Measurement {
constructor(config={}) { constructor(config={}) {
@@ -381,7 +385,7 @@ class Measurement {
const lowPass = this.lowPassFilter(arr); // Apply low-pass filter const lowPass = this.lowPassFilter(arr); // Apply low-pass filter
const highPass = this.highPassFilter(arr); // Apply high-pass filter const highPass = this.highPassFilter(arr); // Apply high-pass filter
return arr.map((val, _idx) => lowPass + highPass - val).pop(); // Combine the filters return arr.map((val, idx) => lowPass + highPass - val).pop(); // Combine the filters
} }
weightedMovingAverage(arr) { weightedMovingAverage(arr) {
@@ -506,7 +510,10 @@ class Measurement {
} }
toggleOutlierDetection() { toggleOutlierDetection() {
this.config.outlierDetection = !this.config.outlierDetection; // Keep the outlier configuration shape stable and only toggle the enabled flag.
const currentState = Boolean(this.config?.outlierDetection?.enabled);
this.config.outlierDetection = this.config.outlierDetection || {};
this.config.outlierDetection.enabled = !currentState;
} }
getOutput() { getOutput() {
@@ -558,7 +565,7 @@ const configuration = {
enabled: true, enabled: true,
}, },
functionality: { functionality: {
positionVsParent: POSITIONS.UPSTREAM positionVsParent: "upstream"
} }
}; };

21
test/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Measurement Test Suite Layout
This folder follows EVOLV standard node test structure.
## Required folders
- `basic/`
- `integration/`
- `edge/`
- `helpers/`
## Baseline files
- `basic/specific-constructor.basic.test.js`
- `basic/scaling-and-output.basic.test.js`
- `basic/nodeclass-routing.basic.test.js`
- `integration/examples-flows.integration.test.js`
- `integration/measurement-event.integration.test.js`
- `edge/invalid-payload.edge.test.js`
- `edge/outlier-toggle.edge.test.js`
Authoritative mapping for coverage intent lives in:
- `.agents/function-anchors/measurement/EVIDENCE-measurement-tests.md`

0
test/basic/.gitkeep Normal file
View File

View File

@@ -0,0 +1,54 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('_attachInputHandler routes known topics to source methods', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const calls = [];
inst.node = node;
inst.RED = makeREDStub();
inst.source = {
toggleSimulation() { calls.push('simulator'); },
toggleOutlierDetection() { calls.push('outlierDetection'); },
calibrate() { calls.push('calibrate'); },
set inputValue(v) { calls.push(['measurement', v]); },
};
inst._attachInputHandler();
const onInput = node._handlers.input;
onInput({ topic: 'simulator' }, () => {}, () => {});
onInput({ topic: 'outlierDetection' }, () => {}, () => {});
onInput({ topic: 'calibrate' }, () => {}, () => {});
onInput({ topic: 'measurement', payload: 12.3 }, () => {}, () => {});
assert.deepEqual(calls[0], 'simulator');
assert.deepEqual(calls[1], 'outlierDetection');
assert.deepEqual(calls[2], 'calibrate');
assert.deepEqual(calls[3], ['measurement', 12.3]);
});
test('_registerChild emits delayed registerChild message on output 2', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
inst.node = node;
inst.config = { functionality: { positionVsParent: 'upstream', distance: 5 } };
const originalSetTimeout = global.setTimeout;
global.setTimeout = (fn) => { fn(); return 1; };
try {
inst._registerChild();
} finally {
global.setTimeout = originalSetTimeout;
}
assert.equal(node._sent.length, 1);
assert.equal(node._sent[0][2].topic, 'registerChild');
assert.equal(node._sent[0][2].positionVsParent, 'upstream');
assert.equal(node._sent[0][2].distance, 5);
});

View File

@@ -0,0 +1,25 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeMeasurementInstance } = require('../helpers/factories');
test('calculateInput applies scaling and updates bounded output', () => {
const m = makeMeasurementInstance();
m.calculateInput(50);
const out = m.getOutput();
assert.equal(out.mAbs >= 0 && out.mAbs <= 10, true);
assert.equal(out.mPercent >= 0 && out.mPercent <= 100, true);
});
test('out-of-range input is constrained to abs range', () => {
const m = makeMeasurementInstance({
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
});
m.calculateInput(10000);
const out = m.getOutput();
assert.equal(out.mAbs, 10);
});

View File

@@ -0,0 +1,16 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeMeasurementInstance } = require('../helpers/factories');
test('Measurement constructor initializes key defaults and ranges', () => {
const m = makeMeasurementInstance();
assert.equal(m.inputValue, 0);
assert.equal(m.outputAbs, 0);
assert.equal(m.outputPercent, 0);
assert.equal(Array.isArray(m.storedValues), true);
assert.equal(typeof m.measurements, 'object');
assert.equal(m.inputRange, 100);
assert.equal(m.processRange, 10);
});

0
test/edge/.gitkeep Normal file
View File

View File

@@ -0,0 +1,28 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('measurement topic accepts numeric strings and ignores non-numeric objects', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const calls = [];
inst.node = node;
inst.RED = makeREDStub();
inst.source = {
set inputValue(v) { calls.push(v); },
toggleSimulation() {},
toggleOutlierDetection() {},
calibrate() {},
};
inst._attachInputHandler();
const onInput = node._handlers.input;
onInput({ topic: 'measurement', payload: '42' }, () => {}, () => {});
onInput({ topic: 'measurement', payload: { value: 42 } }, () => {}, () => {});
assert.deepEqual(calls, [42]);
});

View File

@@ -0,0 +1,14 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeMeasurementInstance } = require('../helpers/factories');
test('toggleOutlierDetection toggles enabled flag while preserving config object', () => {
const m = makeMeasurementInstance();
assert.equal(typeof m.config.outlierDetection, 'object');
const before = Boolean(m.config.outlierDetection.enabled);
m.toggleOutlierDetection();
assert.equal(typeof m.config.outlierDetection, 'object');
assert.equal(Boolean(m.config.outlierDetection.enabled), !before);
});

0
test/helpers/.gitkeep Normal file
View File

111
test/helpers/factories.js Normal file
View File

@@ -0,0 +1,111 @@
const Measurement = require('../../src/specificClass');
function makeUiConfig(overrides = {}) {
return {
unit: 'bar',
enableLog: false,
logLevel: 'error',
supplier: 'vendor',
category: 'sensor',
assetType: 'pressure',
model: 'PT-1',
scaling: true,
i_min: 0,
i_max: 100,
o_min: 0,
o_max: 10,
i_offset: 0,
count: 5,
smooth_method: 'mean',
simulator: false,
positionVsParent: 'atEquipment',
hasDistance: false,
distance: 0,
...overrides,
};
}
function makeMeasurementConfig(overrides = {}) {
return {
general: {
id: 'm-test-1',
name: 'measurement-test',
unit: 'bar',
logging: { enabled: false, logLevel: 'error' },
},
asset: {
uuid: '',
tagCode: '',
tagNumber: 'PT-001',
supplier: 'vendor',
category: 'sensor',
type: 'pressure',
model: 'PT-1',
unit: 'bar',
},
scaling: {
enabled: true,
inputMin: 0,
inputMax: 100,
absMin: 0,
absMax: 10,
offset: 0,
},
smoothing: {
smoothWindow: 5,
smoothMethod: 'mean',
},
simulation: {
enabled: false,
},
functionality: {
positionVsParent: 'atEquipment',
distance: undefined,
},
...overrides,
};
}
function makeNodeStub() {
const handlers = {};
const sent = [];
const status = [];
const warns = [];
return {
id: 'm-node-1',
source: null,
on(event, cb) { handlers[event] = cb; },
send(msg) { sent.push(msg); },
status(s) { status.push(s); },
warn(w) { warns.push(w); },
_handlers: handlers,
_sent: sent,
_status: status,
_warns: warns,
};
}
function makeREDStub(nodeMap = {}) {
return {
nodes: {
getNode(id) {
return nodeMap[id] || null;
},
createNode() {},
registerType() {},
},
httpAdmin: { get() {}, post() {} },
};
}
function makeMeasurementInstance(overrides = {}) {
return new Measurement(makeMeasurementConfig(overrides));
}
module.exports = {
makeUiConfig,
makeMeasurementConfig,
makeNodeStub,
makeREDStub,
makeMeasurementInstance,
};

View File

View File

@@ -0,0 +1,48 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const EXAMPLES_DIR = path.resolve(__dirname, '../../examples');
function readFlow(file) {
const full = path.join(EXAMPLES_DIR, file);
const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
assert.equal(Array.isArray(parsed), true);
return parsed;
}
function nodesByType(flow, type) {
return flow.filter((n) => n && n.type === type);
}
function injectByTopic(flow, topic) {
return flow.filter((n) => n && n.type === 'inject' && n.topic === topic);
}
test('examples package contains required files', () => {
for (const name of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
assert.equal(fs.existsSync(path.join(EXAMPLES_DIR, name)), true, `${name} missing`);
}
});
test('basic flow has measurement node and baseline injects', () => {
const flow = readFlow('basic.flow.json');
assert.equal(nodesByType(flow, 'measurement').length >= 1, true);
assert.equal(injectByTopic(flow, 'measurement').length >= 1, true);
assert.equal(injectByTopic(flow, 'calibrate').length >= 1, true);
});
test('integration flow has two measurement nodes and registerChild example', () => {
const flow = readFlow('integration.flow.json');
assert.equal(nodesByType(flow, 'measurement').length >= 2, true);
assert.equal(injectByTopic(flow, 'registerChild').length >= 1, true);
assert.equal(injectByTopic(flow, 'measurement').length >= 1, true);
});
test('edge flow contains edge-driving injects', () => {
const flow = readFlow('edge.flow.json');
assert.equal(injectByTopic(flow, 'measurement').length >= 1, true);
assert.equal(injectByTopic(flow, 'outlierDetection').length >= 1, true);
assert.equal(injectByTopic(flow, 'doesNotExist').length >= 1, true);
});

View File

@@ -0,0 +1,37 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeMeasurementInstance } = require('../helpers/factories');
test('updateOutputAbs emits measurement event with configured type/position', async () => {
const m = makeMeasurementInstance({
asset: {
uuid: '',
tagCode: '',
tagNumber: 'PT-001',
supplier: 'vendor',
category: 'sensor',
type: 'pressure',
model: 'PT-1',
unit: 'bar',
},
functionality: {
positionVsParent: 'upstream',
distance: undefined,
},
smoothing: {
smoothWindow: 1,
smoothMethod: 'none',
},
});
const event = await new Promise((resolve) => {
m.measurements.emitter.once('pressure.measured.upstream', resolve);
m.calculateInput(30);
});
assert.equal(event.type, 'pressure');
assert.equal(event.variant, 'measured');
assert.equal(event.position, 'upstream');
assert.equal(typeof event.value, 'number');
});

View File

@@ -1,448 +0,0 @@
/**
* Tests for measurement specificClass (domain logic).
*
* The Measurement class handles sensor input processing:
* - scaling (input range -> absolute range)
* - smoothing (various filter methods)
* - outlier detection (z-score, IQR, modified z-score)
* - simulation mode
* - calibration
*/
const Measurement = require('../src/specificClass');
// --------------- helpers ---------------
function makeConfig(overrides = {}) {
const base = {
general: {
name: 'TestSensor',
id: 'test-sensor-1',
logging: { enabled: false, logLevel: 'error' },
},
functionality: {
softwareType: 'measurement',
role: 'sensor',
positionVsParent: 'atEquipment',
distance: null,
},
asset: {
category: 'sensor',
type: 'pressure',
model: 'test-model',
supplier: 'TestCo',
unit: 'bar',
},
scaling: {
enabled: false,
inputMin: 0,
inputMax: 1,
absMin: 0,
absMax: 100,
offset: 0,
},
smoothing: {
smoothWindow: 5,
smoothMethod: 'none',
},
simulation: {
enabled: false,
},
interpolation: {
percentMin: 0,
percentMax: 100,
},
outlierDetection: {
enabled: false,
method: 'zScore',
threshold: 3,
},
};
// Deep-merge one level
for (const key of Object.keys(overrides)) {
if (typeof overrides[key] === 'object' && !Array.isArray(overrides[key]) && base[key]) {
base[key] = { ...base[key], ...overrides[key] };
} else {
base[key] = overrides[key];
}
}
return base;
}
// --------------- tests ---------------
describe('Measurement specificClass', () => {
describe('constructor / initialization', () => {
it('should create an instance with default config overlay', () => {
const m = new Measurement(makeConfig());
expect(m).toBeDefined();
expect(m.config.general.name).toBe('testsensor');
expect(m.outputAbs).toBe(0);
expect(m.outputPercent).toBe(0);
expect(m.storedValues).toEqual([]);
});
it('should initialize inputRange and processRange from scaling config', () => {
const m = new Measurement(makeConfig({
scaling: { enabled: true, inputMin: 4, inputMax: 20, absMin: 0, absMax: 100, offset: 0 },
}));
expect(m.inputRange).toBe(16); // |20 - 4|
expect(m.processRange).toBe(100); // |100 - 0|
});
it('should create with empty config and fall back to defaults', () => {
const m = new Measurement({});
expect(m).toBeDefined();
expect(m.config).toBeDefined();
});
});
// ---- pure math helpers ----
describe('mean()', () => {
let m;
beforeEach(() => { m = new Measurement(makeConfig()); });
it('should return the arithmetic mean', () => {
expect(m.mean([2, 4, 6])).toBe(4);
});
it('should handle a single element', () => {
expect(m.mean([7])).toBe(7);
});
});
describe('min() / max()', () => {
let m;
beforeEach(() => { m = new Measurement(makeConfig()); });
it('should return the minimum value', () => {
expect(m.min([5, 3, 9, 1])).toBe(1);
});
it('should return the maximum value', () => {
expect(m.max([5, 3, 9, 1])).toBe(9);
});
});
describe('standardDeviation()', () => {
let m;
beforeEach(() => { m = new Measurement(makeConfig()); });
it('should return 0 for a single-element array', () => {
expect(m.standardDeviation([42])).toBe(0);
});
it('should return 0 for identical values', () => {
expect(m.standardDeviation([5, 5, 5])).toBe(0);
});
it('should compute sample std dev correctly', () => {
// [2, 4, 4, 4, 5, 5, 7, 9] => mean = 5, sqDiffs sum = 32, variance = 32/7 ~ 4.571, sd ~ 2.138
const sd = m.standardDeviation([2, 4, 4, 4, 5, 5, 7, 9]);
expect(sd).toBeCloseTo(2.138, 2);
});
});
describe('medianFilter()', () => {
let m;
beforeEach(() => { m = new Measurement(makeConfig()); });
it('should return the middle element for odd-length array', () => {
expect(m.medianFilter([3, 1, 2])).toBe(2);
});
it('should return the average of two middle elements for even-length array', () => {
expect(m.medianFilter([1, 2, 3, 4])).toBe(2.5);
});
});
// ---- constrain ----
describe('constrain()', () => {
let m;
beforeEach(() => { m = new Measurement(makeConfig()); });
it('should clamp a value below min to min', () => {
expect(m.constrain(-5, 0, 100)).toBe(0);
});
it('should clamp a value above max to max', () => {
expect(m.constrain(150, 0, 100)).toBe(100);
});
it('should pass through values inside range', () => {
expect(m.constrain(50, 0, 100)).toBe(50);
});
});
// ---- interpolateLinear ----
describe('interpolateLinear()', () => {
let m;
beforeEach(() => { m = new Measurement(makeConfig()); });
it('should map input min to output min', () => {
expect(m.interpolateLinear(0, 0, 10, 0, 100)).toBe(0);
});
it('should map input max to output max', () => {
expect(m.interpolateLinear(10, 0, 10, 0, 100)).toBe(100);
});
it('should map midpoint correctly', () => {
expect(m.interpolateLinear(5, 0, 10, 0, 100)).toBe(50);
});
it('should return the input unchanged if ranges are invalid (iMin >= iMax)', () => {
expect(m.interpolateLinear(5, 10, 10, 0, 100)).toBe(5);
});
});
// ---- applyOffset ----
describe('applyOffset()', () => {
it('should add the configured offset to the value', () => {
const m = new Measurement(makeConfig({
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 10 },
}));
expect(m.applyOffset(5)).toBe(15);
});
it('should add zero offset', () => {
const m = new Measurement(makeConfig());
expect(m.applyOffset(5)).toBe(5);
});
});
// ---- handleScaling ----
describe('handleScaling()', () => {
it('should interpolate from input range to abs range', () => {
const m = new Measurement(makeConfig({
scaling: { enabled: true, inputMin: 4, inputMax: 20, absMin: 0, absMax: 100, offset: 0 },
}));
// midpoint of 4..20 = 12 => should map to 50
const result = m.handleScaling(12);
expect(result).toBeCloseTo(50, 1);
});
it('should constrain values outside input range', () => {
const m = new Measurement(makeConfig({
scaling: { enabled: true, inputMin: 0, inputMax: 10, absMin: 0, absMax: 100, offset: 0 },
}));
// value 15 > inputMax 10, should be constrained then mapped
const result = m.handleScaling(15);
expect(result).toBe(100);
});
});
// ---- applySmoothing ----
describe('applySmoothing()', () => {
it('should return the raw value when method is "none"', () => {
const m = new Measurement(makeConfig({ smoothing: { smoothWindow: 5, smoothMethod: 'none' } }));
expect(m.applySmoothing(42)).toBe(42);
});
it('should compute the mean when method is "mean"', () => {
const m = new Measurement(makeConfig({ smoothing: { smoothWindow: 5, smoothMethod: 'mean' } }));
m.applySmoothing(10);
m.applySmoothing(20);
const result = m.applySmoothing(30);
expect(result).toBe(20); // mean of [10, 20, 30]
});
it('should respect the smoothWindow limit', () => {
const m = new Measurement(makeConfig({ smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }));
m.applySmoothing(10);
m.applySmoothing(20);
m.applySmoothing(30);
const result = m.applySmoothing(40);
// window is [20, 30, 40] after shift
expect(result).toBe(30);
});
});
// ---- outlier detection ----
describe('outlierDetection()', () => {
it('should return false when there are fewer than 2 stored values', () => {
const m = new Measurement(makeConfig({
outlierDetection: { enabled: true, method: 'zScore', threshold: 3 },
}));
expect(m.outlierDetection(100)).toBe(false);
});
it('zScore: should detect a large outlier', () => {
const m = new Measurement(makeConfig({
outlierDetection: { enabled: true, method: 'zScore', threshold: 2 },
}));
// Config manager lowercases enum values, so fix the method after construction
m.config.outlierDetection.method = 'zScore';
m.storedValues = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10];
expect(m.outlierDetection(1000)).toBe(true);
});
it('zScore: should not flag values near the mean', () => {
const m = new Measurement(makeConfig({
outlierDetection: { enabled: true, method: 'zScore', threshold: 3 },
}));
m.config.outlierDetection.method = 'zScore';
m.storedValues = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10];
expect(m.outlierDetection(10.5)).toBe(false);
});
it('iqr: should detect an outlier', () => {
const m = new Measurement(makeConfig({
outlierDetection: { enabled: true, method: 'iqr', threshold: 3 },
}));
m.storedValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
expect(m.outlierDetection(100)).toBe(true);
});
});
// ---- calculateInput (integration) ----
describe('calculateInput()', () => {
it('should update outputAbs when no scaling is applied', () => {
const m = new Measurement(makeConfig({
scaling: { enabled: false, inputMin: 0, inputMax: 100, absMin: 0, absMax: 100, offset: 0 },
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
}));
m.calculateInput(42);
expect(m.outputAbs).toBe(42);
});
it('should apply offset before scaling', () => {
const m = new Measurement(makeConfig({
scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 1000, offset: 10 },
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
}));
m.calculateInput(40); // 40 + 10 = 50, scaled: 50/100 * 1000 = 500
expect(m.outputAbs).toBe(500);
});
it('should skip outlier values when outlier detection is enabled', () => {
const m = new Measurement(makeConfig({
scaling: { enabled: false, inputMin: 0, inputMax: 1000, absMin: 0, absMax: 1000, offset: 0 },
smoothing: { smoothWindow: 20, smoothMethod: 'none' },
outlierDetection: { enabled: true, method: 'iqr', threshold: 1.5 },
}));
// Seed stored values with some variance so IQR method works
for (let i = 0; i < 10; i++) m.storedValues.push(10 + (i % 3));
m.calculateInput(10); // normal value, will update
const afterNormal = m.outputAbs;
m.calculateInput(9999); // outlier, should be ignored by IQR
expect(m.outputAbs).toBe(afterNormal);
});
});
// ---- updateMinMaxValues ----
describe('updateMinMaxValues()', () => {
it('should track minimum and maximum seen values', () => {
const m = new Measurement(makeConfig());
m.updateMinMaxValues(5);
m.updateMinMaxValues(15);
m.updateMinMaxValues(3);
expect(m.totalMinValue).toBe(3);
expect(m.totalMaxValue).toBe(15);
});
});
// ---- isStable ----
describe('isStable()', () => {
it('should return false when fewer than 2 stored values', () => {
const m = new Measurement(makeConfig());
m.storedValues = [1];
expect(m.isStable()).toBe(false);
});
it('should report stable when all values are the same', () => {
const m = new Measurement(makeConfig());
m.storedValues = [5, 5, 5, 5];
const result = m.isStable();
expect(result.isStable).toBe(true);
expect(result.stdDev).toBe(0);
});
});
// ---- getOutput ----
describe('getOutput()', () => {
it('should return an object with expected keys', () => {
const m = new Measurement(makeConfig());
const out = m.getOutput();
expect(out).toHaveProperty('mAbs');
expect(out).toHaveProperty('mPercent');
expect(out).toHaveProperty('totalMinValue');
expect(out).toHaveProperty('totalMaxValue');
expect(out).toHaveProperty('totalMinSmooth');
expect(out).toHaveProperty('totalMaxSmooth');
});
});
// ---- toggleSimulation ----
describe('toggleSimulation()', () => {
it('should flip the simulation enabled flag', () => {
const m = new Measurement(makeConfig({ simulation: { enabled: false } }));
expect(m.config.simulation.enabled).toBe(false);
m.toggleSimulation();
expect(m.config.simulation.enabled).toBe(true);
m.toggleSimulation();
expect(m.config.simulation.enabled).toBe(false);
});
});
// ---- tick (simulation mode) ----
describe('tick()', () => {
it('should resolve without errors when simulation is disabled', async () => {
const m = new Measurement(makeConfig({ simulation: { enabled: false } }));
m.inputValue = 50;
await expect(m.tick()).resolves.toBeUndefined();
});
it('should generate a simulated value when simulation is enabled', async () => {
const m = new Measurement(makeConfig({
scaling: { enabled: false, inputMin: 0, inputMax: 100, absMin: 0, absMax: 100, offset: 0 },
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
simulation: { enabled: true },
}));
await m.tick();
// simValue may be 0 on first call, but it should not throw
expect(m.simValue).toBeDefined();
});
});
// ---- filter methods ----
describe('lowPassFilter()', () => {
let m;
beforeEach(() => { m = new Measurement(makeConfig()); });
it('should return the first value for a single-element array', () => {
expect(m.lowPassFilter([10])).toBe(10);
});
it('should smooth values', () => {
const result = m.lowPassFilter([10, 10, 10, 10]);
expect(result).toBeCloseTo(10, 1);
});
});
describe('weightedMovingAverage()', () => {
let m;
beforeEach(() => { m = new Measurement(makeConfig()); });
it('should give more weight to recent values', () => {
// weights [1,2,3], values [0, 0, 30] => (0*1 + 0*2 + 30*3) / 6 = 15
expect(m.weightedMovingAverage([0, 0, 30])).toBe(15);
});
});
});