Compare commits

..

8 Commits

63 changed files with 2994 additions and 5883 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.*

View File

@@ -1,40 +0,0 @@
# reactor — Claude Code context
Biological reactor with ASM kinetics.
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 (`reactor`) **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 | `reactor.js` |
| Editor HTML | `reactor.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.

View File

@@ -1,51 +0,0 @@
# reactor — 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.clock` | `clock` | `msg.timestamp` (ms since epoch) | Calls `source.updateState(timestamp)` — advances the ASM kinetics integrator by `n_iter` time steps that fit between `currentTime` and the supplied timestamp (scaled by `speedUpFactor`). |
| `data.fluent` | `Fluent` | `{ inlet: number, F: number, C: number[13] }` | Writes the per-inlet flow rate (`F`, m³/d) and concentration vector (`C`) into `engine.Fs[inlet]` / `engine.Cs_in[inlet]`. |
| `data.otr` | `OTR` | numeric | Sets the externally-supplied oxygen transfer rate (used when `kla` is NaN). |
| `data.temperature` | `Temperature` | numeric or `{ value: number }` | Sets `engine.temperature` (°C). Non-numeric payloads are warned and ignored. |
| `data.dispersion` | `Dispersion` | numeric | PFR only — sets the axial dispersion coefficient `D` (m²/d). |
| `child.register` | `registerChild` | child node id (string) | Looks up the sibling node via `RED.nodes.getNode(id)` and delegates to `source.childRegistrationUtils.registerChild` with `msg.positionVsParent`. |
Aliases log a one-time deprecation warning the first time they fire.
## Outputs (msg.topic on Port 0/1/2)
- **Port 0 (process):** every tick emits the engine's effluent:
`{ topic: 'Fluent', payload: { inlet: 0, F, C: number[13] }, timestamp }`.
For a PFR an additional `{ topic: 'GridProfile', payload: { grid, n_x, d_x, length, species, timestamp } }`
message goes out on the same port before the effluent.
- **Port 1 (InfluxDB telemetry):** formatted via `outputUtils.formatMsg(..., 'influxdb')`
from `getOutput()` — carries `flow_total`, `temperature`, and one field per ASM3
species (`S_O`, `S_I`, `S_S`, `S_NH`, `S_N2`, `S_NO`, `S_HCO`, `X_I`, `X_S`, `X_H`,
`X_STO`, `X_A`, `X_TS`).
- **Port 2 (registration):** at startup the node sends one
`{ topic: 'child.register', payload: <node.id>, positionVsParent, distance }`
to its parent.
## Events emitted by `source.emitter`
- `stateChange` — fires after every `updateState()` that advances the integrator.
Payload is the new `currentTime` (ms since epoch). Downstream reactors register
via `child.register` and subscribe to this event to pull the upstream
effluent on each advance.
- `output-changed` — base notification fired by `updateState()` so the
BaseNodeAdapter pipeline pushes outputs (currently used only as a heartbeat;
effluent is emitted directly from the periodic tick).
## Children accepted
- `measurement` — subscribes to `<type>.measured.<position>` on the child's
`measurements.emitter`. Recognised reconciliations: `temperature.measured.atEquipment`
writes `engine.temperature`; PFR additionally honours
`quantity (oxygen).measured.<distance>` to reconcile dissolved-oxygen
concentration into the nearest grid cell.
- `reactor` — registers as the upstream reactor; the downstream `updateState`
pulls the upstream effluent into `Fs[0]` / `Cs_in[0]` before integrating.

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,17 +1,17 @@
# reactor
Reactor: Advanced Hydraulic Tank & Biological Process Simulator
A comprehensive reactor class for wastewater treatment simulation featuring plug flow hydraulics, ASM1-ASM3 biological modeling, and multi-sectional concentration tracking. Implements hydraulic retention time calculations, dispersion modeling, and real-time biological reaction kinetics for accurate process simulation.
Key Features:
Plug Flow Hydraulics: Multi-section reactor with configurable sectioning factor and dispersion modeling
ASM1 Integration: Complete biological nutrient removal modeling with 13 state variables (COD, nitrogen, phosphorus)
Dynamic Volume Control: Automatic section management with overflow handling and retention time calculations
Oxygen Transfer: Saturation-limited O2 transfer with Fick's law slowdown effects and solubility curves
Real-time Kinetics: Continuous biological reaction rate calculations with configurable time acceleration
Weighted Averaging: Volume-based concentration mixing for accurate mass balance calculations
Child Registration: Integration with diffuser systems and upstream/downstream reactor networks
Supports complex biological treatment train modeling with temperature compensation, sludge calculations, and comprehensive process monitoring for wastewater treatment plant optimization and regulatory compliance.
# reactor
Reactor: Advanced Hydraulic Tank & Biological Process Simulator
A comprehensive reactor class for wastewater treatment simulation featuring plug flow hydraulics, ASM1-ASM3 biological modeling, and multi-sectional concentration tracking. Implements hydraulic retention time calculations, dispersion modeling, and real-time biological reaction kinetics for accurate process simulation.
Key Features:
Plug Flow Hydraulics: Multi-section reactor with configurable sectioning factor and dispersion modeling
ASM1 Integration: Complete biological nutrient removal modeling with 13 state variables (COD, nitrogen, phosphorus)
Dynamic Volume Control: Automatic section management with overflow handling and retention time calculations
Oxygen Transfer: Saturation-limited O2 transfer with Fick's law slowdown effects and solubility curves
Real-time Kinetics: Continuous biological reaction rate calculations with configurable time acceleration
Weighted Averaging: Volume-based concentration mixing for accurate mass balance calculations
Child Registration: Integration with diffuser systems and upstream/downstream reactor networks
Supports complex biological treatment train modeling with temperature compensation, sludge calculations, and comprehensive process monitoring for wastewater treatment plant optimization and regulatory compliance.

View File

@@ -1,57 +1,57 @@
<script type="text/javascript">
RED.nodes.registerType("recirculation-pump", {
category: "WWTP",
color: "#e4a363",
defaults: {
name: { value: "" },
F2: { value: 0, required: true },
inlet: { value: 1, required: true }
},
inputs: 1,
outputs: 2,
outputLabels: ["Main effluent", "Recirculation effluent"],
icon: "font-awesome/fa-random",
label: function() {
return this.name || "Recirculation pump";
},
oneditprepare: function() {
$("#node-input-F2").typedInput({
type:"num",
types:["num"]
});
$("#node-input-inlet").typedInput({
type:"num",
types:["num"]
});
},
oneditsave: function() {
let debit = parseFloat($("#node-input-F2").typedInput("value"));
if (isNaN(debit) || debit < 0) {
RED.notify("Debit is not set correctly", {type: "error"});
}
let inlet = parseInt($("#node-input-n_inlets").typedInput("value"));
if (inlet < 1) {
RED.notify("Number of inlets not set correctly", {type: "error"});
}
}
});
</script>
<script type="text/html" data-template-name="recirculation-pump">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-F2"><i class="fa fa-tag"></i> Recirculation debit [m3 d-1]</label>
<input type="text" id="node-input-F2" placeholder="m3 s-1">
</div>
<div class="form-row">
<label for="node-input-inlet"><i class="fa fa-tag"></i> Assigned inlet recirculation</label>
<input type="text" id="node-input-inlet" placeholder="#">
</div>
</script>
<script type="text/html" data-help-name="recirculation-pump">
<p>Recirculation-pump for splitting streams</p>
</script>
<script type="text/javascript">
RED.nodes.registerType("recirculation-pump", {
category: "WWTP",
color: "#e4a363",
defaults: {
name: { value: "" },
F2: { value: 0, required: true },
inlet: { value: 1, required: true }
},
inputs: 1,
outputs: 2,
outputLabels: ["Main effluent", "Recirculation effluent"],
icon: "font-awesome/fa-random",
label: function() {
return this.name || "Recirculation pump";
},
oneditprepare: function() {
$("#node-input-F2").typedInput({
type:"num",
types:["num"]
});
$("#node-input-inlet").typedInput({
type:"num",
types:["num"]
});
},
oneditsave: function() {
let debit = parseFloat($("#node-input-F2").typedInput("value"));
if (isNaN(debit) || debit < 0) {
RED.notify("Debit is not set correctly", {type: "error"});
}
let inlet = parseInt($("#node-input-n_inlets").typedInput("value"));
if (inlet < 1) {
RED.notify("Number of inlets not set correctly", {type: "error"});
}
}
});
</script>
<script type="text/html" data-template-name="recirculation-pump">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-F2"><i class="fa fa-tag"></i> Recirculation debit [m3 d-1]</label>
<input type="text" id="node-input-F2" placeholder="m3 s-1">
</div>
<div class="form-row">
<label for="node-input-inlet"><i class="fa fa-tag"></i> Assigned inlet recirculation</label>
<input type="text" id="node-input-inlet" placeholder="#">
</div>
</script>
<script type="text/html" data-help-name="recirculation-pump">
<p>Recirculation-pump for splitting streams</p>
</script>

View File

@@ -3,12 +3,13 @@ module.exports = function(RED) {
RED.nodes.createNode(this, config);
var node = this;
let name = config.name;
let F2 = parseFloat(config.F2);
const inlet_F2 = parseInt(config.inlet);
node.on('input', function(msg, send, done) {
switch (msg.topic) {
case "Fluent": {
case "Fluent":
// conserve volume flow debit
let F_in = msg.payload.F;
let F1 = Math.max(F_in - F2, 0);
@@ -23,7 +24,6 @@ module.exports = function(RED) {
send([msg_F1, msg_F2]);
break;
}
case "clock":
break;
default:

View File

@@ -1,57 +1,57 @@
<script type="text/javascript">
RED.nodes.registerType("settling-basin", {
category: "WWTP",
color: "#e4a363",
defaults: {
name: { value: "" },
TS_set: { value: 0.1, required: true },
inlet: { value: 1, required: true }
},
inputs: 1,
outputs: 2,
outputLabels: ["Main effluent", "Sludge effluent"],
icon: "font-awesome/fa-random",
label: function() {
return this.name || "Settling basin";
},
oneditprepare: function() {
$("#node-input-TS_set").typedInput({
type:"num",
types:["num"]
});
$("#node-input-inlet").typedInput({
type:"num",
types:["num"]
});
},
oneditsave: function() {
let TS_set = parseFloat($("#node-input-TS_set").typedInput("value"));
if (isNaN(TS_set) || TS_set < 0) {
RED.notify("TS is not set correctly", {type: "error"});
}
let inlet = parseInt($("#node-input-n_inlets").typedInput("value"));
if (inlet < 1) {
RED.notify("Number of inlets not set correctly", {type: "error"});
}
}
});
</script>
<script type="text/html" data-template-name="settling-basin">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-TS_set"><i class="fa fa-tag"></i> Total Solids set point [g m-3]</label>
<input type="text" id="node-input-TS_set" placeholder="">
</div>
<div class="form-row">
<label for="node-input-inlet"><i class="fa fa-tag"></i> Assigned inlet return line</label>
<input type="text" id="node-input-inlet" placeholder="#">
</div>
</script>
<script type="text/html" data-help-name="settling-basin">
<p>Settling tank</p>
</script>
<script type="text/javascript">
RED.nodes.registerType("settling-basin", {
category: "WWTP",
color: "#e4a363",
defaults: {
name: { value: "" },
TS_set: { value: 0.1, required: true },
inlet: { value: 1, required: true }
},
inputs: 1,
outputs: 2,
outputLabels: ["Main effluent", "Sludge effluent"],
icon: "font-awesome/fa-random",
label: function() {
return this.name || "Settling basin";
},
oneditprepare: function() {
$("#node-input-TS_set").typedInput({
type:"num",
types:["num"]
});
$("#node-input-inlet").typedInput({
type:"num",
types:["num"]
});
},
oneditsave: function() {
let TS_set = parseFloat($("#node-input-TS_set").typedInput("value"));
if (isNaN(TS_set) || TS_set < 0) {
RED.notify("TS is not set correctly", {type: "error"});
}
let inlet = parseInt($("#node-input-n_inlets").typedInput("value"));
if (inlet < 1) {
RED.notify("Number of inlets not set correctly", {type: "error"});
}
}
});
</script>
<script type="text/html" data-template-name="settling-basin">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-TS_set"><i class="fa fa-tag"></i> Total Solids set point [g m-3]</label>
<input type="text" id="node-input-TS_set" placeholder="">
</div>
<div class="form-row">
<label for="node-input-inlet"><i class="fa fa-tag"></i> Assigned inlet return line</label>
<input type="text" id="node-input-inlet" placeholder="#">
</div>
</script>
<script type="text/html" data-help-name="settling-basin">
<p>Settling tank</p>
</script>

View File

@@ -3,12 +3,13 @@ module.exports = function(RED) {
RED.nodes.createNode(this, config);
var node = this;
let name = config.name;
let TS_set = parseFloat(config.TS_set);
const inlet_sludge = parseInt(config.inlet);
node.on('input', function(msg, send, done) {
switch (msg.topic) {
case "Fluent": {
case "Fluent":
// conserve volume flow debit
let F_in = msg.payload.F;
let C_in = msg.payload.C;
@@ -40,7 +41,6 @@ module.exports = function(RED) {
send([msg_F1, msg_F2]);
break;
}
case "clock":
break;
default:

View File

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

View File

@@ -1,6 +0,0 @@
[
{"id":"reactor_basic_tab","type":"tab","label":"reactor basic","disabled":false,"info":"reactor basic example"},
{"id":"reactor_basic_node","type":"reactor","z":"reactor_basic_tab","name":"reactor basic","x":420,"y":180,"wires":[["reactor_basic_dbg"]]},
{"id":"reactor_basic_inj","type":"inject","z":"reactor_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":[["reactor_basic_node"]]},
{"id":"reactor_basic_dbg","type":"debug","z":"reactor_basic_tab","name":"reactor basic debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
]

View File

@@ -1,6 +0,0 @@
[
{"id":"reactor_edge_tab","type":"tab","label":"reactor edge","disabled":false,"info":"reactor edge example"},
{"id":"reactor_edge_node","type":"reactor","z":"reactor_edge_tab","name":"reactor edge","x":420,"y":180,"wires":[["reactor_edge_dbg"]]},
{"id":"reactor_edge_inj","type":"inject","z":"reactor_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":[["reactor_edge_node"]]},
{"id":"reactor_edge_dbg","type":"debug","z":"reactor_edge_tab","name":"reactor edge debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
]

View File

@@ -1,6 +0,0 @@
[
{"id":"reactor_int_tab","type":"tab","label":"reactor integration","disabled":false,"info":"reactor integration example"},
{"id":"reactor_int_node","type":"reactor","z":"reactor_int_tab","name":"reactor integration","x":420,"y":180,"wires":[["reactor_int_dbg"]]},
{"id":"reactor_int_inj","type":"inject","z":"reactor_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":[["reactor_int_node"]]},
{"id":"reactor_int_dbg","type":"debug","z":"reactor_int_tab","name":"reactor integration debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":680,"y":180,"wires":[]}
]

File diff suppressed because it is too large Load Diff

238
package-lock.json generated
View File

@@ -1,119 +1,119 @@
{
"name": "reactor",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "reactor",
"version": "0.0.1",
"license": "SEE LICENSE",
"dependencies": {
"generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git",
"mathjs": "^14.5.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/complex.js": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz",
"integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/escape-latex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
"integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==",
"license": "MIT"
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/generalFunctions": {
"version": "1.0.0",
"resolved": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git#efc97d6cd17399391b011298e47e8c1b1599592d",
"license": "SEE LICENSE"
},
"node_modules/javascript-natural-sort": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
"integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==",
"license": "MIT"
},
"node_modules/mathjs": {
"version": "14.8.0",
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.8.0.tgz",
"integrity": "sha512-DN4wmAjNzFVJ9vHqpAJ3vX0UF306u/1DgGKh7iVPuAFH19JDRd9NAaQS764MsKbSwDB6uBSkQEmgVmKdgYaCoQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.26.10",
"complex.js": "^2.2.5",
"decimal.js": "^10.4.3",
"escape-latex": "^1.2.0",
"fraction.js": "^5.2.1",
"javascript-natural-sort": "^0.7.1",
"seedrandom": "^3.0.5",
"tiny-emitter": "^2.1.0",
"typed-function": "^4.2.1"
},
"bin": {
"mathjs": "bin/cli.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
"license": "MIT"
},
"node_modules/typed-function": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz",
"integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
}
}
}
{
"name": "reactor",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "reactor",
"version": "0.0.1",
"license": "SEE LICENSE",
"dependencies": {
"generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git",
"mathjs": "^14.5.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/complex.js": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz",
"integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/escape-latex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
"integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==",
"license": "MIT"
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/generalFunctions": {
"version": "1.0.0",
"resolved": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git#efc97d6cd17399391b011298e47e8c1b1599592d",
"license": "SEE LICENSE"
},
"node_modules/javascript-natural-sort": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
"integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==",
"license": "MIT"
},
"node_modules/mathjs": {
"version": "14.8.0",
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.8.0.tgz",
"integrity": "sha512-DN4wmAjNzFVJ9vHqpAJ3vX0UF306u/1DgGKh7iVPuAFH19JDRd9NAaQS764MsKbSwDB6uBSkQEmgVmKdgYaCoQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.26.10",
"complex.js": "^2.2.5",
"decimal.js": "^10.4.3",
"escape-latex": "^1.2.0",
"fraction.js": "^5.2.1",
"javascript-natural-sort": "^0.7.1",
"seedrandom": "^3.0.5",
"tiny-emitter": "^2.1.0",
"typed-function": "^4.2.1"
},
"bin": {
"mathjs": "bin/cli.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
"license": "MIT"
},
"node_modules/typed-function": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz",
"integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
}
}
}

View File

@@ -1,36 +1,33 @@
{
"name": "reactor",
"version": "0.0.1",
"description": "Implementation of the asm3 model for Node-Red",
"repository": {
"type": "git",
"url": "https://gitea.centraal.wbd-rd.nl/RnD/reactor.git"
},
"keywords": [
"asm3",
"activated sludge",
"wastewater",
"biological model",
"node-red"
],
"license": "SEE LICENSE",
"author": "P.R. van der Wilt",
"main": "reactor.js",
"scripts": {
"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": {
"reactor": "reactor.js",
"recirculation-pump": "additional_nodes/recirculation-pump.js",
"settling-basin": "additional_nodes/settling-basin.js"
}
},
"dependencies": {
"generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git",
"mathjs": "^14.5.2"
}
}
{
"name": "reactor",
"version": "0.0.1",
"description": "Implementation of the asm3 model for Node-Red",
"repository": {
"type": "git",
"url": "https://gitea.centraal.wbd-rd.nl/RnD/reactor.git"
},
"keywords": [
"asm3",
"activated sludge",
"wastewater",
"biological model",
"node-red"
],
"license": "SEE LICENSE",
"author": "P.R. van der Wilt",
"main": "reactor.js",
"scripts": {
"test": "node reactor.js"
},
"node-red": {
"nodes": {
"reactor": "reactor.js",
"recirculation-pump": "additional_nodes/recirculation-pump.js",
"settling-basin": "additional_nodes/settling-basin.js"
}
},
"dependencies": {
"generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git",
"mathjs": "^14.5.2"
}
}

View File

@@ -1,19 +1,9 @@
<!--
| S88-niveau | Primair (blokkleur) | Tekstkleur |
| ---------------------- | ------------------- | ---------- |
| **Area** | `#0f52a5` | wit |
| **Process Cell** | `#0c99d9` | wit |
| **Unit** | `#50a8d9` | zwart |
| **Equipment (Module)** | `#86bbdd` | zwart |
| **Control Module** | `#a9daee` | zwart |
-->
<script src="/reactor/menu.js"></script>
<script type="text/javascript">
RED.nodes.registerType("reactor", {
category: "EVOLV",
color: "#50a8d9",
category: "WWTP",
color: "#c4cce0",
defaults: {
name: { value: "" },
reactor_type: { value: "CSTR", required: true },
@@ -23,7 +13,7 @@
alpha: {value: 0},
n_inlets: { value: 1, required: true},
kla: { value: null },
S_O_init: { value: 0., required: true },
S_I_init: { value: 30., required: true },
S_S_init: { value: 100., required: true },
@@ -35,13 +25,10 @@
X_S_init: { value: 75., required: true },
X_H_init: { value: 30., required: true },
X_STO_init: { value: 0., required: true },
X_A_init: { value: 200, required: true },
X_A_init: { value: 0.001, required: true },
X_TS_init: { value: 125.0009, required: true },
timeStep: { value: 1, required: true },
speedUpFactor: { value: 1 },
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
enableLog: { value: false },
logLevel: { value: "error" },
@@ -52,7 +39,7 @@
outputs: 3,
inputLabels: ["input"],
outputLabels: ["process", "dbase", "parent"],
icon: "font-awesome/fa-flask",
icon: "font-awesome/fa-recycle",
label: function() {
return this.name || "Reactor";
},
@@ -118,10 +105,6 @@
type:"num",
types:["num"]
})
$("#node-input-speedUpFactor").typedInput({
type:"num",
types:["num"]
})
// Set initial visibility on dialog open
const initialType = $("#node-input-reactor_type").typedInput("value");
if (initialType === "CSTR") {
@@ -137,8 +120,8 @@
}
// save position field
if (window.EVOLV?.nodes?.reactor?.positionMenu?.saveEditor) {
window.EVOLV.nodes.reactor.positionMenu.saveEditor(this);
if (window.EVOLV?.nodes?.measurement?.positionMenu?.saveEditor) {
window.EVOLV.nodes.rotatingMachine.positionMenu.saveEditor(this);
}
let volume = parseFloat($("#node-input-volume").typedInput("value"));
@@ -250,27 +233,6 @@
<label for="node-input-timeStep"><i class="fa fa-tag"></i> Time step [s]</label>
<input type="text" id="node-input-timeStep" placeholder="s">
</div>
<div class="form-row">
<label for="node-input-speedUpFactor"><i class="fa fa-tag"></i> Speed-up factor</label>
<input type="text" id="node-input-speedUpFactor" placeholder="1 = real-time">
</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="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<!-- Logger fields injected here -->
<div id="logger-fields-placeholder"></div>

View File

@@ -1,26 +1,26 @@
const nameOfNode = "reactor"; // 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 /advancedReactor/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 = "reactor"; // 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 /advancedReactor/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}`);
}
});
};

View File

@@ -1,25 +0,0 @@
'use strict';
// Reactor input handlers. Each receives (source, msg, ctx) where source is
// the Reactor domain and ctx is { node, RED, send, logger }. The handlers
// either forward to engine setters or drive a synchronous state update.
exports.dataClock = (source, msg) => {
source.updateState(msg.timestamp ?? Date.now());
};
exports.dataFluent = (source, msg) => { source.setInfluent = msg; };
exports.dataOTR = (source, msg) => { source.setOTR = msg; };
exports.dataTemperature = (source, msg) => { source.setTemperature = msg; };
exports.dataDispersion = (source, msg) => { source.setDispersion = msg; };
exports.childRegister = (source, msg, ctx) => {
const childId = msg.payload;
const RED = ctx?.RED;
const childObj = RED?.nodes?.getNode?.(childId);
if (!childObj || !childObj.source) {
source?.logger?.warn?.(`registerChild skipped: missing child/source for id=${childId}`);
return;
}
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
};

View File

@@ -1,55 +0,0 @@
'use strict';
// reactor command registry. Canonical names follow CONTRACTS.md §1.
// Legacy names (clock, Fluent, OTR, Temperature, Dispersion, registerChild)
// stay as aliases — they log a one-time deprecation warning on first use
// and are removed in Phase 7.
const handlers = require('./handlers');
module.exports = [
{
topic: 'data.clock',
aliases: ['clock'],
payloadSchema: { type: 'any' },
description: 'Push the simulation clock tick (timestamp / dt) to the ASM solver.',
handler: handlers.dataClock,
},
{
topic: 'data.fluent',
aliases: ['Fluent'],
payloadSchema: { type: 'object' },
// Compound payload `{F, C: [...]}` — registry-level units normalisation is
// skipped (the handler converts per-field internally).
description: 'Push the influent stream (payload: {F: flow m3/h, C: [concentrations mg/L]}).',
handler: handlers.dataFluent,
},
{
topic: 'data.otr',
aliases: ['OTR'],
payloadSchema: { type: 'any' },
description: 'Push the current oxygen-transfer rate into the reactor.',
handler: handlers.dataOTR,
},
{
topic: 'data.temperature',
aliases: ['Temperature'],
payloadSchema: { type: 'any' },
description: 'Push the current reactor temperature.',
handler: handlers.dataTemperature,
},
{
topic: 'data.dispersion',
aliases: ['Dispersion'],
payloadSchema: { type: 'any' },
description: 'Push a dispersion/mixing parameter update.',
handler: handlers.dataDispersion,
},
{
topic: 'child.register',
aliases: ['registerChild'],
payloadSchema: { type: 'any' },
description: 'Register a child node (settler / measurement) with this reactor.',
handler: handlers.childRegister,
},
];

View File

@@ -1,139 +0,0 @@
'use strict';
const EventEmitter = require('events');
const ASM3 = require('../reaction_modules/asm3_class.js');
const { create, all } = require('mathjs');
const { childRegistrationUtils, logger, MeasurementContainer, POSITIONS } = require('generalFunctions');
const math = create(all, { matrix: 'Array' });
const S_O_INDEX = 0;
const NUM_SPECIES = 13;
// Abstract reactor engine. Holds the influent/OTR/temperature state plus
// the parent-side child registration that the original Reactor class
// exposed. Concrete CSTR / PFR subclasses provide tick().
class BaseReactorEngine {
constructor(config) {
this.config = config;
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.upstreamReactor = null;
this.childRegistrationUtils = new childRegistrationUtils(this);
this.asm = new ASM3();
this.volume = config.volume;
this.Fs = Array(config.n_inlets).fill(0);
this.Cs_in = Array.from(Array(config.n_inlets), () => new Array(NUM_SPECIES).fill(0));
this.OTR = 0.0;
this.temperature = 20;
this.kla = config.kla;
this.currentTime = Date.now();
// timeStep stored in days (the integrator uses [d] internally).
this.timeStep = (1 / (24 * 60 * 60)) * this.config.timeStep;
this.speedUpFactor = config.speedUpFactor ?? 1;
}
set setInfluent(input) {
const index_in = input.payload.inlet;
this.Fs[index_in] = input.payload.F;
this.Cs_in[index_in] = input.payload.C;
}
set setOTR(input) { this.OTR = input.payload; }
set setTemperature(input) {
const p = input?.payload;
const raw = (p && typeof p === 'object' && p.value !== undefined) ? p.value : p;
const v = Number(raw);
if (!Number.isFinite(v)) { this.logger.warn(`Invalid temperature input: ${raw}`); return; }
this.temperature = v;
}
get getEffluent() {
const last = Array.isArray(this.state.at?.(-1)) ? this.state.at(-1) : this.state;
return { topic: 'Fluent', payload: { inlet: 0, F: math.sum(this.Fs), C: last }, timestamp: this.currentTime };
}
get getGridProfile() { return null; }
_calcOTR(S_O, T = 20.0) {
const sat = this._calcOxygenSaturation(T);
return this.kla * (sat - S_O);
}
_calcOxygenSaturation(T = 20.0) {
return 14.652 - 4.1022e-1 * T + 7.9910e-3 * T * T + 7.7774e-5 * T * T * T;
}
_capDissolvedOxygen(state) {
const sat = this._calcOxygenSaturation(this.temperature);
const capRow = (row) => {
if (!Array.isArray(row)) return row;
const next = row.slice();
if (Number.isFinite(next[S_O_INDEX])) next[S_O_INDEX] = Math.max(0, Math.min(next[S_O_INDEX], sat));
return next;
};
return (Array.isArray(state) && Array.isArray(state[0])) ? state.map(capRow) : capRow(state);
}
_arrayClip2Zero(arr) {
if (Array.isArray(arr)) return arr.map((x) => this._arrayClip2Zero(x));
return arr < 0 ? 0 : arr;
}
registerChild(child, softwareType) {
switch (softwareType) {
case 'measurement': this._connectMeasurement(child); break;
case 'reactor': this._connectReactor(child); break;
default: this.logger.error(`Unrecognized softwareType: ${softwareType}`);
}
}
_connectMeasurement(measurement) {
if (!measurement) { this.logger.warn('Invalid measurement provided.'); return; }
const fn = measurement.config.functionality;
const position = fn.distance !== 'undefined' ? fn.distance : fn.positionVsParent;
const measurementType = measurement.config.asset.type;
const eventName = `${measurementType}.measured.${position}`;
measurement.measurements.emitter.on(eventName, (eventData) => {
this.measurements
.type(measurementType).variant('measured').position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
this._updateMeasurement(measurementType, eventData.value, position, eventData);
});
}
_connectReactor(reactor) {
if (!reactor) { this.logger.warn('Invalid reactor provided.'); return; }
this.upstreamReactor = reactor;
reactor.emitter.on('stateChange', (data) => this.updateState(data));
}
_updateMeasurement(measurementType, value, position) {
if (measurementType === 'temperature' && position === POSITIONS.AT_EQUIPMENT) {
this.temperature = value;
return;
}
this.logger.error(`Type '${measurementType}' not recognized for measured update.`);
}
updateState(newTime = Date.now()) {
const day2ms = 1000 * 60 * 60 * 24;
if (this.upstreamReactor) this.setInfluent = this.upstreamReactor.getEffluent;
const n_iter = Math.floor(this.speedUpFactor * (newTime - this.currentTime) / (this.timeStep * day2ms));
if (!n_iter) return;
for (let n = 0; n < n_iter; n += 1) this.tick(this.timeStep);
this.currentTime += (n_iter * this.timeStep * day2ms) / this.speedUpFactor;
this.emitter.emit('stateChange', this.currentTime);
}
}
module.exports = { BaseReactorEngine, math, S_O_INDEX, NUM_SPECIES };

View File

@@ -1,27 +0,0 @@
'use strict';
const { BaseReactorEngine, math, S_O_INDEX, NUM_SPECIES } = require('./baseEngine.js');
class Reactor_CSTR extends BaseReactorEngine {
constructor(config) {
super(config);
this.state = config.initialState;
}
// Forward Euler step over `time_step` days.
tick(time_step) {
const inflow = math.multiply(math.divide([this.Fs], this.volume), this.Cs_in)[0];
const outflow = math.multiply(-1 * math.sum(this.Fs) / this.volume, this.state);
const reaction = this.asm.compute_dC(this.state, this.temperature);
const transfer = Array(NUM_SPECIES).fill(0.0);
transfer[S_O_INDEX] = isNaN(this.kla)
? this.OTR
: this._calcOTR(this.state[S_O_INDEX], this.temperature);
const dC_total = math.multiply(math.add(inflow, outflow, reaction, transfer), time_step);
this.state = this._capDissolvedOxygen(this._arrayClip2Zero(math.add(this.state, dC_total)));
return this.state;
}
}
module.exports = Reactor_CSTR;

View File

@@ -1,132 +0,0 @@
'use strict';
const { assertNoNaN } = require('../utils.js');
const { BaseReactorEngine, math, S_O_INDEX, NUM_SPECIES } = require('./baseEngine.js');
class Reactor_PFR extends BaseReactorEngine {
constructor(config) {
super(config);
this.length = config.length;
this.n_x = config.resolution_L;
this.d_x = this.length / this.n_x;
this.A = this.volume / this.length;
this.alpha = config.alpha;
this.state = Array.from(Array(this.n_x), () => config.initialState.slice());
this.D = 0.0;
this.D_op = this._makeDoperator(true, true);
this.D2_op = this._makeD2operator();
assertNoNaN(this.D_op, 'Derivative operator');
assertNoNaN(this.D2_op, 'Second derivative operator');
}
get getGridProfile() {
return {
grid: this.state.map((row) => row.slice()),
n_x: this.n_x,
d_x: this.d_x,
length: this.length,
species: ['S_O','S_I','S_S','S_NH','S_N2','S_NO','S_HCO',
'X_I','X_S','X_H','X_STO','X_A','X_TS'],
timestamp: this.currentTime,
};
}
set setDispersion(input) { this.D = input.payload; }
updateState(newTime) {
super.updateState(newTime);
const Pe_local = (this.d_x * math.sum(this.Fs)) / (this.D * this.A);
const Co_D = (this.D * this.timeStep) / (this.d_x * this.d_x);
if (Pe_local >= 2) this.logger.warn(`Local Peclet number (${Pe_local}) is too high! Increase reactor resolution.`);
if (Co_D >= 0.5) this.logger.warn(`Courant number (${Co_D}) is too high! Reduce time step size.`);
}
// Explicit finite-difference step over `time_step` days.
tick(time_step) {
const dispersion = math.multiply(this.D / (this.d_x * this.d_x), this.D2_op, this.state);
const advection = math.multiply(-1 * math.sum(this.Fs) / (this.A * this.d_x), this.D_op, this.state);
const reaction = this.state.map((slice) => this.asm.compute_dC(slice, this.temperature));
const transfer = Array.from(Array(this.n_x), () => new Array(NUM_SPECIES).fill(0));
const klaIsNaN = isNaN(this.kla);
for (let i = 1; i < this.n_x - 1; i += 1) {
const otr = klaIsNaN ? this.OTR : this._calcOTR(this.state[i][S_O_INDEX], this.temperature);
transfer[i][S_O_INDEX] = otr * this.n_x / (this.n_x - 2);
}
const dC_total = math.multiply(math.add(dispersion, advection, reaction, transfer), time_step);
const stateNew = math.add(this.state, dC_total);
this._applyBoundaryConditions(stateNew);
this.state = this._capDissolvedOxygen(this._arrayClip2Zero(stateNew));
return stateNew;
}
_updateMeasurement(measurementType, value, position, context) {
if (measurementType === 'quantity (oxygen)') {
if (!Number.isFinite(position) || !Number.isFinite(value) || this.config.length <= 0) {
this.logger.warn(`Ignoring oxygen measurement update with invalid data (position=${position}, value=${value}).`);
return;
}
const rawIndex = Math.round((position / this.config.length) * this.n_x);
const grid_pos = Math.max(0, Math.min(this.n_x - 1, rawIndex));
this.state[grid_pos][S_O_INDEX] = value;
return;
}
super._updateMeasurement(measurementType, value, position, context);
}
// Generalised Danckwerts at inlet when flow > 0; Neumann (no-flux) at outlet
// and at inlet when there is no flow.
_applyBoundaryConditions(state) {
if (math.sum(this.Fs) > 0) {
const BC_C_in = math.multiply(1 / math.sum(this.Fs), [this.Fs], this.Cs_in)[0];
const BC_disp = ((1 - this.alpha) * this.D * this.A) / (math.sum(this.Fs) * this.d_x);
state[0] = math.multiply(1 / (1 + BC_disp), math.add(BC_C_in, math.multiply(BC_disp, state[1])));
} else {
state[0] = state[1];
}
state[this.n_x - 1] = state[this.n_x - 2];
}
_makeDoperator(central = false, higher_order = false) {
if (higher_order) {
if (!central) throw new Error('Upwind higher order method not implemented! Use central scheme instead.');
const I = math.resize(math.diag(Array(this.n_x).fill(1 / 12), -2), [this.n_x, this.n_x]);
const A = math.resize(math.diag(Array(this.n_x).fill(-2 / 3), -1), [this.n_x, this.n_x]);
const B = math.resize(math.diag(Array(this.n_x).fill(2 / 3), 1), [this.n_x, this.n_x]);
const C = math.resize(math.diag(Array(this.n_x).fill(-1 / 12), 2), [this.n_x, this.n_x]);
const D = math.add(I, A, B, C);
// Preserve the pre-refactor aliasing: D[1] = NearBoundary; NearBoundary.reverse()
// mutates D[1] in place; then D[n_x-2] = -1 * NearBoundary uses the reversed view.
const nb = Array(this.n_x).fill(0.0);
nb[0] = -1 / 4; nb[1] = -5 / 6; nb[2] = 3 / 2; nb[3] = -1 / 2; nb[4] = 1 / 12;
D[1] = nb;
nb.reverse();
D[this.n_x - 2] = math.multiply(-1, nb);
D[0] = Array(this.n_x).fill(0);
D[this.n_x - 1] = Array(this.n_x).fill(0);
return D;
}
const I = math.resize(math.diag(Array(this.n_x).fill(1 / (1 + central)), central), [this.n_x, this.n_x]);
const A = math.resize(math.diag(Array(this.n_x).fill(-1 / (1 + central)), -1), [this.n_x, this.n_x]);
const D = math.add(I, A);
D[0] = Array(this.n_x).fill(0);
D[this.n_x - 1] = Array(this.n_x).fill(0);
return D;
}
_makeD2operator() {
const I = math.diag(Array(this.n_x).fill(-2), 0);
const A = math.resize(math.diag(Array(this.n_x).fill(1), 1), [this.n_x, this.n_x]);
const B = math.resize(math.diag(Array(this.n_x).fill(1), -1), [this.n_x, this.n_x]);
const D2 = math.add(I, A, B);
D2[0] = Array(this.n_x).fill(0);
D2[this.n_x - 1] = Array(this.n_x).fill(0);
return D2;
}
}
module.exports = Reactor_PFR;

View File

@@ -1,53 +1,165 @@
'use strict';
const { Reactor_CSTR, Reactor_PFR } = require('./specificClass.js');
const { BaseNodeAdapter } = require('generalFunctions');
const Reactor = require('./specificClass.js');
const commands = require('./commands');
const SPECIES = ['S_O','S_I','S_S','S_NH','S_N2','S_NO','S_HCO',
'X_I','X_S','X_H','X_STO','X_A','X_TS'];
class nodeClass {
/**
* Node-RED node class for advanced-reactor.
* @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;
class nodeClass extends BaseNodeAdapter {
static DomainClass = Reactor;
static commands = commands;
// Tick-driven: ASM kinetics integrate over wall-clock time. The engine's
// updateState computes how many internal Euler/FD steps fit in the elapsed
// ms; without a periodic tick the integrator never advances.
static tickInterval = 1000;
static statusInterval = 1000;
this._loadConfig(uiConfig)
this._setupClass();
buildDomainConfig(uiConfig) {
const initialState = {};
for (const k of SPECIES) initialState[k] = parseFloat(uiConfig[`${k}_init`]);
return {
reactor: {
reactor_type: uiConfig.reactor_type,
volume: parseFloat(uiConfig.volume),
length: parseFloat(uiConfig.length),
resolution_L: parseInt(uiConfig.resolution_L, 10),
alpha: parseFloat(uiConfig.alpha),
n_inlets: parseInt(uiConfig.n_inlets, 10),
kla: parseFloat(uiConfig.kla),
timeStep: parseFloat(uiConfig.timeStep),
speedUpFactor: Number(uiConfig.speedUpFactor) || 1,
},
initialState,
};
}
this._attachInputHandler();
this._registerChild();
this._startTickLoop();
this._attachCloseHandler();
}
// The kinetics engine drives Port-0 effluent + grid-profile shapes that
// don't fit BaseNodeAdapter's delta-compressed payload. Override the
// periodic emission so the Fluent / GridProfile contract is preserved.
_emitOutputs() {
const src = this.source;
if (!src?.engine) return;
src.updateState(Date.now());
const grid = src.getGridProfile;
if (grid) this.node.send([{ topic: 'GridProfile', payload: grid }, null, null]);
const raw = src.getOutput();
const influx = this._output.formatMsg(raw, src.config || this.config, 'influxdb');
this.node.send([src.getEffluent, influx, null]);
}
/**
* Handle node-red input messages
*/
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
switch (msg.topic) {
case "clock":
this.source.updateState(msg.timestamp);
send([msg, null, null]);
break;
case "Fluent":
this.source.setInfluent = msg;
break;
case "OTR":
this.source.setOTR = msg;
break;
case "Temperature":
this.source.setTemperature = msg;
break;
case "Dispersion":
this.source.setDispersion = msg;
break;
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:
console.log("Unknown topic: " + msg.topic);
}
if (done) {
done();
}
});
}
/**
* Parse node configuration
* @param {object} uiConfig Config set in UI in node-red
*/
_loadConfig(uiConfig) {
this.config = {
general: {
name: uiConfig.name || this.name,
id: this.node.id,
unit: null,
logging: {
enabled: uiConfig.enableLog,
logLevel: uiConfig.logLevel
}
},
functionality: {
positionVsParent: uiConfig.positionVsParent || 'atEquipment', // Default to 'atEquipment' if not specified
softwareType: "reactor" // should be set in config manager
},
reactor_type: uiConfig.reactor_type,
volume: parseFloat(uiConfig.volume),
length: parseFloat(uiConfig.length),
resolution_L: parseInt(uiConfig.resolution_L),
alpha: parseFloat(uiConfig.alpha),
n_inlets: parseInt(uiConfig.n_inlets),
kla: parseFloat(uiConfig.kla),
initialState: [
parseFloat(uiConfig.S_O_init),
parseFloat(uiConfig.S_I_init),
parseFloat(uiConfig.S_S_init),
parseFloat(uiConfig.S_NH_init),
parseFloat(uiConfig.S_N2_init),
parseFloat(uiConfig.S_NO_init),
parseFloat(uiConfig.S_HCO_init),
parseFloat(uiConfig.X_I_init),
parseFloat(uiConfig.X_S_init),
parseFloat(uiConfig.X_H_init),
parseFloat(uiConfig.X_STO_init),
parseFloat(uiConfig.X_A_init),
parseFloat(uiConfig.X_TS_init)
],
timeStep: parseFloat(uiConfig.timeStep)
}
}
/**
* 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 reactor class based on config
*/
_setupClass() {
let new_reactor;
switch (this.config.reactor_type) {
case "CSTR":
new_reactor = new Reactor_CSTR(this.config);
break;
case "PFR":
new_reactor = new Reactor_PFR(this.config);
break;
default:
console.warn("Unknown reactor type: " + uiConfig.reactor_type);
}
this.source = new_reactor; // 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();
});
}
}
module.exports = nodeClass;
module.exports = nodeClass;

View File

@@ -13,7 +13,7 @@ class ASM3 {
this.kin_params = {
// Hydrolysis
k_H: 9., // hydrolysis rate constant [g X_S g-1 X_H d-1]
K_X: 1., // hydrolysis saturation constant [g X_S g-1 X_H]
K_X: 1., // hydrolysis saturation constant [g X_S g-1 X_H]
// Heterotrophs
k_STO: 12., // storage rate constant [g S_S g-1 X_H d-1]
nu_NO: 0.5, // anoxic reduction factor [-]
@@ -30,7 +30,7 @@ class ASM3 {
b_STO_NO: 0.15, // anoxic respitation rate X_STO [d-1]
// Autotrophs
mu_A_max: 1.3, // maximum specific growth rate [d-1]
K_A_NH: 1.4, // saturation constant S_NH3 [g NH3-N m-3]
K_A_NH: 1.4, // saturation constant S_NH3 [g NH3-N m-3]
K_A_O: 0.5, // saturation constant S_0 [g O2 m-3]
K_A_HCO: 0.5, // saturation constant S_HCO [mole HCO3 m-3]
b_A_O: 0.20, // aerobic respiration rate [d-1]
@@ -132,7 +132,7 @@ class ASM3 {
/**
* Computes the inverse Monod equation rate value for a given concentration and half-saturation constant. Used for inhibition.
* @param {number} c - Concentration of reaction species.
* @param {number} K - Half-saturation constant for the reaction species.
* @param {number} K - Half-saturation constant for the reaction species.
* @returns {number} - Inverse Monod equation rate value for the given concentration and half-saturation constant.
*/
_inv_monod(c, K) {
@@ -171,7 +171,7 @@ class ASM3 {
compute_rates(state, T = 20) {
// state: S_O, S_I, S_S, S_NH, S_N2, S_NO, S_HCO, X_I, X_S, X_H, X_STO, X_A, X_TS
const rates = Array(12);
const [S_O, , S_S, S_NH, , S_NO, S_HCO, , X_S, X_H, X_STO, X_A] = state;
const [S_O, S_I, S_S, S_NH, S_N2, S_NO, S_HCO, X_I, X_S, X_H, X_STO, X_A, X_TS] = state;
const { k_H, K_X, k_STO, nu_NO, K_O, K_NO, K_S, K_STO, mu_H_max, K_NH, K_HCO, b_H_O, b_H_NO, b_STO_O, b_STO_NO, mu_A_max, K_A_NH, K_A_O, K_A_HCO, b_A_O, b_A_NO } = this.kin_params;
const { theta_H, theta_STO, theta_mu_H, theta_b_H_O, theta_b_H_NO, theta_b_STO_O, theta_b_STO_NO, theta_mu_A, theta_b_A_O, theta_b_A_NO } = this.temp_params;
@@ -187,12 +187,12 @@ class ASM3 {
rates[6] = this._arrhenius(b_H_NO, theta_b_H_NO, T) * this._inv_monod(S_O, K_O) * this._monod(S_NO, K_NO) * X_H;
rates[7] = this._arrhenius(b_STO_O, theta_b_STO_O, T) * this._monod(S_O, K_O) * X_H;
rates[8] = this._arrhenius(b_STO_NO, theta_b_STO_NO, T) * this._inv_monod(S_O, K_O) * this._monod(S_NO, K_NO) * X_STO;
// Autotrophs
rates[9] = this._arrhenius(mu_A_max, theta_mu_A, T) * this._monod(S_O, K_A_O) * this._monod(S_NH, K_A_NH) * this._monod(S_HCO, K_A_HCO) * X_A;
rates[10] = this._arrhenius(b_A_O, theta_b_A_O, T) * this._monod(S_O, K_O) * X_A;
rates[11] = this._arrhenius(b_A_NO, theta_b_A_NO, T) * this._inv_monod(S_O, K_A_O) * this._monod(S_NO, K_NO) * X_A;
return rates;
}
@@ -208,4 +208,4 @@ class ASM3 {
}
}
module.exports = ASM3;
module.exports = ASM3;

View File

@@ -13,7 +13,7 @@ class ASM3 {
this.kin_params = {
// Hydrolysis
k_H: 3., // hydrolysis rate constant [g X_S g-1 X_H d-1]
K_X: 1., // hydrolysis saturation constant [g X_S g-1 X_H]
K_X: 1., // hydrolysis saturation constant [g X_S g-1 X_H]
// Heterotrophs
k_STO: 5., // storage rate constant [g S_S g-1 X_H d-1]
nu_NO: 0.6, // anoxic reduction factor [-]
@@ -30,7 +30,7 @@ class ASM3 {
b_STO_NO: 0.1, // anoxic respitation rate X_STO [d-1]
// Autotrophs
mu_A_max: 1.0, // maximum specific growth rate [d-1]
K_A_NH: 1., // saturation constant S_NH3 [g NH3-N m-3]
K_A_NH: 1., // saturation constant S_NH3 [g NH3-N m-3]
K_A_O: 0.5, // saturation constant S_0 [g O2 m-3]
K_A_HCO: 0.5, // saturation constant S_HCO [mole HCO3 m-3]
b_A_O: 0.15, // aerobic respiration rate [d-1]
@@ -132,7 +132,7 @@ class ASM3 {
/**
* Computes the inverse Monod equation rate value for a given concentration and half-saturation constant. Used for inhibition.
* @param {number} c - Concentration of reaction species.
* @param {number} K - Half-saturation constant for the reaction species.
* @param {number} K - Half-saturation constant for the reaction species.
* @returns {number} - Inverse Monod equation rate value for the given concentration and half-saturation constant.
*/
_inv_monod(c, K) {
@@ -171,7 +171,7 @@ class ASM3 {
compute_rates(state, T = 20) {
// state: S_O, S_I, S_S, S_NH, S_N2, S_NO, S_HCO, X_I, X_S, X_H, X_STO, X_A, X_TS
const rates = Array(12);
const [S_O, , S_S, S_NH, , S_NO, S_HCO, , X_S, X_H, X_STO, X_A] = state;
const [S_O, S_I, S_S, S_NH, S_N2, S_NO, S_HCO, X_I, X_S, X_H, X_STO, X_A, X_TS] = state;
const { k_H, K_X, k_STO, nu_NO, K_O, K_NO, K_S, K_STO, mu_H_max, K_NH, K_HCO, b_H_O, b_H_NO, b_STO_O, b_STO_NO, mu_A_max, K_A_NH, K_A_O, K_A_HCO, b_A_O, b_A_NO } = this.kin_params;
const { theta_H, theta_STO, theta_mu_H, theta_b_H_O, theta_b_H_NO, theta_b_STO_O, theta_b_STO_NO, theta_mu_A, theta_b_A_O, theta_b_A_NO } = this.temp_params;
@@ -187,12 +187,12 @@ class ASM3 {
rates[6] = this._arrhenius(b_H_NO, theta_b_H_NO, T) * this._inv_monod(S_O, K_O) * this._monod(S_NO, K_NO) * X_H;
rates[7] = this._arrhenius(b_STO_O, theta_b_STO_O, T) * this._monod(S_O, K_O) * X_H;
rates[8] = this._arrhenius(b_STO_NO, theta_b_STO_NO, T) * this._inv_monod(S_O, K_O) * this._monod(S_NO, K_NO) * X_STO;
// Autotrophs
rates[9] = this._arrhenius(mu_A_max, theta_mu_A, T) * this._monod(S_O, K_A_O) * this._monod(S_NH, K_A_NH) * this._monod(S_HCO, K_A_HCO) * X_A;
rates[10] = this._arrhenius(b_A_O, theta_b_A_O, T) * this._monod(S_O, K_O) * X_A;
rates[11] = this._arrhenius(b_A_NO, theta_b_A_NO, T) * this._inv_monod(S_O, K_A_O) * this._monod(S_NO, K_NO) * X_A;
return rates;
}
@@ -208,4 +208,4 @@ class ASM3 {
}
}
module.exports = ASM3;
module.exports = ASM3;

View File

@@ -1,134 +1,420 @@
'use strict';
const ASM3 = require('./reaction_modules/asm3_class.js');
const { create, all, isArray } = require('mathjs');
const { assertNoNaN } = require('./utils.js');
const { childRegistrationUtils, logger, MeasurementContainer } = require('generalFunctions');
const EventEmitter = require('events');
const { BaseDomain, statusBadge, POSITIONS } = require('generalFunctions');
const Reactor_CSTR = require('./kinetics/cstr.js');
const Reactor_PFR = require('./kinetics/pfr.js');
const mathConfig = {
matrix: 'Array' // use Array as the matrix type
};
const SPECIES_KEYS = ['S_O','S_I','S_S','S_NH','S_N2','S_NO','S_HCO',
'X_I','X_S','X_H','X_STO','X_A','X_TS'];
const math = create(all, mathConfig);
// Reactor — biological reactor orchestrator (Unit-level). Wraps a CSTR or
// PFR kinetics engine and exposes the BaseDomain surface to BaseNodeAdapter.
// The engines own the ASM3 integration; this class wires child registration
// through ChildRouter, holds the validated config, and presents getOutput /
// getStatusBadge.
class Reactor extends BaseDomain {
static name = 'reactor';
const S_O_INDEX = 0;
const NUM_SPECIES = 13;
const BC_PADDING = 2;
const DEBUG = false;
configure() {
const flat = this._flattenEngineConfig(this.config);
this.engine = this._buildEngine(flat);
class Reactor {
/**
* Reactor base class.
* @param {object} config - Configuration object containing reactor parameters.
*/
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.upstreamReactor = null;
this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility
this.parent = []; // Gets assigned via child registration
// Re-emit upstream-reactor stateChange and engine stateChange events on
// the BaseDomain emitter so adapter listeners pick them up uniformly.
this.engine.emitter.on('stateChange', (t) => this.emitter.emit('stateChange', t));
this.upstreamReactor = null;
this.downstreamReactor = null;
// ChildRouter dispatches to engine handlers — keeps the existing
// _connectMeasurement / _connectReactor wiring intact, just centralised.
this.router.onRegister('measurement', (child) => this.engine._connectMeasurement(child));
this.router.onRegister('reactor', (child) => this.engine._connectReactor(child));
this.asm = new ASM3();
// Bridge engine.measurements into the BaseDomain measurements container
// so getFlattenedOutput surfaces temperature / oxygen series.
this.measurements = this.engine.measurements;
this.volume = config.volume; // fluid volume reactor [m3]
this.Fs = Array(config.n_inlets).fill(0); // fluid debits per inlet [m3 d-1]
this.Cs_in = Array.from(Array(config.n_inlets), () => new Array(NUM_SPECIES).fill(0)); // composition influents
this.OTR = 0.0; // oxygen transfer rate [g O2 d-1 m-3]
this.temperature = 20; // temperature [C]
this.kla = config.kla; // if NaN, use externaly provided OTR [d-1]
this.currentTime = Date.now(); // milliseconds since epoch [ms]
this.timeStep = 1 / (24*60*60) * this.config.timeStep; // time step in seconds, converted to days.
this.speedUpFactor = 100; // speed up factor for simulation, 60 means 1 minute per simulated second
}
// Translate the nested schema config (reactor.*, initialState.*) into the
// flat shape the kinetics engines accept.
_flattenEngineConfig(config) {
const reactor = config.reactor || {};
const init = config.initialState || {};
const initialState = SPECIES_KEYS.map((k) => Number(init[k] ?? 0));
return {
general: config.general,
functionality: config.functionality,
reactor_type: reactor.reactor_type ?? 'CSTR',
volume: Number(reactor.volume),
length: Number(reactor.length),
resolution_L: Number(reactor.resolution_L),
alpha: Number(reactor.alpha),
n_inlets: Number(reactor.n_inlets),
kla: Number(reactor.kla),
timeStep: Number(reactor.timeStep),
speedUpFactor: Number(reactor.speedUpFactor) || 1,
initialState,
};
/**
* Setter for influent data.
* @param {object} input - Input object (msg) containing payload with inlet index, flow rate, and concentrations.
*/
set setInfluent(input) {
let index_in = input.payload.inlet;
this.Fs[index_in] = input.payload.F;
this.Cs_in[index_in] = input.payload.C;
}
_buildEngine(flat) {
// The schema enum validator lowercases the configured value, so accept
// either case.
switch (String(flat.reactor_type || '').toUpperCase()) {
case 'CSTR': return new Reactor_CSTR(flat);
case 'PFR': return new Reactor_PFR(flat);
/**
* Setter for OTR (Oxygen Transfer Rate).
* @param {object} input - Input object (msg) containing payload with OTR value [g O2 d-1 m-3].
*/
set setOTR(input) {
this.OTR = input.payload;
}
/**
* Getter for effluent data.
* @returns {object} Effluent data object (msg), defaults to inlet 0.
*/
get getEffluent() { // getter for Effluent, defaults to inlet 0
if (isArray(this.state.at(-1))) {
return { topic: "Fluent", payload: { inlet: 0, F: math.sum(this.Fs), C: this.state.at(-1) }, timestamp: this.currentTime };
}
return { topic: "Fluent", payload: { inlet: 0, F: math.sum(this.Fs), C: this.state }, timestamp: this.currentTime };
}
/**
* Calculate the oxygen transfer rate (OTR) based on the dissolved oxygen concentration and temperature.
* @param {number} S_O - Dissolved oxygen concentration [g O2 m-3].
* @param {number} T - Temperature in Celsius, default to 20 C.
* @returns {number} - Calculated OTR [g O2 d-1 m-3].
*/
_calcOTR(S_O, T = 20.0) { // caculate the OTR using basic correlation, default to temperature: 20 C
let S_O_sat = 14.652 - 4.1022e-1 * T + 7.9910e-3 * T*T + 7.7774e-5 * T*T*T;
return this.kla * (S_O_sat - S_O);
}
/**
* Clip values in an array to zero.
* @param {Array} arr - Array of values to clip.
* @returns {Array} - New array with values clipped to zero.
*/
_arrayClip2Zero(arr) {
if (Array.isArray(arr)) {
return arr.map(x => this._arrayClip2Zero(x));
} else {
return arr < 0 ? 0 : arr;
}
}
registerChild(child, softwareType) {
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;
default:
this.logger.warn(`Unknown reactor type: ${flat.reactor_type}. Falling back to CSTR.`);
return new Reactor_CSTR(flat);
this.logger.error(`Unrecognized softwareType: ${softwareType}`);
}
}
// Adapter input setters — forwarded straight to the engine.
set setInfluent(msg) { this.engine.setInfluent = msg; }
set setOTR(msg) { this.engine.setOTR = msg; }
set setTemperature(msg) { this.engine.setTemperature = msg; }
set setDispersion(msg) { if (this.engine instanceof Reactor_PFR) this.engine.setDispersion = msg; }
updateState(t) { this.engine.updateState(t); this.notifyOutputChanged(); }
// Engine pass-through — needed so the BaseNodeAdapter tick loop (and
// tests calling reactor.tick(dt) directly) drive the ASM integration.
// Without this the Node-RED tick fires `source.tick?.()`, gets undefined,
// and the kinetics state never advances.
tick(timeStep) {
const result = this.engine.tick(timeStep);
this.notifyOutputChanged();
return result;
}
get getEffluent() { return this.engine.getEffluent; }
get getGridProfile() { return this.engine.getGridProfile; }
get temperature() { return this.engine.temperature; }
// Per-tick output for Port 0 / Port 1. Carries the effluent vector plus
// a flat per-species block keyed by SPECIES_KEYS for InfluxDB telemetry.
getOutput() {
const eff = this.engine.getEffluent;
const C = Array.isArray(eff?.payload?.C) ? eff.payload.C : [];
const out = {
flow_total: Number(eff?.payload?.F),
temperature: Number(this.engine.temperature),
};
for (let i = 0; i < Math.min(SPECIES_KEYS.length, C.length); i += 1) {
const v = Number(C[i]);
if (Number.isFinite(v)) out[SPECIES_KEYS[i]] = v;
_connectMeasurement(measurementChild) {
if (!measurementChild) {
this.logger.warn("Invalid measurement provided.");
return;
}
return out;
const position = measurementChild.config.functionality.positionVsParent;
const measurementType = measurementChild.config.asset.type;
const eventName = `${measurementType}.measured.${position}`;
// 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")
.position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
this._updateMeasurement(measurementType, eventData.value, position, eventData);
});
}
getStatusBadge() {
const eff = this.engine.getEffluent;
const F = Number(eff?.payload?.F) || 0;
const SO = Array.isArray(eff?.payload?.C) ? Number(eff.payload.C[0]) : NaN;
const so = Number.isFinite(SO) ? SO.toFixed(2) : '—';
return statusBadge.compose(
[`${this.engine.constructor.name.replace('Reactor_', '')}`,
`T=${Number(this.engine.temperature).toFixed(1)} C`,
`F=${F.toFixed(2)} m³/d`,
`S_O=${so} mg/L`],
{ fill: 'green', shape: 'dot' },
);
_connectReactor(reactorChild) {
if (!reactorChild) {
this.logger.warn("Invalid reactor provided.");
return;
}
this.upstreamReactor = reactorChild;
reactorChild.downstreamReactor = this;
reactorChild.emitter.on("stateChange", (data) => {
this.logger.debug(`State change of upstream reactor detected.`);
this.updateState(data);
});
}
close() {
this.engine?.emitter?.removeAllListeners?.();
super.close();
_updateMeasurement(measurementType, value, position, context) {
this.logger.debug(`---------------------- updating ${measurementType} ------------------ `);
switch (measurementType) {
case "temperature":
if (position == "atEquipment") {
this.temperature = value;
}
break;
default:
this.logger.error(`Type '${measurementType}' not recognized for measured update.`);
return;
}
}
/**
* Update the reactor state based on the new time.
* @param {number} newTime - New time to update reactor state to, in milliseconds since epoch.
*/
updateState(newTime = Date.now()) { // expect update with timestamp
const day2ms = 1000 * 60 * 60 * 24;
if (this.upstreamReactor) {
this.setInfluent = this.upstreamReactor.getEffluent;
}
let n_iter = Math.floor(this.speedUpFactor * (newTime-this.currentTime) / (this.timeStep*day2ms));
if (n_iter) {
let n = 0;
while (n < n_iter) {
this.tick(this.timeStep);
n += 1;
}
this.currentTime += n_iter * this.timeStep * day2ms / this.speedUpFactor;
this.emitter.emit("stateChange", this.currentTime);
}
}
}
module.exports = Reactor;
module.exports.Reactor = Reactor;
module.exports.Reactor_CSTR = Reactor_CSTR;
module.exports.Reactor_PFR = Reactor_PFR;
// POSITIONS is consumed by older test setups; surface it here so they don't
// need to chase down generalFunctions internals.
module.exports.POSITIONS = POSITIONS;
class Reactor_CSTR extends Reactor {
/**
* Reactor_CSTR class for Continuous Stirred Tank Reactor.
* @param {object} config - Configuration object containing reactor parameters.
*/
constructor(config) {
super(config);
this.state = config.initialState;
}
/**
* Tick the reactor state using the forward Euler method.
* @param {number} time_step - Time step for the simulation [d].
* @returns {Array} - New reactor state.
*/
tick(time_step) { // tick reactor state using forward Euler method
const inflow = math.multiply(math.divide([this.Fs], this.volume), this.Cs_in)[0];
const outflow = math.multiply(-1 * math.sum(this.Fs) / this.volume, this.state);
const reaction = this.asm.compute_dC(this.state, this.temperature);
const transfer = Array(NUM_SPECIES).fill(0.0);
transfer[S_O_INDEX] = isNaN(this.kla) ? this.OTR : this._calcOTR(this.state[S_O_INDEX], this.temperature); // calculate OTR if kla is not NaN, otherwise use externaly calculated OTR
const dC_total = math.multiply(math.add(inflow, outflow, reaction, transfer), time_step)
this.state = this._arrayClip2Zero(math.add(this.state, dC_total)); // clip value element-wise to avoid negative concentrations
if(DEBUG){
assertNoNaN(dC_total, "change in state");
assertNoNaN(this.state, "new state");
}
return this.state;
}
}
class Reactor_PFR extends Reactor {
/**
* Reactor_PFR class for Plug Flow Reactor.
* @param {object} config - Configuration object containing reactor parameters.
*/
constructor(config) {
super(config);
this.length = config.length; // reactor length [m]
this.n_x = config.resolution_L; // number of slices
this.d_x = this.length / this.n_x;
this.A = this.volume / this.length; // crosssectional area [m2]
this.alpha = config.alpha;
this.state = Array.from(Array(this.n_x), () => config.initialState.slice());
this.extendedState = Array.from(Array(this.n_x + 2*BC_PADDING), () => new Array(NUM_SPECIES).fill(0));
// initialise extended state
this.state.forEach((row, i) => this.extendedState[i+BC_PADDING] = row);
this.D = 0.0; // axial dispersion [m2 d-1]
this.D_op = this._makeDoperator();
assertNoNaN(this.D_op, "Derivative operator");
this.D2_op = this._makeD2operator();
assertNoNaN(this.D2_op, "Second derivative operator");
}
/**
* Setter for axial dispersion.
* @param {object} input - Input object (msg) containing payload with dispersion value [m2 d-1].
*/
set setDispersion(input) {
this.D = input.payload;
}
updateState(newTime) {
super.updateState(newTime);
let Pe_local = this.d_x*math.sum(this.Fs)/(this.D*this.A)
let Co_D = this.D*this.timeStep/(this.d_x*this.d_x);
(Pe_local >= 2) && this.logger.warn(`Local Péclet number (${Pe_local}) is too high! Increase reactor resolution.`);
(Co_D >= 0.5) && this.logger.warn(`Courant number (${Co_D}) is too high! Reduce time step size.`);
if(DEBUG) {
console.log("Inlet state max " + math.max(this.state[0]))
console.log("Pe total " + this.length*math.sum(this.Fs)/(this.D*this.A));
console.log("Pe local " + Pe_local);
console.log("Co ad " + math.sum(this.Fs)*this.timeStep/(this.A*this.d_x));
console.log("Co D " + Co_D);
}
}
/**
* Tick the reactor state using explicit finite difference method.
* @param {number} time_step - Time step for the simulation [d].
* @returns {Array} - New reactor state.
*/
tick(time_step) {
this._applyBoundaryConditions();
const dispersion = math.multiply(this.D / (this.d_x*this.d_x), this.D2_op, this.extendedState);
const advection = math.multiply(-1 * math.sum(this.Fs) / (this.A*this.d_x), this.D_op, this.extendedState);
const reaction = this.extendedState.map((state_slice) => this.asm.compute_dC(state_slice, this.temperature));
const transfer = Array.from(Array(this.n_x+2*BC_PADDING), () => new Array(NUM_SPECIES).fill(0));
if (isNaN(this.kla)) { // calculate OTR if kla is not NaN, otherwise use externally calculated OTR
for (let i = BC_PADDING+1; i < BC_PADDING+this.n_x - 1; i++) {
transfer[i][S_O_INDEX] = this.OTR * this.n_x/(this.n_x-2);
}
} else {
for (let i = BC_PADDING+1; i < BC_PADDING+this.n_x - 1; i++) {
transfer[i][S_O_INDEX] = this._calcOTR(this.extendedState[i][S_O_INDEX], this.temperature) * this.n_x/(this.n_x-2);
}
}
const dC_total = math.multiply(math.add(dispersion, advection, reaction, transfer).slice(BC_PADDING, this.n_x+BC_PADDING), time_step);
const stateNew = math.add(this.state, dC_total);
if (DEBUG) {
assertNoNaN(dispersion, "dispersion");
assertNoNaN(advection, "advection");
assertNoNaN(reaction, "reaction");
assertNoNaN(dC_total, "change in state");
assertNoNaN(stateNew, "new state post BC");
}
this.state = this._arrayClip2Zero(stateNew);
this.state.forEach((row, i) => this.extendedState[i+BC_PADDING] = row);
return stateNew;
}
_updateMeasurement(measurementType, value, position, context) {
switch(measurementType) {
case "quantity (oxygen)":
let grid_pos = Math.round(context.distance / this.config.length * this.n_x);
this.state[grid_pos][S_O_INDEX] = value; // naive approach for reconciling measurements and simulation
break;
default:
super._updateMeasurement(measurementType, value, position, context);
}
}
/**
* Apply boundary conditions to the reactor state.
* for inlet, apply generalised Danckwerts BC, if there is not flow, apply Neumann BC with no flux
* for outlet, apply regular Danckwerts BC (Neumann BC with no flux)
*/
_applyBoundaryConditions() {
if (this.upstreamReactor) {
for (let i = 0; i < BC_PADDING; i++) {
this.extendedState[i] = this.upstreamReactor.state.at(i-BC_PADDING);
}
} else {
if (math.sum(this.Fs) > 0) { // Danckwerts BC
const BC_C_in = math.multiply(1 / math.sum(this.Fs), [this.Fs], this.Cs_in)[0];
const BC_dispersion_term = (1-this.alpha)*this.D*this.A/(math.sum(this.Fs)*this.d_x);
this.extendedState[BC_PADDING] = math.multiply(1/(1+BC_dispersion_term), math.add(BC_C_in, math.multiply(BC_dispersion_term, this.extendedState[BC_PADDING+1])));
this.extendedState[BC_PADDING-1] = math.add(math.multiply(2, this.extendedState[BC_PADDING]), math.multiply(-2, this.extendedState[BC_PADDING+2]), this.extendedState[BC_PADDING+3]);
} else {
for (let i = 0; i < BC_PADDING; i++) {
this.extendedState[i] = this.extendedState[BC_PADDING];
}
}
}
if (this.downstreamReactor) {
for (let i = 0; i < BC_PADDING; i++) {
this.extendedState[this.n_x+BC_PADDING+i] = this.downstreamReactor.state[i];
}
} else {
// Neumann BC (no flux)
for (let i = 0; i < BC_PADDING; i++) {
this.extendedState[BC_PADDING+this.n_x+i] = this.extendedState.at(-1-BC_PADDING);
}
}
}
/**
* Create finite difference first derivative operator.
* @returns {Array} - First derivative operator matrix.
*/
_makeDoperator() { // create gradient operator
const D_size = this.n_x+2*BC_PADDING;
const I = math.resize(math.diag(Array(D_size).fill(1/12), -2), [D_size, D_size]);
const A = math.resize(math.diag(Array(D_size).fill(-2/3), -1), [D_size, D_size]);
const B = math.resize(math.diag(Array(D_size).fill(2/3), 1), [D_size, D_size]);
const C = math.resize(math.diag(Array(D_size).fill(-1/12), 2), [D_size, D_size]);
const D = math.add(I, A, B, C);
// set by BCs elsewhere
D.forEach((row, i) => i < BC_PADDING || i >= this.n_x+BC_PADDING ? row.fill(0) : row);
return D;
}
/**
* Create central finite difference second derivative operator.
* @returns {Array} - Second derivative operator matrix.
*/
_makeD2operator() { // create the central second derivative operator
const D_size = this.n_x+2*BC_PADDING;
const I = math.diag(Array(D_size).fill(-2), 0);
const A = math.resize(math.diag(Array(D_size).fill(1), 1), [D_size, D_size]);
const B = math.resize(math.diag(Array(D_size).fill(1), -1), [D_size, D_size]);
const D2 = math.add(I, A, B);
// set by BCs elsewhere
D2.forEach((row, i) => i < BC_PADDING || i >= this.n_x+BC_PADDING ? row.fill(0) : row);
return D2;
}
}
module.exports = { Reactor_CSTR, Reactor_PFR };
// DEBUG
// state: S_O, S_I, S_S, S_NH, S_N2, S_NO, S_HCO, X_I, X_S, X_H, X_STO, X_A, X_TS
// let initial_state = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1];
// const Reactor = new Reactor_PFR(200, 10, 10, 1, 100, initial_state);
// Reactor.Cs_in[0] = [0.0, 30., 100., 16., 0., 0., 5., 25., 75., 30., 0., 0., 125.];
// Reactor.Fs[0] = 10;
// Reactor.D = 0.01;
// let N = 0;
// while (N < 5000) {
// console.log(Reactor.tick(0.001));
// N += 1;
// }

View File

@@ -1,18 +1,18 @@
/**
* Assert that no NaN values are present in an array.
* @param {Array} arr
* @param {string} label
*/
function assertNoNaN(arr, label = "array") {
if (Array.isArray(arr)) {
for (const el of arr) {
assertNoNaN(el, label);
}
} else {
if (Number.isNaN(arr)) {
throw new Error(`NaN detected in ${label}!`);
}
}
}
/**
* Assert that no NaN values are present in an array.
* @param {Array} arr
* @param {string} label
*/
function assertNoNaN(arr, label = "array") {
if (Array.isArray(arr)) {
for (const el of arr) {
assertNoNaN(el, label);
}
} else {
if (Number.isNaN(arr)) {
throw new Error(`NaN detected in ${label}!`);
}
}
}
module.exports = { assertNoNaN };

View File

@@ -1,12 +0,0 @@
# reactor 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

View File

View File

@@ -1,83 +0,0 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The pre-refactor _loadConfig / _setupClass private methods are gone —
// config build is exposed via buildDomainConfig (override hook in
// CONTRACTS.md §2), and engine selection is observable via
// `inst.source.engine instanceof Reactor_CSTR | Reactor_PFR` after a
// full `new nodeClass(...)` construction.
const test = require('node:test');
const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass');
const { makeUiConfig } = require('../helpers/factories');
function makeRED() { return { nodes: { getNode: () => null } }; }
function makeNode(id = 'reactor-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('buildDomainConfig coerces numeric fields and builds initial state vector', () => {
const node = makeNode();
const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
const dc = inst.buildDomainConfig(
makeUiConfig({
volume: '12.5',
length: '9',
resolution_L: '7',
alpha: '0.5',
n_inlets: '3',
timeStep: '2',
S_O_init: '1.1',
}),
);
assert.equal(dc.reactor.volume, 12.5);
assert.equal(dc.reactor.length, 9);
assert.equal(dc.reactor.resolution_L, 7);
assert.equal(dc.reactor.alpha, 0.5);
assert.equal(dc.reactor.n_inlets, 3);
assert.equal(dc.reactor.timeStep, 2);
assert.equal(Object.keys(dc.initialState).length, 13);
assert.equal(dc.initialState.S_O, 1.1);
} finally {
closeNode(node);
}
});
test('Reactor wrapper instantiates CSTR engine when configured as CSTR', () => {
const node = makeNode();
const inst = new nodeClass(makeUiConfig({ reactor_type: 'CSTR' }), makeRED(), node, 'reactor');
try {
assert.ok(inst.source.engine instanceof Reactor_CSTR);
} finally {
closeNode(node);
}
});
test('Reactor wrapper instantiates PFR engine when configured as PFR', () => {
const node = makeNode();
const inst = new nodeClass(makeUiConfig({ reactor_type: 'PFR' }), makeRED(), node, 'reactor');
try {
assert.ok(inst.source.engine instanceof Reactor_PFR);
} finally {
closeNode(node);
}
});

View File

@@ -1,42 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_CSTR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
const NUM_SPECIES = 13;
test('Reactor_CSTR tick clips negative concentrations to zero', () => {
const reactor = new Reactor_CSTR(
makeReactorConfig({
reactor_type: 'CSTR',
volume: 1,
n_inlets: 1,
kla: NaN,
S_O_init: 0.1,
S_I_init: 0.1,
S_S_init: 0.1,
S_NH_init: 0.1,
S_N2_init: 0.1,
S_NO_init: 0.1,
S_HCO_init: 0.1,
X_I_init: 0.1,
X_S_init: 0.1,
X_H_init: 0.1,
X_STO_init: 0.1,
X_A_init: 0.1,
X_TS_init: 0.1,
}),
);
reactor.asm = {
compute_dC: () => Array(NUM_SPECIES).fill(0),
};
reactor.Fs[0] = 1;
reactor.Cs_in[0] = Array(NUM_SPECIES).fill(0);
reactor.tick(1);
assert.equal(reactor.state.every((v) => Number.isFinite(v) && v >= 0), true);
assert.equal(reactor.state.every((v) => v === 0), true);
});

View File

@@ -1,38 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
test('CSTR getEffluent returns flat concentration vector', () => {
const reactor = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR', n_inlets: 1 }));
reactor.state = Array.from({ length: 13 }, (_, i) => i + 1);
reactor.Fs[0] = 5;
const effluent = reactor.getEffluent;
assert.equal(effluent.topic, 'Fluent');
assert.equal(effluent.payload.inlet, 0);
assert.equal(effluent.payload.F, 5);
assert.deepEqual(effluent.payload.C, reactor.state);
});
test('PFR getEffluent returns last slice concentration vector', () => {
const reactor = new Reactor_PFR(
makeReactorConfig({ reactor_type: 'PFR', n_inlets: 1, length: 10, resolution_L: 4 }),
);
reactor.state = [
Array(13).fill(10),
Array(13).fill(20),
Array(13).fill(30),
Array(13).fill(40),
];
reactor.Fs[0] = 7;
const effluent = reactor.getEffluent;
assert.equal(effluent.topic, 'Fluent');
assert.equal(effluent.payload.F, 7);
assert.deepEqual(effluent.payload.C, Array(13).fill(40));
});

View File

@@ -1,45 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
test('CSTR getGridProfile returns null', () => {
const reactor = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR' }));
assert.equal(reactor.getGridProfile, null);
});
test('PFR getGridProfile returns state matrix with correct dimensions', () => {
const n_x = 8;
const length = 40;
const reactor = new Reactor_PFR(
makeReactorConfig({ reactor_type: 'PFR', resolution_L: n_x, length }),
);
const profile = reactor.getGridProfile;
assert.notEqual(profile, null);
assert.equal(profile.n_x, n_x);
assert.equal(profile.d_x, length / n_x);
assert.equal(profile.length, length);
assert.equal(profile.grid.length, n_x, 'grid should have n_x rows');
assert.equal(profile.grid[0].length, 13, 'each row should have 13 species');
assert.ok(Array.isArray(profile.species), 'species list should be an array');
assert.equal(profile.species.length, 13);
assert.equal(profile.species[3], 'S_NH');
assert.equal(typeof profile.timestamp, 'number');
});
test('PFR getGridProfile is mutation-safe', () => {
const reactor = new Reactor_PFR(
makeReactorConfig({ reactor_type: 'PFR', resolution_L: 5, length: 10 }),
);
const profile = reactor.getGridProfile;
const originalValue = reactor.state[0][3]; // S_NH at cell 0
// Mutate the returned grid
profile.grid[0][3] = 999;
// Reactor internal state should be unchanged
assert.equal(reactor.state[0][3], originalValue, 'mutating grid copy must not affect reactor state');
});

View File

@@ -1,111 +0,0 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The pre-refactor _attachInputHandler private switch is gone — input
// dispatch goes through the commands registry that BaseNodeAdapter builds
// at construction. Tests fire msgs through `node.handlers.input` and
// observe via `node.sends`, `inst.source.engine.*`, and per-fire calls
// captured on a child stub registered through `RED.nodes.getNode(id)`.
const test = require('node:test');
const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { makeUiConfig } = require('../helpers/factories');
function makeNode(id = 'reactor-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function makeRED(nodeMap = {}) {
return { nodes: { getNode: (id) => nodeMap[id] || null } };
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('legacy alias topics drive engine setters and updateState', async () => {
const childSource = {
id: 'child-source-A',
config: { general: { id: 'child-source-A' }, functionality: { softwareType: 'measurement', positionVsParent: 'upstream' }, asset: { type: 'temperature' } },
};
const node = makeNode();
const RED = makeRED({ childA: { source: childSource } });
const inst = new nodeClass(makeUiConfig(), RED, node, 'reactor');
try {
let doneCount = 0;
const done = () => { doneCount += 1; };
// data.clock alias → updateState(timestamp). Capture currentTime
// before/after to verify the engine advanced.
const t0 = inst.source.engine.currentTime;
await node.handlers.input({ topic: 'clock', timestamp: t0 + 1 }, () => {}, done);
// Fluent alias → engine setInfluent setter.
await node.handlers.input(
{ topic: 'Fluent', payload: { inlet: 0, F: 7, C: [1,2,3,4,5,6,7,8,9,10,11,12,13] } },
() => {}, done,
);
assert.equal(inst.source.engine.Fs[0], 7);
assert.deepEqual(inst.source.engine.Cs_in[0], [1,2,3,4,5,6,7,8,9,10,11,12,13]);
// OTR alias → engine setOTR setter.
await node.handlers.input({ topic: 'OTR', payload: 3.5 }, () => {}, done);
assert.equal(inst.source.engine.OTR, 3.5);
// Temperature alias → engine setTemperature setter.
await node.handlers.input({ topic: 'Temperature', payload: 18.2 }, () => {}, done);
assert.equal(inst.source.engine.temperature, 18.2);
// Dispersion alias — CSTR engine does not own a setDispersion setter
// (only PFR does); the Reactor wrapper guards on engine type and the
// dispatch should silently return without throwing.
await node.handlers.input({ topic: 'Dispersion', payload: 0.2 }, () => {}, done);
// registerChild alias → registers via childRegistrationUtils.
// The handler resolves the child via RED.nodes.getNode(payload).source.
await node.handlers.input(
{ topic: 'registerChild', payload: 'childA', positionVsParent: 'upstream' },
() => {}, done,
);
assert.equal(doneCount, 6);
} finally {
closeNode(node);
}
});
test('canonical topics are accepted (data.fluent, data.otr, data.temperature)', async () => {
const node = makeNode();
const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let done = 0;
await node.handlers.input(
{ topic: 'data.fluent', payload: { inlet: 0, F: 11, C: [0,0,0,0,0,0,0,0,0,0,0,0,0] } },
() => {}, () => { done += 1; },
);
assert.equal(inst.source.engine.Fs[0], 11);
await node.handlers.input({ topic: 'data.otr', payload: 4.2 }, () => {}, () => { done += 1; });
assert.equal(inst.source.engine.OTR, 4.2);
await node.handlers.input({ topic: 'data.temperature', payload: 19.7 }, () => {}, () => { done += 1; });
assert.equal(inst.source.engine.temperature, 19.7);
assert.equal(done, 3);
} finally {
closeNode(node);
}
});

View File

@@ -1,27 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_PFR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
test('Reactor_PFR derivative operators have expected dimensions and boundary rows', () => {
const reactor = new Reactor_PFR(
makeReactorConfig({
reactor_type: 'PFR',
length: 12,
resolution_L: 6,
volume: 60,
n_inlets: 1,
}),
);
assert.equal(reactor.D_op.length, reactor.n_x);
assert.equal(reactor.D2_op.length, reactor.n_x);
assert.equal(reactor.D_op.every((row) => row.length === reactor.n_x), true);
assert.equal(reactor.D2_op.every((row) => row.length === reactor.n_x), true);
assert.deepEqual(reactor.D_op[0], Array(reactor.n_x).fill(0));
assert.deepEqual(reactor.D_op[reactor.n_x - 1], Array(reactor.n_x).fill(0));
assert.deepEqual(reactor.D2_op[0], Array(reactor.n_x).fill(0));
assert.deepEqual(reactor.D2_op[reactor.n_x - 1], Array(reactor.n_x).fill(0));
});

View File

@@ -1,84 +0,0 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The pre-refactor _registerChild method was renamed to
// _scheduleRegistration inside BaseNodeAdapter and now fires automatically
// 100ms after construction. We verify the emission by capturing the Port-2
// message on `node.sends` after the registration delay elapses.
const test = require('node:test');
const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { makeUiConfig } = require('../helpers/factories');
function makeRED() { return { nodes: { getNode: () => null } }; }
function makeNode(id = 'reactor-node-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('scheduled child.register message lands on Port 2 after construction', async () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ positionVsParent: 'downstream' }),
makeRED(),
node,
'reactor',
);
try {
// BaseNodeAdapter._scheduleRegistration uses a 100ms setTimeout; wait
// slightly longer to let it fire.
await new Promise((r) => setTimeout(r, 130));
// The registration send is the [null, null, {child.register}] triple.
const regSends = node.sends.filter(
(s) => Array.isArray(s) && s[0] === null && s[1] === null && s[2] && s[2].topic === 'child.register',
);
assert.equal(regSends.length, 1, 'exactly one child.register message expected');
const msg = regSends[0][2];
assert.equal(msg.topic, 'child.register');
assert.equal(msg.payload, node.id);
assert.equal(msg.positionVsParent, 'downstream');
// After construction the source is exposed on the node for sibling lookup.
assert.strictEqual(node.source, inst.source);
} finally {
closeNode(node);
}
});
test('child.register handler ignores unknown child ids without throwing', async () => {
const node = makeNode();
const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let done = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ topic: 'child.register', payload: 'missing-child', positionVsParent: 'upstream' },
() => {},
() => { done += 1; },
);
});
assert.equal(done, 1);
// No child should have been registered into the engine's registry.
const registered = inst.source.engine.childRegistrationUtils;
assert.ok(registered, 'childRegistrationUtils exists on engine');
} finally {
closeNode(node);
}
});

View File

@@ -1,91 +0,0 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface for
// the nodeClass-level checks, and the public Reactor_CSTR engine surface
// for the domain-level checks. The pre-refactor private nodeClass methods
// are gone — `buildDomainConfig` is the documented override hook
// (CONTRACTS.md §2) and is fair game to call on a real constructed
// instance.
const test = require('node:test');
const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { Reactor_CSTR } = require('../../src/specificClass');
const { makeReactorConfig, makeUiConfig } = require('../helpers/factories');
function makeRED() { return { nodes: { getNode: () => null } }; }
function makeNode(id = 'reactor-node-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('Reactor_CSTR engine defaults speedUpFactor to 1 when not in config', () => {
const config = makeReactorConfig();
const reactor = new Reactor_CSTR(config);
assert.equal(reactor.speedUpFactor, 1, 'speedUpFactor should default to 1');
});
test('Reactor_CSTR engine accepts speedUpFactor from config', () => {
const config = makeReactorConfig();
config.speedUpFactor = 10;
const reactor = new Reactor_CSTR(config);
assert.equal(reactor.speedUpFactor, 10, 'speedUpFactor should be read from config');
});
test('Reactor_CSTR engine accepts speedUpFactor = 60 for accelerated simulation', () => {
const config = makeReactorConfig();
config.speedUpFactor = 60;
const reactor = new Reactor_CSTR(config);
assert.equal(reactor.speedUpFactor, 60, 'speedUpFactor=60 should be accepted');
});
test('buildDomainConfig propagates speedUpFactor from uiConfig', () => {
const node = makeNode();
const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
const dc = inst.buildDomainConfig(makeUiConfig({ speedUpFactor: 5 }));
assert.equal(dc.reactor.speedUpFactor, 5);
} finally {
closeNode(node);
}
});
test('buildDomainConfig defaults speedUpFactor to 1 when missing from uiConfig', () => {
const node = makeNode();
const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
const ui = makeUiConfig();
delete ui.speedUpFactor;
const dc = inst.buildDomainConfig(ui);
assert.equal(dc.reactor.speedUpFactor, 1);
} finally {
closeNode(node);
}
});
test('updateState with speedUpFactor=1 advances roughly real-time', () => {
const config = makeReactorConfig();
config.speedUpFactor = 1;
config.n_inlets = 1;
const reactor = new Reactor_CSTR(config);
const t0 = reactor.currentTime;
reactor.updateState(t0 + 2000);
const elapsed = reactor.currentTime - t0;
assert.ok(elapsed < 5000, `Elapsed ${elapsed}ms should be close to 2000ms, not 120000ms (old 60x factor)`);
});

View File

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

View File

@@ -1,44 +0,0 @@
'use strict';
// Locks in the contract that `config.timeStep` is interpreted as SECONDS by
// the reactor kinetics engine. Before 2026-05-19 the schema labelled the field
// `unit: "h"` while reactor.html labelled it `[s]` and baseEngine divided by
// 86400 (seconds-per-day) to convert to internal days. A 0.001 schema default
// — read as hours — would have produced a 3.6 s step; read as seconds it is a
// 1 ms step. The fix aligned the schema to seconds. This test prevents the
// drift from reappearing.
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_CSTR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
const SECONDS_PER_DAY = 24 * 60 * 60;
function makeEngine(timeStepSeconds) {
return new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR', n_inlets: 1, timeStep: timeStepSeconds }));
}
test('engine stores timeStep in days, treating input as seconds', () => {
const eng = makeEngine(1);
assert.ok(Math.abs(eng.timeStep - 1 / SECONDS_PER_DAY) < 1e-15,
`engine.timeStep should be 1/86400 days for a 1-second config; got ${eng.timeStep}`);
});
test('engine timeStep scales linearly with config.timeStep (seconds in)', () => {
const a = makeEngine(1);
const b = makeEngine(10);
assert.ok(Math.abs(b.timeStep - 10 * a.timeStep) < 1e-15,
'engine.timeStep must scale linearly with config.timeStep; broke the seconds→days conversion');
});
test('schema default for timeStep matches the seconds convention', () => {
const path = require('node:path');
const gfRoot = path.dirname(require.resolve('generalFunctions'));
const schema = require(path.join(gfRoot, 'src/configs/reactor.json'));
assert.equal(schema.reactor.timeStep.rules.unit, 's',
'schema timeStep.unit must be "s" — engine treats input as seconds');
assert.equal(schema.reactor.timeStep.default, 1,
'schema timeStep.default must be 1 (1 second), matching reactor.html');
});

View File

View File

@@ -1,65 +0,0 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The schema validator coerces `reactor_type` through the enum — values
// outside `CSTR` / `PFR` are remapped to the default `CSTR` at validation
// time. The Reactor wrapper additionally falls back to CSTR if anything
// unrecognised slips through (defensive guard). Either way, the observable
// effect after `new nodeClass(...)` is `inst.source.engine instanceof
// Reactor_CSTR`.
const test = require('node:test');
const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { Reactor_CSTR } = require('../../src/specificClass');
const { makeUiConfig } = require('../helpers/factories');
function makeRED() { return { nodes: { getNode: () => null } }; }
function makeNode(id = 'reactor-node-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('Reactor wrapper falls back to CSTR when reactor_type is unknown', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: 'UNKNOWN_TYPE' }),
makeRED(),
node,
'reactor',
);
try {
assert.ok(inst.source.engine instanceof Reactor_CSTR);
} finally {
closeNode(node);
}
});
test('Reactor wrapper falls back to CSTR when reactor_type is empty string', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: '' }),
makeRED(),
node,
'reactor',
);
try {
assert.ok(inst.source.engine instanceof Reactor_CSTR);
} finally {
closeNode(node);
}
});

