# Reference — Architecture ![code-ref](https://img.shields.io/badge/code--ref-b20a573-blue) > [!NOTE] > Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only. > > Code structure for `valveGroupControl`: the three-tier sandwich, the `src/` concern modules, the Kv-share flow-distribution loop, the source aggregation pipeline, the tick / event lifecycle, and the output-port shape. For an intuitive overview, return to [Home](Home). --- ## Three-tier code layout ``` nodes/valveGroupControl/ | +-- vgc.js entry: RED.nodes.registerType('valveGroupControl', NodeClass) | (LEGACY NAME — should be valveGroupControl.js; see Limitations) +-- vgc.html editor HTML (LEGACY NAME — should be valveGroupControl.html) | +-- src/ | nodeClass.js extends BaseNodeAdapter (Node-RED bridge, tick loop) | specificClass.js extends BaseDomain (orchestration: valve + source routing) | | | +-- commands/ | | index.js topic descriptors (canonical + aliases) | | handlers.js pure handler functions | | | +-- groupOps/ | | flowDistribution.js Kv-share solver, residual pass, calcMaxDeltaP, isValveAvailable | | | +-- sources/ | | fluidContract.js upstream-source registration, flow-event binding, fluid-contract reconciliation | | | +-- io/ | output.js getOutput() + getStatusBadge() ``` ### Tier responsibilities | Tier | File | What it owns | Touches `RED.*` | |:---|:---|:---|:---:| | entry | `vgc.js` (legacy filename) | Type registration | Yes | | nodeClass | `src/nodeClass.js` | Periodic tick (`tickInterval = 1000` ms), status badge (`statusInterval = 1000` ms), tick restart on `reconcileIntervalChange`. `buildDomainConfig()` returns `{}` (no domain overrides). | Yes | | specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; router callbacks for `valve` + the 4 source softwareTypes; `_bindValveEvents` / `_unbindValveEvents`; mode/sequence dispatch via `handleInput`; expose `calcValveFlows`, `calcMaxDeltaP`, `getFluidContract`, `getOutput`, `getStatusBadge`. | No | `specificClass` is stitching. All real work lives in the concern modules: Kv-share + residual + max-deltaP in `groupOps/`; upstream-source registration + fluid-contract reconciliation in `sources/`; output shape in `io/`. --- ## What VGC does NOT have - **No FSM of its own.** `specificClass.configure()` instantiates `new state({}, this.logger)` and stamps `this.state.stateManager.currentState = 'operational'` immediately so `executeSequence` works for group-wide sequences. State semantics belong to the child valves. - **No predictors / curves.** Unlike `rotatingMachine`, VGC has no asset model, no characteristic curve, no prediction pipeline. The only "prediction" written is the per-valve assigned flow from the Kv-share solver. - **No drift assessment.** No EWMA, no NRMSE, no `predictionHealth`. The Port 0 emits only `mode`, `maxDeltaP`, and per-position flow / pressure keys. --- ## Flow-distribution loop The core coordination loop. VGC has no per-tick prediction recompute — the tick just re-runs `calcValveFlows()` to absorb any drift between event-driven recalcs. ```mermaid sequenceDiagram autonumber participant src as upstream source / data.totalFlow participant vgc as VGC participant solver as solveFlowDistribution participant valves as valve children participant out as Port 0 / 1 src->>vgc: flow.predicted.downstream (m3/h) OR data.totalFlow vgc->>vgc: updateFlow('predicted'|'measured', value, 'atEquipment') vgc->>vgc: _write flow at position 'atEquipment' vgc->>vgc: calcValveFlows() vgc->>solver: target=totalFlow, entries=availableValves, recon loop ≤ maxPasses while |residual| > tol solver->>valves: updateFlow('predicted', share, 'downstream') valves-->>solver: read accepted (flow.predicted.downstream) solver->>solver: residual = target − sum(accepted) end solver-->>vgc: { flowsById, residual, passes } vgc->>vgc: _write flow.predicted.atEquipment = sum(accepted) vgc->>vgc: calcMaxDeltaP() vgc->>out: notifyOutputChanged() → Port 0 / 1 valves-->>vgc: positionChange / deltaPChange (drives next calcValveFlows / calcMaxDeltaP) ``` ### Availability filter A valve participates in the split if **all** are true (`groupOps/flowDistribution.isValveAvailable`): | Condition | Source | |:---|:---| | `valve.state.getCurrentState() !== 'off'` | child FSM | | `valve.state.getCurrentState() !== 'maintenance'` | child FSM | | `valve.currentMode !== 'maintenance'` | child mode | | `Number.isFinite(valve.kv) && valve.kv > 0` | child config | Unavailable valves still receive `updateFlow('predicted', 0, 'downstream', flowUnit)` so their state is consistent — they are simply excluded from the solver. ### Residual reconciliation `flowReconciliation` defaults (`groupOps/flowDistribution.DEFAULT_RECONCILIATION`): | Field | Default | Effect | |:---|:---:|:---| | `maxPasses` | `2` | Bound on the correction loop. The first pass distributes by `share = (kv / totalKv) * residual`; subsequent passes correct for the residual between target and accepted total. | | `residualTolerance` | `0.001` | Loop exits when `|residual| < tolerance`. Units are canonical (m³/s for flow). | After the loop: - `lastFlowSolve = { passes, residual, targetTotal, assignedTotal }` is stamped on the domain for telemetry / debug. - `flow.predicted.atEquipment` is written equal to `assignedTotal` (sum of per-valve accepted). - `calcMaxDeltaP` re-reads every valve's `pressure.predicted.delta` and stores `vgc.maxDeltaP` plus `pressure.predicted.deltaMax` in the measurement container. ### Pathological-curve case If `totalKv <= 0` or no valves are available, every valve is pushed `0`, `flow.predicted.atEquipment` is written `0`, and `lastFlowSolve` records `passes: 0, residual: target, assignedTotal: 0`. The status badge flips to `'No valves'` (red dot). --- ## Source aggregation Upstream nodes register as **sources** (not children that VGC controls). Source softwareTypes accepted by `_registerSource`: | Registered as (canonical) | Original softwareType examples | |:---|:---| | `machine` | `rotatingmachine` (canonicalised by `BaseDomain.router`) | | `machinegroup` | `machinegroupcontrol` (canonicalised) | | `pumpingstation` | `pumpingstation` | | `valvegroupcontrol` | `valvegroupcontrol` (cascaded VGC; see Limitations) | For each source `bindSource` attaches listeners to **six** flow event names on the source's `measurements.emitter`: ``` flow.predicted.downstream flow.predicted.atEquipment flow.predicted.atequipment flow.measured.downstream flow.measured.atEquipment flow.measured.atequipment ``` The handler routes any of these to `vgc.updateFlow(variant, value, 'atEquipment', unit)`. Position-label case variants are caught explicitly — the source may publish either `atEquipment` or `atequipment` and the router normalises both into the same internal write. ### Fluid contract aggregation Each source contributes a fluid contract (`liquid` / `gas` / `conflict` / `unknown`). `extractFluidContract`: 1. Calls `child.getFluidContract()` if present. A `conflict` status short-circuits to group conflict. 2. Falls back to a normalised `serviceType` from the child / asset config. 3. Falls back to a defaults table (`DEFAULT_SOURCE_SERVICE_TYPE` — everything maps to `liquid` except where overridden). `refreshFluidContract` aggregates across all registered sources: | Aggregate status | When | |:---|:---| | `conflict` | Any source's contract is `conflict`, OR more than one distinct `serviceType` is present. | | `resolved` | Exactly one distinct `serviceType` across all sources. | | `unknown` | No sources registered. | Changes emit `fluidContractChange` on `vgc.emitter` so downstream consumers (a valve checking compatibility, another VGC) can react. --- ## Lifecycle — tick + event sources | Source | Where it fires | What it triggers | |:---|:---|:---| | Periodic tick | `nodeClass` `setInterval(tickInterval = 1000 ms)` | `source.tick()` → `calcValveFlows()` → `notifyOutputChanged()`. | | Child `state.emitter` `'positionChange'` | per child valve | `onPositionChange` → `calcValveFlows()`. | | Child `emitter` `'deltaPChange'` | per child valve | `onDeltaPChange` → `calcMaxDeltaP()`. | | Source `measurements.emitter` flow events | per upstream source | `updateFlow(variant, value, 'atEquipment', unit)`. | | Source `emitter` `'fluidContractChange'` | per upstream source | Re-read source contract; `refreshFluidContract`. | | `source.emitter` `'reconcileIntervalChange'` | `setReconcileIntervalSeconds` | `nodeClass._restartTick(ms)` — clears + re-schedules tick. | | Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch (see [Contracts](Reference-Contracts#topic-contract)). | | `setInterval(statusInterval = 1000 ms)` | `BaseNodeAdapter` | Status badge re-render. | `tick()` itself is one line — `this.calcValveFlows()`. It exists so a child's accepted flow drift between event-driven recalcs gets re-absorbed. --- ## Output ports | Port | Carries | Sample shape | |:---|:---|:---| | 0 (process) | Delta-compressed snapshot — `mode`, `maxDeltaP`, per-position flow/pressure keys | `{topic, payload: {mode, maxDeltaP, atEquipment_predicted_flow, ...}}` | | 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `valveGroupControl,id=VGC1 mode="auto",maxDeltaP=1450,atEquipment_predicted_flow=80,...` | | 2 (register / control) | `child.register` upward at startup | `{topic: 'child.register', payload: , positionVsParent}` | Port-0 key shape is **`__`** (same as MGC) — written in `io/output.getOutput()` by walking `measurements.measurements` and emitting only keys whose `getCurrentValue()` is non-null. Plus the two scalar keys `mode` and `maxDeltaP`. > [!IMPORTANT] > See `.claude/rules/output-coverage.md` — every output should be enumerated in a `test/_output-manifest.md` and tested in both populated and degraded states. **Not yet produced** for VGC; tracked as backfill in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` (TODO). --- ## Events emitted on `source.emitter` / `source.measurements.emitter` | Event | Emitter | Fires when | |:---|:---|:---| | `output-changed` | `source.emitter` | Public output state shifted; adapter listens and pushes Ports 0/1. | | `fluidContractChange` | `source.emitter` | Group-level fluid contract (status / serviceType / sourceCount) changed. | | `reconcileIntervalChange` | `source.emitter` | `setReconcileIntervalSeconds` was called; adapter restarts the tick loop. | | `flow.predicted.atequipment` | `source.measurements.emitter` | Group predicted flow changed (post-solve). | | `pressure.predicted.deltaMax` | `source.measurements.emitter` | Group max delta-P changed. | The exact emitter set is data-driven by what valves and sources publish. --- ## Where to start reading | If you're changing… | Read first | |:---|:---| | Kv-share solver, residual pass, availability filter | `src/groupOps/flowDistribution.js` | | `calcMaxDeltaP` aggregation | `src/groupOps/flowDistribution.js` `calcMaxDeltaP` | | Upstream-source registration, flow-event names, fluid contract | `src/sources/fluidContract.js` | | Valve event binding / unbinding | `src/specificClass.js` `_bindValveEvents` / `_unbindValveEvents` | | Mode validation, sequence dispatch | `src/specificClass.js` `setMode` / `executeSequence` / `handleInput` | | Reconcile-interval re-tuning | `src/specificClass.js` `setReconcileIntervalSeconds` + `src/nodeClass.js` `_restartTick` | | Topic registration, payload validation, alias deprecation | `src/commands/index.js` + `src/commands/handlers.js` | | Port-0 output keys, status badge | `src/io/output.js` | --- ## Related pages | Page | Why | |:---|:---| | [Home](Home) | Intuitive overview | | [Reference — Contracts](Reference-Contracts) | Topic + config + child filters | | [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes | | [Reference — Limitations](Reference-Limitations) | Known issues and open questions | | [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home) | The sibling Unit-level group controller for pumps | | [valve wiki](https://gitea.wbd-rd.nl/RnD/valve/wiki/Home) | The child node VGC coordinates | | [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |