# Reference — Architecture ![code-ref](https://img.shields.io/badge/code--ref-8c2b2c0-blue) > [!NOTE] > Code structure for `valve`: the three-tier sandwich, the `src/` layout, the position FSM, the hydraulic-model pipeline, the lifecycle, and the output ports. For an intuitive overview, return to [Home](Home). > > Pending full node review (2026-05). Content reflects `CONTRACT.md` and current source only. --- ## Three-tier code layout ``` nodes/valve/ | +-- valve.js entry: RED.nodes.registerType('valve', NodeClass) | +-- src/ | nodeClass.js extends BaseNodeAdapter (Node-RED bridge) | specificClass.js extends BaseDomain (orchestration only) | hydraulicModel.js ValveHydraulicModel + normalizeServiceType | | | +-- commands/ | | index.js topic descriptors | | handlers.js pure handler functions | | | +-- curve/ | | supplierCurve.js SupplierCurvePredictor (Kv-vs-position load + interp) | | | +-- fluid/ | | fluidCompatibility.js FluidCompatibility — upstream service-type aggregation | | | +-- measurement/ | | measurementRouter.js MeasurementRouter + FORMULA_UNITS | | | +-- flow/ | | flowController.js handleInput, executeSequence, setpoint (pre-shutdown ramp) | | | +-- state/ | | stateBindings.js wires state.emitter('positionChange') → updatePosition() | | | +-- io/ | output.js buildOutput + buildStatusBadge ``` ### Tier responsibilities | Tier | File | What it owns | Touches `RED.*` | |:---|:---|:---|:---:| | entry | `valve.js` | Type registration | Yes | | nodeClass | `src/nodeClass.js` | UI-config → domain config, legacy-asset-field reject, status-badge polling (`statusInterval=1000`). No tick loop (`tickInterval=null`) — event-driven. | Yes | | specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; expose the public surface tests + parents (VGC) depend on (`handleInput`, `setMode`, `updatePosition`, `updateFlow`, `updatePressure`, `registerChild`, `showCurve`, `getOutput`, …). Overrides `BaseDomain.registerChild` so upstream-source registration falls into `FluidCompatibility` instead of the generic ChildRouter. | No | `specificClass` is stitching. All real work lives in the concern modules: position bindings in `state/`, deltaP math in `hydraulicModel.js`, Kv interpolation in `curve/`, measurement → deltaP plumbing in `measurement/`, mode + sequences in `flow/`, fluid contract aggregation in `fluid/`, and Port-0 shaping in `io/`. --- ## FSM > [!NOTE] > The state machine is declared in `generalFunctions/src/state/stateConfig.json`. Same allowed-transition graph as `rotatingMachine`, with `accelerating` / `decelerating` reused for position moves up / down. ```mermaid stateDiagram-v2 [*] --> idle idle --> starting: cmd.startup idle --> off idle --> maintenance starting --> warmingup: timer (time.starting) warmingup --> operational: timer (time.warmingup) [protected] operational --> accelerating: set.position up operational --> decelerating: set.position down operational --> stopping: cmd.shutdown accelerating --> operational: target reached decelerating --> operational: target reached stopping --> coolingdown: timer (time.stopping) coolingdown --> idle: timer (time.coolingdown) [protected] coolingdown --> off off --> idle: boot off --> maintenance maintenance --> off maintenance --> idle note right of operational any state -> emergencystop via cmd.estop from emergencystop: idle / off / maintenance end note ``` Default sequences (from `valve.json`): | Sequence | States | |:---|:---| | `startup` | `[starting, warmingup, operational]` | | `shutdown` | `[stopping, coolingdown, idle]` | | `emergencystop` | `[emergencystop, off]` | | `boot` | `[idle, starting, warmingup, operational]` | ### Pre-shutdown ramp to zero `FlowController.executeSequence('shutdown')` checks the FSM. When the valve is `operational` it first calls `setpoint(0)` — the position-ramp to fully closed is interruptible — then iterates the sequence states. ### Protected states `warmingup` and `coolingdown` are **protected** at the state-machine layer (same mechanism as `rotatingMachine`). Aborts during these phases are ignored to preserve safety guarantees. > [!NOTE] > Whether `valve` adopts the `sequenceAbortToken` mechanism from `rotatingMachine` (2026-05-15) for mid-shutdown re-engage races is an open question. TODO: confirm from `generalFunctions/src/state/state.js` whether valve inherits the token automatically. Source: `nodes/valve/src/flow/flowController.js`. ### Position-move bindings `src/state/stateBindings.js` wires the underlying state machine's `positionChange` event to `host.updatePosition()`. Every position tick triggers: 1. `host.kv = host.curvePredictor.predictKvForPosition(x)` — Kv lookup against the supplier curve. 2. `MeasurementRouter.updateDeltaP(currentFlow, kv, downstreamP)` — recompute the hydraulic deltaP and write `pressure.predicted.delta`. 3. `host.emitter.emit('deltaPChange', deltaP)` — upward to the parent VGC. `updatePosition()` is a no-op outside of `operational` / `accelerating` / `decelerating` (see `MeasurementRouter.updatePositionDependent`). --- ## Hydraulic + measurement pipeline ```mermaid flowchart TB set[set.position]:::input --> fc[FlowController.setpoint] fc --> moveTo[state.moveTo] moveTo --> tick[state.emitter 'positionChange'] tick --> upd[updatePosition] upd --> kv[curvePredictor.predictKvForPosition] fdat[data.flow]:::input --> mr[MeasurementRouter.updateFlow] fpres[measurement child
pressure.measured.*]:::input --> mp[MeasurementRouter.updatePressure] mr --> dp[updateDeltaP] mp --> dp kv --> dp dp --> hyd[ValveHydraulicModel
calculateDeltaPMbar] hyd --> write[write pressure.predicted.delta] write --> emit[emitter 'deltaPChange'] write --> out[Port 0] classDef input fill:#a9daee,color:#000 ``` ### Curve loading At `configure()` startup: 1. `assetResolver.resolveAssetMetadata('valve', model)` resolves supplier / type / allowed units from `generalFunctions/datasets/assetData/` — **may return null** for valve; the predictor tolerates an inline `asset.valveCurve` fallback. 2. `SupplierCurvePredictor` is constructed with the model, the inline curve override, density, temperature, and valve diameter. 3. `predictKv` (the curve-evaluation function) is exposed on the host; `host.curveSelection` records which `(densityKey, diameterKey)` lane of the dataset is in use. The `asset.valveCurve` schema is a nested map keyed by gas-density (kg per nm³) and valve diameter (mm); the leaf carries `{x: [position%], y: [Kv (m³/h)]}` lookup tables. ### Hydraulic formula selection `ValveHydraulicModel.calculateDeltaPMbar` picks one of two formulas by `serviceType`: | serviceType | Formula | Notes | |:---|:---|:---| | `liquid` | `deltaP_bar = (Q / Kv)^2 * (rho / 1000)` | Density override via `runtimeOptions.fluidDensity` (default 997 kg/m³). | | `gas` | `deltaP_bar = (Q^2 * rho * T) / (514^2 * Kv^2 * P2_abs)` | Density (default 1.204), absolute downstream pressure, temperature K. Capped at `gasChokedRatioLimit * P2_abs` when choked. | Inputs are validated: `kv > 0`, `flow !== 0`, and (for gas) a finite downstream gauge pressure are required — otherwise the function returns `null` and the router skips the write. ### Formula units are pinned `measurement/measurementRouter.js` declares: ```js const FORMULA_UNITS = Object.freeze({ pressure: 'mbar', flow: 'm3/h', temperature: 'K' }); ``` The hydraulic model expects q in m³/h, downstream gauge in mbar, and T in K. The router reads MeasurementContainer values back in these units before calling `calculateDeltaPMbar` regardless of the per-node `unitPolicy.output.*` rendering choices. ### Unit policy Source: `src/specificClass.js` lines 20–24. | Quantity | Canonical (internal) | Output (rendered) | Required-unit | |:---|:---|:---|:---:| | Pressure | `Pa` | `mbar` | ✓ | | Flow | `m3/s` | `m3/h` | ✓ | | Temperature | `K` | `C` | ✓ | `requireUnitForTypes` means MeasurementContainer rejects writes that omit `unit` for these types. --- ## Lifecycle — what one event does ```mermaid sequenceDiagram autonumber participant parent as VGC / GUI participant v as valve participant fc as flowController participant fsm as state (FSM) participant hyd as hydraulicModel participant out as Port 0 / parent parent->>v: set.position {setpoint: 60} v->>fc: flowController.handleInput('parent','execMovement', 60) fc->>fc: isValidSourceForMode check fc->>fsm: setpoint(60) → state.moveTo(60) fsm-->>v: positionChange events per move tick v->>v: kv = curvePredictor.predictKvForPosition(pos) v->>hyd: calculateDeltaPMbar(q, kv, downP, rho, T) hyd-->>v: { deltaPMbar, details } v->>v: write pressure.predicted.delta v->>parent: emitter.emit('deltaPChange', deltaP) v->>out: notifyOutputChanged (Port 0 delta) parent->>v: data.flow {variant, value, position, unit} v->>v: MeasurementRouter.updateFlow → updateDeltaP ``` ### Mode + source allow-lists Each input is gated in `flowController.handleInput`: ```js if (!this.isValidSourceForMode(source, this.host.currentMode)) { this.logger.warn(`Source '${source}' is not valid for mode '${currentMode}'.`); return { status: false, feedback: msg }; } ``` Defaults (per `valve.json` `mode.allowedSources`): | Mode | Allowed sources | |:---|:---| | `auto` | `parent, GUI, fysical` | | `virtualControl` | `GUI, fysical` | | `fysicalControl` | `fysical` | | `maintenance` | _(no entry — no source accepted; only `statusCheck` action allowed)_ | A rejected request logs at warn and short-circuits. > [!NOTE] > Unlike `rotatingMachine`, `valve`'s `flowController` does not currently gate by `allowedActions` — only by source. The schema defines `mode.allowedActions` but it isn't enforced in `flowController.handleInput`. TODO: confirm intentional or backlog. Source: `nodes/valve/src/flow/flowController.js` lines 18–24. --- ## Output ports | Port | Carries | Sample shape | |:---|:---|:---| | 0 (process) | Delta-compressed state snapshot — FSM state, position %, mode, every populated MeasurementContainer slot | `{topic, payload: {state, percentageOpen, moveTimeleft, mode, delta_predicted_pressure, downstream_measured_flow, ...}}` | | 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `valve,id=valve_a state="operational",percentageOpen=60,delta_predicted_pressure=84,...` | | 2 (register / control) | `child.register` upward at startup; `positionVsParent` and optional `distance` carried on the msg | `{topic: 'child.register', payload: , positionVsParent, distance}` | Port-0 key shape is **`__`** (legacy three-segment). Examples: `delta_predicted_pressure`, `downstream_measured_flow`, `downstream_predicted_pressure`. Only keys with finite values are emitted — consumers must cache and merge. On `query.curve` the node additionally emits `{topic: 'Showing curve', payload: }` synchronously on Port 0. See [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout. --- ## Event sources | Source | Where it fires | What it triggers | |:---|:---|:---| | `state.emitter` `'positionChange'` | `movementManager` during a position move | `updatePosition()` — Kv lookup, deltaP recompute, Port 0 | | `state.emitter` `'stateChange'` | `stateManager.transitionTo` resolve | Logged; `getOutput()` picks up the new `state` value on the next tick | | `source.emitter` `'deltaPChange'` | `MeasurementRouter.updateDeltaP` after a finite deltaP | Consumed by `valveGroupControl` to update group totals | | `source.emitter` `'fluidCompatibilityChange'` | `FluidCompatibility` on upstream-source contract change | Consumed by parent for service-type aggregation | | `source.emitter` `'fluidContractChange'` | `FluidCompatibility` when the contract this valve advertises downstream changes | Consumed by downstream consumers | | `source.measurements.emitter` `'..'` | MeasurementContainer write | Generic handshake; parents subscribe via `child.measurements.emitter.on` | | Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch | | `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render | No per-second tick on the domain itself. Position moves drive their own animation interval inside `movementManager`. --- ## Where to start reading | If you're changing... | Read first | |:---|:---| | Kv curve load / inline-curve fallback | `src/curve/supplierCurve.js` | | Liquid / gas deltaP math, choke cap | `src/hydraulicModel.js` | | Measurement → deltaP plumbing (when a recompute fires) | `src/measurement/measurementRouter.js` | | Position-tick → updatePosition wiring | `src/state/stateBindings.js` | | Mode allow-list, setpoint, executeSequence, pre-shutdown ramp | `src/flow/flowController.js` | | Upstream-source fluid tracking, contract aggregation | `src/fluid/fluidCompatibility.js` | | `query.curve` reply / status badge / Port 0 shape | `src/io/output.js` | | Topic registration, payload validation, aliases | `src/commands/{index, handlers}.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 | | [valveGroupControl wiki](https://gitea.wbd-rd.nl/RnD/valveGroupControl/wiki/Home) | The grouped-control parent | | [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |