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>
225 lines
10 KiB
Markdown
225 lines
10 KiB
Markdown
# Reference — Architecture
|
||
|
||

|
||
|
||
> [!NOTE]
|
||
> Pending full node review (2026-05). Content reflects the source files (`src/nodeClass.js`, `src/specificClass.js`, `src/commands/index.js`, `src/commands/handlers.js`) as currently checked into the submodule. The node has runtime implementation; the placeholder note in `test/README.md` ("diffuser currently has no runtime module files") is stale — verified 2026-05-19.
|
||
|
||
---
|
||
|
||
## Three-tier code layout
|
||
|
||
```
|
||
nodes/diffuser/
|
||
|
|
||
+-- diffuser.js entry: RED.nodes.registerType('diffuser', NodeClass)
|
||
+-- diffuser.html editor form (palette colour #86bbdd, Equipment Module)
|
||
+-- diffuser_class.js legacy single-file domain shim (pre-Phase-6)
|
||
+-- graph.js supplier-curve helper used by the editor preview
|
||
|
|
||
+-- src/
|
||
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
|
||
| specificClass.js extends BaseDomain (domain logic, single file)
|
||
| |
|
||
| +-- commands/
|
||
| index.js topic registry (6 canonical topics + aliases)
|
||
| handlers.js thin pass-through to specificClass setters
|
||
|
|
||
+-- examples/ basic.flow.json, integration.flow.json, edge.flow.json
|
||
+-- test/ basic/, integration/, edge/ — scaffolding only (see test/README.md)
|
||
+-- wiki/ this directory
|
||
```
|
||
|
||
### Tier responsibilities
|
||
|
||
| Tier | File | What it owns | Touches `RED.*` |
|
||
|:---|:---|:---|:---:|
|
||
| entry | `diffuser.js` | Type registration | Yes |
|
||
| nodeClass | `src/nodeClass.js` | `buildDomainConfig(uiConfig)` — coerces editor form values into the `diffuser.*` / `asset.*` / `general.*` config slice. Event-driven: no tick loop (`static tickInterval = null`), status badge polled every second (`static statusInterval = 1000`). | Yes |
|
||
| specificClass | `src/specificClass.js` | `configure()` sets seed state, loads supplier specs via `loadCurve`, then each setter (`setFlow`, `setDensity`, `setWaterHeight`, `setHeaderPressure`, `setElementCount`, `setAlfaFactor`) calls `_recalculate()` which runs the OTR / ΔP pipeline and emits `output-changed`. | No |
|
||
|
||
`specificClass.js` is currently a single file — the node is small enough that the P6 refactor did not split it into per-concern subdirectories. If `_calcOtrPressure` + `_checkLimits` + curve loading grow past ~250 lines, extracting `curves/` and `alarms/` is the natural split.
|
||
|
||
> [!NOTE]
|
||
> `diffuser_class.js` at the repo root is a legacy domain shim kept for backward compatibility with pre-Phase-6 consumers. New code paths should target `src/specificClass.js`.
|
||
|
||
---
|
||
|
||
## OTR + ΔP pipeline
|
||
|
||
```mermaid
|
||
flowchart TB
|
||
inFlow[data.flow]:::input --> setF[setFlow]
|
||
inPress[set.header-pressure]:::input --> setP[setHeaderPressure]
|
||
inH[set.water-height]:::input --> setH[setWaterHeight]
|
||
inD[set.density]:::input --> setD[setDensity]
|
||
inE[set.elements]:::input --> setE[setElementCount]
|
||
inA[set.alfa-factor]:::input --> setA[setAlfaFactor]
|
||
|
||
setF --> recalc
|
||
setP --> recalc
|
||
setH --> recalc
|
||
setD --> recalc
|
||
setE --> recalc
|
||
setA --> recalc
|
||
|
||
recalc{{_recalculate}} -->|idle: iFlow ≤ 0| zero[reset derived outputs<br/>idle = true]
|
||
recalc -->|active: iFlow > 0| pipe
|
||
|
||
subgraph pipe[_calcOtrPressure]
|
||
airDens[_calcAirDensityMbar<br/>atm + header → kg/m³]
|
||
nFlow[normalise flow → Nm³/h]
|
||
flux[flux/m² = nFlow / totalArea]
|
||
otrI[interpolate otr_curve<br/>by coverage % at flux]
|
||
pI[interpolate p_curve<br/>at flux]
|
||
oKgO2[kg O₂/h = otr × nFlow × m_water × α]
|
||
eff[combined efficiency]
|
||
slope[local OTR/flux slope]
|
||
airDens --> nFlow --> flux --> otrI --> oKgO2 --> eff --> slope
|
||
flux --> pI --> eff
|
||
end
|
||
|
||
pipe --> limits[_checkLimits<br/>warn ±2% / alarm ±10%]
|
||
limits --> notify[notifyOutputChanged]
|
||
zero --> notify
|
||
notify --> out[Port 0 / Port 1<br/>delta-compressed getOutput()]
|
||
classDef input fill:#a9daee,color:#000
|
||
```
|
||
|
||
### Curve loading
|
||
|
||
At `configure()` startup:
|
||
|
||
1. `_loadSpecs()` reads `config.asset.model` (default `'gva-elastox-r'`).
|
||
2. `loadCurve(model)` resolves the model id against the curve registry under `generalFunctions/datasets/assetData/curves/`.
|
||
3. If the requested model is missing, falls back to `loadCurve(DEFAULT_DIFFUSER_MODEL)` — the GVA ELASTOX-R reference. This avoids crashing the constructor in production when a freshly-saved flow references an asset id that hasn't been published yet.
|
||
4. The returned struct must carry `otr_curve` and `p_curve`; missing either throws.
|
||
5. `_meta.membraneArea_m2_per_element` from the curve is the source of truth for membrane area. `diffuser.membraneAreaPerElement` overrides it; the final fallback is `0.18` m² (Jäger TD-65 / GVA placeholder).
|
||
|
||
### Curve interpolation
|
||
|
||
`_interpolateCurveByDensity(curve, density, x)` handles both single-key (one coverage) and multi-key (interpolated across coverage) curve shapes. For multi-key curves it linearly interpolates between the two bracketing coverage keys; for single-key it clamps. Within a key the curve is a 1-D linear interpolation by flux per m² of membrane.
|
||
|
||
### Idle behaviour
|
||
|
||
When `iFlow ≤ 0`:
|
||
|
||
- `idle = true`
|
||
- `n_flow`, `o_otr`, `o_p_flow`, `o_flow_element`, `o_flux_per_m2`, `o_kg`, `o_kg_h`, `o_kgo2_h`, `o_kgo2`, `o_combined_eff`, `o_slope` → reset to 0.
|
||
- `o_p_total = o_p_water` (static head only).
|
||
- Warnings + alarms cleared.
|
||
|
||
The `idle` predicate is derived, not an FSM state — the diffuser is **stateless** by design (see [Limitations](Reference-Limitations#stateless-by-design)).
|
||
|
||
### Alarm bands
|
||
|
||
`_checkLimits(minFlow, maxFlow)` compares the current specific flux (`o_flux_per_m2`) against the loaded ΔP curve's x-axis limits, widened by hysteresis:
|
||
|
||
| Band | Hysteresis | Set in |
|
||
|:---|:---|:---|
|
||
| Warning | ± 2 % | `configure()` (literal) |
|
||
| Alarm | ± 10 % | `configure()` (literal) |
|
||
|
||
Outside the band, `warning.state` / `alarm.state` flip to `true` and a human-readable line is appended to `warning.text` / `alarm.text`. Surfaced on Port 0 as the `warning` / `alarm` string arrays.
|
||
|
||
---
|
||
|
||
## Lifecycle — what one event does
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
autonumber
|
||
participant src as upstream (blower / dashboard)
|
||
participant nc as nodeClass (BaseNodeAdapter)
|
||
participant cmd as commands registry
|
||
participant dom as Diffuser (specificClass)
|
||
participant curve as supplier specs
|
||
participant out as Port 0 / 1
|
||
|
||
src->>nc: msg{topic:'data.flow', payload:200}
|
||
nc->>cmd: dispatch by topic
|
||
cmd->>dom: setFlow(200)
|
||
dom->>dom: i_flow = 200; _recalculate()
|
||
alt iFlow ≤ 0
|
||
dom->>dom: idle = true; zero derived outputs
|
||
else iFlow > 0
|
||
dom->>dom: _calcOtrPressure(flow)
|
||
dom->>curve: interpolate otr_curve(density, flux/m²)
|
||
dom->>curve: interpolate p_curve(0, flux/m²)
|
||
dom->>dom: kg O₂/h, efficiency, slope
|
||
dom->>dom: _checkLimits(minX, maxX)
|
||
end
|
||
dom->>nc: emit 'output-changed'
|
||
nc->>out: formatMsg(getOutput()) → Port 0 + Port 1
|
||
```
|
||
|
||
No tick loop, no scheduled work. Every recompute is the synchronous result of an input setter.
|
||
|
||
---
|
||
|
||
## Output ports
|
||
|
||
| Port | Carries | Sample shape |
|
||
|:---|:---|:---|
|
||
| 0 (process) | Delta-compressed state snapshot from `getOutput()` — flow echo, OTR, ΔP, kg O₂/h, efficiency, slope, warn/alarm strings | `{topic: 'diffuser_N', payload: {iFlow, nFlow, oOtr, oPLoss, oKgo2H, oFluxPerM2, efficiency, slope, oZoneOtr, idle, warning, alarm}}` |
|
||
| 1 (telemetry) | Same fields as Port 0, formatted with the `'influxdb'` formatter | InfluxDB line protocol |
|
||
| 2 (registration) | One `child.register` upward at startup | `{topic: 'child.register', payload: <node.id>, positionVsParent: 'atEquipment', distance}` |
|
||
|
||
### Pre-refactor port-count change (Phase 6)
|
||
|
||
Before Phase 6 the diffuser exposed **four** outputs: process, dbase, a dedicated reactor-control message with `topic: 'OTR'`, and parent registration. The reactor-control message was merged into Port 0 as `oZoneOtr`; consumers reading the dedicated control port must migrate to `payload.oZoneOtr`. No alias is provided — the shape differs (single value vs full process payload). See [Limitations](Reference-Limitations#migration-notes).
|
||
|
||
---
|
||
|
||
## Status badge
|
||
|
||
`getStatusBadge()` in `specificClass.js`:
|
||
|
||
| Condition | Symbol / colour | Text |
|
||
|:---|:---|:---|
|
||
| `alarm.state` | red dot | first alarm message |
|
||
| `warning.state` | yellow dot (`⚠`) | first warning message |
|
||
| `idle` (no alarm/warn) | grey dot | `<oKgo2H> kg o2 / h` |
|
||
| active (no alarm/warn) | green dot (`🟢`) | `<oKgo2H> kg o2 / h` |
|
||
|
||
`getStatus()` is the legacy shape kept for backward compatibility with the pre-Phase-6 test suite.
|
||
|
||
---
|
||
|
||
## Event sources
|
||
|
||
| Source | Where it fires | What it triggers |
|
||
|:---|:---|:---|
|
||
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch to the matching setter handler |
|
||
| `source.emitter` `'output-changed'` | `notifyOutputChanged()` at the end of every `_recalculate()` | `BaseNodeAdapter` pushes Port 0 + Port 1 deltas |
|
||
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
|
||
|
||
No `setInterval` on the domain itself. No `MeasurementContainer.emitter` subscribers either — the diffuser has no children.
|
||
|
||
---
|
||
|
||
## Where to start reading
|
||
|
||
| If you're changing... | Read first |
|
||
|:---|:---|
|
||
| Topic naming, alias deprecation | `src/commands/index.js` + `src/commands/handlers.js` |
|
||
| Editor form ↔ domain config mapping | `src/nodeClass.js` `buildDomainConfig` |
|
||
| OTR / ΔP math, curve interpolation, normalisation | `src/specificClass.js` `_calcOtrPressure`, `_interpolateCurveByDensity` |
|
||
| Alarm bands + hysteresis | `src/specificClass.js` `_checkLimits` + `configure()` literals |
|
||
| Output shape, status badge | `src/specificClass.js` `getOutput`, `getStatusBadge` |
|
||
| Curve loading + fallback | `src/specificClass.js` `_loadSpecs` |
|
||
| Schema defaults | `generalFunctions/src/configs/diffuser.json` |
|
||
|
||
---
|
||
|
||
## 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 |
|
||
| [reactor wiki](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Home) | The typical parent of a diffuser |
|
||
| [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|