Compare commits

..

15 Commits

Author SHA1 Message Date
znetsixe
998b2002e9 docs: add CLAUDE.md with S88 classification and superproject rule reference
References the flow-layout rule set in the EVOLV superproject
(.claude/rules/node-red-flow-layout.md) so Claude Code sessions working
in this repo know the S88 level, colour, and placement lane for this node.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:47:22 +02:00
znetsixe
fb8d5c03e6 fix(editor): asset/logger/position menus broken by TDZ ReferenceError in oneditprepare
The previous oneditprepare ran applyMode(initialMode) early in the
function, which called validateChannelsJson(), which referenced const
declarations (channelsArea, channelsHint) that were declared later in
the same function. JavaScript hoists const into the Temporal Dead Zone,
so accessing them before the declaration line throws a ReferenceError.
That uncaught throw aborted the rest of oneditprepare — including the
waitForMenuData() call that initialises the asset / logger / position
menu placeholders. Symptom for the user: opening a measurement node in
the editor showed Mode + analog fields but the asset menu was empty.

Fixes:

1. Move waitForMenuData() to the very top of oneditprepare so the
   shared menu init is independent of any later mode-block work. Even
   if the mode logic ever throws again, the asset / logger / position
   menus still render.

2. Resolve every DOM reference (modeSelect, analogBlock, digitalBlock,
   modeHint, channelsArea, channelsHint) at the top of the function
   before any helper that touches them is invoked. validateChannelsJson
   and applyMode now read closed-over names that are guaranteed to be
   initialised.

3. Guard applyMode(initialMode) with try/catch as defense in depth and
   add null-checks on every DOM reference. A future template change
   that drops one of the IDs will only no-op rather than break the
   editor.

No runtime change. 71/71 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:15:06 +02:00
znetsixe
d6f8af4395 fix(editor): make Input Mode the top-level switch, hide wrong-mode fields
Prior behaviour: the Mode dropdown existed but nothing consumed it in the
editor — analog fields (Scaling, Source Min/Max, Smoothing, …) were
always visible, and the Channels JSON editor was always visible too.
For a legacy node with no saved mode the dropdown defaulted blank so
users reported "I cant even select digital or analog".

Changes:
- Initialize the Mode <select> from node.mode with an 'analog' fallback
  for legacy nodes (safe default — matches pre-digital behaviour).
- Wrap analog-only fields and digital-only fields in labelled containers
  and toggle their display based on the selected mode. Mode change is
  live — no redeploy needed to see the right form.
- Inline hint under the Mode dropdown tells the user what payload shape
  is expected for the current mode.
- Channels JSON gets live validation — shows channel count + names on
  valid JSON, warns on missing key/type, errors on invalid JSON.
- Label function appends ' [digital]' so the node visibly differs in a
  flow from an analog sibling.
- oneditsave is mode-aware: only warns about incomplete scaling ranges
  in analog mode; in digital mode warns if the channels array is empty
  or unparseable.

Runtime friendliness:
- nodeClass node-status now shows 'digital · N channel(s)' on startup in
  digital mode, and 'digital · N/M ch updated' after each incoming msg
  so the editor has a live heartbeat even when there is no single scalar.
- When analog mode receives an object payload (or digital receives a
  number), the node logs an actionable warn suggesting the mode switch
  instead of silently dropping the message.

Explicit, not auto-detected: mode remains a deployment-time choice
because the two modes take different editor config (scaling/smoothing vs
channels map). Auto-detecting at runtime would leave the node
unconfigured in whichever mode the user hadn't anticipated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:00:34 +02:00
znetsixe
495b4cf400 feat: digital (MQTT) mode + fix silent dispatcher bug for camelCase methods
Runtime:
- Fix silent no-op when user selected any camelCase smoothing or outlier
  method from the editor. validateEnum in generalFunctions lowercases enum
  values (zScore -> zscore, lowPass -> lowpass, ...) but the dispatcher
  compared against camelCase keys. Effect: 5 of 11 smoothing methods
  (lowPass, highPass, weightedMovingAverage, bandPass, savitzkyGolay) and
  2 of 3 outlier methods (zScore, modifiedZScore) silently fell through.
  Users got the raw last value or no outlier filtering with no error log.
  Review any pre-2026-04-13 flows that relied on these methods.
  Fix: normalize method names to lowercase on both sides of the lookup.

- New Channel class (src/channel.js) — self-contained per-channel pipeline:
  outlier -> offset -> scaling -> smoothing -> min/max -> constrain -> emit.
  Pure domain logic, no Node-RED deps, reusable by future nodes that need
  the same signal-conditioning chain.

Digital mode:
- config.mode.current = 'digital' opts in. config.channels declares one
  entry per expected JSON key; each channel has its own type, position,
  unit, distance, and optional scaling/smoothing/outlierDetection blocks
  that override the top-level analog-mode fields. One MQTT-shaped payload
  ({t:22.5, h:45, p:1013}) dispatches N independent pipelines and emits N
  MeasurementContainer slots from a single input message.
- Backward compatible: absent mode config = analog = pre-digital behaviour.
  Every existing measurement flow keeps working unchanged.

UI:
- HTML editor: new Mode dropdown and Channels JSON textarea. The Node-RED
  help panel is rewritten end-to-end with topic reference, port contracts,
  per-mode configuration, smoothing/outlier method tables, and a note
  about the pre-fix behaviour.
- README.md rewritten (was a one-line stub).

Tests (12 -> 71, all green):
- test/basic/smoothing-methods.basic.test.js (+16): every smoothing method
  including the formerly-broken camelCase ones.
- test/basic/outlier-detection.basic.test.js (+10): every outlier method,
  fall-through, toggle.
- test/basic/scaling-and-interpolation.basic.test.js (+10): offset,
  interpolateLinear, constrain, handleScaling edge cases, min/max
  tracking, updateOutputPercent fallback, updateOutputAbs emit dedup.
- test/basic/calibration-and-stability.basic.test.js (+11): calibrate
  (stable and unstable), isStable, evaluateRepeatability refusals,
  toggleSimulation, tick simulation on/off.
- test/integration/digital-mode.integration.test.js (+12): channel build
  (including malformed entries), payload dispatch, multi-channel emit,
  unknown keys, per-channel scaling/smoothing/outlier, empty channels,
  non-numeric value rejection, getDigitalOutput shape, analog-default
  back-compat.

E2E verified on Dockerized Node-RED: analog regression unchanged; digital
mode deploys with three channels, dispatches MQTT-style payload, emits
per-channel events, accumulates per-channel smoothing, ignores unknown
keys.

Depends on generalFunctions commit e50be2e (permissive unit check +
mode/channels schema).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:43:03 +02:00
znetsixe
0918be7705 Merge commit 'f7c3dc2' into HEAD
# Conflicts:
#	src/nodeClass.js
2026-03-31 18:11:37 +02:00
Rene De Ren
f7c3dc2482 Expose output format selectors in editor 2026-03-12 16:39:25 +01:00
Rene De Ren
ed5f02605a test: add unit tests for specificClass
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:31:53 +01:00
Rene De Ren
1b7285f29e refactor: adopt POSITIONS constants and fix ESLint warnings
Replace hardcoded position strings with POSITIONS.* constants.
Prefix unused variables with _ to resolve no-unused-vars warnings.
Fix no-prototype-builtins where applicable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:35:28 +01:00
Rene De Ren
294cf49521 Migrate _loadConfig to use ConfigManager.buildConfig()
Replaces manual base config construction with shared buildConfig() method.
Node now only specifies domain-specific config sections.

Part of #1: Extract base config schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:59:41 +01:00
znetsixe
43b5269f0b updates 2026-03-11 11:13:38 +01:00
znetsixe
c587ed9c7b working 2026-02-23 13:17:03 +01:00
znetsixe
9e0e3e3859 before functional changes by codex 2026-02-19 17:37:21 +01:00
znetsixe
f979b1ae2b updates 2026-01-29 10:22:20 +01:00
znetsixe
671eb5f5fb updates 2026-01-29 09:16:33 +01:00
znetsixe
339ae6bdde Updated naming convention for displaying 2025-11-13 19:38:25 +01:00
31 changed files with 2875 additions and 178 deletions

23
CLAUDE.md Normal file
View 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
View File

