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>
245 lines
12 KiB
Markdown
245 lines
12 KiB
Markdown
# Reference — Architecture
|
|
|
|

|
|
|
|
> [!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 → offset → scaling → smoothing → min/max → 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 — 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** → `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** → `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 → pipeline runs → emit).
|
|
|
|
---
|
|
|
|
## The per-`Channel` pipeline
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
in[update(value)] --> oe{outlierDetection<br/>.enabled?}
|
|
oe -- no --> off[+= scaling.offset]
|
|
oe -- yes --> iso[_isOutlier(value)]
|
|
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(smoothMethod)]
|
|
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 <type>.measured.<position>]
|
|
```
|
|
|
|
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]` → `[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` — so a stable input does **not** retrigger downstream.
|
|
|
|
---
|
|
|
|
## Lifecycle — 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(42)
|
|
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()
|
|
nc-->>ext: Port 0 + Port 1 (delta-compressed)
|
|
Note over nc: every 1000 ms: if simulation.enabled,<br/>simulator.step() → 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(payload)
|
|
loop for each key in payload
|
|
m->>chs: Channel.update(value)
|
|
chs->>emitter: <type>.measured.<position> 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()` — 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 — 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()` — runs `Simulator.step()` only when `config.simulation.enabled` |
|
|
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | `Measurement.getStatusBadge()` re-rendered |
|
|
| `Channel._writeOutput` → `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 — 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 → 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 — Contracts](Reference-Contracts) | Topic + config + child registration |
|
|
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
|
| [Reference — 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 — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|