View File

@@ -1,68 +0,0 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface. The
// commands registry built by BaseNodeAdapter logs a warn on unknown topics
// and still calls done — no throw.
const test = require('node:test');
const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { makeUiConfig } = require('../helpers/factories');
function makeRED() { return { nodes: { getNode: () => null } }; }
function makeNode(id = 'reactor-node-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('unknown input topic does not throw and still calls done', async () => {
const node = makeNode();
new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let doneCalled = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ topic: 'somethingUnknown', payload: 1 },
() => {},
() => { doneCalled += 1; },
);
});
assert.equal(doneCalled, 1);
} finally {
closeNode(node);
}
});
test('missing topic field is handled gracefully', async () => {
const node = makeNode();
new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let doneCalled = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ payload: 'no-topic-here' },
() => {},
() => { doneCalled += 1; },
);
});
assert.equal(doneCalled, 1);
} finally {
closeNode(node);
}
});

View File

@@ -1,91 +0,0 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// A child.register / registerChild msg with an unknown id should resolve
// to no-op (the handler logs warn, no throw) and still call done.
const test = require('node:test');
const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { makeUiConfig } = require('../helpers/factories');
function makeRED(nodeMap = {}) {
return { nodes: { getNode: (id) => nodeMap[id] || null } };
}
function makeNode(id = 'reactor-node-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
test('registerChild alias with unknown id is ignored without throwing', async () => {
const node = makeNode();
new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let done = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ topic: 'registerChild', payload: 'missing-child', positionVsParent: 'upstream' },
() => {},
() => { done += 1; },
);
});
assert.equal(done, 1);
} finally {
closeNode(node);
}
});
test('child.register canonical topic with unknown id is ignored without throwing', async () => {
const node = makeNode();
new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
try {
let done = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ topic: 'child.register', payload: 'missing-child', positionVsParent: 'upstream' },
() => {},
() => { done += 1; },
);
});
assert.equal(done, 1);
} finally {
closeNode(node);
}
});
test('child.register with a child that has no .source is ignored without throwing', async () => {
const node = makeNode();
// The looked-up RED node exists but lacks a `.source` — the handler
// guards against this and logs warn.
new nodeClass(makeUiConfig(), makeRED({ orphan: {} }), node, 'reactor');
try {
let done = 0;
await assert.doesNotReject(async () => {
await node.handlers.input(
{ topic: 'child.register', payload: 'orphan', positionVsParent: 'upstream' },
() => {},
() => { done += 1; },
);
});
assert.equal(done, 1);
} finally {
closeNode(node);
}
});

View File

@@ -1,16 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_PFR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
test('oxygen measurement at exact reactor length is clamped to the last PFR grid index', () => {
const reactor = new Reactor_PFR(
makeReactorConfig({ reactor_type: 'PFR', length: 10, resolution_L: 5, n_inlets: 1 }),
);
assert.doesNotThrow(() => {
reactor._updateMeasurement('quantity (oxygen)', 2.5, 10, {});
});
assert.equal(reactor.state[reactor.n_x - 1][0], 2.5);
});

View File

@@ -1,11 +0,0 @@
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 reactor', () => {
const count = flow.filter((n) => n && n.type === 'reactor').length;
assert.equal(count >= 1, true);
});

View File

@@ -1,27 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_PFR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
const DAY_MS = 1000 * 60 * 60 * 24;
test('updateState warns when local Peclet number is too high at zero dispersion', () => {
const reactor = new Reactor_PFR(
makeReactorConfig({ reactor_type: 'PFR', length: 10, resolution_L: 5, volume: 50, n_inlets: 1 }),
);
const warnings = [];
reactor.logger.warn = (msg) => warnings.push(String(msg));
reactor.currentTime = 0;
reactor.timeStep = 1;
reactor.speedUpFactor = 1;
reactor.Fs[0] = 2;
reactor.D = 0;
reactor.tick = () => reactor.state;
reactor.updateState(DAY_MS);
assert.equal(warnings.some((w) => w.includes('Péclet number') || w.includes('Peclet number')), true);
});

View File

View File

@@ -1,149 +0,0 @@
const EventEmitter = require('node:events');
function makeUiConfig(overrides = {}) {
return {
name: 'reactor-test',
reactor_type: 'CSTR',
volume: 100,
length: 10,
resolution_L: 5,
alpha: 0,
n_inlets: 1,
kla: NaN,
S_O_init: 0,
S_I_init: 30,
S_S_init: 100,
S_NH_init: 16,
S_N2_init: 0,
S_NO_init: 0,
S_HCO_init: 5,
X_I_init: 25,
X_S_init: 75,
X_H_init: 30,
X_STO_init: 0,
X_A_init: 200,
X_TS_init: 125,
timeStep: 1,
enableLog: false,
logLevel: 'error',
positionVsParent: 'atEquipment',
...overrides,
};
}
function makeReactorConfig(overrides = {}) {
const ui = makeUiConfig(overrides);
return {
general: {
id: 'reactor-node-1',
name: ui.name,
unit: null,
logging: {
enabled: ui.enableLog,
logLevel: ui.logLevel,
},
},
functionality: {
positionVsParent: ui.positionVsParent || 'atEquipment',
softwareType: 'reactor',
},
reactor_type: ui.reactor_type,
volume: Number(ui.volume),
length: Number(ui.length),
resolution_L: Number(ui.resolution_L),
alpha: Number(ui.alpha),
n_inlets: Number(ui.n_inlets),
kla: Number(ui.kla),
initialState: [
Number(ui.S_O_init),
Number(ui.S_I_init),
Number(ui.S_S_init),
Number(ui.S_NH_init),
Number(ui.S_N2_init),
Number(ui.S_NO_init),
Number(ui.S_HCO_init),
Number(ui.X_I_init),
Number(ui.X_S_init),
Number(ui.X_H_init),
Number(ui.X_STO_init),
Number(ui.X_A_init),
Number(ui.X_TS_init),
],
timeStep: Number(ui.timeStep),
};
}
function makeNodeStub() {
const handlers = {};
const sent = [];
const warns = [];
const errors = [];
const statuses = [];
return {
id: 'reactor-node-1',
source: null,
on(event, cb) {
handlers[event] = cb;
},
send(msg) {
sent.push(msg);
},
warn(msg) {
warns.push(msg);
},
error(msg) {
errors.push(msg);
},
status(msg) {
statuses.push(msg);
},
_handlers: handlers,
_sent: sent,
_warns: warns,
_errors: errors,
_statuses: statuses,
};
}
function makeREDStub(nodeMap = {}) {
return {
nodes: {
getNode(id) {
return nodeMap[id] || null;
},
createNode() {},
registerType() {},
},
httpAdmin: {
get() {},
},
};
}
function makeMeasurementChild({
id = 'measurement-1',
name = 'temp-sensor-1',
distance = 'atEquipment',
positionVsParent = 'atEquipment',
type = 'temperature',
} = {}) {
return {
config: {
general: { id, name },
functionality: { distance, positionVsParent, softwareType: 'measurement' },
asset: { type },
},
measurements: {
emitter: new EventEmitter(),
},
};
}
module.exports = {
makeUiConfig,
makeReactorConfig,
makeNodeStub,
makeREDStub,
makeMeasurementChild,
};

View File

@@ -1,26 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_CSTR } = require('../../src/specificClass');
const { makeReactorConfig, makeMeasurementChild } = require('../helpers/factories');
test('measurement child temperature event updates reactor temperature', () => {
const reactor = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR' }));
const measurement = makeMeasurementChild({
type: 'temperature',
distance: 'atEquipment',
positionVsParent: 'upstream',
});
reactor.registerChild(measurement, 'measurement');
measurement.measurements.emitter.emit('temperature.measured.atEquipment', {
childName: 'T-1',
value: 27.5,
unit: 'C',
timestamp: Date.now(),
});
assert.equal(reactor.temperature, 27.5);
});

View File

@@ -1,91 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
const NUM_SPECIES = 13;
test('CSTR uses external OTR when kla is NaN', () => {
const reactor = new Reactor_CSTR(
makeReactorConfig({ reactor_type: 'CSTR', kla: NaN, n_inlets: 1 }),
);
reactor.asm = {
compute_dC: () => Array(NUM_SPECIES).fill(0),
};
reactor.Fs[0] = 0;
reactor.OTR = 4;
reactor.state = Array(NUM_SPECIES).fill(0);
reactor.tick(1);
assert.equal(reactor.state[0], 4);
});
test('CSTR uses kla-based oxygen transfer when kla is finite', () => {
const reactor = new Reactor_CSTR(
makeReactorConfig({ reactor_type: 'CSTR', kla: 2, n_inlets: 1 }),
);
reactor.asm = {
compute_dC: () => Array(NUM_SPECIES).fill(0),
};
reactor.Fs[0] = 0;
reactor.OTR = 1;
reactor.state = Array(NUM_SPECIES).fill(0);
const expected = Math.min(
reactor._calcOTR(0, reactor.temperature),
reactor._calcOxygenSaturation(reactor.temperature),
);
reactor.tick(1);
assert.ok(Math.abs(reactor.state[0] - expected) < 1e-9);
});
test('PFR uses external OTR branch when kla is NaN', () => {
const reactor = new Reactor_PFR(
makeReactorConfig({ reactor_type: 'PFR', kla: NaN, n_inlets: 1, length: 8, resolution_L: 6, volume: 40 }),
);
reactor.asm = {
compute_dC: () => Array(NUM_SPECIES).fill(0),
};
reactor.Fs[0] = 0;
reactor.D = 0;
reactor.OTR = 3;
reactor.state = Array.from({ length: reactor.n_x }, () => Array(NUM_SPECIES).fill(0));
reactor.tick(1);
assert.equal(reactor.state[1][0], 4.5);
assert.equal(reactor.state[2][0], 4.5);
assert.equal(reactor.state[3][0], 4.5);
assert.equal(reactor.state[4][0], 4.5);
});
test('PFR uses kla-based transfer branch when kla is finite', () => {
const reactor = new Reactor_PFR(
makeReactorConfig({ reactor_type: 'PFR', kla: 1, n_inlets: 1, length: 8, resolution_L: 6, volume: 40 }),
);
reactor.asm = {
compute_dC: () => Array(NUM_SPECIES).fill(0),
};
reactor.Fs[0] = 0;
reactor.D = 0;
reactor.OTR = 0;
reactor.state = Array.from({ length: reactor.n_x }, () => Array(NUM_SPECIES).fill(0));
const expected = Math.min(
reactor._calcOTR(0, reactor.temperature) * (reactor.n_x / (reactor.n_x - 2)),
reactor._calcOxygenSaturation(reactor.temperature),
);
reactor.tick(1);
assert.ok(Math.abs(reactor.state[1][0] - expected) < 1e-9);
assert.ok(Math.abs(reactor.state[2][0] - expected) < 1e-9);
assert.ok(Math.abs(reactor.state[3][0] - expected) < 1e-9);
assert.ok(Math.abs(reactor.state[4][0] - expected) < 1e-9);
});

View File

@@ -1,35 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_PFR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
test('_applyBoundaryConditions enforces Danckwerts inlet and Neumann outlet for flowing case', () => {
const reactor = new Reactor_PFR(
makeReactorConfig({ reactor_type: 'PFR', n_inlets: 1, length: 10, resolution_L: 5, volume: 50, alpha: 0.2 }),
);
reactor.Fs[0] = 2;
reactor.Cs_in[0] = Array(13).fill(9);
reactor.D = 1;
const state = Array.from({ length: reactor.n_x }, (_, i) => Array(13).fill(i));
reactor._applyBoundaryConditions(state);
assert.deepEqual(state[reactor.n_x - 1], state[reactor.n_x - 2]);
assert.equal(state[0].every((v) => Number.isFinite(v)), true);
});
test('_applyBoundaryConditions copies first interior slice when no flow is present', () => {
const reactor = new Reactor_PFR(
makeReactorConfig({ reactor_type: 'PFR', n_inlets: 1, length: 10, resolution_L: 5, volume: 50 }),
);
reactor.Fs[0] = 0;
const state = Array.from({ length: reactor.n_x }, (_, i) => Array(13).fill(i + 10));
reactor._applyBoundaryConditions(state);
assert.deepEqual(state[0], state[1]);
assert.deepEqual(state[reactor.n_x - 1], state[reactor.n_x - 2]);
});

View File

@@ -1,23 +0,0 @@
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 reactor', () => {
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 reactor', () => {
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
const parsed = loadJson(file);
assert.equal(Array.isArray(parsed), true);
}
});

View File

@@ -1,156 +0,0 @@
'use strict';
// Phase 10 rewrite: drives only the public BaseNodeAdapter surface.
// The pre-refactor _tick / _startTickLoop methods are gone — periodic
// emission lives in `_emitOutputs()` (overridden in the reactor nodeClass
// to preserve the Fluent / GridProfile Port-0 contract; delta-compressed
// payloads can't carry the C-vector). The override is part of the
// documented BaseNodeAdapter override surface, so we exercise it
// directly. The fully-constructed adapter wires `inst.source.engine`,
// `inst._output`, etc. so we don't have to assemble stub bags.
const test = require('node:test');
const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { makeUiConfig } = require('../helpers/factories');
function makeRED() { return { nodes: { getNode: () => null } }; }
function makeNode(id = 'reactor-node-1') {
const sends = [];
const statuses = [];
const handlers = {};
return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
};
}
function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
function pickEffluentSends(node) {
return node.sends.filter((s) => Array.isArray(s) && s[0] && s[0].topic === 'Fluent');
}
function pickGridSends(node) {
return node.sends.filter((s) => Array.isArray(s) && s[0] && s[0].topic === 'GridProfile');
}
test('_emitOutputs sends the effluent message on process output (CSTR)', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: 'CSTR' }),
makeRED(),
node,
'reactor',
);
try {
// Reset sends so any construction-time emissions don't pollute the
// assertion (the registration triple lands on the same buffer).
node.sends.length = 0;
inst._emitOutputs();
const fluentSends = pickEffluentSends(node);
assert.equal(fluentSends.length, 1, 'exactly one Fluent message');
const triple = fluentSends[0];
assert.equal(triple[0].topic, 'Fluent');
assert.ok(triple[0].payload && Array.isArray(triple[0].payload.C));
// CSTR has no grid profile.
assert.equal(pickGridSends(node).length, 0);
} finally {
closeNode(node);
}
});
test('_emitOutputs emits a GridProfile message when engine exposes one (PFR)', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: 'PFR' }),
makeRED(),
node,
'reactor',
);
try {
node.sends.length = 0;
inst._emitOutputs();
assert.equal(pickGridSends(node).length, 1, 'exactly one GridProfile message');
assert.equal(pickEffluentSends(node).length, 1, 'exactly one Fluent message');
} finally {
closeNode(node);
}
});
test('_emitOutputs formats per-species influx telemetry via outputUtils', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: 'CSTR' }),
makeRED(),
node,
'reactor',
);
try {
// Stub updateState so the engine integration does not overwrite the
// engineered state we want the telemetry formatter to see.
inst.source.updateState = () => {};
inst.source.engine.setInfluent = {
payload: { inlet: 0, F: 42, C: [2.1, 30, 100, 16, 0, 1, 8, 25, 75, 1500, 0, 15, 2500] },
};
inst.source.engine.state = [2.1, 30, 100, 16, 0, 1, 8, 25, 75, 1500, 0, 15, 2500];
inst.source.engine.temperature = 19.5;
let captured = null;
const realFormat = inst._output.formatMsg.bind(inst._output);
inst._output.formatMsg = (output, cfg, format) => {
if (format === 'influxdb') captured = { output, format };
return realFormat(output, cfg, format);
};
node.sends.length = 0;
inst._emitOutputs();
assert.ok(captured, 'formatMsg was called with influxdb format');
assert.equal(captured.format, 'influxdb');
assert.equal(captured.output.flow_total, 42);
assert.equal(captured.output.temperature, 19.5);
assert.equal(captured.output.S_O, 2.1);
assert.equal(captured.output.S_NH, 16);
assert.equal(captured.output.X_TS, 2500);
} finally {
closeNode(node);
}
});
test('Reactor.tick(dt) drives the kinetics engine and advances state', () => {
const node = makeNode();
const inst = new nodeClass(
makeUiConfig({ reactor_type: 'CSTR' }),
makeRED(),
node,
'reactor',
);
try {
// Feed an influent so the integrator has something to chew on.
inst.source.engine.setInfluent = {
payload: { inlet: 0, F: 5, C: [0,30,100,16,0,0,5,25,75,30,0,0.001,125] },
};
const stateBefore = JSON.stringify(inst.source.engine.state);
inst.source.tick(0.001);
const stateAfter = JSON.stringify(inst.source.engine.state);
assert.notEqual(stateBefore, stateAfter, 'engine state should advance after tick(dt)');
} finally {
closeNode(node);
}
});

View File

@@ -1,48 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { Reactor_CSTR } = require('../../src/specificClass');
const { makeReactorConfig } = require('../helpers/factories');
const DAY_MS = 1000 * 60 * 60 * 24;
test('registering upstream reactor subscribes to upstream stateChange events', () => {
const downstream = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR' }));
const upstream = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR' }));
let calledWith = null;
downstream.updateState = (timestamp) => {
calledWith = timestamp;
};
downstream.registerChild(upstream, 'reactor');
upstream.emitter.emit('stateChange', 12345);
assert.equal(downstream.upstreamReactor, upstream);
assert.equal(calledWith, 12345);
});
test('updateState pulls influent from upstream reactor effluent when linked', () => {
const downstream = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR', n_inlets: 1, timeStep: 1 }));
const upstream = new Reactor_CSTR(makeReactorConfig({ reactor_type: 'CSTR', n_inlets: 1 }));
upstream.Fs[0] = 3;
upstream.state = Array(13).fill(11);
downstream.upstreamReactor = upstream;
downstream.currentTime = 0;
downstream.timeStep = 1;
downstream.speedUpFactor = 1;
let ticks = 0;
downstream.tick = () => {
ticks += 1;
return downstream.state;
};
downstream.updateState(DAY_MS);
assert.equal(ticks, 1);
assert.equal(downstream.Fs[0], 3);
assert.deepEqual(downstream.Cs_in[0], Array(13).fill(11));
});

View File

@@ -1,346 +0,0 @@
/**
* Tests for reactor specificClass (domain logic).
*
* Two reactor classes are exported: Reactor_CSTR and Reactor_PFR.
* Both extend a base Reactor class.
*
* Key methods tested:
* - _calcOTR: oxygen transfer rate calculation
* - _arrayClip2Zero: clip negative values to zero
* - setInfluent / getEffluent: influent/effluent data flow
* - setOTR: external OTR override
* - tick (CSTR): forward Euler state update
* - tick (PFR): finite difference state update
* - registerChild: dispatches to measurement / reactor handlers
*/
const { Reactor_CSTR, Reactor_PFR } = require('../src/specificClass');
// --------------- helpers ---------------
const NUM_SPECIES = 13;
function makeCSTRConfig(overrides = {}) {
return {
general: {
name: 'TestCSTR',
id: 'cstr-test-1',
logging: { enabled: false, logLevel: 'error' },
},
functionality: {
softwareType: 'reactor',
positionVsParent: 'atEquipment',
},
volume: 1000,
n_inlets: 1,
kla: 240,
timeStep: 1, // 1 second
initialState: new Array(NUM_SPECIES).fill(1.0),
...overrides,
};
}
function makePFRConfig(overrides = {}) {
return {
general: {
name: 'TestPFR',
id: 'pfr-test-1',
logging: { enabled: false, logLevel: 'error' },
},
functionality: {
softwareType: 'reactor',
positionVsParent: 'atEquipment',
},
volume: 200,
length: 10,
resolution_L: 10,
n_inlets: 1,
kla: 240,
alpha: 0.5,
timeStep: 1,
initialState: new Array(NUM_SPECIES).fill(0.1),
...overrides,
};
}
// --------------- CSTR tests ---------------
describe('Reactor_CSTR', () => {
describe('constructor / initialization', () => {
it('should create an instance and set state from initialState', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
expect(r).toBeDefined();
expect(r.state).toEqual(new Array(NUM_SPECIES).fill(1.0));
});
it('should initialize Fs and Cs_in arrays based on n_inlets', () => {
const r = new Reactor_CSTR(makeCSTRConfig({ n_inlets: 3 }));
expect(r.Fs).toHaveLength(3);
expect(r.Cs_in).toHaveLength(3);
expect(r.Fs.every(v => v === 0)).toBe(true);
});
it('should store volume from config', () => {
const r = new Reactor_CSTR(makeCSTRConfig({ volume: 500 }));
expect(r.volume).toBe(500);
});
it('should initialize temperature to 20', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
expect(r.temperature).toBe(20);
});
});
describe('_calcOTR()', () => {
let r;
beforeAll(() => { r = new Reactor_CSTR(makeCSTRConfig({ kla: 240 })); });
it('should return a positive value when S_O < saturation', () => {
const otr = r._calcOTR(0, 20);
expect(otr).toBeGreaterThan(0);
});
it('should return approximately zero when S_O equals saturation', () => {
// S_O_sat at T=20: 14.652 - 4.1022e-1*20 + 7.9910e-3*400 + 7.7774e-5*8000
const T = 20;
const S_O_sat = 14.652 - 4.1022e-1 * T + 7.9910e-3 * T * T + 7.7774e-5 * T * T * T;
const otr = r._calcOTR(S_O_sat, T);
expect(otr).toBeCloseTo(0, 5);
});
it('should return a negative value when S_O > saturation (supersaturated)', () => {
const otr = r._calcOTR(100, 20);
expect(otr).toBeLessThan(0);
});
it('should use T=20 as default temperature', () => {
const otr1 = r._calcOTR(0);
const otr2 = r._calcOTR(0, 20);
expect(otr1).toBe(otr2);
});
});
describe('_arrayClip2Zero()', () => {
let r;
beforeAll(() => { r = new Reactor_CSTR(makeCSTRConfig()); });
it('should clip negative values to zero', () => {
expect(r._arrayClip2Zero([-5, 3, -1, 0, 7])).toEqual([0, 3, 0, 0, 7]);
});
it('should leave all-positive arrays unchanged', () => {
expect(r._arrayClip2Zero([1, 2, 3])).toEqual([1, 2, 3]);
});
it('should handle nested arrays (2D)', () => {
const result = r._arrayClip2Zero([[-1, 2], [3, -4]]);
expect(result).toEqual([[0, 2], [3, 0]]);
});
it('should handle a single scalar', () => {
expect(r._arrayClip2Zero(-5)).toBe(0);
expect(r._arrayClip2Zero(5)).toBe(5);
});
});
describe('setInfluent / getEffluent', () => {
it('should store influent data via setter', () => {
const r = new Reactor_CSTR(makeCSTRConfig({ n_inlets: 2 }));
const input = {
payload: {
inlet: 0,
F: 100,
C: new Array(NUM_SPECIES).fill(5),
},
};
r.setInfluent = input;
expect(r.Fs[0]).toBe(100);
expect(r.Cs_in[0]).toEqual(new Array(NUM_SPECIES).fill(5));
});
it('should return effluent with the sum of Fs and the current state', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
r.Fs[0] = 50;
const eff = r.getEffluent;
expect(eff.topic).toBe('Fluent');
expect(eff.payload.F).toBe(50);
expect(eff.payload.C).toEqual(r.state);
});
});
describe('setOTR', () => {
it('should set the OTR value', () => {
const r = new Reactor_CSTR(makeCSTRConfig({ kla: NaN }));
r.setOTR = { payload: 42 };
expect(r.OTR).toBe(42);
});
});
describe('tick()', () => {
it('should return a new state array of correct length', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
const result = r.tick(0.001);
expect(result).toHaveLength(NUM_SPECIES);
});
it('should not produce NaN values', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
r.Fs[0] = 10;
r.Cs_in[0] = new Array(NUM_SPECIES).fill(5);
const result = r.tick(0.001);
result.forEach(v => expect(Number.isNaN(v)).toBe(false));
});
it('should not produce negative concentrations', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
// Run multiple ticks
for (let i = 0; i < 100; i++) {
r.tick(0.001);
}
r.state.forEach(v => expect(v).toBeGreaterThanOrEqual(0));
});
it('should reach steady state with zero flow (concentrations change only via reaction)', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
// No inflow
const initial = [...r.state];
r.tick(0.0001);
// State should have changed due to reaction/OTR
const changed = r.state.some((v, i) => v !== initial[i]);
expect(changed).toBe(true);
});
});
describe('registerChild()', () => {
it('should not throw for "measurement" software type', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
// Passing null child will trigger warn but not crash
expect(() => r.registerChild(null, 'measurement')).not.toThrow();
});
it('should not throw for "reactor" software type', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
expect(() => r.registerChild(null, 'reactor')).not.toThrow();
});
it('should not throw for unknown software type', () => {
const r = new Reactor_CSTR(makeCSTRConfig());
expect(() => r.registerChild(null, 'unknown')).not.toThrow();
});
});
});
// --------------- PFR tests ---------------
describe('Reactor_PFR', () => {
describe('constructor / initialization', () => {
it('should create an instance with 2D state grid', () => {
const r = new Reactor_PFR(makePFRConfig());
expect(r).toBeDefined();
expect(r.state).toHaveLength(10); // resolution_L = 10
expect(r.state[0]).toHaveLength(NUM_SPECIES);
});
it('should compute d_x = length / n_x', () => {
const r = new Reactor_PFR(makePFRConfig({ length: 10, resolution_L: 5 }));
expect(r.d_x).toBe(2);
});
it('should compute cross-sectional area A = volume / length', () => {
const r = new Reactor_PFR(makePFRConfig({ volume: 200, length: 10 }));
expect(r.A).toBe(20);
});
it('should initialize D (dispersion) to 0', () => {
const r = new Reactor_PFR(makePFRConfig());
expect(r.D).toBe(0);
});
it('should create derivative operators of correct size', () => {
const r = new Reactor_PFR(makePFRConfig({ resolution_L: 8 }));
expect(r.D_op).toHaveLength(8);
expect(r.D_op[0]).toHaveLength(8);
expect(r.D2_op).toHaveLength(8);
expect(r.D2_op[0]).toHaveLength(8);
});
});
describe('setDispersion', () => {
it('should set the axial dispersion value', () => {
const r = new Reactor_PFR(makePFRConfig());
r.setDispersion = { payload: 0.5 };
expect(r.D).toBe(0.5);
});
});
describe('tick()', () => {
it('should return a 2D state grid of correct dimensions', () => {
const r = new Reactor_PFR(makePFRConfig());
r.D = 0.01;
const result = r.tick(0.0001);
expect(result).toHaveLength(10);
expect(result[0]).toHaveLength(NUM_SPECIES);
});
it('should not produce NaN values with small time step and dispersion', () => {
const r = new Reactor_PFR(makePFRConfig());
r.D = 0.01;
r.Fs[0] = 10;
r.Cs_in[0] = new Array(NUM_SPECIES).fill(5);
const result = r.tick(0.0001);
result.forEach(row => {
row.forEach(v => expect(Number.isNaN(v)).toBe(false));
});
});
it('should not produce negative concentrations', () => {
const r = new Reactor_PFR(makePFRConfig());
r.D = 0.01;
for (let i = 0; i < 10; i++) {
r.tick(0.0001);
}
r.state.forEach(row => {
row.forEach(v => expect(v).toBeGreaterThanOrEqual(0));
});
});
});
describe('_applyBoundaryConditions()', () => {
it('should apply Neumann BC at outlet (last = second to last)', () => {
const r = new Reactor_PFR(makePFRConfig({ resolution_L: 5 }));
const state = Array.from({ length: 5 }, () => new Array(NUM_SPECIES).fill(1));
state[3] = new Array(NUM_SPECIES).fill(7);
r._applyBoundaryConditions(state);
// outlet BC: state[4] = state[3]
expect(state[4]).toEqual(new Array(NUM_SPECIES).fill(7));
});
it('should apply Neumann BC at inlet when no flow', () => {
const r = new Reactor_PFR(makePFRConfig({ resolution_L: 5 }));
r.Fs[0] = 0;
const state = Array.from({ length: 5 }, () => new Array(NUM_SPECIES).fill(1));
state[1] = new Array(NUM_SPECIES).fill(3);
r._applyBoundaryConditions(state);
// No flow: state[0] = state[1]
expect(state[0]).toEqual(new Array(NUM_SPECIES).fill(3));
});
});
describe('_arrayClip2Zero() (inherited)', () => {
it('should clip 2D arrays correctly', () => {
const r = new Reactor_PFR(makePFRConfig());
const result = r._arrayClip2Zero([[-1, 2], [3, -4]]);
expect(result).toEqual([[0, 2], [3, 0]]);
});
});
describe('_calcOTR() (inherited)', () => {
it('should work the same as in CSTR', () => {
const r = new Reactor_PFR(makePFRConfig({ kla: 240 }));
const otr = r._calcOTR(0, 20);
expect(otr).toBeGreaterThan(0);
});
});
});

View File

