# Reference — Architecture ![code-ref](https://img.shields.io/badge/code--ref-a3583a3-blue) > [!NOTE] > Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only. Code structure for `settler`: the three-tier sandwich, the `src/` layout, the reactor ↔ settler wiring (the load-bearing bit), the lifecycle, and the output-port pipeline. For an intuitive overview, return to [Home](Home). --- ## Three-tier code layout ``` nodes/settler/ | +-- settler.js entry: RED.nodes.registerType('settler', ...) + /settler/menu.js admin route +-- settler.html editor form (Name, output formats, logger, position) — currently colour-drifted to #e4a363 | +-- src/ | nodeClass.js extends BaseNodeAdapter — domain wiring + Port 0/1 emit (~30 LOC) | specificClass.js extends BaseDomain — TSS split + child wiring (~140 LOC) | | | +-- commands/ | index.js 2 topic descriptors: data.influent (+aliases), child.register | handlers.js pure handler functions ``` Settler is small enough (~140 LOC of domain code) that no concern-split was needed; per the platform's MODULE_SPLIT contract a node only fans out into `src//` modules when complexity demands it. ### Tier responsibilities | Tier | File | What it owns | Touches `RED.*` | |:---|:---|:---|:---:| | entry | `settler.js` | Type registration + `/settler/menu.js` admin endpoint (proxies reactor's MenuManager). | yes | | nodeClass | `src/nodeClass.js` | `_emitOutputs()` only — calls `source.getEffluent` for the 3-msg Port-0 array and `source.getOutput()` for Port-1 InfluxDB telemetry. `tickInterval = null` (event-driven, no periodic loop). `statusInterval = 1000` for the badge. | yes | | specificClass | `src/specificClass.js` | `Settler.configure()` wires the `ChildRouter` for `measurement` / `reactor` / `machine` children. `getEffluent` is the mass-balance core. `getOutput` is the scalar Port-1 view. `getStatusBadge` renders the editor badge. | no | `specificClass` does the work directly; there is no orchestration / concern layer between it and the math. > [!NOTE] > Pending full node review (2026-05). The router-based registration uses `this.router.onRegister(...)` — verify against the latest BaseDomain contract when next touched. --- ## FSM Not applicable. Settler is **stateless**. There is no FSM, no `state.*` member, no `executeSequence`, no movement / setpoint. Every trigger (`stateChange` from the upstream reactor, `data.influent` from an operator, or a `quantity (tss)` update from a measurement child) causes a fresh recompute of the three Fluent envelopes from the current runtime state, and the split immediately re-emits. The status badge has two cosmetic branches only: | Condition | Badge | |:---|:---| | `F_in <= 0` | `statusBadge.idle('no influent')` | | else | green dot, label `F_in= eff= surplus=` | There are no "protected states", no abort tokens, no allow-lists. All of that belongs to FSM-bearing nodes (`rotatingMachine`, `pumpingStation`, `machineGroupControl`, …). --- ## Reactor ↔ settler wiring — the load-bearing bit This is the one piece of settler that needs care, because the reactor uses a different event channel than every other child type in EVOLV. ```mermaid flowchart TB reactor[upstream reactor]:::unit rEmit{reactor.emitter} rMeas{reactor.measurements.emitter} settler[settler._connectReactor]:::unit pull[upstreamReactor.getEffluent] apply[F_in = ...
Cs_in = ...] notify[notifyOutputChanged()] reactor --> rEmit reactor --> rMeas rEmit -- stateChange --> settler rMeas -. NOT used .- settler settler --> pull pull --> apply apply --> notify classDef unit fill:#50a8d9,color:#000 ``` Mechanism: 1. The reactor pushes its `stateChange` event on `reactor.emitter` — **not** on `reactor.measurements.emitter`. The standard `router.onMeasurement` subscription path therefore can't see it. 2. `_connectReactor(reactorChild)` attaches the listener manually: ```js reactorChild.emitter.on('stateChange', () => { const raw = this.upstreamReactor.getEffluent; const effluent = Array.isArray(raw) ? raw[0] : raw; this.F_in = effluent.payload.F; this.Cs_in = effluent.payload.C; this.notifyOutputChanged(); }); ``` 3. `reactor.getEffluent` historically returned either an **array** (a 3-stream Fluent envelope, same shape settler itself emits) or a **single envelope** — the 2026-03-02 `_connectReactor` fix preserves both shapes via `Array.isArray(raw) ? raw[0] : raw`. If you change the reactor's effluent shape, this is the line to update. 4. Position check: settler warns `Reactor children of settlers should be upstream.` if `positionVsParent !== 'upstream'`, but still stores the reactor. The wiring is not blocked — it just becomes the operator's problem to confirm intent. > [!NOTE] > Pending full node review (2026-05). The settler-side pull-not-push semantics rely on `reactor.getEffluent` being a property getter (no arguments). Future reactor refactors that turn this into a parameterised method will need a coordinated change here. --- ## Return-pump wiring `_connectMachine(machineChild)` is the second non-trivial registration path: | Condition | Effect | |:---|:---| | `positionVsParent === 'downstream'` | Store as `this.returnPump`. Set `machineChild.upstreamSource = this` so the pump's own flow predictor can use settler's `inlet=2` envelope as its suction-side Fluent. | | else | Warn `Failed to register machine child.`, no storage. | At each call to `getEffluent`, settler reads the pump's current measured flow: ```js F_sr = Math.min( this.returnPump.measurements.type('flow').variant('measured').position(POSITIONS.AT_EQUIPMENT).getCurrentValue(), F_s, ); ``` If no return pump is registered or its flow measurement is zero, `F_sr = 0` — in that case all separated sludge becomes surplus (`inlet=1`) and the return stream (`inlet=2`) is zero-flow. --- ## Measurement-child wiring `_connectMeasurement(measurementChild)` is generic: it subscribes to `.measured.` on the child's `measurements.emitter` and: 1. Re-emits the value on settler's own `this.measurements` container (lets settler's own parent subscribe). 2. Calls `_updateMeasurement(type, value, position, eventData)`. `_updateMeasurement` currently recognises only one type: | `measurementType` | Side-effect | |:---|:---| | `quantity (tss)` | Set `this.C_TS = value`. Trigger `notifyOutputChanged()`. | | anything else | Log an `error` (`Type '' not recognized for measured update.`) — the re-emit still happened, just no settler-side state change. | The `quantity (tss)` value is the **settler's own setpoint** for the target return-sludge concentration. The default is `2500` mg/L. Higher `C_TS` → less return + surplus, more effluent. Lower `C_TS` → more return + surplus. --- ## Mass-balance math (`getEffluent`) ```mermaid flowchart TB F_in[F_in m³/h]:::input Cs_in[Cs_in array 13
mg/L per species]:::input C_TS[C_TS
target return mg/L]:::input pumpFlow[returnPump.flow.measured.atequipment]:::input F_in --> Fs[F_s = min(F_in × Cs_in[12] / C_TS, F_in)] Cs_in --> Fs C_TS --> Fs Fs --> F_eff[F_eff = F_in − F_s] Fs --> F_sr[F_sr = min(pumpFlow, F_s)] pumpFlow --> F_sr F_sr --> F_so[F_so = F_s − F_sr] Cs_in --> CsEff[Cs_eff: copy then zero 7..12 if F_s > 0] Cs_in --> CsS[Cs_s: copy then scale 7..12 by F_in / F_s if F_s > 0] F_eff --> envEff[envelope inlet=0] CsEff --> envEff F_so --> envSur[envelope inlet=1] CsS --> envSur F_sr --> envRet[envelope inlet=2] CsS --> envRet classDef input fill:#a9daee,color:#000 ``` Key facts: - `F_s` is the **total separated sludge stream**. Mass-balance derivation: at steady state, `F_in * Cs_in[12] = F_s * C_TS` → `F_s = F_in * Cs_in[12] / C_TS`. - The clamp `min(..., F_in)` prevents `F_eff` going negative when `Cs_in[12] > C_TS` (i.e. the influent is denser than the target sludge concentration). Sub-rosa: the clamp masks the **upstream** problem; settler does not warn when the clamp fires — see [Reference — Limitations](Reference-Limitations#no-flow-balance-warning). - Species indices 7–12 are the ASM3 particulate species (`X_*`). Index 12 specifically is `X_TS` — the lumped total-suspended-solids surrogate the split is keyed off. - Soluble species 0–6 pass through unchanged in all three streams. - All three envelopes share a single `timestamp = Date.now()` — downstream consumers can rely on them being a coherent triple. --- ## Lifecycle — what one trigger does ```mermaid sequenceDiagram autonumber participant reactor as upstream reactor participant settler as settler (specificClass) participant nc as nodeClass participant pump as return pump child participant out as Port 0 / 1 reactor->>settler: emitter.emit('stateChange') settler->>reactor: read getEffluent reactor-->>settler: {F, C[13]} (or [{...}]) settler->>settler: F_in = F · Cs_in = C settler->>settler: notifyOutputChanged() nc->>settler: read getEffluent (recompute) settler->>pump: read measurements.flow.measured.atequipment pump-->>settler: returnFlow (m³/h) settler->>settler: getEffluent — split into 3 envelopes settler-->>nc: [Fluent inlet=0, inlet=1, inlet=2] nc->>settler: read getOutput() settler-->>nc: {F_in, C_TS, F_eff, F_surplus, F_return, ...measurements} nc->>out: send([fluent, influxMsg, null]) ``` The three triggers that route through this lifecycle are: | Trigger | Origin | Path | |:---|:---|:---| | Reactor `stateChange` | `reactor.emitter.emit('stateChange')` | `_connectReactor` listener → pull `getEffluent` → copy → `notifyOutputChanged` | | Operator `data.influent` | Inbound `msg.topic` | `commands/handlers.js#dataInfluent` → mutate `F_in` / `Cs_in` → `notifyOutputChanged` | | Measurement `quantity (tss)` | `measurementChild.measurements.emitter` | `_connectMeasurement` re-emit + `_updateMeasurement` → mutate `C_TS` → `notifyOutputChanged` | `notifyOutputChanged` is BaseDomain's standard `output-changed` event. `BaseNodeAdapter` listens, calls `_emitOutputs()`, which produces the Port 0 / Port 1 messages. --- ## Output ports | Port | Carries | Sample shape | |:---|:---|:---| | 0 (process) | **Array of three Node-RED messages**, each `{topic: 'Fluent', payload: {inlet, F, C}, timestamp}`. Re-emitted on every recompute. | See [Home — What you'll see come out](Home#what-youll-see-come-out). | | 1 (telemetry) | Single InfluxDB line-protocol payload built by `outputUtils.formatMsg(getOutput(), cfg, 'influxdb')`. Delta-compressed: only changed fields shipped. | `settler,id=settler_a F_in=1000,F_eff=850,F_surplus=50,F_return=100,C_TS=2500,...` | | 2 (register / control) | `null` from `_emitOutputs`. The one-shot `child.register` upward at startup goes through the BaseNodeAdapter init path, not `_emitOutputs`. | `{topic: 'child.register', payload: , positionVsParent, distance}` (init only) | Port-1 key shape from `getOutput()`: | Key | Type | Source | Notes | |:---|:---|:---|:---| | `F_in` | number | `host.F_in` | Influent flow (m³/h). | | `C_TS` | number | `host.C_TS` | Target return-sludge concentration (mg/L). Default 2500. | | `F_eff` | number | `streams[0].payload.F` | Clarified effluent flow. | | `F_surplus` | number | `streams[1].payload.F` | Surplus sludge flow. | | `F_return` | number | `streams[2].payload.F` | Return sludge flow. | | `...` | varies | `measurements.getFlattenedOutput()` | Flattened snapshot of every measurement settler has seen (re-emitted from children). | See [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout. > [!NOTE] > Pending full node review (2026-05). The `getOutput()` return shape has not been audited against an `_output-manifest.md` (the output-coverage rule). TODO: add the manifest and `test/basic/output-*.test.js` coverage in both populated and degraded states (no influent / no pump / no TSS measurement). --- ## Event sources | Source | Where it fires | What it triggers | |:---|:---|:---| | `reactor.emitter` `'stateChange'` | Upstream reactor on every internal state advance | `_connectReactor` listener → pull `getEffluent` → recompute | | `measurementChild.measurements.emitter` `'.measured.'` | Any registered measurement child on a new sample | `_connectMeasurement` re-emit + `_updateMeasurement` switch | | Inbound `msg.topic = data.influent` | Node-RED input wire | `commands/handlers.js#dataInfluent` | | Inbound `msg.topic = child.register` | Node-RED input wire (rare; usually Port 2 wiring) | `commands/handlers.js#childRegister` | | `BaseDomain` `'output-changed'` | `notifyOutputChanged()` in domain | `nodeClass._emitOutputs()` | | `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Re-render the status badge | No per-second tick on the domain itself. `tickInterval = null` is explicit in `nodeClass`. --- ## Where to start reading | If you're changing… | Read first | |:---|:---| | The TSS mass-balance math | `src/specificClass.js#getEffluent` (lines 37–62) | | Reactor ↔ settler wiring (stateChange listener, both-shape envelope handling) | `src/specificClass.js#_connectReactor` | | Return-pump wiring (the `machine` / `downstream` registration) | `src/specificClass.js#_connectMachine` | | Measurement re-emit + `C_TS` setpoint | `src/specificClass.js#_connectMeasurement` + `#_updateMeasurement` | | Operator-side influent override | `src/commands/handlers.js#dataInfluent` | | Port 0 (3-msg array) + Port 1 (InfluxDB) emit pipeline | `src/nodeClass.js#_emitOutputs` | | Status-badge text | `src/specificClass.js#getStatusBadge` | | Editor form / colour drift | `settler.html` (currently `color: '#e4a363'`; should be `#50a8d9`) | --- ## Related pages | Page | Why | |:---|:---| | [Home](Home) | Intuitive overview | | [Reference — Contracts](Reference-Contracts) | Topic + config + child filters | | [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes | | [Reference — Limitations](Reference-Limitations) | Known issues and open questions | | [reactor wiki](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Home) | The upstream parent — emits `stateChange` and exposes `getEffluent` | | [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home) | The return-pump child — consumes `inlet=2` via `upstreamSource` | | [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |