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>
12 KiB
Reference — Architecture
Note
Pending full node review (2026-05). Content reflects
CONTRACT.mdand current source only.Code structure for
valveGroupControl: the three-tier sandwich, thesrc/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.
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()instantiatesnew state({}, this.logger)and stampsthis.state.stateManager.currentState = 'operational'immediately soexecuteSequenceworks 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 onlymode,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.
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 ` |
After the loop:
lastFlowSolve = { passes, residual, targetTotal, assignedTotal }is stamped on the domain for telemetry / debug.flow.predicted.atEquipmentis written equal toassignedTotal(sum of per-valve accepted).calcMaxDeltaPre-reads every valve'spressure.predicted.deltaand storesvgc.maxDeltaPpluspressure.predicted.deltaMaxin 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:
- Calls
child.getFluidContract()if present. Aconflictstatus short-circuits to group conflict. - Falls back to a normalised
serviceTypefrom the child / asset config. - Falls back to a defaults table (
DEFAULT_SOURCE_SERVICE_TYPE— everything maps toliquidexcept 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). |
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 atest/_output-manifest.mdand 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 | Intuitive overview |
| Reference — Contracts | Topic + config + child filters |
| Reference — Examples | Shipped flows + debug recipes |
| Reference — Limitations | Known issues and open questions |
| machineGroupControl wiki | The sibling Unit-level group controller for pumps |
| valve wiki | The child node VGC coordinates |
| EVOLV — Architecture | Platform-wide three-tier pattern |