Compare commits

9 Commits

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:05:58 +02:00
znetsixe
75d0413994 docs(CONTRACT): approve reactor's ASM-textbook unit divergence
reactor uses mg/L for concentrations, m³/d internally, °C, and 1/h
for KLa — diverging from EVOLV's canonical Pa/m³/s/W/K. This was a
real drift surfaced by the wiki audit; consensus is to keep it
because the ASM kinetics literature universally uses these units and
fighting that convention would obscure the math without improving
correctness. Now documented as an explicit, approved exception with
the conversion boundary spelled out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:35:36 +02:00
znetsixe
346a3ce2ab fix(reactor): X_A_init default 0.001 → 200; add timeStep-unit regression test
Two fixes for the reactor unit-confusion drift surfaced in the 2026-05-19
wiki uplift:

1. X_A_init default in reactor.html was 0.001 g COD/m³, which is
   effectively zero nitrifying biomass — the reactor cannot nitrify
   ammonia under that initial condition (per the project memory note,
   ~50 mg/L is the minimum). Aligned to the schema default of 200 in
   generalFunctions/src/configs/reactor.json. Same change in
   test/helpers/factories.js so the test factory mirrors the operational
   default; tests that need low-biomass scenarios already override.

2. New test/basic/timestep-units.basic.test.js locks in the
   `config.timeStep is interpreted as seconds` contract — verifies the
   engine's days-stored / seconds-input invariant and asserts the
   schema declares `unit: "s"`, `default: 1`. Companion to the schema
   fix in the generalFunctions submodule.

Full test suite: 49/49 pass (was 46/46 + 3 new).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:49 +02:00
znetsixe
cb49bb8b4d docs(wiki): full 5-page wiki matching the rotatingMachine reference format
Replaces the prior stub/partial wiki with a Home + Reference-{Architecture,
Contracts,Examples,Limitations} + _Sidebar structure. Topic-contract and
data-model sections wrapped in AUTOGEN markers for the future wiki-gen tool.
Source-vs-spec contradictions surfaced and flagged inline (not silently
fixed). Pending-review notes mark sections that need a full node review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:42:11 +02:00
znetsixe
0e34403c5d docs: add Folder & File Layout section per EVOLV convention
Each repo can now be read standalone for the file-naming convention. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:29 +02:00
znetsixe
d735f9485c docs(wiki): rewrite Home.md to full 14-section visual-first template
- Banner updated to c84dd78 / 2026-05-11
- Section 2: add diffuser (data.otr path, not child-register), upstream
  reactor stateChange, settler downstream; switch to ~~~mermaid fences
- Section 4: accurate code-map — cstr/pfr extend baseEngine, not peer nodes
- Section 6: split measurement into temperature + oxygen(PFR) rows; clarify
  diffuser is NOT a registered child; switch to ~~~mermaid fences
- Section 7: expand sequence with n_iter formula, DO capping, GridProfile alt
- Section 9: correct timeStep unit note (schema h vs HTML label s), add all
  13 init fields, note X_A HTML default footgun, enum-casing note in cell
- Section 14: add row #6 (reactor_type enum lowercasing / toUpperCase guard)
  and row #7 (timeStep unit mismatch — label vs schema vs engine conversion)

AUTOGEN markers (topic-contract, data-model) untouched — regenerated clean.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:41:15 +02:00
20 changed files with 1687 additions and 515 deletions

View File

@@ -21,3 +21,20 @@ Key points for this node:
- Stack same-level siblings vertically. - Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right). - Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
- Wrap in a Node-RED group box coloured `#50a8d9` (Unit). - 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

