Files
valveGroupControl/wiki/Reference-Architecture.md

239 lines
12 KiB
Markdown
Raw Normal View History

# 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 &mdash; 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` &mdash; 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 &mdash; tick + event sources
| Source | Where it fires | What it triggers |
|:---|:---|:---|
| Periodic tick | `nodeClass` `setInterval(tickInterval = 1000 ms)` | `source.tick()` &rarr; `calcValveFlows()` &rarr; `notifyOutputChanged()`. |
| Child `state.emitter` `'positionChange'` | per child valve | `onPositionChange` &rarr; `calcValveFlows()`. |
| Child `emitter` `'deltaPChange'` | per child valve | `onDeltaPChange` &rarr; `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)` &mdash; 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 &mdash; `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 &mdash; `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: <node.id>, positionVsParent}` |
Port-0 key shape is **`<position>_<variant>_<type>`** (same as MGC) &mdash; 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` &mdash; 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&hellip; | 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 &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 |
| [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 &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |