Compare commits

...

15 Commits

Author SHA1 Message Date
znetsixe
0ba28b9cdf style: palette swatch → (domain-hue redesign 2026-05-21)
Sidebar swatch now follows function family rather than S88 level, so the
palette is visually identifiable instead of monochromatically blue. Editor-group
rectangles in flow.json still follow S88 — only the registerType color changed.
Full table + rationale: superproject .claude/rules/node-red-flow-layout.md §10.0
and .claude/refactor/OPEN_QUESTIONS.md (2026-05-21 entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:05:56 +02:00
znetsixe
70acef22d5 fix: settler icon colour + CONTRACT child.register row
- settler.html node colour was #e4a363 (legacy orange); aligned to
  S88 Unit blue #50a8d9 per .claude/rules/node-red-flow-layout.md §16
  cleanup list. Existing flows are unaffected — colour is editor-only.
- CONTRACT.md: add `child.register` row; previously mentioned only in
  prose ("not listed here"), but contract-verify enforces the table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:59:11 +02:00
znetsixe
93ea000734 docs(wiki): regenerate topic-contract AUTOGEN block via wiki-gen
Replaces the agent-written placeholder inside Reference-Contracts.md with
the authoritative table generated from src/commands/index.js. Both the
BEGIN and END markers are normalized to the canonical form used by
`@evolv/wiki-gen`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:49 +02:00
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
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
znetsixe
b199663c77 docs: add CLAUDE.md with S88 classification and superproject rule reference
References the flow-layout rule set in the EVOLV superproject
(.claude/rules/node-red-flow-layout.md) so Claude Code sessions working
in this repo know the S88 level, colour, and placement lane for this node.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:47:26 +02:00
znetsixe
518262ac98 Merge remote-tracking branch 'origin/main' into dev-Rene
# Conflicts:
#	settler.html
#	src/nodeClass.js
#	src/specificClass.js
2026-03-31 16:26:04 +02:00
root
9af42bdc4c Harden settler runtime and scaffold tests 2026-03-31 14:26:10 +02:00
Rene De Ren
a650ca4856 Expose output format selectors in editor 2026-03-12 16:39:25 +01:00
31 changed files with 1736 additions and 538 deletions

272
.gitignore vendored
View File

@@ -1,136 +1,136 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

40
CLAUDE.md Normal file
View File

@@ -0,0 +1,40 @@
# settler — Claude Code context
Secondary clarifier / sludge settling.
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
## S88 classification
| Level | Colour | Placement lane |
|---|---|---|
| **Unit** | `#50a8d9` | L4 |
## Flow layout rules
When wiring this node into a multi-node demo or production flow, follow the
placement rule set in the **EVOLV superproject**:
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
Key points for this node:
- Place on lane **L4** (x-position per the lane table in the rule).
- 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.

52
CONTRACT.md Normal file
View File

@@ -0,0 +1,52 @@
# 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. |
| `child.register` | `registerChild` | `string` — the child node's Node-RED id | Resolves the child via `RED.nodes.getNode` and registers it through `childRegistrationUtils` at the supplied `msg.positionVsParent`. |
Aliases log a one-time deprecation warning the first time they fire.
## 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`.

380
LICENSE
View File

@@ -1,190 +1,190 @@
EUROPEAN UNION PUBLIC LICENCE v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the EUPL) applies to the Work (as defined below) which is provided under the
terms of this Licence. Any use of the Work, other than as authorised under this Licence is prohibited (to the extent such
use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as defined below) has placed the following
notice immediately following the copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
1.Definitions
In this Licence, the following terms have the following meaning:
The Licence:this Licence.
The Original Work:the work or software distributed or communicated by the Licensor under this Licence, available
as Source Code and also as Executable Code as the case may be.
Derivative Works:the works or software that could be created by the Licensee, based upon the Original Work or
modifications thereof. This Licence does not define the extent of modification or dependence on the Original Work
required in order to classify a work as a Derivative Work; this extent is determined by copyright law applicable in
the country mentioned in Article 15.
The Work:the Original Work or its Derivative Works.
The Source Code:the human-readable form of the Work which is the most convenient for people to study and
modify.
The Executable Code:any code which has generally been compiled and which is meant to be interpreted by
a computer as a program.
The Licensor:the natural or legal person that distributes or communicates the Work under the Licence.
Contributor(s):any natural or legal person who modifies the Work under the Licence, or otherwise contributes to
the creation of a Derivative Work.
The Licensee or You:any natural or legal person who makes any usage of the Work under the terms of the
Licence.
Distribution or Communication:any act of selling, giving, lending, renting, distributing, communicating,
transmitting, or otherwise making available, online or offline, copies of the Work or providing access to its essential
functionalities at the disposal of any other natural or legal person.
2.Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, sublicensable licence to do the following, for
the duration of copyright vested in the Original Work:
— use the Work in any circumstance and for all usage,
— reproduce the Work,
— modify the Work, and make Derivative Works based upon the Work,
— communicate to the public, including the right to make available or display the Work or copies thereof to the public
and perform publicly, as the case may be, the Work,
— distribute the Work or copies thereof,
— lend and rent the Work or copies thereof,
— sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now known or later invented, as far as the
applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to exercise his moral right to the extent allowed
by law in order to make effective the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to any patents held by the Licensor, to the
extent necessary to make use of the rights granted on the Work under this Licence.
3.Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as Executable Code. If the Work is provided as
Executable Code, the Licensor provides in addition a machine-readable copy of the Source Code of the Work along with
each copy of the Work that the Licensor distributes or indicates, in a notice following the copyright notice attached to
the Work, a repository where the Source Code is easily and freely accessible for as long as the Licensor continues to
distribute or communicate the Work.
4.Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits from any exception or limitation to the
exclusive rights of the rights owners in the Work, of the exhaustion of those rights or of other applicable limitations
thereto.
5.Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and obligations imposed on the Licensee. Those
obligations are the following:
Attribution right: The Licensee shall keep intact all copyright, patent or trademarks notices and all notices that refer to
the Licence and to the disclaimer of warranties. The Licensee must include a copy of such notices and a copy of the
Licence with every copy of the Work he/she distributes or communicates. The Licensee must cause any Derivative Work
to carry prominent notices stating that the Work has been modified and the date of modification.
Copyleft clause: If the Licensee distributes or communicates copies of the Original Works or Derivative Works, this
Distribution or Communication will be done under the terms of this Licence or of a later version of this Licence unless
the Original Work is expressly distributed only under this version of the Licence — for example by communicating
EUPL v. 1.2 only. The Licensee (becoming Licensor) cannot offer or impose any additional terms or conditions on the
Work or Derivative Work that alter or restrict the terms of the Licence.
Compatibility clause: If the Licensee Distributes or Communicates Derivative Works or copies thereof based upon both
the Work and another work licensed under a Compatible Licence, this Distribution or Communication can be done
under the terms of this Compatible Licence. For the sake of this clause, Compatible Licence refers to the licences listed
in the appendix attached to this Licence. Should the Licensee's obligations under the Compatible Licence conflict with
his/her obligations under this Licence, the obligations of the Compatible Licence shall prevail.
Provision of Source Code: When distributing or communicating copies of the Work, the Licensee will provide
a machine-readable copy of the Source Code or indicate a repository where this Source will be easily and freely available
for as long as the Licensee continues to distribute or communicate the Work.
Legal Protection: This Licence does not grant permission to use the trade names, trademarks, service marks, or names
of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and
reproducing the content of the copyright notice.
6.Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted hereunder is owned by him/her or
licensed to him/her and that he/she has the power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings to the Work are owned by him/her or
licensed to him/her and that he/she has the power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent Contributors grant You a licence to their contributions
to the Work, under the terms of this Licence.
7.Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous Contributors. It is not a finished work
and may therefore contain defects or bugs inherent to this type of development.
For the above reason, the Work is provided under the Licence on an as is basis and without warranties of any kind
concerning the Work, including without limitation merchantability, fitness for a particular purpose, absence of defects or
errors, accuracy, non-infringement of intellectual property rights other than copyright as stated in Article 6 of this
Licence.
This disclaimer of warranty is an essential part of the Licence and a condition for the grant of any rights to the Work.
8.Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural persons, the Licensor will in no event be
liable for any direct or indirect, material or moral, damages of any kind, arising out of the Licence or of the use of the
Work, including without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, loss
of data or any commercial damage, even if the Licensor has been advised of the possibility of such damage. However,
the Licensor will be liable under statutory product liability laws as far such laws apply to the Work.
9.Additional agreements
While distributing the Work, You may choose to conclude an additional agreement, defining obligations or services
consistent with this Licence. However, if accepting obligations, You may act only on your own behalf and on your sole
responsibility, not on behalf of the original Licensor or any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against such Contributor by
the fact You have accepted any warranty or additional liability.
10.Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon I agree placed under the bottom of a window
displaying the text of this Licence or by affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable acceptance of this Licence and all of its terms
and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and conditions by exercising any rights granted to You
by Article 2 of this Licence, such as the use of the Work, the creation by You of a Derivative Work or the Distribution
or Communication by You of the Work or copies thereof.
11.Information to the public
In case of any Distribution or Communication of the Work by means of electronic communication by You (for example,
by offering to download the Work from a remote location) the distribution channel or media (for example, a website)
must at least provide to the public the information requested by the applicable law regarding the Licensor, the Licence
and the way it may be accessible, concluded, stored and reproduced by the Licensee.
12.Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon any breach by the Licensee of the terms
of the Licence.
Such a termination will not terminate the licences of any person who has received the Work from the Licensee under
the Licence, provided such persons remain in full compliance with the Licence.
13.Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete agreement between the Parties as to the
Work.
If any provision of the Licence is invalid or unenforceable under applicable law, this will not affect the validity or
enforceability of the Licence as a whole. Such provision will be construed or reformed so as necessary to make it valid
and enforceable.
The European Commission may publish other linguistic versions or new versions of this Licence or updated versions of
the Appendix, so far this is required and reasonable, without reducing the scope of the rights granted by the Licence.
New versions of the Licence will be published with a unique version number.
All linguistic versions of this Licence, approved by the European Commission, have identical value. Parties can take
advantage of the linguistic version of their choice.
14.Jurisdiction
Without prejudice to specific agreement between parties,
— any litigation resulting from the interpretation of this License, arising between the European Union institutions,
bodies, offices or agencies, as a Licensor, and any Licensee, will be subject to the jurisdiction of the Court of Justice
of the European Union, as laid down in article 272 of the Treaty on the Functioning of the European Union,
— any litigation arising between other parties and resulting from the interpretation of this License, will be subject to
the exclusive jurisdiction of the competent court where the Licensor resides or conducts its primary business.
15.Applicable Law
Without prejudice to specific agreement between parties,
— this Licence shall be governed by the law of the European Union Member State where the Licensor has his seat,
resides or has his registered office,
— this licence shall be governed by Belgian law if the Licensor has no seat, residence or registered office inside
a European Union Member State.
Appendix
Compatible Licences according to Article 5 EUPL are:
— GNU General Public License (GPL) v. 2, v. 3
— GNU Affero General Public License (AGPL) v. 3
— Open Software License (OSL) v. 2.1, v. 3.0
— Eclipse Public License (EPL) v. 1.0
— CeCILL v. 2.0, v. 2.1
— Mozilla Public Licence (MPL) v. 2
— GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
— Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for works other than software
— European Union Public Licence (EUPL) v. 1.1, v. 1.2
— Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the above licences without producing
a new version of the EUPL, as long as they provide the rights granted in Article 2 of this Licence and protect the
covered Source Code from exclusive appropriation.
All other changes or additions to this Appendix require the production of a new EUPL version.
EUROPEAN UNION PUBLIC LICENCE v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the EUPL) applies to the Work (as defined below) which is provided under the
terms of this Licence. Any use of the Work, other than as authorised under this Licence is prohibited (to the extent such
use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as defined below) has placed the following
notice immediately following the copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
1.Definitions
In this Licence, the following terms have the following meaning:
The Licence:this Licence.
The Original Work:the work or software distributed or communicated by the Licensor under this Licence, available
as Source Code and also as Executable Code as the case may be.
Derivative Works:the works or software that could be created by the Licensee, based upon the Original Work or
modifications thereof. This Licence does not define the extent of modification or dependence on the Original Work
required in order to classify a work as a Derivative Work; this extent is determined by copyright law applicable in
the country mentioned in Article 15.
The Work:the Original Work or its Derivative Works.
The Source Code:the human-readable form of the Work which is the most convenient for people to study and
modify.
The Executable Code:any code which has generally been compiled and which is meant to be interpreted by
a computer as a program.
The Licensor:the natural or legal person that distributes or communicates the Work under the Licence.
Contributor(s):any natural or legal person who modifies the Work under the Licence, or otherwise contributes to
the creation of a Derivative Work.
The Licensee or You:any natural or legal person who makes any usage of the Work under the terms of the
Licence.
Distribution or Communication:any act of selling, giving, lending, renting, distributing, communicating,
transmitting, or otherwise making available, online or offline, copies of the Work or providing access to its essential
functionalities at the disposal of any other natural or legal person.
2.Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, sublicensable licence to do the following, for
the duration of copyright vested in the Original Work:
— use the Work in any circumstance and for all usage,
— reproduce the Work,
— modify the Work, and make Derivative Works based upon the Work,
— communicate to the public, including the right to make available or display the Work or copies thereof to the public
and perform publicly, as the case may be, the Work,
— distribute the Work or copies thereof,
— lend and rent the Work or copies thereof,
— sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now known or later invented, as far as the
applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to exercise his moral right to the extent allowed
by law in order to make effective the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to any patents held by the Licensor, to the
extent necessary to make use of the rights granted on the Work under this Licence.
3.Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as Executable Code. If the Work is provided as
Executable Code, the Licensor provides in addition a machine-readable copy of the Source Code of the Work along with
each copy of the Work that the Licensor distributes or indicates, in a notice following the copyright notice attached to
the Work, a repository where the Source Code is easily and freely accessible for as long as the Licensor continues to
distribute or communicate the Work.
4.Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits from any exception or limitation to the
exclusive rights of the rights owners in the Work, of the exhaustion of those rights or of other applicable limitations
thereto.
5.Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and obligations imposed on the Licensee. Those
obligations are the following:
Attribution right: The Licensee shall keep intact all copyright, patent or trademarks notices and all notices that refer to
the Licence and to the disclaimer of warranties. The Licensee must include a copy of such notices and a copy of the
Licence with every copy of the Work he/she distributes or communicates. The Licensee must cause any Derivative Work
to carry prominent notices stating that the Work has been modified and the date of modification.
Copyleft clause: If the Licensee distributes or communicates copies of the Original Works or Derivative Works, this
Distribution or Communication will be done under the terms of this Licence or of a later version of this Licence unless
the Original Work is expressly distributed only under this version of the Licence — for example by communicating
EUPL v. 1.2 only. The Licensee (becoming Licensor) cannot offer or impose any additional terms or conditions on the
Work or Derivative Work that alter or restrict the terms of the Licence.
Compatibility clause: If the Licensee Distributes or Communicates Derivative Works or copies thereof based upon both
the Work and another work licensed under a Compatible Licence, this Distribution or Communication can be done
under the terms of this Compatible Licence. For the sake of this clause, Compatible Licence refers to the licences listed
in the appendix attached to this Licence. Should the Licensee's obligations under the Compatible Licence conflict with
his/her obligations under this Licence, the obligations of the Compatible Licence shall prevail.
Provision of Source Code: When distributing or communicating copies of the Work, the Licensee will provide
a machine-readable copy of the Source Code or indicate a repository where this Source will be easily and freely available
for as long as the Licensee continues to distribute or communicate the Work.
Legal Protection: This Licence does not grant permission to use the trade names, trademarks, service marks, or names
of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and
reproducing the content of the copyright notice.
6.Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted hereunder is owned by him/her or
licensed to him/her and that he/she has the power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings to the Work are owned by him/her or
licensed to him/her and that he/she has the power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent Contributors grant You a licence to their contributions
to the Work, under the terms of this Licence.
7.Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous Contributors. It is not a finished work
and may therefore contain defects or bugs inherent to this type of development.
For the above reason, the Work is provided under the Licence on an as is basis and without warranties of any kind
concerning the Work, including without limitation merchantability, fitness for a particular purpose, absence of defects or
errors, accuracy, non-infringement of intellectual property rights other than copyright as stated in Article 6 of this
Licence.
This disclaimer of warranty is an essential part of the Licence and a condition for the grant of any rights to the Work.
8.Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural persons, the Licensor will in no event be
liable for any direct or indirect, material or moral, damages of any kind, arising out of the Licence or of the use of the
Work, including without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, loss
of data or any commercial damage, even if the Licensor has been advised of the possibility of such damage. However,
the Licensor will be liable under statutory product liability laws as far such laws apply to the Work.
9.Additional agreements
While distributing the Work, You may choose to conclude an additional agreement, defining obligations or services
consistent with this Licence. However, if accepting obligations, You may act only on your own behalf and on your sole
responsibility, not on behalf of the original Licensor or any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against such Contributor by
the fact You have accepted any warranty or additional liability.
10.Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon I agree placed under the bottom of a window
displaying the text of this Licence or by affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable acceptance of this Licence and all of its terms
and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and conditions by exercising any rights granted to You
by Article 2 of this Licence, such as the use of the Work, the creation by You of a Derivative Work or the Distribution
or Communication by You of the Work or copies thereof.
11.Information to the public
In case of any Distribution or Communication of the Work by means of electronic communication by You (for example,
by offering to download the Work from a remote location) the distribution channel or media (for example, a website)
must at least provide to the public the information requested by the applicable law regarding the Licensor, the Licence
and the way it may be accessible, concluded, stored and reproduced by the Licensee.
12.Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon any breach by the Licensee of the terms
of the Licence.
Such a termination will not terminate the licences of any person who has received the Work from the Licensee under
the Licence, provided such persons remain in full compliance with the Licence.
13.Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete agreement between the Parties as to the
Work.
If any provision of the Licence is invalid or unenforceable under applicable law, this will not affect the validity or
enforceability of the Licence as a whole. Such provision will be construed or reformed so as necessary to make it valid
and enforceable.
The European Commission may publish other linguistic versions or new versions of this Licence or updated versions of
the Appendix, so far this is required and reasonable, without reducing the scope of the rights granted by the Licence.
New versions of the Licence will be published with a unique version number.
All linguistic versions of this Licence, approved by the European Commission, have identical value. Parties can take
advantage of the linguistic version of their choice.
14.Jurisdiction
Without prejudice to specific agreement between parties,
— any litigation resulting from the interpretation of this License, arising between the European Union institutions,
bodies, offices or agencies, as a Licensor, and any Licensee, will be subject to the jurisdiction of the Court of Justice
of the European Union, as laid down in article 272 of the Treaty on the Functioning of the European Union,
— any litigation arising between other parties and resulting from the interpretation of this License, will be subject to
the exclusive jurisdiction of the competent court where the Licensor resides or conducts its primary business.
15.Applicable Law
Without prejudice to specific agreement between parties,
— this Licence shall be governed by the law of the European Union Member State where the Licensor has his seat,
resides or has his registered office,
— this licence shall be governed by Belgian law if the Licensor has no seat, residence or registered office inside
a European Union Member State.
Appendix
Compatible Licences according to Article 5 EUPL are:
— GNU General Public License (GPL) v. 2, v. 3
— GNU Affero General Public License (AGPL) v. 3
— Open Software License (OSL) v. 2.1, v. 3.0
— Eclipse Public License (EPL) v. 1.0
— CeCILL v. 2.0, v. 2.1
— Mozilla Public Licence (MPL) v. 2
— GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
— Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for works other than software
— European Union Public Licence (EUPL) v. 1.1, v. 1.2
— Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the above licences without producing
a new version of the EUPL, as long as they provide the rights granted in Article 2 of this Licence and protect the
covered Source Code from exclusive appropriation.
All other changes or additions to this Appendix require the production of a new EUPL version.

