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>
10 KiB
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 intest/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.jsat the repo root is a legacy domain shim kept for backward compatibility with pre-Phase-6 consumers. New code paths should targetsrc/specificClass.js.
OTR + ΔP pipeline
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:
_loadSpecs()readsconfig.asset.model(default'gva-elastox-r').loadCurve(model)resolves the model id against the curve registry undergeneralFunctions/datasets/assetData/curves/.- 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. - The returned struct must carry
otr_curveandp_curve; missing either throws. _meta.membraneArea_m2_per_elementfrom the curve is the source of truth for membrane area.diffuser.membraneAreaPerElementoverrides it; the final fallback is0.18m² (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 = truen_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).
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
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.
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 | Intuitive overview |
| Reference — Contracts | Topic + config + child registration |
| Reference — Examples | Shipped flows + debug recipes |
| Reference — Limitations | Known issues and open questions |
| reactor wiki | The typical parent of a diffuser |
| EVOLV — Architecture | Platform-wide three-tier pattern |