# Reference — Architecture ![code-ref](https://img.shields.io/badge/code--ref-0e34403-blue) > [!NOTE] > Code structure for `reactor`: the three-tier sandwich, the `src/` layout, the ASM3 kinetics engines (CSTR + PFR), the integration sequence, child registration, and the output-port pipeline. For an intuitive overview, return to [Home](Home). > > Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only. --- ## Three-tier code layout ``` nodes/reactor/ | +-- reactor.js entry: RED.nodes.registerType('reactor', NodeClass) | +-- src/ | nodeClass.js extends BaseNodeAdapter (Node-RED bridge) | specificClass.js extends BaseDomain (orchestration only) | utils.js assertNoNaN + small helpers | | | +-- commands/ | | index.js 6 topic descriptors | | handlers.js pure handler functions | | | +-- kinetics/ | | baseEngine.js BaseReactorEngine (influent / OTR / T / child wiring / updateState) | | cstr.js Reactor_CSTR extends BaseReactorEngine (0-D Forward Euler) | | pfr.js Reactor_PFR extends BaseReactorEngine (axial FD + Danckwerts BC) | | | +-- reaction_modules/ | | asm3_class.js ASM3 stoichiometry + rate vector + species list | | asm3_class Koch.js legacy variant (not consumed by current engines) | | | +-- io/ reserved (currently empty) | +-- additional_nodes/ | recirculation-pump.{js,html} legacy companion node shipped from this repo | settling-basin.{js,html} legacy companion node shipped from this repo ``` ### Tier responsibilities | Tier | File | What it owns | Touches `RED.*` | |:---|:---|:---|:---:| | entry | `reactor.js` | Type registration | Yes | | nodeClass | `src/nodeClass.js` | Tick loop (`tickInterval = 1000` ms), status badge (`statusInterval = 1000` ms), `buildDomainConfig` mapping editor fields to nested config, `_emitOutputs` override that preserves the `Fluent` + `GridProfile` envelope (BaseNodeAdapter's default delta-compressed payload doesn't fit). | Yes | | specificClass | `src/specificClass.js` | `_flattenEngineConfig` translates nested schema to engine shape; `_buildEngine` selects CSTR or PFR; wires ChildRouter (`measurement` → `engine._connectMeasurement`, `reactor` → `engine._connectReactor`); re-emits engine `stateChange` on the BaseDomain emitter; surfaces `getOutput()`, `getStatusBadge()`. | No | | kinetics | `src/kinetics/*.js` | Pure ASM3 integration. `BaseReactorEngine` owns influent state, OTR, temperature, child-registration utils, and `updateState`. `Reactor_CSTR` adds the 0-D Forward-Euler tick. `Reactor_PFR` adds spatial discretization + boundary conditions + grid-profile emission. | No | `specificClass` is thin stitching. All the real work lives in the kinetics engines. --- ## No FSM — continuous-state integration reactor has **no finite-state machine, no mode, no setpoint**. The engine runs continuous ODE / PDE integration in process time. The only stateful event is `stateChange`, emitted by `BaseReactorEngine.updateState` after every successful advance (`n_iter > 0` internal steps completed). ```mermaid flowchart LR clk[data.clock
or tick(dt)]:::input --> us[updateState(newTime)] us --> ni{n_iter = floor(
speedUpFactor × Δt / timeStep)} ni -->|0| skip[no-op] ni -->|>0| loop[for each step:
tick(timeStep)] loop --> emit[emit stateChange(currentTime)] classDef input fill:#a9daee,color:#000 ``` `stateChange` is the trigger downstream Units (settlers, chained reactors) use to pull effluent. --- ## Kinetics engines — CSTR vs PFR ```mermaid flowchart TB subgraph base["BaseReactorEngine"] bs["Fs[], Cs_in[][13]
OTR, temperature, kla
upstreamReactor link
updateState(newTime)
_connectMeasurement / _connectReactor"] end subgraph cstr["Reactor_CSTR"] cs["state = number[13]
tick(dt):
inflow + outflow + reaction + transfer
Forward Euler
_capDissolvedOxygen / _arrayClip2Zero"] end subgraph pfr["Reactor_PFR"] ps["state = number[n_x][13]
length, n_x, d_x, A, alpha, D
D_op / D2_op finite-difference operators
tick(dt):
dispersion + advection + reaction + transfer
Explicit FD
Danckwerts inlet / Neumann outlet BC
Peclet / Courant guard warnings"] end bs --> cs bs --> ps ``` ### Forward Euler (CSTR) `Reactor_CSTR.tick(time_step)` adds four contributions per step: | Term | Formula | Notes | |:---|:---|:---| | Inflow | `Fs · Cs_in / volume` | Per inlet, summed into a single concentration delta. | | Outflow | `−sum(Fs) / volume · state` | Mass leaves at the current tank concentration. | | Reaction | `asm.compute_dC(state, T)` | ASM3 rate vector applied at current temperature. | | Transfer | `OTR or kla · (sat(T) − S_O)` on the `S_O` index only | All other species: zero transfer. | After integration, `_capDissolvedOxygen` caps `S_O` to saturation and `_arrayClip2Zero` floors negative concentrations. ### Explicit FD (PFR) `Reactor_PFR.tick(time_step)` operates per grid cell: | Term | Notes | |:---|:---| | Dispersion | `(D / d_x²) · D2_op · state` — central-difference second-derivative operator. | | Advection | `(−sum(Fs) / (A · d_x)) · D_op · state` — first-derivative operator (central or upwind per config). | | Reaction | Per-cell `asm.compute_dC(slice, T)`. | | Transfer | OTR / `kla` on the `S_O` index, scaled by `n_x / (n_x − 2)` for interior cells only. | Boundary conditions: **Danckwerts** at the inlet when `sum(Fs) > 0` (mixes inlet concentration with diffusive back-mix governed by `alpha`); **Neumann** (no-flux) at the outlet and at the inlet when there is no flow. After integration, the same `_capDissolvedOxygen` / `_arrayClip2Zero` post-processing applies cell-by-cell. `updateState` extends `BaseReactorEngine.updateState` with two stability checks: | Check | Threshold | Warning | |:---|:---|:---| | Local Peclet `Pe = d_x · sum(Fs) / (D · A)` | `≥ 2` | `Local Peclet number (…) is too high! Increase reactor resolution.` | | Courant `Co_D = D · timeStep / d_x²` | `≥ 0.5` | `Courant number (…) is too high! Reduce time step size.` | --- ## Lifecycle — what one `data.clock` advance does ```mermaid sequenceDiagram autonumber participant clock as data.clock injector participant rx as reactor (specificClass) participant engine as kinetics engine (CSTR / PFR) participant downstream as settler / next reactor participant out as Port 0 / 1 clock->>rx: data.clock { timestamp } rx->>engine: updateState(timestamp) Note over engine: n_iter = floor(speedUpFactor × Δt / timeStep) alt upstreamReactor present engine->>engine: setInfluent = upstream.getEffluent end loop n_iter times engine->>engine: tick(timeStep) — integrate ASM3 rates engine->>engine: cap S_O to saturation, clip negatives end engine->>rx: emit 'stateChange' (currentTime) rx->>rx: re-emit 'stateChange' on BaseDomain emitter rx->>rx: notifyOutputChanged alt PFR engine rx->>out: Port 0 — GridProfile { grid, n_x, d_x, length, species } end rx->>out: Port 0 — Fluent { inlet=0, F, C[13] } rx->>out: Port 1 — InfluxDB scalars { flow_total, temperature, S_O…X_TS } downstream-->>rx: subscribes to stateChange via _connectReactor downstream->>downstream: pulls getEffluent on each stateChange ``` The tick loop is opt-in (`static tickInterval = 1000`) because the integrator advances **process time** in steps that have no fixed wall-clock mapping. Without ticks the engine simply doesn't advance. `nodeClass._emitOutputs` is overridden so the `Fluent` / `GridProfile` envelope shape survives the BaseNodeAdapter pipeline. --- ## Child registration Source: `src/specificClass.js` `configure()` wires the ChildRouter; `BaseReactorEngine._connectMeasurement` and `_connectReactor` do the actual subscription. ```mermaid flowchart LR subgraph kids["accepted children (softwareType)"] m_t["measurement
asset.type=temperature
positionVsParent=atEquipment"]:::ctrl m_o["measurement
asset.type=quantity (oxygen)
positionVsParent=numeric distance (PFR)"]:::ctrl r_up["reactor
positionVsParent=upstream"]:::unit end m_t -->|temperature.measured.atEquipment| h_meas["engine._connectMeasurement
(baseEngine.js)"] m_o -->|quantity(oxygen).measured.<distance>| h_meas r_up -.stateChange.-> h_react["engine._connectReactor
(baseEngine.js)"] h_meas --> reconcile["reconcile T → engine.temperature
reconcile O2 → state grid cell (PFR only)"] h_react --> pull["pull upstream getEffluent
→ Fs[0] / Cs_in[0] before next tick"] classDef ctrl fill:#a9daee,color:#000 classDef unit fill:#50a8d9,color:#000 ``` ### `_connectMeasurement` event wiring `measurement.measurements.emitter` fires `.measured.` on every published value. The reactor subscribes: ```js const eventName = `${measurementType}.measured.${position}`; measurement.measurements.emitter.on(eventName, (eventData) => { this.measurements .type(measurementType).variant('measured').position(position) .value(eventData.value, eventData.timestamp, eventData.unit); this._updateMeasurement(measurementType, eventData.value, position, eventData); }); ``` `_updateMeasurement` (CSTR base): only `temperature` at `POSITIONS.AT_EQUIPMENT` is honoured — writes `engine.temperature`. Any other type logs `Type '' not recognized for measured update.` `_updateMeasurement` (PFR override): additionally handles `quantity (oxygen)` at a **numeric** position. Position is interpreted as metres along `length`; the value is written to grid cell `clamp(round(pos / length × n_x), 0, n_x − 1)`. Non-finite position / value, or `length ≤ 0`, logs a warn and the update is dropped. ### `_connectReactor` — upstream chain Setting `positionVsParent: 'upstream'` on the upstream reactor's child-register makes this reactor subscribe to the upstream's `stateChange`. On every event the downstream's `updateState` runs, which first pulls the upstream's `getEffluent` into `Fs[0]` / `Cs_in[0]` then integrates. > [!NOTE] > `diffuser` is **not** a registered child. It feeds aeration via the `data.otr` topic on Port 0 (handled in `commands/handlers.js` `dataOTR`). No child-registration handshake. --- ## Output ports | Port | Carries | Sample shape | |:---|:---|:---| | 0 (process) | `Fluent` envelope every advance. For PFR: an additional `GridProfile` message sent **before** the `Fluent`. | `{topic: 'Fluent', payload: {inlet: 0, F, C: [...13...]}, timestamp}` | | 1 (telemetry) | InfluxDB line-protocol payload built from `getOutput()` via `outputUtils.formatMsg`. Fields: `flow_total`, `temperature`, and one per species. | `reactor,id=rx_a flow_total=1000,temperature=20,S_O=2.1,S_NH=0.8,...` | | 2 (registration) | `child.register` upward at init | `{topic: 'child.register', payload: , positionVsParent, distance}` | > [!NOTE] > Pending full node review (2026-05). The flat Port-1 telemetry shape (one field per species, plus `flow_total` + `temperature`) reflects the current `getOutput()` in `src/specificClass.js`. | Key | Type | Unit | Source | |:---|:---|:---|:---| | `flow_total` | number | m³/d | `sum(Fs)` from the engine's effluent envelope | | `temperature` | number | °C | `engine.temperature` | | `S_O` | number | mg/L | effluent `C[0]` — dissolved oxygen, capped to saturation | | `S_I` | number | mg/L | effluent `C[1]` — inert soluble COD | | `S_S` | number | mg/L | effluent `C[2]` — readily biodegradable substrate | | `S_NH` | number | mg/L | effluent `C[3]` — ammonium nitrogen | | `S_N2` | number | mg/L | effluent `C[4]` — dinitrogen | | `S_NO` | number | mg/L | effluent `C[5]` — nitrate / nitrite | | `S_HCO` | number | mmol/L | effluent `C[6]` — alkalinity | | `X_I` | number | mg/L | effluent `C[7]` — inert particulate COD | | `X_S` | number | mg/L | effluent `C[8]` — slowly biodegradable substrate | | `X_H` | number | mg/L | effluent `C[9]` — heterotrophic biomass | | `X_STO` | number | mg/L | effluent `C[10]` — stored COD in biomass | | `X_A` | number | mg/L | effluent `C[11]` — autotrophic biomass | | `X_TS` | number | mg/L | effluent `C[12]` — total suspended solids | ### Status badge Composed by `getStatusBadge()` in `src/specificClass.js`: ``` T= C F= m³/d S_O= mg/L ``` Engine type is `CSTR` or `PFR` (derived from the constructor name). Fill is green by default; the badge is purely informational — no shape / colour transitions tied to plant state, since reactor has no FSM. --- ## Event sources | Source | Where it fires | What it triggers | |:---|:---|:---| | `engine.emitter` `'stateChange'` | `BaseReactorEngine.updateState` after `n_iter > 0` integration steps | `specificClass` re-emits on `this.emitter`; BaseNodeAdapter `_emitOutputs` runs (Port 0 + Port 1) | | Child measurement emitter | `measurement.measurements.emitter` per `.measured.` | `engine._connectMeasurement` callback → writes into MeasurementContainer + `_updateMeasurement` reconcile | | Upstream reactor `'stateChange'` | Upstream reactor's `BaseDomain` emitter | `engine._connectReactor` callback → downstream `updateState(t)` runs, pulling upstream effluent first | | Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch | | `setInterval(tickInterval = 1000)` | `BaseNodeAdapter` periodic tick | `nodeClass._emitOutputs` → `source.updateState(Date.now())` + send | | `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render | --- ## Where to start reading | If you're changing… | Read first | |:---|:---| | ASM3 stoichiometry / kinetic constants | `src/reaction_modules/asm3_class.js` | | Mixed-tank integration, child wiring, influent / OTR / T setters | `src/kinetics/baseEngine.js`, `src/kinetics/cstr.js` | | Plug-flow discretization, dispersion, grid profile | `src/kinetics/pfr.js` | | Topic registration, alias deprecation | `src/commands/index.js`, `src/commands/handlers.js` | | Editor-field ↔ engine-config mapping | `src/nodeClass.js` `buildDomainConfig`, `src/specificClass.js` `_flattenEngineConfig` | | Port-0 envelope shape (`Fluent` + `GridProfile`) | `src/nodeClass.js` `_emitOutputs` | | Schema defaults, types, units | `generalFunctions/src/configs/reactor.json` | --- ## Related pages | Page | Why | |:---|:---| | [Home](Home) | Intuitive overview | | [Reference — Contracts](Reference-Contracts) | Topic + config + child filters | | [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes | | [Reference — Limitations](Reference-Limitations) | Known issues and open questions | | [settler wiki](https://gitea.wbd-rd.nl/RnD/settler/wiki/Home) | The typical downstream Unit that subscribes to reactor `stateChange` | | [diffuser wiki](https://gitea.wbd-rd.nl/RnD/diffuser/wiki/Home) | The Equipment node that pushes `data.otr` | | [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |