Files
settler/wiki/Reference-Architecture.md
znetsixe d54cb66105 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:12 +02:00

292 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Reference &mdash; 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 &harr; 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 &mdash; 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(...)` &mdash; 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`, &hellip;).
---
## Reactor &harr; settler wiring &mdash; 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&#40;&#41;]
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` &mdash; **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** &mdash; 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 &mdash; 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` &mdash; 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.`) &mdash; 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` &rarr; less return + surplus, more effluent. Lower `C_TS` &rarr; 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&#40;F_in × Cs_in&#91;12&#93; / C_TS, F_in&#41;]
Cs_in --> Fs
C_TS --> Fs
Fs --> F_eff[F_eff = F_in F_s]
Fs --> F_sr[F_sr = min&#40;pumpFlow, F_s&#41;]
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` &rarr; `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 &mdash; see [Reference &mdash; Limitations](Reference-Limitations#no-flow-balance-warning).
- Species indices 7&ndash;12 are the ASM3 particulate species (`X_*`). Index 12 specifically is `X_TS` &mdash; the lumped total-suspended-solids surrogate the split is keyed off.
- Soluble species 0&ndash;6 pass through unchanged in all three streams.
- All three envelopes share a single `timestamp = Date.now()` &mdash; downstream consumers can rely on them being a coherent triple.
---
## Lifecycle &mdash; 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 &rarr; pull `getEffluent` &rarr; copy &rarr; `notifyOutputChanged` |
| Operator `data.influent` | Inbound `msg.topic` | `commands/handlers.js#dataInfluent` &rarr; mutate `F_in` / `Cs_in` &rarr; `notifyOutputChanged` |
| Measurement `quantity (tss)` | `measurementChild.measurements.emitter` | `_connectMeasurement` re-emit + `_updateMeasurement` &rarr; mutate `C_TS` &rarr; `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 &mdash; 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 &mdash; 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 &rarr; pull `getEffluent` &rarr; 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&hellip; | Read first |
|:---|:---|
| The TSS mass-balance math | `src/specificClass.js#getEffluent` (lines 37&ndash;62) |
| Reactor &harr; 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 &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 |
| [reactor wiki](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Home) | The upstream parent &mdash; emits `stateChange` and exposes `getEffluent` |
| [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home) | The return-pump child &mdash; consumes `inlet=2` via `upstreamSource` |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |