# Reference — Architecture ![code-ref](https://img.shields.io/badge/code--ref-394a972-blue) > [!NOTE] > Code structure for `rotatingMachine`: the three-tier sandwich, the `src/` layout, the FSM (with the new sequence-abort token), the prediction + drift pipeline, the lifecycle, and the output-port pipeline. For an intuitive overview, return to [Home](Home). --- ## Three-tier code layout ``` nodes/rotatingMachine/ | +-- rotatingMachine.js entry: RED.nodes.registerType('rotatingMachine', NodeClass) | +-- src/ | nodeClass.js extends BaseNodeAdapter (Node-RED bridge) | specificClass.js extends BaseDomain (orchestration only) | | | +-- commands/ | | index.js topic descriptors | | handlers.js pure handler functions | | | +-- curves/ | | curveLoader.js load supplier curve by model id | | curveNormalizer.js unit + shape normalisation | | reverseCurve.js invert flow → ctrl for predictCtrl | | | +-- prediction/ | | predictors.js buildPredictors(curve) → predictFlow / Power / Ctrl | | groupPredictors.js buildGroupPredictors() for MGC integration | | predictionMath.js calcFlow / calcPower / calcCtrl / inputFlowCalcPower | | efficiencyMath.js calcCog / calcEfficiency / calcDistanceBEP | | operatingPoint.js legacy hook kept for migrations | | | +-- drift/ | | driftAssessor.js per-metric drift pipeline (EWMA + alignment) | | healthRefresh.js updates predictionHealth + pressureDrift | | predictionHealth.js derives quality / confidence / flags | | | +-- pressure/ | | pressureInitialization.js pressure-source readiness tracker | | pressureRouter.js routes upstream / downstream measurements | | pressureSelector.js pushes fDimension onto predictors | | virtualChildren.js auto-registered dashboard-sim children | | | +-- state/ | | stateBindings.js wires state.emitter to host callbacks | | sequenceController.js setpoint / executeSequence / waitForOperational | | | +-- measurement/ | | measurementHandlers.js per-type handlers (flow / power / temperature) | | childRegistrar.js filter-aware listener attach / detach | | | +-- flow/ | | flowController.js action dispatch (handleInput) | | | +-- display/ | | workingCurves.js query.curves / query.cog reply shape | | | +-- io/ | output.js getOutput() shape + status badge ``` ### Tier responsibilities | Tier | File | What it owns | Touches `RED.*` | |:---|:---|:---|:---:| | entry | `rotatingMachine.js` | Type registration | Yes | | nodeClass | `src/nodeClass.js` | Input routing, output ports, status-badge polling (`statusInterval=1000`). Stashes `stateConfig` and `errorMetricsConfig` on the class for the constructor. No tick loop — event-driven. | Yes | | specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; expose the same public surface MGC + pumpingStation already call (`handleInput`, `abortMovement`, `setGroupOperatingPoint`, `registerChild`, …); delegate everything else. | No | `specificClass` is stitching. All real work lives in the concern modules: pure math in `prediction/`, `drift/`; live-state-touching in `pressure/`, `state/`, `measurement/`, `flow/`. --- ## FSM The state machine is declared in `generalFunctions/src/state/stateConfig.json`. Allowed transitions (relevant subset): ```mermaid stateDiagram-v2 [*] --> idle idle --> starting: startup idle --> off idle --> maintenance starting --> warmingup: timer (time.starting) warmingup --> operational: timer (time.warmingup) [protected] operational --> accelerating: setpoint up operational --> decelerating: setpoint down operational --> stopping: 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 (first step) off --> maintenance maintenance --> off: exitmaintenance (step 1) maintenance --> idle note right of operational any state -> emergencystop via cmd.estop from emergencystop: idle / off / maintenance end note ``` Allowed transitions are declared in `generalFunctions/src/state/stateConfig.json` `allowedTransitions`. The diagram omits the `emergencystop` arrows for readability — every state has one. Self-edges (`starting → starting`, `maintenance → maintenance`) exist in the config for re-entrancy but aren't load-bearing. ### Protected states `warmingup` and `coolingdown` are **protected** in `state.js` `transitionToState`. When the FROM-state is one of these, the abort signal passed to `stateManager.transitionTo` is nulled out: ```js const protectedStates = ['warmingup', 'coolingdown']; const isProtectedTransition = protectedStates.includes(fromState); if (isProtectedTransition) { signal = null; this.logger.warn(`Transition from ${fromState} to ${targetState} is protected and cannot be aborted.`); } ``` So `abortCurrentMovement` cannot interrupt a warmup or cooldown. This is a deliberate safety guarantee — aborting a motor warmup risks burn-up. ### Routine vs sequence-internal aborts `state.abortCurrentMovement(reason, options)` accepts: | Option | Default | Used by | Effect | |:---|:---|:---|:---| | `returnToOperational: false` | yes (default) | MGC's `abortActiveMovements` — new-demand aborts | Aborts the moveTo. Does NOT auto-transition to operational (avoids a bounce loop on per-tick aborts). **Advances `sequenceAbortToken`** so any in-flight `executeSequence` bails out. | | `returnToOperational: true` | — | `executeSequence` itself when a fresher shutdown / e-stop pre-empts its own setpoint-to-zero step | Aborts the moveTo and auto-transitions back to operational so the sequence can proceed. Does NOT advance `sequenceAbortToken`. | ### Sequence-abort token — what it does `state.sequenceAbortToken` is a monotonic counter, advanced on every external (non-internal) abort. `sequenceController.executeSequence` captures the value at entry: ```js const startToken = host.state.sequenceAbortToken ?? 0; const aborted = () => (host.state.sequenceAbortToken ?? 0) !== startToken; ``` and checks before: 1. Entering the for-loop (after the optional `setpoint(host, 0)` ramp-down step). 2. Every iteration of the state-transition for-loop. A mismatch breaks the loop early with `Sequence '' interrupted ... by external abort`. The pump's `updatePosition` runs anyway so output state stays consistent. Why this matters: without the token, a shutdown's for-loop continues to run after `abortMovement` rejects its `setpoint(host, 0)`. The pump can transition `operational → stopping → coolingdown → idle` even when a new dispatch has already taken the FSM back to operational via the residue handler. The token snapshot ensures only **one** of those two paths wins per dispatch. ### Residue-state handling in `moveTo` `state.moveTo` recognises `accelerating` and `decelerating` as **post-abort residue states**. If a setpoint arrives in either, it transitions back to `operational` first, then proceeds with the new move: ```js const movementResidueStates = ['accelerating', 'decelerating']; if (movementResidueStates.includes(this.stateManager.getCurrentState())) { await this.transitionToState("operational"); // Fall through — state is now operational, proceed with new move. } ``` This is what makes mid-flight retargets work without parking the new setpoint in `delayedMove`. ### `delayedMove` — deferred setpoint When a setpoint arrives while the FSM is in a genuinely non-operational, non-residue state (`starting`, `warmingup`, `stopping`, `coolingdown`, `idle`, `off`, `emergencystop`, `maintenance`) AND mode is `auto`, the value is stashed in `state.delayedMove`. The next transition INTO `operational` picks it up and fires `moveTo(delayedMove)`. So a flow setpoint sent during startup is queued, not lost. ### State-entry timestamp + remaining transition `stateManager.stateEnteredAt` is wall-clock-stamped on every state assignment (constructor + both transition branches). `stateManager.getRemainingTransitionS()` returns `max(0, transitionTimes[currentState] − elapsed)`. The MGC movement planner calls this through `machineProfile.buildProfile` to compute exact rendezvous time for pumps currently in `warmingup` / `starting`. --- ## Prediction + drift pipeline ```mermaid flowchart TB sim[data.simulate-measurement]:::input --> pi[pressureInitialization] real[measurement child
pressure.measured.up/down]:::input --> pi pi --> ps[pressureSelector
prefers real over virtual] ps --> fd[fDimension push:
predictFlow / predictPower / predictCtrl] fd --> upd[updatePosition()] upd --> calc[calcFlowPower(ctrl)] calc --> meas[MeasurementContainer
flow.predicted.*
power.predicted.atequipment] measFlow[flow.measured.*]:::input --> drift[DriftAssessor
EWMA + alignment] measPower[power.measured.atequipment]:::input --> drift meas --> drift drift --> health[predictionHealth.refresh
quality / confidence / flags] health --> out[Port 0] upd --> out classDef input fill:#a9daee,color:#000 ``` ### Curve loading At `configure()` startup: 1. `assetResolver.resolveAssetMetadata('rotatingmachine', model)` resolves supplier / type / allowed units from `generalFunctions/datasets/assetData/`. 2. `asset.unit` is validated (must be a flow unit) and soft-warned if not in the registry's recommended list. 3. `loadModelCurve(model)` reads the raw supplier curve. 4. `normalizeMachineCurve(rawCurve, unitPolicy, logger)` unit-converts and shape-normalises. 5. `buildPredictors(curve)` returns `{predictFlow, predictPower, predictCtrl}` where `predictCtrl` is the reverse curve (flow → control %). Any failure installs **null predictors** (the asset still loads but emits zeros). The status badge falls through to a `predictionQuality: 'invalid'` state on Port 0. ### Drift `DriftAssessor` wraps `generalFunctions/nrmse` into per-metric drift profiles. Defaults (`flow` and `power`): | Field | Value | Notes | |:---|:---|:---| | `windowSize` | `30` | Sample count for long-term NRMSE | | `minSamplesForLongTerm` | `10` | Below this, long-term level stays at 3 (=invalid) | | `ewmaAlpha` | `0.15` | Immediate-level smoothing | | `alignmentToleranceMs` | `2500` | Predicted ↔ measured timestamps must align within this | | `strictValidation` | `true` | Reject samples on alignment failure | Drift feeds `predictionHealth.refresh` — immediate-level and long-term-level reduce `predictionConfidence` and append `flow_*_drift` / `power_*_drift` flags. Pressure drift is computed separately (real vs virtual divergence). ### Virtual pressure children Two `measurement`-typed children are auto-registered at startup: | ID | Position | |:---|:---| | `dashboard-sim-upstream` | `upstream` | | `dashboard-sim-downstream` | `downstream` | `data.simulate-measurement` payloads land on these. `pressureSelector` prefers any **real** pressure child over the virtuals once one registers; the virtuals stay live so the dashboard can keep injecting test values. --- ## Lifecycle — what one event does ```mermaid sequenceDiagram autonumber participant parent as MGC / pumpingStation / GUI participant rm as rotatingMachine participant fc as flowController participant fsm as state (FSM) participant pred as predictors participant out as Port 0 / 1 parent->>rm: flowmovement (Q, unit) rm->>fc: flowController.handle('parent', 'flowmovement', Q) fc->>fc: mode/source allow-list check fc->>fc: convert Q (output unit → canonical m³/s) fc->>fc: pos = host.calcCtrl(Q) fc->>fsm: setpoint(pos) → state.moveTo(pos) Note over fsm: residue handler may re-enter operational first fsm-->>rm: positionChange events per move tick rm->>pred: calcFlowPower(pos) → cFlow, cPower rm->>rm: calcEfficiency / cog / distance-BEP rm->>out: notifyOutputChanged (Port 0/1 delta) parent->>rm: execsequence ('startup' | 'shutdown') rm->>fsm: executeSequence → state transitions fsm-->>rm: stateChange events → _updateState ``` ### Mode + source allow-lists Each input is gated twice in `flowController.handle`: 1. `host.isValidActionForMode(action, currentMode)` — matrix lives in `config.mode.allowedActions`. 2. `host.isValidSourceForMode(source, currentMode)` — matrix in `config.mode.allowedSources`. Defaults (per `generalFunctions/src/configs/rotatingMachine.json`): | Mode | Allowed actions | Allowed sources | |:---|:---|:---| | `auto` | `statuscheck, execmovement, execsequence, flowmovement, emergencystop, entermaintenance` | `parent, GUI, fysical` | | `virtualControl` | `statuscheck, execmovement, flowmovement, execsequence, emergencystop, exitmaintenance` | `GUI, fysical` | | `fysicalControl` | `statuscheck, emergencystop, entermaintenance, exitmaintenance` | `fysical` | A rejected action logs at warn (` is not allowed in mode ` or ` is not allowed in mode `) and short-circuits. --- ## Output ports | Port | Carries | Sample shape | |:---|:---|:---| | 0 (process) | Delta-compressed state snapshot — FSM state, predictions, drift, prediction health | `{topic, payload: {state, ctrl, flow.predicted.*, power.predicted.*, predictionQuality, ...}}` | | 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `rotatingMachine,id=pump_a state="operational",ctrl=60,flow_predicted_downstream_default=12.4,...` | | 2 (register / control) | `child.register` upward at init | `{topic: 'child.register', payload: {ref, softwareType, config}}` | Port-0 key shape is **`...`**. The trailing `` lets dashboards distinguish the same measurement type / position registered from different sources (real sensor vs `dashboard-sim`). 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` setInterval during a move | `updatePosition()` — recompute predictions + Port 0 | | `state.emitter` `'stateChange'` | `stateManager.transitionTo` resolve | `_updateState()` — zero predictions if non-operational, refresh health, Port 0 | | `state.emitter` `'movementComplete'` | `state.moveTo` after a successful move | (subscribed but currently unused by orchestrator) | | `state.emitter` `'movementAborted'` | `state.moveTo` catch on aborted move | (subscribed but currently unused) | | Child measurement emitter | `child.measurements.emitter` per type / position | `pressureRouter.route` or `measurementHandlers.dispatch` | | 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. The movementManager's inner setInterval (50 ms by default) only runs while a position move is in flight. --- ## Where to start reading | If you're changing... | Read first | |:---|:---| | Curve loading, normalisation, fallback | `src/curves/{curveLoader, curveNormalizer, reverseCurve}.js` | | Per-machine + group predictors | `src/prediction/predictors.js`, `groupPredictors.js`, `predictionMath.js` | | Drift detection (EWMA, alignment) | `src/drift/{driftAssessor, healthRefresh, predictionHealth}.js` | | Pressure plumbing, virtual vs real preference | `src/pressure/{pressureInitialization, pressureRouter, pressureSelector, virtualChildren}.js` | | FSM bindings, setpoint, sequence orchestration | `src/state/{stateBindings, sequenceController}.js` + `generalFunctions/src/state/{state, stateManager, movementManager}.js` | | Sequence-abort token (the cooperating change for MGC's planner) | `generalFunctions/src/state/state.js` `abortCurrentMovement` + `src/state/sequenceController.js` `executeSequence` | | Per-type measurement handlers | `src/measurement/{measurementHandlers, childRegistrar}.js` | | Top-level action dispatch | `src/flow/flowController.js` | | `query.curves` / `query.cog` outputs | `src/display/workingCurves.js` | | Output shape, status badge | `src/io/output.js` | | Topic registration, payload validation | `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 | | [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home) | The grouped-control parent: planner, optimizer, rendezvous | | [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |