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>
17 KiB
Reference — Architecture
Note
Code structure for
rotatingMachine: the three-tier sandwich, thesrc/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:
- Entering the for-loop (after the optional
setpoint(host, 0)ramp-down step). - 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()]
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:
assetResolver.resolveAssetMetadata('rotatingmachine', model)resolves supplier / type / allowed units fromgeneralFunctions/datasets/assetData/.asset.unitis validated (must be a flow unit) and soft-warned if not in the registry's recommended list.loadModelCurve(model)reads the raw supplier curve.normalizeMachineCurve(rawCurve, unitPolicy, logger)unit-converts and shape-normalises.buildPredictors(curve)returns{predictFlow, predictPower, predictCtrl}wherepredictCtrlis 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:
host.isValidActionForMode(action, currentMode)— matrix lives inconfig.mode.allowedActions.host.isValidSourceForMode(source, currentMode)— matrix inconfig.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 |
Related pages
| 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 |