Files
rotatingMachine/wiki/Reference-Architecture.md
znetsixe 5ea0b0bda6 feat(state): honor sequenceAbortToken so external aborts cleanly break sequences
Consumer half of the abort-token mechanism added in generalFunctions
state.js. executeSequence captures host.state.sequenceAbortToken at
entry, then re-checks before every state transition and after the
optional ramp-down. If MGC (or any external caller) bumps the token
mid-sequence, the loop bails out cleanly — no more barge-through where
a pre-empted shutdown advances through stopping → coolingdown after a
fresh demand has already engaged the pump.

Without this the MGC rendezvous planner can't reliably re-dispatch a
pump that's mid-shutdown: the new flowmovement claims the gate, but
the old shutdown's for-loop keeps running on microtasks and steps the
FSM into idle/off underneath it.

Also: wiki regen following the same visual-first 14-section template as
the other EVOLV nodes — Reference-{Architecture,Contracts,Examples,
Limitations}.md split with _Sidebar.md index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:44:48 +02:00

341 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Reference &mdash; 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 &mdash; 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`, &hellip;); 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 &mdash; 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 &mdash; 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` &mdash; 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` | &mdash; | `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 &mdash; 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 '<name>' 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` &mdash; 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<br/>pressure.measured.up/down]:::input --> pi
pi --> ps[pressureSelector<br/>prefers real over virtual]
ps --> fd[fDimension push:<br/>predictFlow / predictPower / predictCtrl]
fd --> upd[updatePosition&#40;&#41;]
upd --> calc[calcFlowPower&#40;ctrl&#41;]
calc --> meas[MeasurementContainer<br/>flow.predicted.*<br/>power.predicted.atequipment]
measFlow[flow.measured.*]:::input --> drift[DriftAssessor<br/>EWMA + alignment]
measPower[power.measured.atequipment]:::input --> drift
meas --> drift
drift --> health[predictionHealth.refresh<br/>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` &mdash; 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 &mdash; 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)` &mdash; matrix lives in `config.mode.allowedActions`.
2. `host.isValidSourceForMode(source, currentMode)` &mdash; 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 (`<source> is not allowed in mode <mode>` or `<action> is not allowed in mode <mode>`) and short-circuits.
---
## Output ports
| Port | Carries | Sample shape |
|:---|:---|:---|
| 0 (process) | Delta-compressed state snapshot &mdash; 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 **`<type>.<variant>.<position>.<childId>`**. The trailing `<childId>` lets dashboards distinguish the same measurement type / position registered from different sources (real sensor vs `dashboard-sim`).
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` setInterval during a move | `updatePosition()` &mdash; recompute predictions + Port 0 |
| `state.emitter` `'stateChange'` | `stateManager.transitionTo` resolve | `_updateState()` &mdash; 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 &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 |
| [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home) | The grouped-control parent: planner, optimizer, rendezvous |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |