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

15 KiB
Raw Blame History

Reference — Architecture

code-ref

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.


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&#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.emitternot on reactor.measurements.emitter. The standard router.onMeasurement subscription path therefore can't see it.

  2. _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();
    });
    
  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:

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)

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_TSF_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.
  • Species indices 712 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 06 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_innotifyOutputChanged
Measurement quantity (tss) measurementChild.measurements.emitter _connectMeasurement re-emit + _updateMeasurement → mutate C_TSnotifyOutputChanged

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 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 3762)
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)

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