Replaces the prior stub/partial wiki with a Home + Reference-{Architecture,
Contracts,Examples,Limitations} + _Sidebar structure. Topic-contract and
data-model sections wrapped in AUTOGEN markers for the future wiki-gen tool.
Source-vs-spec contradictions surfaced and flagged inline (not silently
fixed). Pending-review notes mark sections that need a full node review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 KiB
Reference — Architecture
Note
Code structure for
reactor: the three-tier sandwich, thesrc/layout, the ASM3 kinetics engines (CSTR + PFR), the integration sequence, child registration, and the output-port pipeline. For an intuitive overview, return to Home.Pending full node review (2026-05). Content reflects
CONTRACT.mdand 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).
flowchart LR
clk[data.clock<br/>or tick(dt)]:::input --> us[updateState(newTime)]
us --> ni{n_iter = floor(<br/>speedUpFactor × Δt / timeStep)}
ni -->|0| skip[no-op]
ni -->|>0| loop[for each step:<br/>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
flowchart TB
subgraph base["BaseReactorEngine"]
bs["Fs[], Cs_in[][13]<br/>OTR, temperature, kla<br/>upstreamReactor link<br/>updateState(newTime)<br/>_connectMeasurement / _connectReactor"]
end
subgraph cstr["Reactor_CSTR"]
cs["state = number[13]<br/>tick(dt):<br/> inflow + outflow + reaction + transfer<br/> Forward Euler<br/> _capDissolvedOxygen / _arrayClip2Zero"]
end
subgraph pfr["Reactor_PFR"]
ps["state = number[n_x][13]<br/>length, n_x, d_x, A, alpha, D<br/>D_op / D2_op finite-difference operators<br/>tick(dt):<br/> dispersion + advection + reaction + transfer<br/> Explicit FD<br/> Danckwerts inlet / Neumann outlet BC<br/> 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
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.
flowchart LR
subgraph kids["accepted children (softwareType)"]
m_t["measurement<br/>asset.type=temperature<br/>positionVsParent=atEquipment"]:::ctrl
m_o["measurement<br/>asset.type=quantity (oxygen)<br/>positionVsParent=numeric distance (PFR)"]:::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
_connectMeasurement event wiring
measurement.measurements.emitter fires <measurementType>.measured.<position> on every published value. The reactor subscribes:
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 '<x>' 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
diffuseris not a registered child. It feeds aeration via thedata.otrtopic on Port 0 (handled incommands/handlers.jsdataOTR). 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: <node.id>, 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 currentgetOutput()insrc/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:
<EngineType> T=<temperature> C F=<flow> m³/d S_O=<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 <type>.measured.<position> |
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 | Intuitive overview |
| Reference — Contracts | Topic + config + child filters |
| Reference — Examples | Shipped flows + debug recipes |
| Reference — Limitations | Known issues and open questions |
| settler wiki | The typical downstream Unit that subscribes to reactor stateChange |
| diffuser wiki | The Equipment node that pushes data.otr |
| EVOLV — Architecture | Platform-wide three-tier pattern |