Compare commits
9 Commits
5a0c46cb67
...
dev-Rene
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43b5269f0b | ||
|
|
c587ed9c7b | ||
|
|
9e0e3e3859 | ||
|
|
f979b1ae2b | ||
|
|
671eb5f5fb | ||
|
|
339ae6bdde | ||
| 756cc4bd20 | |||
|
|
a12c083b3f | ||
| 496c5688bc |
21
examples/README.md
Normal file
21
examples/README.md
Normal 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
111
examples/basic.flow.json
Normal 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
120
examples/edge.flow.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
142
examples/integration.flow.json
Normal file
142
examples/integration.flow.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
@@ -1,15 +1,24 @@
|
||||
<!--
|
||||
| S88-niveau | Primair (blokkleur) | Tekstkleur |
|
||||
| ---------------------- | ------------------- | ---------- |
|
||||
| **Area** | `#0f52a5` | wit |
|
||||
| **Process Cell** | `#0c99d9` | wit |
|
||||
| **Unit** | `#50a8d9` | zwart |
|
||||
| **Equipment (Module)** | `#86bbdd` | zwart |
|
||||
| **Control Module** | `#a9daee` | zwart |
|
||||
|
||||
-->
|
||||
<script src="/measurement/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||
<script src="/measurement/configData.js"></script> <!-- Load the config script for node information -->
|
||||
|
||||
<script>
|
||||
RED.nodes.registerType("measurement", {
|
||||
category: "EVOLV",
|
||||
color: "#e4a363", // color for the node based on the S88 schema
|
||||
color: "#a9daee", // color for the node based on the S88 schema
|
||||
defaults: {
|
||||
|
||||
// Define default properties
|
||||
name: { value: "sensor" }, // use asset category as name
|
||||
name: { value: "" }, // use asset category as name
|
||||
|
||||
// Define specific properties
|
||||
scaling: { value: false },
|
||||
@@ -29,6 +38,7 @@
|
||||
assetType: { value: "" },
|
||||
model: { value: "" },
|
||||
unit: { value: "" },
|
||||
assetTagNumber: { value: "" },
|
||||
|
||||
//logger properties
|
||||
enableLog: { value: false },
|
||||
@@ -48,10 +58,10 @@
|
||||
outputs: 3,
|
||||
inputLabels: ["Input"],
|
||||
outputLabels: ["process", "dbase", "parent"],
|
||||
icon: "font-awesome/fa-tachometer",
|
||||
icon: "font-awesome/fa-sliders",
|
||||
|
||||
label: function () {
|
||||
return this.positionIcon + " " + this.assetType || "Measurement";
|
||||
return (this.positionIcon || "") + " " + (this.assetType || "Measurement");
|
||||
},
|
||||
|
||||
oneditprepare: function() {
|
||||
|
||||
@@ -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 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
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "Control module measurement",
|
||||
"main": "measurement.js",
|
||||
"scripts": {
|
||||
"test": "node measurement.js"
|
||||
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -59,6 +59,7 @@ class nodeClass {
|
||||
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,
|
||||
@@ -86,8 +87,6 @@ class nodeClass {
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`position vs child for ${this.name} is ${this.config.functionality.positionVsParent} the distance is ${this.config.functionality.distance}`);
|
||||
|
||||
// Utility for formatting outputs
|
||||
this._output = new outputUtils();
|
||||
}
|
||||
@@ -141,8 +140,8 @@ class nodeClass {
|
||||
this.source.tick();
|
||||
|
||||
const raw = this.source.getOutput();
|
||||
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
||||
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
|
||||
|
||||
// Send only updated outputs on ports 0 & 1
|
||||
this.node.send([processMsg, influxMsg]);
|
||||
@@ -153,17 +152,34 @@ class nodeClass {
|
||||
*/
|
||||
_attachInputHandler() {
|
||||
this.node.on('input', (msg, send, done) => {
|
||||
switch (msg.topic) {
|
||||
case 'simulator': this.source.toggleSimulation(); break;
|
||||
case 'outlierDetection': this.source.toggleOutlierDetection(); break;
|
||||
case 'calibrate': this.source.calibrate(); break;
|
||||
case 'measurement':
|
||||
if (typeof msg.payload === 'number') {
|
||||
this.source.inputValue = parseFloat(msg.payload);
|
||||
}
|
||||
break;
|
||||
try {
|
||||
switch (msg.topic) {
|
||||
case 'simulator':
|
||||
this.source.toggleSimulation();
|
||||
break;
|
||||
case 'outlierDetection':
|
||||
this.source.toggleOutlierDetection();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,7 +190,7 @@ class nodeClass {
|
||||
this.node.on('close', (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
//clearInterval(this._statusInterval);
|
||||
done();
|
||||
if (typeof done === 'function') done();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,10 @@
|
||||
/**
|
||||
* @file Measurement.js
|
||||
*
|
||||
* Permission is hereby granted to any person obtaining a copy of this software
|
||||
* and associated documentation files (the "Software"), to use it for personal
|
||||
* or non-commercial purposes, with the following restrictions:
|
||||
*
|
||||
* 1. **No Copying or Redistribution**: The Software or any of its parts may not
|
||||
* be copied, merged, distributed, sublicensed, or sold without explicit
|
||||
* prior written permission from the author.
|
||||
*
|
||||
* 2. **Commercial Use**: Any use of the Software for commercial purposes requires
|
||||
* a valid license, obtainable only with the explicit consent of the author.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* Ownership of this code remains solely with the original author. Unauthorized
|
||||
* use of this Software is strictly prohibited.
|
||||
*
|
||||
* Author:
|
||||
* - Rene De Ren
|
||||
* Email:
|
||||
* - r.de.ren@brabantsedelta.nl
|
||||
*
|
||||
* Future Improvements:
|
||||
* - Time-based stability checks
|
||||
* - Warmup handling
|
||||
* - Dynamic outlier detection thresholds
|
||||
* - Dynamic smoothing window and methods
|
||||
* - Alarm and threshold handling
|
||||
* - Maintenance mode
|
||||
* - Historical data and trend analysis
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions');
|
||||
|
||||
/**
|
||||
* Measurement domain model.
|
||||
* Handles scaling, smoothing, outlier filtering and emits normalized measurement output.
|
||||
*/
|
||||
class Measurement {
|
||||
constructor(config={}) {
|
||||
|
||||
@@ -546,7 +510,10 @@ class Measurement {
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
21
test/README.md
Normal file
21
test/README.md
Normal 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
0
test/basic/.gitkeep
Normal file
54
test/basic/nodeclass-routing.basic.test.js
Normal file
54
test/basic/nodeclass-routing.basic.test.js
Normal 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);
|
||||
});
|
||||
25
test/basic/scaling-and-output.basic.test.js
Normal file
25
test/basic/scaling-and-output.basic.test.js
Normal 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);
|
||||
});
|
||||
16
test/basic/specific-constructor.basic.test.js
Normal file
16
test/basic/specific-constructor.basic.test.js
Normal 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
0
test/edge/.gitkeep
Normal file
28
test/edge/invalid-payload.edge.test.js
Normal file
28
test/edge/invalid-payload.edge.test.js
Normal 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]);
|
||||
});
|
||||
14
test/edge/outlier-toggle.edge.test.js
Normal file
14
test/edge/outlier-toggle.edge.test.js
Normal 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
0
test/helpers/.gitkeep
Normal file
111
test/helpers/factories.js
Normal file
111
test/helpers/factories.js
Normal 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,
|
||||
};
|
||||
0
test/integration/.gitkeep
Normal file
0
test/integration/.gitkeep
Normal file
48
test/integration/examples-flows.integration.test.js
Normal file
48
test/integration/examples-flows.integration.test.js
Normal 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);
|
||||
});
|
||||
37
test/integration/measurement-event.integration.test.js
Normal file
37
test/integration/measurement-event.integration.test.js
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user