Files
valveGroupControl/wiki/Reference-Architecture.md
znetsixe 9552e4fba9 docs(wiki): full 5-page wiki matching the rotatingMachine reference format
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>
2026-05-19 09:42:12 +02:00

239 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Reference &mdash; 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 &mdash; 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 &le; 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 &mdash; 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 |