@@ -1,182 +0,0 @@
# reactor
![code-ref](https://img.shields.io/badge/code--ref-0e34403-blue) ![s88](https://img.shields.io/badge/S88-Unit-50a8d9) ![status](https://img.shields.io/badge/status-pending--review-orange)
A `reactor` models a single biological-treatment tank governed by the ASM3 (Activated Sludge Model No.&nbsp;3) kinetics. It wraps either a CSTR (fully-mixed) or PFR (plug-flow with axial dispersion) integrator, accepts an influent stream + aeration rate, integrates the 13 ASM3 species each tick, and emits the effluent vector for the next Unit downstream (typically a `settler` or another `reactor`). A `diffuser` (Equipment Module) supplies aeration via `data.otr`; `measurement` children supply temperature and (PFR-only) dissolved-oxygen reconciliation.
> [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | One biological-treatment tank running ASM3 kinetics &mdash; aerated, anoxic, or anaerobic |
| S88 level | Unit |
| Use it when | You need an activated-sludge tank with nitrification / denitrification / heterotrophic growth modelled species-by-species |
| Don't use it for | Passive equalisation tanks (no reactions), simple residence-time delays (lighter buffer is better), aerobic-only contactors where ASM3's full 13-species vector is overkill |
| Children it accepts | `measurement` (temperature at equipment; PFR also: dissolved oxygen at numeric distance); upstream `reactor` |
| Parents / sinks it talks to | downstream `reactor` or `settler` (via `Fluent` on Port 0); `diffuser` pushes `data.otr` in |
---
## How it fits
```mermaid
flowchart LR
upstream[reactor<br/>upstream<br/>Unit]:::unit
rx[reactor<br/>Unit]:::unit
settler[settler<br/>downstream<br/>Unit]:::unit
diffuser[diffuser<br/>Equipment]:::equip
tsens[measurement<br/>temperature<br/>atEquipment]:::ctrl
osens[measurement<br/>quantity (oxygen)<br/>at numeric distance, PFR only]:::ctrl
upstream -.stateChange.-> rx
rx -->|Fluent inlet=0| settler
diffuser -->|data.otr| rx
tsens -.measured.-> rx
osens -.measured.-> rx
tsens -->|child.register| rx
osens -->|child.register| rx
upstream -->|child.register<br/>positionVsParent=upstream| rx
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`.
reactor sits on lane **L4** (Unit). The `diffuser` (lane L3) is **not** a registered child &mdash; it just pushes aeration via the `data.otr` topic. A reactor chain (multi-stage treatment, e.g. anoxic &rarr; aerobic &rarr; aerobic) is built by registering each upstream reactor with `positionVsParent: 'upstream'`; downstream reactors then `getEffluent` from the upstream on every `stateChange`.
---
## Try it &mdash; 3-minute demo
Import the basic example flow, deploy, and watch a CSTR consume influent over the simulation clock.
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/reactor/examples/basic.flow.json \
http://localhost:1880/flow
```
What to click after deploy (each inject maps one-to-one to a topic in [Reference &mdash; Contracts](Reference-Contracts#topic-contract)):
1. `data.fluent` &mdash; inject an influent stream `{inlet: 0, F: 1000, C: [...13 species...]}` (m³/d, mg/L). The 13 species follow ASM3 ordering.
2. `data.temperature` &mdash; set reactor temperature (default 20 &deg;C; nitrification rates depend on this).
3. `data.otr` (if `kla` is `NaN`) **or** rely on the configured `kla` for internal aeration.
4. `data.clock` &mdash; push wall-clock `msg.timestamp` to advance the integrator. The engine computes `n_iter = floor(speedUpFactor &times; &Delta;t_wall / timeStep_days)` internal Euler / FD steps and integrates them in one shot.
5. Watch Port 0 (`Fluent` envelope on every advance) and Port 1 (InfluxDB scalar fields: `flow_total`, `temperature`, `S_O`&hellip;`X_TS`).
> [!IMPORTANT]
> **GIF needed.** Demo recording of steps 1&ndash;5 with `S_NH` falling and `S_NO` rising (nitrification proceeding). Save as `wiki/_partial-gifs/reactor/01-basic-cstr.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
---
## The six things you'll send
| Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `data.clock` | `clock` | `{timestamp: ms}` (or use `msg.timestamp`) | Advance the integrator. `updateState` computes how many internal steps fit between `currentTime` and the supplied timestamp (scaled by `speedUpFactor`) and runs them. |
| `data.fluent` | `Fluent` | `{inlet: number, F: number, C: number[13]}` | Set the per-inlet flow rate (`F`) and concentration vector (`C`). Stored in `engine.Fs[inlet]` / `engine.Cs_in[inlet]`. |
| `data.otr` | `OTR` | numeric | Set the externally-supplied oxygen transfer rate. Used when `kla` is `NaN`; ignored otherwise (internal mass transfer takes over). |
| `data.temperature` | `Temperature` | numeric or `{value: number}` | Set `engine.temperature` (&deg;C). Non-numeric payloads are warned and ignored. |
| `data.dispersion` | `Dispersion` | numeric | **PFR only** &mdash; set axial dispersion coefficient `D` (m²/d). Triggers Peclet / Courant guard warnings on the next `updateState`. |
| `child.register` | `registerChild` | child node id (string) | Register a sibling node (`measurement`, upstream `reactor`) with this reactor. Port 2 wiring does this automatically in normal flows. |
> [!NOTE]
> Pending full node review (2026-05). reactor's command surface is data-push only &mdash; there is **no FSM, no setpoint, no mode**. The kinetics engine runs continuous-state ODE / PDE integration; the only stateful event is `stateChange` after every successful advance.
---
## What you'll see come out
Sample Port 0 message (CSTR mid-integration, nitrifying):
```json
{
"topic": "Fluent",
"payload": {
"inlet": 0,
"F": 1000,
"C": [2.1, 30, 12.4, 0.8, 4.3, 18.6, 4.2, 1050, 65, 2150, 4.5, 215, 3680]
},
"timestamp": 1747500000000
}
```
The `C` array is the 13-species ASM3 vector in fixed order (indices 0&ndash;6 soluble, 7&ndash;12 particulate). For a PFR an additional message goes out on the same port **before** the effluent each advance:
```json
{
"topic": "GridProfile",
"payload": {
"grid": [[...13...], [...13...], "...n_x rows..."],
"n_x": 10,
"d_x": 1.0,
"length": 10,
"species": ["S_O","S_I","S_S","S_NH","S_N2","S_NO","S_HCO","X_I","X_S","X_H","X_STO","X_A","X_TS"],
"timestamp": 1747500000000
}
}
```
Port 1 (InfluxDB telemetry) carries the same data flattened as scalar fields &mdash; `flow_total` (m³/d), `temperature` (&deg;C), and one field per species (`S_O`, `S_I`, `S_S`, `S_NH`, `S_N2`, `S_NO`, `S_HCO`, `X_I`, `X_S`, `X_H`, `X_STO`, `X_A`, `X_TS`, mg/L; `S_HCO` is mmol/L).
| Field | Meaning |
|:---|:---|
| `S_O` | Dissolved oxygen. Capped to saturation at each tick via `_capDissolvedOxygen`. |
| `S_I` | Inert soluble COD. |
| `S_S` | Readily biodegradable substrate. |
| `S_NH` | Ammonium nitrogen. Drops during nitrification. |
| `S_N2` | Dinitrogen (denitrification end product). |
| `S_NO` | Nitrate / nitrite nitrogen. Rises during nitrification. |
| `S_HCO` | Alkalinity (bicarbonate, mmol/L). |
| `X_I` | Inert particulate COD. |
| `X_S` | Slowly biodegradable substrate. |
| `X_H` | Heterotrophic biomass. |
| `X_STO` | Stored COD in biomass. |
| `X_A` | Autotrophic biomass. **Must be &ge; ~50 mg/L for nitrification to proceed.** |
| `X_TS` | Total suspended solids. Drives the downstream settler split. |
| `flow_total` | Effluent volumetric flow (m³/d) &mdash; `sum(Fs)`. |
| `temperature` | Reactor temperature (&deg;C). |
---
## The interesting bits
### CSTR vs PFR
The engine is selected once at `configure()` from `reactor.reactor_type`. The same input topics drive both, but PFR additionally:
- Discretises the tank along the `length` axis into `resolution_L` grid cells (`n_x`).
- Emits a `GridProfile` message **before** the effluent each `updateState`.
- Honours `data.dispersion` to set the axial dispersion coefficient.
- Reconciles oxygen measurements at a **numeric** `positionVsParent` (interpreted as distance from inlet) into the nearest grid cell.
- Warns when local Peclet &ge; 2 or Courant &ge; 0.5 (stability of the explicit FD scheme).
Hot-swapping engine type at runtime is not supported &mdash; redeploy the flow.
### Aeration: internal `kla` vs external `data.otr`
`reactor.kla > 0` enables internal mass-transfer: `OTR = kla &times; (sat(T) &minus; S_O)`. Set `kla = NaN` to fall through to the externally-pushed `data.otr` value (the path a `diffuser` Equipment node uses).
### `X_A` footgun
The HTML editor form's default initial autotroph biomass is `0.001` mg/L &mdash; effectively zero, so nitrification never starts. The JSON schema default is `200` mg/L. Always check the deployed node's form value before expecting `S_NH` to drop. See [Reference &mdash; Limitations](Reference-Limitations#x_a-initial-default-footgun).
---
## Need more?
| Page | What you'll find |
|:---|:---|
| [Reference &mdash; Contracts](Reference-Contracts) | Full topic contract, config schema, child registration filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, integration sequence, kinetics layout, output ports |
| [Reference &mdash; Examples](Reference-Examples) | Shipped example flows + debug recipes |
| [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

@@ -1,293 +0,0 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-0e34403-blue)
> [!NOTE]
> Code structure for `reactor`: the three-tier sandwich, the `src/` layout, the ASM3 kinetics engines (CSTR + PFR), the integration sequence, child registration, and the output-port pipeline. For an intuitive overview, return to [Home](Home).
>
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
---
## Three-tier code layout
```
nodes/reactor/
|
+-- reactor.js entry: RED.nodes.registerType('reactor', NodeClass)
|
+-- src/
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
| specificClass.js extends BaseDomain (orchestration only)
| utils.js assertNoNaN + small helpers
| |
| +-- commands/
| | index.js 6 topic descriptors
| | handlers.js pure handler functions
| |
| +-- kinetics/
| | baseEngine.js BaseReactorEngine (influent / OTR / T / child wiring / updateState)
| | cstr.js Reactor_CSTR extends BaseReactorEngine (0-D Forward Euler)
| | pfr.js Reactor_PFR extends BaseReactorEngine (axial FD + Danckwerts BC)
| |
| +-- reaction_modules/
| | asm3_class.js ASM3 stoichiometry + rate vector + species list
| | asm3_class Koch.js legacy variant (not consumed by current engines)
| |
| +-- io/ reserved (currently empty)
|
+-- additional_nodes/
| recirculation-pump.{js,html} legacy companion node shipped from this repo
| settling-basin.{js,html} legacy companion node shipped from this repo
```
### Tier responsibilities
| Tier | File | What it owns | Touches `RED.*` |
|:---|:---|:---|:---:|
| entry | `reactor.js` | Type registration | Yes |
| nodeClass | `src/nodeClass.js` | Tick loop (`tickInterval = 1000` ms), status badge (`statusInterval = 1000` ms), `buildDomainConfig` mapping editor fields to nested config, `_emitOutputs` override that preserves the `Fluent` + `GridProfile` envelope (BaseNodeAdapter's default delta-compressed payload doesn't fit). | Yes |
| specificClass | `src/specificClass.js` | `_flattenEngineConfig` translates nested schema to engine shape; `_buildEngine` selects CSTR or PFR; wires ChildRouter (`measurement` &rarr; `engine._connectMeasurement`, `reactor` &rarr; `engine._connectReactor`); re-emits engine `stateChange` on the BaseDomain emitter; surfaces `getOutput()`, `getStatusBadge()`. | No |
| kinetics | `src/kinetics/*.js` | Pure ASM3 integration. `BaseReactorEngine` owns influent state, OTR, temperature, child-registration utils, and `updateState`. `Reactor_CSTR` adds the 0-D Forward-Euler tick. `Reactor_PFR` adds spatial discretization + boundary conditions + grid-profile emission. | No |
`specificClass` is thin stitching. All the real work lives in the kinetics engines.
---
## No FSM &mdash; continuous-state integration
reactor has **no finite-state machine, no mode, no setpoint**. The engine runs continuous ODE / PDE integration in process time. The only stateful event is `stateChange`, emitted by `BaseReactorEngine.updateState` after every successful advance (`n_iter > 0` internal steps completed).
```mermaid
flowchart LR
clk[data.clock<br/>or tick&#40;dt&#41;]:::input --> us[updateState&#40;newTime&#41;]
us --> ni{n_iter = floor&#40;<br/>speedUpFactor &times; &Delta;t / timeStep&#41;}
ni -->|0| skip[no-op]
ni -->|>0| loop[for each step:<br/>tick&#40;timeStep&#41;]
loop --> emit[emit stateChange&#40;currentTime&#41;]
classDef input fill:#a9daee,color:#000
```
`stateChange` is the trigger downstream Units (settlers, chained reactors) use to pull effluent.
---
## Kinetics engines &mdash; CSTR vs PFR
```mermaid
flowchart TB
subgraph base["BaseReactorEngine"]
bs["Fs[], Cs_in[][13]<br/>OTR, temperature, kla<br/>upstreamReactor link<br/>updateState&#40;newTime&#41;<br/>_connectMeasurement / _connectReactor"]
end
subgraph cstr["Reactor_CSTR"]
cs["state = number[13]<br/>tick&#40;dt&#41;:<br/> inflow + outflow + reaction + transfer<br/> Forward Euler<br/> _capDissolvedOxygen / _arrayClip2Zero"]
end
subgraph pfr["Reactor_PFR"]
ps["state = number[n_x][13]<br/>length, n_x, d_x, A, alpha, D<br/>D_op / D2_op finite-difference operators<br/>tick&#40;dt&#41;:<br/> dispersion + advection + reaction + transfer<br/> Explicit FD<br/> Danckwerts inlet / Neumann outlet BC<br/> Peclet / Courant guard warnings"]
end
bs --> cs
bs --> ps
```
### Forward Euler (CSTR)
`Reactor_CSTR.tick(time_step)` adds four contributions per step:
| Term | Formula | Notes |
|:---|:---|:---|
| Inflow | `Fs &middot; Cs_in / volume` | Per inlet, summed into a single concentration delta. |
| Outflow | `&minus;sum(Fs) / volume &middot; state` | Mass leaves at the current tank concentration. |
| Reaction | `asm.compute_dC(state, T)` | ASM3 rate vector applied at current temperature. |
| Transfer | `OTR or kla &middot; (sat(T) &minus; S_O)` on the `S_O` index only | All other species: zero transfer. |
After integration, `_capDissolvedOxygen` caps `S_O` to saturation and `_arrayClip2Zero` floors negative concentrations.
### Explicit FD (PFR)
`Reactor_PFR.tick(time_step)` operates per grid cell:
| Term | Notes |
|:---|:---|
| Dispersion | `(D / d_x²) &middot; D2_op &middot; state` &mdash; central-difference second-derivative operator. |
| Advection | `(&minus;sum(Fs) / (A &middot; d_x)) &middot; D_op &middot; state` &mdash; first-derivative operator (central or upwind per config). |
| Reaction | Per-cell `asm.compute_dC(slice, T)`. |
| Transfer | OTR / `kla` on the `S_O` index, scaled by `n_x / (n_x &minus; 2)` for interior cells only. |
Boundary conditions: **Danckwerts** at the inlet when `sum(Fs) > 0` (mixes inlet concentration with diffusive back-mix governed by `alpha`); **Neumann** (no-flux) at the outlet and at the inlet when there is no flow. After integration, the same `_capDissolvedOxygen` / `_arrayClip2Zero` post-processing applies cell-by-cell.
`updateState` extends `BaseReactorEngine.updateState` with two stability checks:
| Check | Threshold | Warning |
|:---|:---|:---|
| Local Peclet `Pe = d_x &middot; sum(Fs) / (D &middot; A)` | `&ge; 2` | `Local Peclet number (&hellip;) is too high! Increase reactor resolution.` |
| Courant `Co_D = D &middot; timeStep / d_x²` | `&ge; 0.5` | `Courant number (&hellip;) is too high! Reduce time step size.` |
---
## Lifecycle &mdash; what one `data.clock` advance does
```mermaid
sequenceDiagram
autonumber
participant clock as data.clock injector
participant rx as reactor (specificClass)
participant engine as kinetics engine (CSTR / PFR)
participant downstream as settler / next reactor
participant out as Port 0 / 1
clock->>rx: data.clock { timestamp }
rx->>engine: updateState(timestamp)
Note over engine: n_iter = floor(speedUpFactor &times; &Delta;t / timeStep)
alt upstreamReactor present
engine->>engine: setInfluent = upstream.getEffluent
end
loop n_iter times
engine->>engine: tick(timeStep) &mdash; integrate ASM3 rates
engine->>engine: cap S_O to saturation, clip negatives
end
engine->>rx: emit 'stateChange' (currentTime)
rx->>rx: re-emit 'stateChange' on BaseDomain emitter
rx->>rx: notifyOutputChanged
alt PFR engine
rx->>out: Port 0 &mdash; GridProfile { grid, n_x, d_x, length, species }
end
rx->>out: Port 0 &mdash; Fluent { inlet=0, F, C[13] }
rx->>out: Port 1 &mdash; InfluxDB scalars { flow_total, temperature, S_O&hellip;X_TS }
downstream-->>rx: subscribes to stateChange via _connectReactor
downstream->>downstream: pulls getEffluent on each stateChange
```
The tick loop is opt-in (`static tickInterval = 1000`) because the integrator advances **process time** in steps that have no fixed wall-clock mapping. Without ticks the engine simply doesn't advance. `nodeClass._emitOutputs` is overridden so the `Fluent` / `GridProfile` envelope shape survives the BaseNodeAdapter pipeline.
---
## Child registration
Source: `src/specificClass.js` `configure()` wires the ChildRouter; `BaseReactorEngine._connectMeasurement` and `_connectReactor` do the actual subscription.
```mermaid
flowchart LR
subgraph kids["accepted children (softwareType)"]
m_t["measurement<br/>asset.type=temperature<br/>positionVsParent=atEquipment"]:::ctrl
m_o["measurement<br/>asset.type=quantity (oxygen)<br/>positionVsParent=numeric distance (PFR)"]:::ctrl
r_up["reactor<br/>positionVsParent=upstream"]:::unit
end
m_t -->|temperature.measured.atEquipment| h_meas["engine._connectMeasurement<br/>(baseEngine.js)"]
m_o -->|quantity(oxygen).measured.&lt;distance&gt;| h_meas
r_up -.stateChange.-> h_react["engine._connectReactor<br/>(baseEngine.js)"]
h_meas --> reconcile["reconcile T &rarr; engine.temperature<br/>reconcile O2 &rarr; state grid cell (PFR only)"]
h_react --> pull["pull upstream getEffluent<br/>&rarr; Fs[0] / Cs_in[0] before next tick"]
classDef ctrl fill:#a9daee,color:#000
classDef unit fill:#50a8d9,color:#000
```
### `_connectMeasurement` event wiring
`measurement.measurements.emitter` fires `<measurementType>.measured.<position>` on every published value. The reactor subscribes:
```js
const eventName = `${measurementType}.measured.${position}`;
measurement.measurements.emitter.on(eventName, (eventData) => {
this.measurements
.type(measurementType).variant('measured').position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
this._updateMeasurement(measurementType, eventData.value, position, eventData);
});
```
`_updateMeasurement` (CSTR base): only `temperature` at `POSITIONS.AT_EQUIPMENT` is honoured &mdash; writes `engine.temperature`. Any other type logs `Type '<x>' not recognized for measured update.`
`_updateMeasurement` (PFR override): additionally handles `quantity (oxygen)` at a **numeric** position. Position is interpreted as metres along `length`; the value is written to grid cell `clamp(round(pos / length &times; n_x), 0, n_x &minus; 1)`. Non-finite position / value, or `length &le; 0`, logs a warn and the update is dropped.
### `_connectReactor` &mdash; upstream chain
Setting `positionVsParent: 'upstream'` on the upstream reactor's child-register makes this reactor subscribe to the upstream's `stateChange`. On every event the downstream's `updateState` runs, which first pulls the upstream's `getEffluent` into `Fs[0]` / `Cs_in[0]` then integrates.
> [!NOTE]
> `diffuser` is **not** a registered child. It feeds aeration via the `data.otr` topic on Port 0 (handled in `commands/handlers.js` `dataOTR`). No child-registration handshake.
---
## Output ports
| Port | Carries | Sample shape |
|:---|:---|:---|
| 0 (process) | `Fluent` envelope every advance. For PFR: an additional `GridProfile` message sent **before** the `Fluent`. | `{topic: 'Fluent', payload: {inlet: 0, F, C: [...13...]}, timestamp}` |
| 1 (telemetry) | InfluxDB line-protocol payload built from `getOutput()` via `outputUtils.formatMsg`. Fields: `flow_total`, `temperature`, and one per species. | `reactor,id=rx_a flow_total=1000,temperature=20,S_O=2.1,S_NH=0.8,...` |
| 2 (registration) | `child.register` upward at init | `{topic: 'child.register', payload: <node.id>, positionVsParent, distance}` |
<!-- BEGIN AUTOGEN: data-model — populate via wiki-gen tool (TODO) -->
> [!NOTE]
> Pending full node review (2026-05). The flat Port-1 telemetry shape (one field per species, plus `flow_total` + `temperature`) reflects the current `getOutput()` in `src/specificClass.js`.
| Key | Type | Unit | Source |
|:---|:---|:---|:---|
| `flow_total` | number | m³/d | `sum(Fs)` from the engine's effluent envelope |
| `temperature` | number | &deg;C | `engine.temperature` |
| `S_O` | number | mg/L | effluent `C[0]` &mdash; dissolved oxygen, capped to saturation |
| `S_I` | number | mg/L | effluent `C[1]` &mdash; inert soluble COD |
| `S_S` | number | mg/L | effluent `C[2]` &mdash; readily biodegradable substrate |
| `S_NH` | number | mg/L | effluent `C[3]` &mdash; ammonium nitrogen |
| `S_N2` | number | mg/L | effluent `C[4]` &mdash; dinitrogen |
| `S_NO` | number | mg/L | effluent `C[5]` &mdash; nitrate / nitrite |
| `S_HCO` | number | mmol/L | effluent `C[6]` &mdash; alkalinity |
| `X_I` | number | mg/L | effluent `C[7]` &mdash; inert particulate COD |
| `X_S` | number | mg/L | effluent `C[8]` &mdash; slowly biodegradable substrate |
| `X_H` | number | mg/L | effluent `C[9]` &mdash; heterotrophic biomass |
| `X_STO` | number | mg/L | effluent `C[10]` &mdash; stored COD in biomass |
| `X_A` | number | mg/L | effluent `C[11]` &mdash; autotrophic biomass |
| `X_TS` | number | mg/L | effluent `C[12]` &mdash; total suspended solids |
<!-- END AUTOGEN: data-model -->
### Status badge
Composed by `getStatusBadge()` in `src/specificClass.js`:
```
<EngineType> T=<temperature> C F=<flow> m³/d S_O=<S_O> mg/L
```
Engine type is `CSTR` or `PFR` (derived from the constructor name). Fill is green by default; the badge is purely informational &mdash; no shape / colour transitions tied to plant state, since reactor has no FSM.
---
## Event sources
| Source | Where it fires | What it triggers |
|:---|:---|:---|
| `engine.emitter` `'stateChange'` | `BaseReactorEngine.updateState` after `n_iter > 0` integration steps | `specificClass` re-emits on `this.emitter`; BaseNodeAdapter `_emitOutputs` runs (Port 0 + Port 1) |
| Child measurement emitter | `measurement.measurements.emitter` per `<type>.measured.<position>` | `engine._connectMeasurement` callback &rarr; writes into MeasurementContainer + `_updateMeasurement` reconcile |
| Upstream reactor `'stateChange'` | Upstream reactor's `BaseDomain` emitter | `engine._connectReactor` callback &rarr; downstream `updateState(t)` runs, pulling upstream effluent first |
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch |
| `setInterval(tickInterval = 1000)` | `BaseNodeAdapter` periodic tick | `nodeClass._emitOutputs` &rarr; `source.updateState(Date.now())` + send |
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
---
## Where to start reading
| If you're changing&hellip; | Read first |
|:---|:---|
| ASM3 stoichiometry / kinetic constants | `src/reaction_modules/asm3_class.js` |
| Mixed-tank integration, child wiring, influent / OTR / T setters | `src/kinetics/baseEngine.js`, `src/kinetics/cstr.js` |
| Plug-flow discretization, dispersion, grid profile | `src/kinetics/pfr.js` |
| Topic registration, alias deprecation | `src/commands/index.js`, `src/commands/handlers.js` |
| Editor-field &harr; engine-config mapping | `src/nodeClass.js` `buildDomainConfig`, `src/specificClass.js` `_flattenEngineConfig` |
| Port-0 envelope shape (`Fluent` + `GridProfile`) | `src/nodeClass.js` `_emitOutputs` |
| Schema defaults, types, units | `generalFunctions/src/configs/reactor.json` |
---
## 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 |
| [settler wiki](https://gitea.wbd-rd.nl/RnD/settler/wiki/Home) | The typical downstream Unit that subscribes to reactor `stateChange` |
| [diffuser wiki](https://gitea.wbd-rd.nl/RnD/diffuser/wiki/Home) | The Equipment node that pushes `data.otr` |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |

View File

@@ -1,227 +0,0 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-0e34403-blue)
> [!NOTE]
> Full topic contract, configuration schema, and child-registration filters for `reactor`. Source of truth: `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/reactor.json`.
>
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
>
> 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.clock` | `clock` | any | — | Push the simulation clock tick (timestamp / dt) to the ASM solver. |
| `data.fluent` | `Fluent` | `object` | — | Push the influent stream (payload: {F: flow m3/h, C: [concentrations mg/L]}). |
| `data.otr` | `OTR` | any | — | Push the current oxygen-transfer rate into the reactor. |
| `data.temperature` | `Temperature` | any | — | Push the current reactor temperature. |
| `data.dispersion` | `Dispersion` | any | — | Push a dispersion/mixing parameter update. |
| `child.register` | `registerChild` | any | — | Register a child node (settler / measurement) with this reactor. |
<!-- END AUTOGEN: topic-contract -->
### Modes / sources / actions
reactor has **no mode, no action allow-lists, no source gating**. All topics are accepted as long as the payload shape is valid. (Contrast with `rotatingMachine`, which gates every input through a mode &times; source matrix.)
---
## Data model &mdash; `getOutput()` shape
Composed each tick by `src/specificClass.js` `getOutput()`. Used to build the Port-1 InfluxDB payload; Port 0 carries the engine's `getEffluent` envelope directly.
### Port-0 process payload
The engine's effluent envelope, emitted on every successful `updateState` advance:
```json
{
"topic": "Fluent",
"payload": { "inlet": 0, "F": <m³/d>, "C": [<13 species, mg/L>] },
"timestamp": <ms since epoch>
}
```
For a PFR an additional message is sent **before** the `Fluent` on the same port each advance:
```json
{
"topic": "GridProfile",
"payload": {
"grid": [[<13 cells of n_x>]],
"n_x": <int>,
"d_x": <m>,
"length": <m>,
"species": ["S_O","S_I","S_S","S_NH","S_N2","S_NO","S_HCO","X_I","X_S","X_H","X_STO","X_A","X_TS"],
"timestamp": <ms since epoch>
}
}
```
### Port-1 telemetry &mdash; scalar keys
| Key | Type | Unit | Source |
|:---|:---|:---|:---|
| `flow_total` | number | m³/d | `sum(Fs)` from effluent envelope |
| `temperature` | number | &deg;C | `engine.temperature` |
| `S_O` | number | mg/L | effluent `C[0]` &mdash; capped to saturation by `_capDissolvedOxygen` |
| `S_I` | number | mg/L | effluent `C[1]` |
| `S_S` | number | mg/L | effluent `C[2]` |
| `S_NH` | number | mg/L | effluent `C[3]` |
| `S_N2` | number | mg/L | effluent `C[4]` |
| `S_NO` | number | mg/L | effluent `C[5]` |
| `S_HCO` | number | mmol/L | effluent `C[6]` &mdash; alkalinity |
| `X_I` | number | mg/L | effluent `C[7]` |
| `X_S` | number | mg/L | effluent `C[8]` |
| `X_H` | number | mg/L | effluent `C[9]` |
| `X_STO` | number | mg/L | effluent `C[10]` |
| `X_A` | number | mg/L | effluent `C[11]` |
| `X_TS` | number | mg/L | effluent `C[12]` |
Non-finite species values are **omitted** from the output (the `Number.isFinite` guard in `getOutput`); they are not emitted as `null`. Pick one convention per consumer (absent vs null) and document it &mdash; see `.claude/rules/output-coverage.md`.
### Species ordering
The 13-species vector is **fixed**:
| Index | Key | Group |
|:---:|:---|:---|
| 0 | `S_O` | soluble |
| 1 | `S_I` | soluble |
| 2 | `S_S` | soluble |
| 3 | `S_NH` | soluble |
| 4 | `S_N2` | soluble |
| 5 | `S_NO` | soluble |
| 6 | `S_HCO` | soluble |
| 7 | `X_I` | particulate |
| 8 | `X_S` | particulate |
| 9 | `X_H` | particulate |
| 10 | `X_STO` | particulate |
| 11 | `X_A` | particulate |
| 12 | `X_TS` | particulate |
Don't reshuffle &mdash; `getOutput()` and `_flattenEngineConfig()` both depend on this exact order, as does `additional_nodes/settling-basin` and the downstream `settler` node.
### Status badge
`getStatusBadge()` in `src/specificClass.js`:
```
<EngineType> T=<°C>.X C F=<m³/d>.XX m³/d S_O=<mg/L>.XX mg/L
```
Engine type is the constructor name with `Reactor_` stripped (so `CSTR` or `PFR`). Badge is always green-dot (no FSM-driven state).
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/reactor.json` plus `nodeClass.buildDomainConfig` (`src/nodeClass.js`).
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Name | `general.name` | `Reactor` | Human-readable. |
| (auto-assigned) | `general.id` | `null` | Node-RED node id. |
| Default unit | `general.unit` | `null` | Unused by the reactor's own logic (the engines pick up units from the schema's `rules.unit` strings); kept for parent compatibility. |
| Log enabled | `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 |
|:---|:---|:---|:---|
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | Used in the child-register payload that goes UP to whatever parent registers this reactor. Enum: `upstream` / `atEquipment` / `downstream`. |
| (hidden) | `functionality.softwareType` | `reactor` | Constant. |
| (hidden) | `functionality.role` | `Biological reactor for wastewater treatment` | Constant. |
### Reactor (`config.reactor`)
| Form field | Config key | Schema default | Range / unit | Notes |
|:---|:---|:---|:---|:---|
| Reactor type | `reactor.reactor_type` | `CSTR` | enum: `CSTR` / `PFR` | Selected once at `configure()`. `_buildEngine` calls `.toUpperCase()` so `pfr` and `PFR` both resolve. |
| Volume | `reactor.volume` | `1000` | m³, `> 0` | Used by mass balance and (PFR) surface-area derivation. |
| Length | `reactor.length` | `10` | m, `> 0` | **PFR only.** Sets axial extent and grid pitch (`d_x = length / n_x`). |
| Resolution | `reactor.resolution_L` | `10` | integer `&ge; 1` | **PFR only.** Grid cell count `n_x`. |
| Alpha | `reactor.alpha` | `0.5` | `0..1` | **PFR only.** Inlet boundary blend: `0` = pure Danckwerts, `1` = fully mixed inlet. |
| Inlets | `reactor.n_inlets` | `1` | integer `&ge; 1` | `Fs[]` / `Cs_in[]` array size. |
| kLa | `reactor.kla` | `0` | 1/h, `&ge; 0`; set `NaN` to disable | Enables internal aeration `OTR = kla &middot; (sat(T) &minus; S_O)`. When `NaN`, `data.otr` is honoured instead. |
| Time step | `reactor.timeStep` | `0.001` | `&ge; 0.0001` | Schema declares unit `h`; `baseEngine.js` converts by `&divide; 86400` (treating it as seconds). See [Limitations &mdash; timeStep unit mismatch](Reference-Limitations#timestep-unit-mismatch). |
| Speed-up factor | `reactor.speedUpFactor` | `1` | `&ge; 1` | Multiplies wall-clock &Delta;t when computing `n_iter`. `2` means twice as many internal steps per second. |
### Initial state (`config.initialState`)
13 starting concentrations, all written into the engine's `state` (CSTR: single row; PFR: replicated across all `n_x` grid cells at construction).
| Form field | Config key | Schema default | HTML default | Unit | Notes |
|:---|:---|:---|:---|:---|:---|
| Initial S_O | `initialState.S_O` | `0` | check editor | mg/L | Capped to saturation on the first tick. |
| Initial S_I | `initialState.S_I` | `30` | check editor | mg/L | Inert soluble COD. |
| Initial S_S | `initialState.S_S` | `70` | check editor | mg/L | Readily biodegradable substrate. |
| Initial S_NH | `initialState.S_NH` | `25` | check editor | mg/L | Ammonium &mdash; declines with nitrification. |
| Initial S_N2 | `initialState.S_N2` | `0` | check editor | mg/L | Dinitrogen. |
| Initial S_NO | `initialState.S_NO` | `0` | check editor | mg/L | Nitrate / nitrite. |
| Initial S_HCO | `initialState.S_HCO` | `5` | check editor | mmol/L | Alkalinity. |
| Initial X_I | `initialState.X_I` | `1000` | check editor | mg/L | Inert particulate COD. |
| Initial X_S | `initialState.X_S` | `100` | check editor | mg/L | Slowly biodegradable substrate. |
| Initial X_H | `initialState.X_H` | `2000` | check editor | mg/L | Heterotrophic biomass. |
| Initial X_STO | `initialState.X_STO` | `0` | check editor | mg/L | Stored COD in biomass. |
| Initial X_A | `initialState.X_A` | `200` | **`0.001`** | mg/L | **Footgun.** HTML default in `reactor.html` (per `CONTRACT.md`) is effectively zero, disabling nitrification. Always verify the deployed form value. |
| Initial X_TS | `initialState.X_TS` | `3500` | check editor | mg/L | Total suspended solids &mdash; drives downstream settler split. |
> [!WARNING]
> The HTML form supplies its own defaults; for fields where they differ from the schema (notably `X_A`), the HTML wins at deploy time. Either match the schema in the HTML or audit every deployed flow.
### Unit policy
reactor does **not** declare a UnitPolicy in `specificClass`. Units are carried in the schema's `rules.unit` strings (m³, m, 1/h, mg/L, mmol/L) and consumed by the engines without normalisation through MeasurementContainer's canonical-unit rule. Notable internal conversions:
| Quantity | What the engine uses internally | Where converted |
|:---|:---|:---|
| `timeStep` | days | `baseEngine.js` line ~40: `timeStep = config.timeStep / 86400` |
| `Fs` | m³/d (assumed by mass-balance formulas) | not converted &mdash; the caller is expected to push m³/d on `data.fluent` |
| `temperature` | &deg;C | stored as supplied (Celsius); `_calcOxygenSaturation(T)` expects &deg;C |
This is a known divergence from the platform-wide canonical-unit rule (`Pa` / `m³/s` / `W` / `K`). Tracked.
---
## Child registration
Source: `src/specificClass.js` `configure()` (ChildRouter wiring) + `BaseReactorEngine._connectMeasurement` / `_connectReactor`.
| Software type | Filter | Wired to | Side-effect |
|:---|:---|:---|:---|
| `measurement` | `asset.type = 'temperature'`, `positionVsParent = atEquipment` | `engine._connectMeasurement` &rarr; `_updateMeasurement` | Writes `engine.temperature`. CSTR only honours this. |
| `measurement` | `asset.type = 'quantity (oxygen)'`, `positionVsParent = <numeric distance>` | `engine._connectMeasurement` &rarr; `Reactor_PFR._updateMeasurement` | **PFR only.** Maps measurement to nearest grid cell by `clamp(round(pos / length &times; n_x), 0, n_x &minus; 1)`. Writes into `state[cell][S_O_INDEX]`. |
| `reactor` | `positionVsParent = 'upstream'` | `engine._connectReactor` | Subscribes to upstream reactor's `stateChange`. Each event triggers downstream `updateState`, which pulls upstream `getEffluent` into `Fs[0]` / `Cs_in[0]` before integrating. |
### Not a child: `diffuser`
`diffuser` (Equipment Module) is **not** registered as a reactor child. It feeds aeration via the `data.otr` topic on Port 0. No child-registration handshake is involved. If you want the diffuser's OTR to drive the reactor, wire the diffuser's process output to the reactor's input directly.
### Unrecognised softwareType
`BaseReactorEngine.registerChild` logs `Unrecognized softwareType: <x>` and drops the registration. There is no `valve`, `rotatingMachine`, etc. acceptance path.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, integration sequence, kinetics |
| [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 |

View File

@@ -1,160 +0,0 @@
# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-0e34403-blue)
> [!NOTE]
> Every example flow shipped under `nodes/reactor/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/reactor/examples/`.
>
> Pending full node review (2026-05). The current flows predate the standard 3-tier example-flow rework that `rotatingMachine` has completed; planned upgrade is tracked in the EVOLV superproject memory ("Example Flows" TODO).
---
## Shipped examples
| File | Tier | Dependencies | What it shows |
|:---|:---:|:---|:---|
| `basic.flow.json` | 1 | EVOLV only | Single CSTR with one inlet. Inject `data.fluent` to set influent, `data.clock` to advance the integrator; watch `Fluent` effluent on Port 0 and InfluxDB scalars on Port 1. |
| `integration.flow.json` | 2 | EVOLV only | Upstream `reactor` &rarr; `reactor` &rarr; `settler` chain. The downstream reactor registers the upstream via `child.register positionVsParent=upstream`; on each upstream `stateChange` the downstream pulls effluent and advances. |
| `edge.flow.json` | 3 | EVOLV only | PFR with axial dispersion (`data.dispersion`) and multi-inlet (`n_inlets > 1`). Emits both `GridProfile` and `Fluent` per advance. |
> [!IMPORTANT]
> **Screenshots needed.** Editor capture of each example flow. Save as `wiki/_partial-screenshots/reactor/{01-basic-cstr,02-chain,03-pfr-edge}.png`. Replace these callouts with image links once captured.
The legacy `additional_nodes/recirculation-pump` and `additional_nodes/settling-basin` Node-RED nodes are shipped from this repo but are not yet refactored to BaseDomain &mdash; they aren't part of these examples.
---
## 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/reactor/examples/basic.flow.json \
http://localhost:1880/flows
```
---
## Example &mdash; Basic CSTR
Single-reactor flow with one inlet and the minimum set of inputs needed to drive nitrification.
### What to do after deploy
1. Inject `data.temperature` with `payload: 15` (or whatever process T you want). Optional &mdash; default is 20 &deg;C.
2. Inject `data.fluent` with:
```json
{
"topic": "data.fluent",
"payload": {
"inlet": 0,
"F": 1000,
"C": [0, 30, 70, 25, 0, 0, 5, 1000, 100, 2000, 0, 200, 3500]
}
}
```
Note `C[11] = 200` (X_A &mdash; autotroph biomass). If you copy the HTML default of `0.001`, nitrification never starts.
3. If `kla > 0` is configured, you can skip OTR injection; the engine aerates internally. Otherwise inject `data.otr` with a positive scalar.
4. Inject `data.clock` repeatedly (or rely on the periodic tick &mdash; `tickInterval = 1000` ms wall-clock). Each advance integrates `n_iter = floor(speedUpFactor &middot; &Delta;t / timeStep_days)` internal steps.
5. Watch the debug tap on Port 0: `Fluent` envelopes with the 13-species effluent. `S_NH` should fall, `S_NO` should rise &mdash; nitrification is proceeding.
> [!IMPORTANT]
> **GIF needed.** Demo recording of `S_NH` &darr; / `S_NO` &uarr; over 30 simulated days. Save as `wiki/_partial-gifs/reactor/01-basic-cstr.gif`.
---
## Example &mdash; Reactor chain
Upstream &rarr; downstream coupling demo. The downstream reactor registers the upstream via:
```json
{
"topic": "child.register",
"payload": "<upstream-reactor-node-id>",
"positionVsParent": "upstream"
}
```
On every upstream `stateChange`, `engine._connectReactor` triggers downstream `updateState`. That call first reads `upstream.getEffluent` into the downstream's `Fs[0]` / `Cs_in[0]`, then integrates. So one `data.clock` to the upstream advances the whole chain.
> [!NOTE]
> Pending full node review (2026-05). The flow currently in `integration.flow.json` may not yet conform to the multi-tab layout standard (Process Plant / Dashboard UI / Demo Drivers / Setup) described in `.claude/rules/node-red-flow-layout.md` &mdash; planned upgrade tracked in the EVOLV "Example Flows" TODO.
---
## Example &mdash; PFR edge
Plug-flow reactor with axial discretization. After deploy:
1. Inject `data.dispersion` with `payload: <m²/d>` to set the axial dispersion coefficient `D`.
2. Inject one or more `data.fluent` messages with distinct `inlet` indices (0..`n_inlets &minus; 1`).
3. Drive with `data.clock` as usual.
4. Watch Port 0: each advance emits a `GridProfile` **before** the `Fluent`. The grid has `n_x` rows, 13 columns each.
5. Add a `measurement` child with `asset.type = 'quantity (oxygen)'` and a numeric `positionVsParent` (e.g. `5` for 5 m from the inlet). On each measurement event the PFR engine writes the value into the nearest grid cell's `S_O`.
Stability tips:
- `Pe_local = d_x &middot; sum(Fs) / (D &middot; A)` must be `< 2` &mdash; if you see `Local Peclet number ... is too high!`, either increase `resolution_L` (more cells, smaller `d_x`) or raise `D`.
- `Co_D = D &middot; timeStep / d_x²` must be `< 0.5` for the explicit FD scheme &mdash; if you see `Courant number ... is too high!`, decrease `timeStep`.
---
## Debug recipes
| Symptom | First thing to check | Where to look |
|:---|:---|:---|
| `S_NH` stays at its initial value &mdash; nitrification not proceeding | `initialState.X_A` is effectively zero (HTML default is `0.001` mg/L). Set to `~50` or higher to seed autotrophs. | `reactor.html` &harr; `generalFunctions/src/configs/reactor.json` `initialState.X_A` |
| `Fluent` payload `F = 0` | No `data.fluent` arrived, or `Fs[0]` is still 0 (no inlet flow). Check the message payload shape: `{inlet, F, C}`. | `src/commands/handlers.js` `dataFluent`, engine `setInfluent` |
| `Fluent` payload appears, but `C` array is all zeros / unchanged | `data.clock` not arriving, or `n_iter = 0` (timestamp delta too small for the configured `timeStep`). Bump `speedUpFactor` or check that clock injects are firing. | `engine.updateState` in `baseEngine.js` |
| PFR `GridProfile` not emitted | `reactor.reactor_type` is `CSTR` &mdash; only PFR has a grid profile. | `nodeClass._emitOutputs`, `pfr.getGridProfile` |
| `temperature` ignored | Payload is non-numeric, or wrapped as `{value: ...}` with `value` non-finite. Look for `Invalid temperature input: <raw>` in the log. | `baseEngine.js` `setTemperature` setter |
| Temperature child measurement not reconciling | The child's `asset.type` must be exactly `'temperature'` and `positionVsParent = atEquipment`. Anything else logs `Type '<x>' not recognized for measured update.` | `baseEngine.js` `_updateMeasurement` |
| `Local Peclet number ... is too high!` warning on every PFR `updateState` | Either `D` is too small, or `d_x` is too large. Increase `resolution_L` or set a larger dispersion. | `pfr.updateState` Peclet guard |
| `Courant number ... is too high!` warning | `timeStep` is too large for the configured `D`. Reduce it. | `pfr.updateState` Courant guard |
| Settler downstream not updating | Settler must subscribe to the **reactor's `emitter`**, not `reactor.measurements.emitter`. Historical bug in `settler/src/specificClass.js` `_connectReactor` (fixed 2026-03-02). | upstream chain wiring, `settler._connectReactor` |
| `wiki:datamodel` autogen script slow / timing out | `mathjs` cold-start is ~13 s. The current 60 s wrapper sometimes times out. | known limitation; fall back to the hand-curated Concrete sample in `CONTRACT.md` `Home.md` |
| `reactor_type: 'pfr'` (lowercase) silently runs CSTR | Schema validator lowercases the enum; `_buildEngine` calls `.toUpperCase()` to compensate. If you stripped that guard, lowercase `pfr` falls through to the default branch (CSTR). | `src/specificClass.js` `_buildEngine` |
| `data.otr` value ignored | `reactor.kla > 0`. The engine prefers internal `kla &middot; (sat &minus; S_O)` over external OTR. Set `kla = NaN` to enable external OTR. | `cstr.tick` / `pfr.tick` `klaIsNaN` branch |
> Never ship `enableLog: 'debug'` in a demo &mdash; the kinetics engines log per-step on debug, which fills the container log within seconds.
---
## 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).
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, kinetics engines, integration sequence |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [settler &mdash; Examples](https://gitea.wbd-rd.nl/RnD/settler/wiki/Reference-Examples) | The typical downstream Unit |
| [EVOLV &mdash; Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where reactor fits in a larger plant |

View File

@@ -1,132 +0,0 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-0e34403-blue)
> [!NOTE]
> What `reactor` does not do, current rough edges, and open questions. Open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` and `.claude/refactor/OPEN_QUESTIONS.md` in the superproject.
>
> Pending full node review (2026-05). Content reflects `CONTRACT.md`, the current source, and a partial walkthrough of `src/kinetics/` &mdash; not a full audit.
---
## When you would not use this node
| Scenario | Use instead |
|:---|:---|
| A passive equalisation tank (no biological reactions, just buffering) | A simple Node-RED buffer / function node &mdash; the kinetics engines assume reactions are happening. |
| A residence-time delay (plug-flow without ASM) | A delay node or custom buffer; the ASM3 13-species machinery + `mathjs` cold-start are overkill. |
| Aerobic-only contactors where you only need oxygen mass-transfer | An OTR-only model is lighter; ASM3 brings 13 species you'll ignore. |
| A clarifier / settler | `settler` &mdash; reactor has no settling, no sludge thickening, no underflow / overflow split. |
| A pump / blower / valve | `rotatingMachine` / `valve` &mdash; reactor is a process-tank model, not an actuator. |
| Anaerobic digestion | ASM3 is calibrated for activated sludge under aerobic / anoxic conditions. ADM1 (a separate model family) is the right tool for digesters. |
---
## Known limitations
### `X_A` initial default footgun
The HTML editor form's default for initial autotroph biomass is **`0.001` mg/L** (effectively zero). The JSON schema default is `200` mg/L. The HTML wins at deploy time. With `X_A &asymp; 0` nitrification never starts &mdash; `S_NH` stays at the influent value forever, no `S_NO` is produced.
> [!WARNING]
> Always open every deployed reactor node and confirm `Initial X_A` is `&ge; ~50` mg/L before expecting nitrification. Tracked in `CONTRACT.md` `## 14` row 2 and in EVOLV memory `MEMORY.md` "Key Integration Gotchas".
### `mathjs` cold-start ~13 s
`baseEngine.js` requires `mathjs`. The first `require('mathjs')` in a Node.js process takes ~13 s wall-clock to initialise. This delays first `data.clock` advance after a fresh deploy, and can time out the `wiki:datamodel` autogen wrapper (60 s budget). Two remedies tracked:
1. Tree-shake `mathjs` to only the operations actually used (`add`, `multiply`, `diag`, `resize`, `sum`, `divide`).
2. Lazy-initialise / cache the instance.
Tracked in `.claude/refactor/OPEN_QUESTIONS.md` &mdash; "mathjs slow load".
### `timeStep` unit mismatch
- HTML form label: `Time step [s]`.
- Schema (`generalFunctions/src/configs/reactor.json` line ~144): `unit: "h"`.
- `baseEngine.js` line ~40 converts by `&divide; 86400` (seconds &rarr; days) before using it.
The conversion suggests the **true** unit is seconds. Schema is wrong. Until reconciled, treat the form field as seconds. Tracked in `CONTRACT.md` `## 14` row 7 and in `OPEN_QUESTIONS.md` (Phase 5/6 cleanup list).
### `reactor_type` enum casing
The JSON schema validator lowercases `reactor_type` (so `'PFR'` &rarr; `'pfr'`). `Reactor._buildEngine` calls `.toUpperCase()` to compensate. If that guard is ever removed prematurely (before the platform-wide canonical casing rule is decided in Phase 7), PFR configs silently fall back to the default branch &mdash; which constructs a CSTR. Tracked in `OPEN_QUESTIONS.md`.
### `getEffluent` shape historically varied
Earlier versions of `BaseReactorEngine.getEffluent` returned either an envelope object or an array of envelopes (multi-outlet PFR). The current code emits a single `{topic, payload, timestamp}` envelope, but the downstream `settler._connectReactor` tolerates **both** shapes. Don't break this contract without coordinating with the settler node. EVOLV memory records a 2026-03-02 fix in settler for the array-vs-envelope assumption.
### No FSM &mdash; no mode / setpoint / startup-shutdown sequencing
reactor has no startup, no shutdown, no e-stop, no mode, no setpoint. It runs continuous-state ODE / PDE integration unconditionally as long as `data.clock` advances (or the tick loop fires). A downstream consumer that expects a `state` field on Port 0 will get nothing of the sort. This is by design &mdash; biological reactors don't have meaningful FSM states &mdash; but it's a divergence from `rotatingMachine` / `pumpingStation` patterns that callers should know about.
### No mode / source / action allow-list gating
All incoming topics are accepted as long as the payload validates. There is no `parent` / `GUI` / `fysical` source-gating, no `auto` / `virtualControl` / `fysicalControl` mode-gating. If you want to lock down a deployed reactor (e.g. ignore manual `data.fluent` injections while a real flow sensor is wired), you must do it externally.
### `additional_nodes/` legacy companions not refactored
`additional_nodes/recirculation-pump.js` and `additional_nodes/settling-basin.js` are sibling Node-RED nodes shipped from the reactor repo (because they share the same package context). They are **not yet refactored to BaseDomain**. Tracked as P6.5 follow-up.
### `reaction_modules/` legacy directory
`src/reaction_modules/asm3_class.js` is consumed by the current engines. `src/reaction_modules/asm3_class Koch.js` is a legacy plug-in variant **not consumed by anything in the current codebase**. Removal pending. Tracked as P6.5 follow-up.
### Units don't follow EVOLV canonical-unit rule
The platform-wide MeasurementContainer canonical units are `Pa` / `m³/s` / `W` / `K`. reactor uses m³/d for flow, &deg;C for temperature, mg/L (or mmol/L for alkalinity) for concentrations. No conversion at the system boundary. Calling code that expects canonical units must convert.
### `data.dispersion` is silently a no-op on CSTR
`specificClass.set setDispersion` checks `if (this.engine instanceof Reactor_PFR)` before forwarding. On a CSTR the setter just drops the payload &mdash; no warn, no error. If you deploy a flow that injects `data.dispersion` and switch the reactor type to CSTR, the injection is silently ignored.
### Single output-shape convention not documented per-key
The `getOutput()` implementation **omits** non-finite species values (`Number.isFinite` guard) rather than emitting them as `null`. Per `.claude/rules/output-coverage.md`, every node should pick one convention and document it. reactor's is "absent" &mdash; downstream consumers should treat a missing species key as "not produced this tick", never as zero.
### `output-coverage` manifest not yet present
`test/_output-manifest.md` (required by the platform-wide output-coverage rule, 2026-05-14) is not yet checked in for reactor. The Port-0 envelope shape, Port-1 InfluxDB fields, and `GridProfile` payload all need enumeration with populated + degraded test coverage. Tracked in `.agents/improvements/IMPROVEMENTS_BACKLOG.md`.
---
## Open questions (tracked)
| Question | Where it lives |
|:---|:---|
| `mathjs` slow load &mdash; tree-shake or lazy-init | `.claude/refactor/OPEN_QUESTIONS.md` &mdash; "mathjs slow load" |
| `reactor_type` enum casing &mdash; platform-wide canonical | `.claude/refactor/OPEN_QUESTIONS.md` &mdash; "reactor schema enum lowercases reactor_type" |
| `timeStep` unit reconciliation (HTML `s` vs schema `h` vs engine `d`) | `OPEN_QUESTIONS.md` Phase 5/6 cleanup list |
| Removal of `reaction_modules/asm3_class Koch.js` and `additional_nodes/*` | P6.5 follow-up |
| Output-coverage manifest + populated / degraded tests | `.agents/improvements/IMPROVEMENTS_BACKLOG.md` |
| Should reactor adopt a canonical-unit boundary like the rest of EVOLV? | Internal &mdash; not yet ticketed |
| Multi-outlet PFR (separate effluent streams per spatial point) | Internal &mdash; long-term |
---
## Migration notes
### From the pre-`BaseDomain` reactor
The current `specificClass` extends `BaseDomain` and uses ChildRouter to dispatch `measurement` / `reactor` registrations. Older flows that pre-date this refactor may have hand-wired child handlers; redeploying after `npm install` should pick up the new path automatically &mdash; no schema migration is required.
### From legacy topic names
The five `data.*` topics replace the pre-canonical PascalCase aliases (`Fluent`, `OTR`, `Temperature`, `Dispersion`, `clock`). The aliases are still accepted and emit a one-time deprecation warning on first use, but will be removed in Phase 7. Migrate flows by renaming the topic string on each inject.
### From hand-counted internal steps
Before the `speedUpFactor` field, simulation acceleration required adjusting `timeStep`. The current path is to leave `timeStep` at its physically-meaningful value (~1 s) and crank `speedUpFactor` to advance more process-time per wall-clock second. Old flows with abnormally large `timeStep` should be re-saved with the new field.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child filters |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, kinetics engines, integration sequence |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [settler &mdash; Limitations](https://gitea.wbd-rd.nl/RnD/settler/wiki/Reference-Limitations) | The downstream Unit's quirks (incl. the historical `getEffluent` shape tolerance) |
| [diffuser wiki](https://gitea.wbd-rd.nl/RnD/diffuser/wiki/Home) | The Equipment node that pushes `data.otr` &mdash; not a registered child |

View File

@@ -1,20 +0,0 @@
### reactor
- [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)
- [settler wiki](https://gitea.wbd-rd.nl/RnD/settler/wiki/Home)
- [diffuser wiki](https://gitea.wbd-rd.nl/RnD/diffuser/wiki/Home)
- [measurement wiki](https://gitea.wbd-rd.nl/RnD/measurement/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)