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>
239 lines
12 KiB
Markdown
239 lines
12 KiB
Markdown
# Reference — Architecture
|
||
|
||

|
||
|
||
> [!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: <node.id>, positionVsParent}` |
|
||
|
||
Port-0 key shape is **`<position>_<variant>_<type>`** (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 |
|