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

10 KiB
Raw Blame History

Reference — Architecture

code-ref

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

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).

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

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