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:
291
wiki/Reference-Architecture.md
Normal file
291
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user