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>
341 lines
17 KiB
Markdown
341 lines
17 KiB
Markdown
# Reference — Architecture
|
||
|
||

|
||
|
||
> [!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 '<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` — 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()]
|
||
upd --> calc[calcFlowPower(ctrl)]
|
||
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` — 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 (`<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 — 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 — 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 |
|