# Reference — Architecture ![code-ref](https://img.shields.io/badge/code--ref-4973a8b-blue) > [!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 |