Compare commits
13 Commits
1b7285f29e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
998b2002e9 | ||
|
|
fb8d5c03e6 | ||
|
|
d6f8af4395 | ||
|
|
495b4cf400 | ||
|
|
0918be7705 | ||
|
|
f7c3dc2482 | ||
|
|
ed5f02605a | ||
|
|
43b5269f0b | ||
|
|
c587ed9c7b | ||
|
|
9e0e3e3859 | ||
|
|
f979b1ae2b | ||
|
|
671eb5f5fb | ||
|
|
339ae6bdde |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# measurement — Claude Code context
|
||||||
|
|
||||||
|
Sensor signal conditioning and data quality.
|
||||||
|
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||||
|
|
||||||
|
## S88 classification
|
||||||
|
|
||||||
|
| Level | Colour | Placement lane |
|
||||||
|
|---|---|---|
|
||||||
|
| **Control Module** | `#a9daee` | L2 |
|
||||||
|
|
||||||
|
## Flow layout rules
|
||||||
|
|
||||||
|
When wiring this node into a multi-node demo or production flow, follow the
|
||||||
|
placement rule set in the **EVOLV superproject**:
|
||||||
|
|
||||||
|
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||||
|
|
||||||
|
Key points for this node:
|
||||||
|
- Place on lane **L2** (x-position per the lane table in the rule).
|
||||||
|
- Stack same-level siblings vertically.
|
||||||
|
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||||
|
- Wrap in a Node-RED group box coloured `#a9daee` (Control Module).
|
||||||
119
README.md
119
README.md
@@ -1,3 +1,118 @@
|
|||||||
# convert
|
# measurement
|
||||||
|
|
||||||
Makes unit conversions
|
Node-RED custom node for sensor signal conditioning. Takes raw input — either a single scalar (analog mode) or an MQTT-style JSON object with many keys (digital mode) — and produces scaled, smoothed, outlier-filtered measurements. Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||||
|
|
||||||
|
Registers itself on port 2 as a child of a parent equipment (rotatingMachine, pumpingStation, reactor, etc.). The parent consumes measurements via shared `MeasurementContainer` events.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.node-red
|
||||||
|
npm install github:gitea.wbd-rd.nl/RnD/measurement
|
||||||
|
```
|
||||||
|
|
||||||
|
Or pull the whole platform via the superproject. Restart Node-RED and the node appears in the palette under **EVOLV**.
|
||||||
|
|
||||||
|
## Two input modes
|
||||||
|
|
||||||
|
### Analog mode (default)
|
||||||
|
|
||||||
|
One scalar per message — the classic PLC / 4-20mA pattern.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "topic": "measurement", "payload": 42 }
|
||||||
|
```
|
||||||
|
|
||||||
|
The node runs one offset → scaling → smoothing → outlier pipeline and emits exactly one MeasurementContainer slot. Every existing flow built before digital mode keeps working unchanged.
|
||||||
|
|
||||||
|
### Digital mode (MQTT / IoT)
|
||||||
|
|
||||||
|
One object per message, many keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "topic": "measurement",
|
||||||
|
"payload": { "temperature": 22.5, "humidity": 45, "pressure": 1013 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Each key maps to its own **channel** with independently-configured scaling, smoothing, outlier detection, type, position, unit, and distance. A single inbound message therefore emits N MeasurementContainer slots — one per channel — so a downstream parent sees everything at once.
|
||||||
|
|
||||||
|
Pick the mode in the editor or via `msg.mode`. Analog is the default; digital requires populating `channels` (see *Configuration*).
|
||||||
|
|
||||||
|
## Input topics
|
||||||
|
|
||||||
|
| Topic | Payload | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `measurement` | analog mode: `number` or numeric `string` — stored as `inputValue` and consumed on the next tick. digital mode: `object` keyed by channel names. | drives the pipeline |
|
||||||
|
| `simulator` | — | toggles the simulator flag |
|
||||||
|
| `outlierDetection` | — | toggles outlier detection |
|
||||||
|
| `calibrate` | — | adjust the scaling offset so current output matches `inputMin` (scaling on) or `absMin` (scaling off). Requires a stable window. |
|
||||||
|
|
||||||
|
## Output ports
|
||||||
|
|
||||||
|
| Port | Label | Payload |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | `process` | analog: `{mAbs, mPercent, totalMinValue, totalMaxValue, totalMinSmooth, totalMaxSmooth}`. digital: `{channels: {<key>: {mAbs, mPercent, ...}}}`. Delta-compressed — only changed fields emit each tick. |
|
||||||
|
| 1 | `dbase` | InfluxDB line-protocol telemetry |
|
||||||
|
| 2 | `parent` | `{topic:"registerChild", payload:<nodeId>, positionVsParent, distance}` emitted once ~180ms after deploy |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Common (both modes)
|
||||||
|
|
||||||
|
- **Asset** (menu): supplier, category, `assetType` (measurement type in the container — `pressure`, `flow`, `temperature`, `power`, or any user-defined type like `humidity`), model, unit.
|
||||||
|
- **Logger** (menu): log level + enable flag.
|
||||||
|
- **Position** (menu): `upstream` / `atEquipment` / `downstream` relative to parent; optional distance offset.
|
||||||
|
|
||||||
|
### Analog-mode fields
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `Scaling` (checkbox) | enables linear source→process interpolation |
|
||||||
|
| `Source Min / Max` | input-side range (e.g. 4–20 mA) |
|
||||||
|
| `Input Offset` | additive bias applied before scaling |
|
||||||
|
| `Process Min / Max` | output-side range (e.g. 0–3000 mbar) |
|
||||||
|
| `Simulator` (checkbox) | internal random-walk source |
|
||||||
|
| `Smoothing` | one of: `none`, `mean`, `min`, `max`, `sd`, `lowPass`, `highPass`, `weightedMovingAverage`, `bandPass`, `median`, `kalman`, `savitzkyGolay` |
|
||||||
|
| `Window` | sample count for the smoothing window |
|
||||||
|
|
||||||
|
### Digital-mode fields
|
||||||
|
|
||||||
|
- **Mode**: set to `digital`.
|
||||||
|
- **Channels**: JSON array, one entry per channel. Each entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "temperature",
|
||||||
|
"type": "temperature",
|
||||||
|
"position": "atEquipment",
|
||||||
|
"unit": "C",
|
||||||
|
"scaling": { "enabled": false, "inputMin": 0, "inputMax": 1, "absMin": -50, "absMax": 150, "offset": 0 },
|
||||||
|
"smoothing": { "smoothWindow": 5, "smoothMethod": "mean" },
|
||||||
|
"outlierDetection": { "enabled": true, "method": "zScore", "threshold": 3 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scaling`, `smoothing`, `outlierDetection` are optional — the node falls back to the top-level analog-mode equivalents when missing. `key` is the JSON field name inside `msg.payload`; `type` is the MeasurementContainer axis (can be any string — unknown types are accepted).
|
||||||
|
|
||||||
|
## State and emit contract
|
||||||
|
|
||||||
|
Every channel runs the same pipeline: `outlier → offset → scaling → smoothing → min/max tracking → constrain → emit`. Output is rounded to two decimals. MeasurementContainer events follow the pattern `<type>.<variant>.<position>` all lowercase, e.g. `temperature.measured.atequipment`.
|
||||||
|
|
||||||
|
Unknown measurement types (anything not in the container's built-in measureMap — `pressure`, `flow`, `power`, `temperature`, `volume`, `length`, `mass`, `energy`) are accepted without unit compatibility checks. Known types still validate strictly.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nodes/measurement
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
71 tests cover every smoothing method, every outlier strategy, scaling, interpolation, constrain, calibration, stability, simulation, output-percent fallback, per-channel pipelines, digital payload dispatch, registration events, and example-flow shape.
|
||||||
|
|
||||||
|
## Production status
|
||||||
|
|
||||||
|
Last reviewed **2026-04-13**. See the project memory file `node_measurement.md` for the current verdict, benchmarks, and wishlist.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
SEE LICENSE. Author: Rene De Ren, Waterschap Brabantse Delta R&D.
|
||||||
|
|||||||
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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
375
measurement.html
375
measurement.html
@@ -18,9 +18,13 @@
|
|||||||
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
|
// Input mode: 'analog' (scalar payload, default) or 'digital' (object payload, many channels)
|
||||||
|
mode: { value: "analog" },
|
||||||
|
channels: { value: "[]" },
|
||||||
|
|
||||||
|
// Define specific properties (analog-mode pipeline defaults)
|
||||||
scaling: { value: false },
|
scaling: { value: false },
|
||||||
i_min: { value: 0, required: true },
|
i_min: { value: 0, required: true },
|
||||||
i_max: { value: 0, required: true },
|
i_max: { value: 0, required: true },
|
||||||
@@ -30,6 +34,8 @@
|
|||||||
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: "" },
|
||||||
@@ -38,6 +44,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 },
|
||||||
@@ -60,62 +67,123 @@
|
|||||||
icon: "font-awesome/fa-sliders",
|
icon: "font-awesome/fa-sliders",
|
||||||
|
|
||||||
label: function () {
|
label: function () {
|
||||||
return this.positionIcon + " " + this.assetType || "Measurement";
|
const modeTag = this.mode === 'digital' ? ' [digital]' : '';
|
||||||
|
return (this.positionIcon || "") + " " + (this.assetType || "Measurement") + modeTag;
|
||||||
},
|
},
|
||||||
|
|
||||||
oneditprepare: function() {
|
oneditprepare: function() {
|
||||||
|
const node = this;
|
||||||
|
|
||||||
|
// === Asset / logger / position placeholders (dynamic menus) ===
|
||||||
|
// Kick these off FIRST so that any error in the downstream mode
|
||||||
|
// logic can never block the shared menus. Historical regression:
|
||||||
|
// a ReferenceError in the mode block aborted oneditprepare and
|
||||||
|
// stopped the asset menu from rendering at all.
|
||||||
const waitForMenuData = () => {
|
const waitForMenuData = () => {
|
||||||
if (window.EVOLV?.nodes?.measurement?.initEditor) {
|
if (window.EVOLV?.nodes?.measurement?.initEditor) {
|
||||||
window.EVOLV.nodes.measurement.initEditor(this);
|
window.EVOLV.nodes.measurement.initEditor(node);
|
||||||
} else {
|
} else {
|
||||||
setTimeout(waitForMenuData, 50);
|
setTimeout(waitForMenuData, 50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Wait for the menu data to be ready before initializing the editor
|
|
||||||
waitForMenuData();
|
waitForMenuData();
|
||||||
|
|
||||||
// THIS IS NODE SPECIFIC --------------- Initialize the dropdowns and other specific UI elements -------------- this should be derived from the config in the future (make config based menu)
|
// IMPORTANT: all DOM references are resolved up front so helper
|
||||||
// Populate smoothing methods dropdown
|
// functions called during initial applyMode() don't trip over the
|
||||||
|
// Temporal Dead Zone on later `const` declarations.
|
||||||
|
|
||||||
|
const modeSelect = document.getElementById('node-input-mode');
|
||||||
|
const analogBlock = document.getElementById('analog-only-fields');
|
||||||
|
const digitalBlock = document.getElementById('digital-only-fields');
|
||||||
|
const modeHint = document.getElementById('mode-hint');
|
||||||
|
const channelsArea = document.getElementById('node-input-channels');
|
||||||
|
const channelsHint = document.getElementById('channels-validation');
|
||||||
|
|
||||||
|
// Initialise the mode <select> from the saved node.mode. Legacy
|
||||||
|
// nodes (saved before the mode field existed) fall back to
|
||||||
|
// 'analog' so they keep behaving exactly like before.
|
||||||
|
const initialMode = (node.mode === 'digital' || node.mode === 'analog') ? node.mode : 'analog';
|
||||||
|
if (modeSelect) modeSelect.value = initialMode;
|
||||||
|
|
||||||
|
// Populate the channels textarea from the saved node.channels
|
||||||
|
// (stored as a raw JSON string; parsing happens server-side).
|
||||||
|
if (channelsArea && typeof node.channels === 'string') {
|
||||||
|
channelsArea.value = node.channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateChannelsJson() {
|
||||||
|
if (!channelsHint) return;
|
||||||
|
if (!modeSelect || modeSelect.value !== 'digital') {
|
||||||
|
channelsHint.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = (channelsArea && channelsArea.value || '').trim();
|
||||||
|
if (!raw || raw === '[]') {
|
||||||
|
channelsHint.innerHTML = '<span style="color:#b45309;">Digital mode with no channels — no measurements will be emitted.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) throw new Error('must be an array');
|
||||||
|
const missing = parsed
|
||||||
|
.map((c, i) => (c && c.key && c.type ? null : 'entry ' + i + ': missing key or type'))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (missing.length) {
|
||||||
|
channelsHint.innerHTML = '<span style="color:#b45309;">' + missing.join('; ') + '</span>';
|
||||||
|
} else {
|
||||||
|
channelsHint.innerHTML = '<span style="color:#047857;">' + parsed.length + ' channel(s) defined: ' + parsed.map((c) => c.key).join(', ') + '</span>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
channelsHint.innerHTML = '<span style="color:#b91c1c;">Invalid JSON: ' + e.message + '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMode(mode) {
|
||||||
|
const isDigital = mode === 'digital';
|
||||||
|
if (analogBlock) analogBlock.style.display = isDigital ? 'none' : 'block';
|
||||||
|
if (digitalBlock) digitalBlock.style.display = isDigital ? 'block' : 'none';
|
||||||
|
if (modeHint) {
|
||||||
|
modeHint.textContent = isDigital
|
||||||
|
? 'msg.payload must be an OBJECT, e.g. {"temperature": 22.5, "humidity": 45}. Define each key below.'
|
||||||
|
: 'msg.payload must be a NUMBER (or numeric string). Configure scaling/smoothing below.';
|
||||||
|
}
|
||||||
|
validateChannelsJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeSelect) modeSelect.addEventListener('change', (e) => applyMode(e.target.value));
|
||||||
|
if (channelsArea) channelsArea.addEventListener('input', validateChannelsJson);
|
||||||
|
try { applyMode(initialMode); } catch (e) {
|
||||||
|
console.error('measurement: applyMode failed', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Smoothing method dropdown (analog only) ===
|
||||||
const smoothMethodSelect = document.getElementById('node-input-smooth_method');
|
const smoothMethodSelect = document.getElementById('node-input-smooth_method');
|
||||||
const options = window.EVOLV?.nodes?.measurement?.config?.smoothing?.smoothMethod?.rules?.values || [];
|
const options = window.EVOLV?.nodes?.measurement?.config?.smoothing?.smoothMethod?.rules?.values || [];
|
||||||
|
|
||||||
// Clear existing options
|
|
||||||
smoothMethodSelect.innerHTML = '';
|
smoothMethodSelect.innerHTML = '';
|
||||||
|
|
||||||
// Add empty option
|
|
||||||
const emptyOption = document.createElement('option');
|
const emptyOption = document.createElement('option');
|
||||||
emptyOption.value = '';
|
emptyOption.value = '';
|
||||||
emptyOption.textContent = 'Select method...';
|
emptyOption.textContent = 'Select method...';
|
||||||
smoothMethodSelect.appendChild(emptyOption);
|
smoothMethodSelect.appendChild(emptyOption);
|
||||||
|
|
||||||
// Add smoothing method options
|
|
||||||
options.forEach(option => {
|
options.forEach(option => {
|
||||||
const optionElement = document.createElement('option');
|
const optionElement = document.createElement('option');
|
||||||
optionElement.value = option.value;
|
optionElement.value = option.value;
|
||||||
optionElement.textContent = option.value;
|
optionElement.textContent = option.value;
|
||||||
optionElement.title = option.description; // Add tooltip with full description
|
optionElement.title = option.description;
|
||||||
smoothMethodSelect.appendChild(optionElement);
|
smoothMethodSelect.appendChild(optionElement);
|
||||||
});
|
});
|
||||||
|
if (node.smooth_method) smoothMethodSelect.value = node.smooth_method;
|
||||||
|
|
||||||
// Set current value if it exists
|
// === Scale rows toggle (analog only) ===
|
||||||
if (this.smooth_method) {
|
const chk = document.getElementById('node-input-scaling');
|
||||||
smoothMethodSelect.value = this.smooth_method;
|
const rowMin = document.getElementById('row-input-i_min');
|
||||||
|
const rowMax = document.getElementById('row-input-i_max');
|
||||||
|
function toggleScalingRows() {
|
||||||
|
const show = chk.checked;
|
||||||
|
rowMin.style.display = show ? 'block' : 'none';
|
||||||
|
rowMax.style.display = show ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
chk.addEventListener('change', toggleScalingRows);
|
||||||
// --- Scale rows toggle ---
|
toggleScalingRows();
|
||||||
const chk = document.getElementById('node-input-scaling');
|
|
||||||
const rowMin = document.getElementById('row-input-i_min');
|
|
||||||
const rowMax = document.getElementById('row-input-i_max');
|
|
||||||
|
|
||||||
function toggleScalingRows() {
|
|
||||||
const show = chk.checked;
|
|
||||||
rowMin.style.display = show ? 'block' : 'none';
|
|
||||||
rowMax.style.display = show ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// wire and initialize
|
|
||||||
chk.addEventListener('change', toggleScalingRows);
|
|
||||||
toggleScalingRows();
|
|
||||||
|
|
||||||
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
|
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
|
||||||
},
|
},
|
||||||
@@ -137,12 +205,20 @@
|
|||||||
window.EVOLV.nodes.measurement.positionMenu.saveEditor(this);
|
window.EVOLV.nodes.measurement.positionMenu.saveEditor(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save basic properties
|
// Mode is the top-level switch. Always save it first; its value
|
||||||
["smooth_method"].forEach(
|
// drives which other fields are meaningful.
|
||||||
(field) => (node[field] = document.getElementById(`node-input-${field}`).value || "")
|
node.mode = document.getElementById('node-input-mode').value || 'analog';
|
||||||
);
|
|
||||||
|
|
||||||
// Save numeric and boolean properties
|
// Channels JSON (digital). We store the raw string and let the
|
||||||
|
// server-side nodeClass.js parse it so we can surface parse errors
|
||||||
|
// at deploy time instead of silently dropping bad config.
|
||||||
|
node.channels = document.getElementById('node-input-channels').value || '[]';
|
||||||
|
|
||||||
|
// Analog smoothing method.
|
||||||
|
node.smooth_method = document.getElementById('node-input-smooth_method').value || '';
|
||||||
|
|
||||||
|
// Save checkbox properties (always safe to read regardless of mode;
|
||||||
|
// these elements exist in the DOM even when their section is hidden).
|
||||||
["scaling", "simulator"].forEach(
|
["scaling", "simulator"].forEach(
|
||||||
(field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
|
(field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
|
||||||
);
|
);
|
||||||
@@ -151,11 +227,22 @@
|
|||||||
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
|
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validation checks
|
// Mode-dependent validation. In digital mode we don't care about
|
||||||
if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
|
// scaling completeness (the channels have their own per-channel
|
||||||
|
// scaling); in analog mode we still warn about half-filled ranges.
|
||||||
|
if (node.mode === 'analog' && node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
|
||||||
RED.notify("Scaling enabled, but input range is incomplete!", "error");
|
RED.notify("Scaling enabled, but input range is incomplete!", "error");
|
||||||
}
|
}
|
||||||
|
if (node.mode === 'digital') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(node.channels || '[]');
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||||
|
RED.notify("Digital mode: no channels defined. The node will emit nothing.", "warning");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
RED.notify("Digital mode: Channels JSON is invalid (" + e.message + ")", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -164,60 +251,103 @@
|
|||||||
|
|
||||||
<script type="text/html" data-template-name="measurement">
|
<script type="text/html" data-template-name="measurement">
|
||||||
|
|
||||||
<!-- Scaling Checkbox -->
|
<!-- Input mode -->
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-scaling"
|
<label for="node-input-mode"><i class="fa fa-exchange"></i> Input Mode</label>
|
||||||
><i class="fa fa-compress"></i> Scaling</label>
|
<select id="node-input-mode" style="width:60%;">
|
||||||
<input type="checkbox" id="node-input-scaling" style="width:20px; vertical-align:baseline;"/>
|
<option value="analog">analog — one scalar per msg.payload (classic PLC)</option>
|
||||||
<span>Enable input scaling?</span>
|
<option value="digital">digital — object payload with many channel keys (MQTT/IoT)</option>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Source Min/Max (only if scaling is true) -->
|
|
||||||
<div class="form-row" id="row-input-i_min">
|
|
||||||
<label for="node-input-i_min"><i class="fa fa-arrow-down"></i> Source Min</label>
|
|
||||||
<input type="number" id="node-input-i_min" placeholder="0" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row" id="row-input-i_max">
|
|
||||||
<label for="node-input-i_max"><i class="fa fa-arrow-up"></i> Source Max</label>
|
|
||||||
<input type="number" id="node-input-i_max" placeholder="3000" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Offset -->
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-i_offset"><i class="fa fa-adjust"></i> Input Offset</label>
|
|
||||||
<input type="number" id="node-input-i_offset" placeholder="0" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Output / Process Min/Max -->
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-o_min"><i class="fa fa-tag"></i> Process Min</label>
|
|
||||||
<input type="number" id="node-input-o_min" placeholder="0" />
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-o_max"><i class="fa fa-tag"></i> Process Max</label>
|
|
||||||
<input type="number" id="node-input-o_max" placeholder="1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Simulator Checkbox -->
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-simulator"><i class="fa fa-cog"></i> Simulator</label>
|
|
||||||
<input type="checkbox" id="node-input-simulator" style="width:20px; vertical-align:baseline;"/>
|
|
||||||
<span>Activate internal simulation?</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Smoothing Method -->
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-smooth_method"><i class="fa fa-line-chart"></i> Smoothing</label>
|
|
||||||
<select id="node-input-smooth_method" style="width:60%;">
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row" id="mode-hint" style="margin-left:105px; font-size:12px; color:#666;"></div>
|
||||||
|
|
||||||
<!-- Smoothing Window -->
|
<!-- ===================== DIGITAL MODE FIELDS ===================== -->
|
||||||
|
<div id="digital-only-fields">
|
||||||
|
<div class="form-row" id="row-input-channels">
|
||||||
|
<label for="node-input-channels"><i class="fa fa-list"></i> Channels (JSON)</label>
|
||||||
|
<textarea id="node-input-channels" rows="6" style="width:60%; font-family:monospace;" placeholder='[{"key":"temperature","type":"temperature","position":"atEquipment","unit":"C","scaling":{"enabled":false,"inputMin":0,"inputMax":1,"absMin":-50,"absMax":150,"offset":0},"smoothing":{"smoothWindow":5,"smoothMethod":"mean"}}]'></textarea>
|
||||||
|
<div class="form-tips">One entry per payload key. Each channel has its own type / position / unit / scaling / smoothing / outlier detection. See README for the full schema.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="channels-validation" style="margin-left:105px; font-size:12px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== ANALOG MODE FIELDS ===================== -->
|
||||||
|
<div id="analog-only-fields">
|
||||||
|
<hr>
|
||||||
|
<!-- Scaling Checkbox -->
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-scaling"
|
||||||
|
><i class="fa fa-compress"></i> Scaling</label>
|
||||||
|
<input type="checkbox" id="node-input-scaling" style="width:20px; vertical-align:baseline;"/>
|
||||||
|
<span>Enable input scaling?</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Min/Max (only if scaling is true) -->
|
||||||
|
<div class="form-row" id="row-input-i_min">
|
||||||
|
<label for="node-input-i_min"><i class="fa fa-arrow-down"></i> Source Min</label>
|
||||||
|
<input type="number" id="node-input-i_min" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row" id="row-input-i_max">
|
||||||
|
<label for="node-input-i_max"><i class="fa fa-arrow-up"></i> Source Max</label>
|
||||||
|
<input type="number" id="node-input-i_max" placeholder="3000" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offset -->
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-i_offset"><i class="fa fa-adjust"></i> Input Offset</label>
|
||||||
|
<input type="number" id="node-input-i_offset" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output / Process Min/Max -->
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-o_min"><i class="fa fa-tag"></i> Process Min</label>
|
||||||
|
<input type="number" id="node-input-o_min" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-o_max"><i class="fa fa-tag"></i> Process Max</label>
|
||||||
|
<input type="number" id="node-input-o_max" placeholder="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Simulator Checkbox -->
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-simulator"><i class="fa fa-cog"></i> Simulator</label>
|
||||||
|
<input type="checkbox" id="node-input-simulator" style="width:20px; vertical-align:baseline;"/>
|
||||||
|
<span>Activate internal simulation?</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Smoothing Method -->
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-smooth_method"><i class="fa fa-line-chart"></i> Smoothing</label>
|
||||||
|
<select id="node-input-smooth_method" style="width:60%;">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Smoothing Window -->
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-count">Window</label>
|
||||||
|
<input type="number" id="node-input-count" placeholder="10" style="width:60px;"/>
|
||||||
|
<div class="form-tips">Number of samples for smoothing</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h3>Output Formats</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-count">Window</label>
|
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||||
<input type="number" id="node-input-count" placeholder="10" style="width:60px;"/>
|
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||||
<div class="form-tips">Number of samples for smoothing</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Optional Extended Fields: supplier, cat, type, model, unit -->
|
<!-- Optional Extended Fields: supplier, cat, type, model, unit -->
|
||||||
@@ -234,20 +364,49 @@
|
|||||||
|
|
||||||
|
|
||||||
<script type="text/html" data-help-name="measurement">
|
<script type="text/html" data-help-name="measurement">
|
||||||
<p><b>Measurement Node</b>: Scales, smooths, and simulates measurement data.</p>
|
<p><b>Measurement</b>: signal conditioning for a sensor or a bundle of sensors. Runs offset → scaling → smoothing → outlier filtering on each incoming value and publishes into the shared <code>MeasurementContainer</code>.</p>
|
||||||
<p>Use this node to scale, smooth, and simulate measurement data. The node can be configured to scale input data to a specified range, smooth the data using a variety of methods, and simulate data for testing purposes.</p>
|
|
||||||
<li><b>Supplier:</b> Select a supplier to populate machine options.</li>
|
|
||||||
<li><b>SubType:</b> Select a subtype if applicable to further categorize the asset.</li>
|
|
||||||
<li><b>Model:</b> Define the specific model for more granular asset configuration.</li>
|
|
||||||
<li><b>Unit:</b> Assign a unit to standardize measurements or operations.</li>
|
|
||||||
<li><b>Scaling:</b> Enable or disable input scaling. When enabled, you must provide the source min and max values.</li>
|
|
||||||
<li><b>Source Min/Max:</b> Define the minimum and maximum values for the input range when scaling is enabled.</li>
|
|
||||||
<li><b>Input Offset:</b> Specify an offset value to be added to the input measurement.</li>
|
|
||||||
<li><b>Process Min/Max:</b> Define the minimum and maximum values for the output range after processing.</li>
|
|
||||||
<li><b>Simulator:</b> Activate internal simulation for testing purposes.</li>
|
|
||||||
<li><b>Smoothing:</b> Select a smoothing method to apply to the measurement data.</li>
|
|
||||||
<li><b>Window:</b> Define the number of samples to use for smoothing.</li>
|
|
||||||
<li><b>Enable Log:</b> Enable or disable logging for this node.</li>
|
|
||||||
<li><b>Log Level:</b> Select the log level (Info, Debug, Warn, Error) for logging messages.</li>
|
|
||||||
|
|
||||||
|
<h3>Input modes</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>analog</b> (default) — <code>msg.payload</code> is a single number (PLC / 4-20 mA style). One pipeline, one output measurement.</li>
|
||||||
|
<li><b>digital</b> — <code>msg.payload</code> is an object with many keys (MQTT / JSON IoT). Each key maps to its own <i>channel</i> with independent scaling, smoothing, outlier detection, type, position, unit. One message → N measurements.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Topics (<code>msg.topic</code>)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>measurement</code> — main input. analog: number; digital: object keyed by channel names.</li>
|
||||||
|
<li><code>simulator</code> — toggle the internal random-walk source.</li>
|
||||||
|
<li><code>outlierDetection</code> — toggle the outlier filter.</li>
|
||||||
|
<li><code>calibrate</code> — set offset so current output matches <code>Source Min</code> (scaling on) / <code>Process Min</code> (scaling off). Requires a stable window.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Output ports</h3>
|
||||||
|
<ol>
|
||||||
|
<li><b>process</b> — delta-compressed payload. analog: <code>{mAbs, mPercent, totalMinValue, totalMaxValue, totalMinSmooth, totalMaxSmooth}</code>. digital: <code>{channels: { key: {...} }}</code>.</li>
|
||||||
|
<li><b>dbase</b> — InfluxDB line-protocol telemetry.</li>
|
||||||
|
<li><b>parent</b> — <code>registerChild</code> handshake for the parent equipment node.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Analog configuration</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>Scaling</b>: enables linear interpolation from <code>[Source Min, Source Max]</code> to <code>[Process Min, Process Max]</code>.</li>
|
||||||
|
<li><b>Input Offset</b>: additive bias applied before scaling.</li>
|
||||||
|
<li><b>Smoothing</b>: <code>none</code> | <code>mean</code> | <code>min</code> | <code>max</code> | <code>sd</code> | <code>lowPass</code> | <code>highPass</code> | <code>weightedMovingAverage</code> | <code>bandPass</code> | <code>median</code> | <code>kalman</code> | <code>savitzkyGolay</code>.</li>
|
||||||
|
<li><b>Window</b>: sample count for the smoothing window.</li>
|
||||||
|
<li><b>Outlier detection</b> (via <code>outlierDetection</code> topic toggle): <code>zScore</code>, <code>iqr</code>, <code>modifiedZScore</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Digital configuration</h3>
|
||||||
|
<p>Populate the <b>Channels (JSON)</b> field with an array. Each entry:</p>
|
||||||
|
<pre>{
|
||||||
|
"key": "temperature",
|
||||||
|
"type": "temperature",
|
||||||
|
"position": "atEquipment",
|
||||||
|
"unit": "C",
|
||||||
|
"scaling": { "enabled": false, "inputMin": 0, "inputMax": 1, "absMin": -50, "absMax": 150, "offset": 0 },
|
||||||
|
"smoothing": { "smoothWindow": 5, "smoothMethod": "mean" },
|
||||||
|
"outlierDetection": { "enabled": true, "method": "zScore", "threshold": 3 }
|
||||||
|
}</pre>
|
||||||
|
<p><code>scaling</code>, <code>smoothing</code>, <code>outlierDetection</code> are optional — missing sections fall back to the analog-mode fields above.</p>
|
||||||
|
<p>Unknown <code>type</code> values (anything not in <code>pressure/flow/power/temperature/volume/length/mass/energy</code>) are accepted without unit compatibility checks, so user-defined channels like <code>humidity</code>, <code>co2</code>, <code>voc</code> work out of the box.</p>
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
};
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
311
src/channel.js
Normal file
311
src/channel.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Channel — a single scalar measurement pipeline.
|
||||||
|
*
|
||||||
|
* A Channel owns one rolling window of stored values, one smoothing method,
|
||||||
|
* one outlier detector, one scaling contract, and one MeasurementContainer
|
||||||
|
* slot. It exposes `update(value)` as the single entry point.
|
||||||
|
*
|
||||||
|
* The measurement node composes Channels:
|
||||||
|
* - analog mode -> exactly one Channel built from the flat top-level config
|
||||||
|
* - digital mode -> one Channel per `config.channels[i]` entry, keyed by
|
||||||
|
* `channel.key` (the field inside msg.payload that feeds it)
|
||||||
|
*
|
||||||
|
* This file is pure domain logic. It must never reach into Node-RED APIs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Channel {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.key - identifier inside an incoming object payload (digital) or null (analog)
|
||||||
|
* @param {string} opts.type - MeasurementContainer axis (e.g. 'pressure')
|
||||||
|
* @param {string} opts.position - 'upstream' | 'atEquipment' | 'downstream'
|
||||||
|
* @param {string} opts.unit - output unit label (e.g. 'mbar')
|
||||||
|
* @param {number|null} opts.distance - physical offset from parent equipment
|
||||||
|
* @param {object} opts.scaling - {enabled, inputMin, inputMax, absMin, absMax, offset}
|
||||||
|
* @param {object} opts.smoothing - {smoothWindow, smoothMethod}
|
||||||
|
* @param {object} [opts.outlierDetection] - {enabled, method, threshold}
|
||||||
|
* @param {object} opts.interpolation - {percentMin, percentMax}
|
||||||
|
* @param {object} opts.measurements - the MeasurementContainer to publish into
|
||||||
|
* @param {object} opts.logger - generalFunctions logger instance
|
||||||
|
*/
|
||||||
|
constructor(opts) {
|
||||||
|
this.key = opts.key || null;
|
||||||
|
this.type = opts.type;
|
||||||
|
this.position = opts.position;
|
||||||
|
this.unit = opts.unit;
|
||||||
|
this.distance = opts.distance ?? null;
|
||||||
|
|
||||||
|
this.scaling = { ...opts.scaling };
|
||||||
|
this.smoothing = { ...opts.smoothing };
|
||||||
|
this.outlierDetection = opts.outlierDetection ? { ...opts.outlierDetection } : { enabled: false, method: 'zscore', threshold: 3 };
|
||||||
|
this.interpolation = { ...(opts.interpolation || { percentMin: 0, percentMax: 100 }) };
|
||||||
|
|
||||||
|
this.measurements = opts.measurements;
|
||||||
|
this.logger = opts.logger;
|
||||||
|
|
||||||
|
this.storedValues = [];
|
||||||
|
this.inputValue = 0;
|
||||||
|
this.outputAbs = 0;
|
||||||
|
this.outputPercent = 0;
|
||||||
|
|
||||||
|
this.totalMinValue = Infinity;
|
||||||
|
this.totalMaxValue = -Infinity;
|
||||||
|
this.totalMinSmooth = 0;
|
||||||
|
this.totalMaxSmooth = 0;
|
||||||
|
|
||||||
|
this.inputRange = Math.abs(this.scaling.inputMax - this.scaling.inputMin);
|
||||||
|
this.processRange = Math.abs(this.scaling.absMax - this.scaling.absMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public entry point ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a new scalar value through the full pipeline:
|
||||||
|
* outlier -> offset -> scaling -> smoothing -> min/max -> emit
|
||||||
|
* @param {number} value
|
||||||
|
* @returns {boolean} true if the value advanced the pipeline (not rejected as outlier)
|
||||||
|
*/
|
||||||
|
update(value) {
|
||||||
|
this.inputValue = value;
|
||||||
|
|
||||||
|
if (this.outlierDetection.enabled && this._isOutlier(value)) {
|
||||||
|
this.logger?.warn?.(`[${this.key || this.type}] Outlier detected. Ignoring value=${value}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let v = value + (this.scaling.offset || 0);
|
||||||
|
this._updateMinMax(v);
|
||||||
|
|
||||||
|
if (this.scaling.enabled) {
|
||||||
|
v = this._applyScaling(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoothed = this._applySmoothing(v);
|
||||||
|
this._updateSmoothMinMax(smoothed);
|
||||||
|
this._writeOutput(smoothed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutput() {
|
||||||
|
return {
|
||||||
|
key: this.key,
|
||||||
|
type: this.type,
|
||||||
|
position: this.position,
|
||||||
|
unit: this.unit,
|
||||||
|
mAbs: this.outputAbs,
|
||||||
|
mPercent: this.outputPercent,
|
||||||
|
totalMinValue: this.totalMinValue === Infinity ? 0 : this.totalMinValue,
|
||||||
|
totalMaxValue: this.totalMaxValue === -Infinity ? 0 : this.totalMaxValue,
|
||||||
|
totalMinSmooth: this.totalMinSmooth,
|
||||||
|
totalMaxSmooth: this.totalMaxSmooth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Outlier detection ---
|
||||||
|
|
||||||
|
_isOutlier(val) {
|
||||||
|
if (this.storedValues.length < 2) return false;
|
||||||
|
const raw = this.outlierDetection.method;
|
||||||
|
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||||
|
switch (method) {
|
||||||
|
case 'zscore': return this._zScore(val);
|
||||||
|
case 'iqr': return this._iqr(val);
|
||||||
|
case 'modifiedzscore': return this._modifiedZScore(val);
|
||||||
|
default:
|
||||||
|
this.logger?.warn?.(`[${this.key || this.type}] Unknown outlier method "${raw}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_zScore(val) {
|
||||||
|
const threshold = this.outlierDetection.threshold || 3;
|
||||||
|
const m = Channel._mean(this.storedValues);
|
||||||
|
const sd = Channel._stdDev(this.storedValues);
|
||||||
|
// Intentionally do NOT early-return on sd===0: a perfectly stable
|
||||||
|
// baseline should make any deviation an outlier (z = Infinity > threshold).
|
||||||
|
const z = sd === 0 ? (val === m ? 0 : Infinity) : (val - m) / sd;
|
||||||
|
return Math.abs(z) > threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
_iqr(val) {
|
||||||
|
const sorted = [...this.storedValues].sort((a, b) => a - b);
|
||||||
|
const q1 = sorted[Math.floor(sorted.length / 4)];
|
||||||
|
const q3 = sorted[Math.floor(sorted.length * 3 / 4)];
|
||||||
|
const iqr = q3 - q1;
|
||||||
|
return val < q1 - 1.5 * iqr || val > q3 + 1.5 * iqr;
|
||||||
|
}
|
||||||
|
|
||||||
|
_modifiedZScore(val) {
|
||||||
|
const median = Channel._median(this.storedValues);
|
||||||
|
const mad = Channel._median(this.storedValues.map((v) => Math.abs(v - median)));
|
||||||
|
if (mad === 0) return false;
|
||||||
|
const mz = 0.6745 * (val - median) / mad;
|
||||||
|
const threshold = this.outlierDetection.threshold || 3.5;
|
||||||
|
return Math.abs(mz) > threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scaling ---
|
||||||
|
|
||||||
|
_applyScaling(value) {
|
||||||
|
if (this.inputRange <= 0) {
|
||||||
|
this.logger?.warn?.(`[${this.key || this.type}] Input range invalid; falling back to [0,1].`);
|
||||||
|
this.scaling.inputMin = 0;
|
||||||
|
this.scaling.inputMax = 1;
|
||||||
|
this.inputRange = 1;
|
||||||
|
}
|
||||||
|
const clamped = Math.min(Math.max(value, this.scaling.inputMin), this.scaling.inputMax);
|
||||||
|
return this.scaling.absMin + ((clamped - this.scaling.inputMin) * (this.scaling.absMax - this.scaling.absMin)) / this.inputRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Smoothing ---
|
||||||
|
|
||||||
|
_applySmoothing(value) {
|
||||||
|
this.storedValues.push(value);
|
||||||
|
if (this.storedValues.length > this.smoothing.smoothWindow) {
|
||||||
|
this.storedValues.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = this.smoothing.smoothMethod;
|
||||||
|
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||||
|
const arr = this.storedValues;
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'none': return arr[arr.length - 1];
|
||||||
|
case 'mean': return Channel._mean(arr);
|
||||||
|
case 'min': return Math.min(...arr);
|
||||||
|
case 'max': return Math.max(...arr);
|
||||||
|
case 'sd': return Channel._stdDev(arr);
|
||||||
|
case 'median': return Channel._median(arr);
|
||||||
|
case 'weightedmovingaverage': return Channel._wma(arr);
|
||||||
|
case 'lowpass': return Channel._lowPass(arr);
|
||||||
|
case 'highpass': return Channel._highPass(arr);
|
||||||
|
case 'bandpass': return Channel._bandPass(arr);
|
||||||
|
case 'kalman': return Channel._kalman(arr);
|
||||||
|
case 'savitzkygolay': return Channel._savitzkyGolay(arr);
|
||||||
|
default:
|
||||||
|
this.logger?.error?.(`[${this.key || this.type}] Smoothing method "${raw}" not implemented.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Output writes ---
|
||||||
|
|
||||||
|
_updateMinMax(value) {
|
||||||
|
if (value < this.totalMinValue) this.totalMinValue = value;
|
||||||
|
if (value > this.totalMaxValue) this.totalMaxValue = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateSmoothMinMax(value) {
|
||||||
|
if (this.totalMinSmooth === 0 && this.totalMaxSmooth === 0) {
|
||||||
|
this.totalMinSmooth = value;
|
||||||
|
this.totalMaxSmooth = value;
|
||||||
|
}
|
||||||
|
if (value < this.totalMinSmooth) this.totalMinSmooth = value;
|
||||||
|
if (value > this.totalMaxSmooth) this.totalMaxSmooth = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_writeOutput(val) {
|
||||||
|
const clamped = Math.min(Math.max(val, this.scaling.absMin), this.scaling.absMax);
|
||||||
|
const rounded = Math.round(clamped * 100) / 100;
|
||||||
|
|
||||||
|
if (rounded !== this.outputAbs) {
|
||||||
|
this.outputAbs = rounded;
|
||||||
|
this.outputPercent = this._computePercent(clamped);
|
||||||
|
this.measurements
|
||||||
|
?.type(this.type)
|
||||||
|
.variant('measured')
|
||||||
|
.position(this.position)
|
||||||
|
.distance(this.distance)
|
||||||
|
.value(this.outputAbs, Date.now(), this.unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_computePercent(value) {
|
||||||
|
const { percentMin, percentMax } = this.interpolation;
|
||||||
|
let pct;
|
||||||
|
if (this.processRange <= 0) {
|
||||||
|
const lo = this.totalMinValue === Infinity ? 0 : this.totalMinValue;
|
||||||
|
const hi = this.totalMaxValue === -Infinity ? 1 : this.totalMaxValue;
|
||||||
|
pct = this._lerp(value, lo, hi, percentMin, percentMax);
|
||||||
|
} else {
|
||||||
|
pct = this._lerp(value, this.scaling.absMin, this.scaling.absMax, percentMin, percentMax);
|
||||||
|
}
|
||||||
|
return Math.round(pct * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lerp(n, iMin, iMax, oMin, oMax) {
|
||||||
|
if (iMin >= iMax || oMin >= oMax) return n;
|
||||||
|
return oMin + ((n - iMin) * (oMax - oMin)) / (iMax - iMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pure math helpers (static so they're reusable) ---
|
||||||
|
|
||||||
|
static _mean(arr) {
|
||||||
|
if (!arr.length) return 0;
|
||||||
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _stdDev(arr) {
|
||||||
|
if (arr.length <= 1) return 0;
|
||||||
|
const m = Channel._mean(arr);
|
||||||
|
const variance = arr.map((v) => (v - m) ** 2).reduce((a, b) => a + b, 0) / (arr.length - 1);
|
||||||
|
return Math.sqrt(variance);
|
||||||
|
}
|
||||||
|
|
||||||
|
static _median(arr) {
|
||||||
|
const sorted = [...arr].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _wma(arr) {
|
||||||
|
const weights = arr.map((_, i) => i + 1);
|
||||||
|
const weightedSum = arr.reduce((sum, v, i) => sum + v * weights[i], 0);
|
||||||
|
const weightTotal = weights.reduce((s, w) => s + w, 0);
|
||||||
|
return weightedSum / weightTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _lowPass(arr) {
|
||||||
|
const alpha = 0.2;
|
||||||
|
let out = arr[0];
|
||||||
|
for (let i = 1; i < arr.length; i++) out = alpha * arr[i] + (1 - alpha) * out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _highPass(arr) {
|
||||||
|
const alpha = 0.8;
|
||||||
|
const filtered = [arr[0]];
|
||||||
|
for (let i = 1; i < arr.length; i++) {
|
||||||
|
filtered[i] = alpha * (filtered[i - 1] + arr[i] - arr[i - 1]);
|
||||||
|
}
|
||||||
|
return filtered[filtered.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _bandPass(arr) {
|
||||||
|
const lp = Channel._lowPass(arr);
|
||||||
|
const hp = Channel._highPass(arr);
|
||||||
|
return arr.map((v) => lp + hp - v).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
static _kalman(arr) {
|
||||||
|
let estimate = arr[0];
|
||||||
|
const measurementNoise = 1;
|
||||||
|
const processNoise = 0.1;
|
||||||
|
const gain = processNoise / (processNoise + measurementNoise);
|
||||||
|
for (let i = 1; i < arr.length; i++) estimate = estimate + gain * (arr[i] - estimate);
|
||||||
|
return estimate;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _savitzkyGolay(arr) {
|
||||||
|
const coeffs = [-3, 12, 17, 12, -3];
|
||||||
|
const norm = coeffs.reduce((a, b) => a + b, 0);
|
||||||
|
if (arr.length < coeffs.length) return arr[arr.length - 1];
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < coeffs.length; i++) {
|
||||||
|
s += arr[arr.length - coeffs.length + i] * coeffs[i];
|
||||||
|
}
|
||||||
|
return s / norm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Channel;
|
||||||
100
src/nodeClass.js
100
src/nodeClass.js
@@ -48,6 +48,18 @@ class nodeClass {
|
|||||||
this.defaultConfig = cfgMgr.getConfig(this.name);
|
this.defaultConfig = cfgMgr.getConfig(this.name);
|
||||||
|
|
||||||
// Build config: base sections + measurement-specific domain config
|
// Build config: base sections + measurement-specific domain config
|
||||||
|
// `channels` (digital mode) is stored on the UI as a JSON string to
|
||||||
|
// avoid requiring a custom editor table widget at first. We parse here;
|
||||||
|
// invalid JSON is logged and the node falls back to an empty array.
|
||||||
|
let channels = [];
|
||||||
|
if (typeof uiConfig.channels === 'string' && uiConfig.channels.trim()) {
|
||||||
|
try { channels = JSON.parse(uiConfig.channels); }
|
||||||
|
catch (e) { node.warn(`Invalid channels JSON: ${e.message}`); channels = []; }
|
||||||
|
} else if (Array.isArray(uiConfig.channels)) {
|
||||||
|
channels = uiConfig.channels;
|
||||||
|
}
|
||||||
|
const mode = (typeof uiConfig.mode === 'string' && uiConfig.mode.toLowerCase() === 'digital') ? 'digital' : 'analog';
|
||||||
|
|
||||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
||||||
scaling: {
|
scaling: {
|
||||||
enabled: uiConfig.scaling,
|
enabled: uiConfig.scaling,
|
||||||
@@ -63,7 +75,9 @@ class nodeClass {
|
|||||||
},
|
},
|
||||||
simulation: {
|
simulation: {
|
||||||
enabled: uiConfig.simulator
|
enabled: uiConfig.simulator
|
||||||
}
|
},
|
||||||
|
mode: { current: mode },
|
||||||
|
channels,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Utility for formatting outputs
|
// Utility for formatting outputs
|
||||||
@@ -83,10 +97,18 @@ class nodeClass {
|
|||||||
*/
|
*/
|
||||||
_bindEvents() {
|
_bindEvents() {
|
||||||
|
|
||||||
|
// Analog mode: the classic 'mAbs' event pushes a green dot with the
|
||||||
|
// current value + unit to the editor.
|
||||||
this.source.emitter.on('mAbs', (val) => {
|
this.source.emitter.on('mAbs', (val) => {
|
||||||
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
|
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Digital mode: summarise how many channels have ticked a value.
|
||||||
|
// This runs on every accepted channel update so the editor shows live
|
||||||
|
// activity instead of staying blank when no single scalar exists.
|
||||||
|
if (this.source.mode === 'digital') {
|
||||||
|
this.node.status({ fill: 'blue', shape: 'ring', text: `digital · ${this.source.channels.size} channel(s)` });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,9 +140,15 @@ class nodeClass {
|
|||||||
_tick() {
|
_tick() {
|
||||||
this.source.tick();
|
this.source.tick();
|
||||||
|
|
||||||
const raw = this.source.getOutput();
|
// In digital mode we don't funnel through calculateInput with a single
|
||||||
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
// scalar; instead each Channel has already emitted into the
|
||||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
// MeasurementContainer on message arrival. The tick payload carries a
|
||||||
|
// per-channel snapshot so downstream flows still see a heartbeat.
|
||||||
|
const raw = (this.source.mode === 'digital')
|
||||||
|
? this.source.getDigitalOutput()
|
||||||
|
: this.source.getOutput();
|
||||||
|
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
|
// Send only updated outputs on ports 0 & 1
|
||||||
this.node.send([processMsg, influxMsg]);
|
this.node.send([processMsg, influxMsg]);
|
||||||
@@ -131,17 +159,59 @@ 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':
|
||||||
|
// Dispatch based on mode:
|
||||||
|
// analog -> scalar payload (number or numeric string)
|
||||||
|
// digital -> object payload keyed by channel name
|
||||||
|
if (this.source.mode === 'digital') {
|
||||||
|
if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
|
||||||
|
const summary = this.source.handleDigitalPayload(msg.payload);
|
||||||
|
// Summarise what actually got accepted on the node status so
|
||||||
|
// the editor shows a heartbeat per message.
|
||||||
|
const accepted = Object.values(summary).filter((s) => s.ok).length;
|
||||||
|
const total = Object.keys(summary).length;
|
||||||
|
this.node.status({ fill: 'green', shape: 'dot',
|
||||||
|
text: `digital · ${accepted}/${total} ch updated` });
|
||||||
|
} else if (typeof msg.payload === 'number') {
|
||||||
|
// Helpful hint: the user probably configured the wrong mode.
|
||||||
|
this.source.logger?.warn(`digital mode received a number (${msg.payload}); expected an object like {key: value, ...}. Switch Input Mode to 'analog' in the editor or send an object payload.`);
|
||||||
|
} else {
|
||||||
|
this.source.logger?.warn(`digital mode expects an object payload; got ${typeof msg.payload}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
} else if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
|
||||||
|
// Helpful hint: the payload is object-shaped but the node is
|
||||||
|
// configured analog. Most likely the user wanted digital mode.
|
||||||
|
const keys = Object.keys(msg.payload).slice(0, 3).join(', ');
|
||||||
|
this.source.logger?.warn(`analog mode received an object payload (keys: ${keys}). Switch Input Mode to 'digital' in the editor and define channels, or feed a numeric 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 +222,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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions');
|
const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions');
|
||||||
|
const Channel = require('./channel');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measurement domain model.
|
||||||
|
*
|
||||||
|
* Supports two input modes:
|
||||||
|
* - `analog` (default): one scalar value per msg.payload. The node runs the
|
||||||
|
* classic offset / scaling / smoothing / outlier pipeline on it and emits
|
||||||
|
* exactly one measurement into the MeasurementContainer. This is the
|
||||||
|
* original behaviour; every existing flow keeps working unchanged.
|
||||||
|
* - `digital`: msg.payload is an object with many key/value pairs (MQTT /
|
||||||
|
* IoT style). The node builds one Channel per config.channels entry and
|
||||||
|
* routes each key through its own mini-pipeline, emitting N measurements
|
||||||
|
* into the MeasurementContainer from a single input message.
|
||||||
|
*
|
||||||
|
* Mode is selected via `config.mode.current`. When no mode config is present
|
||||||
|
* or mode=analog, the node behaves identically to pre-digital releases.
|
||||||
|
*/
|
||||||
class Measurement {
|
class Measurement {
|
||||||
constructor(config={}) {
|
constructor(config={}) {
|
||||||
|
|
||||||
@@ -46,8 +63,106 @@ class Measurement {
|
|||||||
this.inputRange = Math.abs(this.config.scaling.inputMax - this.config.scaling.inputMin);
|
this.inputRange = Math.abs(this.config.scaling.inputMax - this.config.scaling.inputMin);
|
||||||
this.processRange = Math.abs(this.config.scaling.absMax - this.config.scaling.absMin);
|
this.processRange = Math.abs(this.config.scaling.absMax - this.config.scaling.absMin);
|
||||||
|
|
||||||
this.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully.`);
|
// Mode + multi-channel (digital) support. Backward-compatible: when the
|
||||||
|
// config does not declare a mode, we fall back to 'analog' and behave
|
||||||
|
// exactly like the original single-channel node.
|
||||||
|
this.mode = (this.config.mode && typeof this.config.mode.current === 'string')
|
||||||
|
? this.config.mode.current.toLowerCase()
|
||||||
|
: 'analog';
|
||||||
|
this.channels = new Map(); // populated only in digital mode
|
||||||
|
if (this.mode === 'digital') {
|
||||||
|
this._buildDigitalChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully. mode=${this.mode} channels=${this.channels.size}`);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one Channel per entry in config.channels. Each Channel gets its
|
||||||
|
* own scaling / smoothing / outlier / position / unit contract; they share
|
||||||
|
* the parent MeasurementContainer so a downstream parent sees all channels
|
||||||
|
* via the same emitter.
|
||||||
|
*/
|
||||||
|
_buildDigitalChannels() {
|
||||||
|
const entries = Array.isArray(this.config.channels) ? this.config.channels : [];
|
||||||
|
if (entries.length === 0) {
|
||||||
|
this.logger.warn(`digital mode enabled but config.channels is empty; no channels will be emitted.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const raw of entries) {
|
||||||
|
if (!raw || typeof raw !== 'object' || !raw.key || !raw.type) {
|
||||||
|
this.logger.warn(`skipping invalid channel entry: ${JSON.stringify(raw)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const channel = new Channel({
|
||||||
|
key: raw.key,
|
||||||
|
type: raw.type,
|
||||||
|
position: raw.position || this.config.functionality?.positionVsParent || 'atEquipment',
|
||||||
|
unit: raw.unit || this.config.asset?.unit || 'unitless',
|
||||||
|
distance: raw.distance ?? this.config.functionality?.distance ?? null,
|
||||||
|
scaling: raw.scaling || { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
|
||||||
|
smoothing: raw.smoothing || { smoothWindow: this.config.smoothing.smoothWindow, smoothMethod: this.config.smoothing.smoothMethod },
|
||||||
|
outlierDetection: raw.outlierDetection || this.config.outlierDetection,
|
||||||
|
interpolation: raw.interpolation || this.config.interpolation,
|
||||||
|
measurements: this.measurements,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
|
this.channels.set(raw.key, channel);
|
||||||
|
}
|
||||||
|
this.logger.info(`digital mode: built ${this.channels.size} channel(s) from config.channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Digital mode entry point. Iterate the object payload, look up each key
|
||||||
|
* in the channel map, and run the configured pipeline per channel. Keys
|
||||||
|
* that are not mapped are logged once per call and ignored.
|
||||||
|
* @param {object} payload - e.g. { temperature: 21.5, humidity: 45.2 }
|
||||||
|
* @returns {object} summary of updated channels (for diagnostics)
|
||||||
|
*/
|
||||||
|
handleDigitalPayload(payload) {
|
||||||
|
if (this.mode !== 'digital') {
|
||||||
|
this.logger.warn(`handleDigitalPayload called while mode=${this.mode}. Ignoring.`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
|
this.logger.warn(`digital payload must be an object; got ${typeof payload}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const summary = {};
|
||||||
|
const unknown = [];
|
||||||
|
for (const [key, raw] of Object.entries(payload)) {
|
||||||
|
const channel = this.channels.get(key);
|
||||||
|
if (!channel) {
|
||||||
|
unknown.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
this.logger.warn(`digital channel '${key}' received non-numeric value: ${raw}`);
|
||||||
|
summary[key] = { ok: false, reason: 'non-numeric' };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ok = channel.update(v);
|
||||||
|
summary[key] = { ok, mAbs: channel.outputAbs, mPercent: channel.outputPercent };
|
||||||
|
}
|
||||||
|
if (unknown.length) {
|
||||||
|
this.logger.debug(`digital payload contained unmapped keys: ${unknown.join(', ')}`);
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return per-channel output snapshots. In analog mode this is the same
|
||||||
|
* getOutput() contract; in digital mode it returns one snapshot per
|
||||||
|
* channel under a `channels` key so the tick output stays JSON-shaped.
|
||||||
|
*/
|
||||||
|
getDigitalOutput() {
|
||||||
|
const out = { channels: {} };
|
||||||
|
for (const [key, ch] of this.channels) {
|
||||||
|
out.channels[key] = ch.getOutput();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Config Initializers -------- //
|
// -------- Config Initializers -------- //
|
||||||
@@ -166,17 +281,23 @@ class Measurement {
|
|||||||
outlierDetection(val) {
|
outlierDetection(val) {
|
||||||
if (this.storedValues.length < 2) return false;
|
if (this.storedValues.length < 2) return false;
|
||||||
|
|
||||||
this.logger.debug(`Outlier detection method: ${this.config.outlierDetection.method}`);
|
// Config enum values are normalized to lowercase by validateEnum in
|
||||||
|
// generalFunctions, so dispatch on the lowercase form to keep this
|
||||||
|
// tolerant of both legacy (camelCase) and normalized (lowercase) config.
|
||||||
|
const raw = this.config.outlierDetection.method;
|
||||||
|
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||||
|
|
||||||
switch (this.config.outlierDetection.method) {
|
this.logger.debug(`Outlier detection method: ${method}`);
|
||||||
case 'zScore':
|
|
||||||
|
switch (method) {
|
||||||
|
case 'zscore':
|
||||||
return this.zScoreOutlierDetection(val);
|
return this.zScoreOutlierDetection(val);
|
||||||
case 'iqr':
|
case 'iqr':
|
||||||
return this.iqrOutlierDetection(val);
|
return this.iqrOutlierDetection(val);
|
||||||
case 'modifiedZScore':
|
case 'modifiedzscore':
|
||||||
return this.modifiedZScoreOutlierDetection(val);
|
return this.modifiedZScoreOutlierDetection(val);
|
||||||
default:
|
default:
|
||||||
this.logger.warn(`Outlier detection method "${this.config.outlierDetection.method}" is not recognized.`);
|
this.logger.warn(`Outlier detection method "${raw}" is not recognized.`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,28 +423,31 @@ class Measurement {
|
|||||||
this.storedValues.shift();
|
this.storedValues.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smoothing strategies
|
// Smoothing strategies keyed by the normalized (lowercase) method name.
|
||||||
|
// validateEnum in generalFunctions lowercases enum values, so dispatch on
|
||||||
|
// the lowercase form to accept both legacy (camelCase) and normalized
|
||||||
|
// (lowercase) config values.
|
||||||
const smoothingMethods = {
|
const smoothingMethods = {
|
||||||
none: (arr) => arr[arr.length - 1],
|
none: (arr) => arr[arr.length - 1],
|
||||||
mean: (arr) => this.mean(arr),
|
mean: (arr) => this.mean(arr),
|
||||||
min: (arr) => this.min(arr),
|
min: (arr) => this.min(arr),
|
||||||
max: (arr) => this.max(arr),
|
max: (arr) => this.max(arr),
|
||||||
sd: (arr) => this.standardDeviation(arr),
|
sd: (arr) => this.standardDeviation(arr),
|
||||||
lowPass: (arr) => this.lowPassFilter(arr),
|
lowpass: (arr) => this.lowPassFilter(arr),
|
||||||
highPass: (arr) => this.highPassFilter(arr),
|
highpass: (arr) => this.highPassFilter(arr),
|
||||||
weightedMovingAverage: (arr) => this.weightedMovingAverage(arr),
|
weightedmovingaverage: (arr) => this.weightedMovingAverage(arr),
|
||||||
bandPass: (arr) => this.bandPassFilter(arr),
|
bandpass: (arr) => this.bandPassFilter(arr),
|
||||||
median: (arr) => this.medianFilter(arr),
|
median: (arr) => this.medianFilter(arr),
|
||||||
kalman: (arr) => this.kalmanFilter(arr),
|
kalman: (arr) => this.kalmanFilter(arr),
|
||||||
savitzkyGolay: (arr) => this.savitzkyGolayFilter(arr),
|
savitzkygolay: (arr) => this.savitzkyGolayFilter(arr),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure the smoothing method is valid
|
const raw = this.config.smoothing.smoothMethod;
|
||||||
const method = this.config.smoothing.smoothMethod;
|
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||||
this.logger.debug(`Applying smoothing method "${method}"`);
|
this.logger.debug(`Applying smoothing method "${method}"`);
|
||||||
|
|
||||||
if (!smoothingMethods[method]) {
|
if (!smoothingMethods[method]) {
|
||||||
this.logger.error(`Smoothing method "${method}" is not implemented.`);
|
this.logger.error(`Smoothing method "${raw}" is not implemented.`);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +630,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() {
|
||||||
|
|||||||
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
121
test/basic/calibration-and-stability.basic.test.js
Normal file
121
test/basic/calibration-and-stability.basic.test.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeMeasurementInstance } = require('../helpers/factories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the calibration / stability / repeatability primitives. These
|
||||||
|
* methods interact with the stored window from the smoothing pipeline, so
|
||||||
|
* each test seeds storedValues explicitly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("isStable returns false with fewer than 2 samples", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
m.storedValues = [];
|
||||||
|
assert.equal(m.isStable(), false); // current implementation returns false (not object) at <2 samples
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isStable reports stability and stdDev for a flat window", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
m.storedValues = [10, 10, 10, 10, 10];
|
||||||
|
const { isStable, stdDev } = m.isStable();
|
||||||
|
assert.equal(isStable, true);
|
||||||
|
assert.equal(stdDev, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluateRepeatability returns stdDev when conditions are met", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
m.storedValues = [10, 10, 10, 10, 10];
|
||||||
|
const rep = m.evaluateRepeatability();
|
||||||
|
assert.equal(rep, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluateRepeatability refuses when smoothing is disabled", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
m.storedValues = [10, 10, 10, 10, 10];
|
||||||
|
assert.equal(m.evaluateRepeatability(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluateRepeatability refuses with insufficient samples", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
m.storedValues = [10];
|
||||||
|
assert.equal(m.evaluateRepeatability(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calibrate sets offset when input is stable and scaling enabled", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: true, inputMin: 4, inputMax: 20, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
// Stable window fed through calculateInput so outputAbs reflects the
|
||||||
|
// pipeline (important because calibrate uses outputAbs for its delta).
|
||||||
|
[3, 3, 3, 3, 3].forEach((v) => m.calculateInput(v));
|
||||||
|
const outputBefore = m.outputAbs;
|
||||||
|
m.calibrate();
|
||||||
|
// Offset should now be inputMin - outputAbs(before).
|
||||||
|
assert.equal(m.config.scaling.offset, 4 - outputBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calibrate aborts when input is not stable", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 10, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
// Cheat: populate storedValues with clearly non-stable data. calibrate
|
||||||
|
// calls isStable() -> stdDev > threshold -> warn + no offset change.
|
||||||
|
m.storedValues = [0, 100, 0, 100, 0];
|
||||||
|
const offsetBefore = m.config.scaling.offset;
|
||||||
|
m.calibrate();
|
||||||
|
assert.equal(m.config.scaling.offset, offsetBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calibrate uses absMin when scaling is disabled", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 5, absMax: 10, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
[5, 5, 5, 5, 5].forEach((v) => m.calculateInput(v));
|
||||||
|
const out = m.outputAbs;
|
||||||
|
m.calibrate();
|
||||||
|
assert.equal(m.config.scaling.offset, 5 - out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toggleSimulation flips the simulation flag", () => {
|
||||||
|
const m = makeMeasurementInstance({ simulation: { enabled: false } });
|
||||||
|
m.toggleSimulation();
|
||||||
|
assert.equal(m.config.simulation.enabled, true);
|
||||||
|
m.toggleSimulation();
|
||||||
|
assert.equal(m.config.simulation.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tick runs simulateInput when simulation is enabled", async () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
simulation: { enabled: true },
|
||||||
|
});
|
||||||
|
const before = m.inputValue;
|
||||||
|
await m.tick();
|
||||||
|
await m.tick();
|
||||||
|
await m.tick();
|
||||||
|
// Simulated input must drift from its initial state.
|
||||||
|
assert.notEqual(m.inputValue, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tick is a no-op on inputValue when simulation is disabled", async () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
simulation: { enabled: false },
|
||||||
|
});
|
||||||
|
m.inputValue = 42;
|
||||||
|
await m.tick();
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.inputValue, 42);
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
98
test/basic/outlier-detection.basic.test.js
Normal file
98
test/basic/outlier-detection.basic.test.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeMeasurementInstance } = require('../helpers/factories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit coverage for the three outlier detection strategies shipped by the
|
||||||
|
* measurement node. Each test seeds the storedValues window first, then
|
||||||
|
* probes the classifier directly. This keeps the assertions focused on the
|
||||||
|
* detection logic rather than the full calculateInput pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeDetector(method, threshold) {
|
||||||
|
return makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -1000, absMax: 1000, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 20, smoothMethod: 'none' },
|
||||||
|
outlierDetection: { enabled: true, method, threshold },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(m, values) {
|
||||||
|
// bypass calculateInput so we don't trigger outlier filtering while seeding
|
||||||
|
m.storedValues = [...values];
|
||||||
|
}
|
||||||
|
|
||||||
|
test("zScore flags a value far above the mean as an outlier", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(100), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zScore does not flag a value inside the distribution", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(11), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("iqr flags a value outside Q1/Q3 fences", () => {
|
||||||
|
const m = makeDetector('iqr');
|
||||||
|
seed(m, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(100), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("iqr does not flag a value inside Q1/Q3 fences", () => {
|
||||||
|
const m = makeDetector('iqr');
|
||||||
|
seed(m, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(5), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modifiedZScore flags heavy-tailed outliers", () => {
|
||||||
|
const m = makeDetector('modifiedZScore', 3.5);
|
||||||
|
seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(1000), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modifiedZScore accepts normal data", () => {
|
||||||
|
const m = makeDetector('modifiedZScore', 3.5);
|
||||||
|
seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(11), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown outlier method falls back to schema default (zScore) and still runs", () => {
|
||||||
|
// validateEnum replaces unknown values with the schema default. The
|
||||||
|
// schema default is "zScore"; the dispatcher normalizes to lowercase
|
||||||
|
// and routes to zScoreOutlierDetection. With a tight window, value=100
|
||||||
|
// is a clear outlier -> returns true.
|
||||||
|
const m = makeDetector('bogus', 3);
|
||||||
|
seed(m, [1, 2, 3, 4, 5]);
|
||||||
|
assert.equal(m.outlierDetection(100), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("outlier detection returns false when window has < 2 samples", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
m.storedValues = [];
|
||||||
|
assert.equal(m.outlierDetection(500), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculateInput ignores a value flagged as outlier", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
// Build a tight baseline then throw a spike at it.
|
||||||
|
[10, 10, 10, 10, 10].forEach((v) => m.calculateInput(v));
|
||||||
|
const before = m.outputAbs;
|
||||||
|
m.calculateInput(9999);
|
||||||
|
// Output must not move to the spike (outlier rejected).
|
||||||
|
assert.equal(m.outputAbs, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toggleOutlierDetection flips the flag without corrupting config", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
const initial = m.config.outlierDetection.enabled;
|
||||||
|
m.toggleOutlierDetection();
|
||||||
|
assert.equal(m.config.outlierDetection.enabled, !initial);
|
||||||
|
// Re-toggle restores
|
||||||
|
m.toggleOutlierDetection();
|
||||||
|
assert.equal(m.config.outlierDetection.enabled, initial);
|
||||||
|
// Method is preserved (enum values are normalized to lowercase by validateEnum).
|
||||||
|
assert.equal(m.config.outlierDetection.method.toLowerCase(), 'zscore');
|
||||||
|
});
|
||||||
122
test/basic/scaling-and-interpolation.basic.test.js
Normal file
122
test/basic/scaling-and-interpolation.basic.test.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeMeasurementInstance } = require('../helpers/factories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the scaling / offset / interpolation primitives and the min/max
|
||||||
|
* tracking side effects that are not exercised by the existing
|
||||||
|
* scaling-and-output test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("applyOffset adds configured offset to the input", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 7 },
|
||||||
|
});
|
||||||
|
assert.equal(m.applyOffset(10), 17);
|
||||||
|
assert.equal(m.applyOffset(-3), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interpolateLinear maps within range", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
assert.equal(m.interpolateLinear(50, 0, 100, 0, 10), 5);
|
||||||
|
assert.equal(m.interpolateLinear(0, 0, 100, 0, 10), 0);
|
||||||
|
assert.equal(m.interpolateLinear(100, 0, 100, 0, 10), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interpolateLinear warns and returns input when ranges collapse", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
// iMin == iMax -> invalid
|
||||||
|
assert.equal(m.interpolateLinear(42, 0, 0, 0, 10), 42);
|
||||||
|
// oMin > oMax -> invalid
|
||||||
|
assert.equal(m.interpolateLinear(42, 0, 100, 10, 0), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("constrain clamps below, inside, and above range", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
assert.equal(m.constrain(-5, 0, 10), 0);
|
||||||
|
assert.equal(m.constrain(5, 0, 10), 5);
|
||||||
|
assert.equal(m.constrain(15, 0, 10), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handleScaling falls back when inputRange is invalid", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: true, inputMin: 5, inputMax: 5, absMin: 0, absMax: 10, offset: 0 },
|
||||||
|
});
|
||||||
|
// Before the call, inputRange is 0 (5-5). handleScaling should reset
|
||||||
|
// inputMin/inputMax to defaults [0, 1] and still return a finite number.
|
||||||
|
const result = m.handleScaling(0.5);
|
||||||
|
assert.ok(Number.isFinite(result), `expected finite result, got ${result}`);
|
||||||
|
assert.equal(m.config.scaling.inputMin, 0);
|
||||||
|
assert.equal(m.config.scaling.inputMax, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handleScaling constrains out-of-range inputs before interpolating", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 10, offset: 0 },
|
||||||
|
});
|
||||||
|
// Input above inputMax is constrained to inputMax then mapped to absMax.
|
||||||
|
assert.equal(m.handleScaling(150), 10);
|
||||||
|
// Input below inputMin is constrained to inputMin then mapped to absMin.
|
||||||
|
assert.equal(m.handleScaling(-20), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculateInput updates raw min/max from the unfiltered input", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1000, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
m.calculateInput(10);
|
||||||
|
m.calculateInput(30);
|
||||||
|
m.calculateInput(5);
|
||||||
|
assert.equal(m.totalMinValue, 5);
|
||||||
|
assert.equal(m.totalMaxValue, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateOutputPercent falls back to observed min/max when processRange <= 0", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 5, absMax: 5, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
// processRange starts at 0 so updateOutputPercent uses totalMinValue/Max.
|
||||||
|
m.totalMinValue = 0;
|
||||||
|
m.totalMaxValue = 100;
|
||||||
|
const pct = m.updateOutputPercent(50);
|
||||||
|
// Linear interp: (50 - 0) / (100 - 0) * 100 = 50.
|
||||||
|
assert.ok(Math.abs(pct - 50) < 0.01, `expected ~50, got ${pct}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateOutputAbs only emits MeasurementContainer update when value changes", async () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
let emitCount = 0;
|
||||||
|
// MeasurementContainer normalizes positions to lowercase, so the
|
||||||
|
// event name uses 'atequipment' not the camelCase config value.
|
||||||
|
m.measurements.emitter.on('pressure.measured.atequipment', () => { emitCount += 1; });
|
||||||
|
|
||||||
|
m.calculateInput(10);
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
m.calculateInput(10); // same value -> no emit
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
m.calculateInput(20); // new value -> emit
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
assert.equal(emitCount, 2, `expected 2 emits (two distinct values), got ${emitCount}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getOutput returns the full tracked state object", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
m.calculateInput(15);
|
||||||
|
const out = m.getOutput();
|
||||||
|
assert.equal(typeof out.mAbs, 'number');
|
||||||
|
assert.equal(typeof out.mPercent, 'number');
|
||||||
|
assert.equal(typeof out.totalMinValue, 'number');
|
||||||
|
assert.equal(typeof out.totalMaxValue, 'number');
|
||||||
|
assert.equal(typeof out.totalMinSmooth, 'number');
|
||||||
|
assert.equal(typeof out.totalMaxSmooth, 'number');
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
132
test/basic/smoothing-methods.basic.test.js
Normal file
132
test/basic/smoothing-methods.basic.test.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeMeasurementInstance } = require('../helpers/factories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baseline coverage for every smoothing method exposed by the measurement
|
||||||
|
* node. Each test forces scaling off + outlier-detection off so we can
|
||||||
|
* assert on the raw smoothing arithmetic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeSmoother(method, windowSize = 5) {
|
||||||
|
return makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1000, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: windowSize, smoothMethod: method },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function feed(m, values) {
|
||||||
|
values.forEach((v) => m.calculateInput(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("smoothing 'none' returns the latest value", () => {
|
||||||
|
const m = makeSmoother('none');
|
||||||
|
feed(m, [10, 20, 30, 40, 50]);
|
||||||
|
assert.equal(m.outputAbs, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'mean' returns arithmetic mean over window", () => {
|
||||||
|
const m = makeSmoother('mean');
|
||||||
|
feed(m, [10, 20, 30, 40, 50]);
|
||||||
|
assert.equal(m.outputAbs, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'min' returns minimum of window", () => {
|
||||||
|
const m = makeSmoother('min');
|
||||||
|
feed(m, [10, 20, 5, 40, 50]);
|
||||||
|
assert.equal(m.outputAbs, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'max' returns maximum of window", () => {
|
||||||
|
const m = makeSmoother('max');
|
||||||
|
feed(m, [10, 20, 5, 40, 50]);
|
||||||
|
assert.equal(m.outputAbs, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'sd' returns standard deviation of window", () => {
|
||||||
|
const m = makeSmoother('sd');
|
||||||
|
feed(m, [2, 4, 4, 4, 5]);
|
||||||
|
// Expected sample sd of [2,4,4,4,5] = 1.0954..., rounded to 1.1 by the outputAbs pipeline
|
||||||
|
assert.ok(Math.abs(m.outputAbs - 1.1) < 0.01, `expected ~1.1, got ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'median' returns median (odd window)", () => {
|
||||||
|
const m = makeSmoother('median');
|
||||||
|
feed(m, [10, 50, 20, 40, 30]);
|
||||||
|
assert.equal(m.outputAbs, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'median' returns average of middle pair (even window)", () => {
|
||||||
|
const m = makeSmoother('median', 4);
|
||||||
|
feed(m, [10, 20, 30, 40]);
|
||||||
|
assert.equal(m.outputAbs, 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'weightedMovingAverage' weights later samples more", () => {
|
||||||
|
const m = makeSmoother('weightedMovingAverage');
|
||||||
|
feed(m, [10, 10, 10, 10, 50]);
|
||||||
|
// weights [1,2,3,4,5], sum of weights = 15
|
||||||
|
// weighted sum = 10+20+30+40+250 = 350 -> 350/15 = 23.333..., rounded 23.33
|
||||||
|
assert.ok(Math.abs(m.outputAbs - 23.33) < 0.02, `expected ~23.33, got ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'lowPass' attenuates transients", () => {
|
||||||
|
const m = makeSmoother('lowPass');
|
||||||
|
feed(m, [0, 0, 0, 0, 100]);
|
||||||
|
// EMA(alpha=0.2) from 0,0,0,0,100: last value should be well below 100.
|
||||||
|
assert.ok(m.outputAbs < 100 * 0.3, `lowPass should attenuate step: ${m.outputAbs}`);
|
||||||
|
assert.ok(m.outputAbs > 0, `lowPass should still react: ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'highPass' emphasises differences", () => {
|
||||||
|
const m = makeSmoother('highPass');
|
||||||
|
feed(m, [0, 0, 0, 0, 100]);
|
||||||
|
// Highpass on a step should produce a positive transient; exact value is
|
||||||
|
// recursive but we at least require it to be positive and non-zero.
|
||||||
|
assert.ok(m.outputAbs > 10, `highPass should emphasise step: ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'bandPass' produces a finite number", () => {
|
||||||
|
const m = makeSmoother('bandPass');
|
||||||
|
feed(m, [1, 2, 3, 4, 5]);
|
||||||
|
assert.ok(Number.isFinite(m.outputAbs));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'kalman' converges toward steady values", () => {
|
||||||
|
const m = makeSmoother('kalman');
|
||||||
|
feed(m, [100, 100, 100, 100, 100]);
|
||||||
|
// Kalman filter fed with a constant input should converge to that value
|
||||||
|
// (within a small tolerance due to its gain smoothing).
|
||||||
|
assert.ok(Math.abs(m.outputAbs - 100) < 5, `kalman should approach steady value: ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'savitzkyGolay' returns last sample when window < 5", () => {
|
||||||
|
const m = makeSmoother('savitzkyGolay', 3);
|
||||||
|
feed(m, [7, 8, 9]);
|
||||||
|
assert.equal(m.outputAbs, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'savitzkyGolay' smooths across a 5-point window", () => {
|
||||||
|
const m = makeSmoother('savitzkyGolay', 5);
|
||||||
|
feed(m, [1, 2, 3, 4, 5]);
|
||||||
|
// SG coefficients [-3,12,17,12,-3] / 35 on linear data returns the
|
||||||
|
// middle value unchanged (=3); exact numeric comes out to 35/35 * 3.
|
||||||
|
assert.ok(Math.abs(m.outputAbs - 3) < 0.01, `SG on linear data should return middle ~3, got ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown smoothing method falls through to raw value with an error", () => {
|
||||||
|
const m = makeSmoother('bogus-method');
|
||||||
|
// calculateInput will try the unknown key, hit the default branch in the
|
||||||
|
// applySmoothing map, log an error, and return the raw value (as
|
||||||
|
// implemented — the test pins that behaviour).
|
||||||
|
feed(m, [42]);
|
||||||
|
assert.equal(m.outputAbs, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing window shifts oldest value when exceeded", () => {
|
||||||
|
const m = makeSmoother('mean', 3);
|
||||||
|
feed(m, [100, 100, 100, 10, 10, 10]);
|
||||||
|
// Last three values are [10,10,10]; mean = 10.
|
||||||
|
assert.equal(m.outputAbs, 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
222
test/integration/digital-mode.integration.test.js
Normal file
222
test/integration/digital-mode.integration.test.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Measurement = require('../../src/specificClass');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for digital mode.
|
||||||
|
*
|
||||||
|
* Digital mode accepts an object payload where each key maps to its own
|
||||||
|
* independently-configured Channel (scaling / smoothing / outlier / unit /
|
||||||
|
* position). A single inbound message can therefore emit N measurements
|
||||||
|
* into the MeasurementContainer in one go — the MQTT / JSON IoT pattern
|
||||||
|
* the analog-centric node previously did not support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeDigitalConfig(channels, overrides = {}) {
|
||||||
|
return {
|
||||||
|
general: { id: 'm-dig-1', name: 'weather-station', unit: 'unitless', logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
asset: { type: 'pressure', unit: 'mbar', category: 'sensor', supplier: 'vendor', model: 'BME280' },
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
|
||||||
|
simulation: { enabled: false },
|
||||||
|
functionality: { positionVsParent: 'atEquipment', distance: null },
|
||||||
|
mode: { current: 'digital' },
|
||||||
|
channels,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('analog-mode default: no channels built, handleDigitalPayload is a no-op', () => {
|
||||||
|
// Factory without mode config — defaults must stay analog.
|
||||||
|
const m = new Measurement({
|
||||||
|
general: { id: 'a', name: 'a', unit: 'bar', logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
asset: { type: 'pressure', unit: 'bar', category: 'sensor', supplier: 'v', model: 'M' },
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
|
||||||
|
simulation: { enabled: false },
|
||||||
|
functionality: { positionVsParent: 'atEquipment' },
|
||||||
|
});
|
||||||
|
assert.equal(m.mode, 'analog');
|
||||||
|
assert.equal(m.channels.size, 0);
|
||||||
|
// In analog mode, handleDigitalPayload must refuse and not mutate state.
|
||||||
|
const res = m.handleDigitalPayload({ temperature: 21 });
|
||||||
|
assert.deepEqual(res, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital mode builds one Channel per config.channels entry', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
{ key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
{ key: 'pressure', type: 'pressure', position: 'atEquipment', unit: 'mbar',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 800, absMax: 1200, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
]));
|
||||||
|
assert.equal(m.mode, 'digital');
|
||||||
|
assert.equal(m.channels.size, 3);
|
||||||
|
assert.ok(m.channels.has('temperature'));
|
||||||
|
assert.ok(m.channels.has('humidity'));
|
||||||
|
assert.ok(m.channels.has('pressure'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital payload routes each key to its own channel', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
{ key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
m.handleDigitalPayload({ temperature: 21.5, humidity: 65 });
|
||||||
|
|
||||||
|
const tempOut = m.channels.get('temperature').outputAbs;
|
||||||
|
const humidOut = m.channels.get('humidity').outputAbs;
|
||||||
|
assert.equal(tempOut, 21.5);
|
||||||
|
assert.equal(humidOut, 65);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital payload emits on the MeasurementContainer per channel', async () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
{ key: 'h', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
m.measurements.emitter.on('temperature.measured.atequipment', (e) => events.push({ on: 't', value: e.value }));
|
||||||
|
m.measurements.emitter.on('humidity.measured.atequipment', (e) => events.push({ on: 'h', value: e.value }));
|
||||||
|
|
||||||
|
m.handleDigitalPayload({ t: 22, h: 50 });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
assert.equal(events.filter((e) => e.on === 't').length, 1);
|
||||||
|
assert.equal(events.filter((e) => e.on === 'h').length, 1);
|
||||||
|
assert.equal(events.find((e) => e.on === 't').value, 22);
|
||||||
|
assert.equal(events.find((e) => e.on === 'h').value, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital payload with unmapped keys silently ignores them', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
const res = m.handleDigitalPayload({ t: 20, unknown: 999, extra: 'x' });
|
||||||
|
assert.equal(m.channels.get('t').outputAbs, 20);
|
||||||
|
assert.equal(res.t.ok, true);
|
||||||
|
assert.equal(res.unknown, undefined);
|
||||||
|
assert.equal(res.extra, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital channel with scaling enabled maps input to abs range', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 'pt', type: 'pressure', position: 'atEquipment', unit: 'mbar',
|
||||||
|
scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 1000, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
m.handleDigitalPayload({ pt: 50 });
|
||||||
|
// 50% of [0..100] -> 50% of [0..1000] = 500
|
||||||
|
assert.equal(m.channels.get('pt').outputAbs, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital channel smoothing accumulates per-channel, independent of siblings', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
{ key: 'h', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Feed only temperature across 3 pushes; humidity never receives a value.
|
||||||
|
m.handleDigitalPayload({ t: 10 });
|
||||||
|
m.handleDigitalPayload({ t: 20 });
|
||||||
|
m.handleDigitalPayload({ t: 30 });
|
||||||
|
|
||||||
|
assert.equal(m.channels.get('t').outputAbs, 20); // mean(10,20,30)=20
|
||||||
|
assert.equal(m.channels.get('t').storedValues.length, 3);
|
||||||
|
// Humidity channel must be untouched.
|
||||||
|
assert.equal(m.channels.get('h').storedValues.length, 0);
|
||||||
|
assert.equal(m.channels.get('h').outputAbs, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital channel rejects non-numeric values in summary', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
const res = m.handleDigitalPayload({ t: 'banana' });
|
||||||
|
assert.equal(res.t.ok, false);
|
||||||
|
assert.equal(res.t.reason, 'non-numeric');
|
||||||
|
assert.equal(m.channels.get('t').outputAbs, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital channel supports per-channel outlier detection', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 10, smoothMethod: 'none' },
|
||||||
|
outlierDetection: { enabled: true, method: 'zscore', threshold: 3 } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Seed a tight baseline then lob an obvious spike.
|
||||||
|
for (const v of [20, 20, 20, 20, 20, 20]) m.handleDigitalPayload({ t: v });
|
||||||
|
const baselineOut = m.channels.get('t').outputAbs;
|
||||||
|
m.handleDigitalPayload({ t: 1e6 });
|
||||||
|
assert.equal(m.channels.get('t').outputAbs, baselineOut, 'spike must be rejected as outlier');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDigitalOutput produces one entry per channel', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
{ key: 'h', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
m.handleDigitalPayload({ t: 25, h: 40 });
|
||||||
|
const out = m.getDigitalOutput();
|
||||||
|
assert.ok(out.channels.t);
|
||||||
|
assert.ok(out.channels.h);
|
||||||
|
assert.equal(out.channels.t.mAbs, 25);
|
||||||
|
assert.equal(out.channels.h.mAbs, 40);
|
||||||
|
assert.equal(out.channels.t.type, 'temperature');
|
||||||
|
assert.equal(out.channels.h.unit, '%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital mode with empty channels array still constructs cleanly', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([]));
|
||||||
|
assert.equal(m.mode, 'digital');
|
||||||
|
assert.equal(m.channels.size, 0);
|
||||||
|
// No throw on empty payload.
|
||||||
|
assert.deepEqual(m.handleDigitalPayload({ anything: 1 }), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital mode ignores malformed channel entries in config', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 'valid', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
null, // malformed
|
||||||
|
{ key: 'no_type' }, // missing type
|
||||||
|
{ type: 'pressure' }, // missing key
|
||||||
|
]));
|
||||||
|
assert.equal(m.channels.size, 1);
|
||||||
|
assert.ok(m.channels.has('valid'));
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
448
test/specificClass.test.js
Normal file
448
test/specificClass.test.js
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user