# Architecture ![code-ref](https://img.shields.io/badge/code--ref-9ab9f6b-blue) ![source](https://img.shields.io/badge/source-CONTRACTS.md-orange) > [!NOTE] > Every EVOLV node is a three-tier sandwich: the entry registers the type with Node-RED; `nodeClass` (extends `BaseNodeAdapter`) bridges runtime to domain; `specificClass` (extends `BaseDomain`) holds pure-JS domain logic with zero `RED.*` imports. Everything shared — `BaseDomain`, `BaseNodeAdapter`, `ChildRouter`, the commands registry, `UnitPolicy`, `MeasurementContainer`, `statusBadge`, `HealthStatus`, `LatestWinsGate`, `logger`, `configManager` — lives in `generalFunctions`. Source of truth: `.claude/refactor/CONTRACTS.md`. --- ## The three-tier pattern ``` Node-RED runtime | +-- entry: nodes//.js | RED.nodes.registerType('', NodeClass) | HTTP admin endpoints (if any) | | +-- nodeClass: src/<...>NodeClass.js | | extends BaseNodeAdapter (generalFunctions) | | static DomainClass = SpecificClass | | static commands = [...descriptors] | | static tickInterval = null | | | buildDomainConfig(uiConfig, nodeId) | | | | +-- specificClass: src/<...>SpecificClass.js | | | extends BaseDomain (generalFunctions) | | | static name = '' | | | static unitPolicy = UnitPolicy.declare(...) | | | configure() <- wire routers + concern modules | | | tick() <- opt-in time-based math | | | getOutput() <- Port 0 / 1 snapshot | | | getStatusBadge() <- node.status badge ``` ### Tier responsibilities | Tier | File path | Extends | Touches `RED.*` | Unit-testable | |:---|:---|:---|:---|:---| | entry | `nodes//.js` | (top-level) | Yes | No (smoke only) | | nodeClass | `src/<...>NodeClass.js` | `BaseNodeAdapter` | Yes | Integration only | | specificClass | `src/<...>SpecificClass.js` | `BaseDomain` | No (never) | Yes | > [!CAUTION] > The specificClass must never import Node-RED APIs. If you find `RED.*` calls outside the entry or nodeClass tier, that is a bug. Pure-JS in the specificClass is what makes unit tests possible without spinning up a Node-RED runtime. Source: `.claude/refactor/CONTRACTS.md` §2 and §3. --- ## generalFunctions — what the library provides The `nodes/generalFunctions` submodule is a plain-JS library every node depends on. Public exports from `require('generalFunctions')`: ``` generalFunctions | +-- Bases | BaseDomain extend in specificClass.js | BaseNodeAdapter extend in nodeClass.js | +-- Wiring | ChildRouter onRegister / onMeasurement / onPrediction | commandRegistry topic -> descriptor map | +-- Data | UnitPolicy canonical + output unit declaration | MeasurementContainer chainable type/variant/position/childId store | convert unit conversion (m3/s <-> m3/h, ...) | +-- Concurrency | LatestWinsGate supersede-semantics mutex | +-- Health and status | statusBadge node.status({fill, shape, text}) composer | HealthStatus {level, flags, message, source} | +-- Utilities logger structured (never console.log) configManager loads configs/.json MenuManager dynamic editor dropdowns outputUtils delta-compressed Port 0 / 1 formatting ``` ### API one-liners | Export | Contract | |:---|:---| | `BaseDomain` | Owns `emitter`, `config`, `logger`, `measurements`, `child`. Calls subclass `configure()` then `_init?()`. | | `BaseNodeAdapter` | Builds merged config, instantiates `DomainClass`, emits Port-2 register, wires output strategy, wires status loop, wires input dispatcher. | | `ChildRouter` | `.onRegister(swType, handler)` · `.onMeasurement(swType, filter, handler)` · `.onPrediction(swType, filter, handler)`. | | `commandRegistry` | Topic + alias map to handler. Coerces `msg.unit` to descriptor `units.default`. Logs one-time deprecation per alias. | | `UnitPolicy` | `.canonical(t)` · `.output(t)` · `.curve(t)` · `.resolve()` · `.convert()` · `.containerOptions()`. Dual access: method form or frozen property bag (`policy.canonical.flow`). | | `MeasurementContainer` | `.type(t).variant(v).position(p).value(x, ts, unit)`. Keys: `...`. | | `statusBadge` | `.compose([..])` · `.error(msg)` · `.idle(label)`. Returns `{fill, shape, text}`. | | `HealthStatus` | `{level: 0..3, flags: string[], message: string, source: string \| null}`. Lower level = healthier. | | `LatestWinsGate` | `.fire(v)` (no-wait) · `.fireAndWait(v)` (per-call result; superseded calls resolve with sentinel `{superseded: true}`). | | `logger` | `.info` · `.warn` · `.error` · `.debug`. Named after `config.general.name`. | | `configManager` | `buildConfig(uiConfig, baseConfig)` — validates, merges, applies defaults. | | `outputUtils` | `formatMsg(snapshot, 'process' \| 'influxdb')` — delta-compressed; only changed fields are emitted. | The full 34-row API surface is on the [generalFunctions wiki Home](https://gitea.wbd-rd.nl/RnD/generalFunctions/wiki/Home) under "API surface". --- ## Output ports Every node emits on three ports. Source: `.claude/refactor/CONTRACTS.md` §10. ```mermaid flowchart LR sc["specificClass — tick() or 'output-changed'"] ou["outputUtils.formatMsg — delta-compress"] p0[("Port 0 — process")] p1[("Port 1 — InfluxDB line")] p2[("Port 2 — register / control")] dl["downstream Node-RED — dashboards, functions"] influx[("InfluxDB")] parent["parent EVOLV node"] sc -- getOutput() --> ou ou --> p0 --> dl ou --> p1 --> influx sc -. child.register .-> p2 --> parent class sc tier3 class ou tier2 class p0 p0c class p1 p1c class p2 p2c class dl,parent dn class influx ext classDef tier3 fill:#50a8d9,color:#000 classDef tier2 fill:#86bbdd,color:#000 classDef p0c fill:#0c99d9,color:#fff classDef p1c fill:#50a8d9,color:#000 classDef p2c fill:#a9daee,color:#000 classDef dn fill:#dddddd,color:#000 classDef ext fill:#fff2cc,color:#000 ``` | Port | Direction | Carries | When | |:---|:---|:---|:---| | 0 | out | Process data, formatted via `outputUtils.formatMsg(..., 'process')`. Object containing only keys that changed since last tick. | `'output-changed'` fires on the emitter, or every tick if tick-driven | | 1 | out | InfluxDB line-protocol string via `outputUtils.formatMsg(..., 'influxdb')`. Numeric fields only. | Same trigger as Port 0 | | 2 | out | `child.register` upward at init plus internal control plumbing. | Once on init (after 100ms delay); on demand | | in | in | Commands by `msg.topic`, dispatched through the `commands/` registry. | Any time another node sends a msg | See [Telemetry](Telemetry) for the line-protocol layout and downstream wiring. --- ## Lifecycle — what BaseNodeAdapter does In order, in the constructor. Source: `.claude/refactor/CONTRACTS.md` §2. ```mermaid sequenceDiagram autonumber participant rt as Node-RED runtime participant nc as nodeClass (BaseNodeAdapter) participant sc as specificClass (BaseDomain) participant outs as Output pipeline rt->>nc: new nodeClass(uiConfig) nc->>nc: configManager.buildConfig(uiConfig) nc->>nc: this.buildDomainConfig(uiConfig) nc->>sc: new DomainClass(mergedConfig) sc->>sc: configure() — wire ChildRouter + concerns sc->>sc: _init?() — optional post-configure hook Note over nc: 100ms delay nc->>nc: emit Port 2 child.register nc->>outs: subscribe to 'output-changed' OR start tick(N ms) nc->>nc: start status loop (every 1000ms) nc->>nc: attach input handler (commands dispatcher) rt->>nc: msg.topic dispatch -> handler nc->>sc: handler.call(source, msg, ctx) sc->>sc: emit 'output-changed' if state shifted outs->>rt: Port 0 + Port 1 send (delta-compressed) ``` ### Two output strategies | Strategy | When to pick | What domain does | What adapter does | |:---|:---|:---|:---| | Event-driven (default) | Domain reacts to incoming events and has no genuinely time-driven math | Fire `this.emitter.emit('output-changed')` when public output state shifts | Subscribes to `'output-changed'`; on each fire, calls `getOutput()` and pushes the delta-compressed msg | | Tick-driven (opt-in) | Domain has time-driven math — integrators, simulators, time-based thresholds | Implement `tick()`. Fire `'output-changed'` from inside it when output state shifts | Calls `tick()` every `static tickInterval` ms; listens to `'output-changed'` the same way as event-driven | Both strategies funnel into the same `'output-changed'` → `getOutput()` → `formatMsg` → `node.send` pipeline. --- ## The commands registry Each node has `src/commands/index.js` exporting an array of descriptors. The base adapter builds a `Map` at construction. Dispatch is one lookup. Source: `.claude/refactor/CONTRACTS.md` §4. ```js module.exports = [ { topic: 'set.demand', aliases: ['setDemand', 'Qd'], // legacy names units: { measure: 'volumeFlowRate', default: 'm3/h' }, payloadSchema: { type: 'number' }, description: 'Operator demand setpoint.', handler: handlers.setDemand, }, { topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, // trigger-only description: 'Trigger a one-shot calibration.', handler: handlers.calibrate, }, ]; ``` | Descriptor field | What it does | |:---|:---| | `topic` | Canonical name. See [Topic Conventions](Topic-Conventions). | | `aliases` | Pre-refactor legacy names. First use of each fires a one-time deprecation warning. | | `units` | `{measure, default}` — pre-dispatch unit normalisation. Handler always sees `default` unit. | | `payloadSchema` | `{type: 'string' \| 'number' \| 'boolean' \| 'object' \| 'any' \| 'none'}` — type-check before handler. | | `description` | Free-text. Surfaced by `.list()` and `wikiGen` topic-contract autogen. | | `handler` | `(source, msg, ctx) => ...` — pure function on the domain. | --- ## Child registration — declarative routing The `ChildRouter` declares which child softwareTypes the parent accepts and what to do with each. Source: `.claude/refactor/CONTRACTS.md` §5. ```js configure() { this.router .onRegister('machine', (child) => this.machines[child.id] = child) .onRegister('measurement', (child) => this._subscribeMeasurement(child)) .onMeasurement('measurement', { type: 'pressure', position: 'upstream' }, (data, child) => this._onPressure('upstream', data)); } ``` ```mermaid sequenceDiagram autonumber participant child as Child nodeClass participant reg as Parent commandRegistry participant rt as Parent ChildRouter participant sc as Parent specificClass child->>reg: msg{topic: child.register, payload: {ref, softwareType}} reg->>rt: dispatchRegister(child, softwareType) rt->>rt: match softwareType in onRegister handlers rt->>sc: invoke handler(child) sc->>sc: store ref, wire emitter.on(, ...) ``` ### Who accepts what Verified against each node's `configure()` in source. | Parent | Accepted softwareTypes | Use | |:---|:---|:---| | pumpingStation | `measurement`, `machine`, `machinegroup`, `pumpingstation` | Basin sensors + pumps + groups + cascaded PS | | machineGroupControl | `machine`, `measurement` | Pumps + pressure sensors | | valveGroupControl | `valve`, `machine`, `machinegroup`, `pumpingstation`, `valvegroupcontrol` | Valves + four flow-source softwareTypes (peer-level, not S88 children) | | reactor | `measurement`, `reactor` | Sensors + upstream reactor in a chain | | settler | `measurement`, `reactor`, `machine` | Sensors + upstream reactor + return pump | | monster | `measurement` | Flow + quality sensors | | diffuser | `measurement` | DO + airflow sensors | | rotatingMachine | `measurement` | Pressure / flow / power sensors | | valve | `measurement` | Position / pressure sensors | | dashboardAPI | any softwareType | Triggers Grafana dashboard generation per node | --- ## Where business logic lives Each node's `src/` follows the same shape (concern modules). ``` nodes// | +-- .js entry +-- .html editor form +-- package.json | +-- src/ | NodeClass.js nodeClass (adapter) | SpecificClass.js specificClass (orchestrator) | | | +-- commands/ | | index.js topic descriptors | | handlers.js pure handler functions | | | +-- state/ FSM (if stateful) | +-- / e.g. basin/, kinetics/, curves/ | +-- / e.g. safety/, dispatch/ | | | +-- io/ | output.js getOutput() composition | statusBadge.js getStatusBadge() | +-- test/ | basic/ · integration/ · edge/ | +-- examples/ 01-Basic.json · 02-Integration.json · 03-Dashboard.json ``` > [!IMPORTANT] > The specificClass is stitching, not implementation. It instantiates concern modules in `configure()` and calls them in `tick()` or in router handlers. Concerns are individually testable; specificClass tests verify wiring, not math. Source: `.claude/refactor/MODULE_SPLIT.md`. --- ## Reading order for newcomers | # | Read | Why | |:---|:---|:---| | 1 | `.claude/refactor/CONTRACTS.md` | Every API shape this page summarises | | 2 | `.claude/refactor/CONVENTIONS.md` | Code style, file size, naming, imports, tests | | 3 | `.claude/refactor/MODULE_SPLIT.md` | Concern layout per node | | 4 | [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/wiki/Home) | The refactor pilot — most mature node | | 5 | The corresponding `src/` folder | Top-down: specificClass → concern modules → handlers | --- ## Related pages | Page | Why | |:---|:---| | [Topology Patterns](Topology-Patterns) | See the contracts above in action across a realistic plant | | [Topic Conventions](Topic-Conventions) | Full reference for `set.` / `cmd.` / `data.` / `query.` / `child.` / `evt.` | | [Telemetry](Telemetry) | Port 0 / 1 / 2 InfluxDB schema details | | [Getting Started](Getting-Started) | First hands-on with the contracts |