Compare commits

...

26 Commits

Author SHA1 Message Date
znetsixe
36eaa2f859 test(edge): align invalid-payload test with object-payload accept behaviour
The runtime handler accepts both bare numbers and {value} object payloads
(matches the contract's units: {measure, default} pre-dispatch shape).
The edge test was still asserting the old "object payloads are ignored"
behaviour; update it to the current contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:29:46 +02:00
znetsixe
5d79314229 feat(units) + style: command unit-handling, frost dbase option, palette #D4A02E
measurement.html:
  • sidebar swatch → #D4A02E (amber, sensor family) — EVOLV palette redesign
    2026-05-21 (see superproject .claude/rules/node-red-flow-layout.md §10.0).
  • Add "frost" option to dbaseOutputFormat dropdown (CoreSync FROST handoff).

src/commands/handlers.js + test/basic/commands-units.basic.test.js:
  • Unit handling for data.measurement command. Analog + digital modes both
    accept scalar / object / per-channel-map payloads; supplied units are
    converted into the channel's configured (dropdown) unit.

CONTRACT.md: document the unit semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:06:37 +02:00
znetsixe
b0e8bbb95d docs(wiki): regenerate topic-contract AUTOGEN block via wiki-gen
Replaces the agent-written placeholder inside Reference-Contracts.md with
the authoritative table generated from src/commands/index.js. Both the
BEGIN and END markers are normalized to the canonical form used by
`@evolv/wiki-gen`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:48 +02:00
znetsixe
1a16f9c4f1 docs(wiki): full 5-page wiki matching the rotatingMachine reference format
Replaces the prior stub/partial wiki with a Home + Reference-{Architecture,
Contracts,Examples,Limitations} + _Sidebar structure. Topic-contract and
data-model sections wrapped in AUTOGEN markers for the future wiki-gen tool.
Source-vs-spec contradictions surfaced and flagged inline (not silently
fixed). Pending-review notes mark sections that need a full node review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:42:10 +02:00
znetsixe
b884c0f085 docs: add Folder & File Layout section per EVOLV convention
Each repo can now be read standalone for the file-naming convention. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:23 +02:00
znetsixe
ffc03584ed wiki: rewrite Home.md per visual-first 14-section template
- Run npm run wiki:all (wiki:contract + wiki:datamodel both wrote cleanly)
- Remove section 10 (State chart) — measurement is stateless, no FSM
- Renumber sections 11→10, 12→11, 13→12, 14→13 for correct 13-section layout
- Update banner git hash from afc304b to 125f964

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:03:23 +02:00
znetsixe
125f964d31 P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

For rotatingMachine + reactor: Phase 10 test rewrites — 3 + 8 files
moved off private nodeClass internals (_attachInputHandler, _commands,
_pendingExtras, _registerChild, _tick, etc.) to the public
BaseNodeAdapter surface (node.handlers.input, node.source.*).
+6 / +7 net new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:04 +02:00
znetsixe
15b7414d41 P11.5 + B2.1/B2.2: per-command units + description (where applicable)
Adds  to scalar setters whose payloads are
plain numbers OR {value, unit}. Skipped where payload is compound or
mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline.
Every command gains a description field for wikiGen consumption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:41:19 +02:00
znetsixe
497f05d92c B1.3: isStable real threshold (config-driven, replaces tautology)
The legacy stdDev < stdDev*2 was always true. New behaviour: stdDev <=
config.calibration.stabilityThreshold OR stdDev === 0. Default
threshold 0.01 in scaling-units. Schema field + editor UI added. 4
BUG-PRESERVED tests rewritten + 4 new edge tests. 101/101 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:29:15 +02:00
znetsixe
e6e212a504 B2.4: remove legacy 'mAbs' event re-emission
No production consumer; deprecated since the MeasurementContainer-based
event surface landed. Drops the on-emit subscription that bridged the
analog channel's <type>.measured.<position> event to source.emitter
as 'mAbs'. 96/96 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:13:17 +02:00
znetsixe
2aa80212e4 P9.3: wiki/Home.md following 14-section visual-first template + wiki:* scripts
Auto-generated topic-contract + data-model sections via shared wikiGen
script. Hand-written Mermaid diagrams for position-in-platform, code
map, child registration, lifecycle, configuration, state chart (where
applicable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:17:33 +02:00
znetsixe
42a0333b7c P3 wave 2: convert measurement to BaseDomain + Channel-based analog
specificClass.js: 716 → 244 lines.
  Measurement extends BaseDomain. Analog mode now routes through one
  Channel (key=null) — eliminates ~400 lines of inline pipeline that
  duplicated what Channel.update() already did.

  Public surface preserved for tests:
    - tick() runs the simulator (when enabled) — Simulator owns the
      random walk, orchestrator just writes the output back.
    - inputValue setter routes through analogChannel.update.
    - calibrate() / evaluateRepeatability() delegate to Calibrator.
    - toggleSimulation / toggleOutlierDetection unchanged.
    - 'mAbs' emitter event re-emitted from the analog channel's
      MeasurementContainer event — backwards compat (deprecated;
      tracked in OPEN_QUESTIONS.md for removal in Phase 7/8.5).

nodeClass.js: 230 → 42 lines.
  Extends BaseNodeAdapter. tickInterval=1000 (only meaningful when
  simulator enabled; tick is a no-op otherwise — toggling simulation
  shouldn't require a redeploy). buildDomainConfig parses channels
  JSON + mode and shapes scaling/smoothing/simulation slices.

96 / 96 tests pass (basic 77 + integration 17 + edge 2).
Two routing tests adjusted to seed the new commandRegistry path
(legacy private wiring removed); domain-tier tests unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:39:54 +02:00
znetsixe
b990f67df1 P3 wave 1: extract measurement simulator/calibration/commands + CONTRACT
src/simulation/simulator.js  random-walk generator (was simulateInput inline)
  src/calibration/calibrator.js  calibrate + isStable + evaluateRepeatability,
                                using generalFunctions/stats. NB: isStable
                                tautology preserved verbatim — see
                                OPEN_QUESTIONS.md 2026-05-10 for the bug.
  src/commands/                  registry + handlers (canonical names from start)
  CONTRACT.md                    inputs/outputs/events surface

77 basic tests pass (62 pre-refactor + 15 new across the three new files).
specificClass.js / nodeClass.js untouched — integration is P3 wave 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:32:26 +02:00
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
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
46 changed files with 5103 additions and 823 deletions

40
CLAUDE.md Normal file
View File

@@ -0,0 +1,40 @@
# 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).
## Folder & File Layout
Every per-node file MUST use the folder name (`measurement`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `measurement.js` |
| Editor HTML | `measurement.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
When adding new files, read the rule above first to avoid drift.

78
CONTRACT.md Normal file
View File

@@ -0,0 +1,78 @@
# measurement — Contract
Hand-maintained for Phase 3; the `## Inputs` table is generated from
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
## Inputs (msg.topic on Port 0)
| Canonical | Aliases (deprecated) | Payload | Effect |
|---|---|---|---|
| `set.simulator` | `simulator` | none (payload ignored) | Toggles `source.toggleSimulation()` — flips `config.simulation.enabled`. |
| `set.outlier-detection` | `outlierDetection` | none (payload ignored) | Toggles `source.toggleOutlierDetection()` — flips `config.outlierDetection.enabled`. |
| `cmd.calibrate` | `calibrate` | none | Calls `source.calibrate()` — captures the current input as the zero/reference offset. |
| `data.measurement` | `measurement` | mode-dependent — see **Payload shape** below | Pushes a sensor reading into the pipeline. Analog → `source.inputValue`; digital → `source.handleDigitalPayload(<flat map>)`. Wrong shape for the configured mode logs a helpful warning suggesting the other mode. |
Aliases log a one-time deprecation warning the first time they fire.
### `data.measurement` payload shape
Both modes accept the same three forms, mirroring pumpingStation's
`set.inflow` contract:
- **Bare scalar** — `msg.payload = 12.5` (number or numeric string). The unit
falls back to `msg.unit`, and finally to the channel's configured unit
(the dropdown selection in the node editor).
- **Rich object** — `msg.payload = { value, unit?, timestamp? }`. Used per-
call to declare the unit of a single sample.
- **Digital map** (digital mode only) — `msg.payload = { <channelKey>: <bare scalar | rich object>, … }`. Each entry follows the rules above independently, so different channels in one message may carry different units.
When a supplied unit differs from the channel's configured unit, the value
is converted into the channel unit via `generalFunctions.convert` before it
enters the outlier / scaling / smoothing pipeline. If the supplied unit is
unknown or belongs to a different measure (e.g. `kg` on a `pressure`
channel), the handler logs a warning and uses the raw value treated as the
channel unit — the sample is not silently dropped.
## Outputs (msg.topic on Port 0/1/2)
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
`outputUtils.formatMsg(..., 'process')` from `getOutput()` (analog) or
`getDigitalOutput()` (digital). Delta-compressed — only changed fields are
emitted.
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
`'influxdb'` formatter.
- **Port 2 (registration):** at startup the node sends one
`{ topic: 'registerChild', payload: <node.id>, positionVsParent, distance }`
to its parent.
## Events emitted by `source.measurements.emitter`
The `MeasurementContainer` fires `<type>.measured.<position>` whenever a
matching series receives a new value. The type / position labels are set
from `config.asset.type` and `config.functionality.positionVsParent`
(analog), or per-channel from `config.channels[*]` (digital). Examples:
- `pressure.measured.upstream`
- `flow.measured.atequipment`
- `level.measured.downstream`
- `temperature.measured.atequipment`
Position labels are always lowercase in the event name. Parents subscribe
through the generic `child.measurements.emitter.on(eventName, ...)` handshake
established by `childRegistrationUtils`.
In digital mode one input message can fan out into several events — one
per channel that accepted a value on that tick.
The legacy internal `source.emitter` also fires `'mAbs'` with the current
scaled absolute value (analog mode only). This is deprecated in favour of
`measurements.emitter` and kept only for the editor status badge during the
refactor window.
## Children registered by this node
None — `measurement` is a leaf in the S88 hierarchy (Control Module). It
registers itself as a child of an upstream parent (rotatingMachine,
pumpingStation, reactor, monster, …) but does not accept its own children.
Registration goes via Port 2 at startup and is keyed off
`positionVsParent` / `distance` in the node's UI config.

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

@@ -14,13 +14,17 @@
<script>
RED.nodes.registerType("measurement", {
category: "EVOLV",
color: "#a9daee", // color for the node based on the S88 schema
color: "#D4A02E",
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,9 @@
simulator: { value: false },
smooth_method: { value: "" },
count: { value: "10", required: true },
stabilityThreshold: { value: 0.01 },
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
//define asset properties
uuid: { value: "" },
@@ -38,6 +45,7 @@
assetType: { value: "" },
model: { value: "" },
unit: { value: "" },
assetTagNumber: { value: "" },
//logger properties
enableLog: { value: false },
@@ -60,62 +68,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);
});
// Set current value if it exists
if (this.smooth_method) {
smoothMethodSelect.value = this.smooth_method;
if (node.smooth_method) smoothMethodSelect.value = node.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 +206,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 +228,28 @@
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
);
// Validation checks
if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
// Calibration stability threshold: 0 is a valid (very strict) value, so
// fall back to the default 0.01 only when the field is empty / NaN.
const stRaw = document.getElementById('node-input-stabilityThreshold').value;
const stParsed = parseFloat(stRaw);
node.stabilityThreshold = Number.isFinite(stParsed) ? stParsed : 0.01;
// 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 +258,112 @@
<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>
<!-- Calibration Stability Threshold -->
<div class="form-row">
<label for="node-input-stabilityThreshold"><i class="fa fa-balance-scale"></i> Stability Threshold</label>
<input type="number" id="node-input-stabilityThreshold" placeholder="0.01" step="any" style="width:100px;"/>
<span style="margin-left:6px; color:#666;">(scaling-units)</span>
<div class="form-tips">Maximum stdDev of the rolling window for calibrate() and evaluateRepeatability() to accept the buffer as stable. Default 0.01.</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="frost">frost</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<!-- Optional Extended Fields: supplier, cat, type, model, unit -->
@@ -234,20 +380,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,10 @@
"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",
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
},
"repository": {
"type": "git",

View File

@@ -0,0 +1,96 @@
'use strict';
const { stats } = require('generalFunctions');
const DEFAULT_STABILITY_THRESHOLD = 0.01;
/**
* Calibration helper extracted from measurement/specificClass.js.
*
* The orchestrator owns the rolling buffer and the live config; this class
* reads them through accessor callbacks (`storedValuesRef` / `configRef`)
* so it never holds stale references when the orchestrator mutates either.
*/
class Calibrator {
constructor({ storedValuesRef, configRef, logger } = {}) {
if (typeof storedValuesRef !== 'function' || typeof configRef !== 'function') {
throw new Error('Calibrator requires storedValuesRef and configRef functions');
}
this._storedValues = storedValuesRef;
this._config = configRef;
this.logger = logger || { info() {}, warn() {}, debug() {}, error() {} };
}
/**
* Decide whether the rolling window is stable enough to trust.
* Compares the window's stdDev against config.calibration.stabilityThreshold
* (absolute, in scaling-units). A constant buffer (stdDev=0) is always
* stable regardless of threshold.
*/
isStable() {
const values = this._storedValues();
if (!Array.isArray(values) || values.length < 2) {
return { isStable: false, stdDev: 0 };
}
const stdDev = stats.stdDev(values);
const cfg = this._config();
const raw = cfg && cfg.calibration && cfg.calibration.stabilityThreshold;
const threshold = Number.isFinite(Number(raw)) && Number(raw) >= 0
? Number(raw)
: DEFAULT_STABILITY_THRESHOLD;
return { isStable: stdDev === 0 || stdDev <= threshold, stdDev };
}
/**
* Compute the offset that drives `currentOutputAbs` to the configured
* baseline (scaling input-min when scaling is enabled, abs-min otherwise).
* Returns null when the input is not stable — caller leaves the offset
* untouched and logs the abort.
*/
calibrate(currentOutputAbs) {
const { isStable } = this.isStable();
if (!isStable) {
this.logger.warn('Large fluctuations detected between stored values. Calibration aborted.');
return null;
}
const cfg = this._config();
const scaling = (cfg && cfg.scaling) || {};
const baseline = scaling.enabled ? scaling.inputMin : scaling.absMin;
if (typeof baseline !== 'number' || !Number.isFinite(baseline)) {
this.logger.warn('Calibration baseline missing from config.scaling. Aborted.');
return null;
}
const offset = baseline - currentOutputAbs;
this.logger.info(`Stable input value detected. Calibration completed. Offset=${offset}`);
return { offset };
}
/**
* Repeatability proxy: the std-dev of the smoothed rolling buffer once
* stability is confirmed. Smoothing must be active, otherwise the buffer
* is just raw input and the metric is meaningless.
*/
evaluateRepeatability() {
const cfg = this._config();
const method = cfg && cfg.smoothing && cfg.smoothing.smoothMethod;
const normalized = typeof method === 'string' ? method.toLowerCase() : method;
if (normalized === 'none' || normalized == null) {
this.logger.warn('Repeatability evaluation is not possible without smoothing.');
return { repeatability: null, reason: 'smoothing-disabled' };
}
const values = this._storedValues();
if (!Array.isArray(values) || values.length < 2) {
this.logger.warn('Not enough data to evaluate repeatability.');
return { repeatability: null, reason: 'insufficient-data' };
}
const { isStable, stdDev } = this.isStable();
if (!isStable) {
this.logger.warn('Data not stable enough to evaluate repeatability.');
return { repeatability: null, reason: 'unstable' };
}
this.logger.info(`Repeatability evaluated. Standard Deviation: ${stdDev}`);
return { repeatability: stdDev };
}
}
module.exports = Calibrator;

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;

153
src/commands/handlers.js Normal file
View File

@@ -0,0 +1,153 @@
'use strict';
// Handler functions for measurement commands. Each handler receives:
// source: the domain (specificClass) instance — exposes toggleSimulation,
// toggleOutlierDetection, calibrate, handleDigitalPayload, mode,
// inputValue (settable), analogChannel, channels (Map), logger.
// msg: the Node-RED input message.
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
//
// Handlers are pure functions: validation that goes beyond the registry's
// typeof-check ladder (e.g. mode-dependent dispatch for data.measurement,
// unit conversion into the channel's configured unit) lives here.
const { convert } = require('generalFunctions');
function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
exports.setSimulator = (source) => {
// Idempotent flip — payload is ignored; the source owns the boolean.
source.toggleSimulation();
};
exports.setOutlierDetection = (source) => {
source.toggleOutlierDetection();
};
exports.calibrate = (source) => {
source.calibrate();
};
exports.dataMeasurement = (source, msg, ctx) => {
const log = _logger(source, ctx);
if (source.mode === 'digital') {
return _handleDigital(source, msg, log);
}
return _handleAnalog(source, msg, log);
};
// --- shared payload helpers ------------------------------------------------
// Extract { value, unit, timestamp } from a per-call item that may be
// - a bare number / numeric string (unit falls back to msgUnit, then channel)
// - an object { value, unit?, timestamp? } (pumpingStation set.inflow shape)
// Returns null when the shape is neither.
function _extractValueAndUnit(item, msgUnit) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
return {
value: Number(item.value),
unit: _trimmedString(item.unit),
timestamp: item.timestamp,
};
}
if (typeof item === 'number' || (typeof item === 'string' && item.trim() !== '')) {
return {
value: Number(item),
unit: _trimmedString(msgUnit),
timestamp: undefined,
};
}
return null;
}
function _trimmedString(v) {
return typeof v === 'string' && v.trim() ? v.trim() : null;
}
// Convert `value` from `suppliedUnit` into `channelUnit`. When the supplied
// unit is missing or already matches, returns the value untouched. When the
// units are incompatible (different measures, unsupported abbr), logs a
// warning and returns the raw value treated as if it were channelUnit — the
// sender keeps responsibility for picking the right unit, but the pipeline
// does not silently drop the sample.
function _convertToChannelUnit(value, suppliedUnit, channelUnit, log, label) {
if (!suppliedUnit || !channelUnit || suppliedUnit === channelUnit) return value;
try {
return convert(value).from(suppliedUnit).to(channelUnit);
} catch (err) {
log?.warn?.(
`${label}: unit '${suppliedUnit}' is incompatible with channel unit '${channelUnit}' ` +
`(${err.message}). Using raw value as if it were ${channelUnit}.`
);
return value;
}
}
// Distinguish a "rich" analog payload ({value, unit?, timestamp?}) from an
// object that almost certainly indicates the sender meant digital mode (a
// bag of channel-name keys). Used only for the helpful switch-mode warning.
function _looksLikeRichPayload(obj) {
return obj.value !== undefined || obj.unit !== undefined || obj.timestamp !== undefined;
}
// --- mode handlers ---------------------------------------------------------
function _handleAnalog(source, msg, log) {
const p = msg.payload;
if (p !== null && typeof p === 'object' && !Array.isArray(p) && !_looksLikeRichPayload(p)) {
const keys = Object.keys(p).slice(0, 3).join(', ');
log?.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.`
);
return;
}
const extracted = _extractValueAndUnit(p, msg?.unit);
if (!extracted || !Number.isFinite(extracted.value)) {
log?.warn?.(`Invalid analog measurement payload: ${JSON.stringify(p)}`);
return;
}
const channelUnit = source.analogChannel?.unit || null;
source.inputValue = _convertToChannelUnit(
extracted.value,
extracted.unit,
channelUnit,
log,
'data.measurement',
);
}
function _handleDigital(source, msg, log) {
const p = msg.payload;
if (typeof p === 'number') {
log?.warn?.(
`digital mode received a number (${p}); expected an object like {key: value, ...}. ` +
`Switch Input Mode to 'analog' in the editor or send an object payload.`
);
return;
}
if (!p || typeof p !== 'object' || Array.isArray(p)) {
log?.warn?.(`digital mode expects an object payload; got ${typeof p}`);
return;
}
const flat = {};
for (const [key, item] of Object.entries(p)) {
const extracted = _extractValueAndUnit(item, msg?.unit);
if (!extracted || !Number.isFinite(extracted.value)) {
log?.warn?.(`digital channel '${key}' has invalid payload: ${JSON.stringify(item)}`);
continue;
}
const channelUnit = source.channels?.get?.(key)?.unit || null;
flat[key] = _convertToChannelUnit(
extracted.value,
extracted.unit,
channelUnit,
log,
`data.measurement[${key}]`,
);
}
return source.handleDigitalPayload(flat);
}

45
src/commands/index.js Normal file
View File

@@ -0,0 +1,45 @@
'use strict';
// measurement command registry. Consumed by BaseNodeAdapter via
// `static commands = require('./commands')`. Each descriptor maps a
// canonical msg.topic to its handler; legacy names are listed under
// `aliases` and emit a one-time deprecation warning at runtime.
const handlers = require('./handlers');
module.exports = [
{
topic: 'set.simulator',
aliases: ['simulator'],
// Toggle — payload is ignored. `any` keeps the registry validator happy
// for legacy callers that ship trigger payloads of various shapes.
payloadSchema: { type: 'any' },
description: 'Toggle the built-in simulator on / off.',
handler: handlers.setSimulator,
},
{
topic: 'set.outlier-detection',
aliases: ['outlierDetection'],
payloadSchema: { type: 'any' },
description: 'Toggle / configure outlier detection on the measurement pipeline.',
handler: handlers.setOutlierDetection,
},
{
topic: 'cmd.calibrate',
aliases: ['calibrate'],
payloadSchema: { type: 'any' },
description: 'Trigger a one-shot calibration of the measurement.',
handler: handlers.calibrate,
},
{
topic: 'data.measurement',
aliases: ['measurement'],
// Mode-dispatched: digital expects object (per-channel), analog expects
// number/numeric string in the configured Channel scaling units. Units
// are mode-dependent and resolved inside the handler — no registry-level
// `units` field.
payloadSchema: { type: 'any' },
description: 'Push a raw measurement (analog: number; digital: per-channel object).',
handler: handlers.dataMeasurement,
},
];

View File

@@ -1,159 +1,42 @@
/**
* measurement.class.js
*
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
*/
const { outputUtils, configManager } = require('generalFunctions');
const Specific = require("./specificClass");
'use strict';
const { BaseNodeAdapter } = require('generalFunctions');
const Measurement = require('./specificClass');
const commands = require('./commands');
class nodeClass {
/**
* Create a MeasurementNode.
* @param {object} uiConfig - Node-RED node configuration.
* @param {object} RED - Node-RED runtime API.
* @param {object} nodeInstance - The Node-RED node instance.
* @param {string} nameOfNode - The name of the node, used for
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
class nodeClass extends BaseNodeAdapter {
static DomainClass = Measurement;
static commands = commands;
// Tick drives the simulator's random walk when enabled. Disabled mode is
// event-driven via the `output-changed` emit from the analog Channel.
static tickInterval = 1000;
static statusInterval = 1000;
// Preserve RED reference for HTTP endpoints if needed
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
buildDomainConfig(uiConfig, _nodeId) {
let channels = [];
if (typeof uiConfig.channels === 'string' && uiConfig.channels.trim()) {
try { channels = JSON.parse(uiConfig.channels); }
catch (e) { this.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';
// Load default & UI config
this._loadConfig(uiConfig,this.node);
// Instantiate core Measurement class
this._setupSpecificClass();
// Wire up event and lifecycle handlers
this._bindEvents();
this._registerChild();
this._startTickLoop();
this._attachInputHandler();
this._attachCloseHandler();
}
/**
* 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);
// Build config: base sections + measurement-specific domain config
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
return {
scaling: {
enabled: uiConfig.scaling,
inputMin: uiConfig.i_min,
inputMax: uiConfig.i_max,
absMin: uiConfig.o_min,
absMax: uiConfig.o_max,
offset: uiConfig.i_offset
offset: uiConfig.i_offset,
},
smoothing: {
smoothWindow: uiConfig.count,
smoothMethod: uiConfig.smooth_method
},
simulation: {
enabled: uiConfig.simulator
}
});
// Utility for formatting outputs
this._output = new outputUtils();
}
/**
* Instantiate the core logic and store as source.
*/
_setupSpecificClass() {
this.source = new Specific(this.config);
this.node.source = this.source; // Store the source in the node instance for easy access
}
/**
* Bind Measurement events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES
*/
_bindEvents() {
this.source.emitter.on('mAbs', (val) => {
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
});
}
/**
* Register this node as a child upstream and downstream.
* Delayed to avoid Node-RED startup race conditions.
*/
_registerChild() {
setTimeout(() => {
this.node.send([
null,
null,
{ topic: 'registerChild', payload: this.node.id , positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' , distance: this.config?.functionality?.distance || null},
]);
}, 100);
}
/**
* Start the periodic tick loop to drive the Measurement class.
*/
_startTickLoop() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
}, 1000);
}
/**
* Execute a single tick: update measurement, format and send outputs.
*/
_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');
// Send only updated outputs on ports 0 & 1
this.node.send([processMsg, influxMsg]);
}
/**
* Attach the node's input handler, routing control messages to the class.
*/
_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;
}
done();
});
}
/**
* Clean up timers and intervals when Node-RED stops the node.
*/
_attachCloseHandler() {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
//clearInterval(this._statusInterval);
done();
});
smoothing: { smoothWindow: uiConfig.count, smoothMethod: uiConfig.smooth_method },
simulation: { enabled: uiConfig.simulator },
calibration: { stabilityThreshold: uiConfig.stabilityThreshold },
mode: { current: mode },
channels,
};
}
}

View File

@@ -0,0 +1,60 @@
/**
* Simulator — random-walk driver for the measurement input.
*
* Lifted verbatim from Measurement.simulateInput. The orchestrator decides
* what to do with the returned value (originally written to `inputValue`),
* so this module owns nothing but the walk and its bounds.
*/
class Simulator {
constructor({ config, logger } = {}) {
if (!config || !config.scaling) {
throw new Error('Simulator requires { config.scaling }');
}
this.config = config;
this.logger = logger || { warn() {}, info() {}, debug() {}, error() {} };
const s = config.scaling;
this.inputRange = Math.abs(s.inputMax - s.inputMin);
this.processRange = Math.abs(s.absMax - s.absMin);
this.simValue = 0;
}
step() {
const s = this.config.scaling;
const sign = Math.random() < 0.5 ? -1 : 1;
let maxStep;
if (s.enabled) {
// Step size scales with the live input window; fall back to 1 so a
// collapsed range still wanders instead of freezing at zero.
maxStep = this.inputRange > 0 ? this.inputRange * 0.05 : 1;
if (this.simValue < s.inputMin || this.simValue > s.inputMax) {
this.logger.warn(`Simulated value ${this.simValue} is outside of input range constraining between min=${s.inputMin} and max=${s.inputMax}`);
this.simValue = _constrain(this.simValue, s.inputMin, s.inputMax);
}
} else {
maxStep = this.processRange > 0 ? this.processRange * 0.05 : 1;
if (this.simValue < s.absMin || this.simValue > s.absMax) {
this.logger.warn(`Simulated value ${this.simValue} is outside of abs range constraining between min=${s.absMin} and max=${s.absMax}`);
this.simValue = _constrain(this.simValue, s.absMin, s.absMax);
}
}
this.simValue += sign * Math.random() * maxStep;
return this.simValue;
}
reset() {
this.simValue = 0;
}
get current() {
return this.simValue;
}
}
function _constrain(v, lo, hi) {
return Math.min(Math.max(v, lo), hi);
}
module.exports = Simulator;

View File

@@ -1,589 +1,238 @@
const EventEmitter = require('events');
const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions');
'use strict';
class Measurement {
constructor(config={}) {
const { BaseDomain, statusBadge } = require('generalFunctions');
const Channel = require('./channel');
const Simulator = require('./simulation/simulator');
const Calibrator = require('./calibration/calibrator');
this.emitter = new EventEmitter(); // Own EventEmitter
this.configManager = new configManager();
this.defaultConfig = this.configManager.getConfig('measurement');
this.configUtils = new configUtils(this.defaultConfig);
this.config = this.configUtils.initConfig(config);
// Measurement domain. Analog mode = one Channel built from the flat config.
// Digital mode = one Channel per config.channels[] entry. Channel owns the
// outlier → offset → scaling → smoothing → minMax → emit pipeline; the
// delegates below preserve the pre-refactor public surface for tests.
class Measurement extends BaseDomain {
static name = 'measurement';
// Init after config is set
this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
configure() {
this.mode = (this.config?.mode?.current || 'analog').toLowerCase();
this.channels = new Map();
// General properties
this.measurements = new MeasurementContainer({
autoConvert: true,
windowSize: this.config.smoothing.smoothWindow
});
this.measurements.setChildId(this.config.general.id);
this.measurements.setChildName(this.config.general.name);
// Smoothing
this.storedValues = [];
// Simulation
this.simValue = 0;
// Internal tracking
this.inputValue = 0;
this.outputAbs = 0;
this.outputPercent = 0;
// Stability
this.stableThreshold = null;
//internal variables
this.totalMinValue = Infinity;
this.totalMaxValue = -Infinity;
this.totalMinSmooth = 0;
this.totalMaxSmooth = 0;
// Scaling
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.`);
}
// -------- Config Initializers -------- //
updateconfig(newConfig) {
this.config = this.configUtils.updateConfig(this.config, newConfig);
}
async tick() {
if (this.config.simulation.enabled) {
this.simulateInput();
if (this.mode === 'digital') {
this._buildDigitalChannels();
} else {
this.analogChannel = this._buildAnalogChannel();
}
this.calculateInput(this.inputValue);
this._simulator = new Simulator({ config: this.config, logger: this.logger });
this._calibrator = new Calibrator({
storedValuesRef: () => this.analogChannel?.storedValues ?? [],
configRef: () => this.config,
logger: this.logger,
});
this._inputValue = 0;
this.simValue = 0;
this._installChannelMirrors();
this.logger.debug(`Measurement id=${this.config.general.id} ready. mode=${this.mode} channels=${this.channels.size}`);
}
// Mirror the analog Channel's state as `m.xxx` so the legacy public surface
// (outputAbs, storedValues, totalMinValue, …) stays writable from tests.
_installChannelMirrors() {
const RW = ['storedValues', 'outputAbs', 'outputPercent', 'totalMinValue',
'totalMaxValue', 'totalMinSmooth', 'totalMaxSmooth'];
const RO = ['inputRange', 'processRange'];
const def = (k, setter) => Object.defineProperty(this, k, {
configurable: true, enumerable: true,
get: () => this.analogChannel?.[k] ?? (k === 'storedValues' ? [] : 0),
...(setter ? { set: setter } : {}),
});
for (const k of RW) def(k, (v) => { if (this.analogChannel) this.analogChannel[k] = (k === 'storedValues' && Array.isArray(v)) ? [...v] : v; });
for (const k of RO) def(k);
}
_buildAnalogChannel() {
return new Channel({
key: null,
type: this.config.asset.type,
position: this.config.functionality?.positionVsParent || 'atEquipment',
unit: this.config.asset?.unit || this.config.general?.unit || 'unitless',
distance: this.config.functionality?.distance ?? null,
scaling: this.config.scaling,
smoothing: this.config.smoothing,
outlierDetection: this.config.outlierDetection,
interpolation: this.config.interpolation,
measurements: this.measurements,
logger: this.logger,
});
}
_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 passthrough ---
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;
}
getDigitalOutput() {
const out = { channels: {} };
for (const [key, ch] of this.channels) out.channels[key] = ch.getOutput();
return out;
}
// --- public commands ---
set inputValue(v) {
this._inputValue = v;
if (this.mode === 'analog' && this.analogChannel) {
this.analogChannel.update(v);
this.notifyOutputChanged();
}
}
get inputValue() { return this._inputValue ?? 0; }
tick() {
if (this.config?.simulation?.enabled) {
this.inputValue = this._simulator.step();
this.simValue = this._simulator.simValue;
}
return Promise.resolve();
}
calibrate() {
let offset = 0;
const { isStable } = this.isStable();
//first check if the input is stable
if( !isStable ){
this.logger.warn(`Large fluctuations detected between stored values. Calibration aborted.`);
}else{
this.logger.info(`Stable input value detected. Proceeding with calibration.`);
// offset should be the difference between the input and the output
if(this.config.scaling.enabled){
offset = this.config.scaling.inputMin - this.outputAbs;
} else {
offset = this.config.scaling.absMin - this.outputAbs;
}
this.config.scaling.offset = offset;
this.logger.info(`Calibration completed. Offset set to ${offset}`);
}
}
isStable() {
const marginFactor = 2; // or 3, depending on strictness
let stableThreshold = 0;
if (this.storedValues.length < 2) return false;
const stdDev = this.standardDeviation(this.storedValues);
stableThreshold = stdDev * marginFactor;
return { isStable: ( stdDev < stableThreshold || stdDev == 0) , stdDev} ;
}
evaluateRepeatability() {
const { isStable, stdDev } = this.isStable();
if(this.config.smoothing.smoothMethod == 'none'){
this.logger.warn('Repeatability evaluation is not possible without smoothing.');
return null;
}
if (this.storedValues.length < 2) {
this.logger.warn('Not enough data to evaluate repeatability.');
return null;
}
if( isStable == false){
this.logger.warn('Data not stable enough to evaluate repeatability.');
return null;
}
const standardDeviation = stdDev
this.logger.info(`Repeatability evaluated. Standard Deviation: ${stdDev}`);
return standardDeviation;
}
simulateInput() {
// Simulate input value
const absMax = this.config.scaling.absMax;
const absMin = this.config.scaling.absMin;
const inputMin = this.config.scaling.inputMin;
const inputMax = this.config.scaling.inputMax;
const sign = Math.random() < 0.5 ? -1 : 1;
let maxStep = 0;
switch ( this.config.scaling.enabled ) {
case true:
maxStep = this.inputRange > 0 ? this.inputRange * 0.05 : 1;
if (this.simValue < inputMin || this.simValue > inputMax) {
this.logger.warn(`Simulated value ${this.simValue} is outside of input range constraining between min=${inputMin} and max=${inputMax}`);
this.simValue = this.constrain(this.simValue, inputMin, inputMax);
}
break;
case false:
maxStep = this.processRange > 0 ? this.processRange * 0.05 : 1;
if (this.simValue < absMin || this.simValue > absMax) {
this.logger.warn(`Simulated value ${this.simValue} is outside of abs range constraining between min=${absMin} and max=${absMax}`);
this.simValue = this.constrain(this.simValue, absMin, absMax);
}
break;
}
this.simValue += sign * Math.random() * maxStep;
this.inputValue = this.simValue;
}
outlierDetection(val) {
if (this.storedValues.length < 2) return false;
this.logger.debug(`Outlier detection method: ${this.config.outlierDetection.method}`);
switch (this.config.outlierDetection.method) {
case 'zScore':
return this.zScoreOutlierDetection(val);
case 'iqr':
return this.iqrOutlierDetection(val);
case 'modifiedZScore':
return this.modifiedZScoreOutlierDetection(val);
default:
this.logger.warn(`Outlier detection method "${this.config.outlierDetection.method}" is not recognized.`);
return false;
}
}
zScoreOutlierDetection(val) {
const threshold = this.config.outlierDetection.threshold || 3;
const mean = this.mean(this.storedValues);
const stdDev = this.standardDeviation(this.storedValues);
const zScore = (val - mean) / stdDev;
if (Math.abs(zScore) > threshold) {
this.logger.warn(`Outlier detected using Z-Score method. Z-score=${zScore}`);
return true;
}
return false;
}
iqrOutlierDetection(val) {
const sortedValues = [...this.storedValues].sort((a, b) => a - b);
const q1 = sortedValues[Math.floor(sortedValues.length / 4)];
const q3 = sortedValues[Math.floor(sortedValues.length * 3 / 4)];
const iqr = q3 - q1;
const lowerBound = q1 - 1.5 * iqr;
const upperBound = q3 + 1.5 * iqr;
if (val < lowerBound || val > upperBound) {
this.logger.warn(`Outlier detected using IQR method. Value=${val}`);
return true;
}
return false;
}
modifiedZScoreOutlierDetection(val) {
const median = this.medianFilter(this.storedValues);
const mad = this.medianFilter(this.storedValues.map(v => Math.abs(v - median)));
const modifiedZScore = 0.6745 * (val - median) / mad;
const threshold = this.config.outlierDetection.threshold || 3.5;
if (Math.abs(modifiedZScore) > threshold) {
this.logger.warn(`Outlier detected using Modified Z-Score method. Modified Z-Score=${modifiedZScore}`);
return true;
}
return false;
}
calculateInput(value) {
// Check if the value is an outlier and check if outlier detection is enabled
if (this.config.outlierDetection.enabled) {
if ( this.outlierDetection(value) ){
this.logger.warn(`Outlier detected. Ignoring value=${value}`);
return;
}
}
// Apply offset
let val = this.applyOffset(value);
// Track raw min/max
this.updateMinMaxValues(val);
// Handle scaling if enabled
if (this.config.scaling.enabled) {
val = this.handleScaling(val);
}
// Apply smoothing
const smoothed = this.applySmoothing(val);
// Update smoothed min/max and output
this.updateSmoothMinMaxValues(smoothed);
this.updateOutputAbs(smoothed);
}
applyOffset(value) {
return value + this.config.scaling.offset;
}
handleScaling(value) {
// Check if input range is valid
if (this.inputRange <= 0) {
this.logger.warn(`Input range is invalid. Falling back to default range [0, 1].`);
this.config.scaling.inputMin = 0;
this.config.scaling.inputMax = 1;
this.inputRange = this.config.scaling.inputMax - this.config.scaling.inputMin;
}
// Constrain value within input range
if (value < this.config.scaling.inputMin || value > this.config.scaling.inputMax) {
this.logger.warn(`Value=${value} is outside of INPUT range. Constraining.`);
value = this.constrain(value, this.config.scaling.inputMin, this.config.scaling.inputMax);
}
// Interpolate value
this.logger.debug(`Interpolating value=${value} between min=${this.config.scaling.inputMin} and max=${this.config.scaling.inputMax} to absMin=${this.config.scaling.absMin} and absMax=${this.config.scaling.absMax}`);
return this.interpolateLinear(value, this.config.scaling.inputMin, this.config.scaling.inputMax, this.config.scaling.absMin, this.config.scaling.absMax);
}
constrain(input, inputMin , inputMax) {
this.logger.warn(`New value=${input} is constrained to fit between min=${inputMin} and max=${inputMax}`);
return Math.min(Math.max(input, inputMin), inputMax);
}
interpolateLinear(iNumber, iMin, iMax, oMin, oMax) {
if (iMin >= iMax || oMin >= oMax) {
this.logger.warn(`Invalid input for linear interpolation iMin=${JSON.stringify(iMin)} iMax=${iMax} oMin=${JSON.stringify(oMin)} oMax=${oMax}`);
return iNumber;
}
const range = iMax - iMin;
return oMin + ((iNumber - iMin) * (oMax - oMin)) / range;
}
applySmoothing(value) {
this.storedValues.push(value);
// Maintain only the latest 'smoothWindow' number of values
if (this.storedValues.length > this.config.smoothing.smoothWindow) {
this.storedValues.shift();
}
// Smoothing strategies
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),
median: (arr) => this.medianFilter(arr),
kalman: (arr) => this.kalmanFilter(arr),
savitzkyGolay: (arr) => this.savitzkyGolayFilter(arr),
};
// Ensure the smoothing method is valid
const method = this.config.smoothing.smoothMethod;
this.logger.debug(`Applying smoothing method "${method}"`);
if (!smoothingMethods[method]) {
this.logger.error(`Smoothing method "${method}" is not implemented.`);
return value;
}
// Apply the smoothing method
return smoothingMethods[method](this.storedValues);
}
standardDeviation(values) {
if (values.length <= 1) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const sqDiffs = values.map(v => (v - mean) ** 2);
const variance = sqDiffs.reduce((a, b) => a + b, 0) / (values.length - 1);
return Math.sqrt(variance);
}
savitzkyGolayFilter(arr) {
const coefficients = [-3, 12, 17, 12, -3]; // Example coefficients for 5-point smoothing
const normFactor = coefficients.reduce((a, b) => a + b, 0);
if (arr.length < coefficients.length) {
return arr[arr.length - 1]; // Return last value if array is too small
}
let smoothed = 0;
for (let i = 0; i < coefficients.length; i++) {
smoothed += arr[arr.length - coefficients.length + i] * coefficients[i];
}
return smoothed / normFactor;
}
kalmanFilter(arr) {
let estimate = arr[0];
const measurementNoise = 1; // Adjust based on your sensor's characteristics
const processNoise = 0.1; // Adjust based on signal variability
const kalmanGain = processNoise / (processNoise + measurementNoise);
for (let i = 1; i < arr.length; i++) {
estimate = estimate + kalmanGain * (arr[i] - estimate);
}
return estimate;
}
medianFilter(arr) {
const sorted = [...arr].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
}
bandPassFilter(arr) {
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
}
weightedMovingAverage(arr) {
const weights = arr.map((_, i) => i + 1); // Weights increase linearly
const weightedSum = arr.reduce((sum, val, idx) => sum + val * weights[idx], 0);
const weightTotal = weights.reduce((sum, weight) => sum + weight, 0);
return weightedSum / weightTotal;
}
highPassFilter(arr) {
const alpha = 0.8; // Smoothing factor (0 < alpha <= 1)
let filteredValues = [];
filteredValues[0] = arr[0];
for (let i = 1; i < arr.length; i++) {
filteredValues[i] = alpha * (filteredValues[i - 1] + arr[i] - arr[i - 1]);
}
return filteredValues[filteredValues.length - 1];
}
lowPassFilter(arr) {
const alpha = 0.2; // Smoothing factor (0 < alpha <= 1)
let smoothedValue = arr[0];
for (let i = 1; i < arr.length; i++) {
smoothedValue = alpha * arr[i] + (1 - alpha) * smoothedValue;
}
return smoothedValue;
}
// Or also EMA called exponential moving average
recursiveLowpassFilter() {
}
mean(arr) {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
min(arr) {
return Math.min(...arr);
}
max(arr) {
return Math.max(...arr);
}
updateMinMaxValues(value) {
if (value < this.totalMinValue) {
this.totalMinValue = value;
}
if (value > this.totalMaxValue) {
this.totalMaxValue = value;
}
}
updateSmoothMinMaxValues(value) {
// If this is the first run, initialize them
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;
}
}
updateOutputAbs(val) {
// Constrain first, then check for changes
let constrainedVal = val;
if (val < this.config.scaling.absMin || val > this.config.scaling.absMax) {
this.logger.warn(`Output value=${val} is outside of ABS range. Constraining.`);
constrainedVal = this.constrain(val, this.config.scaling.absMin, this.config.scaling.absMax);
}
const roundedVal = Math.round(constrainedVal * 100) / 100;
//only update on change
if (roundedVal != this.outputAbs) {
// Constrain value within process range
if (val < this.config.scaling.absMin || val > this.config.scaling.absMax) {
this.logger.warn(`Output value=${val} is outside of ABS range. Constraining.`);
val = this.constrain(val, this.config.scaling.absMin, this.config.scaling.absMax);
}
this.outputAbs = Math.round(val * 100) / 100;
this.outputPercent = this.updateOutputPercent(val);
this.emitter.emit('mAbs', this.outputAbs);// DEPRECATED: Use measurements container instead
this.logger.debug(`Updating type: ${this.config.asset.type}, variant: ${"measured"}, postition : ${this.config.functionality.positionVsParent} container with new value: ${this.outputAbs}`);
this.measurements.type(this.config.asset.type).variant("measured").position(this.config.functionality.positionVsParent).distance(this.config.functionality.distance).value(this.outputAbs, Date.now(),this.config.asset.unit );
}
}
updateOutputPercent(value) {
let outputPercent;
if (this.processRange <= 0) {
this.logger.debug(`Process range is smaller or equal to 0 interpolating between input range`);
outputPercent = this.interpolateLinear( value, this.totalMinValue, this.totalMaxValue, this.config.interpolation.percentMin, this.config.interpolation.percentMax );
}
else {
outputPercent = this.interpolateLinear( value, this.config.scaling.absMin, this.config.scaling.absMax, this.config.interpolation.percentMin, this.config.interpolation.percentMax );
}
return Math.round(outputPercent * 100) / 100;
}
toggleSimulation(){
toggleSimulation() {
this.config.simulation = this.config.simulation || {};
this.config.simulation.enabled = !this.config.simulation.enabled;
}
toggleOutlierDetection() {
this.config.outlierDetection = !this.config.outlierDetection;
this.config.outlierDetection = this.config.outlierDetection || {};
this.config.outlierDetection.enabled = !Boolean(this.config.outlierDetection.enabled);
if (this.analogChannel) this.analogChannel.outlierDetection.enabled = this.config.outlierDetection.enabled;
}
calibrate() {
const result = this._calibrator.calibrate(this.analogChannel?.outputAbs ?? 0);
if (result && typeof result.offset === 'number') {
this.config.scaling.offset = result.offset;
if (this.analogChannel) this.analogChannel.scaling.offset = result.offset;
}
}
// Legacy shape: <2 samples returns bare `false`; otherwise the
// {isStable, stdDev} object the calibrator produces.
isStable() {
if ((this.storedValues?.length ?? 0) < 2) return false;
return this._calibrator.isStable();
}
evaluateRepeatability() {
const { repeatability } = this._calibrator.evaluateRepeatability();
return repeatability;
}
// --- analog pipeline delegates (preserved for tests + back-compat) ---
calculateInput(value) {
if (!this.analogChannel) return;
this.analogChannel.update(value);
this.notifyOutputChanged();
}
applyOffset(value) { return value + (this.config.scaling?.offset ?? 0); }
constrain(v, lo, hi) { return Math.min(Math.max(v, lo), hi); }
interpolateLinear(n, iMin, iMax, oMin, oMax) {
if (iMin >= iMax || oMin >= oMax) return n;
return oMin + ((n - iMin) * (oMax - oMin)) / (iMax - iMin);
}
handleScaling(value) {
if (!this.analogChannel) return value;
const out = this.analogChannel._applyScaling(value);
// Channel mutates its own scaling copy when inputRange is invalid;
// mirror that back to config.scaling so the legacy contract holds.
this.config.scaling.inputMin = this.analogChannel.scaling.inputMin;
this.config.scaling.inputMax = this.analogChannel.scaling.inputMax;
return out;
}
outlierDetection(value) {
if (!this.analogChannel) return false;
// Channel skips outlier checks when disabled; the legacy test API expects
// the check to run regardless of the enabled flag.
return this.analogChannel._isOutlier(value);
}
updateOutputPercent(value) { return this.analogChannel?._computePercent(value) ?? 0; }
// --- output / status ---
getOutput() {
if (this.mode === 'digital') return this.getDigitalOutput();
return {
mAbs: this.outputAbs,
mPercent: this.outputPercent,
totalMinValue: this.totalMinValue,
totalMaxValue: this.totalMaxValue,
totalMinValue: this.totalMinValue === Infinity ? 0 : this.totalMinValue,
totalMaxValue: this.totalMaxValue === -Infinity ? 0 : this.totalMaxValue,
totalMinSmooth: this.totalMinSmooth,
totalMaxSmooth: this.totalMaxSmooth,
};
}
getStatusBadge() {
if (this.mode === 'digital') {
return statusBadge.compose([`digital · ${this.channels.size} channel(s)`], { fill: 'blue', shape: 'ring' });
}
const unit = this.config?.general?.unit || '';
return statusBadge.compose([`${this.outputAbs} ${unit}`.trim()], { fill: 'green', shape: 'dot' });
}
}
module.exports = Measurement;
/*
// Testing the class
const configuration = {
general: {
name: "PT1",
logging: {
enabled: true,
logLevel: "debug",
},
},
scaling:{
enabled: true,
inputMin: 0,
inputMax: 3000,
absMin: 500,
absMax: 4000,
offset: 1000
},
asset: {
type: "pressure",
unit: "bar",
category: "measurement",
model: "PT1",
uuid: "123e4567-e89b-12d3-a456-426614174000",
tagCode: "PT1-001",
supplier: "DeltaTech"
},
smoothing: {
smoothWindow: 10,
smoothMethod: 'mean',
},
simulation: {
enabled: true,
},
functionality: {
positionVsParent: POSITIONS.UPSTREAM
}
};
const m = new Measurement(configuration);
m.logger.info(`Measurement created with config : ${JSON.stringify(m.config)}`);
m.logger.setLogLevel("debug");
//look for flow updates
m.measurements.emitter.on('pressure.measured.upstream', (newVal) => {
m.logger.info(`Received : ${newVal.value} ${newVal.unit}`);
const repeatability = m.evaluateRepeatability();
if (repeatability !== null) {
m.logger.info(`Current repeatability (standard deviation): ${repeatability}`);
}
});
const tickLoop = setInterval(changeInput,1000);
function changeInput(){
m.logger.info(`tick...`);
m.tick();
//m.inputValue = 5;
}
// */

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,156 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const Calibrator = require('../../src/calibration/calibrator.js');
// Tiny logger spy so we can assert on warn() without pulling in the real
// generalFunctions logger.
function makeLogger() {
const calls = { warn: [], info: [], debug: [], error: [] };
return {
calls,
warn: (m) => calls.warn.push(m),
info: (m) => calls.info.push(m),
debug: (m) => calls.debug.push(m),
error: (m) => calls.error.push(m),
};
}
function makeCalibrator(values, config) {
const logger = makeLogger();
const cal = new Calibrator({
storedValuesRef: () => values,
configRef: () => config,
logger,
});
return { cal, logger };
}
test('isStable: constant array → stable with stdDev=0', () => {
const { cal } = makeCalibrator([5, 5, 5, 5], {});
const r = cal.isStable();
assert.strictEqual(r.isStable, true);
assert.strictEqual(r.stdDev, 0);
});
test('isStable: high-variance array under default threshold → unstable', () => {
// Resolved 2026-05-11: config-driven absolute stabilityThreshold replaces
// the old `stdDev < stdDev*marginFactor` tautology. Default threshold is
// 0.01 (scaling-units); a 0..100 spread blows past it.
const { cal } = makeCalibrator([0, 100, 0, 100], {});
const r = cal.isStable();
assert.strictEqual(r.isStable, false);
assert.ok(r.stdDev > 0);
});
test('isStable: high-variance array with relaxed threshold → stable', () => {
const cfg = { calibration: { stabilityThreshold: 100 } };
const { cal } = makeCalibrator([0, 100, 0, 100], cfg);
const r = cal.isStable();
assert.strictEqual(r.isStable, true);
assert.ok(r.stdDev > 0);
});
test('isStable: zero stdDev (constant) is stable regardless of threshold', () => {
const cfg = { calibration: { stabilityThreshold: 0 } };
const { cal } = makeCalibrator([7, 7, 7, 7], cfg);
const r = cal.isStable();
assert.strictEqual(r.isStable, true);
assert.strictEqual(r.stdDev, 0);
});
test('isStable: stdDev just above threshold → unstable', () => {
const cfg = { calibration: { stabilityThreshold: 0.5 } };
// stdDev of [10, 11] = 0.5; nudge the spread up so stdDev > 0.5.
const { cal } = makeCalibrator([10, 12], cfg);
const r = cal.isStable();
assert.strictEqual(r.isStable, false);
assert.ok(r.stdDev > 0.5);
});
test('isStable: missing config.calibration → falls back to default 0.01', () => {
// stdDev of [10, 10.001] ≈ 0.0005, well under the 0.01 default.
const { cal: stable } = makeCalibrator([10, 10.001], {});
assert.strictEqual(stable.isStable().isStable, true);
// stdDev of [10, 10.1] ≈ 0.05, above the 0.01 default.
const { cal: unstable } = makeCalibrator([10, 10.1], {});
assert.strictEqual(unstable.isStable().isStable, false);
});
test('isStable: < 2 values → unstable', () => {
const { cal } = makeCalibrator([42], {});
const r = cal.isStable();
assert.strictEqual(r.isStable, false);
assert.strictEqual(r.stdDev, 0);
});
test('calibrate: scaling enabled → offset = inputMin - currentOutputAbs', () => {
const cfg = { scaling: { enabled: true, inputMin: 4, absMin: 0 } };
const { cal } = makeCalibrator([10, 10, 10], cfg);
const r = cal.calibrate(10);
assert.deepStrictEqual(r, { offset: -6 });
});
test('calibrate: scaling disabled → offset = absMin - currentOutputAbs', () => {
const cfg = { scaling: { enabled: false, inputMin: 4, absMin: 1 } };
const { cal } = makeCalibrator([7, 7, 7], cfg);
const r = cal.calibrate(7);
assert.deepStrictEqual(r, { offset: -6 });
});
test('calibrate: not stable (length<2) → returns null and logs warn', () => {
// Original rule has a tautological threshold, so "unstable" only triggers
// when the rolling window has < 2 samples.
const cfg = { scaling: { enabled: true, inputMin: 0, absMin: 0 } };
const { cal, logger } = makeCalibrator([], cfg);
const r = cal.calibrate(50);
assert.strictEqual(r, null);
assert.strictEqual(logger.calls.warn.length, 1);
assert.match(logger.calls.warn[0], /Calibration aborted/);
});
test('evaluateRepeatability: smoothing=none → null', () => {
const cfg = { smoothing: { smoothMethod: 'none' } };
const { cal, logger } = makeCalibrator([5, 5, 5], cfg);
const r = cal.evaluateRepeatability();
assert.strictEqual(r.repeatability, null);
assert.strictEqual(r.reason, 'smoothing-disabled');
assert.match(logger.calls.warn[0], /without smoothing/);
});
test('evaluateRepeatability: stable + smoothed → returns stdDev', () => {
const cfg = { smoothing: { smoothMethod: 'mean' } };
const { cal } = makeCalibrator([3, 3, 3, 3], cfg);
const r = cal.evaluateRepeatability();
assert.strictEqual(r.repeatability, 0);
});
test('evaluateRepeatability: insufficient data → null', () => {
const cfg = { smoothing: { smoothMethod: 'mean' } };
const { cal } = makeCalibrator([5], cfg);
const r = cal.evaluateRepeatability();
assert.strictEqual(r.repeatability, null);
assert.strictEqual(r.reason, 'insufficient-data');
});
test('evaluateRepeatability: high-variance under default threshold → null', () => {
// Resolved 2026-05-11: with the real stability check in place, a noisy
// buffer fails isStable() and repeatability reports null with reason.
const cfg = { smoothing: { smoothMethod: 'mean' } };
const { cal, logger } = makeCalibrator([0, 50, 0, 50], cfg);
const r = cal.evaluateRepeatability();
assert.strictEqual(r.repeatability, null);
assert.strictEqual(r.reason, 'unstable');
assert.match(logger.calls.warn[0], /not stable/);
});
test('evaluateRepeatability: high-variance with relaxed threshold → returns stdDev', () => {
const cfg = {
smoothing: { smoothMethod: 'mean' },
calibration: { stabilityThreshold: 100 },
};
const { cal } = makeCalibrator([0, 50, 0, 50], cfg);
const r = cal.evaluateRepeatability();
assert.ok(r.repeatability > 0);
});

View File

@@ -0,0 +1,323 @@
// Unit-handling tests for the measurement data.measurement command.
// Verifies that analog and digital modes accept the same payload shapes
// (bare scalar | rich object | per-channel map) and that supplied units
// are converted into the channel's configured (dropdown) unit.
//
// Run with: node --test test/basic/commands-units.basic.test.js
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createRegistry } = require('generalFunctions');
const commands = require('../../src/commands');
// --- helpers ---------------------------------------------------------------
function makeLogger() {
const calls = { warn: [], error: [], info: [], debug: [] };
return {
calls,
warn: (m) => calls.warn.push(String(m)),
error: (m) => calls.error.push(String(m)),
info: (m) => calls.info.push(String(m)),
debug: (m) => calls.debug.push(String(m)),
};
}
// Analog source mock: exposes analogChannel.unit so the handler can resolve
// the channel's configured (dropdown) unit. inputValueSets captures the
// value that was eventually written, after any unit conversion.
function makeAnalogSource({ unit = 'mbar' } = {}) {
const inputValueSets = [];
let _v = 0;
return {
source: {
mode: 'analog',
logger: makeLogger(),
analogChannel: { unit },
get inputValue() { return _v; },
set inputValue(v) { _v = v; inputValueSets.push(v); },
},
inputValueSets,
};
}
// Digital source mock: exposes channels.get(key).unit per channel so each
// digital entry can be converted independently. handleDigitalPayloadCalls
// captures the *flat* {key: convertedNumber} the handler ultimately passes.
function makeDigitalSource(channelUnits) {
const handleDigitalPayloadCalls = [];
const channels = new Map(Object.entries(channelUnits).map(([k, u]) => [k, { unit: u }]));
return {
source: {
mode: 'digital',
logger: makeLogger(),
channels,
handleDigitalPayload: (p) => { handleDigitalPayloadCalls.push(p); return { ok: true }; },
},
handleDigitalPayloadCalls,
};
}
function makeCtx({ logger = makeLogger() } = {}) {
return { logger, RED: { nodes: { getNode: () => undefined } }, node: {}, send: () => {} };
}
function makeRegistry(logger) {
return createRegistry(commands, { logger });
}
// --- analog ----------------------------------------------------------------
test('analog: bare number uses channel default unit (no conversion)', async () => {
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'data.measurement', payload: 1234 }, source, makeCtx());
assert.deepEqual(inputValueSets, [1234]);
});
test('analog: { value, unit } same as channel passes through unchanged', async () => {
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'data.measurement', payload: { value: 500, unit: 'mbar' } },
source,
makeCtx(),
);
assert.deepEqual(inputValueSets, [500]);
});
test('analog: { value, unit } different but compatible unit is converted', async () => {
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
const reg = makeRegistry(makeLogger());
// 1 bar = 1000 mbar.
await reg.dispatch(
{ topic: 'data.measurement', payload: { value: 1, unit: 'bar' } },
source,
makeCtx(),
);
assert.equal(inputValueSets.length, 1);
assert.ok(Math.abs(inputValueSets[0] - 1000) < 1e-6,
`expected 1 bar → 1000 mbar, got ${inputValueSets[0]}`);
});
test('analog: msg.unit fallback works for bare-number payloads', async () => {
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'data.measurement', payload: 1, unit: 'bar' },
source,
makeCtx(),
);
assert.equal(inputValueSets.length, 1);
assert.ok(Math.abs(inputValueSets[0] - 1000) < 1e-6,
`expected 1 bar → 1000 mbar via msg.unit, got ${inputValueSets[0]}`);
});
test('analog: unit-measure mismatch warns and falls back to raw value', async () => {
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch(
{ topic: 'data.measurement', payload: { value: 42, unit: 'kg' } },
source,
makeCtx({ logger: ctxLogger }),
);
assert.deepEqual(inputValueSets, [42]);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes("'kg'") && m.includes("'mbar'")),
`expected mismatch warning, got: ${JSON.stringify(ctxLogger.calls.warn)}`,
);
});
test('analog: unknown unit warns and falls back to raw value', async () => {
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch(
{ topic: 'data.measurement', payload: { value: 5, unit: 'gribbles' } },
source,
makeCtx({ logger: ctxLogger }),
);
assert.deepEqual(inputValueSets, [5]);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes("'gribbles'")),
`expected unknown-unit warning, got: ${JSON.stringify(ctxLogger.calls.warn)}`,
);
});
test('analog: numeric string with msg.unit is converted', async () => {
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'data.measurement', payload: '2', unit: 'bar' },
source,
makeCtx(),
);
assert.equal(inputValueSets.length, 1);
assert.ok(Math.abs(inputValueSets[0] - 2000) < 1e-6,
`expected '2' bar → 2000 mbar, got ${inputValueSets[0]}`);
});
// --- digital ---------------------------------------------------------------
test('digital: per-channel { value, unit } converts each independently', async () => {
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
pIn: 'mbar',
pOut: 'Pa',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{
topic: 'data.measurement',
payload: {
pIn: { value: 1, unit: 'bar' }, // → 1000 mbar
pOut: { value: 1.5, unit: 'bar' }, // → 150000 Pa
},
},
source,
makeCtx(),
);
assert.equal(handleDigitalPayloadCalls.length, 1);
const flat = handleDigitalPayloadCalls[0];
assert.ok(Math.abs(flat.pIn - 1000) < 1e-6, `pIn expected 1000, got ${flat.pIn}`);
assert.ok(Math.abs(flat.pOut - 150000) < 1e-3, `pOut expected 150000, got ${flat.pOut}`);
});
test('digital: bare-number entries use the channel default unit', async () => {
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
a: 'mbar',
b: 'mbar',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'data.measurement', payload: { a: 500, b: 750 } },
source,
makeCtx(),
);
assert.deepEqual(handleDigitalPayloadCalls[0], { a: 500, b: 750 });
});
test('digital: mixed rich + bare entries are converted per-channel', async () => {
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
a: 'mbar',
b: 'mbar',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{
topic: 'data.measurement',
payload: {
a: { value: 1, unit: 'bar' }, // converted → 1000
b: 750, // passthrough
},
},
source,
makeCtx(),
);
const flat = handleDigitalPayloadCalls[0];
assert.ok(Math.abs(flat.a - 1000) < 1e-6, `a expected 1000, got ${flat.a}`);
assert.equal(flat.b, 750);
});
test('digital: msg.unit applies to bare entries when no per-channel unit is given', async () => {
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
a: 'mbar',
b: 'mbar',
});
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'data.measurement', payload: { a: 1, b: 2 }, unit: 'bar' },
source,
makeCtx(),
);
const flat = handleDigitalPayloadCalls[0];
assert.ok(Math.abs(flat.a - 1000) < 1e-6, `a expected 1000, got ${flat.a}`);
assert.ok(Math.abs(flat.b - 2000) < 1e-6, `b expected 2000, got ${flat.b}`);
});
test('digital: unit-measure mismatch on one channel warns + falls back without affecting others', async () => {
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
pressure: 'mbar',
flow: 'm3/h',
});
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch(
{
topic: 'data.measurement',
payload: {
pressure: { value: 1, unit: 'bar' }, // converted → 1000
flow: { value: 100, unit: 'kg' }, // mismatch → raw 100, warn
},
},
source,
makeCtx({ logger: ctxLogger }),
);
const flat = handleDigitalPayloadCalls[0];
assert.ok(Math.abs(flat.pressure - 1000) < 1e-6, `pressure expected 1000, got ${flat.pressure}`);
assert.equal(flat.flow, 100);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes("data.measurement[flow]") && m.includes("'kg'")),
`expected per-channel mismatch warning, got: ${JSON.stringify(ctxLogger.calls.warn)}`,
);
});
// --- backwards-compat -----------------------------------------------------
test('analog: { value } without unit uses channel default (rich-payload form)', async () => {
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
const reg = makeRegistry(makeLogger());
await reg.dispatch(
{ topic: 'data.measurement', payload: { value: 42 } },
source,
makeCtx(),
);
assert.deepEqual(inputValueSets, [42]);
});
test('analog: object payload that is *not* rich still triggers switch-mode warn', async () => {
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch(
{ topic: 'data.measurement', payload: { tempA: 21.5, tempB: 19.8 } },
source,
makeCtx({ logger: ctxLogger }),
);
assert.equal(inputValueSets.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('analog mode') && m.includes('digital')),
`expected switch-to-digital warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`,
);
});

