Files
reactor/wiki/Reference-Architecture.md
znetsixe cb49bb8b4d docs(wiki): full 5-page wiki matching the rotatingMachine reference format
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>
2026-05-19 09:42:11 +02:00

16 KiB

Reference — Architecture

code-ref

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.

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 (measurementengine._connectMeasurement, reactorengine._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&#40;dt&#41;]:::input --> us[updateState&#40;newTime&#41;]
    us --> ni{n_iter = floor&#40;<br/>speedUpFactor &times; &Delta;t / timeStep&#41;}
    ni -->|0| skip[no-op]
    ni -->|>0| loop[for each step:<br/>tick&#40;timeStep&#41;]
    loop --> emit[emit stateChange&#40;currentTime&#41;]
    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&#40;newTime&#41;<br/>_connectMeasurement / _connectReactor"]
    end
    subgraph cstr["Reactor_CSTR"]
        cs["state = number[13]<br/>tick&#40;dt&#41;:<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&#40;dt&#41;:<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 &middot; Cs_in / volume Per inlet, summed into a single concentration delta.
Outflow &minus;sum(Fs) / volume &middot; 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 &middot; (sat(T) &minus; 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²) &middot; D2_op &middot; state — central-difference second-derivative operator.
Advection (&minus;sum(Fs) / (A &middot; d_x)) &middot; D_op &middot; 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 &minus; 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 &middot; sum(Fs) / (D &middot; A) &ge; 2 Local Peclet number (&hellip;) is too high! Increase reactor resolution.
Courant Co_D = D &middot; timeStep / d_x² &ge; 0.5 Courant number (&hellip;) 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 &times; &Delta;t / timeStep)
    alt upstreamReactor present
        engine->>engine: setInfluent = upstream.getEffluent
    end
    loop n_iter times
        engine->>engine: tick(timeStep) &mdash; 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 &mdash; GridProfile { grid, n_x, d_x, length, species }
    end
    rx->>out: Port 0 &mdash; Fluent { inlet=0, F, C[13] }
    rx->>out: Port 1 &mdash; InfluxDB scalars { flow_total, temperature, S_O&hellip;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.&lt;distance&gt;| h_meas
    r_up -.stateChange.-> h_react["engine._connectReactor<br/>(baseEngine.js)"]
    h_meas --> reconcile["reconcile T &rarr; engine.temperature<br/>reconcile O2 &rarr; state grid cell (PFR only)"]
    h_react --> pull["pull upstream getEffluent<br/>&rarr; 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 &times; n_x), 0, n_x &minus; 1). Non-finite position / value, or length &le; 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: <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 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:

<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._emitOutputssource.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

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