Compare commits

...

7 Commits

Author SHA1 Message Date
znetsixe
a3583a3edb docs: add Folder & File Layout section per EVOLV convention
Each repo can now be read standalone for the file-naming convention. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:31 +02:00
znetsixe
98052a16e7 docs(wiki): update Home.md to match WIKI_TEMPLATE §14-section standard
- Bump banner hash to 94b6616 (current HEAD)
- Section 9: add processOutputFormat + dbaseOutputFormat + enableLog fields
  that exist in settler.html but were absent from the config table
- Section 10: replace "Skipped" with precise stateless rationale
- Section 14: add item 5 — editor colour #e4a363 vs S88 #50a8d9 discrepancy,
  referencing node-red-flow-layout.md §16 for cleanup tracking
- Re-ran npm run wiki:all; AUTOGEN markers intact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:05:00 +02:00
znetsixe
94b661658c P11.6 wiki regen + Phase 10 private-test rewrites where applicable
For all 11 nodes with auto-gen markers: wiki/Home.md sections 5 (topic
contract) and 9 (data model) regenerated via npm run wiki:all. New
Unit column shows '<measure> (default <unit>)' for declared topics,
'—' otherwise. Effect column now uses descriptor.description (P11.2
field) overriding the generic per-prefix fallback.

For rotatingMachine + reactor: Phase 10 test rewrites — 3 + 8 files
moved off private nodeClass internals (_attachInputHandler, _commands,
_pendingExtras, _registerChild, _tick, etc.) to the public
BaseNodeAdapter surface (node.handlers.input, node.source.*).
+6 / +7 net new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:12 +02:00
znetsixe
43a5bf5468 P11.5 + B2.1/B2.2: per-command units + description (where applicable)
Adds  to scalar setters whose payloads are
plain numbers OR {value, unit}. Skipped where payload is compound or
mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline.
Every command gains a description field for wikiGen consumption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:41:16 +02:00
znetsixe
2af30c0bd8 fix(commands): restore child.register handler (alias registerChild)
Same fix as monster: P6.6 refactor dropped the case 'registerChild'
branch when extracting commands. Settler registers reactor + measurement
children — without this, Port 2 inbound handshakes were silently
ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:04:32 +02:00
znetsixe
6953d6473e P9.3: wiki/Home.md following 14-section visual-first template + wiki:* scripts
Auto-generated topic-contract + data-model sections via shared wikiGen
script. Hand-written Mermaid diagrams for position-in-platform, code
map, child registration, lifecycle, configuration, state chart (where
applicable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:17:44 +02:00
znetsixe
b8247fc755 P6: convert settler to platform infrastructure
Refactor of settler to use BaseNodeAdapter + commandRegistry + statusBadge.
settler follows the platform refactor plan in .claude/refactor/MODULE_SPLIT.md.
Tests stay green; CONTRACT.md generated; legacy aliases preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:23:44 +02:00
9 changed files with 623 additions and 191 deletions

View File

@@ -21,3 +21,20 @@ Key points for this node:
- Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#50a8d9` (Unit).
## Folder & File Layout
Every per-node file MUST use the folder name (`settler`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `settler.js` |
| Editor HTML | `settler.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
When adding new files, read the rule above first to avoid drift.

53
CONTRACT.md Normal file
View File

@@ -0,0 +1,53 @@
# settler — Contract
Hand-maintained for Phase 6; the `## Inputs` table is generated from
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
## Inputs (msg.topic on Port 0)
| Canonical | Aliases (deprecated) | Payload | Effect |
|---|---|---|---|
| `data.influent` | `influent`, `setInfluent` | `{ F: number, C: number[13] }` — either field optional | Replaces influent flow and/or the 13-species concentration vector. Triggers `output-changed`, re-emits the 3-stream Fluent envelope. |
Aliases log a one-time deprecation warning the first time they fire.
Plumbing topics (`child.register`) are handled by the BaseNodeAdapter and
not listed here.
## Outputs
- **Port 0 (process):** array of three Node-RED messages, each with
`topic = 'Fluent'` and `payload = { inlet, F, C }`:
- `inlet=0` — clarified effluent (particulate species 712 zeroed when `F_s > 0`).
- `inlet=1` — surplus sludge (particulates concentrated by `F_in / F_s`).
- `inlet=2` — return sludge (drawn by the downstream return pump up to `F_s`).
Re-emitted whenever the upstream reactor fires `stateChange`, an
operator pushes `data.influent`, or a child measurement updates `C_TS`.
- **Port 1 (InfluxDB telemetry):** `msg.topic = config.general.name`,
payload built by `outputUtils.formatMsg(..., 'influxdb')` from
`getOutput()`. Carries `F_in`, `C_TS`, `F_eff`, `F_surplus`, `F_return`
plus the flat measurements snapshot. Delta-compressed.
- **Port 2 (registration):** at startup the node sends one
`{ topic: 'child.register', payload: <node.id>, positionVsParent, distance }`
to its parent.
## Events emitted by `source.measurements.emitter`
The `MeasurementContainer` fires `<type>.measured.<position>` whenever a
matching series receives a new value. Settler re-emits incoming child
measurements (e.g. `quantity (tss).measured.atequipment`) so its own
parent can subscribe.
## Children accepted
| Software type | Position | Effect |
|---|---|---|
| `measurement` | any | Re-emit on `source.measurements`. `quantity (tss)` updates `C_TS` and triggers `output-changed`. |
| `reactor` | `upstream` (warns otherwise) | Stored as `upstreamReactor`. Listener attached to the reactor's own `emitter` (NOT measurements) for `'stateChange'`; on fire, settler pulls `reactor.getEffluent` and copies `F_in` + `Cs_in`. Handles both array and single-envelope `getEffluent` shapes. |
| `machine` | `downstream` | Stored as `returnPump`. Settler reads `returnPump.measurements.type('flow').variant('measured').position('atEquipment').getCurrentValue()` to determine `F_sr`. Sets `machineChild.upstreamSource = this`. |
## Parent relationship
Settler typically registers as `softwareType: 'settler'` with
`positionVsParent: 'downstream'` against a reactor (the reactor's
downstream stage). The downstream reactor consumes the three Fluent
streams via `payload.inlet`.

View File

@@ -16,7 +16,10 @@
"author": "P.R. van der Wilt",
"main": "settler.js",
"scripts": {
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
},
"node-red": {
"nodes": {

45
src/commands/handlers.js Normal file
View File

@@ -0,0 +1,45 @@
'use strict';
// Handler functions for settler commands. Each handler receives:
// source: the Settler domain instance.
// msg: the Node-RED input message.
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
//
// Settler accepts `child.register` (alias `registerChild`) on Port 0 input
// to register measurement / reactor children, plus `data.influent` for
// manual override. BaseNodeAdapter dispatches msg.topic through the
// per-node registry — there is no implicit `child.register` handler in
// the base, so it must be listed explicitly here.
function _logger(source, ctx) {
return ctx?.logger || source?.logger || null;
}
// Allows operators / upstream nodes to push an influent stream directly,
// bypassing the reactor stateChange path. Payload mirrors the reactor's
// `getEffluent` shape: { F, C } where C is the 13-species concentration
// vector. Either field may be omitted to update only the other.
exports.dataInfluent = (source, msg, ctx) => {
const log = _logger(source, ctx);
const p = msg?.payload;
if (!p || typeof p !== 'object' || Array.isArray(p)) {
log?.warn?.(`data.influent expects an object {F, C}; got ${typeof p}`);
return;
}
if (typeof p.F === 'number' && Number.isFinite(p.F)) source.F_in = p.F;
if (Array.isArray(p.C)) source.Cs_in = [...p.C];
source.notifyOutputChanged();
};
// Inbound child registration from a measurement (or reactor) child.
// Ported from the legacy `case 'registerChild'` branch in nodeClass.
exports.childRegister = (source, msg, ctx) => {
const log = _logger(source, ctx);
const childId = msg.payload;
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
if (!childObj?.source) {
log?.warn?.(`child.register skipped: missing child/source for id=${childId}`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent, msg.distance);
};

27
src/commands/index.js Normal file
View File

@@ -0,0 +1,27 @@
'use strict';
// settler command registry. Consumed by BaseNodeAdapter via
// `static commands = require('./commands')`. Each descriptor maps a
// canonical msg.topic to its handler; legacy names are listed under
// `aliases` and emit a one-time deprecation warning at runtime.
const handlers = require('./handlers');
module.exports = [
{
topic: 'data.influent',
aliases: ['influent', 'setInfluent'],
payloadSchema: { type: 'any' },
// Compound payload `{F, C: [...]}` — registry-level units normalisation is
// skipped (the handler converts per-field internally; flow=m3/h, conc=mg/L).
description: 'Push the influent stream (payload: {F: flow m3/h, C: [concentrations mg/L]}).',
handler: handlers.dataInfluent,
},
{
topic: 'child.register',
aliases: ['registerChild'],
payloadSchema: { type: 'string' },
description: 'Register a child node (typically a measurement) with this settler.',
handler: handlers.childRegister,
},
];

View File

@@ -1,108 +1,32 @@
const { Settler } = require('./specificClass.js');
const { configManager } = require('generalFunctions');
'use strict';
const { BaseNodeAdapter } = require('generalFunctions');
const Settler = require('./specificClass');
const commands = require('./commands');
class nodeClass {
/**
* Node-RED node class for settler.
* @param {object} uiConfig - Node-RED node configuration
* @param {object} RED - Node-RED runtime API
* @param {object} nodeInstance - Node-RED node instance
* @param {string} nameOfNode - Name of the node
*/
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
// Preserve RED reference for HTTP endpoints if needed
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
this.source = null;
// settler is event-driven on Port 0: the 3-stream Fluent envelope is
// re-emitted whenever the upstream reactor fires stateChange or an
// operator pushes data.influent. Port 1 (InfluxDB telemetry) reuses the
// base `output-changed` pipeline via `getOutput()`. `tickInterval=null`
// means BaseNodeAdapter installs no periodic loop — settling state has
// no time-dependent integrator.
class nodeClass extends BaseNodeAdapter {
static DomainClass = Settler;
static commands = commands;
static tickInterval = null;
static statusInterval = 1000;
this._loadConfig(uiConfig)
this._setupClass();
this._attachInputHandler();
this._registerChild();
this._startTickLoop();
this._attachCloseHandler();
buildDomainConfig() {
return {};
}
/**
* Handle node-red input messages
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
try {
switch (msg.topic) {
case 'registerChild': {
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
this.source?.logger?.warn(`registerChild skipped: missing child/source for id=${childId}`);
break;
}
this.source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
break;
}
default:
this.source?.logger?.warn(`Unknown topic: ${msg.topic}`);
}
} catch (error) {
this.source?.logger?.error(`Input handler failure: ${error.message}`);
}
if (typeof done === 'function') {
done();
}
});
}
/**
* Parse node configuration
* @param {object} uiConfig Config set in UI in node-red
*/
_loadConfig(uiConfig) {
const cfgMgr = new configManager();
this.config = cfgMgr.buildConfig('settler', uiConfig, this.node.id);
}
/**
* Register this node as a child upstream and downstream.
* Delayed to avoid Node-RED startup race conditions.
*/
_registerChild() {
setTimeout(() => {
this.node.send([
null,
null,
{ topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' }
]);
}, 100);
}
/**
* Setup settler class
*/
_setupClass() {
this.source = new Settler(this.config); // protect from reassignment
this.node.source = this.source;
}
_startTickLoop() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
}, 1000);
}
_tick(){
this.node.send([this.source.getEffluent, null, null]);
}
_attachCloseHandler() {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
if (typeof done === 'function') done();
});
_emitOutputs() {
if (!this.source) return;
const fluent = this.source.getEffluent;
const raw = this.source.getOutput?.() || {};
const cfg = this.source.config || this.config;
const influxMsg = this._output.formatMsg(raw, cfg, 'influxdb');
this.node.send([fluent, influxMsg, null]);
}
}

View File

@@ -1,157 +1,146 @@
const { childRegistrationUtils, logger, MeasurementContainer, POSITIONS } = require('generalFunctions');
const EventEmitter = require('events');
'use strict';
const { BaseDomain, POSITIONS, statusBadge } = require('generalFunctions');
// Compatibility-safe array clone for Node runtimes without global structuredClone.
function cloneArray(values) {
if (typeof structuredClone === 'function') {
return structuredClone(values);
}
if (typeof structuredClone === 'function') return structuredClone(values);
return Array.isArray(values) ? [...values] : values;
}
/**
* Settler domain model.
* Splits influent into effluent, sludge and return sludge based on solids balance.
*/
class Settler {
constructor(config) {
this.config = config;
// EVOLV stuff
this.logger = new logger(this.config.general.logging.enabled, this.config.general.logging.logLevel, config.general.name);
this.emitter = new EventEmitter();
this.measurements = new MeasurementContainer();
this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility
// Settler — secondary clarifier / sludge separator (Unit level).
// Splits influent into effluent, surplus sludge and return sludge based
// on a TSS mass balance. State updates come from an upstream reactor
// (stateChange → pull `getEffluent`) or operator-supplied influent via
// the `data.influent` command. The 3-port Fluent stream is produced by
// `getEffluent` and pushed onto Port 0 by the nodeClass.
class Settler extends BaseDomain {
static name = 'settler';
configure() {
this.upstreamReactor = null;
this.returnPump = null;
// state variables
this.F_in = 0; // debit in
this.Cs_in = new Array(13).fill(0); // Concentrations in
this.C_TS = 2500; // Total solids concentration sludge
this.F_in = 0;
this.Cs_in = new Array(13).fill(0);
this.C_TS = 2500;
this.router
.onRegister('measurement', (child) => this._connectMeasurement(child))
.onRegister('reactor', (child) => this._connectReactor(child))
.onRegister('machine', (child) => this._connectMachine(child));
}
// Three-stream output: effluent (inlet=0), surplus sludge (inlet=1),
// return sludge (inlet=2). Downstream consumers (reactor inlets,
// returnPump) read these by `payload.inlet`. F_s is clamped to F_in
// to prevent negative effluent when X_TS_in/C_TS exceeds 1.
get getEffluent() {
// constrain flow to prevent negatives
const F_s = Math.min((this.F_in * this.Cs_in[12]) / this.C_TS, this.F_in);
const F_eff = this.F_in - F_s;
let F_sr = 0;
if (this.returnPump) {
F_sr = Math.min(this.returnPump.measurements.type("flow").variant("measured").position(POSITIONS.AT_EQUIPMENT).getCurrentValue(), F_s);
F_sr = Math.min(
this.returnPump.measurements.type('flow').variant('measured').position(POSITIONS.AT_EQUIPMENT).getCurrentValue(),
F_s,
);
}
const F_so = F_s - F_sr;
// effluent
const Cs_eff = cloneArray(this.Cs_in);
if (F_s > 0) {
Cs_eff[7] = 0;
Cs_eff[8] = 0;
Cs_eff[9] = 0;
Cs_eff[10] = 0;
Cs_eff[11] = 0;
Cs_eff[12] = 0;
}
if (F_s > 0) for (let i = 7; i <= 12; i++) Cs_eff[i] = 0;
// sludge
const Cs_s = cloneArray(this.Cs_in);
if (F_s > 0) {
Cs_s[7] = this.F_in * this.Cs_in[7] / F_s;
Cs_s[8] = this.F_in * this.Cs_in[8] / F_s;
Cs_s[9] = this.F_in * this.Cs_in[9] / F_s;
Cs_s[10] = this.F_in * this.Cs_in[10] / F_s;
Cs_s[11] = this.F_in * this.Cs_in[11] / F_s;
Cs_s[12] = this.F_in * this.Cs_in[12] / F_s;
}
if (F_s > 0) for (let i = 7; i <= 12; i++) Cs_s[i] = this.F_in * this.Cs_in[i] / F_s;
const ts = Date.now();
return [
{ topic: "Fluent", payload: { inlet: 0, F: F_eff, C: Cs_eff }, timestamp: Date.now() },
{ topic: "Fluent", payload: { inlet: 1, F: F_so, C: Cs_s }, timestamp: Date.now() },
{ topic: "Fluent", payload: { inlet: 2, F: F_sr, C: Cs_s }, timestamp: Date.now() }
{ topic: 'Fluent', payload: { inlet: 0, F: F_eff, C: Cs_eff }, timestamp: ts },
{ topic: 'Fluent', payload: { inlet: 1, F: F_so, C: Cs_s }, timestamp: ts },
{ topic: 'Fluent', payload: { inlet: 2, F: F_sr, C: Cs_s }, timestamp: ts },
];
}
registerChild(child, softwareType) {
if(!child) {
this.logger.error(`Invalid ${softwareType} child provided.`);
return;
}
switch (softwareType) {
case "measurement":
this.logger.debug(`Registering measurement child...`);
this._connectMeasurement(child);
break;
case "reactor":
this.logger.debug(`Registering reactor child...`);
this._connectReactor(child);
break;
case "machine":
this.logger.debug(`Registering machine child...`);
this._connectMachine(child);
break;
default:
this.logger.error(`Unrecognized softwareType: ${softwareType}`);
}
}
_connectMeasurement(measurementChild) {
const position = measurementChild.config.functionality.positionVsParent;
const measurementType = measurementChild.config.asset.type;
const eventName = `${measurementType}.measured.${position}`;
const eventName = `${measurementType}.measured.${String(position).toLowerCase()}`;
// Register event listener for measurement updates
measurementChild.measurements.emitter.on(eventName, (eventData) => {
this.logger.debug(`${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
// Store directly in parent's measurement container
this.measurements
.type(measurementType)
.variant("measured")
.variant('measured')
.position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
this._updateMeasurement(measurementType, eventData.value, position, eventData);
});
}
// Reactor → settler integration: the reactor pushes a `stateChange` event
// on its own emitter (NOT measurements.emitter), so router.onMeasurement
// can't subscribe — we wire the listener manually here, mirroring the
// pre-refactor `_connectReactor`. The settler pulls `getEffluent` rather
// than receiving it pushed; reactor.getEffluent may return an array or a
// single envelope (the 2026-03-02 bug fix preserved both shapes).
_connectReactor(reactorChild) {
if (reactorChild.config.functionality.positionVsParent != POSITIONS.UPSTREAM) {
this.logger.warn("Reactor children of settlers should be upstream.");
if (reactorChild.config.functionality.positionVsParent !== POSITIONS.UPSTREAM) {
this.logger.warn('Reactor children of settlers should be upstream.');
}
this.upstreamReactor = reactorChild;
reactorChild.emitter.on("stateChange", (_eventData) => {
this.logger.debug(`State change of upstream reactor detected.`);
reactorChild.emitter.on('stateChange', () => {
this.logger.debug('State change of upstream reactor detected.');
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();
});
}
_connectMachine(machineChild) {
if (machineChild.config.functionality.positionVsParent == POSITIONS.DOWNSTREAM) {
if (machineChild.config.functionality.positionVsParent === POSITIONS.DOWNSTREAM) {
machineChild.upstreamSource = this;
this.returnPump = machineChild;
return;
}
this.logger.warn(`Failed to register machine child.`);
this.logger.warn('Failed to register machine child.');
}
_updateMeasurement(measurementType, value, _position, _context) {
_updateMeasurement(measurementType, value /*, _position, _context */) {
switch (measurementType) {
case "quantity (tss)":
case 'quantity (tss)':
this.C_TS = value;
break;
this.notifyOutputChanged();
return;
default:
this.logger.error(`Type '${measurementType}' not recognized for measured update.`);
return;
}
}
}
module.exports = { Settler };
// Telemetry snapshot for Port 1 (InfluxDB). Port 0 carries the 3-message
// Fluent stream directly; this scalar view feeds dashboards.
getOutput() {
const streams = this.getEffluent;
return {
...this.measurements.getFlattenedOutput?.(),
F_in: this.F_in,
C_TS: this.C_TS,
F_eff: streams[0].payload.F,
F_surplus: streams[1].payload.F,
F_return: streams[2].payload.F,
};
}
getStatusBadge() {
if (this.F_in <= 0) return statusBadge.idle('no influent');
const streams = this.getEffluent;
const eff = streams[0].payload.F.toFixed(2);
const sur = streams[1].payload.F.toFixed(2);
return statusBadge.compose([`F_in=${this.F_in.toFixed(2)}`, `eff=${eff}`, `surplus=${sur}`], { fill: 'green', shape: 'dot' });
}
}
module.exports = Settler;
module.exports.Settler = Settler;

View File

@@ -0,0 +1,123 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('events');
const Settler = require('../../src/specificClass');
const NUM_SPECIES = 13;
function makeSettler() {
return new Settler({
general: { name: 'TestSettler', id: 'settler-test-1', logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'settler', positionVsParent: 'downstream' },
});
}
test('constructor sets default state', () => {
const s = makeSettler();
assert.equal(s.F_in, 0);
assert.deepEqual(s.Cs_in, new Array(NUM_SPECIES).fill(0));
assert.equal(s.C_TS, 2500);
assert.equal(s.upstreamReactor, null);
assert.equal(s.returnPump, null);
});
test('getEffluent conserves total flow (mass balance)', () => {
const s = makeSettler();
s.F_in = 200;
s.C_TS = 3000;
const C = new Array(NUM_SPECIES).fill(5);
C[12] = 2000;
s.Cs_in = C;
const [eff, sur, ret] = s.getEffluent;
assert.equal(eff.topic, 'Fluent');
assert.ok(Math.abs(eff.payload.F + sur.payload.F + ret.payload.F - 200) < 1e-6);
for (let i = 7; i <= 12; i++) assert.equal(eff.payload.C[i], 0);
});
test('getEffluent clamps F_s to F_in when X_TS exceeds C_TS', () => {
const s = makeSettler();
s.F_in = 100;
s.C_TS = 1000;
s.Cs_in = new Array(NUM_SPECIES).fill(10);
s.Cs_in[12] = 5000;
const [eff] = s.getEffluent;
assert.equal(eff.payload.F, 0);
});
test('reactor stateChange pulls effluent (preserves _connectReactor integration)', () => {
const s = makeSettler();
let outputChanges = 0;
s.emitter.on('output-changed', () => outputChanges++);
const reactor = {
config: { general: { name: 'r', id: 'r-1' }, functionality: { positionVsParent: 'upstream' } },
emitter: new EventEmitter(),
measurements: { emitter: new EventEmitter() },
// Mirror the array shape the reactor produces in production.
get getEffluent() {
const C = new Array(NUM_SPECIES).fill(2);
C[12] = 3500;
return [{ topic: 'Fluent', payload: { inlet: 0, F: 150, C } }];
},
};
s.router.dispatchRegister(reactor, 'reactor');
reactor.emitter.emit('stateChange');
assert.equal(s.upstreamReactor, reactor);
assert.equal(s.F_in, 150);
assert.equal(s.Cs_in[12], 3500);
assert.ok(outputChanges >= 1, 'reactor stateChange should trigger output-changed');
});
test('reactor stateChange handles single-envelope getEffluent (not array)', () => {
const s = makeSettler();
const reactor = {
config: { general: { name: 'r', id: 'r-1' }, functionality: { positionVsParent: 'upstream' } },
emitter: new EventEmitter(),
measurements: { emitter: new EventEmitter() },
get getEffluent() {
const C = new Array(NUM_SPECIES).fill(1);
C[12] = 800;
return { topic: 'Fluent', payload: { inlet: 0, F: 42, C } };
},
};
s.router.dispatchRegister(reactor, 'reactor');
reactor.emitter.emit('stateChange');
assert.equal(s.F_in, 42);
assert.equal(s.Cs_in[12], 800);
});
test('TSS measurement updates C_TS via _updateMeasurement', () => {
const s = makeSettler();
s._updateMeasurement('quantity (tss)', 7000);
assert.equal(s.C_TS, 7000);
});
test('downstream machine becomes returnPump', () => {
const s = makeSettler();
const pump = {
config: { general: { name: 'pump', id: 'p-1' }, functionality: { positionVsParent: 'downstream' } },
measurements: { emitter: new EventEmitter() },
};
s.router.dispatchRegister(pump, 'machine');
assert.equal(s.returnPump, pump);
assert.equal(pump.upstreamSource, s);
});
test('getStatusBadge returns idle when F_in=0, green when flowing', () => {
const s = makeSettler();
const idle = s.getStatusBadge();
assert.equal(idle.fill, 'blue');
s.F_in = 100;
s.C_TS = 5000;
const C = new Array(NUM_SPECIES).fill(10);
C[12] = 3000;
s.Cs_in = C;
const active = s.getStatusBadge();
assert.equal(active.fill, 'green');
assert.ok(active.text.includes('F_in'));
});

251
wiki/Home.md Normal file
View File

@@ -0,0 +1,251 @@
# settler
> **Reflects code as of `94b6616` · regenerated `2026-05-11` via `npm run wiki:all`**
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
## 1. What this node is
**settler** is an S88 Unit that models a secondary clarifier. It takes the upstream reactor's effluent stream, performs a 13-species TSS mass balance, and splits it into three Fluent envelopes: clarified effluent, surplus sludge, and return sludge. A downstream return pump (rotatingMachine child) draws the return-sludge flow.
## 2. Position in the platform
```mermaid
flowchart LR
upstream[reactor<br/>upstream<br/>Unit]:::unit
settler[settler<br/>Unit]:::unit
downstream[reactor<br/>downstream<br/>Unit]:::unit
return[rotatingMachine<br/>return pump<br/>Equipment]:::equip
tss[measurement<br/>type=quantity (tss)<br/>position=atequipment]:::ctrl
upstream -.stateChange.-> settler
settler -->|Fluent inlet=0,1,2| downstream
return -->|child.register downstream| settler
settler -.F_sr.-> return
tss -->|quantity (tss).measured.atequipment| settler
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000
```
S88 colours: Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
## 3. Capability matrix
| Capability | Status | Notes |
|---|---|---|
| TSS mass-balance split (3 streams) | ✅ | Effluent / surplus / return derived from `F_in * Cs[12] / C_TS`. |
| Particulate zeroing in effluent | ✅ | Species 712 set to 0 in effluent when `F_s > 0`. |
| Particulate concentration in sludge | ✅ | Species 712 scaled by `F_in / F_s` in surplus + return. |
| Return-pump flow draw | ✅ | `F_sr` = min(pump flow, F_s). Surplus = F_s F_sr. |
| F_s clamp to F_in | ✅ | Prevents negative effluent when X_TS_in > C_TS. |
| Manual influent override | ✅ | `data.influent` lets ops supply `{ F, C }` directly. |
| Multiple reactor upstreams | ❌ | Only one `upstreamReactor` slot; last registration wins. |
| Stateful FSM | ❌ | Stateless transform — recomputes on every push. |
## 4. Code map
```mermaid
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
nc["buildDomainConfig()<br/>static DomainClass = Settler<br/>static commands"]
end
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
sc["Settler.configure()<br/>ChildRouter rules<br/>getEffluent — TSS split<br/>_connectReactor (manual listener)"]
end
subgraph commands["src/commands/"]
cmds["index.js + handlers.js<br/>data.influent + aliases"]
end
nc --> sc
nc --> cmds
```
| Module | Owns | Read first if you're changing… |
|---|---|---|
| `specificClass.js` | All domain logic: getEffluent split, reactor + machine + measurement wiring, getOutput, getStatusBadge. | Mass-balance math, child wiring, telemetry shape. |
| `commands/` | Single command (`data.influent`) + aliases + payload validation. | Manual-influent topic, new aliases. |
Settler is small enough (~140 LOC) that no concern-split was needed (per P6.6).
## 5. Topic contract
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
<!-- BEGIN AUTOGEN: topic-contract -->
| Canonical topic | Aliases | Payload | Unit | Effect |
|---|---|---|---|---|
| `data.influent` | `influent`, `setInfluent` | `any` | — | Push the influent stream (payload: {F: flow m3/h, C: [concentrations mg/L]}). |
| `child.register` | `registerChild` | `string` | — | Register a child node (typically a measurement) with this settler. |
<!-- END AUTOGEN: topic-contract -->
## 6. Child registration
```mermaid
flowchart LR
subgraph kids["accepted children (softwareType)"]
m["measurement"]:::ctrl
r["reactor<br/>upstream"]:::unit
mach["machine<br/>downstream"]:::equip
end
m -->|"&lt;type&gt;.measured.&lt;position&gt;"| h_m[_connectMeasurement]
r -.stateChange.-> h_r[_connectReactor<br/>manual listener]
mach -->|registered| h_mach[_connectMachine<br/>sets returnPump]
h_r --> pull[upstreamReactor.getEffluent]
pull --> emit[notifyOutputChanged]
classDef ctrl fill:#a9daee,color:#000
classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000
```
| softwareType | filter | wired to | side-effect |
|---|---|---|---|
| `measurement` | any | `_connectMeasurement` | Re-emits on settler's measurements; `quantity (tss)` updates `C_TS`. |
| `reactor` | `positionVsParent=upstream` (warns otherwise) | `_connectReactor` | Stores as `upstreamReactor`; subscribes to its **own** `emitter` (NOT `measurements.emitter`) for `'stateChange'`. |
| `machine` | `positionVsParent=downstream` | `_connectMachine` | Stores as `returnPump`; sets `machine.upstreamSource = settler`. |
### 6.1 Reactor ↔ settler wiring (the load-bearing bit)
The reactor pushes its `stateChange` event on `reactor.emitter`, not `reactor.measurements.emitter`. The standard `router.onMeasurement` path can't subscribe — so settler attaches the listener manually inside `_connectReactor`. On each fire, settler **pulls** the upstream effluent via `reactor.getEffluent` and copies it into `this.F_in` + `this.Cs_in`.
`reactor.getEffluent` historically returned either an array (3-stream) or a single envelope — the 2026-03-02 `_connectReactor` fix preserves both shapes:
```js
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();
```
If you change the reactor's effluent shape, this is the line to update.
## 7. Lifecycle — what one stateChange does
```mermaid
sequenceDiagram
participant reactor as upstream reactor
participant settler as settler
participant pump as return pump child
participant downstream as downstream consumer
participant out as Port-0 output
reactor->>settler: emitter.emit('stateChange')
settler->>reactor: pull getEffluent
reactor-->>settler: { F, C[13] }
settler->>settler: F_in = F, Cs_in = C
settler->>pump: read measurements.flow.measured.atequipment
pump-->>settler: returnFlow
settler->>settler: getEffluent — split into 3 inlets
settler->>out: [Fluent inlet=0, Fluent inlet=1, Fluent inlet=2]
out->>downstream: 3 msgs on Port 0
```
The split runs lazily inside `getEffluent`: each call recomputes from current `F_in`, `Cs_in`, `C_TS`, and the pump's reported `flow.measured.atequipment`.
## 8. Data model — `getOutput()`
Port 0 carries the 3-envelope Fluent stream directly; Port 1 (this snapshot) is the scalar dashboard view.
<!-- BEGIN AUTOGEN: data-model -->
| Key | Type | Unit | Sample |
|---|---|---|---|
| `C_TS` | number | — | `2500` |
| `F_eff` | number | — | `0` |
| `F_in` | number | — | `0` |
| `F_return` | number | — | `0` |
| `F_surplus` | number | — | `0` |
<!-- END AUTOGEN: data-model -->
**Concrete sample** (typical operating point):
```json
{
"F_in": 1000,
"C_TS": 2500,
"F_eff": 850.0,
"F_surplus": 50.0,
"F_return": 100.0
}
```
`F_eff + F_surplus + F_return = F_in` always holds (modulo float). Particulates concentrate by `F_in / F_s` in the surplus + return streams.
## 9. Configuration — editor form ↔ config keys
```mermaid
flowchart TB
subgraph editor["Node-RED editor form"]
f1[Name]
f2[Process Output Format]
f3[Database Output Format]
f4[Logging level]
f5[Position vs parent]
end
subgraph config["Domain config / nodeClass"]
c1[general.name]
c2[processOutputFormat → nodeClass]
c3[dbaseOutputFormat → nodeClass]
c4[general.logging.logLevel]
c5[functionality.positionVsParent]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
```
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| Name | `general.name` | `Settler` | string | display + Port-1 topic |
| Process Output Format | `processOutputFormat` (nodeClass) | `process` | `process` / `json` / `csv` | Port-0 serialisation |
| Database Output Format | `dbaseOutputFormat` (nodeClass) | `influxdb` | `influxdb` / `json` / `csv` | Port-1 serialisation |
| Logging level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error` | logger threshold |
| Position vs parent | `functionality.positionVsParent` | `downstream` | `upstream` / `atEquipment` / `downstream` | parent-side routing |
| Software type | `functionality.softwareType` | `settler` | string | parent-side router filter |
| ID | `general.id` | `null` | nullable string | child registration key |
Settler has no operational process config of its own — all behaviour is driven by runtime state (`F_in`, `Cs_in`, `C_TS`). Tune behaviour by feeding it different reactor effluents or `C_TS` measurements.
## 10. State chart
Not applicable — settler is stateless. There is no FSM. Every trigger (`stateChange` from the reactor, `data.influent`, or a `quantity (tss)` update) causes a fresh recompute of the 3 Fluent streams from the current runtime state and the split immediately re-emits.
## 11. Examples
| Tier | File | What it shows | Status |
|---|---|---|---|
| Basic | `examples/basic.flow.json` | Inject `data.influent`, watch 3-stream split | ✅ in repo |
| Integration | `examples/integration.flow.json` | reactor (upstream) + settler + return pump | ✅ in repo |
| Edge | `examples/edge.flow.json` | F_s clamp + zero-influent fallback | ✅ in repo |
One screenshot per tier where helpful. PNG ≤ 200 KB under `wiki/_partial-screenshots/settler/`.
## 12. Debug recipes
| Symptom | First thing to check | Where to look |
|---|---|---|
| `F_eff` negative or NaN | `C_TS` zero or `Cs_in[12]` huge. F_s clamp should prevent — confirm clamp present. | `specificClass.js → getEffluent` |
| Settler never updates after reactor changes | Reactor child not on `'upstream'` position, or listener attached to wrong emitter. | `_connectReactor` — listens on `reactor.emitter`, NOT `measurements.emitter`. |
| Return-sludge flow = 0 | `returnPump.measurements.type('flow').variant('measured').position('atEquipment')` empty. Wire a flow measurement on the pump. | `_connectMachine`, pump measurement chain. |
| 3 Fluent envelopes not arriving downstream | `payload.inlet` selector on the downstream reactor mismatches (0=eff, 1=surplus, 2=return). | downstream reactor's `data.fluent` handler. |
| `quantity (tss)` updates don't change `C_TS` | Measurement child's `asset.type` not `quantity (tss)` exactly. | `_updateMeasurement` switch. |
## 13. When you would NOT use this node
- Use settler for **secondary clarification** downstream of a biological reactor. For primary sedimentation (raw sewage), the species-7-12 zeroing is wrong — model that as a separate process.
- Don't use settler as a generic mass-balance node — the 13-species ASM3 vector is hard-coded.
- Skip settler when the downstream reactor doesn't need a 3-stream split (e.g. single-tank SBR). A direct reactor → reactor wire is lighter.
## 14. Known limitations / current issues
| # | Issue | Tracked in |
|---|---|---|
| 1 | Only one `upstreamReactor` slot — multi-reactor settlers not supported (last registration wins). | `_connectReactor` |
| 2 | TSS mass balance uses index 12 (`X_TS`) hard-coded — coupled tightly to ASM3 species ordering. | `getEffluent`, `_updateMeasurement` |
| 3 | Settler depends on `mathjs` (~14 MB install) but only uses it transitively via reactor; no direct mathjs call in settler code. | `package.json` |
| 4 | No flow-balance check at runtime — if particulate concentration drives F_s above F_in, the clamp masks an upstream bug rather than warning. | `getEffluent` |
| 5 | Editor colour is `#e4a363` (orange) in `settler.html` but S88 Unit level requires `#50a8d9` (blue). Diagrams in this wiki use the correct `#50a8d9`. Colour cleanup tracked in `.claude/rules/node-red-flow-layout.md` §16. | `settler.html` |