# Reference — Architecture

> [!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 |