# reactor ![code-ref](https://img.shields.io/badge/code--ref-0e34403-blue) ![s88](https://img.shields.io/badge/S88-Unit-50a8d9) ![status](https://img.shields.io/badge/status-pending--review-orange) 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)