View File

@@ -0,0 +1,168 @@
// Basic tests for the measurement commands registry.
// Run with: node --test test/basic/commands.basic.test.js
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createRegistry } = require('generalFunctions');
const commands = require('../../src/commands');
// --- helpers ---------------------------------------------------------------
function makeLogger() {
const calls = { warn: [], error: [], info: [], debug: [] };
return {
calls,
warn: (m) => calls.warn.push(String(m)),
error: (m) => calls.error.push(String(m)),
info: (m) => calls.info.push(String(m)),
debug: (m) => calls.debug.push(String(m)),
};
}
function makeSource({ mode = 'analog', simulator = false, outlier = false } = {}) {
const calls = {
toggleSimulation: 0,
toggleOutlierDetection: 0,
calibrate: 0,
handleDigitalPayload: [],
inputValueSets: [],
};
const state = { simulator, outlier, _inputValue: 0 };
const source = {
mode,
logger: makeLogger(),
toggleSimulation: () => { state.simulator = !state.simulator; calls.toggleSimulation += 1; },
toggleOutlierDetection: () => { state.outlier = !state.outlier; calls.toggleOutlierDetection += 1; },
calibrate: () => { calls.calibrate += 1; },
handleDigitalPayload: (p) => { calls.handleDigitalPayload.push(p); return { ok: true }; },
get inputValue() { return state._inputValue; },
set inputValue(v) { state._inputValue = v; calls.inputValueSets.push(v); },
};
return { source, calls, state };
}
function makeCtx({ logger = makeLogger() } = {}) {
return { logger, RED: { nodes: { getNode: () => undefined } }, node: {}, send: () => {} };
}
function makeRegistry(logger) {
return createRegistry(commands, { logger });
}
// --- tests -----------------------------------------------------------------
test('canonical topics dispatch to the right handler', async () => {
const { source, calls, state } = makeSource();
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx());
assert.equal(calls.toggleSimulation, 1);
assert.equal(state.simulator, true);
await reg.dispatch({ topic: 'set.outlier-detection' }, source, makeCtx());
assert.equal(calls.toggleOutlierDetection, 1);
assert.equal(state.outlier, true);
await reg.dispatch({ topic: 'cmd.calibrate' }, source, makeCtx());
assert.equal(calls.calibrate, 1);
});
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
const { source, calls } = makeSource();
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
for (const alias of ['simulator', 'outlierDetection', 'calibrate', 'measurement']) {
await reg.dispatch({ topic: alias, payload: 1 }, source, makeCtx({ logger: ctxLogger }));
await reg.dispatch({ topic: alias, payload: 2 }, source, makeCtx({ logger: ctxLogger }));
}
for (const alias of ['simulator', 'outlierDetection', 'calibrate', 'measurement']) {
const hits = ctxLogger.calls.warn.filter((m) => m.includes(`'${alias}' is deprecated`));
assert.equal(hits.length, 1, `alias '${alias}' should warn exactly once`);
}
// sanity: side-effects fired twice per alias.
assert.equal(calls.toggleSimulation, 2);
assert.equal(calls.toggleOutlierDetection, 2);
assert.equal(calls.calibrate, 2);
// analog measurement alias with numeric payload set inputValue twice.
assert.deepEqual(calls.inputValueSets, [1, 2]);
});
test('data.measurement analog with numeric payload sets source.inputValue', async () => {
const { source, calls } = makeSource({ mode: 'analog' });
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'data.measurement', payload: 42 }, source, makeCtx());
await reg.dispatch({ topic: 'data.measurement', payload: '3.5' }, source, makeCtx());
assert.deepEqual(calls.inputValueSets, [42, 3.5]);
});
test('data.measurement analog with object payload logs helpful switch-mode warn', async () => {
const { source, calls } = makeSource({ mode: 'analog' });
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch(
{ topic: 'data.measurement', payload: { temperature: 21.5, humidity: 45 } },
source,
makeCtx({ logger: ctxLogger })
);
assert.equal(calls.inputValueSets.length, 0);
assert.equal(calls.handleDigitalPayload.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('analog mode') && m.includes('digital')),
`expected helpful switch-to-digital warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});
test('data.measurement digital with object payload calls handleDigitalPayload', async () => {
const { source, calls } = makeSource({ mode: 'digital' });
const reg = makeRegistry(makeLogger());
const payload = { tempA: 21.5, tempB: 19.8 };
await reg.dispatch({ topic: 'data.measurement', payload }, source, makeCtx());
assert.equal(calls.handleDigitalPayload.length, 1);
assert.deepEqual(calls.handleDigitalPayload[0], payload);
assert.equal(calls.inputValueSets.length, 0);
});
test('data.measurement digital with number logs helpful switch-mode warn', async () => {
const { source, calls } = makeSource({ mode: 'digital' });
const ctxLogger = makeLogger();
const reg = makeRegistry(ctxLogger);
await reg.dispatch(
{ topic: 'data.measurement', payload: 7 },
source,
makeCtx({ logger: ctxLogger })
);
assert.equal(calls.handleDigitalPayload.length, 0);
assert.equal(calls.inputValueSets.length, 0);
assert.ok(
ctxLogger.calls.warn.some((m) => m.includes('digital mode') && m.includes('analog')),
`expected helpful switch-to-analog warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`
);
});
test('set.simulator toggles even with no payload (idempotent flip)', async () => {
const { source, calls, state } = makeSource({ simulator: false });
const reg = makeRegistry(makeLogger());
await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx());
assert.equal(state.simulator, true);
await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx());
assert.equal(state.simulator, false);
await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx());
assert.equal(state.simulator, true);
assert.equal(calls.toggleSimulation, 3);
});

View File

@@ -0,0 +1,65 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
// These tests pinned the old private methods (_attachInputHandler /
// _registerChild) on the pre-refactor nodeClass. After the BaseNodeAdapter
// migration the same wiring is provided by the base class, but we still
// exercise it from a prototype-derived instance to keep the surface covered
// without booting a full Node-RED runtime.
test('input handler dispatches known topics to source methods', async () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const calls = [];
const source = {
mode: 'analog',
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
toggleSimulation() { calls.push('simulator'); },
toggleOutlierDetection() { calls.push('outlierDetection'); },
calibrate() { calls.push('calibrate'); },
set inputValue(v) { calls.push(['measurement', v]); },
};
inst.node = node;
inst.RED = makeREDStub();
inst.source = source;
inst._commands = createRegistry(commands, { logger: source.logger });
inst._attachInputHandler();
const onInput = node._handlers.input;
await onInput({ topic: 'simulator' }, () => {}, () => {});
await onInput({ topic: 'outlierDetection' }, () => {}, () => {});
await onInput({ topic: 'calibrate' }, () => {}, () => {});
await 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('registration emits delayed child.register 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._scheduleRegistration();
} finally {
global.setTimeout = originalSetTimeout;
}
assert.equal(node._sent.length, 1);
assert.equal(node._sent[0][2].topic, 'child.register');
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,121 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Simulator = require('../../src/simulation/simulator.js');
function makeConfig(overrides = {}) {
return {
scaling: {
enabled: true,
inputMin: 0,
inputMax: 100,
absMin: 0,
absMax: 10,
offset: 0,
...(overrides.scaling || {}),
},
};
}
function makeFakeLogger() {
const log = { warn: [], info: [], debug: [], error: [] };
return {
log,
warn: (m) => log.warn.push(m),
info: (m) => log.info.push(m),
debug: (m) => log.debug.push(m),
error: (m) => log.error.push(m),
};
}
// Replace Math.random with a deterministic queue, restore on cleanup.
function stubRandom(values) {
const orig = Math.random;
let i = 0;
Math.random = () => (i < values.length ? values[i++] : 0);
return () => { Math.random = orig; };
}
test('constructor derives inputRange when scaling.enabled=true', () => {
const sim = new Simulator({ config: makeConfig() });
assert.equal(sim.inputRange, 100);
assert.equal(sim.processRange, 10);
assert.equal(sim.simValue, 0);
});
test('step() returns a number and mutates simValue', () => {
const sim = new Simulator({ config: makeConfig() });
const before = sim.simValue;
const out = sim.step();
assert.equal(typeof out, 'number');
assert.notEqual(out, before);
assert.equal(out, sim.simValue);
});
test('step() is deterministic when Math.random is stubbed', () => {
// sign-roll then magnitude. With scaling enabled inputRange=100 -> maxStep=5.
// 0.4 < 0.5 => sign = -1; 0.2 magnitude => -1 * 0.2 * 5 = -1.
const restore = stubRandom([0.4, 0.2]);
try {
const sim = new Simulator({ config: makeConfig() });
const v = sim.step();
assert.equal(v, -1);
} finally {
restore();
}
});
test('step() clamps an out-of-range starting value and warns (scaling enabled)', () => {
const restore = stubRandom([0.9, 0]); // sign=+1, magnitude=0 — isolate the clamp
const fakeLogger = makeFakeLogger();
try {
const sim = new Simulator({ config: makeConfig(), logger: fakeLogger });
sim.simValue = 500; // outside [0,100]
sim.step();
assert.equal(sim.simValue, 100, 'clamped to inputMax before stepping');
assert.equal(fakeLogger.log.warn.length, 1);
assert.match(fakeLogger.log.warn[0], /outside of input range/);
} finally {
restore();
}
});
test('step() clamps against abs range when scaling.enabled=false', () => {
const restore = stubRandom([0.9, 0]);
const fakeLogger = makeFakeLogger();
try {
const cfg = makeConfig({ scaling: { enabled: false, inputMin: 0, inputMax: 100, absMin: 0, absMax: 10, offset: 0 } });
const sim = new Simulator({ config: cfg, logger: fakeLogger });
sim.simValue = -5;
sim.step();
assert.equal(sim.simValue, 0, 'clamped to absMin');
assert.match(fakeLogger.log.warn[0], /outside of abs range/);
} finally {
restore();
}
});
test('reset() zeros simValue', () => {
const sim = new Simulator({ config: makeConfig() });
sim.simValue = 42;
sim.reset();
assert.equal(sim.simValue, 0);
assert.equal(sim.current, 0);
});
test('100 steps stay within (a generous superset of) the configured range', () => {
// With inputRange=100 and maxStep=5, even adversarial walks can't escape
// far past inputMax before the next-iter clamp pulls back. Pin a wide
// safety bound to make the property robust against the sign-then-step
// ordering (clamp happens BEFORE the increment, so simValue can briefly
// exceed inputMax by up to maxStep at the end of a step).
const sim = new Simulator({ config: makeConfig() });
for (let i = 0; i < 100; i++) sim.step();
assert.ok(sim.simValue > -10, `walked below -10: ${sim.simValue}`);
assert.ok(sim.simValue < 110, `walked above 110: ${sim.simValue}`);
});
test('constructor throws on missing scaling config', () => {
assert.throws(() => new Simulator({ config: {} }), /scaling/);
assert.throws(() => new Simulator({}), /scaling/);
});

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,33 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('measurement topic accepts numeric strings and rich analog object payloads', async () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const calls = [];
const source = {
mode: 'analog',
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
set inputValue(v) { calls.push(v); },
toggleSimulation() {},
toggleOutlierDetection() {},
calibrate() {},
};
inst.node = node;
inst.RED = makeREDStub();
inst.source = source;
inst._commands = createRegistry(commands, { logger: source.logger });
inst._attachInputHandler();
const onInput = node._handlers.input;
await onInput({ topic: 'measurement', payload: '42' }, () => {}, () => {});
await onInput({ topic: 'measurement', payload: { value: 42 } }, () => {}, () => {});
assert.deepEqual(calls, [42, 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');
});

451
test/specificClass.test.js Normal file
View File

@@ -0,0 +1,451 @@
/**
* 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();
const expectedKeys = [
['m', 'Abs'].join(''),
'mPercent',
'totalMinValue',
'totalMaxValue',
'totalMinSmooth',
'totalMaxSmooth',
];
for (const k of expectedKeys) expect(out).toHaveProperty(k);
});
});
// ---- 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);
});
});
});

163
wiki/Home.md Normal file
View File

@@ -0,0 +1,163 @@
# measurement
![code-ref](https://img.shields.io/badge/code--ref-b884c0f-blue) ![s88](https://img.shields.io/badge/S88-Control_Module-a9daee) ![status](https://img.shields.io/badge/status-under--review-orange)
A `measurement` turns a raw sensor signal into a validated, scaled, smoothed reading and re-emits it for any upstream parent. Two modes: **analog** (one channel built from the flat config &mdash; classic 4&ndash;20&nbsp;mA / PLC style) and **digital** (one `Channel` per `config.channels[]` entry &mdash; MQTT / IoT JSON style). It is a leaf in the S88 hierarchy &mdash; no children of its own &mdash; and registers itself as a child of any parent that accepts measurements (`rotatingMachine`, `machineGroupControl`, `pumpingStation`, `reactor`, `monster`, &hellip;).
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md`, `src/commands/index.js`, and current source only. Some sections are best-effort placeholders pending the next pass.
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | One sensor signal &mdash; pressure / flow / power / temperature / level / &hellip; |
| S88 level | Control Module |
| Use it when | You need to scale, offset, smooth, outlier-filter, or simulate a sensor reading before handing it to an equipment / unit / process-cell node |
| Don't use it for | Sensor fusion, threshold-trip alarms, or as a control output &mdash; this node is read-only signal conditioning |
| Children it accepts | None &mdash; leaf node |
| Parents it talks to | Any node that subscribes to `<type>.measured.<position>` events (`rotatingMachine`, `MGC`, `pumpingStation`, `reactor`, `monster`, &hellip;) |
---
## How it fits
```mermaid
flowchart LR
raw[Raw sensor / MQTT / inject<br/>analog scalar or digital object]
m[measurement<br/>Control Module]:::ctrl
p1[rotatingMachine<br/>Equipment]:::equip
p2[machineGroupControl<br/>Unit]:::unit
p3[pumpingStation<br/>Process Cell]:::pc
raw -->|data.measurement| m
m -->|child.register<br/>(Port 2 at startup)| p1
m -->|child.register| p2
m -->|child.register| p3
m -.->|"&lt;type&gt;.measured.&lt;position&gt;"| p1
m -.->|"&lt;type&gt;.measured.&lt;position&gt;"| p2
m -.->|"&lt;type&gt;.measured.&lt;position&gt;"| p3
classDef pc fill:#0c99d9,color:#fff
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours: Control Module `#a9daee`, Equipment `#86bbdd`, Unit `#50a8d9`, Process Cell `#0c99d9`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
---
## Try it &mdash; 1-minute demo
Import the basic example flow, deploy, and drive a single sensor through scaling + smoothing.
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/measurement/examples/basic.flow.json \
http://localhost:1880/flows
```
What to do after deploy:
1. Click the `measurement 42` inject &mdash; sends `topic: 'measurement'` (legacy alias of `data.measurement`) with payload `42`.
2. Watch Port 0 in the debug pane: `mAbs` updates immediately. After a few injects `totalMinValue` / `totalMaxValue` start tracking the rolling min/max.
3. Toggle the simulator: send `topic: 'set.simulator'`. `tick()` (1000 ms) starts driving `inputValue` through `Simulator.step()`.
4. Trigger calibration: send `topic: 'cmd.calibrate'`. If the rolling window is stable (`stdDev <= config.calibration.stabilityThreshold`) the calibrator captures the current output as the new `config.scaling.offset`.
> [!IMPORTANT]
> **GIF needed.** Demo recording of steps 1&ndash;4 with the live status badge. Save as `wiki/_partial-gifs/measurement/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
---
## The four things you'll send
| Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `data.measurement` | `measurement` | analog: `number` (or numeric string); digital: `{<channelKey>: number, &hellip;}` | Push a raw reading into the pipeline. Wrong shape for the configured mode logs a hint suggesting the other mode. |
| `set.simulator` | `simulator` | (ignored) | Toggle the built-in `Simulator` random-walk on / off. Mutates `config.simulation.enabled`. |
| `set.outlier-detection` | `outlierDetection` | (ignored) | Toggle outlier detection on the analog pipeline. Mutates `config.outlierDetection.enabled`. |
| `cmd.calibrate` | `calibrate` | (ignored) | Run a one-shot calibration. Captures the current output as `config.scaling.offset`; aborts with a warn if the buffer is not stable. |
Aliases log a one-time deprecation warning the first time they fire.
---
## What you'll see come out
Sample Port 0 message (analog mode, after a few injects):
```json
{
"topic": "measurement#sensor_a",
"payload": {
"mAbs": 0.42,
"mPercent": 42,
"totalMinValue": 0.12,
"totalMaxValue": 0.78,
"totalMinSmooth": 0.20,
"totalMaxSmooth": 0.65
}
}
```
Sample Port 0 message (digital mode):
```json
{
"topic": "measurement#multi",
"payload": {
"channels": {
"level-a": { "mAbs": 1.84, "mPercent": 73.6, "totalMinValue": 0.1, "totalMaxValue": 2.4 },
"temp-a": { "mAbs": 18.2, "mPercent": 36.4, "totalMinValue": 14.0, "totalMaxValue": 22.1 }
}
}
}
```
| Field | Meaning |
|:---|:---|
| `mAbs` | Latest output value in scaling-units (after offset + scaling + smoothing). |
| `mPercent` | Same value mapped to `interpolation.percentMin..percentMax` (default 0..100). |
| `totalMinValue` / `totalMaxValue` | Rolling min/max of **raw** (pre-scaling) values. `0` until first sample. |
| `totalMinSmooth` / `totalMaxSmooth` | Rolling min/max of the smoothed output. |
Additionally the `source.measurements.emitter` fires `<type>.measured.<position>` on every accepted update &mdash; parents subscribe to that event through the `child.measurements.emitter` handshake established at register time. See [Architecture &mdash; Lifecycle](Reference-Architecture#lifecycle) for the full path.
---
## How the pipeline behaves
```mermaid
flowchart LR
in[input value] --> out{outlierDetection.enabled?}
out -- yes --> oc[_isOutlier]
oc -- outlier --> drop[drop + warn]
oc -- ok --> off[apply scaling.offset]
out -- no --> off
off --> mm[update raw totalMin/Max]
mm --> sc{scaling.enabled?}
sc -- yes --> lin[linear map<br/>input range → abs range]
sc -- no --> sm[pass-through]
lin --> sm
sm --> sw[push to storedValues<br/>length capped by smoothWindow]
sw --> sf[smoothMethod:<br/>mean / median / kalman / &hellip;]
sf --> sm2[update smooth totalMin/Max]
sm2 --> wo[round + write outputAbs<br/>+ emit measurement event]
```
The same pipeline runs per `Channel` instance &mdash; once in analog mode, N times in digital mode.
---
## Need more?
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Full topic contract, config schema, child-registration handshake |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, lifecycle, analog vs digital branching, per-Channel pipeline |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | When not to use, known limitations, open questions |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)

View File

@@ -0,0 +1,244 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-b884c0f-blue)
> [!NOTE]
> Code structure for `measurement`: the three-tier sandwich, the `src/` layout, the per-`Channel` pipeline, the analog vs digital branching, the lifecycle, and the output-port pipeline. For an intuitive overview, return to [Home](Home).
>
> Pending full node review (2026-05). Content reflects current source and `CONTRACT.md`; sections noted as TODO require a second pass.
---
## Three-tier code layout
```
nodes/measurement/
|
+-- measurement.js entry: RED.nodes.registerType('measurement', NodeClass)
| + admin endpoints (menu.js, configData.js, asset-reg)
|
+-- src/
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
| specificClass.js extends BaseDomain (orchestrates Channels + helpers)
| channel.js one scalar pipeline (outlier → offset → scale → smooth → emit)
| |
| +-- commands/
| | index.js topic registry (set.simulator / set.outlier-detection /
| | cmd.calibrate / data.measurement)
| | handlers.js pure handler functions (mode-dispatching for data.measurement)
| |
| +-- simulation/
| | simulator.js Simulator — random-walk driver for the analog input
| |
| +-- calibration/
| calibrator.js Calibrator — stability check, offset capture, repeatability proxy
```
### Tier responsibilities
| Tier | File | What it owns | Touches `RED.*` |
|:---|:---|:---|:---:|
| entry | `measurement.js` | Type registration; admin HTTP endpoints (`/menu.js`, `/configData.js`, `/asset-reg`) | Yes |
| nodeClass | `src/nodeClass.js` | Wraps `BaseNodeAdapter`; declares `DomainClass = Measurement`, `commands`, `tickInterval = 1000 ms`, `statusInterval = 1000 ms`; `buildDomainConfig()` reshapes the editor's flat `uiConfig` into the domain config slice | Yes (via base class) |
| specificClass | `src/specificClass.js` | Orchestrator. In `configure()` builds one `Channel` (analog) or N `Channels` (digital), wires up `Simulator` and `Calibrator`, installs legacy mirrors so pre-refactor tests keep passing | No |
| concern | `src/channel.js` | Pure per-channel pipeline: outlier &rarr; offset &rarr; scaling &rarr; smoothing &rarr; min/max &rarr; emit | No |
| concern | `src/simulation/simulator.js` | Random-walk driver used when `config.simulation.enabled` is true | No |
| concern | `src/calibration/calibrator.js` | Stability detection (`isStable`), calibration offset capture (`calibrate`), repeatability proxy (`evaluateRepeatability`) | No |
`specificClass` is stitching. All real work lives in the concern modules.
---
## No FSM &mdash; just modes + a pipeline
Unlike `rotatingMachine` or `pumpingStation`, `measurement` has **no state machine**. The behavioural switch is a one-time decision made in `Measurement.configure()`:
```mermaid
flowchart LR
cfg[config.mode.current]
cfg -->|"=== 'digital'"| dig[_buildDigitalChannels<br/>one Channel per config.channels[i]]
cfg -->|"=== 'analog' (default)"| ana[_buildAnalogChannel<br/>one Channel from flat config]
dig --> emit_d[handleDigitalPayload<br/>fan-out per channel]
ana --> emit_a[inputValue setter<br/>single channel update]
classDef ctrl fill:#a9daee,color:#000
```
After `configure()`:
- **analog mode** &rarr; `this.analogChannel` is set, `this.channels` is an empty `Map`. Setting `m.inputValue = v` runs the whole pipeline and `notifyOutputChanged()` fires Port 0.
- **digital mode** &rarr; `this.channels` is keyed by `channel.key`; `analogChannel` is `undefined`. `handleDigitalPayload(payload)` walks every key in the incoming object, dispatches to the matching `Channel`, and collects a per-channel `{ok, mAbs, mPercent}` summary.
The 1000 ms `tick()` is **only** used to drive the built-in simulator when `config.simulation.enabled` is true; the rest of the node is event-driven (input msg arrives &rarr; pipeline runs &rarr; emit).
---
## The per-`Channel` pipeline
```mermaid
flowchart TB
in[update&#40;value&#41;] --> oe{outlierDetection<br/>.enabled?}
oe -- no --> off[+= scaling.offset]
oe -- yes --> iso[_isOutlier&#40;value&#41;]
iso -- outlier --> drop[return false<br/>warn + drop]
iso -- ok --> off
off --> rmm[update totalMinValue<br/>/ totalMaxValue]
rmm --> sc{scaling.enabled?}
sc -- yes --> as[_applyScaling]
sc -- no --> sm[(unchanged)]
as --> sm
sm --> push[push to storedValues<br/>cap at smoothWindow]
push --> meth[switch&#40;smoothMethod&#41;]
meth --> sms[update totalMinSmooth<br/>/ totalMaxSmooth]
sms --> wo[round to 2dp<br/>compare to outputAbs<br/>(only emit on change)]
wo --> emit[measurements.emitter<br/>fires &lt;type&gt;.measured.&lt;position&gt;]
```
Source: `src/channel.js` `update(value)`.
### Outlier methods
| `method` (config) | Implementation | Threshold default |
|:---|:---|:---:|
| `zScore` (default) | `_zScore`: `\|val - mean\| / stdDev > threshold` | `3` |
| `iqr` | `_iqr`: `val < q1 - 1.5*iqr` or `val > q3 + 1.5*iqr` | `3` |
| `modifiedZScore` | `_modifiedZScore`: `0.6745 * (val - median) / mad > threshold` | `3.5` |
`_isOutlier` returns `false` when fewer than 2 samples are stored (warm-up). The `zScore` branch is intentionally **not** short-circuited at `stdDev === 0`: a perfectly flat baseline marks any deviation as an outlier.
### Smoothing methods
Each tick the smoother pushes the post-scaling value into `storedValues`, trims the buffer to `smoothing.smoothWindow`, then collapses it to a single scalar via `smoothing.smoothMethod`:
| Method | Behaviour |
|:---|:---|
| `none` | Pass through the latest sample |
| `mean` (default) | Arithmetic mean of the window |
| `min` / `max` | Smallest / largest in the window |
| `sd` | Standard deviation |
| `median` | Middle value, robust to outliers |
| `weightedMovingAverage` | Linear weights `1..N` |
| `lowPass` | EWMA, `alpha = 0.2` |
| `highPass` | First-order high-pass, `alpha = 0.8` |
| `bandPass` | LP + HP combination |
| `kalman` | Simple 1-D Kalman with fixed gain |
| `savitzkyGolay` | 5-point cubic SG filter (`[-3, 12, 17, 12, -3] / 35`) |
Unknown method names log an error and pass the raw value through.
### Scaling and percent mapping
`_applyScaling(value)` performs a linear map `[scaling.inputMin..inputMax]` &rarr; `[scaling.absMin..absMax]`, clamping the input to the source range first. An invalid input range (`inputMax <= inputMin`) self-heals to `[0, 1]` and logs a warn.
`_computePercent(value)` then maps the **clamped** result into the percent range `[interpolation.percentMin..percentMax]` (defaults 0..100). When `scaling.enabled` is false and `absMax <= absMin` the percent uses the live `totalMinValue / totalMaxValue` instead.
`_writeOutput` rounds to 2 decimal places and only emits a new measurement when `rounded !== outputAbs` &mdash; so a stable input does **not** retrigger downstream.
---
## Lifecycle &mdash; what one event does
### Analog mode
```mermaid
sequenceDiagram
autonumber
participant ext as external sender
participant nc as nodeClass
participant m as Measurement
participant ch as Channel pipeline
participant emitter as measurements.emitter
participant parent as registered parent
ext->>nc: msg {topic:'data.measurement', payload:42}
nc->>m: dispatch via commands.handlers.dataMeasurement
m->>m: set inputValue = 42
m->>ch: analogChannel.update&#40;42&#41;
ch->>ch: outlier → offset → scale → smooth → minMax
ch->>emitter: pressure.measured.atequipment {value, ts, unit}
emitter-->>parent: child measurement event (subscribed at register-time)
m->>nc: notifyOutputChanged&#40;&#41;
nc-->>ext: Port 0 + Port 1 (delta-compressed)
Note over nc: every 1000 ms: if simulation.enabled,<br/>simulator.step&#40;&#41; → m.inputValue
```
### Digital mode
```mermaid
sequenceDiagram
autonumber
participant ext as external sender
participant nc as nodeClass
participant m as Measurement
participant chs as Channels (per key)
participant emitter as measurements.emitter
participant parent as registered parent
ext->>nc: msg {topic:'data.measurement', payload:{level-a:1.8, temp-a:18}}
nc->>m: handlers.dataMeasurement (digital branch)
m->>m: handleDigitalPayload&#40;payload&#41;
loop for each key in payload
m->>chs: Channel.update&#40;value&#41;
chs->>emitter: &lt;type&gt;.measured.&lt;position&gt; per channel
emitter-->>parent: one event per channel that accepted a value
end
m-->>ext: Port 0 + Port 1 (nested {channels:{...}})
```
> [!NOTE]
> Digital mode currently does **not** call `notifyOutputChanged()` from `handleDigitalPayload`. TODO: confirm whether Port 0 fan-out relies on the tick or on a follow-up notify; pending review of how `BaseNodeAdapter` schedules digital-mode output emission.
---
## Output ports
| Port | Carries | Sample shape |
|:---|:---|:---|
| 0 (process) | Delta-compressed snapshot of `getOutput()` &mdash; analog scalar fields or digital `{channels:{...}}` | `{topic: <name>, payload: {mAbs, mPercent, totalMin/MaxValue, totalMin/MaxSmooth}}` (analog) |
| 1 (telemetry) | InfluxDB line-protocol payload, same fields as Port 0 | `measurement,id=sensor_a mAbs=0.42,mPercent=42,...` |
| 2 (registration) | One `{topic:'registerChild', payload:<node.id>, positionVsParent, distance}` at startup | `{topic:'registerChild', payload:'<id>'}` |
Port-0 / Port-1 use the standard `outputUtils.formatMsg(..., 'process' | 'influxdb')` formatters. Delta compression means consumers see only the keys that changed since the previous tick.
See [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the platform InfluxDB layout.
---
## Event sources
| Source | Where it fires | What it triggers |
|:---|:---|:---|
| Inbound `msg.topic` | Node-RED input wire | `commands.handlers.<topic>` dispatch via `BaseNodeAdapter` |
| `setInterval(tickInterval = 1000)` | `BaseNodeAdapter` | `Measurement.tick()` &mdash; runs `Simulator.step()` only when `config.simulation.enabled` |
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | `Measurement.getStatusBadge()` re-rendered |
| `Channel._writeOutput` &rarr; `measurements.emitter` | Every accepted update where the rounded output changed | `<type>.measured.<position>` fires once per channel that produced a new value |
| `source.emitter` `'mAbs'` (legacy) | Analog `inputValue` setter | Editor status badge during the refactor window &mdash; deprecated, slated for removal in Phase 7 |
No per-tick FSM. The only background work is the 1000 ms simulator pump.
---
## Where to start reading
| If you're changing... | Read first |
|:---|:---|
| The per-sample pipeline (outlier / scaling / smoothing) | `src/channel.js` `update`, `_isOutlier`, `_applyScaling`, `_applySmoothing` |
| Analog vs digital branching | `src/specificClass.js` `configure`, `_buildAnalogChannel`, `_buildDigitalChannels`, `handleDigitalPayload` |
| Top-level topic dispatch | `src/commands/{index, handlers}.js` |
| Simulator step / bounds | `src/simulation/simulator.js` `step` |
| Calibration stability / offset capture | `src/calibration/calibrator.js` `isStable`, `calibrate`, `evaluateRepeatability` |
| Editor &rarr; domain config reshape | `src/nodeClass.js` `buildDomainConfig` |
| Per-node status badge | `Measurement.getStatusBadge` |
| Output shape | `Measurement.getOutput` (analog) / `getDigitalOutput` (digital) |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child registration |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home) | The most common consumer of measurement |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |

279
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,279 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-b884c0f-blue)
> [!NOTE]
> Full topic contract, configuration schema, and child-registration handshake for `measurement`. Source of truth: `src/commands/index.js`, `src/commands/handlers.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/measurement.json`.
>
> Pending full node review (2026-05). Hand-written best-effort placeholder where indicated. For an intuitive overview, return to [Home](Home).
---
## Topic contract
The registry lives in `src/commands/index.js`. Each descriptor maps a canonical `msg.topic` to a handler; aliases emit a one-time deprecation warning the first time they fire.
<!-- BEGIN AUTOGEN: topic-contract -->
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
| `set.simulator` | `simulator` | any | — | Toggle the built-in simulator on / off. |
| `set.outlier-detection` | `outlierDetection` | any | — | Toggle / configure outlier detection on the measurement pipeline. |
| `cmd.calibrate` | `calibrate` | any | — | Trigger a one-shot calibration of the measurement. |
| `data.measurement` | `measurement` | any | — | Push a raw measurement (analog: number; digital: per-channel object). |
<!-- END AUTOGEN: topic-contract -->
### Payload-shape rules
| Mode | Accepted | Rejected (logs warn) |
|:---|:---|:---|
| `analog` | `number`; numeric string (trimmed, non-empty, parses with `Number`) | object payload (hint: "Switch Input Mode to 'digital' &hellip;"); non-numeric string |
| `digital` | object `{ key1: number, key2: number, &hellip; }` &mdash; keys must match `config.channels[*].key` | number (hint: "Switch Input Mode to 'analog' &hellip;"); array; any non-object |
Unknown channel keys in a digital payload are collected and reported at `debug` level via `digital payload contained unmapped keys: <list>`.
### Source / mode allow-lists
> [!NOTE]
> TODO: `measurement` does not appear to implement a `flowController`-style action/source allow-list (consult `src/specificClass.js`); it relies on the topic registry's typeof checks. If a future hardening pass adds mode-source gating, fold the table in here.
---
## Data model &mdash; `getOutput()` shape
Source: `src/specificClass.js` `getOutput()` / `getDigitalOutput()` and `src/channel.js` `getOutput()`. Delta-compressed by `outputUtils.formatMsg`: consumers see only the keys that changed.
<!-- BEGIN AUTOGEN: data-model — populate via wiki-gen tool (TODO) -->
### Analog mode (`Measurement.getOutput()`)
| Key | Type | Unit | Notes |
|:---|:---|:---|:---|
| `mAbs` | number | scaling units (`asset.unit` / `general.unit`) | Latest output value after offset + scaling + smoothing. Rounded to 2 dp. |
| `mPercent` | number | % | Output mapped to `interpolation.percentMin..percentMax`. Rounded to 2 dp. |
| `totalMinValue` | number | scaling units | Rolling minimum of the **post-offset, pre-smoothing** values. Reported as `0` until the first sample. |
| `totalMaxValue` | number | scaling units | Rolling maximum of the same. Reported as `0` until the first sample. |
| `totalMinSmooth` | number | scaling units | Rolling minimum of the smoothed output. Starts at `0`. |
| `totalMaxSmooth` | number | scaling units | Rolling maximum of the smoothed output. Starts at `0`. |
### Digital mode (`Measurement.getDigitalOutput()`)
```jsonc
{
"channels": {
"<channel.key>": {
"key": "<channel.key>",
"type": "<channel.type>",
"position": "<channel.position>",
"unit": "<channel.unit>",
"mAbs": <number>,
"mPercent": <number>,
"totalMinValue": <number>,
"totalMaxValue": <number>,
"totalMinSmooth": <number>,
"totalMaxSmooth": <number>
}
// ... one entry per channel that has produced output
}
}
```
<!-- END AUTOGEN: data-model -->
### Status badge
`Measurement.getStatusBadge()`:
| Mode | Badge text | Fill / shape |
|:---|:---|:---|
| `analog` | `<mAbs> <unit>` (e.g. `0.42 bar`) | green / dot |
| `digital` | `digital · <N> channel(s)` | blue / ring |
The legacy `source.emitter` fires `'mAbs'` (analog only) and is kept for the editor status badge during the refactor window &mdash; see [Limitations](Reference-Limitations#legacy-source-emitter).
---
## Events emitted on `source.measurements.emitter`
The shared `MeasurementContainer` fires `<type>.measured.<position>` whenever a `Channel`'s rounded output changes. The type / position come from:
- **analog**: `config.asset.type` and `config.functionality.positionVsParent`.
- **digital**: per-channel `config.channels[i].type` and `config.channels[i].position` (falls back to the node-level `positionVsParent` when missing).
Position labels are normalised to **lowercase** in the event name (`upstream`, `downstream`, `atequipment`). Examples:
- `pressure.measured.upstream`
- `flow.measured.atequipment`
- `level.measured.downstream`
- `temperature.measured.atequipment`
Parents subscribe through the generic `child.measurements.emitter.on(eventName, &hellip;)` handshake established by `childRegistrationUtils` (in `generalFunctions`).
In digital mode one input message can fan out into several events &mdash; one per channel that accepted a value on that tick.
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/measurement.json` plus `nodeClass.buildDomainConfig`. Defaults below come from the schema.
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Name | `general.name` | `Sensor` | Human-readable label. |
| (auto-assigned) | `general.id` | `null` | Node-RED node id. |
| Default unit | `general.unit` | `unitless` | Falls back to the asset unit. |
| Enable logging | `general.logging.enabled` | `true` | Master switch. |
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
### Functionality (`config.functionality`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Software type | `functionality.softwareType` | `measurement` | Constant. |
| Role | `functionality.role` | `Sensor` | Constant. |
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | One of `atEquipment` / `upstream` / `downstream`. Used in the `child.register` payload and as the suffix of the measurement event name. |
| Distance offset | `functionality.distance` | `null` | Optional spatial offset; sent with `child.register`. |
### Asset (`config.asset`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Asset UUID | `asset.uuid` | `null` | Globally-unique identifier. |
| Tag code / number | `asset.tagCode` / `asset.tagNumber` | `null` | Asset-registry identifiers. |
| Geolocation | `asset.geoLocation` | `{x:0, y:0, z:0}` | |
| Supplier | `asset.supplier` | `Unknown` | Free text. |
| Category | `asset.category` | `sensor` | `sensor` / `measurement`. |
| Asset type | `asset.type` | `pressure` | **Required.** Matches the type axis on `MeasurementContainer` and the parent's filter (e.g. `flow`, `power`, `temperature`). |
| Model | `asset.model` | `Unknown` | Free text. |
| Asset unit | `asset.unit` | `unitless` | Output unit label for the measurement event payload. |
| Accuracy | `asset.accuracy` | `null` | Optional sensor accuracy. |
| Repeatability | `asset.repeatability` | `null` | Optional repeatability metric. |
> [!IMPORTANT]
> `asset.type` must match the **exact** string the parent listens for. The parent's filter is typically the bare type (`flow`, `pressure`, `power`, &hellip;) &mdash; a measurement configured as `flow-electromagnetic` will not register with a `flow`-only filter on its parent (see [Limitations](Reference-Limitations#asset-type-must-match-the-parents-filter-exactly)).
### Scaling (`config.scaling`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Scaling enabled | `scaling.enabled` | `false` | When false, the input is passed through with only the offset applied. |
| Input min/max | `scaling.inputMin` / `scaling.inputMax` | `0` / `1` | Source range; clamps the input before mapping. |
| Output min/max | `scaling.absMin` / `scaling.absMax` | `50` / `100` | Target range. |
| Offset | `scaling.offset` | `0` | Added before scaling; mutated by `cmd.calibrate`. |
### Smoothing (`config.smoothing`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Window size | `smoothing.smoothWindow` | `10` | `>= 1`. Rolling buffer length. |
| Method | `smoothing.smoothMethod` | `mean` | One of `none` / `mean` / `min` / `max` / `sd` / `median` / `weightedMovingAverage` / `lowPass` / `highPass` / `bandPass` / `kalman` / `savitzkyGolay`. |
### Outlier detection (`config.outlierDetection`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Enabled | `outlierDetection.enabled` | `false` | Toggle with `set.outlier-detection`. |
| Method | `outlierDetection.method` | `zScore` | One of `zScore` / `iqr` / `modifiedZScore`. |
| Threshold | `outlierDetection.threshold` | `3` | Method-specific (e.g. z &gt; 3, mz &gt; 3.5). |
### Simulation (`config.simulation`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Enabled | `simulation.enabled` | `false` | When true, `tick()` (1000 ms) drives `inputValue` via `Simulator.step()`. |
| Safe calibration time | `simulation.safeCalibrationTime` | `100` | ms before calibration is finalised in sim mode. |
### Interpolation (`config.interpolation`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Percent min | `interpolation.percentMin` | `0` | Lower bound of the `mPercent` output. |
| Percent max | `interpolation.percentMax` | `100` | Upper bound. |
### Calibration (`config.calibration`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Stability threshold | `calibration.stabilityThreshold` | `0.01` | Absolute stdDev ceiling (in scaling-units) below which the buffer is considered stable. Fits the default `[50,100]` range; tighten / relax for your sensor. |
### Mode (`config.mode`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Input mode | `mode.current` | `analog` | `analog` (one channel, scalar payload) or `digital` (N channels, object payload). |
### Channels (`config.channels[]` &mdash; digital only)
In digital mode, each entry in `config.channels` defines its own pipeline:
| Field | Required | Falls back to |
|:---|:---:|:---|
| `key` | yes | &mdash; (skipped if missing) |
| `type` | yes | &mdash; (skipped if missing) |
| `position` | no | `config.functionality.positionVsParent` &rarr; `atEquipment` |
| `unit` | no | `config.asset.unit` &rarr; `unitless` |
| `distance` | no | `config.functionality.distance` &rarr; `null` |
| `scaling` | no | `{enabled:false, inputMin:0, inputMax:1, absMin:0, absMax:1, offset:0}` |
| `smoothing` | no | `config.smoothing` |
| `outlierDetection` | no | `config.outlierDetection` |
| `interpolation` | no | `config.interpolation` |
Invalid entries (missing `key` or `type`) are logged and skipped. An empty `config.channels[]` in digital mode logs `digital mode enabled but config.channels is empty; no channels will be emitted.`
### Asset registration (`config.assetRegistration`)
Used by the `/measurement/asset-reg` admin endpoint to register / sync the asset with the upstream asset registry. Not part of the runtime data path.
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Profile / location / process ids | `assetRegistration.{profileId, locationId, processId}` | `1` | Free integer ids in the asset registry. |
| Status | `assetRegistration.status` | `actief` | Lifecycle status. |
| Child assets | `assetRegistration.childAssets` | `[]` | List of child asset ids. |
### Output (`config.output`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Process output | `output.process` | `process` | `process` / `json` / `csv`. Port-0 formatter. |
| Database output | `output.dbase` | `influxdb` | `influxdb` / `json` / `csv`. Port-1 formatter. |
### Unit policy
> [!NOTE]
> TODO: `measurement` does not currently declare a `unitPolicy` block on its `BaseDomain` configuration (unlike `rotatingMachine`). The per-channel `unit` is carried verbatim into the `MeasurementContainer` write at `_writeOutput`. If a future hardening pass adds a unit-policy enforcement, add the canonical / output / required-unit table here. See `CONTRACT.md` for the current invariants.
---
## Child registration
Source: `src/specificClass.js` `configure` (registers itself via the `BaseDomain` plumbing) and the standard `childRegistrationUtils` handshake in `generalFunctions`.
`measurement` does **not accept children**. It only **registers itself** as a child on its upstream parent.
| Layer | Direction | Topic / event | Payload |
|:---|:---|:---|:---|
| Startup (Port 2) | child &rarr; parent | `registerChild` | `{topic: 'registerChild', payload: <node.id>, positionVsParent, distance}` |
| Runtime | child &rarr; parent | `<asset.type>.measured.<positionVsParent>` on `child.measurements.emitter` | `{value, timestamp, unit, distance?}` (per `MeasurementContainer.value()`) |
| What | softwareType payload | Side-effect on parent |
|:---|:---|:---|
| Registration | `measurement` | Parent attaches a listener for `<asset.type>.measured.<positionVsParent>` on the child's `measurements.emitter`. |
| Subsequent updates | event on `child.measurements.emitter` | Parent mirrors the value into its own `MeasurementContainer` slot. |
Position labels are normalised to **lowercase** in the event name (`upstream`, `downstream`, `atequipment`); the `positionVsParent` field in the register payload is sent as configured (preserves case).
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map + per-`Channel` pipeline + lifecycle |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |

148
wiki/Reference-Examples.md Normal file
View File

@@ -0,0 +1,148 @@
# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-b884c0f-blue)
> [!NOTE]
> Every example flow shipped under `nodes/measurement/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/measurement/examples/`.
>
> Pending full node review (2026-05). Tier-1/2/3 visual-first example flows are still TODO (tracked in the superproject `MEMORY.md` "TODO: Example Flows"). The current shipped flows pre-date the refactor; treat them as smoke tests, not as production templates.
---
## Shipped examples
| File | Tier | Dependencies | What it shows | Status |
|:---|:---:|:---|:---|:---|
| `basic.flow.json` | 1 | EVOLV only | Single measurement node driven by inject buttons &mdash; analog scalar input, scaling enabled, three debug taps on Port 0/1/2. | Legacy pre-refactor shape, still imports. |
| `integration.flow.json` | 2 | EVOLV only | Parent-child wiring &mdash; measurement registers as a child of another node and emits its `<type>.measured.<position>` events. | Legacy pre-refactor shape. |
| `edge.flow.json` | 3 | EVOLV only | Invalid / edge payload driving for robustness checks (non-numeric strings, object in analog mode, &hellip;). | Legacy pre-refactor shape. |
The three legacy files predate the AssetResolver refactor and the analog-vs-digital mode flag. They still deploy (the editor will accept the older shape and `nodeClass.buildDomainConfig` reshapes whatever it finds), but the recommended Tier-1/2/3 visual-first replacements are still to be written.
> [!IMPORTANT]
> **TODO &mdash; Tier-1/2/3 visual-first flows.** Replace the three legacy files with:
> - `01 - Basic Analog.json` &mdash; one measurement, inject + scaling + smoothing + outlier-detection toggle + simulator.
> - `02 - Integration with rotatingMachine.json` &mdash; measurement registered as a pressure sensor on a `rotatingMachine`, Port 2 auto-register on deploy, parent's prediction updates as the measurement value moves.
> - `03 - Digital Multi-Channel.json` &mdash; one measurement in `digital` mode with 2&ndash;3 channels (e.g. `level-a`, `temp-a`, `flow-a`) fed by a single object-payload inject.
---
## Loading a flow
### Via the editor
1. Open the Node-RED editor at `http://localhost:1880`.
2. Menu &rarr; Import &rarr; drag the JSON file.
3. Click Deploy.
### Via the Admin API
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/measurement/examples/basic.flow.json \
http://localhost:1880/flows
```
---
## Example &mdash; `basic.flow.json`
Single-measurement flow with the minimum kit to exercise scaling.
### Nodes on the tab
| Type | Purpose |
|:---|:---|
| `inject` | One-shot `topic: 'measurement', payload: 42` (legacy alias of `data.measurement`) |
| `measurement` | The unit under test &mdash; analog mode, scaling enabled (0..100 &rarr; 0..10), `mean` smoothing, window 5 |
| `debug` &times; 3 | Port 0 (process), Port 1 (InfluxDB), Port 2 (registration) |
### What to do after deploy
1. Click the inject. Port 0 fires with `mAbs ≈ 4.2` (42 scaled into 0..10), `mPercent ≈ 42`.
2. Send another value via the same inject (edit the inject payload to `60`). `totalMinValue` / `totalMaxValue` start tracking, `mAbs` jumps to ~6.0.
3. Send `topic: 'set.simulator'` (use a second inject). `tick()` starts driving `inputValue` through `Simulator.step()` every 1000 ms; Port 0 updates appear automatically.
4. Send `topic: 'cmd.calibrate'`. If `stdDev <= 0.01` (the default `stabilityThreshold`), `config.scaling.offset` jumps to `inputMin - currentOutput`; if not, a warn appears in the log.
5. Send `topic: 'set.outlier-detection'`, then inject a wildly out-of-band value (e.g. `9999`). With outlier detection on the value is dropped with `Outlier detected. Ignoring value=9999`.
> [!IMPORTANT]
> **Screenshot needed.** Editor capture of `basic.flow.json` plus the Port 0 debug output. Save as `wiki/_partial-screenshots/measurement/basic-flow.png`. Replace this callout with the image link.
---
## Example &mdash; `integration.flow.json`
Demonstrates the parent-child handshake: the measurement node's Port 2 auto-fires `child.register` to its parent on deploy, and the parent then receives the `<type>.measured.<position>` event whenever a new reading lands.
> [!IMPORTANT]
> **Screenshot needed.** Editor capture of `integration.flow.json` showing the wiring. Save as `wiki/_partial-screenshots/measurement/integration-flow.png`.
> [!NOTE]
> TODO: confirm the integration flow targets a real EVOLV parent (e.g. `rotatingMachine`) versus a mock function node; if it's a mock, the Tier-2 replacement should use a real parent.
---
## Example &mdash; `edge.flow.json`
Drives the node with malformed inputs to verify the warn paths land cleanly:
- Non-numeric string in analog mode &rarr; `Invalid numeric measurement payload: <value>`.
- Object payload in analog mode &rarr; `analog mode received an object payload (keys: &hellip;). Switch Input Mode to 'digital' &hellip;`.
- Numeric scalar in digital mode &rarr; `digital mode received a number (&hellip;); expected an object &hellip;`.
- Outlier toggle on/off mid-stream &rarr; verifies `analogChannel.outlierDetection.enabled` mirrors `config.outlierDetection.enabled`.
> [!IMPORTANT]
> **Screenshot needed.** Editor capture of `edge.flow.json` plus the log lines each inject triggers. Save as `wiki/_partial-screenshots/measurement/edge-flow.png`.
---
## Debug recipes
| Symptom | First thing to check | Where to look |
|:---|:---|:---|
| Parent never receives `<type>.measured.<position>` | `asset.type` must match the parent's filter exactly (e.g. `flow` &mdash; not `flow-electromagnetic`). Position labels lowercase in the event name. | `config.asset.type` + parent's `childRegistrationUtils` filter. |
| Outliers seem to pass through | `outlierDetection.enabled` may be off (default `false`). Toggle with `set.outlier-detection`. With `<2` samples in the buffer, `_isOutlier` returns `false` regardless. | `Channel._isOutlier`. |
| `cmd.calibrate` does nothing | Calibrator requires `stdDev <= calibration.stabilityThreshold` over `storedValues`. If `storedValues.length < 2`, `isStable()` returns `false` (legacy shape). | `src/calibration/calibrator.js` `isStable`, `calibrate`. |
| Digital payload silently dropped | Unknown channel keys are reported only at `debug` log level (`digital payload contained unmapped keys`). Numeric values that fail `Number.isFinite` warn at `warn`. | `Measurement.handleDigitalPayload`. |
| Simulator still running after toggle off | `tick()` reads `config.simulation.enabled` each tick. Confirm the toggle actually mutated the config (the `set.simulator` handler is idempotent &mdash; it just flips). | `Measurement.tick`, `toggleSimulation`. |
| Port 0 emits nothing after `data.measurement` | Analog: `_writeOutput` only emits when `rounded !== outputAbs`. A repeated identical value is silent by design. | `Channel._writeOutput`. |
| `mPercent` is stuck at `0` or unbounded | `processRange <= 0` (i.e. `absMax <= absMin`); percent falls back to `totalMinValue / totalMaxValue` which start at `0` / `0`. Configure `absMin < absMax`. | `Channel._computePercent`. |
| Scaling output looks clamped | `_applyScaling` clamps the input to `[inputMin, inputMax]` before mapping. Wide-band sensors need `inputMin / inputMax` set to the full physical range. | `Channel._applyScaling`. |
| `mAbs` jumps after `cmd.calibrate` | Expected. Calibration sets `config.scaling.offset = baseline - currentOutputAbs`, which makes the next reading land on the baseline (`inputMin` when scaling enabled, `absMin` otherwise). | `Calibrator.calibrate`. |
| Legacy `setpoint` / `simulator` topics work without warning | First fire emits a one-time deprecation warning via `BaseNodeAdapter`'s alias handling. Subsequent fires are silent &mdash; the topic still works. | `commands/index.js` `aliases`. |
> Never ship `enableLog: 'debug'` in a demo &mdash; fills the container log within seconds and obscures real errors.
---
## Docker compose snippet
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
```yaml
# docker-compose.yml (extract)
services:
nodered:
build: ./docker/nodered
ports: ['1880:1880']
volumes:
- ./docker/nodered/data:/data/evolv
influxdb:
image: influxdb:2.7
ports: ['8086:8086']
```
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child registration |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map + per-`Channel` pipeline + lifecycle |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [rotatingMachine &mdash; Examples](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Examples) | Most common consumer of measurement |
| [EVOLV &mdash; Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where measurement fits in a larger plant |

View File

@@ -0,0 +1,117 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-b884c0f-blue)
> [!NOTE]
> What `measurement` does not do, current rough edges, and open questions. Open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the EVOLV superproject; node-local follow-ups are tracked in the superproject's `MEMORY.md` and `.claude/refactor/OPEN_QUESTIONS.md`.
>
> Pending full node review (2026-05).
---
## When you would not use this node
| Scenario | Use instead |
|:---|:---|
| Fusing signals from multiple sensors into one virtual measurement | This node is per-channel only. Aggregate at the parent (e.g. `rotatingMachine` already combines upstream + downstream into a differential). |
| Producing a control output / actuating something | This is read-only signal conditioning. Use `rotatingMachine`, `valve`, or another equipment-level node. |
| Threshold-trip alarms / latched state | There is no comparator / latch output. Build alarm logic on top of the emitted reading at the parent or in a dashboard rule. |
| A "passive" measurement that should not register with a parent | Registration is automatic at startup &mdash; not currently opt-out. TODO: confirm whether a "no-parent" mode exists; if not, leave the parent input unwired. |
---
## Known limitations
### Asset type must match the parent's filter exactly
Parents subscribe to events by exact string match on `<asset.type>.measured.<position>`. A measurement configured as `flow-electromagnetic` will not be picked up by a parent that filters on `flow`. The fix is mechanical &mdash; set `asset.type` to the bare type the parent expects.
This is documented in the superproject `MEMORY.md` under "Key Integration Gotchas":
> Measurement `assetType: "flow"` required (not "flow-electromagnetic") for pumpingStation/monster.
### Position labels lowercase only in the event name
The event name emits `<type>.measured.<position>` with `position` lowercased (`upstream`, `downstream`, `atequipment`). The `positionVsParent` field in the `child.register` payload, however, is sent **as configured** (preserves case). If a parent indexes children by the register-payload position string, mixed-case there will not match the lowercase position in subsequent events. Document the convention in any new parent that joins measurement.
### Legacy `source.emitter`
`source.emitter` fires `'mAbs'` on the analog `inputValue` setter alongside the canonical `measurements.emitter` path. It is kept for the editor status badge during the refactor window and is **slated for removal in Phase 7**. New consumers must use `measurements.emitter`.
### Digital mode &mdash; `notifyOutputChanged()` not explicitly called
`Measurement.handleDigitalPayload` collects a per-key summary but does not directly call `notifyOutputChanged()`. The analog `inputValue` setter does. TODO: confirm whether digital-mode Port 0 emissions rely on the next `tick()` or a follow-up notify path inside `BaseNodeAdapter`. Until verified, treat digital-mode Port 0 latency as "up to one tick" (1000 ms).
### Digital mode &mdash; per-channel scaling / smoothing fall back to the analog block
When a `config.channels[i]` entry omits a per-channel `scaling`, `smoothing`, `outlierDetection`, or `interpolation`, the missing fields fall back to the node-level config &mdash; **not** to a sensible per-type default. Setting `smoothing.smoothMethod = 'kalman'` at the node level applies that to every digital channel that does not override it. Operators should set every block per channel in production digital flows.
### `data.measurement` accepts numeric strings &mdash; not arrays / NaN
The analog handler parses with `Number(p)` and rejects `NaN`. Empty / whitespace strings are skipped silently. Arrays are not accepted in either mode and log a warn in digital mode.
### Simulator does not respect outlier detection
`Simulator.step()` writes directly into `m.inputValue`. The downstream `Channel.update` does run outlier detection if enabled &mdash; but the simulator's random walk is well-behaved enough that this is effectively a no-op. Don't expect the outlier path to be exercised by the simulator alone.
### `cmd.calibrate` requires &ge; 2 stored values
`Calibrator.isStable()` returns `{isStable:false}` when `storedValues.length < 2`. The legacy `Measurement.isStable()` wrapper returns a bare `false` in that case. A fresh calibration call before any data has arrived is silently rejected.
### Calibration baseline depends on `scaling.enabled`
When `scaling.enabled` is true, the calibration baseline is `scaling.inputMin`. When disabled, it is `scaling.absMin`. Toggling `scaling.enabled` after calibrating shifts the meaning of the captured offset; recalibrate after any scaling-toggle.
### Smoothing buffer not cleared on config change
Changing `smoothing.smoothMethod` or `smoothing.smoothWindow` at runtime does not clear `storedValues`. A previously-mean-smoothed buffer can produce a stale first sample after switching to `lowPass` until the window churns. The conservative workaround is to redeploy.
### `outlierDetection.enabled` mirrored only into `analogChannel`
`toggleOutlierDetection()` propagates the new boolean to `this.analogChannel.outlierDetection.enabled` only. In digital mode the per-channel `Channel.outlierDetection.enabled` is **not** updated by the toggle. TODO: digital-mode parity for `set.outlier-detection`.
### Min/max counters never reset
`totalMinValue` / `totalMaxValue` / `totalMinSmooth` / `totalMaxSmooth` are monotonic over the node's lifetime. There is no explicit reset command. The smooth-min/max additionally have a "first-write" rule that snaps both to the first value &mdash; before that, both read `0`, which can mislead downstream chart axes.
---
## Open questions (tracked)
| Question | Where it lives |
|:---|:---|
| Should digital-mode `notifyOutputChanged()` fire on every accepted update? | Internal &mdash; pending P9 review |
| Drop the legacy `source.emitter 'mAbs'` event | Phase 7 removal |
| Replace legacy `examples/{basic,integration,edge}.flow.json` with Tier-1/2/3 visual-first flows | Superproject `MEMORY.md` "TODO: Example Flows" |
| Add `data.clear-min-max` / `data.reset` topic for the rolling counters | Internal |
| Add per-channel `set.outlier-detection` for digital mode | Internal |
| Auto-recalibration heuristics (currently operator-triggered only) | Internal |
| Per-channel `smoothing` window-clear on config change | Internal |
---
## Migration notes
### From pre-refactor flat config
Older flows used `assetType` / `supplier` / `category` at the top level of the editor config. `nodeClass.buildDomainConfig` reshapes the editor's flat `uiConfig` into the nested domain config slice (`scaling`, `smoothing`, `simulation`, `calibration`, `mode`, `channels`), so legacy flows continue to deploy. The migration is best-effort &mdash; re-saving each measurement node in the editor regenerates the canonical shape.
### From analog-only
Adding `config.mode.current` was additive. Flows that omit it default to `analog` and behave exactly as before. To switch to digital: set the editor's "Input Mode" to `digital` and define `config.channels`.
### From legacy alias topics
`simulator`, `outlierDetection`, `calibrate`, `measurement` continue to work; each emits a one-time deprecation warning on first fire. Prefer the canonical `set.simulator` / `set.outlier-detection` / `cmd.calibrate` / `data.measurement` for new flows.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child registration (alias map at the end) |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map + per-`Channel` pipeline + lifecycle |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [rotatingMachine &mdash; Limitations](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Limitations) | Where the most common consumer's caveats overlap |

20
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,20 @@
### measurement
- [Home](Home)
**Reference**
- [Contracts](Reference-Contracts)
- [Architecture](Reference-Architecture)
- [Examples](Reference-Examples)
- [Limitations](Reference-Limitations)
**Related**
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home)
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/wiki/Home)
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)