Clone
2
Architecture
znetsixe edited this page 2026-05-11 22:24:29 +02:00

Architecture

code-ref source

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/<name>/<NodeName>.js
|       RED.nodes.registerType('<nodeName>', NodeClass)
|       HTTP admin endpoints (if any)
|
|   +-- nodeClass: src/<...>NodeClass.js
|   |       extends BaseNodeAdapter (generalFunctions)
|   |       static DomainClass = SpecificClass
|   |       static commands = [...descriptors]
|   |       static tickInterval = null | <ms>
|   |       buildDomainConfig(uiConfig, nodeId)
|   |
|   |   +-- specificClass: src/<...>SpecificClass.js
|   |   |       extends BaseDomain (generalFunctions)
|   |   |       static name = '<softwareType>'
|   |   |       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/<name>/<NodeName>.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/<name>.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: <type>.<variant>.<position>.<childId>.
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 under "API surface".


Output ports

Every node emits on three ports. Source: .claude/refactor/CONTRACTS.md §10.

flowchart LR
    sc["specificClass &mdash; tick() or 'output-changed'"]
    ou["outputUtils.formatMsg &mdash; delta-compress"]
    p0[("Port 0 &mdash; process")]
    p1[("Port 1 &mdash; InfluxDB line")]
    p2[("Port 2 &mdash; register / control")]
    dl["downstream Node-RED &mdash; 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 for the line-protocol layout and downstream wiring.


Lifecycle — what BaseNodeAdapter does

In order, in the constructor. Source: .claude/refactor/CONTRACTS.md §2.

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() &mdash; wire ChildRouter + concerns
    sc->>sc: _init?() &mdash; 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()formatMsgnode.send pipeline.


The commands registry

Each node has src/commands/index.js exporting an array of descriptors. The base adapter builds a Map<topic | alias, descriptor> at construction. Dispatch is one lookup. Source: .claude/refactor/CONTRACTS.md §4.

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.
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.

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));
}
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(<topic>, ...)

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/<name>/
|
+-- <NodeName>.js              entry
+-- <NodeName>.html            editor form
+-- package.json
|
+-- src/
|     <Name>NodeClass.js       nodeClass (adapter)
|     <Name>SpecificClass.js   specificClass (orchestrator)
|     |
|     +-- commands/
|     |     index.js           topic descriptors
|     |     handlers.js        pure handler functions
|     |
|     +-- state/               FSM (if stateful)
|     +-- <concern1>/          e.g. basin/, kinetics/, curves/
|     +-- <concern2>/          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 The refactor pilot — most mature node
5 The corresponding src/ folder Top-down: specificClass → concern modules → handlers

Page Why
Topology Patterns See the contracts above in action across a realistic plant
Topic Conventions Full reference for set. / cmd. / data. / query. / child. / evt.
Telemetry Port 0 / 1 / 2 InfluxDB schema details
Getting Started First hands-on with the contracts