# reactor
  
A `reactor` models a single biological-treatment tank governed by the ASM3 (Activated Sludge Model No. 3) kinetics. It wraps either a CSTR (fully-mixed) or PFR (plug-flow with axial dispersion) integrator, accepts an influent stream + aeration rate, integrates the 13 ASM3 species each tick, and emits the effluent vector for the next Unit downstream (typically a `settler` or another `reactor`). A `diffuser` (Equipment Module) supplies aeration via `data.otr`; `measurement` children supply temperature and (PFR-only) dissolved-oxygen reconciliation.
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | One biological-treatment tank running ASM3 kinetics — aerated, anoxic, or anaerobic |
| S88 level | Unit |
| Use it when | You need an activated-sludge tank with nitrification / denitrification / heterotrophic growth modelled species-by-species |
| Don't use it for | Passive equalisation tanks (no reactions), simple residence-time delays (lighter buffer is better), aerobic-only contactors where ASM3's full 13-species vector is overkill |
| Children it accepts | `measurement` (temperature at equipment; PFR also: dissolved oxygen at numeric distance); upstream `reactor` |
| Parents / sinks it talks to | downstream `reactor` or `settler` (via `Fluent` on Port 0); `diffuser` pushes `data.otr` in |
---
## How it fits
```mermaid
flowchart LR
upstream[reactor
upstream
Unit]:::unit
rx[reactor
Unit]:::unit
settler[settler
downstream
Unit]:::unit
diffuser[diffuser
Equipment]:::equip
tsens[measurement
temperature
atEquipment]:::ctrl
osens[measurement
quantity (oxygen)
at numeric distance, PFR only]:::ctrl
upstream -.stateChange.-> rx
rx -->|Fluent inlet=0| settler
diffuser -->|data.otr| rx
tsens -.measured.-> rx
osens -.measured.-> rx
tsens -->|child.register| rx
osens -->|child.register| rx
upstream -->|child.register
positionVsParent=upstream| rx
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`.
reactor sits on lane **L4** (Unit). The `diffuser` (lane L3) is **not** a registered child — it just pushes aeration via the `data.otr` topic. A reactor chain (multi-stage treatment, e.g. anoxic → aerobic → aerobic) is built by registering each upstream reactor with `positionVsParent: 'upstream'`; downstream reactors then `getEffluent` from the upstream on every `stateChange`.
---
## Try it — 3-minute demo
Import the basic example flow, deploy, and watch a CSTR consume influent over the simulation clock.
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/reactor/examples/basic.flow.json \
http://localhost:1880/flow
```
What to click after deploy (each inject maps one-to-one to a topic in [Reference — Contracts](Reference-Contracts#topic-contract)):
1. `data.fluent` — inject an influent stream `{inlet: 0, F: 1000, C: [...13 species...]}` (m³/d, mg/L). The 13 species follow ASM3 ordering.
2. `data.temperature` — set reactor temperature (default 20 °C; nitrification rates depend on this).
3. `data.otr` (if `kla` is `NaN`) **or** rely on the configured `kla` for internal aeration.
4. `data.clock` — push wall-clock `msg.timestamp` to advance the integrator. The engine computes `n_iter = floor(speedUpFactor × Δt_wall / timeStep_days)` internal Euler / FD steps and integrates them in one shot.
5. Watch Port 0 (`Fluent` envelope on every advance) and Port 1 (InfluxDB scalar fields: `flow_total`, `temperature`, `S_O`…`X_TS`).
> [!IMPORTANT]
> **GIF needed.** Demo recording of steps 1–5 with `S_NH` falling and `S_NO` rising (nitrification proceeding). Save as `wiki/_partial-gifs/reactor/01-basic-cstr.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
---
## The six things you'll send
| Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `data.clock` | `clock` | `{timestamp: ms}` (or use `msg.timestamp`) | Advance the integrator. `updateState` computes how many internal steps fit between `currentTime` and the supplied timestamp (scaled by `speedUpFactor`) and runs them. |
| `data.fluent` | `Fluent` | `{inlet: number, F: number, C: number[13]}` | Set the per-inlet flow rate (`F`) and concentration vector (`C`). Stored in `engine.Fs[inlet]` / `engine.Cs_in[inlet]`. |
| `data.otr` | `OTR` | numeric | Set the externally-supplied oxygen transfer rate. Used when `kla` is `NaN`; ignored otherwise (internal mass transfer takes over). |
| `data.temperature` | `Temperature` | numeric or `{value: number}` | Set `engine.temperature` (°C). Non-numeric payloads are warned and ignored. |
| `data.dispersion` | `Dispersion` | numeric | **PFR only** — set axial dispersion coefficient `D` (m²/d). Triggers Peclet / Courant guard warnings on the next `updateState`. |
| `child.register` | `registerChild` | child node id (string) | Register a sibling node (`measurement`, upstream `reactor`) with this reactor. Port 2 wiring does this automatically in normal flows. |
> [!NOTE]
> Pending full node review (2026-05). reactor's command surface is data-push only — there is **no FSM, no setpoint, no mode**. The kinetics engine runs continuous-state ODE / PDE integration; the only stateful event is `stateChange` after every successful advance.
---
## What you'll see come out
Sample Port 0 message (CSTR mid-integration, nitrifying):
```json
{
"topic": "Fluent",
"payload": {
"inlet": 0,
"F": 1000,
"C": [2.1, 30, 12.4, 0.8, 4.3, 18.6, 4.2, 1050, 65, 2150, 4.5, 215, 3680]
},
"timestamp": 1747500000000
}
```
The `C` array is the 13-species ASM3 vector in fixed order (indices 0–6 soluble, 7–12 particulate). For a PFR an additional message goes out on the same port **before** the effluent each advance:
```json
{
"topic": "GridProfile",
"payload": {
"grid": [[...13...], [...13...], "...n_x rows..."],
"n_x": 10,
"d_x": 1.0,
"length": 10,
"species": ["S_O","S_I","S_S","S_NH","S_N2","S_NO","S_HCO","X_I","X_S","X_H","X_STO","X_A","X_TS"],
"timestamp": 1747500000000
}
}
```
Port 1 (InfluxDB telemetry) carries the same data flattened as scalar fields — `flow_total` (m³/d), `temperature` (°C), and one field per species (`S_O`, `S_I`, `S_S`, `S_NH`, `S_N2`, `S_NO`, `S_HCO`, `X_I`, `X_S`, `X_H`, `X_STO`, `X_A`, `X_TS`, mg/L; `S_HCO` is mmol/L).
| Field | Meaning |
|:---|:---|
| `S_O` | Dissolved oxygen. Capped to saturation at each tick via `_capDissolvedOxygen`. |
| `S_I` | Inert soluble COD. |
| `S_S` | Readily biodegradable substrate. |
| `S_NH` | Ammonium nitrogen. Drops during nitrification. |
| `S_N2` | Dinitrogen (denitrification end product). |
| `S_NO` | Nitrate / nitrite nitrogen. Rises during nitrification. |
| `S_HCO` | Alkalinity (bicarbonate, mmol/L). |
| `X_I` | Inert particulate COD. |
| `X_S` | Slowly biodegradable substrate. |
| `X_H` | Heterotrophic biomass. |
| `X_STO` | Stored COD in biomass. |
| `X_A` | Autotrophic biomass. **Must be ≥ ~50 mg/L for nitrification to proceed.** |
| `X_TS` | Total suspended solids. Drives the downstream settler split. |
| `flow_total` | Effluent volumetric flow (m³/d) — `sum(Fs)`. |
| `temperature` | Reactor temperature (°C). |
---
## The interesting bits
### CSTR vs PFR
The engine is selected once at `configure()` from `reactor.reactor_type`. The same input topics drive both, but PFR additionally:
- Discretises the tank along the `length` axis into `resolution_L` grid cells (`n_x`).
- Emits a `GridProfile` message **before** the effluent each `updateState`.
- Honours `data.dispersion` to set the axial dispersion coefficient.
- Reconciles oxygen measurements at a **numeric** `positionVsParent` (interpreted as distance from inlet) into the nearest grid cell.
- Warns when local Peclet ≥ 2 or Courant ≥ 0.5 (stability of the explicit FD scheme).
Hot-swapping engine type at runtime is not supported — redeploy the flow.
### Aeration: internal `kla` vs external `data.otr`
`reactor.kla > 0` enables internal mass-transfer: `OTR = kla × (sat(T) − S_O)`. Set `kla = NaN` to fall through to the externally-pushed `data.otr` value (the path a `diffuser` Equipment node uses).
### `X_A` footgun
The HTML editor form's default initial autotroph biomass is `0.001` mg/L — effectively zero, so nitrification never starts. The JSON schema default is `200` mg/L. Always check the deployed node's form value before expecting `S_NH` to drop. See [Reference — Limitations](Reference-Limitations#x_a-initial-default-footgun).
---
## Need more?
| Page | What you'll find |
|:---|:---|
| [Reference — Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
| [Reference — Architecture](Reference-Architecture) | Code map, integration sequence, kinetics layout, output ports |
| [Reference — Examples](Reference-Examples) | Shipped example flows + debug recipes |
| [Reference — Limitations](Reference-Limitations) | When not to use, known limitations, open questions |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) · [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) · [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)