- Banner updated to c84dd78 / 2026-05-11
- Section 2: add diffuser (data.otr path, not child-register), upstream
reactor stateChange, settler downstream; switch to ~~~mermaid fences
- Section 4: accurate code-map — cstr/pfr extend baseEngine, not peer nodes
- Section 6: split measurement into temperature + oxygen(PFR) rows; clarify
diffuser is NOT a registered child; switch to ~~~mermaid fences
- Section 7: expand sequence with n_iter formula, DO capping, GridProfile alt
- Section 9: correct timeStep unit note (schema h vs HTML label s), add all
13 init fields, note X_A HTML default footgun, enum-casing note in cell
- Section 14: add row #6 (reactor_type enum lowercasing / toUpperCase guard)
and row #7 (timeStep unit mismatch — label vs schema vs engine conversion)
AUTOGEN markers (topic-contract, data-model) untouched — regenerated clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
17 KiB
reactor
Reflects code as of
c84dd78· regenerated2026-05-11vianpm run wiki:allIf this banner is stale, the page may be out of date. Treat as informative, not authoritative.
1. What this node is
reactor is an S88 Unit that wraps an ASM3 biological-process engine — either a CSTR (fully mixed tank) or a PFR (plug-flow with axial dispersion). It integrates 13 species (S_O, S_NH, X_H, X_TS, …) and emits the effluent vector each tick. Drives a settler downstream and accepts a recirculation pump child.
2. Position in the platform
flowchart LR
upstream[reactor<br/>upstream<br/>Unit]:::unit
reactor[reactor<br/>Unit]:::unit
settler[settler<br/>downstream<br/>Unit]:::unit
diffuser[diffuser<br/>Equipment]:::equip
tsens[measurement<br/>temperature<br/>atequipment]:::ctrl
osens[measurement<br/>oxygen<br/>at distance]:::ctrl
upstream -.stateChange.-> reactor
reactor -->|Fluent inlet=0| settler
settler -.stateChange.-> reactor
diffuser -->|data.otr| reactor
tsens -->|child.register| reactor
osens -->|child.register| reactor
tsens -->|temperature.measured.atEquipment| reactor
osens -->|quantity(oxygen).measured.distance| reactor
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
S88 colours: Unit #50a8d9, Equipment #86bbdd, Control Module #a9daee. Source of truth: .claude/rules/node-red-flow-layout.md.
reactor sits at the Unit level. A diffuser (Equipment) sends aeration rates via data.otr — it is NOT registered as a child. Measurement children (Control Module) register and supply temperature or dissolved-oxygen reconciliation. Settler is a downstream Unit that listens to stateChange to pull effluent; an upstream reactor drives this reactor the same way.
3. Capability matrix
| Capability | Status | Notes |
|---|---|---|
| ASM3 13-species ODE integration | ✅ | CSTR + PFR engines under kinetics/. |
| CSTR (fully mixed) | ✅ | Single concentration vector per tick. |
| PFR (axial discretization) | ✅ | resolution_L grid cells; emits GridProfile alongside Fluent. |
| Multi-inlet mixing | ✅ | n_inlets; each inlet receives its own data.fluent with inlet index. |
| Temperature reconcile from measurement | ✅ | temperature.measured.atEquipment writes engine.temperature. Code constant: POSITIONS.AT_EQUIPMENT. |
| Oxygen reconcile (PFR) | ✅ | quantity (oxygen).measured.<distance> maps to nearest grid cell. |
| KLa-driven aeration | ✅ | reactor.kla > 0 enables internal mass transfer; falls back to data.otr. |
| Speed-up factor (sim time) | ✅ | reactor.speedUpFactor accelerates wall-clock → process time. |
| Dispersion override (PFR) | ✅ | data.dispersion updates axial D. |
| Hot-swap engine type | ❌ | reactor_type is read once in configure(). |
4. Code map
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass = Reactor<br/>static commands"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["Reactor.configure()<br/>_flattenEngineConfig()<br/>_buildEngine() → CSTR or PFR<br/>ChildRouter.onRegister rules"]
end
subgraph kinetics["src/kinetics/"]
be["baseEngine.js<br/>BaseReactorEngine<br/>influent state, OTR, T<br/>_connectMeasurement / _connectReactor<br/>updateState() → n_iter ticks"]
cstr["cstr.js<br/>Reactor_CSTR extends BaseReactorEngine<br/>Forward-Euler 0-D integrator"]
pfr["pfr.js<br/>Reactor_PFR extends BaseReactorEngine<br/>FD spatial grid + Danckwerts BC"]
end
subgraph commands["src/commands/"]
cmds["index.js — 6 descriptors<br/>handlers.js — 6 pure fns"]
end
nc --> sc
nc --> cmds
sc --> be
cstr --> be
pfr --> be
| Module | Owns | Read first if you're changing… |
|---|---|---|
kinetics/baseEngine.js |
ASM3 stoichiometry + rate vector + species list. | Stoichiometric matrix, kinetic constants. |
kinetics/cstr.js |
0-D CSTR integrator + _connectMeasurement + _connectReactor. |
Mixed-tank behaviour, child wiring. |
kinetics/pfr.js |
Axial discretization, dispersion, grid profile emission. | PFR-specific behaviour, grid math. |
commands/ |
6 input descriptors + handlers (clock, fluent, OTR, temperature, dispersion, child). | Inbound topic API, alias deprecation. |
reaction_modules/ |
Optional plug-in reaction modules (legacy — not yet refactored). | Adding new bio-process modules. |
additional_nodes/ |
Sibling Node-RED nodes (recirculation-pump, settling-basin) shipped from this repo. |
Cross-node deploy in same package. |
5. Topic contract
Auto-generated from
src/commands/index.js. Do NOT hand-edit between the markers. Re-runnpm run wiki:contract.
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
data.clock |
clock |
any |
— | Push the simulation clock tick (timestamp / dt) to the ASM solver. |
data.fluent |
Fluent |
object |
— | Push the influent stream (payload: {F: flow m3/h, C: [concentrations mg/L]}). |
data.otr |
OTR |
any |
— | Push the current oxygen-transfer rate into the reactor. |
data.temperature |
Temperature |
any |
— | Push the current reactor temperature. |
data.dispersion |
Dispersion |
any |
— | Push a dispersion/mixing parameter update. |
child.register |
registerChild |
any |
— | Register a child node (settler / measurement) with this reactor. |
6. Child registration
flowchart LR
subgraph kids["accepted children (softwareType)"]
m_t["measurement<br/>temperature<br/>positionVsParent=atEquipment"]:::ctrl
m_o["measurement<br/>quantity (oxygen)<br/>positionVsParent=distance (numeric)"]:::ctrl
r_up["reactor<br/>positionVsParent=upstream"]:::unit
end
m_t -->|temperature.measured.atEquipment| h_meas["engine._connectMeasurement<br/>(baseEngine.js)"]
m_o -->|quantity(oxygen).measured.distance| h_meas
r_up -.stateChange.-> h_react["engine._connectReactor<br/>(baseEngine.js)"]
h_meas --> reconcile["reconcile T → engine.temperature<br/>reconcile O2 → state grid cell (PFR only)"]
h_react --> pull["pull upstream getEffluent<br/>→ Fs[0] / Cs_in[0] before next tick"]
classDef ctrl fill:#a9daee,color:#000
classDef unit fill:#50a8d9,color:#000
| softwareType | filter | wired to | side-effect |
|---|---|---|---|
measurement |
asset.type = temperature, positionVsParent = atEquipment |
engine._connectMeasurement |
Writes engine.temperature. CSTR only honours temperature; PFR additionally reconciles quantity (oxygen).measured.<distance> (numeric position) → nearest grid cell DO. |
measurement |
asset.type = quantity (oxygen), positionVsParent = <numeric distance> |
engine._connectMeasurement → pfr._updateMeasurement |
PFR only: maps measurement to nearest grid cell by round(pos / length × n_x). |
reactor |
positionVsParent = upstream |
engine._connectReactor |
Subscribes to upstream reactor's stateChange; pulls getEffluent into Fs[0] / Cs_in[0] before next integration step. |
diffuser is NOT a registered child — it feeds aeration via data.otr on Port 0. No child-registration handshake is involved.
7. Lifecycle — what one data.clock advance does
sequenceDiagram
participant clock as clock injector
participant reactor as reactor (specificClass)
participant engine as kinetics engine (CSTR/PFR)
participant downstream as settler / next reactor
participant out as Port-0 output
clock->>reactor: data.clock { timestamp }
reactor->>engine: updateState(timestamp)
Note over engine: n_iter = floor(speedUpFactor × Δt / timeStep)<br/>each step calls tick(timeStep)
engine->>engine: integrate ASM3 rates (CSTR: Forward Euler / PFR: FD)
engine->>engine: cap S_O to saturation, clip negatives to 0
engine->>engine: emit 'stateChange' (currentTime)
reactor->>reactor: notifyOutputChanged → getOutput()
reactor->>out: getOutput() → {flow_total, temperature, S_O … X_TS}
alt PFR engine
reactor->>out: GridProfile { grid[n_x][13], n_x, d_x, length, species }
end
out->>downstream: Fluent { inlet=0, F, C[13] } via stateChange listener
stateChange re-emits on reactor.emitter (BaseDomain emitter) — wired in specificClass.configure(). Downstream settlers or chained reactors subscribed via _connectReactor call their own updateState on each stateChange event. The tick loop is opt-in (tick-driven via static tickInterval) because the reactor integrates process-time steps that have no fixed wall-clock mapping.
8. Data model — getOutput()
Port-0 process payload is the Fluent envelope (+ optional GridProfile for PFR). Port-1 telemetry is the scalar snapshot below.
| Key | Type | Unit | Sample |
|---|---|---|---|
S_HCO |
number | — | 5 |
S_I |
number | — | 30 |
S_N2 |
number | — | 0 |
S_NH |
number | — | 25 |
S_NO |
number | — | 0 |
S_O |
number | — | 0 |
S_S |
number | — | 70 |
X_A |
number | — | 200 |
X_H |
number | — | 2000 |
X_I |
number | — | 1000 |
X_S |
number | — | 100 |
X_STO |
number | — | 0 |
X_TS |
number | — | 3500 |
flow_total |
number | — | 0 |
temperature |
number | — | 20 |
Concrete sample (CSTR mid-integration, nitrifying):
{
"flow_total": 1000,
"temperature": 15.2,
"S_O": 2.1,
"S_I": 30,
"S_S": 12.4,
"S_NH": 0.8,
"S_N2": 4.3,
"S_NO": 18.6,
"S_HCO": 4.2,
"X_I": 1050,
"X_S": 65,
"X_H": 2150,
"X_STO": 4.5,
"X_A": 215,
"X_TS": 3680
}
Species ordering follows ASM3: indices 0–6 are soluble, 7–12 are particulate. flow_total is the effluent flow (m³/d); the reactor uses days as the time unit internally.
9. Configuration — editor form ↔ config keys
flowchart TB
subgraph editor["Node-RED editor form (reactor.html)"]
f1["Reactor type: CSTR / PFR"]
f2["Volume (m³)"]
f3["Length (m) + Resolution — PFR only"]
f4["Alpha α (boundary condition blend)"]
f5["Number of inlets"]
f6["kLa (d⁻¹) — internal aeration"]
f7["13 × initial concentration fields"]
f8["Time step (s label) + Speed-up factor"]
end
subgraph config["Domain config slice (reactor.json)"]
c1[reactor.reactor_type]
c2[reactor.volume]
c3["reactor.length<br/>reactor.resolution_L"]
c4[reactor.alpha]
c5[reactor.n_inlets]
c6[reactor.kla]
c7["initialState.S_O … X_TS"]
c8["reactor.timeStep (unit: h per schema)<br/>reactor.speedUpFactor"]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
f7 --> c7
f8 --> c8
| Form field | Config key | Schema default | Range | Where used |
|---|---|---|---|---|
| Reactor type | reactor.reactor_type |
CSTR |
enum: CSTR / PFR (schema validator lowercases; _buildEngine toUpperCase guards) |
engine selection in Reactor._buildEngine() |
| Volume (m³) | reactor.volume |
1000 |
> 0 | residence time, mass balance |
| Length (m) | reactor.length |
10 |
> 0 | PFR only — axial extent |
| Resolution | reactor.resolution_L |
10 |
≥ 1 | PFR grid cell count n_x |
| Alpha | reactor.alpha |
0.5 |
0–1 | Danckwerts (0) vs Dirichlet (1) inlet BC |
| Inlets | reactor.n_inlets |
1 |
≥ 1 | Fs[] / Cs_in[] array sizes |
| kLa (d⁻¹) | reactor.kla |
0 |
≥ 0; set to NaN to use data.otr instead |
_calcOTR() in baseEngine.js |
| Time step | reactor.timeStep |
0.001 |
≥ 0.0001 | integrator inner step (schema says h; HTML label says s — see limitation #6) |
| Speed-up factor | reactor.speedUpFactor |
1 |
≥ 1 | n_iter = floor(speedUpFactor × Δt_wall / timeStep_days) |
| Initial S_O | initialState.S_O |
0 |
≥ 0 (mg/L) | starting dissolved oxygen (caps to saturation in first tick) |
| Initial S_NH | initialState.S_NH |
25 |
≥ 0 (mg/L) | starting ammonium |
| Initial X_H | initialState.X_H |
2000 |
≥ 0 (mg/L) | starting heterotroph biomass |
| Initial X_A | initialState.X_A |
200 |
≥ 0 (mg/L) | starting autotroph biomass — must be ≥ ~50 mg/L for nitrification; HTML default is 0.001 (footgun) |
| Initial X_TS | initialState.X_TS |
3500 |
≥ 0 (mg/L) | starting TSS — drives settler split |
10. State chart
Skipped — reactor has no FSM. It runs continuous-state ODE integration; the engine's only stateful event is stateChange, fired after every successful integration advance. See section 7 for the integration sequence.
11. Examples
| Tier | File | What it shows | Status |
|---|---|---|---|
| Basic | examples/basic.flow.json |
CSTR with one inlet, watch Fluent effluent |
✅ in repo |
| Integration | examples/integration.flow.json |
upstream reactor → reactor → settler chain | ✅ in repo |
| Edge | examples/edge.flow.json |
PFR with dispersion + multi-inlet | ✅ in repo |
| Companions | additional_nodes/* |
recirculation-pump + settling-basin Node-RED nodes shipped from this repo | ✅ in repo |
One screenshot per tier where helpful. PNG ≤ 200 KB under wiki/_partial-screenshots/reactor/.
12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| Nitrification doesn't proceed (S_NH stays high) | initialState.X_A must be ≥ ~50 mg/L. Defaulting to 0.001 (a known footgun) means no autotrophs. |
generalFunctions/src/configs/reactor.json |
Fluent effluent flow zero |
No data.clock ticks arriving, or data.fluent never set Fs[0] > 0. |
commands/handlers.js, engine setInfluent |
PFR GridProfile not emitted |
reactor_type set to CSTR — only PFR emits grid. |
_buildEngine switch |
| Settler downstream not updating | stateChange event listener path: settler must subscribe to reactor.emitter, NOT reactor.measurements.emitter. |
settler _connectReactor |
| Temperature reconcile silently ignored | Child measurement's asset.type not temperature exactly, or positionVsParent not atEquipment. |
engine._connectMeasurement |
| Integrator slow / stalls | reactor.timeStep too small for speedUpFactor. Internal n_iter count blows up. |
engine.updateState |
wiki:datamodel script slow / hangs |
mathjs cold-start ~13 s; instantiation depends on it transitively. See known-limitations row 1. |
kinetics/baseEngine.js |
13. When you would NOT use this node
- Use reactor for ASM3 biological treatment modelling (activated sludge, nitrification, denitrification). For aerobic-only or simpler kinetics, the ASM3 species vector is overkill.
- Don't use reactor for a passive equalisation tank — the kinetics engines assume reactions are happening.
- Skip reactor when you only need a residence-time delay; a simple buffer node is lighter and doesn't require
mathjs.
14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | mathjs cold-start adds ~13 s to first require() — wiki:datamodel auto-gen may time out on the 60 s wrapper. Falls back to the hand-curated concrete sample block. Two remedies tracked: tree-shake mathjs to used ops only; cache the instance. |
.claude/refactor/OPEN_QUESTIONS.md — "mathjs slow load" |
| 2 | initialState.X_A HTML default is 0.001 mg/L (silently disabling nitrification) but the schema default is 200 mg/L. Always check the deployed node's form value before expecting nitrification. |
reactor.html line 38 vs generalFunctions/src/configs/reactor.json |
| 3 | getEffluent shape historically varied (array vs single envelope) — settler's _connectReactor tolerates both. Don't break the contract without updating settler. |
nodes/settler/src/specificClass.js → _connectReactor |
| 4 | additional_nodes/recirculation-pump and settling-basin are legacy companions — not yet refactored to BaseDomain. |
P6.5 follow-up |
| 5 | reaction_modules/ is a legacy plug-in directory not consumed by the current engines. Removal pending. |
P6.5 follow-up |
| 6 | reactor_type enum casing: the JSON schema validator lowercases the user-supplied value ('PFR' → 'pfr'). Reactor._buildEngine calls .toUpperCase() to work around this until Phase 7 decides the platform-wide canonical casing. If the guard is removed prematurely, PFR config silently falls back to CSTR. |
.claude/refactor/OPEN_QUESTIONS.md — "reactor schema enum lowercases reactor_type" |
| 7 | timeStep unit mismatch: the HTML form label says "Time step [s]" but reactor.json declares unit: "h". baseEngine.js converts config.timeStep by ÷ 86 400 (seconds → days), suggesting the true input unit is seconds. Audited in OPEN_QUESTIONS.md Phase 5/6 cleanup list. |
baseEngine.js line 40; reactor.json timeStep.rules.unit; reactor.html time-step label |