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

12 KiB
Raw Blame History

Reference — Architecture

code-ref

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.


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.

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 — 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.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 onPositionChangecalcValveFlows().
Child emitter 'deltaPChange' per child valve onDeltaPChangecalcMaxDeltaP().
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 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

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