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>
12 KiB
Reference — Architecture
Note
Code structure for
measurement: the three-tier sandwich, thesrc/layout, the per-Channelpipeline, the analog vs digital branching, the lifecycle, and the output-port pipeline. For an intuitive overview, return to 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():
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.analogChannelis set,this.channelsis an emptyMap. Settingm.inputValue = vruns the whole pipeline andnotifyOutputChanged()fires Port 0. - digital mode →
this.channelsis keyed bychannel.key;analogChannelisundefined.handleDigitalPayload(payload)walks every key in the incoming object, dispatches to the matchingChannel, 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
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
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
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()fromhandleDigitalPayload. TODO: confirm whether Port 0 fan-out relies on the tick or on a follow-up notify; pending review of howBaseNodeAdapterschedules 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 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 | Intuitive overview |
| Reference — Contracts | Topic + config + child registration |
| Reference — Examples | Shipped flows + debug recipes |
| Reference — Limitations | Known issues and open questions |
| rotatingMachine wiki | The most common consumer of measurement |
| EVOLV — Architecture | Platform-wide three-tier pattern |