# 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
idle = true]
recalc -->|active: iFlow > 0| pipe
subgraph pipe[_calcOtrPressure]
airDens[_calcAirDensityMbar
atm + header → kg/m³]
nFlow[normalise flow → Nm³/h]
flux[flux/m² = nFlow / totalArea]
otrI[interpolate otr_curve
by coverage % at flux]
pI[interpolate p_curve
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
warn ±2% / alarm ±10%]
limits --> notify[notifyOutputChanged]
zero --> notify
notify --> out[Port 0 / Port 1
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: , 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 | ` kg o2 / h` |
| active (no alarm/warn) | green dot (`🟢`) | ` 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 |