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

17 KiB
Raw Blame History

Reference — Architecture

code-ref

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.


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):

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:

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:

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:

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

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 — 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

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 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

Page Why
Home Intuitive overview
Reference — Contracts Topic + config + child filters
Reference — Examples Shipped flows + debug recipes
Reference — Limitations Known issues and open questions
machineGroupControl wiki The grouped-control parent: planner, optimizer, rendezvous
EVOLV — Architecture Platform-wide three-tier pattern