2025-07-01 17:03:36 +02:00
//load local dependencies
const EventEmitter = require ( "events" ) ;
2026-03-31 18:17:41 +02:00
const { logger , configUtils , configManager , MeasurementContainer , interpolation , childRegistrationUtils , convert , POSITIONS } = require ( 'generalFunctions' ) ;
2026-03-11 11:12:52 +01:00
const CANONICAL _UNITS = Object . freeze ( {
pressure : 'Pa' ,
flow : 'm3/s' ,
power : 'W' ,
temperature : 'K' ,
} ) ;
const DEFAULT _IO _UNITS = Object . freeze ( {
pressure : 'mbar' ,
flow : 'm3/h' ,
power : 'kW' ,
temperature : 'C' ,
} ) ;
2025-07-01 17:03:36 +02:00
2026-02-23 13:17:39 +01:00
/ * *
* Machine group controller domain model .
* Aggregates multiple rotating machines and coordinates group - level optimization / control .
* /
2025-07-01 17:03:36 +02:00
class MachineGroup {
constructor ( machineGroupConfig = { } ) {
this . emitter = new EventEmitter ( ) ; // Own EventEmitter
this . configManager = new configManager ( ) ; // Config manager to handle dynamic config loading
this . defaultConfig = this . configManager . getConfig ( 'machineGroupControl' ) ; // Load default config for rotating machine ( use software type name ? )
this . configUtils = new configUtils ( this . defaultConfig ) ; // this will handle the config endpoints so we can load them dynamically
this . config = this . configUtils . initConfig ( machineGroupConfig ) ; // verify and set the config for the machine group
2026-03-11 11:12:52 +01:00
this . unitPolicy = this . _buildUnitPolicy ( this . config ) ;
this . config = this . configUtils . updateConfig ( this . config , {
general : {
unit : this . unitPolicy . output . flow ,
}
} ) ;
2025-07-01 17:03:36 +02:00
// Init after config is set
this . logger = new logger ( this . config . general . logging . enabled , this . config . general . logging . logLevel , this . config . general . name ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
// Initialize measurements
2025-11-13 19:39:32 +01:00
this . measurements = new MeasurementContainer ( {
2026-03-11 11:12:52 +01:00
autoConvert : true ,
windowSize : 50 ,
defaultUnits : {
pressure : this . unitPolicy . output . pressure ,
flow : this . unitPolicy . output . flow ,
power : this . unitPolicy . output . power ,
temperature : this . unitPolicy . output . temperature
} ,
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' , 'flow' , 'power' , 'temperature' ]
2025-11-13 19:39:32 +01:00
} ) ;
2025-07-01 17:03:36 +02:00
this . interpolation = new interpolation ( ) ;
2025-07-31 09:10:34 +02:00
// Machines and child data
2025-07-01 17:03:36 +02:00
this . machines = { } ;
this . child = { } ;
this . scaling = this . config . scaling . current ;
this . mode = this . config . mode . current ;
this . absDistFromPeak = 0 ;
this . relDistFromPeak = 0 ;
// Combination curve data
this . dynamicTotals = { flow : { min : Infinity , max : 0 } , power : { min : Infinity , max : 0 } , NCog : 0 } ;
this . absoluteTotals = { flow : { min : Infinity , max : 0 } , power : { min : Infinity , max : 0 } } ;
2026-05-09 09:14:59 +02:00
// Dispatch serialization. PS ticks demand into MGC at 1 Hz, but
// a real pump ramp takes several seconds — without this gate
// every PS tick aborts the in-flight dispatch and starts a new
// one, so pumps never reach their setpoint. Mirrors
// rotatingMachine state.delayedMove: while a dispatch is in
// flight the latest demand is parked here for pickup when the
// current dispatch settles. Latest-wins.
this . _dispatchInFlight = false ;
this . _delayedCall = null ;
2025-07-01 17:03:36 +02:00
//this always last in the constructor
this . childRegistrationUtils = new childRegistrationUtils ( this ) ;
this . logger . info ( "MachineGroup initialized." ) ;
}
2025-09-04 17:07:18 +02:00
registerChild ( child , softwareType ) {
this . logger . debug ( 'Setting up childs specific for this class' ) ;
2025-11-13 19:39:32 +01:00
2026-02-23 13:17:39 +01:00
// Prefer functionality-scoped position metadata; keep general fallback for legacy nodes.
const position = child . config ? . functionality ? . positionVsParent || child . config ? . general ? . positionVsParent ;
2026-03-31 18:17:41 +02:00
2025-09-04 17:07:18 +02:00
if ( softwareType == "machine" ) {
// Check if the machine is already registered
this . machines [ child . config . general . id ] === undefined ? this . machines [ child . config . general . id ] = child : this . logger . warn ( ` Machine ${ child . config . general . id } is already registered. ` ) ;
2026-03-31 18:17:41 +02:00
2025-09-23 15:50:40 +02:00
//listen for machine pressure changes
this . logger . debug ( ` Listening for pressure changes from machine ${ child . config . general . id } ` ) ;
child . measurements . emitter . on ( "pressure.measured.differential" , ( eventData ) => {
2025-10-02 17:08:41 +02:00
this . logger . debug ( ` Pressure update from ${ child . config . general . id } : ${ eventData . value } ${ eventData . unit } ` ) ;
this . handlePressureChange ( ) ;
} ) ;
child . measurements . emitter . on ( "pressure.measured.downstream" , ( eventData ) => {
this . logger . debug ( ` Pressure update from ${ child . config . general . id } : ${ eventData . value } ${ eventData . unit } ` ) ;
this . handlePressureChange ( ) ;
} ) ;
child . measurements . emitter . on ( "flow.predicted.downstream" , ( eventData ) => {
this . logger . debug ( ` Flow prediction update from ${ child . config . general . id } : ${ eventData . value } ${ eventData . unit } ` ) ;
//later change to this.handleFlowPredictionChange();
this . handlePressureChange ( ) ;
2025-09-23 15:50:40 +02:00
} ) ;
2025-09-23 11:19:22 +02:00
2025-10-02 17:08:41 +02:00
2026-05-08 11:19:47 +02:00
} else if ( softwareType === "measurement" ) {
// Header-side measurement (e.g. discharge-manifold pressure
// sensor at MGC's downstream, suction-manifold sensor at
// upstream). Subscribed at the group level so optimalControl
// can use ONE header operating point for all pumps instead of
// each pump's individual reading. Without this, small per-pump
// pressure differences make the BEP-Gravitation optimum flip
// between near-equivalent combinations every tick → flap.
const measurementType = child . config ? . asset ? . type ;
if ( ! measurementType || ! position ) {
this . logger . warn ( ` Measurement child ${ child . config ? . general ? . id } missing asset.type or positionVsParent — skipping ` ) ;
return ;
}
const eventName = ` ${ measurementType } .measured. ${ position } ` ;
this . logger . debug ( ` Listening for ${ eventName } from measurement ${ child . config . general . id } ` ) ;
child . measurements . emitter . on ( eventName , ( eventData = { } ) => {
this . measurements
. type ( measurementType )
. variant ( "measured" )
. position ( position )
. value ( eventData . value , eventData . timestamp , eventData . unit ) ;
// Header pressure changes are operating-point inputs to
// optimalControl — recompute combinations.
if ( measurementType === "pressure" ) this . handlePressureChange ( ) ;
} ) ;
2025-09-04 17:07:18 +02:00
}
}
2025-08-07 13:52:56 +02:00
2025-07-01 17:03:36 +02:00
calcAbsoluteTotals ( ) {
const absoluteTotals = { flow : { min : Infinity , max : 0 } , power : { min : Infinity , max : 0 } } ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
Object . values ( this . machines ) . forEach ( machine => {
const totals = { flow : { min : Infinity , max : 0 } , power : { min : Infinity , max : 0 } } ;
//fetch min flow ever seen over all machines
2026-03-12 16:43:29 +01:00
Object . entries ( machine . predictFlow . inputCurve ) . forEach ( ( [ pressure , xyCurve ] , _index ) => {
2025-07-01 17:03:36 +02:00
const minFlow = Math . min ( ... xyCurve . y ) ;
const maxFlow = Math . max ( ... xyCurve . y ) ;
const minPower = Math . min ( ... machine . predictPower . inputCurve [ pressure ] . y ) ;
const maxPower = Math . max ( ... machine . predictPower . inputCurve [ pressure ] . y ) ;
// min ever seen for 1 machine
if ( minFlow < totals . flow . min ) { totals . flow . min = minFlow ; }
if ( minPower < totals . power . min ) { totals . power . min = minPower ; }
if ( maxFlow > totals . flow . max ) { totals . flow . max = maxFlow ; }
if ( maxPower > totals . power . max ) { totals . power . max = maxPower ; }
} ) ;
2025-10-02 17:08:41 +02:00
2025-07-01 17:03:36 +02:00
//surplus machines for max flow and power
if ( totals . flow . min < absoluteTotals . flow . min ) { absoluteTotals . flow . min = totals . flow . min ; }
if ( totals . power . min < absoluteTotals . power . min ) { absoluteTotals . power . min = totals . power . min ; }
absoluteTotals . flow . max += totals . flow . max ;
absoluteTotals . power . max += totals . power . max ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
} ) ;
2026-03-31 18:17:41 +02:00
if ( absoluteTotals . flow . min === Infinity ) {
2025-10-02 17:08:41 +02:00
this . logger . warn ( ` Flow min ${ absoluteTotals . flow . min } is Infinity. Setting to 0. ` ) ;
absoluteTotals . flow . min = 0 ;
}
2026-03-31 18:17:41 +02:00
if ( absoluteTotals . power . min === Infinity ) {
2025-10-02 17:08:41 +02:00
this . logger . warn ( ` Power min ${ absoluteTotals . power . min } is Infinity. Setting to 0. ` ) ;
2026-03-31 18:17:41 +02:00
absoluteTotals . power . min = 0 ;
2025-10-02 17:08:41 +02:00
}
2026-03-31 18:17:41 +02:00
if ( absoluteTotals . flow . max === - Infinity ) {
2025-10-02 17:08:41 +02:00
this . logger . warn ( ` Flow max ${ absoluteTotals . flow . max } is -Infinity. Setting to 0. ` ) ;
2026-03-31 18:17:41 +02:00
absoluteTotals . flow . max = 0 ;
2025-10-02 17:08:41 +02:00
}
2026-03-31 18:17:41 +02:00
if ( absoluteTotals . power . max === - Infinity ) {
2025-10-02 17:08:41 +02:00
this . logger . warn ( ` Power max ${ absoluteTotals . power . max } is -Infinity. Setting to 0. ` ) ;
2026-03-31 18:17:41 +02:00
absoluteTotals . power . max = 0 ;
2025-10-02 17:08:41 +02:00
}
// Place data in object for external use
this . absoluteTotals = absoluteTotals ;
2025-07-01 17:03:36 +02:00
return absoluteTotals ;
}
2026-03-31 18:17:41 +02:00
//max and min current flow and power based on their actual pressure curve
2025-07-01 17:03:36 +02:00
calcDynamicTotals ( ) {
2025-10-02 17:08:41 +02:00
const dynamicTotals = { flow : { min : Infinity , max : 0 , act : 0 } , power : { min : Infinity , max : 0 , act : 0 } , NCog : 0 } ;
2025-07-01 17:03:36 +02:00
2025-09-23 15:50:40 +02:00
this . logger . debug ( ` \n --------- Calculating dynamic totals for ${ Object . keys ( this . machines ) . length } machines. @ current pressure settings : ---------- ` ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
Object . values ( this . machines ) . forEach ( machine => {
2025-11-13 19:39:32 +01:00
//skip machines without valid curve
if ( ! machine . hasCurve ) {
this . logger . error ( ` Machine ${ machine . config . general . id } does not have a valid curve. Skipping in dynamic totals calculation. ` ) ;
return ;
}
2025-09-23 15:50:40 +02:00
this . logger . debug ( ` Processing machine with id: ${ machine . config . general . id } ` ) ;
2026-05-08 11:19:47 +02:00
const gpf = this . _groupFlow ( machine ) ;
const gpp = this . _groupPower ( machine ) ;
this . logger . debug ( ` Group operating point: ${ JSON . stringify ( gpf . currentF ) } ` ) ;
2025-11-13 19:39:32 +01:00
2026-05-08 11:19:47 +02:00
//fetch min flow ever seen over all machines (at the group operating point)
const minFlow = gpf . currentFxyYMin ;
const maxFlow = gpf . currentFxyYMax ;
const minPower = gpp . currentFxyYMin ;
const maxPower = gpp . currentFxyYMax ;
2025-11-13 19:39:32 +01:00
2026-03-31 18:17:41 +02:00
const actFlow = this . _readChildMeasurement ( machine , "flow" , "predicted" , POSITIONS . DOWNSTREAM , this . unitPolicy . canonical . flow ) || 0 ;
const actPower = this . _readChildMeasurement ( machine , "power" , "predicted" , POSITIONS . AT _EQUIPMENT , this . unitPolicy . canonical . power ) || 0 ;
2025-10-02 17:08:41 +02:00
2026-05-08 11:19:47 +02:00
this . logger . debug ( ` Machine ${ machine . config . general . id } - Min Flow: ${ minFlow } , Max Flow: ${ maxFlow } , Min Power: ${ minPower } , Max Power: ${ maxPower } , NCog: ${ this . _groupNCog ( machine ) } ` ) ;
2025-07-01 17:03:36 +02:00
if ( minFlow < dynamicTotals . flow . min ) { dynamicTotals . flow . min = minFlow ; }
if ( minPower < dynamicTotals . power . min ) { dynamicTotals . power . min = minPower ; }
dynamicTotals . flow . max += maxFlow ;
dynamicTotals . power . max += maxPower ;
2025-10-02 17:08:41 +02:00
dynamicTotals . flow . act += actFlow ;
dynamicTotals . power . act += actPower ;
2025-07-01 17:03:36 +02:00
2026-05-08 11:19:47 +02:00
//fetch total Normalized Cog over all machines (group operating point)
dynamicTotals . NCog += this . _groupNCog ( machine ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
} ) ;
2025-10-02 17:08:41 +02:00
// Place data in object for external use
this . dynamicTotals = dynamicTotals ;
2025-07-01 17:03:36 +02:00
return dynamicTotals ;
}
activeTotals ( ) {
const totals = { flow : { min : 0 , max : 0 } , power : { min : 0 , max : 0 } , countActiveMachines : 0 } ;
Object . entries ( this . machines ) . forEach ( ( [ id , machine ] ) => {
this . logger . debug ( ` Processing machine with id: ${ id } ` ) ;
if ( this . isMachineActive ( id ) ) {
2026-05-08 11:19:47 +02:00
//fetch min flow ever seen over all machines (group operating point)
const minFlow = this . _groupFlow ( machine ) . currentFxyYMin ;
const maxFlow = this . _groupFlow ( machine ) . currentFxyYMax ;
const minPower = this . _groupPower ( machine ) . currentFxyYMin ;
const maxPower = this . _groupPower ( machine ) . currentFxyYMax ;
2025-07-01 17:03:36 +02:00
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
totals . flow . min += minFlow ;
totals . flow . max += maxFlow ;
totals . power . min += minPower ;
totals . power . max += maxPower ;
totals . countActiveMachines ++ ;
}
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
} ) ;
return totals ;
}
handlePressureChange ( ) {
2026-03-11 11:12:52 +01:00
this . logger . debug ( "Pressure change detected." ) ;
2026-05-08 11:19:47 +02:00
// Equalize before computing dynamicTotals so the cached value (read
// by optimalControl) reflects the consistent header operating point,
// not whichever per-pump sensor fired last.
this . _equalizeOperatingPoint ( ) ;
2025-10-02 17:08:41 +02:00
// Recalculate totals
2025-11-13 19:39:32 +01:00
const { flow , power } = this . calcDynamicTotals ( ) ;
2025-10-02 17:08:41 +02:00
this . logger . debug ( ` Dynamic Totals after pressure change - Flow: Min ${ flow . min } , Max ${ flow . max } , Act ${ flow . act } | Power: Min ${ power . min } , Max ${ power . max } , Act ${ power . act } ` ) ;
2026-03-31 18:17:41 +02:00
this . _writeMeasurement ( "flow" , "predicted" , POSITIONS . AT _EQUIPMENT , flow . act , this . unitPolicy . canonical . flow ) ;
2026-05-08 17:20:21 +02:00
// Mirror the aggregate flow onto DOWNSTREAM as well. PS subscribes to
// flow.predicted.downstream from MGC and uses it as the outflow
// estimate for net-flow computation. Without this mirror, the only
// place DOWNSTREAM gets written is optimalControl's bestFlow (the
// optimizer's TARGET, not the achieved aggregate). During transients
// — e.g. demand dropping to dead-band keep-alive while pumps are
// still ramping down from full throttle — PS would see a stale
// 25 m³/h target while pumps are physically delivering 500+ m³/h,
// making netFlow look small and stable when the basin is actually
// draining fast. flow.act here is the sum of every pump's current
// predicted output, so it IS the achieved aggregate.
this . _writeMeasurement ( "flow" , "predicted" , POSITIONS . DOWNSTREAM , flow . act , this . unitPolicy . canonical . flow ) ;
2026-03-31 18:17:41 +02:00
this . _writeMeasurement ( "power" , "predicted" , POSITIONS . AT _EQUIPMENT , power . act , this . unitPolicy . canonical . power ) ;
2025-10-02 17:08:41 +02:00
2025-07-01 17:03:36 +02:00
const { maxEfficiency , lowestEfficiency } = this . calcGroupEfficiency ( this . machines ) ;
2026-03-12 16:43:29 +01:00
const efficiency = this . measurements . type ( "efficiency" ) . variant ( "predicted" ) . position ( POSITIONS . AT _EQUIPMENT ) . getCurrentValue ( ) ;
2025-07-01 17:03:36 +02:00
this . calcDistanceBEP ( efficiency , maxEfficiency , lowestEfficiency ) ;
}
calcDistanceFromPeak ( currentEfficiency , peakEfficiency ) {
return Math . abs ( currentEfficiency - peakEfficiency ) ;
}
calcRelativeDistanceFromPeak ( currentEfficiency , maxEfficiency , minEfficiency ) {
let distance = 1 ;
2026-04-07 13:40:45 +02:00
if ( currentEfficiency != null && maxEfficiency !== minEfficiency ) {
2025-07-01 17:03:36 +02:00
distance = this . interpolation . interpolate _lin _single _point ( currentEfficiency , maxEfficiency , minEfficiency , 0 , 1 ) ;
}
return distance ;
}
calcDistanceBEP ( efficiency , maxEfficiency , minEfficiency ) {
const absDistFromPeak = this . calcDistanceFromPeak ( efficiency , maxEfficiency ) ;
const relDistFromPeak = this . calcRelativeDistanceFromPeak ( efficiency , maxEfficiency , minEfficiency ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
//store internally
this . absDistFromPeak = absDistFromPeak ;
this . relDistFromPeak = relDistFromPeak ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
return { absDistFromPeak : absDistFromPeak , relDistFromPeak : relDistFromPeak } ;
}
checkSpecialCases ( machines , Qd ) {
Object . values ( machines ) . forEach ( machine => {
2025-09-23 11:19:22 +02:00
2025-07-01 17:03:36 +02:00
const state = machine . state . getCurrentState ( ) ;
const mode = machine . currentMode ;
2026-03-31 18:17:41 +02:00
//add special cases
2025-07-01 17:03:36 +02:00
if ( state === "operational" && ( mode == "virtualControl" || mode === "fysicalControl" ) ) {
let flow = 0 ;
2026-03-31 18:17:41 +02:00
const measuredFlow = this . _readChildMeasurement ( machine , "flow" , "measured" , POSITIONS . DOWNSTREAM , this . unitPolicy . canonical . flow ) ;
const predictedFlow = this . _readChildMeasurement ( machine , "flow" , "predicted" , POSITIONS . DOWNSTREAM , this . unitPolicy . canonical . flow ) ;
2026-03-11 11:12:52 +01:00
if ( Number . isFinite ( measuredFlow ) && measuredFlow !== 0 ) {
flow = measuredFlow ;
2026-03-31 18:17:41 +02:00
}
2026-03-11 11:12:52 +01:00
else if ( Number . isFinite ( predictedFlow ) && predictedFlow !== 0 ) {
flow = predictedFlow ;
2025-07-01 17:03:36 +02:00
}
else {
this . logger . error ( "Dont perform calculation at all seeing that there is a machine working but we dont know the flow its producing" ) ;
//abort the calculation
return false ;
}
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
//Qd is less because we allready have machines delivering flow on manual control
Qd = Qd - flow ;
}
} ) ;
return Qd ;
}
validPumpCombinations ( machines , Qd , PowerCap = Infinity ) {
let subsets = [ [ ] ] ;
// adjust demand flow when there are machines being controlled by a manual source
Qd = this . checkSpecialCases ( machines , Qd ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
// Generate all possible subsets of machines (power set)
Object . keys ( machines ) . forEach ( machineId => {
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
const state = machines [ machineId ] . state . getCurrentState ( ) ;
2025-11-13 19:39:32 +01:00
const validActionForMode = machines [ machineId ] . isValidActionForMode ( "execsequence" , "auto" ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
// Reasons why a machine is not valid for the combination
2025-09-23 15:03:57 +02:00
if ( state === "off" || state === "coolingdown" || state === "stopping" || state === "emergencystop" || ! validActionForMode ) {
2025-07-01 17:03:36 +02:00
return ;
}
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
// go through each machine and add it to the subsets
let newSubsets = subsets . map ( set => [ ... set , machineId ] ) ;
subsets = subsets . concat ( newSubsets ) ;
} ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
// Filter for non-empty subsets that can meet or exceed demand flow
const combinations = subsets . filter ( subset => {
if ( subset . length === 0 ) return false ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
// Calculate total and minimum flow for the subset in one pass
2026-05-08 11:19:47 +02:00
// (uses group operating point — see _groupFlow/_groupPower)
2025-07-01 17:03:36 +02:00
const { maxFlow , minFlow , maxPower } = subset . reduce (
( acc , machineId ) => {
const machine = machines [ machineId ] ;
2026-05-08 11:19:47 +02:00
const minFlow = this . _groupFlow ( machine ) . currentFxyYMin ;
const maxFlow = this . _groupFlow ( machine ) . currentFxyYMax ;
const maxPower = this . _groupPower ( machine ) . currentFxyYMax ;
2025-07-01 17:03:36 +02:00
return {
maxFlow : acc . maxFlow + maxFlow ,
minFlow : acc . minFlow + minFlow ,
maxPower : acc . maxPower + maxPower
} ;
2026-03-31 18:17:41 +02:00
} ,
2025-07-01 17:03:36 +02:00
{ maxFlow : 0 , minFlow : 0 , maxPower : 0 }
) ;
// If total flow can deliver the demand
if ( maxFlow >= Qd && minFlow <= Qd && maxPower <= PowerCap ) {
return true ;
}
else {
return false ;
}
} ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
return combinations ;
}
calcBestCombination ( combinations , Qd ) {
let bestCombination = null ;
let bestPower = Infinity ;
let bestFlow = 0 ;
let bestCog = 0 ;
2025-11-20 22:28:49 +01:00
combinations . forEach ( combination => {
let flowDistribution = [ ] ;
2025-07-01 17:03:36 +02:00
let totalCoG = 0 ;
let totalPower = 0 ;
2026-05-08 11:19:47 +02:00
// Sum normalized CoG for the combination (group operating point)
2025-11-20 22:28:49 +01:00
combination . forEach ( machineId => {
2026-05-08 11:19:47 +02:00
totalCoG += Math . round ( ( this . _groupNCog ( this . machines [ machineId ] ) || 0 ) * 100 ) / 100 ;
2025-11-20 22:28:49 +01:00
} ) ;
2025-07-01 17:03:36 +02:00
2025-11-20 22:28:49 +01:00
// Initial CoG-based distribution
2025-07-01 17:03:36 +02:00
combination . forEach ( machineId => {
let flow = 0 ;
2025-11-20 22:28:49 +01:00
2025-07-01 17:03:36 +02:00
if ( totalCoG === 0 ) {
flow = Qd / combination . length ;
} else {
2026-05-08 11:19:47 +02:00
flow = ( ( this . _groupNCog ( this . machines [ machineId ] ) || 0 ) / totalCoG ) * Qd ;
2025-07-01 17:03:36 +02:00
this . logger . debug ( ` Machine Normalized CoG-based distribution ${ machineId } flow: ${ flow } ` ) ;
}
2025-11-20 22:28:49 +01:00
flowDistribution . push ( { machineId , flow } ) ;
} ) ;
2026-05-08 11:19:47 +02:00
// Clamp to min/max and spill leftover once (group operating point)
2025-11-20 22:28:49 +01:00
const clamped = flowDistribution . map ( entry => {
const machine = this . machines [ entry . machineId ] ;
2026-05-08 11:19:47 +02:00
const min = this . _groupFlow ( machine ) . currentFxyYMin ;
const max = this . _groupFlow ( machine ) . currentFxyYMax ;
2025-11-20 22:28:49 +01:00
const clampedFlow = Math . min ( max , Math . max ( min , entry . flow ) ) ;
return { ... entry , flow : clampedFlow , min , max , desired : entry . flow } ;
} ) ;
let remainder = Qd - clamped . reduce ( ( sum , entry ) => sum + entry . flow , 0 ) ;
if ( Math . abs ( remainder ) > 1e-6 ) {
const adjustable = clamped . filter ( entry =>
remainder > 0 ? entry . flow < entry . max : entry . flow > entry . min
) ;
const weightSum = adjustable . reduce ( ( sum , entry ) => sum + entry . desired , 0 ) || adjustable . length ;
adjustable . forEach ( entry => {
const weight = entry . desired / weightSum || 1 / adjustable . length ;
const delta = remainder * weight ;
const next = remainder > 0
? Math . min ( entry . max , entry . flow + delta )
: Math . max ( entry . min , entry . flow + delta ) ;
remainder -= ( next - entry . flow ) ;
entry . flow = next ;
} ) ;
}
flowDistribution = clamped ;
let totalFlow = 0 ;
flowDistribution . forEach ( ( { machineId , flow } ) => {
2025-07-01 17:03:36 +02:00
totalFlow += flow ;
2026-05-08 11:19:47 +02:00
totalPower += this . _groupCalcPower ( this . machines [ machineId ] , flow ) ;
2025-07-01 17:03:36 +02:00
} ) ;
if ( totalPower < bestPower ) {
this . logger . debug ( ` New best combination found: ${ totalPower } < ${ bestPower } ` ) ;
this . logger . debug ( ` combination ${ JSON . stringify ( flowDistribution ) } ` ) ;
bestPower = totalPower ;
bestFlow = totalFlow ;
bestCog = totalCoG ;
bestCombination = flowDistribution ;
}
} ) ;
return { bestCombination , bestPower , bestFlow , bestCog } ;
}
2026-03-31 18:17:41 +02:00
2025-11-25 15:10:36 +01:00
// Estimate the local dP/dQ slopes around the BEP for the provided machine.
2025-11-22 21:09:38 +01:00
estimateSlopesAtBEP ( machine , Q _BEP , delta = 1.0 ) {
const fallback = {
slopeLeft : 0 ,
slopeRight : 0 ,
alpha : 1 ,
Q _BEP : Q _BEP || 0 ,
P _BEP : 0
} ;
2026-05-08 11:19:47 +02:00
// Group operating point — slopes around BEP must use the same op-point
// the optimizer evaluates at, otherwise gravitation pulls toward an
// off-by-one BEP target.
const minFlow = this . _groupFlow ( machine ) . currentFxyYMin ;
const maxFlow = this . _groupFlow ( machine ) . currentFxyYMax ;
2025-11-25 15:10:36 +01:00
const span = Math . max ( 0 , maxFlow - minFlow ) ;
2026-05-08 11:19:47 +02:00
const normalizedCog = Math . max ( 0 , Math . min ( 1 , this . _groupNCog ( machine ) || 0 ) ) ;
2025-11-25 15:10:36 +01:00
const targetBEP = Q _BEP ? ? ( minFlow + span * normalizedCog ) ;
const clampFlow = ( flow ) => Math . min ( maxFlow , Math . max ( minFlow , flow ) ) ; // ensure within bounds using small helper function
const center = clampFlow ( targetBEP ) ;
const deltaSafe = Math . max ( delta , 0.01 ) ;
const leftFlow = clampFlow ( center - deltaSafe ) ;
const rightFlow = clampFlow ( center + deltaSafe ) ;
2026-05-08 11:19:47 +02:00
const powerAt = ( flow ) => this . _groupCalcPower ( machine , flow ) ; // helper to get power at a given flow
2025-11-25 15:10:36 +01:00
const P _center = powerAt ( center ) ;
const P _left = powerAt ( leftFlow ) ;
const P _right = powerAt ( rightFlow ) ;
const slopeLeft = ( P _center - P _left ) / Math . max ( 1e-6 , center - leftFlow ) ;
const slopeRight = ( P _right - P _center ) / Math . max ( 1e-6 , rightFlow - center ) ;
const alpha = Math . max ( 1e-6 , ( Math . abs ( slopeLeft ) + Math . abs ( slopeRight ) ) / 2 ) ;
2025-11-22 21:09:38 +01:00
2025-11-25 15:10:36 +01:00
return {
slopeLeft ,
slopeRight ,
alpha ,
Q _BEP : center ,
P _BEP : P _center
} ;
2025-11-22 21:09:38 +01:00
}
2025-11-25 15:10:36 +01:00
//Redistribute remaining demand using slope-based weights so flatter curves attract more flow.
2025-11-22 21:09:38 +01:00
redistributeFlowBySlope ( pumpInfos , flowDistribution , delta , directional = true ) {
2025-11-25 15:10:36 +01:00
const tolerance = 1e-3 ; // Small tolerance to avoid infinite loops
let remaining = delta ; // Remaining flow to distribute
const entryMap = new Map ( flowDistribution . map ( entry => [ entry . machineId , entry ] ) ) ; // Map for quick access
2025-11-22 21:09:38 +01:00
2025-11-25 15:10:36 +01:00
// Loop until remaining flow is within tolerance
2025-11-22 21:09:38 +01:00
while ( Math . abs ( remaining ) > tolerance ) {
2025-11-25 15:10:36 +01:00
const increasing = remaining > 0 ; // Determine if we are increasing or decreasing flow
// Build candidates with capacity and weight
2025-11-22 21:09:38 +01:00
const candidates = pumpInfos . map ( info => {
const entry = entryMap . get ( info . id ) ;
if ( ! entry ) { return null ; }
2025-11-25 15:10:36 +01:00
const capacity = increasing ? info . maxFlow - entry . flow : entry . flow - info . minFlow ; // Calculate available capacity based on direction
2025-11-22 21:09:38 +01:00
if ( capacity <= tolerance ) { return null ; }
const slope = increasing
? ( directional ? info . slopes . slopeRight : info . slopes . alpha )
: ( directional ? info . slopes . slopeLeft : info . slopes . alpha ) ;
const weight = 1 / Math . max ( 1e-6 , Math . abs ( slope ) || info . slopes . alpha || 1 ) ;
return { entry , capacity , weight } ;
} ) . filter ( Boolean ) ;
2025-11-25 15:10:36 +01:00
if ( ! candidates . length ) { break ; } // No candidates available, exit loop
2025-11-22 21:09:38 +01:00
2025-11-25 15:10:36 +01:00
const weightSum = candidates . reduce ( ( sum , candidate ) => sum + candidate . weight * candidate . capacity , 0 ) ; // weighted sum of capacities
if ( weightSum <= 0 ) { break ; } // Avoid division by zero
2025-11-22 21:09:38 +01:00
let progress = 0 ;
2025-11-25 15:10:36 +01:00
// Distribute remaining flow among candidates based on their weights and capacities
2025-11-22 21:09:38 +01:00
candidates . forEach ( candidate => {
let share = ( candidate . weight * candidate . capacity / weightSum ) * Math . abs ( remaining ) ;
2025-11-25 15:10:36 +01:00
share = Math . min ( share , candidate . capacity ) ; // Ensure we don't exceed capacity
if ( share <= 0 ) { return ; } // Skip if no share to allocate
2025-11-22 21:09:38 +01:00
if ( increasing ) {
candidate . entry . flow += share ;
} else {
candidate . entry . flow -= share ;
}
2025-11-25 15:10:36 +01:00
progress += share ; // Track total progress made in this iteration
2025-11-22 21:09:38 +01:00
} ) ;
if ( progress <= tolerance ) { break ; }
2025-11-25 15:10:36 +01:00
remaining += increasing ? - progress : progress ; // Update remaining flow to distribute
2025-11-22 21:09:38 +01:00
}
}
2025-11-25 15:10:36 +01:00
// BEP-gravitation based combination finder that biases allocation around each pump's BEP.
2025-11-22 21:09:38 +01:00
calcBestCombinationBEPGravitation ( combinations , Qd , method = "BEP-Gravitation-Directional" ) {
let bestCombination = null ;
let bestPower = Infinity ;
let bestFlow = 0 ;
let bestCog = 0 ;
let bestDeviation = Infinity ;
const directional = method === "BEP-Gravitation-Directional" ;
combinations . forEach ( combination => {
const pumpInfos = combination . map ( machineId => {
const machine = this . machines [ machineId ] ;
2026-05-08 11:19:47 +02:00
// Group operating point — BEP and curve envelope must come
// from the same view the optimizer evaluates power on.
const minFlow = this . _groupFlow ( machine ) . currentFxyYMin ;
const maxFlow = this . _groupFlow ( machine ) . currentFxyYMax ;
2025-11-22 21:09:38 +01:00
const span = Math . max ( 0 , maxFlow - minFlow ) ;
2026-05-08 11:19:47 +02:00
const NCog = Math . max ( 0 , Math . min ( 1 , this . _groupNCog ( machine ) || 0 ) ) ;
2025-11-25 15:10:36 +01:00
const estimatedBEP = minFlow + span * NCog ; // Estimated BEP flow based on current curve
2025-11-22 21:09:38 +01:00
const slopes = this . estimateSlopesAtBEP ( machine , estimatedBEP ) ;
return {
id : machineId ,
machine ,
minFlow ,
maxFlow ,
NCog ,
Q _BEP : slopes . Q _BEP ,
slopes
} ;
} ) ;
2025-11-25 15:10:36 +01:00
// Skip if no pumps in combination
2025-11-22 21:09:38 +01:00
if ( pumpInfos . length === 0 ) { return ; }
2025-11-25 15:10:36 +01:00
// Start at BEP flows
2025-11-22 21:09:38 +01:00
const flowDistribution = pumpInfos . map ( info => ( {
machineId : info . id ,
flow : Math . min ( info . maxFlow , Math . max ( info . minFlow , info . Q _BEP ) )
2026-03-31 18:17:41 +02:00
} ) ) ;
2025-11-22 21:09:38 +01:00
2025-11-25 15:10:36 +01:00
let totalFlow = flowDistribution . reduce ( ( sum , entry ) => sum + entry . flow , 0 ) ; // Initial total flow
const delta = Qd - totalFlow ; // Difference to target demand
2025-11-22 21:09:38 +01:00
if ( Math . abs ( delta ) > 1e-6 ) {
this . redistributeFlowBySlope ( pumpInfos , flowDistribution , delta , directional ) ;
}
2026-04-07 13:40:45 +02:00
// Clamp and compute initial power
flowDistribution . forEach ( entry => {
const info = pumpInfos . find ( info => info . id === entry . machineId ) ;
entry . flow = Math . min ( info . maxFlow , Math . max ( info . minFlow , entry . flow ) ) ;
} ) ;
// Marginal-cost refinement: shift flow from most expensive to cheapest
2026-05-08 11:19:47 +02:00
// pump using actual power evaluations on the group operating
// point. Converges regardless of curve convexity.
2026-04-07 13:40:45 +02:00
const mcDelta = Math . max ( 1e-6 , ( Qd / pumpInfos . length ) * 0.005 ) ;
for ( let refineIter = 0 ; refineIter < 50 ; refineIter ++ ) {
const mcEntries = flowDistribution . map ( entry => {
const info = pumpInfos . find ( i => i . id === entry . machineId ) ;
2026-05-08 11:19:47 +02:00
const pNow = this . _groupCalcPower ( info . machine , entry . flow ) ;
const pUp = this . _groupCalcPower ( info . machine , Math . min ( info . maxFlow , entry . flow + mcDelta ) ) ;
2026-04-07 13:40:45 +02:00
return { entry , info , mc : ( pUp - pNow ) / mcDelta } ;
} ) ;
let expensive = null , cheap = null ;
for ( const e of mcEntries ) {
if ( e . entry . flow > e . info . minFlow + mcDelta ) { if ( ! expensive || e . mc > expensive . mc ) expensive = e ; }
if ( e . entry . flow < e . info . maxFlow - mcDelta ) { if ( ! cheap || e . mc < cheap . mc ) cheap = e ; }
}
if ( ! expensive || ! cheap || expensive === cheap ) break ;
if ( expensive . mc - cheap . mc < expensive . mc * 0.001 ) break ;
2026-05-08 11:19:47 +02:00
const before = this . _groupCalcPower ( expensive . info . machine , expensive . entry . flow ) + this . _groupCalcPower ( cheap . info . machine , cheap . entry . flow ) ;
const after = this . _groupCalcPower ( expensive . info . machine , expensive . entry . flow - mcDelta ) + this . _groupCalcPower ( cheap . info . machine , cheap . entry . flow + mcDelta ) ;
2026-04-07 13:40:45 +02:00
if ( after < before ) { expensive . entry . flow -= mcDelta ; cheap . entry . flow += mcDelta ; } else { break ; }
}
2025-11-22 21:09:38 +01:00
let totalPower = 0 ;
totalFlow = 0 ;
flowDistribution . forEach ( entry => {
2026-04-07 13:40:45 +02:00
totalFlow += entry . flow ;
const info = pumpInfos . find ( i => i . id === entry . machineId ) ;
2026-05-08 11:19:47 +02:00
totalPower += this . _groupCalcPower ( info . machine , entry . flow ) ;
2025-11-22 21:09:38 +01:00
} ) ;
const totalCog = pumpInfos . reduce ( ( sum , info ) => sum + info . NCog , 0 ) ;
const deviation = pumpInfos . reduce ( ( sum , info ) => {
const entry = flowDistribution . find ( item => item . machineId === info . id ) ;
const deltaFlow = entry ? ( entry . flow - info . Q _BEP ) : 0 ;
return sum + ( deltaFlow * deltaFlow ) * ( info . slopes . alpha || 1 ) ;
} , 0 ) ;
const shouldUpdate = totalPower < bestPower ||
( totalPower === bestPower && deviation < bestDeviation ) ;
if ( shouldUpdate ) {
bestCombination = flowDistribution . map ( entry => ( { ... entry } ) ) ;
bestPower = totalPower ;
bestFlow = totalFlow ;
bestCog = totalCog ;
bestDeviation = deviation ;
}
} ) ;
return {
bestCombination ,
bestPower ,
bestFlow ,
bestCog ,
bestDeviation ,
method
} ;
}
2025-11-20 22:28:49 +01:00
2025-07-01 17:03:36 +02:00
// -------- Mode and Input Management -------- //
isValidActionForMode ( action , mode ) {
const allowedActionsSet = this . config . mode . allowedActions [ mode ] || [ ] ;
return allowedActionsSet . has ( action ) ;
}
setScaling ( scaling ) {
2025-09-23 11:19:22 +02:00
const scalingSet = new Set ( this . defaultConfig . scaling . current . rules . values . map ( ( value ) => value . value ) ) ;
2025-07-01 17:03:36 +02:00
scalingSet . has ( scaling ) ? this . scaling = scaling : this . logger . warn ( ` ${ scaling } is not a valid scaling option. ` ) ;
2025-09-23 11:19:22 +02:00
this . logger . debug ( ` Scaling set to: ${ scaling } ` ) ;
2025-07-01 17:03:36 +02:00
}
2025-10-02 17:08:41 +02:00
async abortActiveMovements ( reason = "new demand" ) {
2026-05-09 09:43:12 +02:00
// Safety net: in normal operation the _dispatchInFlight gate
// (handleInput) ensures no pump movement is in flight when a
// new dispatch starts, so this is a no-op. If a pump IS still
// moving here, the gate was bypassed (direct call to
// abortActiveMovements, mode change racing a dispatch, etc.) —
// surface that loudly so the bypass can be diagnosed.
const movementStates = new Set ( [ 'accelerating' , 'decelerating' ] ) ;
2025-10-02 17:08:41 +02:00
await Promise . all ( Object . values ( this . machines ) . map ( async machine => {
2026-05-09 09:43:12 +02:00
const state = machine . state ? . getCurrentState ? . ( ) ;
if ( ! movementStates . has ( state ) ) return ;
this . logger . warn ( ` Force-aborting in-flight movement on ${ machine . config . general . id } (state= ${ state } ) due to: ${ reason } — _dispatchInFlight gate bypassed. ` ) ;
2025-10-02 17:08:41 +02:00
if ( typeof machine . abortMovement === "function" ) {
await machine . abortMovement ( reason ) ;
}
} ) ) ;
}
2025-07-01 17:03:36 +02:00
//handle input from parent / user / UI
async optimalControl ( Qd , powerCap = Infinity ) {
2025-10-02 17:08:41 +02:00
2025-07-01 17:03:36 +02:00
try {
2026-04-07 13:40:45 +02:00
if ( Object . keys ( this . machines ) . length === 0 ) {
this . logger . warn ( "No machines registered. Cannot execute optimal control." ) ;
return ;
}
2026-05-08 11:19:47 +02:00
this . _equalizeOperatingPoint ( ) ;
2026-03-31 18:17:41 +02:00
2025-10-02 17:08:41 +02:00
//fetch dynamic totals
const dynamicTotals = this . dynamicTotals ;
2025-07-01 17:03:36 +02:00
const machineStates = Object . entries ( this . machines ) . reduce ( ( acc , [ machineId , machine ] ) => {
acc [ machineId ] = machine . state . getCurrentState ( ) ;
return acc ;
} , { } ) ;
if ( Qd <= 0 ) {
2026-04-07 13:40:45 +02:00
this . logger . debug ( "Flow demand <= 0, turning all machines off." ) ;
await this . turnOffAllMachines ( ) ;
return ;
2025-07-01 17:03:36 +02:00
}
if ( Qd < dynamicTotals . flow . min && Qd > 0 ) {
//Capping Qd to lowest possible value
this . logger . warn ( ` Flow demand ${ Qd } is below minimum possible flow ${ dynamicTotals . flow . min } . Capping to minimum flow. ` ) ;
Qd = dynamicTotals . flow . min ;
}
else if ( Qd > dynamicTotals . flow . max ) {
//Capping Qd to highest possible value
this . logger . warn ( ` Flow demand ${ Qd } is above maximum possible flow ${ dynamicTotals . flow . max } . Capping to maximum flow. ` ) ;
Qd = dynamicTotals . flow . max ;
}
// fetch all valid combinations that meet expectations
2026-03-31 18:17:41 +02:00
const combinations = this . validPumpCombinations ( this . machines , Qd , powerCap ) ;
2025-11-22 21:09:38 +01:00
if ( ! combinations || combinations . length === 0 ) {
this . logger . warn ( ` Demand: ${ Qd . toFixed ( 2 ) } -> No valid combination found (empty set). ` ) ;
return ;
}
// Decide which optimization routine we run. Defaults to BEP-based gravitation with directionality.
const optimizationMethod = this . config . optimization ? . method || "BEP-Gravitation-Directional" ;
let bestResult ;
if ( optimizationMethod === "NCog" ) {
bestResult = this . calcBestCombination ( combinations , Qd ) ;
} else if (
optimizationMethod === "BEP-Gravitation" ||
optimizationMethod === "BEP-Gravitation-Directional"
) {
bestResult = this . calcBestCombinationBEPGravitation ( combinations , Qd , optimizationMethod ) ;
} else {
this . logger . warn ( ` Unknown optimization method ' ${ optimizationMethod } ', falling back to BEP-Gravitation-Directional. ` ) ;
bestResult = this . calcBestCombinationBEPGravitation ( combinations , Qd , "BEP-Gravitation-Directional" ) ;
}
2025-07-01 17:03:36 +02:00
if ( bestResult . bestCombination === null ) {
this . logger . warn ( ` Demand: ${ Qd . toFixed ( 2 ) } -> No valid combination found => not updating control ` ) ;
return ;
}
2025-10-02 17:08:41 +02:00
2025-07-01 17:03:36 +02:00
const debugInfo = bestResult . bestCombination . map ( ( { machineId , flow } ) => ` ${ machineId } : ${ flow . toFixed ( 2 ) } units ` ) . join ( " | " ) ;
this . logger . debug ( ` Moving to demand: ${ Qd . toFixed ( 2 ) } -> Pumps: [ ${ debugInfo } ] => Total Power: ${ bestResult . bestPower . toFixed ( 2 ) } ` ) ;
2026-03-31 18:17:41 +02:00
2026-05-08 18:32:58 +02:00
// Store the optimizer's INTENT on AT_EQUIPMENT (what we
// commanded). DOWNSTREAM is reserved for the live aggregate
// written by handlePressureChange — PS subscribes to that
// for net-flow computation and must see what pumps are
// actually delivering, not the planned target. Writing
// bestFlow to DOWNSTREAM here would clobber the live value
// every handleInput tick (see ps-mgc-flow-contract test).
2026-03-31 18:17:41 +02:00
this . _writeMeasurement ( "power" , "predicted" , POSITIONS . AT _EQUIPMENT , bestResult . bestPower , this . unitPolicy . canonical . power ) ;
2026-05-08 18:32:58 +02:00
this . _writeMeasurement ( "flow" , "predicted" , POSITIONS . AT _EQUIPMENT , bestResult . bestFlow , this . unitPolicy . canonical . flow ) ;
2026-03-12 16:43:29 +01:00
this . measurements . type ( "efficiency" ) . variant ( "predicted" ) . position ( POSITIONS . AT _EQUIPMENT ) . value ( bestResult . bestFlow / bestResult . bestPower ) ;
this . measurements . type ( "Ncog" ) . variant ( "predicted" ) . position ( POSITIONS . AT _EQUIPMENT ) . value ( bestResult . bestCog ) ;
2025-07-01 17:03:36 +02:00
await Promise . all ( Object . entries ( this . machines ) . map ( async ( [ machineId , machine ] ) => {
2025-10-02 17:08:41 +02:00
// Find the flow for this machine in the best combination
this . logger . debug ( ` Searching for machine ${ machineId } with state ${ machineStates [ machineId ] } in best combination. ` ) ;
const pumpInfo = bestResult . bestCombination . find ( item => item . machineId == machineId ) ;
2025-07-01 17:03:36 +02:00
let flow ;
if ( pumpInfo !== undefined ) {
flow = pumpInfo . flow ;
} else {
2025-10-02 17:08:41 +02:00
this . logger . debug ( ` Machine ${ machineId } not in best combination, setting flow control to 0 ` ) ;
2025-07-01 17:03:36 +02:00
flow = 0 ;
}
2026-05-08 11:19:47 +02:00
// Dispatch policy: send the setpoint to ANY pump that
// should be running (flow > 0), not just operational
// ones. rotatingMachine.state.moveTo handles queueing:
// - operational → execute immediately
// - accelerating /
// decelerating → unpark post-abort residue
// and execute (state.js fix)
// - idle / starting /
// warmingup / stopping /
// coolingdown → save as delayedMove,
// auto-fires on next
// transition to operational
//
// CRUCIAL ORDERING: flowmovement BEFORE execsequence
// startup. If we awaited startup first (~3 s), other
// concurrent MGC.handleInput calls would update this
// pump's delayedMove during the startup window. When
// startup completes, transitionToState('operational')
// correctly fires the LATEST delayedMove. But then this
// call's chained `await flowmovement(stale)` would run
// on an already-operational pump and overwrite the
// correct position with the stale snapshot value.
//
// By sending flowmovement first, the setpoint lands in
// delayedMove while the pump is still idle. Concurrent
// calls overwrite delayedMove with newer setpoints. The
// final transitionToState('operational') at the end of
// startup fires whichever delayedMove is current — the
// genuinely latest demand wins.
//
// See test/integration/idle-startup-deadlock.integration.test.js
// Scenario 4 for the deterministic reproducer.
const state = machineStates [ machineId ] ;
if ( flow > 0 ) {
2026-04-07 13:40:45 +02:00
await machine . handleInput ( "parent" , "flowmovement" , this . _canonicalToOutputFlow ( flow ) ) ;
2026-05-08 11:19:47 +02:00
if ( state === "idle" ) {
await machine . handleInput ( "parent" , "execsequence" , "startup" ) ;
}
} else if ( state === "operational" || state === "accelerating" || state === "decelerating" ) {
await machine . handleInput ( "parent" , "execsequence" , "shutdown" ) ;
2025-07-01 17:03:36 +02:00
}
2026-05-08 11:19:47 +02:00
// flow ≤ 0 AND state already in shutdown chain (idle/
// stopping/coolingdown/off/emergencystop) → nothing
// to do, preserve previous behaviour.
2025-07-01 17:03:36 +02:00
} ) ) ;
}
catch ( err ) {
this . logger . error ( err ) ;
}
}
2026-05-08 11:19:47 +02:00
// Equalize all machines (running + idle) to the group's header
// operating point so dynamicTotals + combination optimization see one
// consistent operating point. See _equalizeOperatingPoint for the
// implementation rationale.
2025-07-01 17:03:36 +02:00
equalizePressure ( ) {
2026-05-08 11:19:47 +02:00
this . _equalizeOperatingPoint ( ) ;
}
// Force every machine's predict-curve interpolators to use the same
// (header) differential pressure for the duration of MGC's optimization.
//
// Why direct fDimension assignment, not measurement writes:
// rotatingMachine._getPreferredPressureValue reads from each pressure
// sensor child (keyed by child id) BEFORE falling back to the position-
// level measurement. MGC has no way to know which child id a pump's
// sensor uses, so writes via _writeChildMeasurement land at the
// "default" child key and are never consulted by getMeasuredPressure().
// Setting fDimension directly is the same effect getMeasuredPressure()
// would have produced if its read had succeeded.
//
// Per-pump diagnostics are unaffected: this only mutates the predict
// objects' interpolation parameter, NOT the pump's measurement container.
// The pump's own emitted upstream/downstream measurements (and the
// differential they imply) keep their real sensor values.
//
// Header source order:
// 1. MGC's own header measurement (a measurement child registered at
// DOWNSTREAM / UPSTREAM with MGC as parent). Authoritative manifold
// reading when present.
// 2. Worst-case envelope across pump-side sensors —
// downstream = max (highest discharge load),
// upstream = min of POSITIVE values (lowest suction = highest
// required head). Zeros are filtered to skip pumps
// that haven't emitted yet.
_equalizeOperatingPoint ( ) {
2026-04-07 13:40:45 +02:00
if ( Object . keys ( this . machines ) . length === 0 ) return ;
2026-05-08 11:19:47 +02:00
const groupHeaderDown = this . measurements
. type ( "pressure" ) . variant ( "measured" ) . position ( POSITIONS . DOWNSTREAM )
. getCurrentValue ( this . unitPolicy . canonical . pressure ) ;
const groupHeaderUp = this . measurements
. type ( "pressure" ) . variant ( "measured" ) . position ( POSITIONS . UPSTREAM )
. getCurrentValue ( this . unitPolicy . canonical . pressure ) ;
const childDown = [ ] ;
const childUp = [ ] ;
Object . values ( this . machines ) . forEach ( machine => {
const d = this . _readChildMeasurement ( machine , "pressure" , "measured" , POSITIONS . DOWNSTREAM , this . unitPolicy . canonical . pressure ) ;
const u = this . _readChildMeasurement ( machine , "pressure" , "measured" , POSITIONS . UPSTREAM , this . unitPolicy . canonical . pressure ) ;
if ( Number . isFinite ( d ) && d > 0 ) childDown . push ( d ) ;
if ( Number . isFinite ( u ) && u > 0 ) childUp . push ( u ) ;
2025-07-01 17:03:36 +02:00
} ) ;
2026-03-31 18:17:41 +02:00
2026-05-08 11:19:47 +02:00
const headerDownSrc = Number . isFinite ( groupHeaderDown ) && groupHeaderDown > 0 ? "header" : "max-child" ;
const headerUpSrc = Number . isFinite ( groupHeaderUp ) && groupHeaderUp > 0 ? "header" : "min-child" ;
const headerDownstream = headerDownSrc === "header" ? groupHeaderDown : ( childDown . length ? Math . max ( ... childDown ) : 0 ) ;
const headerUpstream = headerUpSrc === "header" ? groupHeaderUp : ( childUp . length ? Math . min ( ... childUp ) : 0 ) ;
const headerDiff = headerDownstream - headerUpstream ;
if ( ! Number . isFinite ( headerDiff ) || headerDiff <= 0 ) {
this . logger . debug ( ` Skipping equalization: invalid header diff ${ headerDiff } (down= ${ headerDownstream } , up= ${ headerUpstream } ) ` ) ;
return ;
}
this . logger . debug ( ` Equalizing operating point: down= ${ headerDownstream } ( ${ headerDownSrc } ), up= ${ headerUpstream } ( ${ headerUpSrc } ), diff= ${ headerDiff } ` ) ;
// Push the header operating point onto each pump's group-scope
// predicts. The pump's individual predicts (driven by its own
// sensors) are untouched; only the group view used by this MGC
// is shifted. See rotatingMachine.setGroupOperatingPoint().
Object . values ( this . machines ) . forEach ( machine => {
if ( typeof machine . setGroupOperatingPoint === "function" ) {
machine . setGroupOperatingPoint ( headerDownstream , headerUpstream ) ;
} else {
// Older rotatingMachine without the group API — fall back
// to direct fDimension write so the demo still works while
// submodules are rolled forward.
if ( machine . predictFlow ) machine . predictFlow . fDimension = headerDiff ;
if ( machine . predictPower ) machine . predictPower . fDimension = headerDiff ;
if ( machine . predictCtrl ) machine . predictCtrl . fDimension = headerDiff ;
2025-07-01 17:03:36 +02:00
}
} ) ;
}
2026-05-08 11:19:47 +02:00
// ---------- Group-scope read helpers ----------
// Optimization paths read pump curves at the GROUP operating point,
// not the pump's individual sensor-driven point. These helpers fall
// back to the individual predicts if a pump hasn't been initialised
// for group operation yet (first tick after registration).
_groupFlow ( machine ) { return machine . groupPredictFlow ? ? machine . predictFlow ; }
_groupPower ( machine ) { return machine . groupPredictPower ? ? machine . predictPower ; }
_groupNCog ( machine ) { return machine . groupPredictFlow ? ( machine . groupNCog ? ? 0 ) : ( machine . NCog ? ? 0 ) ; }
_groupCalcPower ( machine , flow ) {
return typeof machine . groupCalcPower === "function"
? machine . groupCalcPower ( flow )
: machine . inputFlowCalcPower ( flow ) ;
}
2025-07-01 17:03:36 +02:00
isMachineActive ( machineId ) {
if ( this . machines [ machineId ] . state . getCurrentState ( ) === "operational" || this . machines [ machineId ] . state . getCurrentState ( ) === "accelerating" || this . machines [ machineId ] . state . getCurrentState ( ) === "decelerating" ) {
return true ;
}
return false ;
}
capFlowDemand ( Qd , dynamicTotals ) {
if ( Qd < dynamicTotals . flow . min && Qd > 0 ) {
this . logger . warn ( ` Flow demand ${ Qd } is below minimum possible flow ${ dynamicTotals . flow . min } . Capping to minimum flow. ` ) ;
Qd = dynamicTotals . flow . min ;
} else if ( Qd > dynamicTotals . flow . max ) {
this . logger . warn ( ` Flow demand ${ Qd } is above maximum possible flow ${ dynamicTotals . flow . max } . Capping to maximum flow. ` ) ;
Qd = dynamicTotals . flow . max ;
}
return Qd ;
}
sortMachinesByPriority ( priorityList ) {
let machinesInPriorityOrder ;
if ( priorityList && Array . isArray ( priorityList ) ) {
machinesInPriorityOrder = priorityList
. filter ( id => this . machines [ id ] )
. map ( id => ( { id , machine : this . machines [ id ] } ) ) ;
} else {
machinesInPriorityOrder = Object . entries ( this . machines )
2025-10-02 17:08:41 +02:00
. map ( ( [ id , machine ] ) => ( { id : id , machine } ) )
2025-07-01 17:03:36 +02:00
. sort ( ( a , b ) => a . id - b . id ) ;
}
return machinesInPriorityOrder ;
}
filterOutUnavailableMachines ( list ) {
2026-03-12 16:43:29 +01:00
const newList = list . filter ( ( { machine } ) => {
2025-07-01 17:03:36 +02:00
const state = machine . state . getCurrentState ( ) ;
2025-11-13 19:39:32 +01:00
const validActionForMode = machine . isValidActionForMode ( "execsequence" , "auto" ) ;
2026-03-31 18:17:41 +02:00
return ! ( state === "off" || state === "coolingdown" || state === "stopping" || state === "emergencystop" || ! validActionForMode ) ;
2025-07-01 17:03:36 +02:00
} ) ;
return newList ;
}
calcGroupEfficiency ( machines ) {
let cumEfficiency = 0 ;
let machineCount = 0 ;
let lowestEfficiency = Infinity ;
// Calculate the average efficiency of all machines -> peak is the average of them all
2026-03-12 16:43:29 +01:00
Object . entries ( machines ) . forEach ( ( [ _machineId , machine ] ) => {
2025-07-01 17:03:36 +02:00
cumEfficiency += machine . cog ;
if ( machine . cog < lowestEfficiency ) {
lowestEfficiency = machine . cog ;
}
machineCount ++ ;
} ) ;
const maxEfficiency = cumEfficiency / machineCount ;
return { maxEfficiency , lowestEfficiency } ;
}
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
//move machines assuming equal control in flow and a priority list
2026-03-12 16:43:29 +01:00
async equalFlowControl ( Qd , _powerCap = Infinity , priorityList = null ) {
2025-07-01 17:03:36 +02:00
try {
// equalize pressure across all machines
this . equalizePressure ( ) ;
// Update dynamic totals
const dynamicTotals = this . calcDynamicTotals ( ) ;
// Cap flow demand to min/max possible values
Qd = this . capFlowDemand ( Qd , dynamicTotals ) ;
// Get machines sorted by priority
let machinesInPriorityOrder = this . sortMachinesByPriority ( priorityList ) ;
// Filter out machines that are unavailable for control
machinesInPriorityOrder = this . filterOutUnavailableMachines ( machinesInPriorityOrder ) ;
// Initialize flow distribution
let flowDistribution = [ ] ;
let totalFlow = 0 ;
let totalPower = 0 ;
let totalCog = 0 ;
const activeTotals = this . activeTotals ( ) ;
// Distribute flow equally among all available machines
switch ( true ) {
case ( Qd < activeTotals . flow . min && activeTotals . flow . min !== 0 ) : {
let availableFlow = activeTotals . flow . min ;
for ( let i = machinesInPriorityOrder . length - 1 ; i >= 0 && availableFlow > Qd ; i -- ) {
const machine = machinesInPriorityOrder [ i ] ;
if ( this . isMachineActive ( machine . id ) ) {
flowDistribution . push ( { machineId : machine . id , flow : 0 } ) ;
2026-05-08 11:19:47 +02:00
availableFlow -= this . _groupFlow ( machine . machine ) . currentFxyYMin ;
2025-07-01 17:03:36 +02:00
}
}
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
// Determine remaining active machines (not shut down).
const remainingMachines = machinesInPriorityOrder . filter (
( { id } ) =>
this . isMachineActive ( id ) &&
! flowDistribution . some ( item => item . machineId === id )
) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
// Evenly distribute Qd among the remaining machines.
const distributedFlow = Qd / remainingMachines . length ;
for ( let machine of remainingMachines ) {
flowDistribution . push ( { machineId : machine . id , flow : distributedFlow } ) ;
totalFlow += distributedFlow ;
2026-05-08 11:19:47 +02:00
totalPower += this . _groupCalcPower ( machine . machine , distributedFlow ) ;
2025-07-01 17:03:36 +02:00
}
break ;
}
2026-03-31 18:17:41 +02:00
2026-03-12 16:43:29 +01:00
case ( Qd > activeTotals . flow . max ) : {
2025-07-01 17:03:36 +02:00
// Case 2: Demand is above the maximum available flow.
// Start the non-active machine with the highest priority and distribute Qd over all available machines.
let i = 1 ;
while ( totalFlow < Qd && i <= machinesInPriorityOrder . length ) {
Qd = Qd / i ;
2026-03-12 16:43:29 +01:00
2026-05-08 11:19:47 +02:00
if ( this . _groupFlow ( machinesInPriorityOrder [ i - 1 ] . machine ) . currentFxyYMax >= Qd ) {
2025-07-01 17:03:36 +02:00
for ( let i2 = 0 ; i2 < i ; i2 ++ ) {
if ( ! this . isMachineActive ( machinesInPriorityOrder [ i2 ] . id ) ) {
flowDistribution . push ( { machineId : machinesInPriorityOrder [ i2 ] . id , flow : Qd } ) ;
totalFlow += Qd ;
2026-05-08 11:19:47 +02:00
totalPower += this . _groupCalcPower ( machinesInPriorityOrder [ i2 ] . machine , Qd ) ;
2025-07-01 17:03:36 +02:00
}
}
}
i ++ ;
}
2026-03-12 16:43:29 +01:00
2025-07-01 17:03:36 +02:00
break ;
2026-03-12 16:43:29 +01:00
}
2025-07-01 17:03:36 +02:00
2026-03-12 16:43:29 +01:00
default : {
2025-07-01 17:03:36 +02:00
// Default case: Demand is within the active range.
const countActiveMachines = machinesInPriorityOrder . filter ( ( { id } ) => this . isMachineActive ( id ) ) . length ;
Qd /= countActiveMachines ;
// Simply distribute the demand equally among all available machines.
for ( let i = 0 ; i < countActiveMachines ; i ++ ) {
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
flowDistribution . push ( { machineId : machinesInPriorityOrder [ i ] . id , flow : Qd } ) ;
totalFlow += Qd ;
2026-05-08 11:19:47 +02:00
totalPower += this . _groupCalcPower ( machinesInPriorityOrder [ i ] . machine , Qd ) ;
2025-07-01 17:03:36 +02:00
}
break ;
2026-03-12 16:43:29 +01:00
}
2025-07-01 17:03:36 +02:00
}
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
// Log information about flow distribution
const debugInfo = flowDistribution
. filter ( ( { flow } ) => flow > 0 )
. map ( ( { machineId , flow } ) => ` ${ machineId } : ${ flow . toFixed ( 2 ) } units ` )
. join ( " | " ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
this . logger . debug ( ` Priority control for demand: ${ totalFlow . toFixed ( 2 ) } -> Active pumps: [ ${ debugInfo } ] => Total Power: ${ totalPower . toFixed ( 2 ) } ` ) ;
2026-03-31 18:17:41 +02:00
2026-05-08 18:32:58 +02:00
// Store the planned distribution as INTENT on AT_EQUIPMENT.
// DOWNSTREAM (live aggregate) is owned by handlePressureChange.
// Writing the plan here would clobber PS's outflow signal.
2026-03-31 18:17:41 +02:00
this . _writeMeasurement ( "power" , "predicted" , POSITIONS . AT _EQUIPMENT , totalPower , this . unitPolicy . canonical . power ) ;
2026-05-08 18:32:58 +02:00
this . _writeMeasurement ( "flow" , "predicted" , POSITIONS . AT _EQUIPMENT , totalFlow , this . unitPolicy . canonical . flow ) ;
2026-03-12 16:43:29 +01:00
this . measurements . type ( "efficiency" ) . variant ( "predicted" ) . position ( POSITIONS . AT _EQUIPMENT ) . value ( totalFlow / totalPower ) ;
this . measurements . type ( "Ncog" ) . variant ( "predicted" ) . position ( POSITIONS . AT _EQUIPMENT ) . value ( totalCog ) ;
2025-07-01 17:03:36 +02:00
2025-10-02 17:08:41 +02:00
this . logger . debug ( ` Flow distribution: ${ JSON . stringify ( flowDistribution ) } ` ) ;
2025-07-01 17:03:36 +02:00
// Apply the flow distribution to machines
await Promise . all ( flowDistribution . map ( async ( { machineId , flow } ) => {
const machine = this . machines [ machineId ] ;
2026-03-31 18:17:41 +02:00
this . logger . debug ( this . machines [ machineId ] . state ) ;
2025-07-01 17:03:36 +02:00
const currentState = this . machines [ machineId ] . state . getCurrentState ( ) ;
2026-05-08 11:47:17 +02:00
// Same dispatch shape as optimalControl — see the comment
// there for the rationale. flowmovement BEFORE startup so
// concurrent retargets can update delayedMove without a
// stale chained flowmovement overwriting it after startup.
if ( flow > 0 ) {
2026-04-07 13:40:45 +02:00
await machine . handleInput ( "parent" , "flowmovement" , this . _canonicalToOutputFlow ( flow ) ) ;
2026-05-08 11:47:17 +02:00
if ( currentState === "idle" ) {
await machine . handleInput ( "parent" , "execsequence" , "startup" ) ;
}
} else if ( currentState === "operational" || currentState === "accelerating" || currentState === "decelerating" ) {
await machine . handleInput ( "parent" , "execsequence" , "shutdown" ) ;
2025-07-01 17:03:36 +02:00
}
} ) ) ;
}
catch ( err ) {
this . logger . error ( err ) ;
}
}
//only valid with equal machines
async prioPercentageControl ( input , priorityList = null ) {
try {
// stop all machines if input is negative
if ( input < 0 ) {
Fix stale flow cache on MGC shutdown; correct NCog physics tests
### Bug fix — stale flow cache on shutdown (specificClass.js)
When turnOffAllMachines() fires (negative demand, zero flow demand, or
safety trip), the MGC was only shutting pumps down. The pumps' last
emitted predicted flow / power stayed in the MeasurementContainer,
so the parent pumpingStation kept computing net flow from cached
non-zero values — reading the MGC as "still draining" when it wasn't.
Net: net-flow direction and safety triggers misfired during and
shortly after an MGC shutdown.
Fix: after shutting down all machines, write 0 to the predicted
flow (downstream + atEquipment) and predicted power (atEquipment)
slots so the cache reflects reality immediately.
### Correctness — async/await on shutdown (specificClass.js)
Two call sites invoked turnOffAllMachines() without awaiting it, so
the subsequent `return` raced the shutdown promises. Now awaited.
Also DRY'd one inline shutdown loop into a call to
turnOffAllMachines().
### Physics correction — NCog for centrifugal pumps (integration tests)
The previous tests asserted NCog > 0 for centrifugal pumps. That's
physically wrong: for variable-speed centrifugal pumps P ∝ n³ and
Q ∝ n, so Q/P ∝ 1/n² is monotonically decreasing with speed. Peak
efficiency (peak Q/P) is always at minimum speed → cogIndex = 0 →
NCog = 0 by the current formula.
Tests now:
- Assert NCog == 0 for all centrifugal configurations
- Assert distributeByNCog() falls back to equal distribution when
NCog == 0 (confirmed by the existing tests 4-6 that slope-based
redistribution is what actually differentiates pumps with different
BEPs — not NCog)
This matches the actual implementation; the previous tests were
asserting an idealised COG model that doesn't apply here.
### Editor hygiene (mgc.html, nodeClass.js)
- mgc.html: add missing asset-menu defaults (uuid, supplier, category,
assetType, model, unit) — brings MGC in line with rotatingMachine
and pumpingStation editor shapes.
- nodeClass.js: clear node status badge on close.
All 13 tests (basic + integration) pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:51:10 +02:00
await this . turnOffAllMachines ( ) ;
2025-07-01 17:03:36 +02:00
return ;
}
//capp input to 100
2026-03-12 16:43:29 +01:00
if ( input > 100 ) { input = 100 ; }
2025-07-01 17:03:36 +02:00
const numOfMachines = Object . keys ( this . machines ) . length ;
const procentTotal = numOfMachines * input ;
const machinesNeeded = Math . ceil ( procentTotal / 100 ) ;
const activeTotals = this . activeTotals ( ) ;
const machinesActive = activeTotals . countActiveMachines ;
// Get machines sorted by priority
let machinesInPriorityOrder = this . sortMachinesByPriority ( priorityList ) ;
const ctrlDistribution = [ ] ; //{machineId : 0, flow : 0} push for each machine
if ( machinesNeeded > machinesActive ) {
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
//start extra machine and put all active machines at min control
2026-03-12 16:43:29 +01:00
machinesInPriorityOrder . forEach ( ( { id } , index ) => {
2025-07-01 17:03:36 +02:00
if ( index < machinesNeeded ) {
ctrlDistribution . push ( { machineId : id , ctrl : 0 } ) ;
}
} ) ;
}
if ( machinesNeeded < machinesActive ) {
2026-03-31 18:17:41 +02:00
2026-03-12 16:43:29 +01:00
machinesInPriorityOrder . forEach ( ( { id } , index ) => {
2025-07-01 17:03:36 +02:00
if ( this . isMachineActive ( id ) ) {
if ( index < machinesNeeded ) {
ctrlDistribution . push ( { machineId : id , ctrl : 100 } ) ;
}
else {
//turn machine off
ctrlDistribution . push ( { machineId : id , ctrl : - 1 } ) ;
}
}
} ) ;
}
if ( machinesNeeded === machinesActive ) {
// distribute input equally among active machines (0 - 100%)
const ctrlPerMachine = procentTotal / machinesActive ;
2026-03-31 18:17:41 +02:00
2026-03-12 16:43:29 +01:00
machinesInPriorityOrder . forEach ( ( { id } ) => {
2025-07-01 17:03:36 +02:00
if ( this . isMachineActive ( id ) ) {
// ensure ctrl is capped between 0 and 100%
2026-03-31 18:17:41 +02:00
const ctrlValue = Math . max ( 0 , Math . min ( ctrlPerMachine , 100 ) ) ;
2025-07-01 17:03:36 +02:00
ctrlDistribution . push ( { machineId : id , ctrl : ctrlValue } ) ;
}
} ) ;
}
const debugInfo = ctrlDistribution . map ( ( { machineId , ctrl } ) => ` ${ machineId } : ${ ctrl . toFixed ( 2 ) } % ` ) . join ( " | " ) ;
this . logger . debug ( ` Priority control for input: ${ input . toFixed ( 2 ) } -> Active pumps: [ ${ debugInfo } ] ` ) ;
// Apply the ctrl distribution to machines
await Promise . all ( ctrlDistribution . map ( async ( { machineId , ctrl } ) => {
const machine = this . machines [ machineId ] ;
const currentState = this . machines [ machineId ] . state . getCurrentState ( ) ;
if ( ctrl < 0 && ( currentState === "operational" || currentState === "accelerating" || currentState === "decelerating" ) ) {
2025-11-13 19:39:32 +01:00
await machine . handleInput ( "parent" , "execsequence" , "shutdown" ) ;
2025-07-01 17:03:36 +02:00
}
else if ( currentState === "idle" && ctrl >= 0 ) {
2025-11-13 19:39:32 +01:00
await machine . handleInput ( "parent" , "execsequence" , "startup" ) ;
2025-07-01 17:03:36 +02:00
}
else if ( currentState === "operational" && ctrl > 0 ) {
2025-11-13 19:39:32 +01:00
await machine . handleInput ( "parent" , "execmovement" , ctrl ) ;
2025-07-01 17:03:36 +02:00
}
} ) ) ;
const totalPower = [ ] ;
const totalFlow = [ ] ;
// fetch and store measurements
2026-03-12 16:43:29 +01:00
Object . entries ( this . machines ) . forEach ( ( [ _machineId , machine ] ) => {
2025-10-02 17:08:41 +02:00
2026-03-31 18:17:41 +02:00
const powerValue = this . _readChildMeasurement ( machine , "power" , "predicted" , POSITIONS . AT _EQUIPMENT , this . unitPolicy . canonical . power ) ;
const flowValue = this . _readChildMeasurement ( machine , "flow" , "predicted" , POSITIONS . DOWNSTREAM , this . unitPolicy . canonical . flow ) ;
2025-10-02 17:08:41 +02:00
2025-07-01 17:03:36 +02:00
if ( powerValue !== null ) {
totalPower . push ( powerValue ) ;
}
if ( flowValue !== null ) {
totalFlow . push ( flowValue ) ;
}
} ) ;
2026-05-08 18:32:58 +02:00
// Write to AT_EQUIPMENT not DOWNSTREAM. handlePressureChange
// is the canonical writer of DOWNSTREAM (the live aggregate
// that PS subscribes to for outflow). See optimalControl
// comment above.
2026-03-31 18:17:41 +02:00
this . _writeMeasurement ( "power" , "predicted" , POSITIONS . AT _EQUIPMENT , totalPower . reduce ( ( a , b ) => a + b , 0 ) , this . unitPolicy . canonical . power ) ;
2026-05-08 18:32:58 +02:00
this . _writeMeasurement ( "flow" , "predicted" , POSITIONS . AT _EQUIPMENT , totalFlow . reduce ( ( a , b ) => a + b , 0 ) , this . unitPolicy . canonical . flow ) ;
2025-10-02 17:08:41 +02:00
2025-07-01 17:03:36 +02:00
if ( totalPower . reduce ( ( a , b ) => a + b , 0 ) > 0 ) {
2026-03-12 16:43:29 +01:00
this . measurements . type ( "efficiency" ) . variant ( "predicted" ) . position ( POSITIONS . AT _EQUIPMENT ) . value ( totalFlow . reduce ( ( a , b ) => a + b , 0 ) / totalPower . reduce ( ( a , b ) => a + b , 0 ) ) ;
2025-07-01 17:03:36 +02:00
}
}
catch ( err ) {
this . logger . error ( err ) ;
}
}
2026-03-31 18:17:41 +02:00
2025-10-02 17:08:41 +02:00
async handleInput ( source , demand , powerCap = Infinity , priorityList = null ) {
2026-05-09 09:14:59 +02:00
// Serialize dispatches: if a previous handleInput is still
// awaiting pump movements, park the latest demand and return.
// The in-flight dispatch's `finally` block will pick it up.
// See rotatingMachine state.delayedMove for the analogous
// pattern at the pump level.
if ( this . _dispatchInFlight ) {
this . _delayedCall = { source , demand , powerCap , priorityList } ;
this . logger . debug ( ` Dispatch in flight; deferring demand= ${ demand } until current pump moves complete. ` ) ;
return ;
}
this . _dispatchInFlight = true ;
try {
return await this . _runDispatch ( source , demand , powerCap , priorityList ) ;
} finally {
this . _dispatchInFlight = false ;
// Pick up the latest deferred call (intermediate values were
// stomped while we were busy — only the last one matters).
if ( this . _delayedCall ) {
const next = this . _delayedCall ;
this . _delayedCall = null ;
this . logger . debug ( ` Dispatch finished; picking up deferred demand= ${ next . demand } . ` ) ;
// Recursive call re-enters the gate; safe because
// _dispatchInFlight has been reset to false above.
await this . handleInput ( next . source , next . demand , next . powerCap , next . priorityList ) ;
}
}
}
async _runDispatch ( source , demand , powerCap = Infinity , priorityList = null ) {
2025-11-13 19:39:32 +01:00
const demandQ = parseFloat ( demand ) ;
if ( ! Number . isFinite ( demandQ ) ) {
this . logger . error ( ` Invalid flow demand input: ${ demand } . Must be a finite number. ` ) ;
return ;
}
2025-10-02 17:08:41 +02:00
//abort current movements
await this . abortActiveMovements ( "new demand received" ) ;
2025-07-01 17:03:36 +02:00
const scaling = this . scaling ;
const mode = this . mode ;
2025-10-02 17:08:41 +02:00
const dynamicTotals = this . calcDynamicTotals ( ) ;
let demandQout = 0 ; // keep output Q by default 0 for safety
this . logger . debug ( ` Handling input from ${ source } : Demand = ${ demand } , Power Cap = ${ powerCap } , Priority List = ${ priorityList } ` ) ;
2025-07-01 17:03:36 +02:00
switch ( scaling ) {
case "absolute" :
2025-10-02 17:08:41 +02:00
if ( isNaN ( demandQ ) ) {
this . logger . warn ( ` Invalid absolute flow demand: ${ demand } . Must be a number. ` ) ;
demandQout = 0 ;
return ;
}
2026-03-31 18:17:41 +02:00
2026-04-07 13:40:45 +02:00
if ( demandQ <= 0 ) {
2025-10-02 17:08:41 +02:00
this . logger . debug ( ` Turning machines off ` ) ;
demandQout = 0 ;
Fix stale flow cache on MGC shutdown; correct NCog physics tests
### Bug fix — stale flow cache on shutdown (specificClass.js)
When turnOffAllMachines() fires (negative demand, zero flow demand, or
safety trip), the MGC was only shutting pumps down. The pumps' last
emitted predicted flow / power stayed in the MeasurementContainer,
so the parent pumpingStation kept computing net flow from cached
non-zero values — reading the MGC as "still draining" when it wasn't.
Net: net-flow direction and safety triggers misfired during and
shortly after an MGC shutdown.
Fix: after shutting down all machines, write 0 to the predicted
flow (downstream + atEquipment) and predicted power (atEquipment)
slots so the cache reflects reality immediately.
### Correctness — async/await on shutdown (specificClass.js)
Two call sites invoked turnOffAllMachines() without awaiting it, so
the subsequent `return` raced the shutdown promises. Now awaited.
Also DRY'd one inline shutdown loop into a call to
turnOffAllMachines().
### Physics correction — NCog for centrifugal pumps (integration tests)
The previous tests asserted NCog > 0 for centrifugal pumps. That's
physically wrong: for variable-speed centrifugal pumps P ∝ n³ and
Q ∝ n, so Q/P ∝ 1/n² is monotonically decreasing with speed. Peak
efficiency (peak Q/P) is always at minimum speed → cogIndex = 0 →
NCog = 0 by the current formula.
Tests now:
- Assert NCog == 0 for all centrifugal configurations
- Assert distributeByNCog() falls back to equal distribution when
NCog == 0 (confirmed by the existing tests 4-6 that slope-based
redistribution is what actually differentiates pumps with different
BEPs — not NCog)
This matches the actual implementation; the previous tests were
asserting an idealised COG model that doesn't apply here.
### Editor hygiene (mgc.html, nodeClass.js)
- mgc.html: add missing asset-menu defaults (uuid, supplier, category,
assetType, model, unit) — brings MGC in line with rotatingMachine
and pumpingStation editor shapes.
- nodeClass.js: clear node status badge on close.
All 13 tests (basic + integration) pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:51:10 +02:00
await this . turnOffAllMachines ( ) ;
2025-10-02 17:08:41 +02:00
return ;
2026-04-07 13:40:45 +02:00
} else if ( demandQ < this . absoluteTotals . flow . min ) {
this . logger . warn ( ` Flow demand ${ demandQ } is below minimum possible flow ${ this . absoluteTotals . flow . min } . Capping to minimum flow. ` ) ;
demandQout = this . absoluteTotals . flow . min ;
} else if ( demandQ > this . absoluteTotals . flow . max ) {
this . logger . warn ( ` Flow demand ${ demandQ } is above maximum possible flow ${ this . absoluteTotals . flow . max } . Capping to maximum flow. ` ) ;
demandQout = this . absoluteTotals . flow . max ;
} else {
demandQout = demandQ ;
2025-07-01 17:03:36 +02:00
}
break ;
case "normalized" :
2025-10-02 17:08:41 +02:00
this . logger . debug ( ` Normalizing flow demand: ${ demandQ } with min: ${ dynamicTotals . flow . min } and max: ${ dynamicTotals . flow . max } ` ) ;
2026-05-08 11:19:47 +02:00
// demand <= 0 → off. Previously only `< 0` triggered off,
// so demand=0 fell through to interpolate(0, 0..100, min..max)
// which returns flow.min — i.e., a pumpingStation dead-zone
// (level in [stopLevel, startLevel] sending percControl=0)
// would silently keep a pump running at min flow,
// balancing inflow and pinning the basin in the dead band.
if ( demandQ <= 0 ) {
this . logger . debug ( ` Demand ≤ 0 — turning all machines off ` ) ;
2025-10-02 17:08:41 +02:00
demandQout = 0 ;
Fix stale flow cache on MGC shutdown; correct NCog physics tests
### Bug fix — stale flow cache on shutdown (specificClass.js)
When turnOffAllMachines() fires (negative demand, zero flow demand, or
safety trip), the MGC was only shutting pumps down. The pumps' last
emitted predicted flow / power stayed in the MeasurementContainer,
so the parent pumpingStation kept computing net flow from cached
non-zero values — reading the MGC as "still draining" when it wasn't.
Net: net-flow direction and safety triggers misfired during and
shortly after an MGC shutdown.
Fix: after shutting down all machines, write 0 to the predicted
flow (downstream + atEquipment) and predicted power (atEquipment)
slots so the cache reflects reality immediately.
### Correctness — async/await on shutdown (specificClass.js)
Two call sites invoked turnOffAllMachines() without awaiting it, so
the subsequent `return` raced the shutdown promises. Now awaited.
Also DRY'd one inline shutdown loop into a call to
turnOffAllMachines().
### Physics correction — NCog for centrifugal pumps (integration tests)
The previous tests asserted NCog > 0 for centrifugal pumps. That's
physically wrong: for variable-speed centrifugal pumps P ∝ n³ and
Q ∝ n, so Q/P ∝ 1/n² is monotonically decreasing with speed. Peak
efficiency (peak Q/P) is always at minimum speed → cogIndex = 0 →
NCog = 0 by the current formula.
Tests now:
- Assert NCog == 0 for all centrifugal configurations
- Assert distributeByNCog() falls back to equal distribution when
NCog == 0 (confirmed by the existing tests 4-6 that slope-based
redistribution is what actually differentiates pumps with different
BEPs — not NCog)
This matches the actual implementation; the previous tests were
asserting an idealised COG model that doesn't apply here.
### Editor hygiene (mgc.html, nodeClass.js)
- mgc.html: add missing asset-menu defaults (uuid, supplier, category,
assetType, model, unit) — brings MGC in line with rotatingMachine
and pumpingStation editor shapes.
- nodeClass.js: clear node status badge on close.
All 13 tests (basic + integration) pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:51:10 +02:00
await this . turnOffAllMachines ( ) ;
2025-10-02 17:08:41 +02:00
return ;
}
2026-05-08 11:19:47 +02:00
// Scale demand to flow range. interpolate_lin_single_point
// maps demandQ (0..100) onto (flow.min..flow.max) linearly.
demandQout = this . interpolation . interpolate _lin _single _point ( demandQ , 0 , 100 , dynamicTotals . flow . min , dynamicTotals . flow . max ) ;
this . logger . debug ( ` Normalized flow demand ${ demandQ } % to: ${ demandQout } Q units ` ) ;
2025-07-01 17:03:36 +02:00
break ;
}
2025-10-02 17:08:41 +02:00
// Execute control based on mode
2025-07-01 17:03:36 +02:00
switch ( mode ) {
case "prioritycontrol" :
2025-10-02 17:08:41 +02:00
this . logger . debug ( ` Calculating prio control. Input flow demand: ${ demandQ } scaling : ${ scaling } -> ${ demandQout } ` ) ;
await this . equalFlowControl ( demandQout , powerCap , priorityList ) ;
2025-07-01 17:03:36 +02:00
break ;
2025-10-02 17:08:41 +02:00
2025-07-01 17:03:36 +02:00
case "prioritypercentagecontrol" :
2025-10-02 17:08:41 +02:00
this . logger . debug ( ` Calculating prio percentage control. Input flow demand: ${ demandQ } scaling : ${ scaling } -> ${ demandQout } ` ) ;
2025-07-01 17:03:36 +02:00
if ( scaling !== "normalized" ) {
this . logger . warn ( "Priority percentage control is only valid with normalized scaling." ) ;
return ;
}
2026-03-31 18:17:41 +02:00
await this . prioPercentageControl ( demandQout , priorityList ) ;
2025-07-01 17:03:36 +02:00
break ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
case "optimalcontrol" :
2025-10-02 17:08:41 +02:00
this . logger . debug ( ` Calculating optimal control. Input flow demand: ${ demandQ } scaling : ${ scaling } -> ${ demandQout } ` ) ;
await this . optimalControl ( demandQout , powerCap ) ;
2025-07-01 17:03:36 +02:00
break ;
2025-09-23 15:03:57 +02:00
default :
this . logger . warn ( ` ${ mode } is not a valid mode. ` ) ;
break ;
2025-07-01 17:03:36 +02:00
}
//recalc distance from BEP
const { maxEfficiency , lowestEfficiency } = this . calcGroupEfficiency ( this . machines ) ;
2026-03-31 18:17:41 +02:00
const efficiency = this . measurements . type ( "efficiency" ) . variant ( "predicted" ) . position ( POSITIONS . AT _EQUIPMENT ) . getCurrentValue ( ) ;
2025-07-01 17:03:36 +02:00
this . calcDistanceBEP ( efficiency , maxEfficiency , lowestEfficiency ) ;
}
2025-10-02 17:08:41 +02:00
async turnOffAllMachines ( ) {
await Promise . all ( Object . entries ( this . machines ) . map ( async ( [ machineId , machine ] ) => {
2025-11-13 19:39:32 +01:00
if ( this . isMachineActive ( machineId ) ) { await machine . handleInput ( "parent" , "execsequence" , "shutdown" ) ; }
2025-10-02 17:08:41 +02:00
} ) ) ;
Fix stale flow cache on MGC shutdown; correct NCog physics tests
### Bug fix — stale flow cache on shutdown (specificClass.js)
When turnOffAllMachines() fires (negative demand, zero flow demand, or
safety trip), the MGC was only shutting pumps down. The pumps' last
emitted predicted flow / power stayed in the MeasurementContainer,
so the parent pumpingStation kept computing net flow from cached
non-zero values — reading the MGC as "still draining" when it wasn't.
Net: net-flow direction and safety triggers misfired during and
shortly after an MGC shutdown.
Fix: after shutting down all machines, write 0 to the predicted
flow (downstream + atEquipment) and predicted power (atEquipment)
slots so the cache reflects reality immediately.
### Correctness — async/await on shutdown (specificClass.js)
Two call sites invoked turnOffAllMachines() without awaiting it, so
the subsequent `return` raced the shutdown promises. Now awaited.
Also DRY'd one inline shutdown loop into a call to
turnOffAllMachines().
### Physics correction — NCog for centrifugal pumps (integration tests)
The previous tests asserted NCog > 0 for centrifugal pumps. That's
physically wrong: for variable-speed centrifugal pumps P ∝ n³ and
Q ∝ n, so Q/P ∝ 1/n² is monotonically decreasing with speed. Peak
efficiency (peak Q/P) is always at minimum speed → cogIndex = 0 →
NCog = 0 by the current formula.
Tests now:
- Assert NCog == 0 for all centrifugal configurations
- Assert distributeByNCog() falls back to equal distribution when
NCog == 0 (confirmed by the existing tests 4-6 that slope-based
redistribution is what actually differentiates pumps with different
BEPs — not NCog)
This matches the actual implementation; the previous tests were
asserting an idealised COG model that doesn't apply here.
### Editor hygiene (mgc.html, nodeClass.js)
- mgc.html: add missing asset-menu defaults (uuid, supplier, category,
assetType, model, unit) — brings MGC in line with rotatingMachine
and pumpingStation editor shapes.
- nodeClass.js: clear node status badge on close.
All 13 tests (basic + integration) pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:51:10 +02:00
// Update measurements to zero so the parent (PS) sees the
// outflow drop immediately — without this the PS keeps the
// last active flow value cached and computes wrong net flow.
this . _writeMeasurement ( "flow" , "predicted" , POSITIONS . DOWNSTREAM , 0 , this . unitPolicy . canonical . flow ) ;
this . _writeMeasurement ( "flow" , "predicted" , POSITIONS . AT _EQUIPMENT , 0 , this . unitPolicy . canonical . flow ) ;
this . _writeMeasurement ( "power" , "predicted" , POSITIONS . AT _EQUIPMENT , 0 , this . unitPolicy . canonical . power ) ;
2025-10-02 17:08:41 +02:00
}
2026-03-11 11:12:52 +01:00
_buildUnitPolicy ( config = { } ) {
const flowUnit = this . _resolveUnitOrFallback (
config ? . general ? . unit ,
'volumeFlowRate' ,
DEFAULT _IO _UNITS . flow
) ;
const pressureUnit = this . _resolveUnitOrFallback (
config ? . general ? . pressureUnit ,
'pressure' ,
DEFAULT _IO _UNITS . pressure
) ;
const powerUnit = this . _resolveUnitOrFallback (
config ? . general ? . powerUnit ,
'power' ,
DEFAULT _IO _UNITS . power
) ;
return {
canonical : { ... CANONICAL _UNITS } ,
output : {
flow : flowUnit ,
pressure : pressureUnit ,
power : powerUnit ,
temperature : DEFAULT _IO _UNITS . temperature ,
} ,
} ;
}
_resolveUnitOrFallback ( candidate , expectedMeasure , fallbackUnit ) {
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 } ', got ' ${ desc . measure } ' ` ) ;
}
return raw ;
} catch ( error ) {
this . logger ? . warn ? . ( ` Invalid unit ' ${ raw } ' ( ${ error . message } ); falling back to ' ${ fallback } '. ` ) ;
return fallback ;
}
}
2026-05-08 11:19:47 +02:00
2026-04-07 13:40:45 +02:00
_canonicalToOutputFlow ( value ) {
const from = this . unitPolicy . canonical . flow ;
const to = this . unitPolicy . output . flow ;
if ( ! from || ! to || from === to ) return value ;
return convert ( value ) . from ( from ) . to ( to ) ;
}
2026-03-11 11:12:52 +01:00
_outputUnitForType ( type ) {
switch ( String ( type || '' ) . toLowerCase ( ) ) {
case 'flow' :
return this . unitPolicy . output . flow ;
case 'power' :
return this . unitPolicy . output . power ;
case 'pressure' :
return this . unitPolicy . output . pressure ;
case 'temperature' :
return this . unitPolicy . output . temperature ;
default :
return null ;
}
}
_readMeasurement ( type , variant , position , unit = null ) {
const requestedUnit = unit || this . _outputUnitForType ( type ) ;
return this . measurements
. type ( type )
. variant ( variant )
. position ( position )
. getCurrentValue ( requestedUnit || undefined ) ;
}
_writeMeasurement ( type , variant , position , value , unit = null , timestamp = Date . now ( ) ) {
if ( ! Number . isFinite ( value ) ) {
return ;
}
this . measurements
. type ( type )
. variant ( variant )
. position ( position )
. value ( value , timestamp , unit || undefined ) ;
}
_readChildMeasurement ( machine , type , variant , position , unit = null ) {
return machine ? . measurements
? . type ( type )
? . variant ( variant )
? . position ( position )
? . getCurrentValue ( unit || undefined ) ;
}
_writeChildMeasurement ( machine , type , variant , position , value , unit = null , timestamp = Date . now ( ) ) {
if ( ! machine ? . measurements || ! Number . isFinite ( value ) ) {
return ;
}
machine . measurements
. type ( type )
. variant ( variant )
. position ( position )
. value ( value , timestamp , unit || undefined ) ;
}
2025-09-23 15:03:57 +02:00
setMode ( mode ) {
this . mode = mode ;
2025-07-01 17:03:36 +02:00
}
getOutput ( ) {
// Improved output object generation
const output = { } ;
//build the output object
this . measurements . getTypes ( ) . forEach ( type => {
this . measurements . getVariants ( type ) . forEach ( variant => {
2026-03-31 18:17:41 +02:00
const unit = this . _outputUnitForType ( type ) ;
const downstreamVal = this . _readMeasurement ( type , variant , POSITIONS . DOWNSTREAM , unit ) ;
const atEquipmentVal = this . _readMeasurement ( type , variant , POSITIONS . AT _EQUIPMENT , unit ) ;
const upstreamVal = this . _readMeasurement ( type , variant , POSITIONS . UPSTREAM , unit ) ;
2025-07-01 17:03:36 +02:00
if ( downstreamVal != null ) {
output [ ` downstream_ ${ variant } _ ${ type } ` ] = downstreamVal ;
}
if ( upstreamVal != null ) {
output [ ` upstream_ ${ variant } _ ${ type } ` ] = upstreamVal ;
}
2025-10-02 17:08:41 +02:00
if ( atEquipmentVal != null ) {
output [ ` atEquipment_ ${ variant } _ ${ type } ` ] = atEquipmentVal ;
}
2025-07-01 17:03:36 +02:00
if ( downstreamVal != null && upstreamVal != null ) {
2026-03-31 18:17:41 +02:00
const diff = this . measurements
. type ( type )
. variant ( variant )
. difference ( { from : POSITIONS . DOWNSTREAM , to : POSITIONS . UPSTREAM , unit } ) ;
if ( diff ? . value != null ) {
output [ ` differential_ ${ variant } _ ${ type } ` ] = diff . value ;
}
2025-07-01 17:03:36 +02:00
}
} ) ;
} ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
//fill in the rest of the output object
output [ "mode" ] = this . mode ;
output [ "scaling" ] = this . scaling ;
output [ "flow" ] = this . flow ;
output [ "power" ] = this . power ;
output [ "NCog" ] = this . NCog ; // normalized cog
output [ "absDistFromPeak" ] = this . absDistFromPeak ;
output [ "relDistFromPeak" ] = this . relDistFromPeak ;
//this.logger.debug(`Output: ${JSON.stringify(output)}`);
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
return output ;
}
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
}
module . exports = MachineGroup ;
2025-11-13 19:39:32 +01:00
/ *
2025-11-25 15:10:36 +01:00
const { coolprop } = require ( 'generalFunctions' ) ;
2025-09-23 11:19:22 +02:00
const Machine = require ( '../../rotatingMachine/src/specificClass' ) ;
const Measurement = require ( '../../measurement/src/specificClass' ) ;
const specs = require ( '../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json' ) ;
2025-10-02 17:08:41 +02:00
const { max } = require ( "mathjs" ) ;
2025-07-01 17:03:36 +02:00
2025-09-23 11:19:22 +02:00
function createBaseMachineConfig ( machineNum , name , specs ) {
2025-07-01 17:03:36 +02:00
return {
2026-03-31 18:17:41 +02:00
general : {
logging : { enabled : true , logLevel : "debug" } ,
2025-07-01 17:03:36 +02:00
name : name ,
2025-09-23 11:19:22 +02:00
id : machineNum ,
2025-07-01 17:03:36 +02:00
unit : "m3/h"
} ,
functionality : {
softwareType : "machine" ,
2025-09-23 11:19:22 +02:00
role : "rotationaldevicecontroller"
2025-07-01 17:03:36 +02:00
} ,
asset : {
2025-09-23 11:19:22 +02:00
category : "pump" ,
type : "centrifugal" ,
model : "hidrostal-h05k-s03r" ,
supplier : "hydrostal" ,
machineCurve : specs
2025-07-01 17:03:36 +02:00
} ,
mode : {
current : "auto" ,
allowedActions : {
2025-11-13 19:39:32 +01:00
auto : [ "execsequence" , "execmovement" , "statuscheck" ] ,
virtualControl : [ "execmovement" , "statuscheck" ] ,
fysicalControl : [ "statuscheck" ]
2025-07-01 17:03:36 +02:00
} ,
allowedSources : {
auto : [ "parent" , "GUI" ] ,
virtualControl : [ "GUI" ] ,
fysicalControl : [ "fysical" ]
}
} ,
sequences : {
startup : [ "starting" , "warmingup" , "operational" ] ,
shutdown : [ "stopping" , "coolingdown" , "idle" ] ,
emergencystop : [ "emergencystop" , "off" ] ,
boot : [ "idle" , "starting" , "warmingup" , "operational" ]
}
} ;
}
2025-10-02 17:08:41 +02:00
function createStateConfig ( ) {
return {
time : {
starting : 1 ,
stopping : 1 ,
warmingup : 1 ,
coolingdown : 1 ,
emergencystop : 1
} ,
movement : {
mode : "dynspeed" ,
speed : 100 ,
maxSpeed : 1000
}
}
} ;
2025-07-01 17:03:36 +02:00
function createBaseMachineGroupConfig ( name ) {
return {
2026-03-31 18:17:41 +02:00
general : {
logging : { enabled : true , logLevel : "debug" } ,
2025-07-01 17:03:36 +02:00
name : name
} ,
functionality : {
2025-09-23 11:19:22 +02:00
softwareType : "machinegroup" ,
role : "groupcontroller"
2025-07-01 17:03:36 +02:00
} ,
scaling : {
current : "normalized"
} ,
mode : {
current : "optimalControl"
}
} ;
}
2025-09-23 11:19:22 +02:00
const machineGroupConfig = createBaseMachineGroupConfig ( "testmachinegroup" ) ;
2025-10-02 17:08:41 +02:00
const stateConfigs = { } ;
2025-09-23 11:19:22 +02:00
const machineConfigs = { } ;
2025-10-02 17:08:41 +02:00
stateConfigs [ 1 ] = createStateConfig ( ) ;
stateConfigs [ 2 ] = createStateConfig ( ) ;
machineConfigs [ 1 ] = createBaseMachineConfig ( "asdfkj;asdf" , "testmachine" , specs ) ;
machineConfigs [ 2 ] = createBaseMachineConfig ( "asdfkj;asdf2" , "testmachine2" , specs ) ;
2025-09-23 11:19:22 +02:00
2025-07-01 17:03:36 +02:00
const ptConfig = {
general : {
logging : { enabled : true , logLevel : "debug" } ,
2025-09-23 11:19:22 +02:00
name : "testpt" ,
id : "0" ,
2025-07-01 17:03:36 +02:00
unit : "mbar" ,
} ,
functionality : {
softwareType : "measurement" ,
2025-09-23 11:19:22 +02:00
role : "sensor"
2025-07-01 17:03:36 +02:00
} ,
asset : {
2025-09-23 11:19:22 +02:00
category : "sensor" ,
type : "pressure" ,
model : "testmodel" ,
2025-07-01 17:03:36 +02:00
supplier : "vega"
} ,
scaling : {
absMin : 0 ,
absMax : 4000 ,
}
}
async function makeMachines ( ) {
const mg = new MachineGroup ( machineGroupConfig ) ;
const pt1 = new Measurement ( ptConfig ) ;
const numofMachines = 2 ;
2025-09-23 11:19:22 +02:00
for ( let i = 1 ; i <= numofMachines ; i ++ ) {
2025-10-02 17:08:41 +02:00
const machine = new Machine ( machineConfigs [ i ] , stateConfigs [ i ] ) ;
2025-07-01 17:03:36 +02:00
//mg.machines[i] = machine;
mg . childRegistrationUtils . registerChild ( machine , "downstream" ) ;
}
2025-10-02 17:08:41 +02:00
Object . keys ( mg . machines ) . forEach ( machineId => {
mg . machines [ machineId ] . childRegistrationUtils . registerChild ( pt1 , "downstream" ) ;
} ) ;
mg . setMode ( "prioritycontrol" ) ;
2025-07-01 17:03:36 +02:00
mg . setScaling ( "normalized" ) ;
const absMax = mg . dynamicTotals . flow . max ;
const absMin = mg . dynamicTotals . flow . min ;
const percMin = 0 ;
const percMax = 100 ;
try {
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
for ( let demand = mg . dynamicTotals . flow . min ; demand <= mg . dynamicTotals . flow . max ; demand += 2 ) {
//set pressure
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
console . log ( "------------------------------------" ) ;
await mg . handleInput ( "parent" , demand ) ;
pt1 . calculateInput ( 1400 ) ;
//await new Promise(resolve => setTimeout(resolve, 200));
console . log ( "------------------------------------" ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
}
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
for ( let demand = 240 ; demand >= mg . dynamicTotals . flow . min ; demand -= 40 ) {
//set pressure
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
console . log ( "------------------------------------" ) ;
await mg . handleInput ( "parent" , demand ) ;
pt1 . calculateInput ( 1400 ) ;
//await new Promise(resolve => setTimeout(resolve, 200));
console . log ( "------------------------------------" ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
}
2025-10-02 17:08:41 +02:00
//*//*
for ( let demand = 0 ; demand <= 50 ; demand += 1 ) {
2025-07-01 17:03:36 +02:00
//set pressure
2025-10-02 17:08:41 +02:00
console . log ( ` TESTING: processing demand of ${ demand } ` ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
await mg . handleInput ( "parent" , demand ) ;
2025-10-02 17:08:41 +02:00
Object . keys ( mg . machines ) . forEach ( machineId => {
console . log ( mg . machines [ machineId ] . state . getCurrentState ( ) ) ;
} ) ;
console . log ( ` updating pressure to 1400 mbar ` ) ;
2025-07-01 17:03:36 +02:00
pt1 . calculateInput ( 1400 ) ;
console . log ( "------------------------------------" ) ;
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
}
}
catch ( err ) {
console . log ( err ) ;
}
2026-03-31 18:17:41 +02:00
2025-07-01 17:03:36 +02:00
}
2026-02-23 13:17:39 +01:00
if ( require . main === module ) {
makeMachines ( ) ;
}
2025-09-23 11:19:22 +02:00
2025-09-23 15:03:57 +02:00
//*/