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>
This commit is contained in:
244
wiki/Reference-Architecture.md
Normal file
244
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user