# Reference — 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 → 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
one Channel per config.channels[i]] cfg -->|"=== 'analog' (default)"| ana[_buildAnalogChannel
one Channel from flat config] dig --> emit_d[handleDigitalPayload
fan-out per channel] ana --> emit_a[inputValue setter
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
.enabled?} oe -- no --> off[+= scaling.offset] oe -- yes --> iso[_isOutlier(value)] iso -- outlier --> drop[return false
warn + drop] iso -- ok --> off off --> rmm[update totalMinValue
/ totalMaxValue] rmm --> sc{scaling.enabled?} sc -- yes --> as[_applyScaling] sc -- no --> sm[(unchanged)] as --> sm sm --> push[push to storedValues
cap at smoothWindow] push --> meth[switch(smoothMethod)] meth --> sms[update totalMinSmooth
/ totalMaxSmooth] sms --> wo[round to 2dp
compare to outputAbs
(only emit on change)] wo --> emit[measurements.emitter
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,
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: , 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:, positionVsParent, distance}` at startup | `{topic:'registerChild', payload:''}` | 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.` 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 | `.measured.` 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 |