2025-06-25 17:26:13 +02:00
const EventEmitter = require ( 'events' ) ;
2026-03-31 18:23:38 +02:00
const { loadCurve , gravity , logger , configUtils , configManager , state , nrmse , MeasurementContainer , predict , interpolation , childRegistrationUtils , coolprop , convert , POSITIONS } = require ( 'generalFunctions' ) ;
2026-03-11 11:13:26 +01:00
const CANONICAL _UNITS = Object . freeze ( {
pressure : 'Pa' ,
atmPressure : 'Pa' ,
flow : 'm3/s' ,
power : 'W' ,
temperature : 'K' ,
} ) ;
const DEFAULT _IO _UNITS = Object . freeze ( {
pressure : 'mbar' ,
flow : 'm3/h' ,
power : 'kW' ,
temperature : 'C' ,
} ) ;
const DEFAULT _CURVE _UNITS = Object . freeze ( {
pressure : 'mbar' ,
flow : 'm3/h' ,
power : 'kW' ,
control : '%' ,
} ) ;
2025-06-25 17:26:13 +02:00
2026-02-23 13:17:18 +01:00
/ * *
* Rotating machine domain model .
* Combines machine curves , state transitions and measurement reconciliation
* to produce flow / power / efficiency behavior for pumps and similar assets .
* /
2025-06-25 17:26:13 +02:00
class Machine {
/*------------------- Construct and set vars -------------------*/
constructor ( machineConfig = { } , stateConfig = { } , errorMetricsConfig = { } ) {
//basic setup
this . emitter = new EventEmitter ( ) ; // Own EventEmitter
2025-07-01 17:02:51 +02:00
2025-07-01 15:25:07 +02:00
this . logger = new logger ( machineConfig . general . logging . enabled , machineConfig . general . logging . logLevel , machineConfig . general . name ) ;
2025-06-25 17:26:13 +02:00
this . configManager = new configManager ( ) ;
2025-07-01 15:25:07 +02:00
this . defaultConfig = this . configManager . getConfig ( 'rotatingMachine' ) ; // Load default config for rotating machine ( use software type name ? )
2025-06-25 17:26:13 +02:00
this . configUtils = new configUtils ( this . defaultConfig ) ;
2025-07-01 15:25:07 +02:00
// Load a specific curve
this . model = machineConfig . asset . model ; // Get the model from the machineConfig
2026-03-11 11:13:26 +01:00
this . rawCurve = this . model ? loadCurve ( this . model ) : null ;
this . curve = null ;
2025-07-01 15:25:07 +02:00
2025-07-24 13:15:33 +02:00
//Init config and check if it is valid
2025-07-02 16:00:52 +02:00
this . config = this . configUtils . initConfig ( machineConfig ) ;
2025-09-22 16:06:18 +02:00
//add unique name for this node.
this . config = this . configUtils . updateConfig ( this . config , { general : { name : this . config . functionality ? . softwareType + "_" + machineConfig . general . id } } ) ; // add unique name if not present
2026-03-11 11:13:26 +01:00
this . unitPolicy = this . _buildUnitPolicy ( this . config ) ;
this . config = this . configUtils . updateConfig ( this . config , {
general : { unit : this . unitPolicy . output . flow } ,
asset : {
... this . config . asset ,
unit : this . unitPolicy . output . flow ,
curveUnits : this . unitPolicy . curve ,
} ,
} ) ;
2025-07-02 16:00:52 +02:00
2026-03-11 11:13:26 +01:00
if ( ! this . model || ! this . rawCurve ) {
2025-09-23 15:11:06 +02:00
this . logger . error ( ` ${ ! this . model ? 'Model not specified' : 'Curve not found for model ' + this . model } in machineConfig. Cannot make predictions. ` ) ;
2025-07-01 15:25:07 +02:00
// Set prediction objects to null to prevent method calls
this . predictFlow = null ;
this . predictPower = null ;
this . predictCtrl = null ;
this . hasCurve = false ;
}
else {
2026-03-11 11:13:26 +01:00
try {
this . hasCurve = true ;
this . curve = this . _normalizeMachineCurve ( this . rawCurve ) ;
this . config = this . configUtils . updateConfig ( this . config , { asset : { ... this . config . asset , machineCurve : this . curve } } ) ;
//machineConfig = { ...machineConfig, asset: { ...machineConfig.asset, machineCurve: this.curve } }; // Merge curve into machineConfig
this . predictFlow = new predict ( { curve : this . config . asset . machineCurve . nq } ) ; // load nq (x : ctrl , y : flow relationship)
this . predictPower = new predict ( { curve : this . config . asset . machineCurve . np } ) ; // load np (x : ctrl , y : power relationship)
this . predictCtrl = new predict ( { curve : this . reverseCurve ( this . config . asset . machineCurve . nq ) } ) ; // load reversed nq (x: flow, y: ctrl relationship)
} catch ( error ) {
this . logger . error ( ` Curve normalization failed for model ' ${ this . model } ': ${ error . message } ` ) ;
this . predictFlow = null ;
this . predictPower = null ;
this . predictCtrl = null ;
this . hasCurve = false ;
}
2025-07-01 15:25:07 +02:00
}
2025-06-25 17:26:13 +02:00
this . state = new state ( stateConfig , this . logger ) ; // Init State manager and pass logger
this . errorMetrics = new nrmse ( errorMetricsConfig , this . logger ) ;
// Initialize measurements
2025-10-31 18:35:40 +01:00
this . measurements = new MeasurementContainer ( {
autoConvert : true ,
windowSize : 50 ,
defaultUnits : {
2026-03-11 11:13:26 +01:00
pressure : this . unitPolicy . output . pressure ,
flow : this . unitPolicy . output . flow ,
power : this . unitPolicy . output . power ,
temperature : this . unitPolicy . output . temperature ,
atmPressure : 'Pa' ,
} ,
preferredUnits : {
pressure : this . unitPolicy . output . pressure ,
flow : this . unitPolicy . output . flow ,
power : this . unitPolicy . output . power ,
temperature : this . unitPolicy . output . temperature ,
atmPressure : 'Pa' ,
} ,
canonicalUnits : this . unitPolicy . canonical ,
storeCanonical : true ,
strictUnitValidation : true ,
throwOnInvalidUnit : true ,
requireUnitForTypes : [ 'pressure' , 'flow' , 'power' , 'temperature' , 'atmPressure' ] ,
} , this . logger ) ;
2025-10-31 18:35:40 +01:00
2025-06-25 17:26:13 +02:00
this . interpolation = new interpolation ( ) ;
2025-08-07 13:52:06 +02:00
2025-06-25 17:26:13 +02:00
this . flowDrift = null ;
2026-03-11 11:13:26 +01:00
this . powerDrift = null ;
this . pressureDrift = { level : 0 , flags : [ "nominal" ] , source : null } ;
this . driftProfiles = {
flow : {
windowSize : 30 ,
minSamplesForLongTerm : 10 ,
ewmaAlpha : 0.15 ,
alignmentToleranceMs : 2500 ,
strictValidation : true ,
} ,
power : {
windowSize : 30 ,
minSamplesForLongTerm : 10 ,
ewmaAlpha : 0.15 ,
alignmentToleranceMs : 2500 ,
strictValidation : true ,
} ,
} ;
this . errorMetrics . registerMetric ( "flow" , this . driftProfiles . flow ) ;
this . errorMetrics . registerMetric ( "power" , this . driftProfiles . power ) ;
this . predictionHealth = {
quality : "invalid" ,
confidence : 0 ,
pressureSource : null ,
flags : [ "not_initialized" ] ,
} ;
2025-06-25 17:26:13 +02:00
this . currentMode = this . config . mode . current ;
this . currentEfficiencyCurve = { } ;
this . cog = 0 ;
this . NCog = 0 ;
this . cogIndex = 0 ;
this . minEfficiency = 0 ;
this . absDistFromPeak = 0 ;
this . relDistFromPeak = 0 ;
2025-08-07 13:52:06 +02:00
// When position state changes, update position
2025-06-25 17:26:13 +02:00
this . state . emitter . on ( "positionChange" , ( data ) => {
this . logger . debug ( ` Position change detected: ${ data } ` ) ;
this . updatePosition ( ) ;
} ) ;
2025-10-31 18:35:40 +01:00
//When state changes look if we need to do other updates
this . state . emitter . on ( "stateChange" , ( newState ) => {
this . logger . debug ( ` State change detected: ${ newState } ` ) ;
this . _updateState ( ) ;
} ) ;
2025-11-12 17:40:38 +01:00
//perform init for certain values
this . _init ( ) ;
2025-08-07 13:52:06 +02:00
this . child = { } ; // object to hold child information so we know on what to subscribe
2025-06-25 17:26:13 +02:00
this . childRegistrationUtils = new childRegistrationUtils ( this ) ; // Child registration utility
2026-02-19 17:36:44 +01:00
this . virtualPressureChildIds = {
upstream : "dashboard-sim-upstream" ,
downstream : "dashboard-sim-downstream" ,
} ;
this . virtualPressureChildren = { } ;
this . realPressureChildIds = {
upstream : new Set ( ) ,
downstream : new Set ( ) ,
} ;
2026-03-11 11:13:26 +01:00
this . childMeasurementListeners = new Map ( ) ;
2026-02-19 17:36:44 +01:00
this . _initVirtualPressureChildren ( ) ;
2025-06-25 17:26:13 +02:00
2025-11-12 17:40:38 +01:00
}
2026-02-19 17:36:44 +01:00
_initVirtualPressureChildren ( ) {
const createVirtualChild = ( position ) => {
const id = this . virtualPressureChildIds [ position ] ;
const name = ` dashboard-sim- ${ position } ` ;
const measurements = new MeasurementContainer ( {
autoConvert : true ,
defaultUnits : {
2026-03-11 11:13:26 +01:00
pressure : this . unitPolicy . output . pressure ,
flow : this . unitPolicy . output . flow ,
power : this . unitPolicy . output . power ,
temperature : this . unitPolicy . output . temperature ,
2026-02-19 17:36:44 +01:00
} ,
2026-03-11 11:13:26 +01:00
preferredUnits : {
pressure : this . unitPolicy . output . pressure ,
flow : this . unitPolicy . output . flow ,
power : this . unitPolicy . output . power ,
temperature : this . unitPolicy . output . temperature ,
} ,
canonicalUnits : this . unitPolicy . canonical ,
storeCanonical : true ,
strictUnitValidation : true ,
throwOnInvalidUnit : true ,
requireUnitForTypes : [ 'pressure' ] ,
} , this . logger ) ;
2026-02-19 17:36:44 +01:00
measurements . setChildId ( id ) ;
measurements . setChildName ( name ) ;
measurements . setParentRef ( this ) ;
return {
config : {
general : { id , name } ,
functionality : {
softwareType : "measurement" ,
positionVsParent : position ,
} ,
asset : {
type : "pressure" ,
2026-03-11 11:13:26 +01:00
unit : this . unitPolicy . output . pressure ,
2026-02-19 17:36:44 +01:00
} ,
} ,
measurements ,
} ;
} ;
const upstreamChild = createVirtualChild ( "upstream" ) ;
const downstreamChild = createVirtualChild ( "downstream" ) ;
this . virtualPressureChildren . upstream = upstreamChild ;
this . virtualPressureChildren . downstream = downstreamChild ;
this . registerChild ( upstreamChild , "measurement" ) ;
this . registerChild ( downstreamChild , "measurement" ) ;
}
2025-11-12 17:40:38 +01:00
_init ( ) {
//assume standard temperature is 20degrees
2026-03-11 11:13:26 +01:00
this . measurements . type ( 'temperature' ) . variant ( 'measured' ) . position ( 'atEquipment' ) . value ( 15 , Date . now ( ) , this . unitPolicy . output . temperature ) ;
2025-11-12 17:40:38 +01:00
//assume standard atm pressure is at sea level
2026-03-11 11:13:26 +01:00
this . measurements . type ( 'atmPressure' ) . variant ( 'measured' ) . position ( 'atEquipment' ) . value ( 101325 , Date . now ( ) , 'Pa' ) ;
2026-02-12 10:48:44 +01:00
//populate min and max when curve data is available
2026-03-11 11:13:26 +01:00
const flowunit = this . unitPolicy . canonical . flow ;
2026-02-12 10:48:44 +01:00
if ( this . predictFlow ) {
this . measurements . type ( 'flow' ) . variant ( 'predicted' ) . position ( 'max' ) . value ( this . predictFlow . currentFxyYMax , Date . now ( ) , flowunit ) ;
2026-03-11 11:13:26 +01:00
this . measurements . type ( 'flow' ) . variant ( 'predicted' ) . position ( 'min' ) . value ( this . predictFlow . currentFxyYMin , Date . now ( ) , flowunit ) ;
2026-02-12 10:48:44 +01:00
} else {
this . measurements . type ( 'flow' ) . variant ( 'predicted' ) . position ( 'max' ) . value ( 0 , Date . now ( ) , flowunit ) ;
this . measurements . type ( 'flow' ) . variant ( 'predicted' ) . position ( 'min' ) . value ( 0 , Date . now ( ) , flowunit ) ;
}
2025-06-25 17:26:13 +02:00
}
2025-10-31 18:35:40 +01:00
_updateState ( ) {
const isOperational = this . _isOperationalState ( ) ;
if ( ! isOperational ) {
//overrule the last prediction this should be 0 now
2026-03-11 11:13:26 +01:00
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "downstream" ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "atEquipment" ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
this . measurements . type ( "power" ) . variant ( "predicted" ) . position ( "atEquipment" ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . power ) ;
2025-10-31 18:35:40 +01:00
}
2026-03-11 11:13:26 +01:00
this . _updatePredictionHealth ( ) ;
2025-10-31 18:35:40 +01:00
}
2025-08-07 13:52:06 +02:00
/*------------------- Register child events -------------------*/
2025-09-04 17:07:29 +02:00
registerChild ( child , softwareType ) {
2026-02-19 17:36:44 +01:00
const resolvedSoftwareType = softwareType || child ? . config ? . functionality ? . softwareType || "measurement" ;
this . logger . debug ( 'Setting up child event for softwaretype ' + resolvedSoftwareType ) ;
2025-09-04 17:07:29 +02:00
2026-02-19 17:36:44 +01:00
if ( resolvedSoftwareType === "measurement" ) {
const position = String ( child . config . functionality . positionVsParent || "atEquipment" ) . toLowerCase ( ) ;
2025-09-04 17:07:29 +02:00
const measurementType = child . config . asset . type ;
2026-02-19 17:36:44 +01:00
const childId = child . config ? . general ? . id || ` ${ measurementType } - ${ position } -unknown ` ;
const isVirtualPressureChild = Object . values ( this . virtualPressureChildIds ) . includes ( childId ) ;
if ( measurementType === "pressure" && ! isVirtualPressureChild ) {
this . realPressureChildIds [ position ] ? . add ( childId ) ;
}
2025-10-03 15:41:53 +02:00
//rebuild to measurementype.variant no position and then switch based on values not strings or names.
2025-09-04 17:07:29 +02:00
const eventName = ` ${ measurementType } .measured. ${ position } ` ;
2026-03-11 11:13:26 +01:00
const listenerKey = ` ${ childId } : ${ eventName } ` ;
const existingListener = this . childMeasurementListeners . get ( listenerKey ) ;
if ( existingListener ) {
if ( typeof existingListener . emitter . off === "function" ) {
existingListener . emitter . off ( existingListener . eventName , existingListener . handler ) ;
} else if ( typeof existingListener . emitter . removeListener === "function" ) {
existingListener . emitter . removeListener ( existingListener . eventName , existingListener . handler ) ;
}
}
2025-09-04 17:07:29 +02:00
2025-10-03 15:41:53 +02:00
this . logger . debug ( ` Setting up listener for ${ eventName } from child ${ child . config . general . name } ` ) ;
2025-09-04 17:07:29 +02:00
// Register event listener for measurement updates
2026-03-11 11:13:26 +01:00
const listener = ( eventData ) => {
2025-09-04 17:07:29 +02:00
this . logger . debug ( ` 🔄 ${ position } ${ measurementType } from ${ eventData . childName } : ${ eventData . value } ${ eventData . unit } ` ) ;
2025-08-07 13:52:06 +02:00
2025-10-03 15:41:53 +02:00
2025-11-13 19:39:05 +01:00
this . logger . debug ( ` Emitting... ${ eventName } with data: ` ) ;
2026-03-11 11:13:26 +01:00
// Route through centralized handlers so unit validation/conversion is applied once.
2025-09-04 17:07:29 +02:00
this . _callMeasurementHandler ( measurementType , eventData . value , position , eventData ) ;
2026-03-11 11:13:26 +01:00
} ;
child . measurements . emitter . on ( eventName , listener ) ;
this . childMeasurementListeners . set ( listenerKey , {
emitter : child . measurements . emitter ,
eventName ,
handler : listener ,
2025-09-04 17:07:29 +02:00
} ) ;
2025-08-07 13:52:06 +02:00
}
2025-09-04 17:07:29 +02:00
}
2025-08-08 14:29:15 +02:00
// Centralized handler dispatcher
_callMeasurementHandler ( measurementType , value , position , context ) {
switch ( measurementType ) {
case 'pressure' :
this . updateMeasuredPressure ( value , position , context ) ;
break ;
case 'flow' :
this . updateMeasuredFlow ( value , position , context ) ;
break ;
2026-03-11 11:13:26 +01:00
case 'power' :
this . updateMeasuredPower ( value , position , context ) ;
break ;
2025-08-08 14:29:15 +02:00
case 'temperature' :
this . updateMeasuredTemperature ( value , position , context ) ;
break ;
default :
this . logger . warn ( ` No handler for measurement type: ${ measurementType } ` ) ;
// Generic handler - just update position
this . updatePosition ( ) ;
break ;
}
}
//---------------- END child stuff -------------//
2025-08-07 13:52:06 +02:00
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
/ * *
* Wait until the state machine reaches 'operational' , or until a timeout .
* Used after an aborted movement to ensure subsequent sequence transitions
* ( stopping / emergencystop ) will be accepted by the FSM .
* @ param { number } timeoutMs - maximum time to wait in milliseconds
* @ returns { Promise < string > } the state observed when the wait ends
* /
async _waitForOperational ( timeoutMs = 2000 ) {
if ( this . state . getCurrentState ( ) === "operational" ) {
return "operational" ;
}
return await new Promise ( ( resolve ) => {
let done = false ;
const timer = setTimeout ( ( ) => {
if ( done ) return ;
done = true ;
this . state . emitter . off ( "stateChange" , onChange ) ;
resolve ( this . state . getCurrentState ( ) ) ;
} , timeoutMs ) ;
const onChange = ( newState ) => {
if ( done ) return ;
if ( newState === "operational" ) {
done = true ;
clearTimeout ( timer ) ;
this . state . emitter . off ( "stateChange" , onChange ) ;
resolve ( "operational" ) ;
}
} ;
this . state . emitter . on ( "stateChange" , onChange ) ;
} ) ;
}
2026-03-11 11:13:26 +01:00
_buildUnitPolicy ( config ) {
const flowOutputUnit = this . _resolveUnitOrFallback (
config ? . general ? . unit ,
'volumeFlowRate' ,
DEFAULT _IO _UNITS . flow ,
'general.flow'
) ;
const pressureOutputUnit = this . _resolveUnitOrFallback (
config ? . asset ? . pressureUnit ,
'pressure' ,
DEFAULT _IO _UNITS . pressure ,
'asset.pressure'
) ;
const powerOutputUnit = this . _resolveUnitOrFallback (
config ? . asset ? . powerUnit ,
'power' ,
DEFAULT _IO _UNITS . power ,
'asset.power'
) ;
const temperatureOutputUnit = this . _resolveUnitOrFallback (
config ? . asset ? . temperatureUnit ,
'temperature' ,
DEFAULT _IO _UNITS . temperature ,
'asset.temperature'
) ;
const curveUnits = this . _resolveCurveUnits ( config ? . asset ? . curveUnits || { } , flowOutputUnit ) ;
2025-06-25 17:26:13 +02:00
2026-03-11 11:13:26 +01:00
return {
canonical : { ... CANONICAL _UNITS } ,
output : {
pressure : pressureOutputUnit ,
flow : flowOutputUnit ,
power : powerOutputUnit ,
temperature : temperatureOutputUnit ,
atmPressure : 'Pa' ,
} ,
curve : curveUnits ,
} ;
}
_resolveCurveUnits ( curveUnits = { } , fallbackFlowUnit = DEFAULT _CURVE _UNITS . flow ) {
const pressure = this . _resolveUnitOrFallback (
curveUnits . pressure ,
'pressure' ,
DEFAULT _CURVE _UNITS . pressure ,
'asset.curveUnits.pressure'
) ;
const flow = this . _resolveUnitOrFallback (
curveUnits . flow ,
'volumeFlowRate' ,
fallbackFlowUnit || DEFAULT _CURVE _UNITS . flow ,
'asset.curveUnits.flow'
) ;
const power = this . _resolveUnitOrFallback (
curveUnits . power ,
'power' ,
DEFAULT _CURVE _UNITS . power ,
'asset.curveUnits.power'
) ;
const control = typeof curveUnits . control === 'string' && curveUnits . control . trim ( )
? curveUnits . control . trim ( )
: DEFAULT _CURVE _UNITS . control ;
return { pressure , flow , power , control } ;
}
_resolveUnitOrFallback ( candidate , expectedMeasure , fallbackUnit , label ) {
const fallback = String ( fallbackUnit || '' ) . trim ( ) ;
const raw = typeof candidate === 'string' ? candidate . trim ( ) : '' ;
if ( ! raw ) return fallback ;
try {
const desc = convert ( ) . describe ( raw ) ;
if ( expectedMeasure && desc . measure !== expectedMeasure ) {
throw new Error ( ` expected ${ expectedMeasure } but got ${ desc . measure } ` ) ;
}
return raw ;
} catch ( error ) {
this . logger . warn ( ` Invalid ${ label } unit ' ${ raw } ' ( ${ error . message } ). Falling back to ' ${ fallback } '. ` ) ;
return fallback ;
}
}
_convertUnitValue ( value , fromUnit , toUnit , contextLabel = 'unit conversion' ) {
const numeric = Number ( value ) ;
if ( ! Number . isFinite ( numeric ) ) {
throw new Error ( ` ${ contextLabel } : value ' ${ value } ' is not finite ` ) ;
}
if ( ! fromUnit || ! toUnit || fromUnit === toUnit ) {
return numeric ;
}
return convert ( numeric ) . from ( fromUnit ) . to ( toUnit ) ;
}
_normalizeCurveSection ( section , fromYUnit , toYUnit , fromPressureUnit , toPressureUnit , sectionName ) {
const normalized = { } ;
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
const pressureEntries = Object . entries ( section || { } ) ;
let prevMedianY = null ;
for ( const [ pressureKey , pair ] of pressureEntries ) {
2026-03-11 11:13:26 +01:00
const canonicalPressure = this . _convertUnitValue (
Number ( pressureKey ) ,
fromPressureUnit ,
toPressureUnit ,
` ${ sectionName } pressure axis `
2025-06-25 17:26:13 +02:00
) ;
2026-03-11 11:13:26 +01:00
const xArray = Array . isArray ( pair ? . x ) ? pair . x . map ( Number ) : [ ] ;
const yArray = Array . isArray ( pair ? . y ) ? pair . y . map ( ( v ) => this . _convertUnitValue ( v , fromYUnit , toYUnit , ` ${ sectionName } output ` ) ) : [ ] ;
if ( ! xArray . length || ! yArray . length || xArray . length !== yArray . length ) {
throw new Error ( ` Invalid ${ sectionName } section at pressure ' ${ pressureKey } '. ` ) ;
}
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
// Cross-pressure anomaly detection: flag sudden jumps in median y between adjacent pressure levels
const sortedY = [ ... yArray ] . sort ( ( a , b ) => a - b ) ;
const medianY = sortedY [ Math . floor ( sortedY . length / 2 ) ] ;
if ( prevMedianY != null && prevMedianY > 0 ) {
const ratio = medianY / prevMedianY ;
if ( ratio > 3 || ratio < 0.33 ) {
this . logger . warn (
` Curve anomaly in ${ sectionName } at pressure ${ pressureKey } : median y= ${ medianY . toFixed ( 2 ) } ` +
` deviates ${ ( ratio ) . toFixed ( 1 ) } x from adjacent level ( ${ prevMedianY . toFixed ( 2 ) } ). Check curve data. `
) ;
}
}
prevMedianY = medianY ;
2026-03-11 11:13:26 +01:00
normalized [ String ( canonicalPressure ) ] = {
x : xArray ,
y : yArray ,
} ;
}
return normalized ;
}
_normalizeMachineCurve ( rawCurve , curveUnits = this . unitPolicy . curve ) {
if ( ! rawCurve || typeof rawCurve !== 'object' || ! rawCurve . nq || ! rawCurve . np ) {
throw new Error ( 'Machine curve is missing required nq/np sections.' ) ;
}
return {
nq : this . _normalizeCurveSection (
rawCurve . nq ,
curveUnits . flow ,
this . unitPolicy . canonical . flow ,
curveUnits . pressure ,
this . unitPolicy . canonical . pressure ,
'nq'
) ,
np : this . _normalizeCurveSection (
rawCurve . np ,
curveUnits . power ,
this . unitPolicy . canonical . power ,
curveUnits . pressure ,
this . unitPolicy . canonical . pressure ,
'np'
) ,
} ;
}
isUnitValidForType ( type , unit ) {
return this . measurements ? . isUnitCompatible ? . ( type , unit ) === true ;
}
_resolveMeasurementUnit ( type , providedUnit ) {
const unit = typeof providedUnit === 'string' ? providedUnit . trim ( ) : '' ;
if ( ! unit ) {
throw new Error ( ` Missing unit for ${ type } measurement. ` ) ;
}
if ( ! this . isUnitValidForType ( type , unit ) ) {
throw new Error ( ` Unsupported unit ' ${ unit } ' for ${ type } measurement. ` ) ;
}
return unit ;
}
_measurementPositionForMetric ( metricId ) {
if ( metricId === "power" ) return "atEquipment" ;
return "downstream" ;
}
_resolveProcessRangeForMetric ( metricId , predictedValue , measuredValue ) {
let processMin = NaN ;
let processMax = NaN ;
if ( metricId === "flow" ) {
processMin = Number ( this . predictFlow ? . currentFxyYMin ) ;
processMax = Number ( this . predictFlow ? . currentFxyYMax ) ;
} else if ( metricId === "power" ) {
processMin = Number ( this . predictPower ? . currentFxyYMin ) ;
processMax = Number ( this . predictPower ? . currentFxyYMax ) ;
}
if ( ! Number . isFinite ( processMin ) || ! Number . isFinite ( processMax ) || processMax <= processMin ) {
const p = Number ( predictedValue ) ;
const m = Number ( measuredValue ) ;
const localMin = Math . min ( p , m ) ;
const localMax = Math . max ( p , m ) ;
processMin = Number . isFinite ( localMin ) ? localMin : 0 ;
processMax = Number . isFinite ( localMax ) && localMax > processMin ? localMax : processMin + 1 ;
}
return { processMin , processMax } ;
}
_updateMetricDrift ( metricId , measuredValue , context = { } ) {
const position = this . _measurementPositionForMetric ( metricId ) ;
const predictedValue = Number (
this . measurements
. type ( metricId )
. variant ( "predicted" )
. position ( position )
. getCurrentValue ( )
) ;
const measured = Number ( measuredValue ) ;
if ( ! Number . isFinite ( predictedValue ) || ! Number . isFinite ( measured ) ) return null ;
const { processMin , processMax } = this . _resolveProcessRangeForMetric ( metricId , predictedValue , measured ) ;
const timestamp = Number ( context . timestamp || Date . now ( ) ) ;
const profile = this . driftProfiles [ metricId ] || { } ;
try {
const drift = this . errorMetrics . assessPoint ( metricId , predictedValue , measured , {
... profile ,
processMin ,
processMax ,
predictedTimestamp : timestamp ,
measuredTimestamp : timestamp ,
} ) ;
if ( drift && drift . valid ) {
if ( metricId === "flow" ) this . flowDrift = drift ;
if ( metricId === "power" ) this . powerDrift = drift ;
}
return drift ;
} catch ( error ) {
this . logger . warn ( ` Drift update failed for metric ' ${ metricId } ': ${ error . message } ` ) ;
return null ;
}
}
_updatePressureDriftStatus ( ) {
const status = this . getPressureInitializationStatus ( ) ;
const flags = [ ] ;
let level = 0 ;
if ( ! status . initialized ) {
level = 2 ;
flags . push ( "no_pressure_input" ) ;
} else if ( ! status . hasDifferential ) {
level = 1 ;
flags . push ( "single_side_pressure" ) ;
}
if ( status . hasDifferential ) {
const upstream = this . _getPreferredPressureValue ( "upstream" ) ;
const downstream = this . _getPreferredPressureValue ( "downstream" ) ;
const diff = Number ( downstream ) - Number ( upstream ) ;
if ( Number . isFinite ( diff ) && diff < 0 ) {
level = Math . max ( level , 3 ) ;
flags . push ( "negative_pressure_differential" ) ;
}
}
this . pressureDrift = {
level ,
source : status . source ,
flags : flags . length ? flags : [ "nominal" ] ,
} ;
return this . pressureDrift ;
}
assessDrift ( measurement , processMin , processMax ) {
const metricId = String ( measurement || "" ) . toLowerCase ( ) ;
const position = this . _measurementPositionForMetric ( metricId ) ;
const predictedMeasurement = this . measurements . type ( metricId ) . variant ( "predicted" ) . position ( position ) . getAllValues ( ) ;
const measuredMeasurement = this . measurements . type ( metricId ) . variant ( "measured" ) . position ( position ) . getAllValues ( ) ;
if ( ! predictedMeasurement ? . values || ! measuredMeasurement ? . values ) return null ;
return this . errorMetrics . assessDrift (
predictedMeasurement . values ,
measuredMeasurement . values ,
processMin ,
processMax ,
{
metricId ,
predictedTimestamps : predictedMeasurement . timestamps ,
measuredTimestamps : measuredMeasurement . timestamps ,
... ( this . driftProfiles [ metricId ] || { } ) ,
}
) ;
}
_applyDriftPenalty ( drift , confidence , flags , prefix ) {
if ( ! drift || ! drift . valid || ! Number . isFinite ( drift . nrmse ) ) return confidence ;
if ( drift . immediateLevel >= 3 ) {
confidence -= 0.3 ;
flags . push ( ` ${ prefix } _high_immediate_drift ` ) ;
} else if ( drift . immediateLevel === 2 ) {
confidence -= 0.2 ;
flags . push ( ` ${ prefix } _medium_immediate_drift ` ) ;
} else if ( drift . immediateLevel === 1 ) {
confidence -= 0.1 ;
flags . push ( ` ${ prefix } _low_immediate_drift ` ) ;
}
if ( drift . longTermLevel >= 2 ) {
confidence -= 0.1 ;
flags . push ( ` ${ prefix } _long_term_drift ` ) ;
}
return confidence ;
}
_updatePredictionHealth ( ) {
const status = this . getPressureInitializationStatus ( ) ;
const pressureDrift = this . _updatePressureDriftStatus ( ) ;
const flags = [ ... pressureDrift . flags ] ;
let confidence = 0 ;
const pressureSource = status . source ;
if ( pressureSource === "differential" ) {
confidence = 0.9 ;
} else if ( pressureSource === "upstream" || pressureSource === "downstream" ) {
confidence = 0.55 ;
} else {
confidence = 0.2 ;
}
if ( ! this . _isOperationalState ( ) ) {
confidence = 0 ;
flags . push ( "not_operational" ) ;
}
if ( pressureDrift . level >= 3 ) confidence -= 0.35 ;
else if ( pressureDrift . level === 2 ) confidence -= 0.2 ;
else if ( pressureDrift . level === 1 ) confidence -= 0.1 ;
const currentPosition = Number ( this . state ? . getCurrentPosition ? . ( ) ) ;
const { min , max } = this . _resolveSetpointBounds ( ) ;
if ( Number . isFinite ( currentPosition ) && Number . isFinite ( min ) && Number . isFinite ( max ) && max > min ) {
const span = max - min ;
const edgeDistance = Math . min ( Math . abs ( currentPosition - min ) , Math . abs ( max - currentPosition ) ) ;
if ( edgeDistance < span * 0.05 ) {
confidence -= 0.1 ;
flags . push ( "near_curve_edge" ) ;
}
2025-06-25 17:26:13 +02:00
}
2026-03-11 11:13:26 +01:00
confidence = this . _applyDriftPenalty ( this . flowDrift , confidence , flags , "flow" ) ;
confidence = this . _applyDriftPenalty ( this . powerDrift , confidence , flags , "power" ) ;
confidence = Math . max ( 0 , Math . min ( 1 , confidence ) ) ;
let quality = "invalid" ;
if ( confidence >= 0.8 ) quality = "high" ;
else if ( confidence >= 0.55 ) quality = "medium" ;
else if ( confidence >= 0.3 ) quality = "low" ;
this . predictionHealth = {
quality ,
confidence ,
pressureSource ,
flags : flags . length ? Array . from ( new Set ( flags ) ) : [ "nominal" ] ,
} ;
return this . predictionHealth ;
}
2025-06-25 17:26:13 +02:00
reverseCurve ( curve ) {
const reversedCurve = { } ;
for ( const [ pressure , values ] of Object . entries ( curve ) ) {
reversedCurve [ pressure ] = {
x : [ ... values . y ] , // Previous y becomes new x
y : [ ... values . x ] // Previous x becomes new y
} ;
}
return reversedCurve ;
}
// -------- Config -------- //
updateConfig ( newConfig ) {
this . config = this . configUtils . updateConfig ( this . config , newConfig ) ;
}
// -------- Mode and Input Management -------- //
isValidSourceForMode ( source , mode ) {
const allowedSourcesSet = this . config . mode . allowedSources [ mode ] || [ ] ;
2025-11-05 17:15:47 +01:00
const allowed = allowedSourcesSet . has ( source ) ;
allowed ?
this . logger . debug ( ` source is allowed proceeding with ${ source } for mode ${ mode } ` ) :
this . logger . warn ( ` ${ source } is not allowed in mode ${ mode } ` ) ;
return allowed ;
2025-06-25 17:26:13 +02:00
}
isValidActionForMode ( action , mode ) {
const allowedActionsSet = this . config . mode . allowedActions [ mode ] || [ ] ;
2025-11-05 17:15:47 +01:00
const allowed = allowedActionsSet . has ( action ) ;
allowed ?
this . logger . debug ( ` Action is allowed proceeding with ${ action } for mode ${ mode } ` ) :
this . logger . warn ( ` ${ action } is not allowed in mode ${ mode } ` ) ;
return allowed ;
2025-06-25 17:26:13 +02:00
}
async handleInput ( source , action , parameter ) {
2025-10-02 17:09:24 +02:00
2025-11-05 15:47:39 +01:00
//sanitize input
if ( typeof action !== 'string' ) { this . logger . error ( ` Action must be string ` ) ; return ; }
//convert to lower case to avoid to many mistakes in commands
action = action . toLowerCase ( ) ;
2025-11-05 17:15:47 +01:00
// check for validity of the request
if ( ! this . isValidActionForMode ( action , this . currentMode ) ) { return ; }
if ( ! this . isValidSourceForMode ( source , this . currentMode ) ) { return ; }
2025-06-25 17:26:13 +02:00
this . logger . info ( ` Handling input from source ' ${ source } ' with action ' ${ action } ' in mode ' ${ this . currentMode } '. ` ) ;
2025-10-02 17:09:24 +02:00
2025-06-25 17:26:13 +02:00
try {
switch ( action ) {
2025-11-05 15:47:39 +01:00
case "execsequence" :
2025-10-02 17:09:24 +02:00
return await this . executeSequence ( parameter ) ;
2025-11-05 15:47:39 +01:00
case "execmovement" :
2025-10-02 17:09:24 +02:00
return await this . setpoint ( parameter ) ;
2025-11-05 17:15:47 +01:00
case "entermaintenance" :
return await this . executeSequence ( parameter ) ;
case "exitmaintenance" :
return await this . executeSequence ( parameter ) ;
2025-11-05 15:47:39 +01:00
case "flowmovement" :
2026-03-11 11:13:26 +01:00
// External flow setpoint is interpreted in configured output flow unit.
const canonicalFlowSetpoint = this . _convertUnitValue (
parameter ,
this . unitPolicy . output . flow ,
this . unitPolicy . canonical . flow ,
'flowmovement setpoint'
) ;
2025-06-25 17:26:13 +02:00
// Calculate the control value for a desired flow
2026-03-11 11:13:26 +01:00
const pos = this . calcCtrl ( canonicalFlowSetpoint ) ;
2025-06-25 17:26:13 +02:00
// Move to the desired setpoint
2025-10-02 17:09:24 +02:00
return await this . setpoint ( pos ) ;
2025-11-05 15:47:39 +01:00
case "emergencystop" :
2025-06-25 17:26:13 +02:00
this . logger . warn ( ` Emergency stop activated by ' ${ source } '. ` ) ;
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
return await this . executeSequence ( "emergencystop" ) ;
2025-10-02 17:09:24 +02:00
2025-11-05 15:47:39 +01:00
case "statuscheck" :
2025-06-25 17:26:13 +02:00
this . logger . info ( ` Status Check: Mode = ' ${ this . currentMode } ', Source = ' ${ source } '. ` ) ;
break ;
2025-10-02 17:09:24 +02:00
2025-06-25 17:26:13 +02:00
default :
this . logger . warn ( ` Action ' ${ action } ' is not implemented. ` ) ;
break ;
}
this . logger . debug ( ` Action ' ${ action } ' successfully executed ` ) ;
return { status : true , feedback : ` Action ' ${ action } ' successfully executed. ` } ;
} catch ( error ) {
this . logger . error ( ` Error handling input: ${ error } ` ) ;
}
2025-10-02 17:09:24 +02:00
}
abortMovement ( reason = "group override" ) {
if ( this . state ? . abortCurrentMovement ) {
this . state . abortCurrentMovement ( reason ) ;
}
2025-06-25 17:26:13 +02:00
}
setMode ( newMode ) {
2025-07-24 13:15:33 +02:00
const availableModes = this . defaultConfig . mode . current . rules . values . map ( v => v . value ) ;
2025-06-25 17:26:13 +02:00
if ( ! availableModes . includes ( newMode ) ) {
this . logger . warn ( ` Invalid mode ' ${ newMode } '. Allowed modes are: ${ availableModes . join ( ', ' ) } ` ) ;
return ;
}
this . currentMode = newMode ;
this . logger . info ( ` Mode successfully changed to ' ${ newMode } '. ` ) ;
}
// -------- Sequence Handlers -------- //
async executeSequence ( sequenceName ) {
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
// Defensive: sequence keys in the config are lowercase. Accept any casing
// from callers (parent orchestrators, tests, legacy flows) and normalize.
if ( typeof sequenceName === 'string' ) {
sequenceName = sequenceName . toLowerCase ( ) ;
}
2025-06-25 17:26:13 +02:00
const sequence = this . config . sequences [ sequenceName ] ;
if ( ! sequence || sequence . size === 0 ) {
this . logger . warn ( ` Sequence ' ${ sequenceName } ' not defined. ` ) ;
return ;
}
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
// Interruptible movement: if a shutdown or emergency-stop is requested
// while a setpoint move is mid-flight (accelerating/decelerating), abort
// the move first and wait briefly for the FSM to return to 'operational'.
// Without this, transitions like accelerating->stopping are rejected by
// stateManager.isValidTransition, leaving the machine running.
const currentState = this . state . getCurrentState ( ) ;
const interruptible = new Set ( [ "shutdown" , "emergencystop" ] ) ;
if ( interruptible . has ( sequenceName ) &&
( currentState === "accelerating" || currentState === "decelerating" ) ) {
this . logger . warn ( ` Sequence ' ${ sequenceName } ' requested during ' ${ currentState } '. Aborting active movement. ` ) ;
2026-04-14 12:01:49 +02:00
this . state . abortCurrentMovement ( ` ${ sequenceName } sequence requested ` , { returnToOperational : true } ) ;
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
await this . _waitForOperational ( 2000 ) ;
}
2025-06-25 17:26:13 +02:00
if ( this . state . getCurrentState ( ) == "operational" && sequenceName == "shutdown" ) {
this . logger . info ( ` Machine will ramp down to position 0 before performing ${ sequenceName } sequence ` ) ;
await this . setpoint ( 0 ) ;
}
this . logger . info ( ` --------- Executing sequence: ${ sequenceName } ------------- ` ) ;
for ( const state of sequence ) {
try {
await this . state . transitionToState ( state ) ;
// Update measurements after state change
} catch ( error ) {
this . logger . error ( ` Error during sequence ' ${ sequenceName } ': ${ error } ` ) ;
break ; // Exit sequence execution on error
}
}
2025-10-02 17:09:24 +02:00
//recalc flow and power
this . updatePosition ( ) ;
2025-06-25 17:26:13 +02:00
}
async setpoint ( setpoint ) {
try {
2026-03-11 11:13:26 +01:00
// Validate and normalize setpoint
if ( ! Number . isFinite ( setpoint ) ) {
this . logger . error ( "Invalid setpoint: Setpoint must be a finite number." ) ;
return ;
}
const { min , max } = this . _resolveSetpointBounds ( ) ;
const constrainedSetpoint = Math . min ( Math . max ( setpoint , min ) , max ) ;
if ( constrainedSetpoint !== setpoint ) {
this . logger . warn ( ` Requested setpoint ${ setpoint } constrained to ${ constrainedSetpoint } (min= ${ min } , max= ${ max } ) ` ) ;
2025-06-25 17:26:13 +02:00
}
2026-03-11 11:13:26 +01:00
this . logger . info ( ` Setting setpoint to ${ constrainedSetpoint } . Current position: ${ this . state . getCurrentPosition ( ) } ` ) ;
2025-07-02 16:00:52 +02:00
2025-06-25 17:26:13 +02:00
// Move to the desired setpoint
2026-03-11 11:13:26 +01:00
await this . state . moveTo ( constrainedSetpoint ) ;
2025-06-25 17:26:13 +02:00
} catch ( error ) {
2026-03-11 11:13:26 +01:00
this . logger . error ( ` Error setting setpoint: ${ error } ` ) ;
2025-06-25 17:26:13 +02:00
}
}
2026-03-11 11:13:26 +01:00
_resolveSetpointBounds ( ) {
const stateMin = Number ( this . state ? . movementManager ? . minPosition ) ;
const stateMax = Number ( this . state ? . movementManager ? . maxPosition ) ;
const curveMin = Number ( this . predictFlow ? . currentFxyXMin ) ;
const curveMax = Number ( this . predictFlow ? . currentFxyXMax ) ;
const minCandidates = [ stateMin , curveMin ] . filter ( Number . isFinite ) ;
const maxCandidates = [ stateMax , curveMax ] . filter ( Number . isFinite ) ;
const fallbackMin = Number . isFinite ( stateMin ) ? stateMin : 0 ;
const fallbackMax = Number . isFinite ( stateMax ) ? stateMax : 100 ;
let min = minCandidates . length ? Math . max ( ... minCandidates ) : fallbackMin ;
let max = maxCandidates . length ? Math . min ( ... maxCandidates ) : fallbackMax ;
if ( min > max ) {
this . logger . warn ( ` Invalid setpoint bounds detected (min= ${ min } , max= ${ max } ). Falling back to movement bounds. ` ) ;
min = fallbackMin ;
max = fallbackMax ;
}
return { min , max } ;
}
2025-06-25 17:26:13 +02:00
// Calculate flow based on current pressure and position
calcFlow ( x ) {
2025-07-02 16:00:52 +02:00
if ( this . hasCurve ) {
2025-08-07 13:52:06 +02:00
if ( ! this . _isOperationalState ( ) ) {
2026-03-11 11:13:26 +01:00
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "downstream" ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "atEquipment" ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
2025-07-01 15:25:07 +02:00
this . logger . debug ( ` Machine is not operational. Setting predicted flow to 0. ` ) ;
return 0 ;
}
2025-06-25 17:26:13 +02:00
2026-04-14 10:07:02 +02:00
const rawFlow = this . predictFlow . y ( x ) ;
2026-04-14 10:28:13 +02:00
// Clamp: at position ≤ 0 the pump isn't rotating — physical flow is 0.
2026-04-14 10:07:02 +02:00
const cFlow = ( x <= 0 ) ? 0 : Math . max ( 0 , rawFlow ) ;
2026-03-11 11:13:26 +01:00
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "downstream" ) . value ( cFlow , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "atEquipment" ) . value ( cFlow , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
2025-06-25 17:26:13 +02:00
return cFlow ;
2025-07-01 15:25:07 +02:00
}
// If no curve data is available, log a warning and return 0
this . logger . warn ( ` No curve data available for flow calculation. Returning 0. ` ) ;
2026-03-11 11:13:26 +01:00
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "downstream" ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "atEquipment" ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
2025-07-01 15:25:07 +02:00
return 0 ;
2025-06-25 17:26:13 +02:00
}
// Calculate power based on current pressure and position
calcPower ( x ) {
2025-07-02 16:00:52 +02:00
if ( this . hasCurve ) {
2025-08-07 13:52:06 +02:00
if ( ! this . _isOperationalState ( ) ) {
2026-03-11 11:13:26 +01:00
this . measurements . type ( "power" ) . variant ( "predicted" ) . position ( 'atEquipment' ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . power ) ;
2025-07-01 15:25:07 +02:00
this . logger . debug ( ` Machine is not operational. Setting predicted power to 0. ` ) ;
return 0 ;
}
2025-06-25 17:26:13 +02:00
2026-04-14 10:07:02 +02:00
const rawPower = this . predictPower . y ( x ) ;
const cPower = ( x <= 0 ) ? 0 : Math . max ( 0 , rawPower ) ;
2026-03-11 11:13:26 +01:00
this . measurements . type ( "power" ) . variant ( "predicted" ) . position ( 'atEquipment' ) . value ( cPower , Date . now ( ) , this . unitPolicy . canonical . power ) ;
2025-06-25 17:26:13 +02:00
return cPower ;
2025-07-01 15:25:07 +02:00
}
// If no curve data is available, log a warning and return 0
this . logger . warn ( ` No curve data available for power calculation. Returning 0. ` ) ;
2026-03-11 11:13:26 +01:00
this . measurements . type ( "power" ) . variant ( "predicted" ) . position ( 'atEquipment' ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . power ) ;
2025-07-01 15:25:07 +02:00
return 0 ;
2025-06-25 17:26:13 +02:00
}
// calculate the power consumption using only flow and pressure
inputFlowCalcPower ( flow ) {
2025-07-02 16:00:52 +02:00
if ( this . hasCurve ) {
2025-07-01 15:25:07 +02:00
this . predictCtrl . currentX = flow ;
const cCtrl = this . predictCtrl . y ( flow ) ;
this . predictPower . currentX = cCtrl ;
const cPower = this . predictPower . y ( cCtrl ) ;
return cPower ;
}
// If no curve data is available, log a warning and return 0
this . logger . warn ( ` No curve data available for power calculation. Returning 0. ` ) ;
2026-03-11 11:13:26 +01:00
this . measurements . type ( "power" ) . variant ( "predicted" ) . position ( 'atEquipment' ) . value ( 0 , Date . now ( ) , this . unitPolicy . canonical . power ) ;
2025-07-01 15:25:07 +02:00
return 0 ;
2025-06-25 17:26:13 +02:00
}
// Function to predict control value for a desired flow
calcCtrl ( x ) {
2025-07-02 16:00:52 +02:00
if ( this . hasCurve ) {
2025-07-01 15:25:07 +02:00
this . predictCtrl . currentX = x ;
const cCtrl = this . predictCtrl . y ( x ) ;
2025-10-02 17:09:24 +02:00
this . measurements . type ( "ctrl" ) . variant ( "predicted" ) . position ( 'atEquipment' ) . value ( cCtrl ) ;
2025-07-01 15:25:07 +02:00
//this.logger.debug(`Calculated ctrl: ${cCtrl} for pressure: ${this.getMeasuredPressure()} and position: ${x}`);
return cCtrl ;
}
// If no curve data is available, log a warning and return 0
this . logger . warn ( ` No curve data available for control calculation. Returning 0. ` ) ;
2026-03-11 11:13:26 +01:00
this . measurements . type ( "ctrl" ) . variant ( "predicted" ) . position ( 'atEquipment' ) . value ( 0 , Date . now ( ) ) ;
2025-07-01 15:25:07 +02:00
return 0 ;
2025-06-25 17:26:13 +02:00
}
2025-08-07 13:52:06 +02:00
// returns the best available pressure measurement to use in the prediction calculation
// this will be either the differential pressure, downstream or upstream pressure
2025-06-25 17:26:13 +02:00
getMeasuredPressure ( ) {
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( ! this . hasCurve || ! this . predictFlow || ! this . predictPower || ! this . predictCtrl ) {
2025-11-13 19:39:05 +01:00
this . logger . error ( ` No valid curve available to calculate prediction using last known pressure ` ) ;
return 0 ;
}
2026-02-19 17:36:44 +01:00
const upstreamPressure = this . _getPreferredPressureValue ( "upstream" ) ;
const downstreamPressure = this . _getPreferredPressureValue ( "downstream" ) ;
2025-10-02 17:09:24 +02:00
2025-06-25 17:26:13 +02:00
// Both upstream & downstream => differential
2026-02-19 17:36:44 +01:00
if ( upstreamPressure != null && downstreamPressure != null ) {
const pressureDiffValue = downstreamPressure - upstreamPressure ;
this . logger . debug ( ` Pressure differential: ${ pressureDiffValue } ` ) ;
this . predictFlow . fDimension = pressureDiffValue ;
this . predictPower . fDimension = pressureDiffValue ;
this . predictCtrl . fDimension = pressureDiffValue ;
2025-06-25 17:26:13 +02:00
//update the cog
const { cog , minEfficiency } = this . calcCog ( ) ;
// calc efficiency
const efficiency = this . calcEfficiency ( this . predictPower . outputY , this . predictFlow . outputY , "predicted" ) ;
//update the distance from peak
this . calcDistanceBEP ( efficiency , cog , minEfficiency ) ;
2026-02-19 17:36:44 +01:00
return pressureDiffValue ;
2025-06-25 17:26:13 +02:00
}
// Only downstream => use it, warn that it's partial
if ( downstreamPressure != null ) {
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
this . logger . warn ( ` Using downstream pressure only for prediction: ${ downstreamPressure } . Prediction accuracy is degraded; inject upstream pressure too. ` ) ;
2025-06-25 17:26:13 +02:00
this . predictFlow . fDimension = downstreamPressure ;
this . predictPower . fDimension = downstreamPressure ;
this . predictCtrl . fDimension = downstreamPressure ;
//update the cog
const { cog , minEfficiency } = this . calcCog ( ) ;
// calc efficiency
const efficiency = this . calcEfficiency ( this . predictPower . outputY , this . predictFlow . outputY , "predicted" ) ;
//update the distance from peak
this . calcDistanceBEP ( efficiency , cog , minEfficiency ) ;
return downstreamPressure ;
}
2026-02-19 17:36:44 +01:00
// Only upstream => use it, warn that it's partial
if ( upstreamPressure != null ) {
fix: interruptible shutdown/emergencystop + dual-curve test coverage
Runtime:
- executeSequence now normalizes sequenceName to lowercase so parent
orchestrators that use 'emergencyStop' (capital S) route correctly to
the 'emergencystop' sequence key. Closes the "Sequence 'emergencyStop'
not defined" warn seen when commands reach the node during accelerating.
- When a shutdown or emergencystop sequence is requested while the FSM is
in accelerating/decelerating, the active movement is aborted via
state.abortCurrentMovement() and the sequence waits (up to 2s) for the
FSM to return to 'operational' before proceeding. New helper
_waitForOperational listens on the state emitter for the transition.
- Single-side pressure warning: fix "acurate" typo and make the message
actionable.
Tests (+15, now 91/91 passing):
- test/integration/interruptible-movement.integration.test.js (+3):
shutdown during accelerating -> idle; emergencystop during accelerating
-> off; mixed-case sequence-name normalization.
- test/integration/curve-prediction.integration.test.js (+12):
parametrized across both shipped pump curves (hidrostal-H05K-S03R and
hidrostal-C5-D03R-SHN1). Verifies loader integrity, mid-range prediction
sanity, flow monotonicity in ctrl, inverse-pressure monotonicity, CoG
finiteness, and reverse-predictor round-trip.
E2E:
- test/e2e/curve-prediction-benchmark.py: live Dockerized Node-RED
benchmark that deploys one rotatingMachine per curve and runs a per-pump
(pressure x ctrl) sweep inside each curve's envelope. Reports envelope
compliance and monotonicity.
- test/e2e/README.md documents the benchmark and a known limitation:
pressure below the curve's minimum slice extrapolates wildly
(defended by upstream measurement-node clamping in production).
UX:
- rotatingMachine.html: added placeholders and descriptions for Reaction
Speed / Startup / Warmup / Shutdown / Cooldown. Expanded the Node-RED
help panel with a topic reference, port documentation, state diagram,
and prediction rules.
Docs:
- README.md rewritten (was a single line) with install, quick start,
topic/port reference, state machine, predictions, testing, production
status.
Depends on generalFunctions commit 75d16c6 (state.js abort recovery and
rotatingMachine schema additions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:48 +02:00
this . logger . warn ( ` Using upstream pressure only for prediction: ${ upstreamPressure } . Prediction accuracy is degraded; inject downstream pressure too. ` ) ;
2026-02-19 17:36:44 +01:00
this . predictFlow . fDimension = upstreamPressure ;
this . predictPower . fDimension = upstreamPressure ;
this . predictCtrl . fDimension = upstreamPressure ;
//update the cog
const { cog , minEfficiency } = this . calcCog ( ) ;
// calc efficiency
const efficiency = this . calcEfficiency ( this . predictPower . outputY , this . predictFlow . outputY , "predicted" ) ;
//update the distance from peak
this . calcDistanceBEP ( efficiency , cog , minEfficiency ) ;
return upstreamPressure ;
}
2025-06-25 17:26:13 +02:00
this . logger . error ( ` No valid pressure measurements available to calculate prediction using last known pressure ` ) ;
//set default at 0 => lowest pressure possible
this . predictFlow . fDimension = 0 ;
this . predictPower . fDimension = 0 ;
this . predictCtrl . fDimension = 0 ;
//update the cog
const { cog , minEfficiency } = this . calcCog ( ) ;
// calc efficiency
const efficiency = this . calcEfficiency ( this . predictPower . outputY , this . predictFlow . outputY , "predicted" ) ;
//update the distance from peak
this . calcDistanceBEP ( efficiency , cog , minEfficiency ) ;
2025-11-27 17:46:56 +01:00
//place min and max flow capabilities in containerthis.predictFlow.currentFxyYMax - this.predictFlow.currentFxyYMin
2026-03-11 11:13:26 +01:00
this . measurements . type ( 'flow' ) . variant ( 'predicted' ) . position ( 'max' ) . value ( this . predictFlow . currentFxyYMax , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
this . measurements . type ( 'flow' ) . variant ( 'predicted' ) . position ( 'min' ) . value ( this . predictFlow . currentFxyYMin , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
2025-06-25 17:26:13 +02:00
return 0 ;
}
2026-02-19 17:36:44 +01:00
_getPreferredPressureValue ( position ) {
const realIds = Array . from ( this . realPressureChildIds [ position ] || [ ] ) ;
for ( const childId of realIds ) {
const value = this . measurements
. type ( "pressure" )
. variant ( "measured" )
. position ( position )
. child ( childId )
. getCurrentValue ( ) ;
if ( value != null ) return value ;
}
const virtualId = this . virtualPressureChildIds [ position ] ;
if ( virtualId ) {
const simulatedValue = this . measurements
. type ( "pressure" )
. variant ( "measured" )
. position ( position )
. child ( virtualId )
. getCurrentValue ( ) ;
if ( simulatedValue != null ) return simulatedValue ;
}
return this . measurements
. type ( "pressure" )
. variant ( "measured" )
. position ( position )
. getCurrentValue ( ) ;
}
getPressureInitializationStatus ( ) {
const upstreamPressure = this . _getPreferredPressureValue ( "upstream" ) ;
const downstreamPressure = this . _getPreferredPressureValue ( "downstream" ) ;
const hasUpstream = upstreamPressure != null ;
const hasDownstream = downstreamPressure != null ;
const hasDifferential = hasUpstream && hasDownstream ;
return {
hasUpstream ,
hasDownstream ,
hasDifferential ,
initialized : hasUpstream || hasDownstream || hasDifferential ,
source : hasDifferential ? 'differential' : hasDownstream ? 'downstream' : hasUpstream ? 'upstream' : null ,
} ;
}
updateSimulatedMeasurement ( type , position , value , context = { } ) {
const normalizedType = String ( type || "" ) . toLowerCase ( ) ;
const normalizedPosition = String ( position || "atEquipment" ) . toLowerCase ( ) ;
if ( normalizedType !== "pressure" ) {
this . _callMeasurementHandler ( normalizedType , value , normalizedPosition , context ) ;
return ;
}
if ( ! this . virtualPressureChildIds [ normalizedPosition ] ) {
this . logger . warn ( ` Unsupported simulated pressure position ' ${ normalizedPosition } ' ` ) ;
return ;
}
const child = this . virtualPressureChildren [ normalizedPosition ] ;
if ( ! child ? . measurements ) {
this . logger . error ( ` Virtual pressure child ' ${ normalizedPosition } ' is missing ` ) ;
return ;
}
2026-03-11 11:13:26 +01:00
let measurementUnit ;
try {
measurementUnit = this . _resolveMeasurementUnit ( 'pressure' , context . unit ) ;
} catch ( error ) {
this . logger . warn ( ` Rejected simulated pressure measurement: ${ error . message } ` ) ;
return ;
}
2026-02-19 17:36:44 +01:00
child . measurements
. type ( "pressure" )
. variant ( "measured" )
. position ( normalizedPosition )
2026-03-11 11:13:26 +01:00
. value ( value , context . timestamp || Date . now ( ) , measurementUnit ) ;
2026-02-19 17:36:44 +01:00
}
2025-06-25 17:26:13 +02:00
handleMeasuredFlow ( ) {
const flowDiff = this . measurements . type ( 'flow' ) . variant ( 'measured' ) . difference ( ) ;
// If both are present
if ( flowDiff != null ) {
// In theory, mass flow in = mass flow out, so they should match or be close.
if ( flowDiff . value < 0.001 ) {
// flows match within tolerance
this . logger . debug ( ` Flow match: ${ flowDiff . value } ` ) ;
return flowDiff . value ;
} else {
// Mismatch => decide how to handle. Maybe take the average?
// Or bail out with an error. Example: we bail out here.
this . logger . error ( ` Something wrong with down or upstream flow measurement. Bailing out! ` ) ;
return null ;
}
}
// get
2025-07-02 16:00:52 +02:00
const upstreamFlow = this . measurements . type ( 'flow' ) . variant ( 'measured' ) . position ( 'upstream' ) . getCurrentValue ( ) ;
2025-06-25 17:26:13 +02:00
// Only upstream => might still accept it, but warn
if ( upstreamFlow != null ) {
this . logger . warn ( ` Only upstream flow is present. Using it but results may be incomplete! ` ) ;
return upstreamFlow ;
}
// get
2025-07-02 16:00:52 +02:00
const downstreamFlow = this . measurements . type ( 'flow' ) . variant ( 'measured' ) . position ( 'downstream' ) . getCurrentValue ( ) ;
2025-06-25 17:26:13 +02:00
// Only downstream => might still accept it, but warn
if ( downstreamFlow != null ) {
this . logger . warn ( ` Only downstream flow is present. Using it but results may be incomplete! ` ) ;
return downstreamFlow ;
}
// Neither => error
this . logger . error ( ` No upstream or downstream flow measurement. Bailing out! ` ) ;
return null ;
}
handleMeasuredPower ( ) {
2025-08-07 13:52:06 +02:00
const power = this . measurements . type ( "power" ) . variant ( "measured" ) . position ( "atEquipment" ) . getCurrentValue ( ) ;
2025-06-25 17:26:13 +02:00
// If your system calls it "upstream" or just a single "value", adjust accordingly
if ( power != null ) {
this . logger . debug ( ` Measured power: ${ power } ` ) ;
return power ;
} else {
this . logger . error ( ` No measured power found. Bailing out! ` ) ;
return null ;
}
}
2026-01-29 13:32:39 +01:00
updateMeasuredTemperature ( value , position , context = { } ) {
this . logger . debug ( ` Temperature update: ${ value } at ${ position } from ${ context . childName || 'child' } ( ${ context . childId || 'unknown-id' } ) ` ) ;
2026-03-11 11:13:26 +01:00
let measurementUnit ;
try {
measurementUnit = this . _resolveMeasurementUnit ( 'temperature' , context . unit ) ;
} catch ( error ) {
this . logger . warn ( ` Rejected temperature update: ${ error . message } ` ) ;
return ;
}
this . measurements . type ( "temperature" ) . variant ( "measured" ) . position ( position || 'atEquipment' ) . child ( context . childId ) . value ( value , context . timestamp , measurementUnit ) ;
2026-01-29 13:32:39 +01:00
}
2025-10-07 18:10:45 +02:00
// context handler for pressure updates
2025-08-07 13:52:06 +02:00
updateMeasuredPressure ( value , position , context = { } ) {
2025-10-02 17:09:24 +02:00
2025-08-07 13:52:06 +02:00
this . logger . debug ( ` Pressure update: ${ value } at ${ position } from ${ context . childName || 'child' } ( ${ context . childId || 'unknown-id' } ) ` ) ;
2026-03-11 11:13:26 +01:00
let measurementUnit ;
try {
measurementUnit = this . _resolveMeasurementUnit ( 'pressure' , context . unit ) ;
} catch ( error ) {
this . logger . warn ( ` Rejected pressure update: ${ error . message } ` ) ;
return ;
}
2025-08-07 13:52:06 +02:00
2025-10-07 18:10:45 +02:00
// Store in parent's measurement container
2026-03-11 11:13:26 +01:00
this . measurements . type ( "pressure" ) . variant ( "measured" ) . position ( position ) . child ( context . childId ) . value ( value , context . timestamp , measurementUnit ) ;
2025-08-08 14:29:15 +02:00
// Determine what kind of value to use as pressure (upstream , downstream or difference)
2025-08-07 13:52:06 +02:00
const pressure = this . getMeasuredPressure ( ) ;
this . updatePosition ( ) ;
2026-03-11 11:13:26 +01:00
this . _updatePressureDriftStatus ( ) ;
this . _updatePredictionHealth ( ) ;
2025-08-07 13:52:06 +02:00
this . logger . debug ( ` Using pressure: ${ pressure } for calculations ` ) ;
}
2025-06-25 17:26:13 +02:00
2025-09-23 15:51:16 +02:00
// NEW: Flow handler
2025-08-07 13:52:06 +02:00
updateMeasuredFlow ( value , position , context = { } ) {
if ( ! this . _isOperationalState ( ) ) {
this . logger . warn ( ` Machine not operational, skipping flow update from ${ context . childName || 'unknown' } ` ) ;
return ;
}
2025-06-25 17:26:13 +02:00
2025-08-07 13:52:06 +02:00
this . logger . debug ( ` Flow update: ${ value } at ${ position } from ${ context . childName || 'child' } ` ) ;
2026-03-11 11:13:26 +01:00
let measurementUnit ;
try {
measurementUnit = this . _resolveMeasurementUnit ( 'flow' , context . unit ) ;
} catch ( error ) {
this . logger . warn ( ` Rejected flow update: ${ error . message } ` ) ;
return ;
}
2025-08-07 13:52:06 +02:00
// Store in parent's measurement container
2026-03-11 11:13:26 +01:00
this . measurements . type ( "flow" ) . variant ( "measured" ) . position ( position ) . child ( context . childId ) . value ( value , context . timestamp , measurementUnit ) ;
2025-08-07 13:52:06 +02:00
// Update predicted flow if you have prediction capability
if ( this . predictFlow ) {
2026-03-11 11:13:26 +01:00
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "downstream" ) . value ( this . predictFlow . outputY || 0 , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
this . measurements . type ( "flow" ) . variant ( "predicted" ) . position ( "atEquipment" ) . value ( this . predictFlow . outputY || 0 , Date . now ( ) , this . unitPolicy . canonical . flow ) ;
}
const measuredCanonical = this . measurements
. type ( "flow" )
. variant ( "measured" )
. position ( position )
. getCurrentValue ( this . unitPolicy . canonical . flow ) ;
this . _updateMetricDrift ( "flow" , measuredCanonical , context ) ;
this . _updatePredictionHealth ( ) ;
}
updateMeasuredPower ( value , position , context = { } ) {
if ( ! this . _isOperationalState ( ) ) {
this . logger . warn ( ` Machine not operational, skipping power update from ${ context . childName || 'unknown' } ` ) ;
return ;
2025-06-25 17:26:13 +02:00
}
2026-03-11 11:13:26 +01:00
this . logger . debug ( ` Power update: ${ value } at ${ position } from ${ context . childName || 'child' } ` ) ;
let measurementUnit ;
try {
measurementUnit = this . _resolveMeasurementUnit ( 'power' , context . unit ) ;
} catch ( error ) {
this . logger . warn ( ` Rejected power update: ${ error . message } ` ) ;
return ;
}
this . measurements . type ( "power" ) . variant ( "measured" ) . position ( position ) . child ( context . childId ) . value ( value , context . timestamp , measurementUnit ) ;
if ( this . predictPower ) {
this . measurements . type ( "power" ) . variant ( "predicted" ) . position ( "atEquipment" ) . value ( this . predictPower . outputY || 0 , Date . now ( ) , this . unitPolicy . canonical . power ) ;
}
const measuredCanonical = this . measurements
. type ( "power" )
. variant ( "measured" )
. position ( position )
. getCurrentValue ( this . unitPolicy . canonical . power ) ;
this . _updateMetricDrift ( "power" , measuredCanonical , context ) ;
this . _updatePredictionHealth ( ) ;
2025-06-25 17:26:13 +02:00
}
2025-08-07 13:52:06 +02:00
// Helper method for operational state check
_isOperationalState ( ) {
const state = this . state . getCurrentState ( ) ;
2026-02-19 17:36:44 +01:00
const activeStates = [ "operational" , "warmingup" , "accelerating" , "decelerating" ] ;
this . logger . debug ( ` Checking operational state ${ this . state . getCurrentState ( ) } ? ${ activeStates . includes ( state ) } ` ) ;
return activeStates . includes ( state ) ;
2025-06-25 17:26:13 +02:00
}
//what is the internal functions that need updating when something changes that has influence on this.
updatePosition ( ) {
2025-10-02 17:09:24 +02:00
2025-08-07 13:52:06 +02:00
if ( this . _isOperationalState ( ) ) {
2025-06-25 17:26:13 +02:00
const currentPosition = this . state . getCurrentPosition ( ) ;
// Update the predicted values based on the new position
const { cPower , cFlow } = this . calcFlowPower ( currentPosition ) ;
// Calc predicted efficiency
const efficiency = this . calcEfficiency ( cPower , cFlow , "predicted" ) ;
//update the cog
const { cog , minEfficiency } = this . calcCog ( ) ;
//update the distance from peak
this . calcDistanceBEP ( efficiency , cog , minEfficiency ) ;
}
2025-10-31 18:35:40 +01:00
2026-03-11 11:13:26 +01:00
this . _updatePredictionHealth ( ) ;
2025-06-25 17:26:13 +02:00
}
calcDistanceFromPeak ( currentEfficiency , peakEfficiency ) {
return Math . abs ( currentEfficiency - peakEfficiency ) ;
}
calcRelativeDistanceFromPeak ( currentEfficiency , maxEfficiency , minEfficiency ) {
let distance = 1 ;
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( currentEfficiency != null && maxEfficiency !== minEfficiency ) {
2025-06-25 17:26:13 +02:00
distance = this . interpolation . interpolate _lin _single _point ( currentEfficiency , maxEfficiency , minEfficiency , 0 , 1 ) ;
}
return distance ;
}
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
showCoG ( ) {
if ( ! this . hasCurve ) {
return { error : 'No curve data available' , cog : 0 , NCog : 0 , cogIndex : 0 } ;
}
const { cog , cogIndex , NCog , minEfficiency } = this . calcCog ( ) ;
return {
cog ,
cogIndex ,
NCog ,
NCogPercent : Math . round ( NCog * 100 * 100 ) / 100 ,
minEfficiency ,
currentEfficiencyCurve : this . currentEfficiencyCurve ,
absDistFromPeak : this . absDistFromPeak ,
relDistFromPeak : this . relDistFromPeak ,
} ;
}
2025-07-24 13:15:33 +02:00
showWorkingCurves ( ) {
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( ! this . hasCurve ) {
return { error : 'No curve data available' } ;
}
2025-07-24 13:15:33 +02:00
// Show the current curves for debugging
const { powerCurve , flowCurve } = this . getCurrentCurves ( ) ;
return {
powerCurve : powerCurve ,
flowCurve : flowCurve ,
cog : this . cog ,
cogIndex : this . cogIndex ,
NCog : this . NCog ,
minEfficiency : this . minEfficiency ,
currentEfficiencyCurve : this . currentEfficiencyCurve ,
absDistFromPeak : this . absDistFromPeak ,
relDistFromPeak : this . relDistFromPeak
} ;
}
2025-06-25 17:26:13 +02:00
// Calculate the center of gravity for current pressure
calcCog ( ) {
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( ! this . hasCurve || ! this . predictFlow || ! this . predictPower ) {
return { cog : 0 , cogIndex : 0 , NCog : 0 , minEfficiency : 0 } ;
}
2025-06-25 17:26:13 +02:00
//fetch current curve data for power and flow
const { powerCurve , flowCurve } = this . getCurrentCurves ( ) ;
const { efficiencyCurve , peak , peakIndex , minEfficiency } = this . calcEfficiencyCurve ( powerCurve , flowCurve ) ;
// Calculate the normalized center of gravity
2025-11-20 22:29:24 +01:00
const NCog = ( flowCurve . y [ peakIndex ] - this . predictFlow . currentFxyYMin ) / ( this . predictFlow . currentFxyYMax - this . predictFlow . currentFxyYMin ) ; //
2025-06-25 17:26:13 +02:00
//store in object for later retrieval
this . currentEfficiencyCurve = efficiencyCurve ;
this . cog = peak ;
this . cogIndex = peakIndex ;
this . NCog = NCog ;
this . minEfficiency = minEfficiency ;
return { cog : peak , cogIndex : peakIndex , NCog : NCog , minEfficiency : minEfficiency } ;
}
calcEfficiencyCurve ( powerCurve , flowCurve ) {
const efficiencyCurve = [ ] ;
let peak = 0 ;
let peakIndex = 0 ;
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
let minEfficiency = Infinity ;
2025-06-25 17:26:13 +02:00
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( ! powerCurve ? . y ? . length || ! flowCurve ? . y ? . length ) {
return { efficiencyCurve : [ ] , peak : 0 , peakIndex : 0 , minEfficiency : 0 } ;
}
2025-06-25 17:26:13 +02:00
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
// Specific flow ratio (Q/P): for variable-speed centrifugal pumps this is
// monotonically decreasing (P scales ~Q³ by affinity laws), so the peak is
// always at minimum flow and NCog = 0. The MGC BEP-Gravitation algorithm
// compensates via slope-based redistribution which IS sensitive to curve shape.
powerCurve . y . forEach ( ( power , index ) => {
2025-06-25 17:26:13 +02:00
const flow = flowCurve . y [ index ] ;
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
const eff = ( power > 0 && flow >= 0 ) ? flow / power : 0 ;
efficiencyCurve . push ( eff ) ;
2025-06-25 17:26:13 +02:00
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( eff > peak ) {
peak = eff ;
peakIndex = index ;
}
if ( eff < minEfficiency ) {
minEfficiency = eff ;
}
2025-06-25 17:26:13 +02:00
} ) ;
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( ! Number . isFinite ( minEfficiency ) ) minEfficiency = 0 ;
2025-06-25 17:26:13 +02:00
return { efficiencyCurve , peak , peakIndex , minEfficiency } ;
}
//calc flow power based on pressure and current position
calcFlowPower ( x ) {
// Calculate flow and power
const cFlow = this . calcFlow ( x ) ;
const cPower = this . calcPower ( x ) ;
return { cPower , cFlow } ;
}
2025-11-12 17:40:38 +01:00
calcEfficiency ( power , flow , variant ) {
2026-02-23 13:17:18 +01:00
// Request a pressure differential explicitly in Pascal for hydraulic efficiency.
const pressureDiff = this . measurements
. type ( 'pressure' )
. variant ( 'measured' )
. difference ( { unit : 'Pa' } ) ;
2025-11-12 17:40:38 +01:00
const g = gravity . getStandardGravity ( ) ;
const temp = this . measurements . type ( 'temperature' ) . variant ( 'measured' ) . position ( 'atEquipment' ) . getCurrentValue ( 'K' ) ;
2025-11-28 09:59:51 +01:00
const atmPressure = this . measurements . type ( 'atmPressure' ) . variant ( 'measured' ) . position ( 'atEquipment' ) . getCurrentValue ( 'Pa' ) ;
2026-02-12 10:48:44 +01:00
let rho = null ;
try {
rho = coolprop . PropsSI ( 'D' , 'T' , temp , 'P' , atmPressure , 'WasteWater' ) ;
} catch ( error ) {
// coolprop can throw transient initialization errors; keep machine calculations running.
this . logger . warn ( ` CoolProp density lookup failed: ${ error . message } . Using fallback density. ` ) ;
rho = 1000 ; // kg/m3 fallback for water-like fluids
}
2025-11-12 17:40:38 +01:00
this . logger . debug ( ` temp: ${ temp } atmPressure : ${ atmPressure } rho : ${ rho } pressureDiff: ${ pressureDiff ? . value || 0 } ` ) ;
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
const flowM3s = this . measurements . type ( 'flow' ) . variant ( variant ) . position ( 'atEquipment' ) . getCurrentValue ( 'm3/s' ) ;
const powerWatt = this . measurements . type ( 'power' ) . variant ( variant ) . position ( 'atEquipment' ) . getCurrentValue ( 'W' ) ;
2025-11-12 17:40:38 +01:00
this . logger . debug ( ` Flow : ${ flowM3s } power: ${ powerWatt } ` ) ;
2025-06-25 17:26:13 +02:00
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( power > 0 && flow > 0 ) {
2025-11-12 17:40:38 +01:00
const specificFlow = flow / power ;
const specificEnergyConsumption = power / flow ;
this . measurements . type ( "efficiency" ) . variant ( variant ) . position ( 'atEquipment' ) . value ( specificFlow ) ;
this . measurements . type ( "specificEnergyConsumption" ) . variant ( variant ) . position ( 'atEquipment' ) . value ( specificEnergyConsumption ) ;
2026-03-11 11:13:26 +01:00
if ( pressureDiff ? . value != null && Number . isFinite ( flowM3s ) && Number . isFinite ( powerWatt ) && powerWatt > 0 ) {
// Engineering references: P_h = Q * Δp = ρ g Q H, η_h = P_h / P_in
const pressureDiffPa = Number ( pressureDiff . value ) ;
const headMeters = ( Number . isFinite ( rho ) && rho > 0 ) ? pressureDiffPa / ( rho * g ) : null ;
const hydraulicPowerW = pressureDiffPa * flowM3s ;
const nHydraulicEfficiency = hydraulicPowerW / powerWatt ;
if ( Number . isFinite ( headMeters ) ) {
this . measurements . type ( "pumpHead" ) . variant ( variant ) . position ( 'atEquipment' ) . value ( headMeters , Date . now ( ) , 'm' ) ;
}
this . measurements . type ( "hydraulicPower" ) . variant ( variant ) . position ( 'atEquipment' ) . value ( hydraulicPowerW , Date . now ( ) , 'W' ) ;
2025-11-12 17:40:38 +01:00
this . measurements . type ( "nHydraulicEfficiency" ) . variant ( variant ) . position ( 'atEquipment' ) . value ( nHydraulicEfficiency ) ;
}
}
2025-06-25 17:26:13 +02:00
2025-11-12 17:40:38 +01:00
//change this to nhydrefficiency ?
2025-10-02 17:09:24 +02:00
return this . measurements . type ( "efficiency" ) . variant ( variant ) . position ( 'atEquipment' ) . getCurrentValue ( ) ;
2025-06-25 17:26:13 +02:00
}
updateCurve ( newCurve ) {
this . logger . info ( ` Updating machine curve ` ) ;
2026-03-11 11:13:26 +01:00
const normalizedCurve = this . _normalizeMachineCurve ( newCurve ) ;
const newConfig = {
asset : {
machineCurve : normalizedCurve ,
curveUnits : this . unitPolicy . curve ,
} ,
} ;
2025-06-25 17:26:13 +02:00
//validate input of new curve fed to the machine
this . config = this . configUtils . updateConfig ( this . config , newConfig ) ;
//After we passed validation load the curves into their predictors
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( ! this . predictFlow || ! this . predictPower || ! this . predictCtrl ) {
this . predictFlow = new predict ( { curve : this . config . asset . machineCurve . nq } ) ;
this . predictPower = new predict ( { curve : this . config . asset . machineCurve . np } ) ;
this . predictCtrl = new predict ( { curve : this . reverseCurve ( this . config . asset . machineCurve . nq ) } ) ;
this . hasCurve = true ;
} else {
this . predictFlow . updateCurve ( this . config . asset . machineCurve . nq ) ;
this . predictPower . updateCurve ( this . config . asset . machineCurve . np ) ;
this . predictCtrl . updateCurve ( this . reverseCurve ( this . config . asset . machineCurve . nq ) ) ;
}
2025-06-25 17:26:13 +02:00
}
getCompleteCurve ( ) {
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( ! this . hasCurve || ! this . predictPower || ! this . predictFlow ) {
return { powerCurve : null , flowCurve : null } ;
}
2025-06-25 17:26:13 +02:00
const powerCurve = this . predictPower . inputCurveData ;
const flowCurve = this . predictFlow . inputCurveData ;
return { powerCurve , flowCurve } ;
}
getCurrentCurves ( ) {
fix: production hardening — safety fixes, prediction accuracy, test coverage
Safety:
- Async input handler: await all handleInput() calls, prevents unhandled rejections
- Fix emergencyStop case mismatch: "emergencyStop" → "emergencystop" matching config
- Implement showCoG() method (was routing to undefined)
- Null guards on 6 methods for missing curve data
- Editor menu polling timeout (5s max)
- Listener cleanup on node close (child measurements + state emitter)
- Tick loop race condition: track startup timeout, clear on close
Prediction accuracy:
- Remove efficiency rounding that destroyed signal in canonical units
- Fix calcEfficiency variant: hydraulic power reads from correct variant
- Guard efficiency calculations against negative/zero values
- Division-by-zero protection in calcRelativeDistanceFromPeak
- Curve data anomaly detection (cross-pressure median-y ratio check)
- calcEfficiencyCurve O(n²) → O(n) with running min
- updateCurve bootstraps predictors when they were null
Tests: 43 new tests (76 total) covering emergency stop, shutdown/maintenance
sequences, efficiency/CoG, movement lifecycle, output format, null guards,
and listener cleanup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:41:00 +02:00
if ( ! this . hasCurve || ! this . predictPower || ! this . predictFlow ) {
return { powerCurve : { x : [ ] , y : [ ] } , flowCurve : { x : [ ] , y : [ ] } } ;
}
2025-06-25 17:26:13 +02:00
const powerCurve = this . predictPower . currentFxyCurve [ this . predictPower . currentF ] ;
const flowCurve = this . predictFlow . currentFxyCurve [ this . predictFlow . currentF ] ;
return { powerCurve , flowCurve } ;
}
calcDistanceBEP ( efficiency , maxEfficiency , minEfficiency ) {
const absDistFromPeak = this . calcDistanceFromPeak ( efficiency , maxEfficiency ) ;
const relDistFromPeak = this . calcRelativeDistanceFromPeak ( efficiency , maxEfficiency , minEfficiency ) ;
//store internally
this . absDistFromPeak = absDistFromPeak ;
this . relDistFromPeak = relDistFromPeak ;
return { absDistFromPeak : absDistFromPeak , relDistFromPeak : relDistFromPeak } ;
}
getOutput ( ) {
// Improved output object generation
2025-11-05 15:47:39 +01:00
2026-03-11 11:13:26 +01:00
const output = this . measurements . getFlattenedOutput ( {
requestedUnits : this . unitPolicy . output ,
} ) ;
2025-06-25 17:26:13 +02:00
//fill in the rest of the output object
output [ "state" ] = this . state . getCurrentState ( ) ;
output [ "runtime" ] = this . state . getRunTimeHours ( ) ;
output [ "ctrl" ] = this . state . getCurrentPosition ( ) ;
output [ "moveTimeleft" ] = this . state . getMoveTimeLeft ( ) ;
output [ "mode" ] = this . currentMode ;
output [ "cog" ] = this . cog ; // flow / power efficiency
output [ "NCog" ] = this . NCog ; // normalized cog
output [ "NCogPercent" ] = Math . round ( this . NCog * 100 * 100 ) / 100 ;
2025-11-05 15:47:39 +01:00
output [ "maintenanceTime" ] = this . state . getMaintenanceTimeHours ( ) ;
2025-06-25 17:26:13 +02:00
if ( this . flowDrift != null ) {
const flowDrift = this . flowDrift ;
output [ "flowNrmse" ] = flowDrift . nrmse ;
output [ "flowLongterNRMSD" ] = flowDrift . longTermNRMSD ;
2026-03-11 11:13:26 +01:00
output [ "flowLongTermNRMSD" ] = flowDrift . longTermNRMSD ;
2025-06-25 17:26:13 +02:00
output [ "flowImmediateLevel" ] = flowDrift . immediateLevel ;
output [ "flowLongTermLevel" ] = flowDrift . longTermLevel ;
2026-03-11 11:13:26 +01:00
output [ "flowDriftValid" ] = flowDrift . valid ;
2025-06-25 17:26:13 +02:00
}
2026-03-11 11:13:26 +01:00
if ( this . powerDrift != null ) {
const powerDrift = this . powerDrift ;
output [ "powerNrmse" ] = powerDrift . nrmse ;
output [ "powerLongTermNRMSD" ] = powerDrift . longTermNRMSD ;
output [ "powerImmediateLevel" ] = powerDrift . immediateLevel ;
output [ "powerLongTermLevel" ] = powerDrift . longTermLevel ;
output [ "powerDriftValid" ] = powerDrift . valid ;
}
output [ "pressureDriftLevel" ] = this . pressureDrift . level ;
output [ "pressureDriftSource" ] = this . pressureDrift . source ;
output [ "pressureDriftFlags" ] = this . pressureDrift . flags ;
output [ "predictionQuality" ] = this . predictionHealth . quality ;
output [ "predictionConfidence" ] = Math . round ( this . predictionHealth . confidence * 1000 ) / 1000 ;
output [ "predictionPressureSource" ] = this . predictionHealth . pressureSource ;
output [ "predictionFlags" ] = this . predictionHealth . flags ;
2025-06-25 17:26:13 +02:00
//should this all go in the container of measurements?
output [ "effDistFromPeak" ] = this . absDistFromPeak ;
output [ "effRelDistFromPeak" ] = this . relDistFromPeak ;
//this.logger.debug(`Output: ${JSON.stringify(output)}`);
return output ;
}
} // end of class
module . exports = Machine ;