Files
measurement/wiki/Reference-Architecture.md
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

12 KiB

Reference — Architecture

code-ref

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.

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 modethis.analogChannel is set, this.channels is an empty Map. Setting m.inputValue = v runs the whole pipeline and notifyOutputChanged() fires Port 0.
  • digital modethis.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

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][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&#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

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() — 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._writeOutputmeasurements.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)

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