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>
This commit is contained in:
znetsixe
2026-05-19 09:42:11 +02:00
parent 0e34403c5d
commit cb49bb8b4d
6 changed files with 964 additions and 250 deletions

View File

@@ -0,0 +1,293 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-0e34403-blue)
> [!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](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 (`measurement` &rarr; `engine._connectMeasurement`, `reactor` &rarr; `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 &mdash; 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).
```mermaid
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 &mdash; CSTR vs PFR
```mermaid
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` &mdash; central-difference second-derivative operator. |
| Advection | `(&minus;sum(Fs) / (A &middot; d_x)) &middot; D_op &middot; state` &mdash; 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 &mdash; what one `data.clock` advance does
```mermaid
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.
```mermaid
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:
```js
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 &mdash; 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` &mdash; 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}` |
<!-- BEGIN AUTOGEN: data-model — populate via wiki-gen tool (TODO) -->
> [!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 | &deg;C | `engine.temperature` |
| `S_O` | number | mg/L | effluent `C[0]` &mdash; dissolved oxygen, capped to saturation |
| `S_I` | number | mg/L | effluent `C[1]` &mdash; inert soluble COD |
| `S_S` | number | mg/L | effluent `C[2]` &mdash; readily biodegradable substrate |
| `S_NH` | number | mg/L | effluent `C[3]` &mdash; ammonium nitrogen |
| `S_N2` | number | mg/L | effluent `C[4]` &mdash; dinitrogen |
| `S_NO` | number | mg/L | effluent `C[5]` &mdash; nitrate / nitrite |
| `S_HCO` | number | mmol/L | effluent `C[6]` &mdash; alkalinity |
| `X_I` | number | mg/L | effluent `C[7]` &mdash; inert particulate COD |
| `X_S` | number | mg/L | effluent `C[8]` &mdash; slowly biodegradable substrate |
| `X_H` | number | mg/L | effluent `C[9]` &mdash; heterotrophic biomass |
| `X_STO` | number | mg/L | effluent `C[10]` &mdash; stored COD in biomass |
| `X_A` | number | mg/L | effluent `C[11]` &mdash; autotrophic biomass |
| `X_TS` | number | mg/L | effluent `C[12]` &mdash; total suspended solids |
<!-- END AUTOGEN: data-model -->
### 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 &mdash; 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 &rarr; writes into MeasurementContainer + `_updateMeasurement` reconcile |
| Upstream reactor `'stateChange'` | Upstream reactor's `BaseDomain` emitter | `engine._connectReactor` callback &rarr; 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` &rarr; `source.updateState(Date.now())` + send |
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
---
## Where to start reading
| If you're changing&hellip; | 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 &harr; 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](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [settler wiki](https://gitea.wbd-rd.nl/RnD/settler/wiki/Home) | The typical downstream Unit that subscribes to reactor `stateChange` |
| [diffuser wiki](https://gitea.wbd-rd.nl/RnD/diffuser/wiki/Home) | The Equipment node that pushes `data.otr` |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |