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:12 +02:00
parent a3583a3edb
commit d54cb66105
6 changed files with 829 additions and 205 deletions

View File

@@ -0,0 +1,291 @@
# 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 |