Files
diffuser/wiki/Reference-Architecture.md
znetsixe 8c03fe774c 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:13 +02:00

225 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Reference &mdash; 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 &mdash; 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)` &mdash; 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 &mdash; 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)` &mdash; 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` &rarr; reset to 0.
- `o_p_total = o_p_water` (static head only).
- Warnings + alarms cleared.
The `idle` predicate is derived, not an FSM state &mdash; 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 | &pm; 2 % | `configure()` (literal) |
| Alarm | &pm; 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 &mdash; 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()` &mdash; 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 &mdash; 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 &mdash; 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 &harr; 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 &mdash; Contracts](Reference-Contracts) | Topic + config + child registration |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; 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 &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |