Files
reactor/wiki/Home.md
znetsixe d735f9485c docs(wiki): rewrite Home.md to full 14-section visual-first template
- 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>
2026-05-11 21:07:07 +02:00

17 KiB
Raw Blame History

reactor

Reflects code as of c84dd78 · regenerated 2026-05-11 via npm run wiki:all If 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-run npm 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._connectMeasurementpfr._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 06 are soluble, 712 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 01 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