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>
292 lines
15 KiB
Markdown
292 lines
15 KiB
Markdown
# Reference — Architecture
|
||
|
||

|
||
|
||
> [!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/<concern>/` 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=<n> eff=<n> surplus=<n>` |
|
||
|
||
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 = ...<br/>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 `<type>.measured.<position>` 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 '<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<br/>mg/L per species]:::input
|
||
C_TS[C_TS<br/>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: <node.id>, 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. |
|
||
| `<type>.<variant>.<position>.<childId>` | 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` `'<type>.measured.<position>'` | 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 |
|