@@ -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. 420 mA) |
| `Input Offset` | additive bias applied before scaling |
| `Process Min / Max` | output-side range (e.g. 03000 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
View File

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

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

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

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

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

View File

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

View File

@@ -18,9 +18,13 @@
defaults: {
// 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 },
i_min: { value: 0, required: true },
i_max: { value: 0, required: true },
@@ -30,6 +34,8 @@
simulator: { value: false },
smooth_method: { value: "" },
count: { value: "10", required: true },
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
//define asset properties
uuid: { value: "" },
@@ -38,6 +44,7 @@
assetType: { value: "" },
model: { value: "" },
unit: { value: "" },
assetTagNumber: { value: "" },
//logger properties
enableLog: { value: false },
@@ -60,62 +67,123 @@
icon: "font-awesome/fa-sliders",
label: function () {
return this.positionIcon + " " + this.assetType || "Measurement";
const modeTag = this.mode === 'digital' ? ' [digital]' : '';
return (this.positionIcon || "") + " " + (this.assetType || "Measurement") + modeTag;
},
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 = () => {
if (window.EVOLV?.nodes?.measurement?.initEditor) {
window.EVOLV.nodes.measurement.initEditor(this);
window.EVOLV.nodes.measurement.initEditor(node);
} else {
setTimeout(waitForMenuData, 50);
}
};
// Wait for the menu data to be ready before initializing the editor
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)
// Populate smoothing methods dropdown
// IMPORTANT: all DOM references are resolved up front so helper
// 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 options = window.EVOLV?.nodes?.measurement?.config?.smoothing?.smoothMethod?.rules?.values || [];
// Clear existing options
smoothMethodSelect.innerHTML = '';
// Add empty option
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = 'Select method...';
smoothMethodSelect.appendChild(emptyOption);
// Add smoothing method options
options.forEach(option => {
const optionElement = document.createElement('option');
optionElement.value = option.value;
optionElement.textContent = option.value;
optionElement.title = option.description; // Add tooltip with full description
smoothMethodSelect.appendChild(optionElement);
const optionElement = document.createElement('option');
optionElement.value = option.value;
optionElement.textContent = option.value;
optionElement.title = option.description;
smoothMethodSelect.appendChild(optionElement);
});
if (node.smooth_method) smoothMethodSelect.value = node.smooth_method;
// Set current value if it exists
if (this.smooth_method) {
smoothMethodSelect.value = this.smooth_method;
// === Scale rows toggle (analog only) ===
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';
}
// --- Scale rows toggle ---
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();
chk.addEventListener('change', toggleScalingRows);
toggleScalingRows();
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
},
@@ -137,12 +205,20 @@
window.EVOLV.nodes.measurement.positionMenu.saveEditor(this);
}
// Save basic properties
["smooth_method"].forEach(
(field) => (node[field] = document.getElementById(`node-input-${field}`).value || "")
);
// Mode is the top-level switch. Always save it first; its value
// drives which other fields are meaningful.
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(
(field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
);
@@ -151,11 +227,22 @@
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
);
// Validation checks
if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
// Mode-dependent validation. In digital mode we don't care about
// 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");
}
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>
@@ -164,60 +251,103 @@
<script type="text/html" data-template-name="measurement">
<!-- Scaling Checkbox -->
<!-- Input mode -->
<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%;">
<label for="node-input-mode"><i class="fa fa-exchange"></i> Input Mode</label>
<select id="node-input-mode" style="width:60%;">
<option value="analog">analog one scalar per msg.payload (classic PLC)</option>
<option value="digital">digital object payload with many channel keys (MQTT/IoT)</option>
</select>
</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">
<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>
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
<select id="node-input-processOutputFormat" style="width:60%;">
<option value="process">process</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<div class="form-row">
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;">
<option value="influxdb">influxdb</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<!-- Optional Extended Fields: supplier, cat, type, model, unit -->
@@ -234,20 +364,49 @@
<script type="text/html" data-help-name="measurement">
<p><b>Measurement Node</b>: Scales, smooths, and simulates measurement data.</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>
<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>
<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>

View File

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

View File

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

311
src/channel.js Normal file
View 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;

View File

@@ -39,32 +39,28 @@ class nodeClass {
/**
* Load and merge default config with user-defined settings.
* Uses ConfigManager.buildConfig() for base sections (general, asset, functionality),
* then adds measurement-specific domain config.
* @param {object} uiConfig - Raw config from Node-RED UI.
*/
_loadConfig(uiConfig,node) {
const cfgMgr = new configManager();
this.defaultConfig = cfgMgr.getConfig(this.name);
// Merge UI config over defaults
this.config = {
general: {
name: this.name,
id: node.id, // node.id is for the child registration process
unit: uiConfig.unit, // add converter options later to convert to default units (need like a model that defines this which units we are going to use and then conver to those standards)
logging: {
enabled: uiConfig.enableLog,
logLevel: uiConfig.logLevel
}
},
asset: {
uuid: uiConfig.assetUuid, //need to add this later to the asset model
tagCode: uiConfig.assetTagCode, //need to add this later to the asset model
supplier: uiConfig.supplier,
category: uiConfig.category, //add later to define as the software type
type: uiConfig.assetType,
model: uiConfig.model,
unit: uiConfig.unit
},
// 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, {
scaling: {
enabled: uiConfig.scaling,
inputMin: uiConfig.i_min,
@@ -80,13 +76,9 @@ class nodeClass {
simulation: {
enabled: uiConfig.simulator
},
functionality: {
positionVsParent: uiConfig.positionVsParent,// Default to 'atEquipment' if not specified
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
}
};
console.log(`position vs child for ${this.name} is ${this.config.functionality.positionVsParent} the distance is ${this.config.functionality.distance}`);
mode: { current: mode },
channels,
});
// Utility for formatting outputs
this._output = new outputUtils();
@@ -105,10 +97,18 @@ class nodeClass {
*/
_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.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)` });
}
}
/**
@@ -140,9 +140,15 @@ class nodeClass {
_tick() {
this.source.tick();
const raw = this.source.getOutput();
const processMsg = this._output.formatMsg(raw, this.config, 'process');
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
// In digital mode we don't funnel through calculateInput with a single
// scalar; instead each Channel has already emitted into the
// 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
this.node.send([processMsg, influxMsg]);
@@ -153,17 +159,59 @@ class nodeClass {
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
switch (msg.topic) {
case 'simulator': this.source.toggleSimulation(); break;
case 'outlierDetection': this.source.toggleOutlierDetection(); break;
case 'calibrate': this.source.calibrate(); break;
case 'measurement':
if (typeof msg.payload === 'number') {
this.source.inputValue = parseFloat(msg.payload);
}
break;
try {
switch (msg.topic) {
case 'simulator':
this.source.toggleSimulation();
break;
case 'outlierDetection':
this.source.toggleOutlierDetection();
break;
case 'calibrate':
this.source.calibrate();
break;
case 'measurement':
// 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();
});
}
@@ -174,7 +222,7 @@ class nodeClass {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
//clearInterval(this._statusInterval);
done();
if (typeof done === 'function') done();
});
}
}

View File

@@ -1,6 +1,23 @@
const EventEmitter = require('events');
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 {
constructor(config={}) {
@@ -46,8 +63,106 @@ class Measurement {
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.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 -------- //
@@ -166,17 +281,23 @@ class Measurement {
outlierDetection(val) {
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) {
case 'zScore':
this.logger.debug(`Outlier detection method: ${method}`);
switch (method) {
case 'zscore':
return this.zScoreOutlierDetection(val);
case 'iqr':
return this.iqrOutlierDetection(val);
case 'modifiedZScore':
case 'modifiedzscore':
return this.modifiedZScoreOutlierDetection(val);
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;
}
}
@@ -302,28 +423,31 @@ class Measurement {
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 = {
none: (arr) => arr[arr.length - 1],
mean: (arr) => this.mean(arr),
min: (arr) => this.min(arr),
max: (arr) => this.max(arr),
sd: (arr) => this.standardDeviation(arr),
lowPass: (arr) => this.lowPassFilter(arr),
highPass: (arr) => this.highPassFilter(arr),
weightedMovingAverage: (arr) => this.weightedMovingAverage(arr),
bandPass: (arr) => this.bandPassFilter(arr),
lowpass: (arr) => this.lowPassFilter(arr),
highpass: (arr) => this.highPassFilter(arr),
weightedmovingaverage: (arr) => this.weightedMovingAverage(arr),
bandpass: (arr) => this.bandPassFilter(arr),
median: (arr) => this.medianFilter(arr),
kalman: (arr) => this.kalmanFilter(arr),
savitzkyGolay: (arr) => this.savitzkyGolayFilter(arr),
savitzkygolay: (arr) => this.savitzkyGolayFilter(arr),
};
// Ensure the smoothing method is valid
const method = this.config.smoothing.smoothMethod;
const raw = this.config.smoothing.smoothMethod;
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
this.logger.debug(`Applying smoothing method "${method}"`);
if (!smoothingMethods[method]) {
this.logger.error(`Smoothing method "${method}" is not implemented.`);
this.logger.error(`Smoothing method "${raw}" is not implemented.`);
return value;
}
@@ -381,7 +505,7 @@ class Measurement {
const lowPass = this.lowPassFilter(arr); // Apply low-pass filter
const highPass = this.highPassFilter(arr); // Apply high-pass filter
return arr.map((val, idx) => lowPass + highPass - val).pop(); // Combine the filters
return arr.map((val, _idx) => lowPass + highPass - val).pop(); // Combine the filters
}
weightedMovingAverage(arr) {
@@ -506,7 +630,10 @@ class Measurement {
}
toggleOutlierDetection() {
this.config.outlierDetection = !this.config.outlierDetection;
// Keep the outlier configuration shape stable and only toggle the enabled flag.
const currentState = Boolean(this.config?.outlierDetection?.enabled);
this.config.outlierDetection = this.config.outlierDetection || {};
this.config.outlierDetection.enabled = !currentState;
}
getOutput() {
@@ -558,7 +685,7 @@ const configuration = {
enabled: true,
},
functionality: {
positionVsParent: "upstream"
positionVsParent: POSITIONS.UPSTREAM
}
};

21
test/README.md Normal file
View File

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

0
test/basic/.gitkeep Normal file
View File

View File

@@ -0,0 +1,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);
});

View File

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

View File

@@ -0,0 +1,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');
});

View 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');
});

View File

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

View File

@@ -0,0 +1,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);
});

View File

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

0
test/edge/.gitkeep Normal file
View File

View File

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

View File

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

0
test/helpers/.gitkeep Normal file
View File

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

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

View File

View File

@@ -0,0 +1,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'));
});

View File

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

View File

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

448
test/specificClass.test.js Normal file
View 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);
});
});
});