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>
15 KiB
Reference — Architecture
Note
Pending full node review (2026-05). Content reflects
CONTRACT.mdand 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.
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.
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:
-
The reactor pushes its
stateChangeevent onreactor.emitter— not onreactor.measurements.emitter. The standardrouter.onMeasurementsubscription path therefore can't see it. -
_connectReactor(reactorChild)attaches the listener manually: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(); }); -
reactor.getEffluenthistorically returned either an array (a 3-stream Fluent envelope, same shape settler itself emits) or a single envelope — the 2026-03-02_connectReactorfix preserves both shapes viaArray.isArray(raw) ? raw[0] : raw. If you change the reactor's effluent shape, this is the line to update. -
Position check: settler warns
Reactor children of settlers should be upstream.ifpositionVsParent !== '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.getEffluentbeing 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:
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:
- Re-emits the value on settler's own
this.measurementscontainer (lets settler's own parent subscribe). - 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)
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_sis 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)preventsF_effgoing negative whenCs_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. - Species indices 7–12 are the ASM3 particulate species (
X_*). Index 12 specifically isX_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
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. |
| 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 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 andtest/basic/output-*.test.jscoverage 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 | Intuitive overview |
| Reference — Contracts | Topic + config + child filters |
| Reference — Examples | Shipped flows + debug recipes |
| Reference — Limitations | Known issues and open questions |
| reactor wiki | The upstream parent — emits stateChange and exposes getEffluent |
| rotatingMachine wiki | The return-pump child — consumes inlet=2 via upstreamSource |
| EVOLV — Architecture | Platform-wide three-tier pattern |