View File

@@ -1,3 +1,3 @@
# Sludge Settler
# Sludge Settler
Sludge settler node

8
examples/README.md Normal file
View File

@@ -0,0 +1,8 @@
# settler Example Flows
Import-ready Node-RED examples for settler.
## Files
- basic.flow.json
- integration.flow.json
- edge.flow.json

6
examples/basic.flow.json Normal file
View File

@@ -0,0 +1,6 @@
[
{"id":"settler_basic_tab","type":"tab","label":"settler basic","disabled":false,"info":"settler basic example"},
{"id":"settler_basic_node","type":"settler","z":"settler_basic_tab","name":"settler basic","x":420,"y":180,"wires":[["settler_basic_dbg"]]},
{"id":"settler_basic_inj","type":"inject","z":"settler_basic_tab","name":"basic trigger","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"ping","payload":"1","payloadType":"str","x":160,"y":180,"wires":[["settler_basic_node"]]},
{"id":"settler_basic_dbg","type":"debug","z":"settler_basic_tab","name":"settler basic debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
]

6
examples/edge.flow.json Normal file
View File

@@ -0,0 +1,6 @@
[
{"id":"settler_edge_tab","type":"tab","label":"settler edge","disabled":false,"info":"settler edge example"},
{"id":"settler_edge_node","type":"settler","z":"settler_edge_tab","name":"settler edge","x":420,"y":180,"wires":[["settler_edge_dbg"]]},
{"id":"settler_edge_inj","type":"inject","z":"settler_edge_tab","name":"unknown topic","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"doesNotExist","payload":"x","payloadType":"str","x":170,"y":180,"wires":[["settler_edge_node"]]},
{"id":"settler_edge_dbg","type":"debug","z":"settler_edge_tab","name":"settler edge debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
]

View File

@@ -0,0 +1,6 @@
[
{"id":"settler_int_tab","type":"tab","label":"settler integration","disabled":false,"info":"settler integration example"},
{"id":"settler_int_node","type":"settler","z":"settler_int_tab","name":"settler integration","x":420,"y":180,"wires":[["settler_int_dbg"]]},
{"id":"settler_int_inj","type":"inject","z":"settler_int_tab","name":"registerChild","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"registerChild","payload":"example-child-id","payloadType":"str","x":170,"y":180,"wires":[["settler_int_node"]]},
{"id":"settler_int_dbg","type":"debug","z":"settler_int_tab","name":"settler integration debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":680,"y":180,"wires":[]}
]

View File

@@ -16,7 +16,10 @@
"author": "P.R. van der Wilt",
"main": "settler.js",
"scripts": {
"test": "node settler.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": {

View File

@@ -3,9 +3,11 @@
<script type="text/javascript">
RED.nodes.registerType("settler", {
category: "EVOLV",
color: "#e4a363",
color: "#8FAD3F",
defaults: {
name: { value: "" },
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
enableLog: { value: false },
logLevel: { value: "error" },
@@ -55,6 +57,25 @@
<input type="text" id="node-input-name" placeholder="Name">
</div>
<h3>Output Formats</h3>
<div class="form-row">
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
<select id="node-input-processOutputFormat" style="width:60%;">
<option value="process">process</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<div class="form-row">
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;">
<option value="influxdb">influxdb</option>
<option value="frost">frost</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<!-- Logger fields injected here -->
<div id="logger-fields-placeholder"></div>
@@ -65,4 +86,4 @@
<script type="text/html" data-help-name="settler">
<p>Settling tank</p>
</script>
</script>

View File

@@ -1,26 +1,26 @@
const nameOfNode = "settler"; // name of the node, should match file name and node type in Node-RED
const nodeClass = require('./src/nodeClass.js'); // node class
const { MenuManager } = require('generalFunctions');
module.exports = function (RED) {
// Register the node type
RED.nodes.registerType(nameOfNode, function (config) {
// Initialize the Node-RED node first
RED.nodes.createNode(this, config);
// Then create your custom class and attach it
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
});
const menuMgr = new MenuManager();
// Serve /settler/menu.js
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
try {
const script = menuMgr.createEndpoint(nameOfNode, ['logger', 'position']);
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating menu: ${err.message}`);
}
});
const nameOfNode = "settler"; // name of the node, should match file name and node type in Node-RED
const nodeClass = require('./src/nodeClass.js'); // node class
const { MenuManager } = require('generalFunctions');
module.exports = function (RED) {
// Register the node type
RED.nodes.registerType(nameOfNode, function (config) {
// Initialize the Node-RED node first
RED.nodes.createNode(this, config);
// Then create your custom class and attach it
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
});
const menuMgr = new MenuManager();
// Serve /settler/menu.js
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
try {
const script = menuMgr.createEndpoint(nameOfNode, ['logger', 'position']);
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating menu: ${err.message}`);
}
});
};

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,103 +1,33 @@
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) => {
switch (msg.topic) {
case 'registerChild': {
// Register this node as a parent of the child node
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
this.source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
break;
}
default:
this.source.logger.warn(`Unknown topic: ${msg.topic}`);
}
if (done) {
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);
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]);
}
}
module.exports = nodeClass;
module.exports = nodeClass;

View File

@@ -1,144 +1,146 @@
const { childRegistrationUtils, logger, MeasurementContainer, POSITIONS } = require('generalFunctions');
const EventEmitter = require('events');
'use strict';
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
const { BaseDomain, POSITIONS, statusBadge } = require('generalFunctions');
function cloneArray(values) {
if (typeof structuredClone === 'function') return structuredClone(values);
return Array.isArray(values) ? [...values] : values;
}
// 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 = structuredClone(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;
}
// sludge
const Cs_s = structuredClone(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;
}
const Cs_eff = cloneArray(this.Cs_in);
if (F_s > 0) for (let i = 7; i <= 12; i++) Cs_eff[i] = 0;
const Cs_s = cloneArray(this.Cs_in);
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.`);
const effluent = this.upstreamReactor.getEffluent[0];
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) {
switch(measurementType) {
case "quantity (tss)":
_updateMeasurement(measurementType, value /*, _position, _context */) {
switch (measurementType) {
case 'quantity (tss)':
this.C_TS = value;
break;
this.notifyOutputChanged();
return;
default:
this.logger.error(`Type '${measurementType}' not recognized for measured update.`);
return;
}
}
// 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;
module.exports.Settler = Settler;

12
test/README.md Normal file
View File

@@ -0,0 +1,12 @@
# settler Test Suite Layout
Required EVOLV layout:
- basic/
- integration/
- edge/
- helpers/
Baseline structure tests:
- basic/structure-module-load.basic.test.js
- integration/structure-examples.integration.test.js
- edge/structure-examples-node-type.edge.test.js

0
test/basic/.gitkeep Normal file
View File

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'));
});

View File

@@ -0,0 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
test('settler module load smoke', () => {
assert.doesNotThrow(() => {
require('../../settler.js');
});
});

0
test/edge/.gitkeep Normal file
View File

View File

@@ -0,0 +1,11 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8'));
test('basic example includes node type settler', () => {
const count = flow.filter((n) => n && n.type === 'settler').length;
assert.equal(count >= 1, true);
});

0
test/helpers/.gitkeep Normal file
View File

View File

View File

@@ -0,0 +1,23 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const dir = path.resolve(__dirname, '../../examples');
function loadJson(file) {
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
}
test('examples package exists for settler', () => {
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing');
}
});
test('example flows are parseable arrays for settler', () => {
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
const parsed = loadJson(file);
assert.equal(Array.isArray(parsed), true);
}
});

134
wiki/Home.md Normal file
View File

@@ -0,0 +1,134 @@
# settler
![code-ref](https://img.shields.io/badge/code--ref-a3583a3-blue) ![s88](https://img.shields.io/badge/S88-Unit-50a8d9) ![status](https://img.shields.io/badge/status-stub--level-lightgrey)
A `settler` models a secondary clarifier &mdash; the sludge-separation stage that sits downstream of a biological reactor. It receives the upstream reactor's effluent stream, performs a 13-species TSS mass balance, and splits the result into three Fluent envelopes: clarified effluent, surplus sludge, and return sludge. A downstream return pump (a `rotatingMachine` registered as `machine` / `downstream`) draws the return-sludge flow.
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only. The shipped `examples/` folder ships stubs only &mdash; production-grade flows are still TODO. Treat this page as informative, not authoritative.
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | Secondary clarifier / sludge settler &mdash; the gravity-separation stage between a biological reactor and its downstream sludge handling |
| S88 level | Unit |
| Use it when | You have a reactor whose effluent must be split into clarified water + return / surplus sludge by a TSS mass balance |
| Don't use it for | Primary sedimentation (species 7&ndash;12 zeroing is wrong), generic mass-balance transforms (the 13-species ASM3 vector is hard-coded), single-tank SBRs that don't need a 3-stream split |
| Children it accepts | `measurement` (any, but `quantity (tss)` is the only one that mutates state), `reactor` (upstream), `machine` (downstream &mdash; the return pump) |
| Parents it talks to | Typically a downstream `reactor` &mdash; the three Fluent streams are routed by `payload.inlet` |
---
## How it fits
```mermaid
flowchart LR
upstream[reactor<br/>upstream<br/>Unit]:::unit
settler[settler<br/>Unit]:::unit
downstream[reactor<br/>downstream<br/>Unit]:::unit
return_pump[rotatingMachine<br/>return pump<br/>Equipment]:::equip
tss[measurement<br/>quantity tss<br/>atequipment]:::ctrl
upstream -.stateChange.-> settler
settler -->|Fluent inlet=0 effluent| downstream
settler -->|Fluent inlet=1 surplus| downstream
settler -->|Fluent inlet=2 return| return_pump
return_pump -->|child.register downstream| settler
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 are anchored in `.claude/rules/node-red-flow-layout.md`. The settler editor colour is currently `#e4a363` (orange) &mdash; tracked as drift in §16 of that rule; diagrams in this wiki use the correct Unit blue (`#50a8d9`).
---
## Try it &mdash; 1-minute demo
> [!IMPORTANT]
> The shipped examples (`examples/basic.flow.json`, `integration.flow.json`, `edge.flow.json`) are skeleton stubs &mdash; they create a settler node and a debug tap, but do not exercise the reactor &rarr; settler &rarr; pump chain. A proper Tier-1 / Tier-2 / Tier-3 example set is on the TODO list; until then this section walks the minimum stimulus.
Import the basic stub, deploy, then drive influent manually:
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/settler/examples/basic.flow.json \
http://localhost:1880/flow
```
After deploy, send one inject:
| Topic | Payload | What it does |
|:---|:---|:---|
| `data.influent` | `{ "F": 1000, "C": [0,0,0,0,0,0,0,0,0,0,0,0,3000] }` | Pushes 1000 m³/h influent with 3000 mg/L total solids (index 12 = `X_TS`). Three Fluent envelopes appear on Port 0 immediately. |
> [!NOTE]
> Pending full node review (2026-05). Real flows (Tier-1 inject only, Tier-2 reactor + settler + pump, Tier-3 dashboard) are not yet shipped. See [Reference &mdash; Examples](Reference-Examples).
---
## The two things you'll send
| Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `data.influent` | `influent`, `setInfluent` | `{F: number, C: number[13]}` &mdash; either field optional | Override the influent stream directly. Triggers a recompute of all three Fluent envelopes. |
| `child.register` | `registerChild` | `string` (child node id) | Register a `measurement`, `reactor`, or `machine` child. Port 2 wiring does this automatically in normal flows. |
That is the entire input contract. Settler has no FSM, no setpoint, no startup sequence &mdash; it is a stateless transform on top of whatever the upstream reactor + measurement children push into it.
---
## What you'll see come out
Sample Port 0 messages (one push, three envelopes):
```json
[
{ "topic": "Fluent", "payload": { "inlet": 0, "F": 850.0, "C": [/*7-12 zeroed*/] }, "timestamp": 1715000000000 },
{ "topic": "Fluent", "payload": { "inlet": 1, "F": 50.0, "C": [/*7-12 concentrated*/] }, "timestamp": 1715000000000 },
{ "topic": "Fluent", "payload": { "inlet": 2, "F": 100.0, "C": [/*7-12 concentrated*/] }, "timestamp": 1715000000000 }
]
```
| `payload.inlet` | Meaning | Particulate species (indices 7&ndash;12) |
|:---:|:---|:---|
| `0` | Clarified effluent | zeroed when `F_s > 0` |
| `1` | Surplus sludge (the fraction the return pump does not draw) | concentrated by `F_in / F_s` |
| `2` | Return sludge (drawn by the downstream return pump, capped at `F_s`) | concentrated by `F_in / F_s` |
Mass balance invariant: `F_eff + F_surplus + F_return = F_in` (modulo float).
Port 1 (InfluxDB) is the scalar dashboard view &mdash; see [Reference &mdash; Contracts](Reference-Contracts#data-model--getoutput-shape). Port 2 carries the one-shot `child.register` upward at startup.
---
## Capability matrix
| Capability | Status | Notes |
|:---|:---:|:---|
| TSS mass-balance split (3 streams) | yes | `F_s = min(F_in * Cs[12] / C_TS, F_in)` &mdash; clamped to prevent negative effluent. |
| Particulate zeroing in effluent | yes | Species 7&ndash;12 set to 0 in `inlet=0` when `F_s > 0`. |
| Particulate concentration in sludge | yes | Species 7&ndash;12 scaled by `F_in / F_s` in `inlet=1` + `inlet=2`. |
| Return-pump flow draw | yes | `F_sr = min(pump flow at equipment, F_s)`. Surplus = `F_s - F_sr`. |
| `F_s` clamp to `F_in` | yes | Prevents negative effluent when `X_TS_in > C_TS`. |
| Manual influent override | yes | `data.influent` lets ops supply `{F, C}` directly. |
| Multiple reactor upstreams | no | Only one `upstreamReactor` slot; last registration wins. |
| Stateful FSM | no | Stateless transform &mdash; recomputes on every trigger. |
| Curve loading / drift / sequence-abort | n/a | Not applicable to a passive split. |
---
## Need more?
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Topic registry, config schema, child registration filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Three-tier code map, reactor &harr; settler wiring (the load-bearing bit), lifecycle, output ports |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows (currently stubs) + the TODO list for production-grade demos |
| [Reference &mdash; Limitations](Reference-Limitations) | When not to use, known limitations, open questions |
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)

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 |

176
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,176 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-a3583a3-blue)
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md`, `src/commands/index.js`, and `generalFunctions/src/configs/settler.json` only.
Full topic contract, configuration schema, and child-registration filters for `settler`. Source of truth: `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/settler.json`. For an intuitive overview, return to the [Home](Home).
---
## Topic contract
The registry lives in `src/commands/index.js`. Each descriptor maps a canonical `msg.topic` to its handler; aliases emit a one-time deprecation warning the first time they fire.
<!-- 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 -->
> [!NOTE]
> Pending full node review (2026-05). The autogen markers above will be populated by a future `npm run wiki:contract` tool. Until then the table is hand-maintained against `src/commands/index.js`.
### Mode / source / action allow-lists
**Not applicable.** Settler has no operational mode, no source allow-list, no action allow-list. Both topics are accepted unconditionally; payload-shape validation lives in the handler itself.
The `data.influent` handler validates:
```js
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];
```
Non-finite or non-numeric `F` is silently ignored. Non-array `C` is silently ignored. Either field may be omitted to update only the other. Negative `F` is **not** rejected &mdash; the downstream `getEffluent` math will produce nonsense but the node will not throw.
---
## Data model &mdash; `getOutput()` shape
Composed each tick by `src/specificClass.js` `getOutput()`. Port 0 carries the 3-message Fluent stream **directly** (not via `getOutput`); Port 1 (this snapshot) is the scalar dashboard view.
<!-- BEGIN AUTOGEN: data-model — populate via wiki-gen tool (TODO) -->
### Scalar keys
| Key | Type | Unit | Source | Notes |
|:---|:---|:---|:---|:---|
| `F_in` | number | m³/h | `host.F_in` | Influent flow. Default 0. |
| `C_TS` | number | mg/L | `host.C_TS` | Target return-sludge concentration. Default 2500. Updated by `quantity (tss)` measurement child. |
| `F_eff` | number | m³/h | `streams[0].payload.F` | Clarified effluent flow. |
| `F_surplus` | number | m³/h | `streams[1].payload.F` | Surplus sludge flow (`F_s - F_sr`). |
| `F_return` | number | m³/h | `streams[2].payload.F` | Return sludge flow (`min(pumpFlow, F_s)`). |
### Per-measurement keys
For every `(type, variant, position)` stored in `this.measurements` MeasurementContainer, the flattened output emits:
```
<type>.<variant>.<position>.<childId>
```
Position labels are normalised to lowercase. The trailing `<childId>` is the registering measurement child's id. Settler does not write its own measurements directly &mdash; every key in this group came from a registered child (the `_connectMeasurement` re-emit).
<!-- END AUTOGEN: data-model -->
### Status badge
`getStatusBadge()` in `src/specificClass.js`:
| Condition | Badge |
|:---|:---|
| `F_in <= 0` | `statusBadge.idle('no influent')` (grey ring) |
| else | green dot, label `F_in=<n.nn> eff=<n.nn> surplus=<n.nn>` (m³/h, 2 dp) |
No state-symbol enumeration &mdash; settler has no FSM.
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/settler.json` plus `settler.html`.
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Name | `general.name` | `"Settler"` | Human-readable name. |
| (auto-assigned) | `general.id` | `null` | Node-RED node id. |
| Default unit | `general.unit` | `null` | Default measurement unit. Currently unused by settler &mdash; child measurements carry their own units. |
| Enable logging | `general.logging.enabled` | `true` | Master switch. |
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
### Functionality (`config.functionality`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| (hidden) | `functionality.softwareType` | `settler` | Constant. Used by the parent's router as a registration filter. |
| (hidden) | `functionality.role` | `Secondary settler for sludge separation` | Documentation string. |
| Position vs parent | `functionality.positionVsParent` | `downstream` | One of `upstream` / `atEquipment` / `downstream`. Settler typically registers as `downstream` against an upstream reactor. |
### Node-RED-side (editor form, not domain config)
| Form field | Stored on | Default | Notes |
|:---|:---|:---|:---|
| Process Output Format | `nodeClass.processOutputFormat` | `process` | `process` / `json` / `csv`. Port-0 serialisation. |
| Database Output Format | `nodeClass.dbaseOutputFormat` | `influxdb` | `influxdb` / `json` / `csv`. Port-1 serialisation. |
`buildDomainConfig()` returns `{}` &mdash; settler does not push any editor-derived values into the domain at start-up. All operational state is runtime (`F_in`, `Cs_in`, `C_TS`).
> [!NOTE]
> Pending full node review (2026-05). The editor form (`settler.html`) is currently colour-drifted to `#e4a363` (orange); should be `#50a8d9` (Unit blue) per `.claude/rules/node-red-flow-layout.md` §16.
### Unit policy
| Quantity | Canonical (internal) | Output (Port 1) | Notes |
|:---|:---|:---|:---|
| Flow (`F_in`, `F_eff`, `F_surplus`, `F_return`) | m³/h | m³/h | No conversion &mdash; settler is unit-agnostic above the storage layer. |
| Concentration (`C_TS`, `Cs_in[*]`) | mg/L | mg/L | Same. |
> [!NOTE]
> Pending full node review (2026-05). Settler does **not** declare a `requireUnitForTypes` policy via MeasurementContainer; verify against the general unit policy before relying on internal canonicalisation.
---
## Child registration
Source: `src/specificClass.js` `configure()` (the `this.router.onRegister(...)` chain) and the three `_connect*` methods.
| Software type | Filter | Wired to | Side-effect |
|:---|:---|:---|:---|
| `measurement` | any (no asset-type / position filter at register time) | `_connectMeasurement(child)` | Subscribes to `<type>.measured.<position>` on the child's `measurements.emitter`. Re-emits on settler's own MeasurementContainer (lets settler's parent see the value). `quantity (tss)` updates `C_TS`; anything else logs an `error` from `_updateMeasurement` but the re-emit still happened. |
| `reactor` | `positionVsParent === 'upstream'` (warns otherwise but still registers) | `_connectReactor(child)` | Stored as `this.upstreamReactor`. Listener attached **manually** to `reactor.emitter` (NOT `measurements.emitter`) for `'stateChange'`; on fire, settler pulls `reactor.getEffluent` and copies `F_in` + `Cs_in`. Handles both array and single-envelope `getEffluent` shapes. |
| `machine` | `positionVsParent === 'downstream'` (warns + skips otherwise) | `_connectMachine(child)` | Stored as `this.returnPump`. Settler reads `returnPump.measurements.type('flow').variant('measured').position('atEquipment').getCurrentValue()` to determine `F_sr`. Sets `machineChild.upstreamSource = this` so the pump can use settler's `inlet=2` Fluent as its suction-side context. |
> [!NOTE]
> Pending full node review (2026-05). The `measurement` filter accepts any asset-type at register time; only the runtime `_updateMeasurement` switch acts on `quantity (tss)`. Other measurement types are silently re-emitted but not consumed. TODO: decide whether this is desired (pass-through telemetry) or a contract gap.
### No virtual children
Unlike `rotatingMachine`, settler does **not** auto-register any virtual measurement children. Every measurement must come from an explicitly wired child node.
---
## 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`:
| `inlet` | Consumer expectation |
|:---|:---|
| `0` (clarified effluent) | Routed onward to the next process unit. |
| `1` (surplus sludge) | Typically routed to a sludge-handling process (digestion, thickening, dewatering). |
| `2` (return sludge) | Drawn back to a reactor inlet or the head of the biological train. |
Multi-reactor settlers are **not supported** &mdash; `this.upstreamReactor` is a single slot; the last `child.register` call wins.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, reactor &harr; settler wiring, mass-balance math |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |

139
wiki/Reference-Examples.md Normal file
View File

@@ -0,0 +1,139 @@
# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-a3583a3-blue)
> [!NOTE]
> Pending full node review (2026-05). The shipped example flows are **stub level**: each is a 4-node skeleton (tab + node + inject + debug) that proves the node loads in Node-RED but does **not** exercise the reactor &rarr; settler &rarr; return-pump chain or the TSS mass-balance math. Production-grade examples are TODO. See [Reference &mdash; Limitations &mdash; Example flows](Reference-Limitations#example-flows-are-stub-level).
---
## Shipped examples
| File | Tier | Dependencies | What it shows |
|:---|:---:|:---|:---|
| `basic.flow.json` | 1 (stub) | EVOLV only | Loads a single settler node, wires a `ping` inject to its input, taps Port 0 (only) to a debug node. Inject payload is the string `"1"` &mdash; will be rejected by `data.influent`'s payload validator (warn logged). Useful only to verify the node type registers. |
| `integration.flow.json` | 2 (stub) | EVOLV only | Same shape; inject sends `topic = registerChild`, payload `example-child-id`. The lookup will fail (no such node) and log a warn. Does **not** exercise reactor or pump wiring. |
| `edge.flow.json` | 3 (stub) | EVOLV only | Same shape; inject sends `topic = doesNotExist`. Verifies the registry rejects unknown topics. |
### Status
| Aspect | State |
|:---|:---|
| Loads in Node-RED on deploy | yes |
| Drives `data.influent` with a valid payload | no |
| Drives the reactor &rarr; settler &rarr; return-pump chain | no |
| Shows the 3-stream Fluent split on Port 0 | no |
| Has a dashboard tier | no |
| Validated against a live Node-RED instance | no |
---
## Loading a flow
### Via the editor
1. Open the Node-RED editor at `http://localhost:1880`.
2. Menu &rarr; Import &rarr; drag the JSON file.
3. Click Deploy.
### Via the Admin API
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/settler/examples/basic.flow.json \
http://localhost:1880/flows
```
---
## TODO &mdash; production-grade example set
The matching `rotatingMachine` repo ships three tiers; settler needs the same. Tracking placeholder:
| Tier | Proposed filename | What it should show |
|:---|:---|:---|
| 1 | `01 - Basic Manual Influent.json` | Single settler + inject sending `data.influent` with a realistic `{F, C}` payload. Three debug taps (one per Port 0 stream by `payload.inlet`). Operator can vary F and watch the split rebalance. |
| 2 | `02 - Reactor and Return Pump.json` | One `reactor` (upstream) + one `settler` + one `rotatingMachine` (return pump, downstream). Auto-registration via Port 2. Drive the reactor with an inject; settler should re-split on every reactor `stateChange`. |
| 3 | `03 - Dashboard Visualization.json` | FlowFuse Dashboard 2.0 page: F_in / F_eff / F_surplus / F_return trend chart, C_TS gauge, status badges per stream. Required: `@flowfuse/node-red-dashboard` installed. |
> [!IMPORTANT]
> **Screenshots needed** once the production-grade examples land. Save as `wiki/_partial-screenshots/settler/01-basic-editor.png`, `02-reactor-pump-editor.png`, `03-dashboard-rendered.png`, &le; 200 KB each.
---
## What the basic example would do (sketch &mdash; not yet shipped)
Operator workflow once a real Tier-1 ships:
1. Deploy the flow.
2. Send `data.influent` with payload:
```json
{ "F": 1000, "C": [0,0,0,0,0,0,0,30,80,400,200,80,3000] }
```
Twelve soluble species at low concentrations + index 12 `X_TS = 3000` mg/L.
3. Observe three Port-0 messages arrive simultaneously, each with `topic = "Fluent"` and `payload.inlet` &isin; {0, 1, 2}.
4. With default `C_TS = 2500` mg/L:
- `F_s = 1000 * 3000 / 2500 = 1200` &mdash; but clamped to `F_in = 1000`. **The clamp fires** &mdash; this is the input edge case [Reference &mdash; Limitations &mdash; no-flow-balance warning](Reference-Limitations#no-flow-balance-warning) refers to.
- `F_eff = 0`. The clarified-effluent envelope carries zero flow but still has its `C` vector (with species 7&ndash;12 zeroed).
- `F_sr` is 0 (no pump wired) &rarr; `F_surplus = 1000`, `F_return = 0`.
5. Send `data.influent` with `X_TS = 1500` mg/L instead. Now `F_s = 600`, `F_eff = 400`, `F_surplus = 600`, `F_return = 0`. Realistic split.
> [!NOTE]
> Pending full node review (2026-05). The clamp-fires behaviour above is intended (per code comment in `getEffluent`) but produces no operator-visible warning. Tracked.
---
## Docker compose snippet
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
```yaml
# docker-compose.yml (extract)
services:
nodered:
build: ./docker/nodered
ports: ['1880:1880']
volumes:
- ./docker/nodered/data:/data/evolv
influxdb:
image: influxdb:2.7
ports: ['8086:8086']
```
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
---
## Debug recipes
> [!NOTE]
> Pending full node review (2026-05). Recipes below are grounded in the source; the symptom-side has not been confirmed against a live deployment.
| Symptom | First thing to check | Where to look |
|:---|:---|:---|
| `F_eff` negative or `NaN` | `C_TS` is zero or `Cs_in[12]` is huge. The `F_s` clamp should prevent negatives &mdash; confirm the clamp `min(..., F_in)` is present. Likely the clamp fires but masks a deeper input problem. | `src/specificClass.js#getEffluent` line `const F_s = Math.min(...)`. |
| Settler never updates after reactor changes | Reactor child is not on `'upstream'` position (warn logged but registration proceeds), or the listener is attached to the wrong emitter. | `_connectReactor` &mdash; listens on `reactor.emitter`, **NOT** `reactor.measurements.emitter`. |
| Return-sludge flow stays at 0 | `returnPump.measurements.type('flow').variant('measured').position('atEquipment')` has no current value. Wire a flow measurement child on the pump (with `asset.type='flow'`, `positionVsParent='atEquipment'`). | `_connectMachine`, pump's MeasurementContainer chain. |
| Three Fluent envelopes do not arrive at the downstream consumer | `payload.inlet` selector on the downstream reactor / pump mismatches (0 = effluent, 1 = surplus, 2 = return). | The downstream consumer's inlet routing. |
| `quantity (tss)` updates don't change `C_TS` | Measurement child's `asset.type` must be the literal string `"quantity (tss)"` (with the space + parenthesised "tss"). | `src/specificClass.js#_updateMeasurement` switch case. |
| `data.influent` inject is silently dropped | Payload must be an object `{F, C}`. A string, number, or array logs a warn (`data.influent expects an object {F, C}; got <type>`) and short-circuits. | `src/commands/handlers.js#dataInfluent`. |
| Settler logs an `error` `Type '<x>' not recognized for measured update.` | A measurement child has registered with an `asset.type` settler doesn't recognise. The re-emit still happened &mdash; the error is about the absence of a `_updateMeasurement` switch case. Currently only `quantity (tss)` mutates state. | `_updateMeasurement`. |
| `child.register` topic logs `child.register skipped: missing child/source for id=<x>` | The given node id doesn't resolve to a Node-RED node with a `.source` (i.e. an EVOLV domain). Verify the id is correct and the target node has already been deployed. | `src/commands/handlers.js#childRegister`. |
| Status badge stuck on `idle: no influent` | `F_in <= 0`. Either no reactor has fired `stateChange` yet, or `data.influent` has not been sent with a positive `F`. | `src/specificClass.js#getStatusBadge`. |
> Never ship `enableLog: 'debug'` in a demo &mdash; fills the container log within seconds and obscures real errors.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, reactor &harr; settler wiring, mass-balance math |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [reactor &mdash; Examples](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Reference-Examples) | The upstream parent &mdash; how to drive `stateChange` |
| [EVOLV &mdash; Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where settler fits in a larger plant |

View File

@@ -0,0 +1,116 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-a3583a3-blue)
> [!NOTE]
> Pending full node review (2026-05). What `settler` does not do, current rough edges, and open questions. Open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the superproject.
---
## When you would not use this node
| Scenario | Use instead |
|:---|:---|
| Primary sedimentation upstream of biological treatment | The species 7&ndash;12 zeroing in the effluent stream is wrong for primary sludge (the soluble / particulate split is different). Model as a separate node. |
| Generic mass-balance transform | The 13-species ASM3 concentration vector is hard-coded; `Cs[12]` (`X_TS`) is the only species the split is keyed off. Not a fit for arbitrary stream-splitting. |
| Single-tank SBR with no separation stage | The 3-stream output expects a downstream consumer that routes by `payload.inlet`. A direct reactor &rarr; reactor wire is lighter. |
| A reactor &mdash; you want to model biological transformation, not separation | `reactor` (settler is a passive separator, not a biological process). |
| A return-pump itself &mdash; you want to model the pump's behaviour | `rotatingMachine` (settler reads the pump's measured flow but does not control it). |
---
## Known limitations
### Example flows are stub level
The three shipped flows (`basic.flow.json`, `integration.flow.json`, `edge.flow.json`) are 4-node skeletons (tab + node + inject + debug). They prove the node loads in Node-RED but do not exercise the reactor &rarr; settler &rarr; pump chain, do not drive `data.influent` with a valid payload, and do not have a dashboard tier. Production-grade examples are TODO &mdash; see [Reference &mdash; Examples &mdash; TODO](Reference-Examples#todo--production-grade-example-set).
### Editor colour drift
`settler.html` declares `color: '#e4a363'` (orange). The S88 Unit level requires `#50a8d9` (blue). The placement-rule registry (`.claude/rules/node-red-flow-layout.md` §14) already maps `settler` to the `UN` lane regardless of editor colour, so demos lay out correctly; the cosmetic mismatch is tracked in §16 of the same rule and is on the colour-cleanup list. The wiki diagrams use the correct blue.
### Single-reactor upstream slot
`this.upstreamReactor` is a single slot. Registering a second `reactor` child with `positionVsParent='upstream'` silently overwrites the first &mdash; the listener on the previous reactor's `emitter` is not detached, so it keeps firing into a settler that will pull effluent from the new reactor instead. Tracked.
### `X_TS` index is hard-coded
The mass balance uses `Cs_in[12]` (the ASM3 `X_TS` lumped solids species) as the surrogate for total suspended solids. Any change to the species ordering in the upstream reactor breaks settler. The coupling is not documented in the schema &mdash; it lives only in the `getEffluent` math and the `_updateMeasurement` switch case for `quantity (tss)`. Tracked.
### No flow-balance warning
When influent solids exceed the target return concentration (`Cs_in[12] > C_TS`), `F_s` is clamped to `F_in` and clarified effluent drops to zero. This is mathematically correct but masks an upstream problem (overloaded reactor, miscalibrated `C_TS` setpoint). The clamp fires silently &mdash; no warn, no badge change beyond the eventual `F_in <= 0` idle state. Operator must monitor `F_eff` directly. Tracked.
### `quantity (tss)` measurement passes through but doesn't validate
`_connectMeasurement` re-emits every measurement type, but `_updateMeasurement` only acts on `quantity (tss)`. Other types log an `error` (`Type '<x>' not recognized for measured update.`) but the re-emit already happened &mdash; the parent of settler still sees the value. Whether this is desired (settler acts as telemetry pass-through) or a contract gap is unresolved.
> [!NOTE]
> Pending full node review (2026-05). Open question: should `_connectMeasurement` filter by asset-type at register time and reject non-`quantity (tss)` children, or continue accepting everything as pass-through telemetry?
### No output manifest / no degraded-state coverage
Per the platform output-coverage rule (`.claude/rules/output-coverage.md`), every node needs a `test/_output-manifest.md` enumerating every Port 0 / 1 / 2 key and a `test/basic/output-*.test.js` exercising each one in both populated **and** degraded states. Settler has neither. The most likely degraded-state crash points:
- Port 0 emitted before any reactor `stateChange` &mdash; `F_in = 0`, `Cs_in = [0...]`, the three envelopes carry zero flow but valid (zero) `C` arrays. Should not crash a downstream consumer, but un-tested.
- Port 0 with `returnPump` registered but no flow measurement landed yet &mdash; `returnPump.measurements.type('flow').variant('measured').position('atEquipment').getCurrentValue()` returns `undefined` &rarr; `Math.min(undefined, F_s) = NaN`. `F_sr` becomes `NaN` &rarr; both `inlet=1` and `inlet=2` carry `NaN` flow.
Tracked. TODO before trial-readiness: add the manifest, tests, and `null`-flow-measurement handling.
### `reactor.getEffluent` shape coupling
`_connectReactor` does `Array.isArray(raw) ? raw[0] : raw` to absorb both the older single-envelope shape and the newer 3-stream array shape of `reactor.getEffluent`. The 2026-03-02 fix is the only thing keeping the older shape alive in production. If `reactor.getEffluent` ever returns a 3-stream array and settler should consume `inlet=0` specifically, the current `raw[0]` selector works by accident &mdash; it picks the first envelope regardless of inlet number. Open question whether to make the selection inlet-aware.
### Stateful telemetry &mdash; no decay / no TTL
The MeasurementContainer holds the last-known value of every re-emitted measurement forever. If a child stops publishing, the stale value persists on Port 1 indefinitely. There is no TTL, no `data.clear-measurement` topic, and no health flag like `rotatingMachine`'s `predictionQuality`. Open question.
---
## Open questions (tracked)
| Question | Where it lives |
|:---|:---|
| Filter `_connectMeasurement` by asset-type at register time? | Internal &mdash; not yet ticketed |
| Multi-reactor upstream support &mdash; teardown ordering, listener detach | Internal |
| Flow-balance warning when `F_s` clamp fires | Internal |
| Inlet-aware selection in `_connectReactor` shape handling | Internal |
| Measurement TTL / staleness flag | Internal |
| Production-grade example flows (Tier 1 / 2 / 3) | `.agents/improvements/IMPROVEMENTS_BACKLOG.md` |
| Output manifest + degraded-state tests | `.claude/rules/output-coverage.md` (platform-wide rule) |
| Editor colour cleanup (`#e4a363` &rarr; `#50a8d9`) | `.claude/rules/node-red-flow-layout.md` §16 |
---
## Migration notes
> [!NOTE]
> Pending full node review (2026-05). No structural migrations have been performed on settler since the AssetResolver refactor of rotatingMachine; the notes below document the one historical fix on record.
### From pre-2026-03-02 `_connectReactor`
Before 2026-03-02 `_connectReactor` assumed `reactor.getEffluent` always returned an array, and indexed `[0]` unconditionally. After the reactor refactor, `getEffluent` returns a single envelope &mdash; pre-fix settler would crash with `Cannot read properties of undefined (reading 'payload')`. The fix:
```js
const raw = this.upstreamReactor.getEffluent;
const effluent = Array.isArray(raw) ? raw[0] : raw;
```
If you maintain a fork of settler from before that date, port this guard.
### From topic-aliased payloads
Both `influent` and `setInfluent` are accepted as aliases for `data.influent`. A one-time deprecation warning fires the first time each alias is seen. Tracked for removal; use the canonical `data.influent` in new flows.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, reactor &harr; settler wiring, mass-balance math |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + the TODO list for production-grade demos |
| [reactor &mdash; Limitations](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Reference-Limitations) | The upstream parent &mdash; effluent shape contract |
| [EVOLV &mdash; output-coverage rule](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/output-coverage.md) | Platform-wide output-coverage requirement |

19
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,19 @@
### settler
- [Home](Home)
**Reference**
- [Contracts](Reference-Contracts)
- [Architecture](Reference-Architecture)
- [Examples](Reference-Examples)
- [Limitations](Reference-Limitations)
**Related**
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
- [reactor wiki](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Home)
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)