@@ -3,6 +3,28 @@
Hand-maintained for Phase 6; the `## Inputs` table is generated from Hand-maintained for Phase 6; the `## Inputs` table is generated from
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines. `src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
## Unit convention — approved exception to the canonical-unit rule
EVOLV's canonical units (`CLAUDE.md`, `generalFunctions/CONTRACT.md`)
are Pa / m³/s / W / K. **reactor diverges deliberately** — it follows
the ASM (Activated Sludge Model) kinetics literature convention:
- Concentrations: `mg/L` (= g/m³), `mmol/L` for alkalinity.
- Flow internally: `m³/d` (engine integrator runs in days; see
`baseEngine.js` line 40 — `timeStep` config field is seconds, but the
internal time base is days).
- Temperature: `°C`.
- KLa: `1/h` per the schema; multiplied by the seconds-input `timeStep`
inside `_calcOTR` — readers verifying the math should account for the
day-internal time base.
Unit conversion at the parent/child boundary happens via
`MeasurementContainer.UnitPolicy` and the `convert` utility. Other
nodes (rotatingMachine, pumpingStation, …) honour canonical units;
reactor is the only ASM-modelled node and pays the small cost of
domain-textbook units to stay aligned with every published reactor
reference.
## Inputs (msg.topic on Port 0) ## Inputs (msg.topic on Port 0)
| Canonical | Aliases (deprecated) | Payload | Effect | | Canonical | Aliases (deprecated) | Payload | Effect |

View File

@@ -13,7 +13,7 @@
<script type="text/javascript"> <script type="text/javascript">
RED.nodes.registerType("reactor", { RED.nodes.registerType("reactor", {
category: "EVOLV", category: "EVOLV",
color: "#50a8d9", color: "#6FAE5F",
defaults: { defaults: {
name: { value: "" }, name: { value: "" },
reactor_type: { value: "CSTR", required: true }, reactor_type: { value: "CSTR", required: true },
@@ -35,7 +35,7 @@
X_S_init: { value: 75., required: true }, X_S_init: { value: 75., required: true },
X_H_init: { value: 30., required: true }, X_H_init: { value: 30., required: true },
X_STO_init: { value: 0., required: true }, X_STO_init: { value: 0., required: true },
X_A_init: { value: 0.001, required: true }, X_A_init: { value: 200, required: true },
X_TS_init: { value: 125.0009, required: true }, X_TS_init: { value: 125.0009, required: true },
timeStep: { value: 1, required: true }, timeStep: { value: 1, required: true },
@@ -267,7 +267,8 @@
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label> <label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;"> <select id="node-input-dbaseOutputFormat" style="width:60%;">
<option value="influxdb">influxdb</option> <option value="influxdb">influxdb</option>
<option value="json">json</option> <option value="frost">frost</option>
<option value="json">json</option>
<option value="csv">csv</option> <option value="csv">csv</option>
</select> </select>
</div> </div>

View File

@@ -12,36 +12,44 @@ module.exports = [
topic: 'data.clock', topic: 'data.clock',
aliases: ['clock'], aliases: ['clock'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
description: 'Push the simulation clock tick (timestamp / dt) to the ASM solver.',
handler: handlers.dataClock, handler: handlers.dataClock,
}, },
{ {
topic: 'data.fluent', topic: 'data.fluent',
aliases: ['Fluent'], aliases: ['Fluent'],
payloadSchema: { type: 'object' }, 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, handler: handlers.dataFluent,
}, },
{ {
topic: 'data.otr', topic: 'data.otr',
aliases: ['OTR'], aliases: ['OTR'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
description: 'Push the current oxygen-transfer rate into the reactor.',
handler: handlers.dataOTR, handler: handlers.dataOTR,
}, },
{ {
topic: 'data.temperature', topic: 'data.temperature',
aliases: ['Temperature'], aliases: ['Temperature'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
description: 'Push the current reactor temperature.',
handler: handlers.dataTemperature, handler: handlers.dataTemperature,
}, },
{ {
topic: 'data.dispersion', topic: 'data.dispersion',
aliases: ['Dispersion'], aliases: ['Dispersion'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
description: 'Push a dispersion/mixing parameter update.',
handler: handlers.dataDispersion, handler: handlers.dataDispersion,
}, },
{ {
topic: 'child.register', topic: 'child.register',
aliases: ['registerChild'], aliases: ['registerChild'],
payloadSchema: { type: 'any' }, payloadSchema: { type: 'any' },
description: 'Register a child node (settler / measurement) with this reactor.',
handler: handlers.childRegister, handler: handlers.childRegister,
}, },
]; ];

View File

@@ -1,65 +1,83 @@
'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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass'); const { Reactor_CSTR, Reactor_PFR } = require('../../src/specificClass');
const { makeUiConfig } = require('../helpers/factories'); const { makeUiConfig } = require('../helpers/factories');
// These tests pinned the old private _loadConfig / _setupClass methods on function makeRED() { return { nodes: { getNode: () => null } }; }
// the pre-refactor nodeClass. After the BaseNodeAdapter migration the
// same logic lives in buildDomainConfig + the Reactor wrapper's engine function makeNode(id = 'reactor-1') {
// selector. We exercise both surfaces directly. 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', () => { test('buildDomainConfig coerces numeric fields and builds initial state vector', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
inst.node = { id: 'n-reactor-1' }; const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
inst.name = 'reactor'; try {
const dc = inst.buildDomainConfig( const dc = inst.buildDomainConfig(
makeUiConfig({ makeUiConfig({
volume: '12.5', volume: '12.5',
length: '9', length: '9',
resolution_L: '7', resolution_L: '7',
alpha: '0.5', alpha: '0.5',
n_inlets: '3', n_inlets: '3',
timeStep: '2', timeStep: '2',
S_O_init: '1.1', S_O_init: '1.1',
}), }),
); );
assert.equal(dc.reactor.volume, 12.5); assert.equal(dc.reactor.volume, 12.5);
assert.equal(dc.reactor.length, 9); assert.equal(dc.reactor.length, 9);
assert.equal(dc.reactor.resolution_L, 7); assert.equal(dc.reactor.resolution_L, 7);
assert.equal(dc.reactor.alpha, 0.5); assert.equal(dc.reactor.alpha, 0.5);
assert.equal(dc.reactor.n_inlets, 3); assert.equal(dc.reactor.n_inlets, 3);
assert.equal(dc.reactor.timeStep, 2); assert.equal(dc.reactor.timeStep, 2);
assert.equal(Object.keys(dc.initialState).length, 13); assert.equal(Object.keys(dc.initialState).length, 13);
assert.equal(dc.initialState.S_O, 1.1); assert.equal(dc.initialState.S_O, 1.1);
} finally {
closeNode(node);
}
}); });
test('Reactor wrapper instantiates CSTR engine when configured as CSTR', () => { test('Reactor wrapper instantiates CSTR engine when configured as CSTR', () => {
const Reactor = require('../../src/specificClass'); const node = makeNode();
const config = { const inst = new nodeClass(makeUiConfig({ reactor_type: 'CSTR' }), makeRED(), node, 'reactor');
general: { name: 'reactor', id: 'n', logging: { enabled: false, logLevel: 'error' } }, try {
functionality: { softwareType: 'reactor', positionVsParent: 'atEquipment' }, assert.ok(inst.source.engine instanceof Reactor_CSTR);
reactor: { reactor_type: 'CSTR', volume: 100, length: 10, resolution_L: 5, alpha: 0, } finally {
n_inlets: 1, kla: NaN, timeStep: 1 }, closeNode(node);
initialState: { S_O: 0, S_I: 30, S_S: 100, S_NH: 16, S_N2: 0, S_NO: 0, S_HCO: 5, }
X_I: 25, X_S: 75, X_H: 30, X_STO: 0, X_A: 0.001, X_TS: 125 },
};
const r = new Reactor(config);
assert.ok(r.engine instanceof Reactor_CSTR);
}); });
test('Reactor wrapper instantiates PFR engine when configured as PFR', () => { test('Reactor wrapper instantiates PFR engine when configured as PFR', () => {
const Reactor = require('../../src/specificClass'); const node = makeNode();
const config = { const inst = new nodeClass(makeUiConfig({ reactor_type: 'PFR' }), makeRED(), node, 'reactor');
general: { name: 'reactor', id: 'n', logging: { enabled: false, logLevel: 'error' } }, try {
functionality: { softwareType: 'reactor', positionVsParent: 'atEquipment' }, assert.ok(inst.source.engine instanceof Reactor_PFR);
reactor: { reactor_type: 'PFR', volume: 100, length: 10, resolution_L: 5, alpha: 0, } finally {
n_inlets: 1, kla: NaN, timeStep: 1 }, closeNode(node);
initialState: { S_O: 0, S_I: 30, S_S: 100, S_NH: 16, S_N2: 0, S_NO: 0, S_HCO: 5, }
X_I: 25, X_S: 75, X_H: 30, X_STO: 0, X_A: 0.001, X_TS: 125 },
};
const r = new Reactor(config);
assert.ok(r.engine instanceof Reactor_PFR);
}); });

View File

@@ -1,56 +1,111 @@
'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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands'); const { makeUiConfig } = require('../helpers/factories');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
// Post-refactor: dispatch goes through the commands registry built by function makeNode(id = 'reactor-1') {
// BaseNodeAdapter (this._commands). We seed the registry on a prototype- const sends = [];
// derived instance, then drive _attachInputHandler the same way the live const statuses = [];
// adapter would. const handlers = {};
return {
test('input handler routes legacy topic aliases to engine setters', async () => { id, sends, statuses, handlers,
const inst = Object.create(NodeClass.prototype); send(arr) { sends.push(arr); },
const node = makeNodeStub(); status(b) { statuses.push(b); },
const calls = []; on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
const source = {
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
updateState(t) { calls.push(['clock', t]); },
childRegistrationUtils: {
registerChild(childSource, position) { calls.push(['registerChild', childSource, position]); },
},
}; };
}
Object.defineProperty(source, 'setInfluent', { set(v) { calls.push(['Fluent', v]); } }); function makeRED(nodeMap = {}) {
Object.defineProperty(source, 'setOTR', { set(v) { calls.push(['OTR', v]); } }); return { nodes: { getNode: (id) => nodeMap[id] || null } };
Object.defineProperty(source, 'setTemperature', { set(v) { calls.push(['Temperature', v]); } }); }
Object.defineProperty(source, 'setDispersion', { set(v) { calls.push(['Dispersion', v]); } });
inst.node = node; function closeNode(node) {
inst.RED = makeREDStub({ childA: { source: { id: 'child-source-A' } } }); if (node.handlers.close) node.handlers.close(() => {});
inst.source = source; }
inst._commands = createRegistry(commands, { logger: source.logger });
inst._attachInputHandler();
const onInput = node._handlers.input; test('legacy alias topics drive engine setters and updateState', async () => {
let doneCount = 0; const childSource = {
const done = () => { doneCount += 1; }; 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');
await onInput({ topic: 'clock', timestamp: 1000 }, () => {}, done); try {
await onInput({ topic: 'Fluent', payload: { inlet: 0, F: 10, C: [] } }, () => {}, done); let doneCount = 0;
await onInput({ topic: 'OTR', payload: 3.5 }, () => {}, done); const done = () => { doneCount += 1; };
await onInput({ topic: 'Temperature', payload: 18.2 }, () => {}, done);
await onInput({ topic: 'Dispersion', payload: 0.2 }, () => {}, done);
await onInput({ topic: 'registerChild', payload: 'childA', positionVsParent: 'upstream' }, () => {}, done);
assert.equal(doneCount, 6); // data.clock alias → updateState(timestamp). Capture currentTime
assert.deepEqual(calls[0], ['clock', 1000]); // before/after to verify the engine advanced.
assert.equal(calls.some((x) => x[0] === 'Fluent'), true); const t0 = inst.source.engine.currentTime;
assert.equal(calls.some((x) => x[0] === 'OTR'), true); await node.handlers.input({ topic: 'clock', timestamp: t0 + 1 }, () => {}, done);
assert.equal(calls.some((x) => x[0] === 'Temperature'), true);
assert.equal(calls.some((x) => x[0] === 'Dispersion'), true); // Fluent alias → engine setInfluent setter.
assert.deepEqual(calls.at(-1), ['registerChild', { id: 'child-source-A' }, 'upstream']); 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,32 +1,84 @@
'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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const { makeNodeStub } = require('../helpers/factories'); const { makeUiConfig } = require('../helpers/factories');
// Post-refactor: BaseNodeAdapter handles registration via _scheduleRegistration function makeRED() { return { nodes: { getNode: () => null } }; }
// (was _registerChild). Topic moved from 'registerChild' to 'child.register'.
test('_scheduleRegistration emits delayed child.register message on output 2', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
inst.node = node; function makeNode(id = 'reactor-node-1') {
inst.config = { functionality: { positionVsParent: 'downstream', distance: null } }; 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() {},
};
}
const originalSetTimeout = global.setTimeout; function closeNode(node) {
const delays = []; if (node.handlers.close) node.handlers.close(() => {});
global.setTimeout = (fn, ms) => { delays.push(ms); fn(); return 1; }; }
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 { try {
inst._scheduleRegistration(); // BaseNodeAdapter._scheduleRegistration uses a 100ms setTimeout; wait
} finally { // slightly longer to let it fire.
global.setTimeout = originalSetTimeout; await new Promise((r) => setTimeout(r, 130));
}
assert.deepEqual(delays, [100]); // The registration send is the [null, null, {child.register}] triple.
assert.equal(node._sent.length, 1); const regSends = node.sends.filter(
assert.equal(Array.isArray(node._sent[0]), true); (s) => Array.isArray(s) && s[0] === null && s[1] === null && s[2] && s[2].topic === 'child.register',
assert.equal(node._sent[0][2].topic, 'child.register'); );
assert.equal(node._sent[0][2].payload, node.id); assert.equal(regSends.length, 1, 'exactly one child.register message expected');
assert.equal(node._sent[0][2].positionVsParent, 'downstream'); 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,28 +1,52 @@
'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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const nodeClass = require('../../src/nodeClass');
const { Reactor_CSTR } = require('../../src/specificClass'); const { Reactor_CSTR } = require('../../src/specificClass');
const NodeClass = require('../../src/nodeClass');
const { makeReactorConfig, makeUiConfig } = require('../helpers/factories'); const { makeReactorConfig, makeUiConfig } = require('../helpers/factories');
/** function makeRED() { return { nodes: { getNode: () => null } }; }
* Smoke tests for Fix 3: configurable speedUpFactor on Reactor.
*/
test('specificClass defaults speedUpFactor to 1 when not in config', () => { 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 config = makeReactorConfig();
const reactor = new Reactor_CSTR(config); const reactor = new Reactor_CSTR(config);
assert.equal(reactor.speedUpFactor, 1, 'speedUpFactor should default to 1'); assert.equal(reactor.speedUpFactor, 1, 'speedUpFactor should default to 1');
}); });
test('specificClass accepts speedUpFactor from config', () => { test('Reactor_CSTR engine accepts speedUpFactor from config', () => {
const config = makeReactorConfig(); const config = makeReactorConfig();
config.speedUpFactor = 10; config.speedUpFactor = 10;
const reactor = new Reactor_CSTR(config); const reactor = new Reactor_CSTR(config);
assert.equal(reactor.speedUpFactor, 10, 'speedUpFactor should be read from config'); assert.equal(reactor.speedUpFactor, 10, 'speedUpFactor should be read from config');
}); });
test('specificClass accepts speedUpFactor = 60 for accelerated simulation', () => { test('Reactor_CSTR engine accepts speedUpFactor = 60 for accelerated simulation', () => {
const config = makeReactorConfig(); const config = makeReactorConfig();
config.speedUpFactor = 60; config.speedUpFactor = 60;
const reactor = new Reactor_CSTR(config); const reactor = new Reactor_CSTR(config);
@@ -30,21 +54,27 @@ test('specificClass accepts speedUpFactor = 60 for accelerated simulation', () =
}); });
test('buildDomainConfig propagates speedUpFactor from uiConfig', () => { test('buildDomainConfig propagates speedUpFactor from uiConfig', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
inst.node = { id: 'n-reactor' }; const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
inst.name = 'reactor'; try {
const dc = inst.buildDomainConfig(makeUiConfig({ speedUpFactor: 5 })); const dc = inst.buildDomainConfig(makeUiConfig({ speedUpFactor: 5 }));
assert.equal(dc.reactor.speedUpFactor, 5); assert.equal(dc.reactor.speedUpFactor, 5);
} finally {
closeNode(node);
}
}); });
test('buildDomainConfig defaults speedUpFactor to 1 when missing from uiConfig', () => { test('buildDomainConfig defaults speedUpFactor to 1 when missing from uiConfig', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
inst.node = { id: 'n-reactor' }; const inst = new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
inst.name = 'reactor'; try {
const ui = makeUiConfig(); const ui = makeUiConfig();
delete ui.speedUpFactor; delete ui.speedUpFactor;
const dc = inst.buildDomainConfig(ui); const dc = inst.buildDomainConfig(ui);
assert.equal(dc.reactor.speedUpFactor, 1); assert.equal(dc.reactor.speedUpFactor, 1);
} finally {
closeNode(node);
}
}); });
test('updateState with speedUpFactor=1 advances roughly real-time', () => { test('updateState with speedUpFactor=1 advances roughly real-time', () => {

View File

@@ -0,0 +1,44 @@
'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

@@ -1,21 +1,65 @@
'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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const Reactor = require('../../src/specificClass'); const nodeClass = require('../../src/nodeClass');
const { Reactor_CSTR } = require('../../src/specificClass'); const { Reactor_CSTR } = require('../../src/specificClass');
const { makeUiConfig } = require('../helpers/factories');
// Post-refactor: an unknown reactor_type falls back to CSTR and warns, function makeRED() { return { nodes: { getNode: () => null } }; }
// rather than throwing.
test('Reactor wrapper falls back to CSTR when reactor_type is unknown', () => { function makeNode(id = 'reactor-node-1') {
const config = { const sends = [];
general: { name: 'reactor', id: 'n', logging: { enabled: false, logLevel: 'error' } }, const statuses = [];
functionality: { softwareType: 'reactor', positionVsParent: 'atEquipment' }, const handlers = {};
reactor: { reactor_type: 'UNKNOWN_TYPE', volume: 100, length: 10, resolution_L: 5, return {
alpha: 0, n_inlets: 1, kla: NaN, timeStep: 1 }, id, sends, statuses, handlers,
initialState: { S_O: 0, S_I: 30, S_S: 100, S_NH: 16, S_N2: 0, S_NO: 0, S_HCO: 5, send(arr) { sends.push(arr); },
X_I: 25, X_S: 75, X_H: 30, X_STO: 0, X_A: 0.001, X_TS: 125 }, status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
}; };
}
const r = new Reactor(config); function closeNode(node) {
assert.ok(r.engine instanceof Reactor_CSTR); 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,31 +1,68 @@
'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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands'); const { makeUiConfig } = require('../helpers/factories');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = 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 () => { test('unknown input topic does not throw and still calls done', async () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
const node = makeNodeStub(); new nodeClass(makeUiConfig(), makeRED(), node, 'reactor');
inst.node = node; try {
inst.RED = makeREDStub(); let doneCalled = 0;
inst.source = { await assert.doesNotReject(async () => {
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} }, await node.handlers.input(
childRegistrationUtils: { registerChild() {} }, { topic: 'somethingUnknown', payload: 1 },
updateState() {}, () => {},
}; () => { doneCalled += 1; },
inst._commands = createRegistry(commands, { logger: inst.source.logger }); );
inst._attachInputHandler();
let doneCalled = 0;
await assert.doesNotReject(async () => {
await node._handlers.input({ topic: 'somethingUnknown', payload: 1 }, () => {}, () => {
doneCalled += 1;
}); });
}); assert.equal(doneCalled, 1);
} finally {
assert.equal(doneCalled, 1); 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,29 +1,91 @@
'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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const commands = require('../../src/commands'); const { makeUiConfig } = require('../helpers/factories');
const { createRegistry } = require('generalFunctions');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('registerChild with unknown node id is ignored without throwing', async () => { function makeRED(nodeMap = {}) {
const inst = Object.create(NodeClass.prototype); return { nodes: { getNode: (id) => nodeMap[id] || null } };
const node = makeNodeStub(); }
inst.node = node; function makeNode(id = 'reactor-node-1') {
inst.RED = makeREDStub(); const sends = [];
inst.source = { const statuses = [];
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} }, const handlers = {};
childRegistrationUtils: { registerChild() {} }, return {
id, sends, statuses, handlers,
send(arr) { sends.push(arr); },
status(b) { statuses.push(b); },
on(ev, fn) { handlers[ev] = fn; },
warn() {}, error() {},
}; };
inst._commands = createRegistry(commands, { logger: inst.source.logger }); }
inst._attachInputHandler();
await assert.doesNotReject(async () => { function closeNode(node) {
await node._handlers.input( if (node.handlers.close) node.handlers.close(() => {});
{ topic: 'registerChild', payload: 'missing-child', positionVsParent: 'upstream' }, }
() => {},
() => {}, 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

@@ -21,7 +21,7 @@ function makeUiConfig(overrides = {}) {
X_S_init: 75, X_S_init: 75,
X_H_init: 30, X_H_init: 30,
X_STO_init: 0, X_STO_init: 0,
X_A_init: 0.001, X_A_init: 200,
X_TS_init: 125, X_TS_init: 125,
timeStep: 1, timeStep: 1,
enableLog: false, enableLog: false,

View File

@@ -1,103 +1,156 @@
'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 test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass'); const nodeClass = require('../../src/nodeClass');
const { makeNodeStub } = require('../helpers/factories'); const { makeUiConfig } = require('../helpers/factories');
// Post-refactor: BaseNodeAdapter drives tick + status loops. The reactor function makeRED() { return { nodes: { getNode: () => null } }; }
// nodeClass overrides _emitOutputs to preserve the Fluent / GridProfile
// Port-0 contract (delta-compressed payloads can't carry the C-vector).
test('_emitOutputs emits effluent on process output', () => { function makeNode(id = 'reactor-node-1') {
const inst = Object.create(NodeClass.prototype); const sends = [];
const node = makeNodeStub(); const statuses = [];
const handlers = {};
inst.node = node; return {
inst.config = { functionality: { softwareType: 'reactor' }, general: { id: 'r-1' } }; id, sends, statuses, handlers,
inst._output = { formatMsg() { return null; } }; send(arr) { sends.push(arr); },
inst.source = { status(b) { statuses.push(b); },
engine: { temperature: 18, getEffluent: { topic: 'Fluent', payload: { inlet: 0, F: 1, C: [] }, timestamp: 1 }, get getGridProfile() { return null; } }, on(ev, fn) { handlers[ev] = fn; },
config: inst.config, warn() {}, error() {},
updateState() {},
get getEffluent() { return this.engine.getEffluent; },
get getGridProfile() { return this.engine.getGridProfile; },
getOutput() { return {}; },
}; };
}
inst._emitOutputs(); function closeNode(node) {
if (node.handlers.close) node.handlers.close(() => {});
}
assert.equal(node._sent.length, 1); function pickEffluentSends(node) {
assert.equal(node._sent[0][0].topic, 'Fluent'); return node.sends.filter((s) => Array.isArray(s) && s[0] && s[0].topic === 'Fluent');
assert.equal(node._sent[0][1], null); }
assert.equal(node._sent[0][2], null);
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 reactor telemetry on influx output', () => { test('_emitOutputs emits a GridProfile message when engine exposes one (PFR)', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
const node = makeNodeStub(); const inst = new nodeClass(
let captured = null; makeUiConfig({ reactor_type: 'PFR' }),
makeRED(),
node,
'reactor',
);
inst.node = node; try {
inst.config = { functionality: { softwareType: 'reactor' }, general: { id: 'reactor-node-1' } }; node.sends.length = 0;
inst._output = { inst._emitOutputs();
formatMsg(output, _config, format) {
captured = { output, format };
return { topic: `reactor_${inst.config.general.id}`, payload: { measurement: 'reactor', fields: output } };
},
};
const effluent = { topic: 'Fluent', payload: { inlet: 0, F: 42, C: [2.1, 30, 100, 16, 0, 1, 8, 25, 75, 1500, 0, 15, 2500] }, timestamp: 1 };
inst.source = {
engine: { temperature: 19.5, getEffluent: effluent, get getGridProfile() { return null; } },
config: inst.config,
updateState() {},
get getEffluent() { return this.engine.getEffluent; },
get getGridProfile() { return this.engine.getGridProfile; },
getOutput() {
const C = effluent.payload.C;
const out = { flow_total: effluent.payload.F, temperature: 19.5 };
const 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'];
for (let i = 0; i < keys.length; i += 1) out[keys[i]] = C[i];
return out;
},
};
inst._emitOutputs(); assert.equal(pickGridSends(node).length, 1, 'exactly one GridProfile message');
assert.equal(pickEffluentSends(node).length, 1, 'exactly one Fluent message');
assert.equal(node._sent.length, 1); } finally {
assert.equal(node._sent[0][0].topic, 'Fluent'); closeNode(node);
assert.equal(node._sent[0][1].topic, 'reactor_reactor-node-1'); }
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);
}); });
test('_emitOutputs also emits GridProfile when engine exposes one', () => { test('_emitOutputs formats per-species influx telemetry via outputUtils', () => {
const inst = Object.create(NodeClass.prototype); const node = makeNode();
const node = makeNodeStub(); const inst = new nodeClass(
makeUiConfig({ reactor_type: 'CSTR' }),
makeRED(),
node,
'reactor',
);
inst.node = node; try {
inst.config = { functionality: { softwareType: 'reactor' }, general: { id: 'r-1' } }; // Stub updateState so the engine integration does not overwrite the
inst._output = { formatMsg() { return null; } }; // engineered state we want the telemetry formatter to see.
const grid = { grid: [[0]], n_x: 1, d_x: 1, length: 1, species: [], timestamp: 1 }; inst.source.updateState = () => {};
inst.source = { inst.source.engine.setInfluent = {
engine: { payload: { inlet: 0, F: 42, C: [2.1, 30, 100, 16, 0, 1, 8, 25, 75, 1500, 0, 15, 2500] },
temperature: 18, };
getEffluent: { topic: 'Fluent', payload: { inlet: 0, F: 1, C: [] }, timestamp: 1 }, inst.source.engine.state = [2.1, 30, 100, 16, 0, 1, 8, 25, 75, 1500, 0, 15, 2500];
get getGridProfile() { return grid; }, inst.source.engine.temperature = 19.5;
},
config: inst.config,
updateState() {},
get getEffluent() { return this.engine.getEffluent; },
get getGridProfile() { return this.engine.getGridProfile; },
getOutput() { return {}; },
};
inst._emitOutputs(); 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);
};
assert.equal(node._sent.length, 2); node.sends.length = 0;
assert.equal(node._sent[0][0].topic, 'GridProfile'); inst._emitOutputs();
assert.equal(node._sent[1][0].topic, 'Fluent');
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,285 +1,182 @@
# reactor # reactor
> **Reflects code as of `b8247fc` · regenerated `2026-05-11` via `npm run wiki:all`** ![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)
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
## 1. What this node is 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.
**reactor** is an S88 Unit that wraps an ASM3 biological-process engine — either a CSTR (fully mixed tank) or a PFR (plug-flow with axial dispersion). It integrates 13 species (S_O, S_NH, X_H, X_TS, …) and emits the effluent vector each tick. Drives a settler downstream and accepts a recirculation pump child. > [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only.
## 2. Position in the platform ---
## 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 ```mermaid
flowchart LR flowchart LR
upstream[reactor<br/>upstream<br/>Unit]:::unit upstream[reactor<br/>upstream<br/>Unit]:::unit
reactor[reactor<br/>Unit]:::unit rx[reactor<br/>Unit]:::unit
settler[settler<br/>downstream<br/>Unit]:::unit settler[settler<br/>downstream<br/>Unit]:::unit
pump[rotatingMachine<br/>downstream<br/>Equipment]:::equip diffuser[diffuser<br/>Equipment]:::equip
tsens[measurement<br/>temperature<br/>atequipment]:::ctrl tsens[measurement<br/>temperature<br/>atEquipment]:::ctrl
osens[measurement<br/>oxygen<br/>position]:::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
upstream -.stateChange.-> reactor
reactor -->|Fluent inlet=0| settler
pump -->|child.register downstream| reactor
tsens -->|temperature.measured.atequipment| reactor
osens -->|quantity (oxygen).measured.&lt;position&gt;| reactor
classDef unit fill:#50a8d9,color:#000 classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000 classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,color:#000 classDef ctrl fill:#a9daee,color:#000
``` ```
S88 colours: Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`. S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`.
## 3. Capability matrix 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`.
| Capability | Status | Notes | ---
|---|---|---|
| ASM3 13-species ODE integration | ✅ | CSTR + PFR engines under `kinetics/`. |
| CSTR (fully mixed) | ✅ | Single concentration vector per tick. |
| PFR (axial discretization) | ✅ | `resolution_L` grid cells; emits `GridProfile` alongside `Fluent`. |
| Multi-inlet mixing | ✅ | `n_inlets`; each inlet receives its own `data.fluent` with `inlet` index. |
| Temperature reconcile from measurement | ✅ | `temperature.measured.atEquipment` writes `engine.temperature`. |
| Oxygen reconcile (PFR) | ✅ | `quantity (oxygen).measured.<distance>` maps to nearest grid cell. |
| KLa-driven aeration | ✅ | `reactor.kla` > 0 enables internal mass transfer; falls back to `data.otr`. |
| Speed-up factor (sim time) | ✅ | `reactor.speedUpFactor` accelerates wall-clock → process time. |
| Dispersion override (PFR) | ✅ | `data.dispersion` updates axial `D`. |
| Hot-swap engine type | ❌ | `reactor_type` is read once in `configure()`. |
## 4. Code map ## Try it &mdash; 3-minute demo
```mermaid Import the basic example flow, deploy, and watch a CSTR consume influent over the simulation clock.
flowchart TB
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"] ```bash
nc["buildDomainConfig()<br/>static DomainClass = Reactor<br/>static commands"] curl -X POST -H 'Content-Type: application/json' \
end --data @nodes/reactor/examples/basic.flow.json \
subgraph domain["specificClass.js — orchestrator (BaseDomain)"] http://localhost:1880/flow
sc["Reactor.configure()<br/>flatten config → build engine<br/>ChildRouter rules"]
end
subgraph kinetics["src/kinetics/"]
be["baseEngine.js<br/>shared ASM3 rate vector"]
cstr["cstr.js<br/>0-D integrator"]
pfr["pfr.js<br/>spatial discretization + dispersion"]
end
subgraph commands["src/commands/"]
cmds["index.js + handlers.js<br/>6 input topics"]
end
sc --> be
sc --> cstr
sc --> pfr
nc --> sc
nc --> cmds
``` ```
| Module | Owns | Read first if you're changing… | What to click after deploy (each inject maps one-to-one to a topic in [Reference &mdash; Contracts](Reference-Contracts#topic-contract)):
|---|---|---|
| `kinetics/baseEngine.js` | ASM3 stoichiometry + rate vector + species list. | Stoichiometric matrix, kinetic constants. |
| `kinetics/cstr.js` | 0-D CSTR integrator + `_connectMeasurement` + `_connectReactor`. | Mixed-tank behaviour, child wiring. |
| `kinetics/pfr.js` | Axial discretization, dispersion, grid profile emission. | PFR-specific behaviour, grid math. |
| `commands/` | 6 input descriptors + handlers (clock, fluent, OTR, temperature, dispersion, child). | Inbound topic API, alias deprecation. |
| `reaction_modules/` | Optional plug-in reaction modules (legacy — not yet refactored). | Adding new bio-process modules. |
| `additional_nodes/` | Sibling Node-RED nodes (`recirculation-pump`, `settling-basin`) shipped from this repo. | Cross-node deploy in same package. |
## 5. 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`).
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`. > [!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`.
<!-- BEGIN AUTOGEN: topic-contract --> ---
| Canonical topic | Aliases | Payload | Effect | ## The six things you'll send
|---|---|---|---|
| `data.clock` | `clock` | `any` | Pushes a value into the node's measurement stream. |
| `data.fluent` | `Fluent` | `object` | Pushes a value into the node's measurement stream. |
| `data.otr` | `OTR` | `any` | Pushes a value into the node's measurement stream. |
| `data.temperature` | `Temperature` | `any` | Pushes a value into the node's measurement stream. |
| `data.dispersion` | `Dispersion` | `any` | Pushes a value into the node's measurement stream. |
| `child.register` | `registerChild` | `any` | Parent/child plumbing — registers or unregisters a child node. |
<!-- END AUTOGEN: topic-contract --> | 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. |
## 6. Child registration > [!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.
```mermaid ---
flowchart LR
subgraph kids["accepted children (softwareType)"]
m_t["measurement<br/>temperature"]:::ctrl
m_o["measurement<br/>quantity (oxygen)"]:::ctrl
r_up["reactor<br/>upstream"]:::unit
end
m_t -->|temperature.measured.atEquipment| h_meas[engine._connectMeasurement]
m_o -->|quantity (oxygen).measured.&lt;pos&gt;| h_meas
r_up -.stateChange.-> h_react[engine._connectReactor]
h_meas --> reconcile[reconcile T / O2 into engine state]
h_react --> pull[pull upstream effluent → Fs/Cs_in]
classDef ctrl fill:#a9daee,color:#000
classDef unit fill:#50a8d9,color:#000
```
| softwareType | filter | wired to | side-effect | ## What you'll see come out
|---|---|---|---|
| `measurement` | any | `engine._connectMeasurement` | `temperature.measured.atEquipment``engine.temperature`. PFR additionally honours `quantity (oxygen).measured.<distance>` → nearest grid cell DO. |
| `reactor` | upstream | `engine._connectReactor` | Subscribes to upstream reactor's `stateChange`; pulls effluent into `Fs[0]` / `Cs_in[0]` before next integration step. |
## 7. Lifecycle — what one `data.clock` advance does Sample Port 0 message (CSTR mid-integration, nitrifying):
```mermaid
sequenceDiagram
participant clock as clock injector
participant reactor as reactor
participant engine as kinetics engine
participant downstream as settler / next reactor
participant out as Port-0 output
clock->>reactor: data.clock { timestamp }
reactor->>engine: updateState(timestamp)
Note over engine: n_iter steps,<br/>each timeStep × speedUpFactor
engine->>engine: integrate ASM3 rates
engine->>engine: emit 'stateChange'
reactor->>reactor: notifyOutputChanged
reactor->>out: Fluent { inlet=0, F, C[13] }
alt PFR
reactor->>out: GridProfile { grid, n_x, d_x, … }
end
out->>downstream: Fluent envelope
```
`stateChange` re-emits on `reactor.emitter` (BaseDomain emitter) so downstream reactors / settlers can listen. The effluent emission goes through the BaseNodeAdapter tick pipeline.
## 8. Data model — `getOutput()`
Port-0 process payload is the `Fluent` envelope (+ optional `GridProfile` for PFR). Port-1 telemetry is the scalar snapshot below.
<!-- BEGIN AUTOGEN: data-model -->
| Key | Type | Unit | Sample |
|---|---|---|---|
| `S_HCO` | number | — | `5` |
| `S_I` | number | — | `30` |
| `S_N2` | number | — | `0` |
| `S_NH` | number | — | `25` |
| `S_NO` | number | — | `0` |
| `S_O` | number | — | `0` |
| `S_S` | number | — | `70` |
| `X_A` | number | — | `200` |
| `X_H` | number | — | `2000` |
| `X_I` | number | — | `1000` |
| `X_S` | number | — | `100` |
| `X_STO` | number | — | `0` |
| `X_TS` | number | — | `3500` |
| `flow_total` | number | — | `0` |
| `temperature` | number | — | `20` |
<!-- END AUTOGEN: data-model -->
**Concrete sample** (CSTR mid-integration, nitrifying):
```json ```json
{ {
"flow_total": 1000, "topic": "Fluent",
"temperature": 15.2, "payload": {
"S_O": 2.1, "inlet": 0,
"S_I": 30, "F": 1000,
"S_S": 12.4, "C": [2.1, 30, 12.4, 0.8, 4.3, 18.6, 4.2, 1050, 65, 2150, 4.5, 215, 3680]
"S_NH": 0.8, },
"S_N2": 4.3, "timestamp": 1747500000000
"S_NO": 18.6,
"S_HCO": 4.2,
"X_I": 1050,
"X_S": 65,
"X_H": 2150,
"X_STO": 4.5,
"X_A": 215,
"X_TS": 3680
} }
``` ```
Species ordering follows ASM3: indices 06 are soluble, 712 are particulate. `flow_total` is the effluent flow (m³/d); the reactor uses days as the time unit internally. 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:
## 9. Configuration — editor form ↔ config keys ```json
{
```mermaid "topic": "GridProfile",
flowchart TB "payload": {
subgraph editor["Node-RED editor form"] "grid": [[...13...], [...13...], "...n_x rows..."],
f1[Reactor type CSTR / PFR] "n_x": 10,
f2[Volume m3] "d_x": 1.0,
f3[Length m + resolution] "length": 10,
f4[Alpha dispersion] "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"],
f5[KLa 1/h] "timestamp": 1747500000000
f6[Time step + speed-up] }
f7[Initial state 13 species] }
end
subgraph config["Domain config slice"]
c1[reactor.reactor_type]
c2[reactor.volume]
c3[reactor.length<br/>reactor.resolution_L]
c4[reactor.alpha]
c5[reactor.kla]
c6[reactor.timeStep<br/>reactor.speedUpFactor]
c7[initialState.* ASM3 keys]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
f7 --> c7
``` ```
| Form field | Config key | Default | Range | Where used | 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).
|---|---|---|---|---|
| Reactor type | `reactor.reactor_type` | `CSTR` | enum: `CSTR` / `PFR` | engine selection in `_buildEngine` |
| Volume (m³) | `reactor.volume` | `1000` | > 0 | residence time, mass balance |
| Length (m) | `reactor.length` | `10` | > 0 | PFR only — axial extent |
| Resolution L | `reactor.resolution_L` | `10` | ≥ 1 | PFR grid cell count |
| Alpha | `reactor.alpha` | `0.5` | 01 | dispersion vs plug-flow blend |
| Inlets | `reactor.n_inlets` | `1` | ≥ 1 | `Fs[]` / `Cs_in[]` array sizes |
| KLa (1/h) | `reactor.kla` | `0` | ≥ 0 | aeration mass transfer (NaN → use `data.otr`) |
| Time step (h) | `reactor.timeStep` | `0.001` | ≥ 0.0001 | integrator inner step |
| Speed-up factor | `reactor.speedUpFactor` | `1` | ≥ 1 | wall-clock → process-time multiplier |
| Initial S_NH | `initialState.S_NH` | `25` | ≥ 0 (mg/L) | starting ammonium |
| Initial X_H | `initialState.X_H` | `2000` | ≥ 0 (mg/L) | starting heterotroph biomass |
| Initial X_A | `initialState.X_A` | `200` | ≥ 0 (mg/L) | starting autotroph biomass — must be ≥ ~50 for nitrification |
| Initial X_TS | `initialState.X_TS` | `3500` | ≥ 0 (mg/L) | starting TSS — drives settler split |
## 10. State chart | 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). |
Skipped — reactor has no FSM. It runs continuous-state ODE integration; the engine's only stateful event is `stateChange`, fired after every successful integration advance. See section 7 for the integration sequence. ---
## 11. Examples ## The interesting bits
| Tier | File | What it shows | Status | ### CSTR vs PFR
|---|---|---|---|
| Basic | `examples/basic.flow.json` | CSTR with one inlet, watch `Fluent` effluent | ✅ in repo |
| Integration | `examples/integration.flow.json` | upstream reactor → reactor → settler chain | ✅ in repo |
| Edge | `examples/edge.flow.json` | PFR with dispersion + multi-inlet | ✅ in repo |
| Companions | `additional_nodes/*` | recirculation-pump + settling-basin Node-RED nodes shipped from this repo | ✅ in repo |
One screenshot per tier where helpful. PNG ≤ 200 KB under `wiki/_partial-screenshots/reactor/`. The engine is selected once at `configure()` from `reactor.reactor_type`. The same input topics drive both, but PFR additionally:
## 12. Debug recipes - 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).
| Symptom | First thing to check | Where to look | Hot-swapping engine type at runtime is not supported &mdash; redeploy the flow.
|---|---|---|
| Nitrification doesn't proceed (S_NH stays high) | `initialState.X_A` must be ≥ ~50 mg/L. Defaulting to `0.001` (a known footgun) means no autotrophs. | `generalFunctions/src/configs/reactor.json` |
| `Fluent` effluent flow zero | No `data.clock` ticks arriving, or `data.fluent` never set `Fs[0] > 0`. | `commands/handlers.js`, engine `setInfluent` |
| PFR `GridProfile` not emitted | `reactor_type` set to `CSTR` — only PFR emits grid. | `_buildEngine` switch |
| Settler downstream not updating | `stateChange` event listener path: settler must subscribe to `reactor.emitter`, NOT `reactor.measurements.emitter`. | settler `_connectReactor` |
| Temperature reconcile silently ignored | Child measurement's `asset.type` not `temperature` exactly, or `positionVsParent` not `atEquipment`. | `engine._connectMeasurement` |
| Integrator slow / stalls | `reactor.timeStep` too small for `speedUpFactor`. Internal `n_iter` count blows up. | `engine.updateState` |
| `wiki:datamodel` script slow / hangs | `mathjs` cold-start ~13 s; instantiation depends on it transitively. See known-limitations row 1. | `kinetics/baseEngine.js` |
## 13. When you would NOT use this node ### Aeration: internal `kla` vs external `data.otr`
- Use reactor for **ASM3 biological treatment** modelling (activated sludge, nitrification, denitrification). For aerobic-only or simpler kinetics, the ASM3 species vector is overkill. `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).
- Don't use reactor for a passive equalisation tank — the kinetics engines assume reactions are happening.
- Skip reactor when you only need a residence-time delay; a simple buffer node is lighter and doesn't require `mathjs`.
## 14. Known limitations / current issues ### `X_A` footgun
| # | Issue | Tracked in | 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).
|---|---|---|
| 1 | `mathjs` cold-start adds ~13 s to first `require()``wiki:datamodel` auto-gen may time out on the 60 s wrapper. Falls back to the hand-curated `concrete sample` block. | `.claude/refactor/OPEN_QUESTIONS.md` — "mathjs slow load" | ---
| 2 | `initialState.X_A` default of `200` mg/L is correct; older config snapshots used `0.001` which silently disabled nitrification. Verify on every new deploy. | `generalFunctions/src/configs/reactor.json` |
| 3 | `getEffluent` shape historically varied (array vs single envelope) — settler's `_connectReactor` tolerates both. Don't break the contract without updating settler. | `nodes/settler/src/specificClass.js → _connectReactor` | ## Need more?
| 4 | `additional_nodes/recirculation-pump` and `settling-basin` are legacy companions — not yet refactored to BaseDomain. | P6.5 follow-up |
| 5 | `reaction_modules/` is a legacy plug-in directory not consumed by the current engines. Removal pending. | P6.5 follow-up | | 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

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

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

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

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

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

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

20
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,20 @@
### 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)