Files
valve/wiki/Reference-Architecture.md
znetsixe 87214788d2 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:11 +02:00

301 lines
14 KiB
Markdown

# Reference &mdash; 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 &rarr; domain config, legacy-asset-field reject, status-badge polling (`statusInterval=1000`). No tick loop (`tickInterval=null`) &mdash; 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`, &hellip;). 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 &rarr; 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)` &mdash; the position-ramp to fully closed is interruptible &mdash; 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)` &mdash; Kv lookup against the supplier curve.
2. `MeasurementRouter.updateDeltaP(currentFlow, kv, downstreamP)` &mdash; recompute the hydraulic deltaP and write `pressure.predicted.delta`.
3. `host.emitter.emit('deltaPChange', deltaP)` &mdash; 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<br/>pressure.measured.*]:::input --> mp[MeasurementRouter.updatePressure]
mr --> dp[updateDeltaP]
mp --> dp
kv --> dp
dp --> hyd[ValveHydraulicModel<br/>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/` &mdash; **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 &mdash; 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&ndash;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 &mdash; 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 &mdash; 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` &mdash; 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&ndash;24.
---
## Output ports
| Port | Carries | Sample shape |
|:---|:---|:---|
| 0 (process) | Delta-compressed state snapshot &mdash; 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: <node.id>, positionVsParent, distance}` |
Port-0 key shape is **`<position>_<variant>_<type>`** (legacy three-segment). Examples: `delta_predicted_pressure`, `downstream_measured_flow`, `downstream_predicted_pressure`. Only keys with finite values are emitted &mdash; consumers must cache and merge.
On `query.curve` the node additionally emits `{topic: 'Showing curve', payload: <SupplierCurvePredictor.snapshot()>}` synchronously on Port 0.
See [EVOLV &mdash; 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()` &mdash; 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` `'<type>.<variant>.<position>'` | 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 &rarr; deltaP plumbing (when a recompute fires) | `src/measurement/measurementRouter.js` |
| Position-tick &rarr; 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 &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 |
| [valveGroupControl wiki](https://gitea.wbd-rd.nl/RnD/valveGroupControl/wiki/Home) | The grouped-control parent |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |