diff --git a/src/specificClass.js b/src/specificClass.js index b5671c4..d59a3d6 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -1,843 +1,674 @@ const EventEmitter = require('events'); const {logger,configUtils,configManager,childRegistrationUtils,MeasurementContainer,coolprop,interpolation} = require('generalFunctions'); - -class pumpingStation { - constructor(config={}) { - - this.emitter = new EventEmitter(); // Own EventEmitter - this.configManager = new configManager(); +class PumpingStationV2 { + constructor(config = {}) { + this.emitter = new EventEmitter(); + this.configManager = new configManager(); this.defaultConfig = this.configManager.getConfig('pumpingStation'); this.configUtils = new configUtils(this.defaultConfig); this.config = this.configUtils.initConfig(config); this.interpolate = new interpolation(); + this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel,this.config.general.name); - // Init after config is set - this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name); + this.measurements = new MeasurementContainer({ autoConvert: true }); + this.measurements.setPreferredUnit('flow', 'm3/s'); + this.measurements.setPreferredUnit('level', 'm'); + this.measurements.setPreferredUnit('volume', 'm3'); + this.childRegistrationUtils = new childRegistrationUtils(this); + this.machines = {}; + this.stations = {}; - // General properties - this.measurements = new MeasurementContainer({ - autoConvert: true - }); + //variants in determining what gets priority + this.flowVariants = ['measured', 'predicted']; + this.levelVariants = ['measured', 'predicted']; + this.flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }; - // init basin object in pumping station this.basin = {}; this.state = { - direction: "steady", + direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null - }; // init state object of pumping station to see whats going on + }; + + const thresholdFromConfig = Number(this.config.general?.flowThreshold); + this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4; - // Initialize basin-specific properties and calculate used parameters this.initBasinProperties(); - this.parent = {}; // object to hold parent information for when we follow flow directions. - this.child = {}; // object to hold child information so we know on what to subscribe - this.machines = {}; // object to hold child machine information - this.stations = {}; // object to hold station information - this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility - - this.logger.debug('pumpstation Initialized with all helpers'); + this.logger.debug('PumpingStationV2 initialized'); } - /*------------------- Register child events -------------------*/ registerChild(child, softwareType) { - this.logger.debug('Setting up child event for softwaretype ' + softwareType); + this.logger.debug(`Registering child (${softwareType}) "${child.config.general.name}"`); - //define what to do with measurements - if(softwareType === "measurement"){ - const position = child.config.functionality.positionVsParent; - const distance = child.config.functionality.distanceVsParent || 0; - const measurementType = child.config.asset.type; - const key = `${measurementType}_${position}`; - //rebuild to measurementype.variant no position and then switch based on values not strings or names. - const eventName = `${measurementType}.measured.${position}`; - - this.logger.debug(`Setting up listener for ${eventName} from child ${child.config.general.name}`); - // Register event listener for measurement updates - child.measurements.emitter.on(eventName, (eventData) => { - this.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`); - - this.logger.debug(` Emitting... ${eventName} with data:`); - // Store directly in parent's measurement container - this.measurements.type(measurementType).variant("measured").position(position).value(eventData.value, eventData.timestamp, eventData.unit); - - // Call the appropriate handler - this._callMeasurementHandler(measurementType, eventData.value, position, eventData); - }); - } - - //define what to do when machines are connected - 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.`); - - //listen for machine pressure changes - this.logger.debug(`Listening for flow changes from machine ${child.config.general.id}`); - - switch(child.config.functionality.positionVsParent){ - case("downstream"): - case("atequipment"): //in case of atequipment we also assume downstream seeing as it is registered at this pumpingstation as part of it. - //for now lets focus on handling downstream predicted flow - child.measurements.emitter.on("flow.predicted.downstream", (eventData) => { - this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`); - this.measurements.type('flow').variant('predicted').position('out').value(eventData.value,eventData.timestamp,eventData.unit); - }); - break; - - case("upstream"): - //check for predicted outgoing flow at the connected child pumpingsation - child.measurements.emitter.on("flow.predicted.downstream", (eventData) => { - this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`); - //register this then as upstream flow that arrives at the station - this.measurements.type('flow').variant('predicted').position('in').value(eventData.value,eventData.timestamp,eventData.unit); - }); - break; - - default: - this.logger.warn(`nu such position ${child.config.functionality.positionVsParent}`); - } - - } - - // add one for group later - if( softwareType == "machineGroup" ){ - - } - - // add one for pumping station - if ( softwareType == "pumpingStation"){ - // Check if the machine is already registered - this.stations[child.config.general.id] === undefined ? this.machistationsnes[child.config.general.id] = child : this.logger.warn(`Machine ${child.config.general.id} is already registered.`); - - //listen for machine pressure changes - this.logger.debug(`Listening for flow changes from machine ${child.config.general.id}`); - - switch(child.config.functionality.positionVsParent){ - case("downstream"): - //check for predicted outgoing flow at the connected child pumpingsation - child.measurements.emitter.on("flow.predicted.downstream", (eventData) => { - this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`); - //register this then as upstream flow that arrives at the station - this.measurements.type('flow').variant('predicted').position('out').value(eventData.value,eventData.timestamp,eventData.unit); - }); - break; - - case("upstream"): - //check for predicted outgoing flow at the connected child pumpingsation - child.measurements.emitter.on("flow.predicted.downstream", (eventData) => { - this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`); - //register this then as upstream flow that arrives at the station - this.measurements.type('flow').variant('predicted').position('in').value(eventData.value,eventData.timestamp,eventData.unit); - }); - break; - - default: - // there is no such thing as atequipment from 1 pumpingstation to another.... - this.logger.warn(`nu such position ${child.config.functionality.positionVsParent} for pumping station`); - } - } - } - - //in or outgoing flow = direction - _updateVolumePrediction(flowDir){ - - //get downflow - const seriesExists = this.measurements.type("flow").variant("predicted").position(flowDir).exists(); - if(!seriesExists){return}; - - const series = this.measurements.type("flow").variant("predicted").position(flowDir); - const currFLow = series.getLaggedValue(0, "m3/s"); // { value, timestamp, unit } - const prevFlow = series.getLaggedValue(1, "m3/s"); // { value, timestamp, unit } - - if (!currFLow || !prevFlow) return; - - this.logger.debug(`Flowdir ${flowDir} => currFlow ${currFLow.value} , prevflow = ${prevFlow.value}`); - - // calc difference in time - const deltaT = currFLow.timestamp - prevFlow.timestamp; - const deltaSeconds = deltaT / 1000; - - if (deltaSeconds <= 0) { - this.logger.warn(`Flow integration aborted; invalid Δt=${deltaSeconds}s.`); + if (softwareType === 'measurement') { + this._registerMeasurementChild(child); return; } - - const avgFlow = (currFLow.value + prevFlow.value) / 2; - const calcVol = avgFlow * deltaSeconds; - //substract seeing as this is downstream and is being pulled away from the pumpingstaion and keep track of status - const currVolume = this.measurements.type('volume').variant('predicted').position('atEquipment').getCurrentValue('m3'); - let newVol = currVolume; - - switch(flowDir){ - case("out"): - newVol = currVolume - calcVol; - break; - - case("in"): - newVol = currVolume + calcVol; - break; - - default: - this.logger.error('Flow must come in or out of the station!'); - } - - - this.measurements.type('volume').variant('predicted').position('atEquipment').value(newVol).unit('m3'); - //convert to a predicted level - const newLevel = this._calcLevelFromVolume(newVol); - - this.measurements.type('level').variant('predicted').position('atEquipment').value(newLevel).unit('m'); - - this.logger.debug(`new predicted volume : ${newVol} new predicted level: ${newLevel} `); - - } - - - //trigger shutdown when level is too low and trigger no start flag for childs ? - safetyVolCheck(){ - - } - - - //update measured temperature to adjust density of liquid - updateMeasuredTemperature(){ - - } - - //update measured flow and recalc - updateMeasuredFlow(){ - - } - - //keep updating the volume / level when the flow is still active from a machine or machinegroup or incoming from another source - tick(){ - //go through all the functions that require time based checks or updates - this._updateVolumePrediction("out"); //check for changes in outgoing flow - this._updateVolumePrediction("in"); // check for changes in incomming flow - - //calc the most important values back to determine state and net up or downstream flow - //this._calcNetFlow(); - const {time:timeleft, source:variant} = this._calcTimeRemaining(); - - this.logger.debug(`Remaining time ~${Math.round(timeleft/60/60*10)/10} h, based on variant ${variant} `); - } - - _calcTimeRemaining(){ - //init timeRemaining - const winningTime = {time:0,source:""}; - - //calculate time left prioritise flow based variant - const { time: flowTime, variant: flowVariant } = this._selectBestRemainingTimeFlowVariant(); - - //if flow doesnt work then use level based varianti to calc timeleft - if(flowVariant == null){ - const {time: levelTime, variant: levelVariant} = this._selectBestRemainingTimeLevelVariant(); - winningTime.time = levelTime; - winningTime.source = levelVariant; - if(levelVariant == null){ - winningTime.time = null; - winningTime.source = null; - } - } - else{ - winningTime.time = flowTime; - winningTime.source = flowVariant; + if (softwareType === 'machine' || softwareType === 'pumpingStation') { + this._registerPredictedFlowChild(child); + return; } - return winningTime; + this.logger.warn(`Unsupported child software type: ${softwareType}`); } - // Select remaining time based on flow + level variation measured or predicted and give back {time:0,variant:null}; - _selectBestRemainingTimeFlowVariant(){ + tick() { + const snapshot = this._takeMeasurementSnapshot(); - //define variants - const remainingTimeVariants = [ - { flowVariant: "measured", levelVariant: "measured" }, - { flowVariant: "measured", levelVariant: "predicted" }, - { flowVariant: "predicted", levelVariant: "measured" }, - { flowVariant: "predicted", levelVariant: "predicted" } - ]; + this._updatePredictedVolume(snapshot); - let remainingT = null; + const netFlow = this._selectBestNetFlow(snapshot); + const remaining = this._computeRemainingTime(snapshot, netFlow); - for (const variant of remainingTimeVariants) { - const candidate = this._calcRemainingTimeBasedOnFlow(variant); - if (candidate != null) { - remainingT = candidate; - return {time:remainingT,variant:variant}; - } - } - return {time:0,variant:null}; + this.state = { + direction: netFlow.direction, + netFlow: netFlow.value, + flowSource: netFlow.source, + seconds: remaining.seconds, + remainingSource: remaining.source + }; + + this.logger.debug(`netflow = ${JSON.stringify(netFlow)}`); + this.logger.debug(`Height : ${this.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m') } m`); } - // Select remaining time based only on level variation measured or predicted and give back {time:0,variant:null}; - _selectBestRemainingTimeLevelVariant(){ - //define variants (in sequence of priority first measured then predicted etc...) - const remainingTimeVariants = ["measured","predicted"]; + _registerMeasurementChild(child) { + const position = child.config.functionality.positionVsParent; + const measurementType = child.config.asset.type; + const eventName = `${measurementType}.measured.${position}`; - let remainingT = null; + child.measurements.emitter.on(eventName, (eventData) => { + this.logger.debug( + `Measurement update ${eventName} <- ${eventData.childName}: ${eventData.value} ${eventData.unit}` + ); - for (const variant of remainingTimeVariants) { - const candidate = this._calcRemainingTimeBasedOnLevel(variant); - if (candidate != null) { - remainingT = candidate; - return {time:remainingT,variant:variant}; - } - } - return {time:0,variant:null}; + this.measurements + .type(measurementType) + .variant('measured') + .position(position) + .value(eventData.value, eventData.timestamp, eventData.unit); + + this._handleMeasurement(measurementType, eventData.value, position, eventData); + }); } - _callMeasurementHandler(measurementType, value, position, context) { - switch (measurementType) { - case 'pressure': - this.updateMeasuredPressure(value, position, context); - break; - - case 'flow': - this.updateMeasuredFlow(value, position, context); - break; - - case 'temperature': - this.updateMeasuredTemperature(value, position, context); - break; + _registerPredictedFlowChild(child) { + const position = child.config.functionality.positionVsParent; + const childName = child.config.general.name; - case 'level': - this.updateMeasuredLevel(value, position, context); - break; - - default: - this.logger.warn(`No handler for measurement type: ${measurementType}`); - // Generic handler - just update position - this.updatePosition(); - break; - } - } + const listener = (eventName, posKey) => { + child.measurements.emitter.on(eventName, (eventData) => { + this.logger.debug( + `Predicted flow update from ${childName} (${position}) -> ${eventData.value} ${eventData.unit}` + ); + this.measurements + .type('flow') + .variant('predicted') + .position(posKey) + .value(eventData.value, eventData.timestamp, eventData.unit); + }); + }; - // context handler for pressure updates - updateMeasuredPressure(value, position, context = {}) { - - // init temp - let kelvinTemp = null; - - //pressure updates come from pressure boxes inside the basin they get converted to a level and stored as level measured at position inlet or outlet - this.logger.debug(`Pressure update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`); - - // Store in parent's measurement container for the first time - this.measurements.type("pressure").variant("measured").position(position).value(value, context.timestamp, context.unit); - - //convert pressure to level based on density of water and height of pressure sensor - const mTemp = this.measurements.type("temperature").variant("measured").position("atEquipment").getCurrentValue('K'); //default to 20C if no temperature measurement - - //prefer measured temp but otherwise assume nominal temp for wastewater - if(mTemp === null){ - this.logger.warn(`No temperature measurement available, defaulting to 15C for pressure to level conversion.`); - this.measurements.type("temperature").variant("assumed").position("atEquipment").value(15, Date.now(), "C"); - kelvinTemp = this.measurements.type('temperature').variant('assumed').position('atEquipment').getCurrentValue('K'); - this.logger.debug(`Temperature is : ${kelvinTemp}`); + if (position === 'downstream' || position === 'atequipment' || position === 'out') { + listener('flow.predicted.downstream', 'out'); + } else if (position === 'upstream' || position === 'in') { + listener('flow.predicted.downstream', 'in'); } else { - kelvinTemp = mTemp; + this.logger.warn(`Unsupported predicted flow position "${position}" from ${childName}`); } - this.logger.debug(`Using temperature: ${kelvinTemp} K for calculations`); - const density = coolprop.PropsSI('D','T',kelvinTemp,'P',101325,'Water'); //density in kg/m3 at temp and surface pressure + } + + _handleMeasurement(measurementType, value, position, context) { + switch (measurementType) { + case 'level': + this._onLevelMeasurement(position, value, context); + break; + case 'pressure': + this._onPressureMeasurement(position, value, context); + break; + case 'flow': + // Additional flow-specific logic could go here if needed + break; + default: + this.logger.debug(`Unhandled measurement type "${measurementType}", storing only.`); + break; + } + } + + _onLevelMeasurement(position, value, context = {}) { + const levelSeries = this.measurements.type('level').variant('measured').position(position); + const levelMeters = levelSeries.getCurrentValue('m'); + if (levelMeters == null) return; + + const volume = this._calcVolumeFromLevel(levelMeters); + const percent = this.interpolate.interpolate_lin_single_point( + volume, + this.basin.minVol, + this.basin.maxVolOverflow, + 0, + 100 + ); + + this.measurements + .type('volume') + .variant('measured') + .position('atequipment') + .value(volume, context.timestamp, 'm3'); + + this.measurements + .type('volume') + .variant('percent') + .position('atequipment') + .value(percent, context.timestamp, '%'); + } + + _onPressureMeasurement(position, value, context = {}) { + let kelvinTemp = + this.measurements + .type('temperature') + .variant('measured') + .position('atequipment') + .getCurrentValue('K') ?? null; + + if (kelvinTemp === null) { + this.logger.warn('No temperature measurement; assuming 15C for pressure to level conversion.'); + this.measurements + .type('temperature') + .variant('assumed') + .position('atequipment') + .value(15, Date.now(), 'C'); + kelvinTemp = this.measurements + .type('temperature') + .variant('assumed') + .position('atequipment') + .getCurrentValue('K'); + } + + if (kelvinTemp == null) return; + + const density = coolprop.PropsSI('D', 'T', kelvinTemp, 'P', 101325, 'Water'); + const pressurePa = this.measurements + .type('pressure') + .variant('measured') + .position(position) + .getCurrentValue('Pa'); + + if (!Number.isFinite(pressurePa) || !Number.isFinite(density)) return; + const g = 9.80665; - const pressure_Pa = this.measurements.type("pressure").variant("measured").position(position).getCurrentValue('Pa'); - const level = pressure_Pa / density * g; + const level = pressurePa / (density * g); - this.measurements.type("level").variant("predicted").position(position).value(level); - //updatePredictedLevel(); ?? OLIFANT! - - //calculate how muc flow went in or out based on pressure difference - this.logger.debug(`Using pressure: ${value} for calculations`); - + this.measurements.type('level').variant('predicted').position(position).value(level, context.timestamp, 'm'); } - updateMeasuredLevel(value,position, context = {}){ - // Store in parent's measurement container for the first time - this.measurements.type("level").variant("measured").position(position).value(value, context.timestamp, context.unit); + _takeMeasurementSnapshot() { + const snapshot = { + flows: {}, + levels: {}, + levelRates: {} + }; - //fetch level in meter - const level = this.measurements.type("level").variant("measured").position(position).getCurrentValue('m'); - //calc vol in m3 - const volume = this._calcVolumeFromLevel(level); - this.logger.debug(`basin minvol : ${this.basin.minVol}, cur volume : ${volume} / ${this.basin.maxVolOverflow}`); + for (const variant of this.flowVariants) { + snapshot.flows[variant] = this._snapshotFlowsForVariant(variant); + } - const proc = this.interpolate.interpolate_lin_single_point(volume,this.basin.minVol,this.basin.maxVolOverflow,0,100); - this.logger.debug(`PROC volume : ${proc}`); - this.measurements.type("volume").variant("measured").position("atEquipment").value(volume).unit('m3'); - this.measurements.type("volume").variant("procent").position("atEquipment").value(proc); - + for (const variant of this.levelVariants) { + snapshot.levels[variant] = this._snapshotLevelForVariant(variant); + snapshot.levelRates[variant] = this._estimateLevelRate(snapshot.levels[variant]); + } + + return snapshot; } - _calcNetFlow() { - let netFlow = null; + _snapshotFlowsForVariant(variant) { + const inflowSeries = this._locateSeries('flow', variant, this.flowPositions.inflow); + const outflowSeries = this._locateSeries('flow', variant, this.flowPositions.outflow); - const netFlow_FlowSensor = Math.abs(this.measurements.type("flow").variant("measured").difference({ from: "downstream", to: "upstream", unit: "m3/s" })); - const netFlow_LevelSensor = this._calcNetFlowFromLevelDiff("measured"); - const netFlow_PredictedFlow = Math.abs(this.measurements.type('flow').variant('predicted').difference({ from: "in", to: "out", unit: "m3/s" })); + return { + variant, + inflow: this._seriesSamples(inflowSeries), + outflow: this._seriesSamples(outflowSeries) + }; + } - switch (true){ - //prefer flowsensor netflow - case (netFlow_FlowSensor!=null): - return netFlow_FlowSensor; - //try using level difference if possible to infer netflow - case (netFlow_LevelSensor!= null): - return netFlow_LevelSensor; - case (netFlow_PredictedFlow != null): - return netFlow_PredictedFlow; - default: - this.logger.warn(`Can't calculate netflow without the proper measurements or predictions`); - return null; + _snapshotLevelForVariant(variant) { + const levelSeries = this._locateSeries('level', variant, ['atequipment']); + return { + variant, + samples: this._seriesSamples(levelSeries) + }; + } + + _seriesSamples(seriesInfo) { + if (!seriesInfo) { + return { exists: false, measurement: null, current: null, previous: null }; + } + + try { + const current = seriesInfo.measurement.getLaggedSample(0); // newest + const previous = seriesInfo.measurement.getLaggedSample(1); // previous + return { + exists: Boolean(current), + measurement: seriesInfo.measurement, + current, + previous + }; + } catch (err) { + this.logger.debug( + `Failed to read samples for ${seriesInfo.type}.${seriesInfo.variant}.${seriesInfo.position}: ${err.message}` + ); + return { exists: false, measurement: seriesInfo.measurement, current: null, previous: null }; } } - //@params : params : example {flowVariant: "predicted",levelVariant: "measured"}; - _calcRemainingTimeBasedOnFlow(params){ - const {flowVariant,levelVariant} = params; - this.logger.debug(`${flowVariant} - ${levelVariant} `); + _locateSeries(type, variant, positions) { + for (const position of positions) { + try { + const chain = this.measurements.type(type).variant(variant).position(position); + if (!chain.exists({ requireValues: true })) continue; - if( flowVariant === null || levelVariant === null ){ - this.logger.warn(`Cant calculate remaining time without needed variants`); - return 0; + const measurement = chain.get(); + if (!measurement) continue; + + return { type, variant, position, measurement }; + } catch (err) { + // ignore missing combinations + } } - + return null; + } + + _estimateLevelRate(levelSnapshot) { + if (!levelSnapshot.samples.exists){ return null}; + const { current, previous } = levelSnapshot.samples; + if (!current || !previous || previous.timestamp == null){return null}; + + const deltaT = (current.timestamp - previous.timestamp) / 1000; + if (!Number.isFinite(deltaT) || deltaT <= 0){ return null}; + + const deltaLevel = current.value - previous.value; + return deltaLevel / deltaT; + } + + _selectBestNetFlow(snapshot) { + for (const variant of this.flowVariants) { + const flow = snapshot.flows[variant]; + if (!flow.inflow.exists && !flow.outflow.exists) continue; + + const inflow = flow.inflow.current?.value ?? 0; + const outflow = flow.outflow.current?.value ?? 0; + const net = inflow - outflow; // positive => filling + + return { value: net,source: variant,direction: this._deriveDirection(net) }; + } + + // fallback using level trend + for (const variant of this.levelVariants) { + const levelRate = snapshot.levelRates[variant]; + if (!Number.isFinite(levelRate)) continue; + + const netFlow = levelRate * this.basin.surfaceArea; + return { + value: netFlow, + source: `level:${variant}`, + direction: this._deriveDirection(netFlow) + }; + } + + this.logger.warn('No usable measurements to compute net flow; assuming steady.'); + return { value: 0, source: null, direction: 'steady' }; + } + + _computeRemainingTime(snapshot, netFlow) { + if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) { + return { seconds: null, source: null }; + } + const { heightOverflow, heightOutlet, surfaceArea } = this.basin; - const levelexists = this.measurements.type("level").variant(levelVariant).exists({ position: "atEquipment", requireValues: true }); - const flowOutExists = this.measurements.type("flow").variant(flowVariant).exists({ position: "out", requireValues: true }); - const flowInExists = this.measurements.type("flow").variant(flowVariant).exists({ position: "in", requireValues: true }); - let secondsRemaining = 0; - - if( ! flowOutExists || ! flowInExists || ! levelexists){ - this.logger.warn(`Cant calculate remaining time without needed parameters ${flowOutExists} , ${flowInExists} , ${levelexists}`); - return null; + if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) { + this.logger.warn('Invalid basin surface area.'); + return { seconds: null, source: null }; } - const flowDiff = this.measurements.type("flow").variant(flowVariant).difference({ from: "downstream", to: "upstream", unit: "m3/s" }); - const level = this.measurements.type("level").variant(levelVariant).type('atEquipment').getCurrentValue('m'); - let remainingHeight = 0; + for (const variant of this.levelVariants) { + const levelSnap = snapshot.levels[variant]; + const current = levelSnap.samples.current?.value ?? null; + if (!Number.isFinite(current)) continue; - switch(true){ + const remainingHeight = + netFlow.value > 0 + ? Math.max(heightOverflow - current, 0) + : Math.max(current - heightOutlet, 0); - case(flowDiff>0): - remainingHeight = Math.max(heightOverflow - level, 0); - secondsRemaining = remainingHeight * surfaceArea / flowDiff; - return secondsRemaining; + const seconds = (remainingHeight * surfaceArea) / Math.abs(netFlow.value); + if (!Number.isFinite(seconds)) continue; - case(flowDiff<0): - remainingHeight = Math.max(level - heightOutlet, 0); - secondsRemaining = remainingHeight * surfaceArea / Math.abs(flowDiff); - return secondsRemaining; - - default: - this.logger.debug(`Flowdiff is 0 not doing anything.`); - return secondsRemaining; - } + return { seconds, source: `${netFlow.source}/${variant}` }; + } + this.logger.warn('No level data available to compute remaining time.'); + return { seconds: null, source: netFlow.source }; } - //@params : variant : example "predicted","measured" - _calcRemainingTimeBasedOnLevel(variant){ + _updatePredictedVolume(snapshot) { + const predicted = snapshot.flows.predicted; + if (!predicted) return; - const {heightOverflow,heightOutlet} = this.basin; - const levelObj = this.measurements.type("level").variant(variant).position("atEquipment"); - const level = levelObj.getCurrentValue("m"); - const prevLevelSample = levelObj.getLaggedSample(2, "m"); // { value, timestamp, unit } - const measurement = levelObj.get(); - const latestTimestamp = measurement?.getLatestTimestamp(); + const now = Date.now(); + const inflowSample = predicted.inflow.current ?? predicted.inflow.previous ?? null; + const outflowSample = predicted.outflow.current ?? predicted.outflow.previous ?? null; - if (level === null || prevLevelSample == null || latestTimestamp == null) { - this.logger.warn(`no flowdiff ${level}, previous level ${prevLevelSample}, latestTimestamp ${latestTimestamp} found escaping`); - return null; + if (!this._predictedFlowState) { + this._predictedFlowState = { + inflow: inflowSample?.value ?? 0, + outflow: outflowSample?.value ?? 0, + lastTimestamp: inflowSample?.timestamp ?? outflowSample?.timestamp ?? now + }; } - const deltaSeconds = (latestTimestamp - prevLevelSample.timestamp) / 1000; - if (deltaSeconds <= 0) { - this.logger.warn(`Level fallback: invalid Δt=${deltaSeconds} , LatestTimestamp : ${latestTimestamp}, PrevTimestamp : ${prevLevelSample.value}`); - return null; + if (inflowSample) this._predictedFlowState.inflow = inflowSample.value; + if (outflowSample) this._predictedFlowState.outflow = outflowSample.value; + + const latestObservedTimestamp = + inflowSample?.timestamp ?? outflowSample?.timestamp ?? this._predictedFlowState.lastTimestamp; + + const timestampPrev = this._predictedFlowState.lastTimestamp ?? latestObservedTimestamp; + + let timestampNow = latestObservedTimestamp; + if (!Number.isFinite(timestampNow) || timestampNow <= timestampPrev) { + timestampNow = now; } - const lvlDiff = level - prevLevelSample.value; - const lvlRate = lvlDiff / deltaSeconds; // m/s - let secondsRemaining = 0; - let remainingHeight = 0; + let deltaSeconds = (timestampNow - timestampPrev) / 1000; + if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) { + deltaSeconds = 0; + } - switch(true){ + let netVolumeChange = 0; + if (deltaSeconds > 0) { + const avgInflow = inflowSample ? inflowSample.value : this._predictedFlowState.inflow; + const avgOutflow = outflowSample ? outflowSample.value : this._predictedFlowState.outflow; + netVolumeChange = (avgInflow - avgOutflow) * deltaSeconds; + } - case(lvlRate>0): - remainingHeight = Math.max(heightOverflow - level, 0); - secondsRemaining = remainingHeight / Math.abs(lvlRate); // seconds - return secondsRemaining; + const writeTimestamp = timestampPrev + Math.max(deltaSeconds, 0) * 1000; - case(lvlRate<0): - remainingHeight = Math.max(level - heightOutlet, 0); - secondsRemaining = remainingHeight / Math.abs(lvlRate); - return secondsRemaining; + const volumeSeries = this.measurements + .type('volume') + .variant('predicted') + .position('atEquipment'); - default: - this.logger.debug(`Flowdiff is 0 not doing anything.`); - return secondsRemaining; - } + const currentVolume = volumeSeries.getCurrentValue('m3') ?? this.basin.minVol; + const nextVolume = currentVolume + netVolumeChange; + + volumeSeries.value(nextVolume, writeTimestamp, 'm3').unit('m3'); + + const nextLevel = this._calcLevelFromVolume(nextVolume); + this.measurements + .type('level') + .variant('predicted') + .position('atEquipment') + .value(nextLevel, writeTimestamp, 'm') + .unit('m'); + + this._predictedFlowState.lastTimestamp = writeTimestamp; } - //Give a flowDifference and calculate direction => spits out filling , draining or stable - _calcDirectionBasedOnFlow(flowDiff){ - - let direction = null; - - switch (true){ - case flowDiff > flowThreshold: - direction = "filling"; - break; - - case flowDiff < -flowThreshold: - direction = "draining"; - break; - - case flowDiff < flowThreshold && flowDiff > -flowThreshold: - direction = "stable"; - break; - - default: - this.logger.warn("Uknown state direction detected??"); - return null; - - } - return direction; + _averageSampleValues(sampleA, sampleB) { + const values = [sampleA?.value, sampleB?.value].filter((v) => Number.isFinite(v)); + if (!values.length) return 0; + return values.reduce((acc, val) => acc + val, 0) / values.length; } - _calcNetFlowFromLevelDiff(variant) { - const { surfaceArea } = this.basin; - const levelObj = this.measurements.type("level").variant(variant).position("atEquipment"); - const level = levelObj.getCurrentValue("m"); - const prevLevelSample = levelObj.getLaggedSample(2, "m"); // { value, timestamp, unit } - const measurement = levelObj.get(); - const latestTimestamp = measurement?.getLatestTimestamp(); - - if (level === null || prevLevelSample == null || latestTimestamp == null) { - this.logger.warn(`no flowdiff ${level}, previous level ${prevLevelSample}, latestTimestamp ${latestTimestamp} found escaping`); - return null; - } - - const deltaSeconds = (latestTimestamp - prevLevelSample.timestamp) / 1000; - if (deltaSeconds <= 0) { - this.logger.warn(`Level fallback: invalid Δt=${deltaSeconds} , LatestTimestamp : ${latestTimestamp}, PrevTimestamp : ${prevLevelSample.timestamp}`); - return null; - } - - const lvlDiff = level - prevLevelSample.value; - const lvlRate = lvlDiff / deltaSeconds; // m/s - const netFlowRate = lvlRate * surfaceArea; // m³/s inferred from level trend - - return netFlowRate; + _deriveDirection(netFlow) { + if (netFlow > this.flowThreshold) return 'filling'; + if (netFlow < -this.flowThreshold) return 'draining'; + return 'steady'; } + /* ------------------------------------------------------------------ */ + /* Basin Calculations */ + /* ------------------------------------------------------------------ */ + initBasinProperties() { - - // Load and calc basic params - const volEmptyBasin = this.config.basin.volume; - const heightBasin = this.config.basin.height; - const heightInlet = this.config.basin.heightInlet; - const heightOutlet = this.config.basin.heightOutlet; - const heightOverflow = this.config.basin.heightOverflow; + const volEmptyBasin = this.config.basin.volume; + const heightBasin = this.config.basin.height; + const heightInlet = this.config.basin.heightInlet; + const heightOutlet = this.config.basin.heightOutlet; + const heightOverflow = this.config.basin.heightOverflow; - //calculated params - const surfaceArea = volEmptyBasin / heightBasin; - const maxVol = heightBasin * surfaceArea; // if Basin where to ever fill up completely this is the water volume - const maxVolOverflow = heightOverflow * surfaceArea ; // Max water volume before you start loosing water to overflow - const minVol = heightOutlet * surfaceArea; - const minVolOut = heightInlet * surfaceArea ; // this will indicate if its an open end or a closed end. + const surfaceArea = volEmptyBasin / heightBasin; + const maxVol = heightBasin * surfaceArea; + const maxVolOverflow = heightOverflow * surfaceArea; + const minVol = heightOutlet * surfaceArea; + const minVolOut = heightInlet * surfaceArea; - this.basin.volEmptyBasin = volEmptyBasin ; - this.basin.heightBasin = heightBasin ; - this.basin.heightInlet = heightInlet ; - this.basin.heightOutlet = heightOutlet ; - this.basin.heightOverflow = heightOverflow ; - this.basin.surfaceArea = surfaceArea ; - this.basin.maxVol = maxVol ; - this.basin.maxVolOverflow = maxVolOverflow; - this.basin.minVol = minVol ; - this.basin.minVolOut = minVolOut ; + this.basin = { + volEmptyBasin, + heightBasin, + heightInlet, + heightOutlet, + heightOverflow, + surfaceArea, + maxVol, + maxVolOverflow, + minVol, + minVolOut + }; - //init predicted min volume to min vol in order to have a starting point - this.measurements.type("volume").variant("predicted").position("atEquipment").value(minVol).unit('m3'); - this.measurements.type("volume").variant("predicted").position("atEquipment").value(maxVol).unit('m3'); + this.measurements + .type('volume') + .variant('predicted') + .position('atEquipment') + .value(maxVol) + .unit('m3'); - this.logger.debug(` - Basin initialized | area=${surfaceArea.toFixed(2)} m², - max=${maxVol.toFixed(2)} m³, - overflow=${maxVolOverflow.toFixed(2)} m³` + this.logger.debug( + `Basin initialized | area=${surfaceArea.toFixed(2)} m2, max=${maxVol.toFixed(2)} m3, overflow=${maxVolOverflow.toFixed(2)} m3` ); } -_calcVolumeFromLevel(level) { - const surfaceArea = this.basin.surfaceArea; - return Math.max(level, 0) * surfaceArea; -} + _calcVolumeFromLevel(level) { + return Math.max(level, 0) * this.basin.surfaceArea; + } -_calcLevelFromVolume(vol){ - const surfaceArea = this.basin.surfaceArea; - return Math.max(vol, 0) / surfaceArea; -} + _calcLevelFromVolume(volume) { + return Math.max(volume, 0) / this.basin.surfaceArea; + } + /* ------------------------------------------------------------------ */ + /* Output */ + /* ------------------------------------------------------------------ */ -getOutput() { - // Improved output object generation + getOutput() { const output = {}; - //build the output object - this.measurements.getTypes().forEach(type => { - this.measurements.getVariants(type).forEach(variant => { - this.measurements.getPositions(variant).forEach(position => { - const sample = this.measurements.type(type).variant(variant).position(position); - output[`${type}.${variant}.${position}`] = sample.getCurrentValue(); + Object.entries(this.measurements.measurements).forEach(([type, variants]) => { + Object.entries(variants).forEach(([variant, positions]) => { + Object.entries(positions).forEach(([position, measurement]) => { + output[`${type}.${variant}.${position}`] = measurement.getCurrentValue(); }); }); }); - //fill in the rest of the output object - output["state"] = this.state; - output["basin"] = this.basin; - - if(this.flowDrift != null){ - const flowDrift = this.flowDrift; - output["flowNrmse"] = flowDrift.nrmse; - output["flowLongterNRMSD"] = flowDrift.longTermNRMSD; - output["flowImmediateLevel"] = flowDrift.immediateLevel; - output["flowLongTermLevel"] = flowDrift.longTermLevel; - } - - + output.state = this.state; + output.basin = this.basin; return output; -} -} - -module.exports = pumpingStation; - -/* ------------------------------------------------------------------------- */ -/* Example: pumping station + rotating machine + measurements (stand-alone) */ -/* ------------------------------------------------------------------------- */ - -const PumpingStation = require("./specificClass"); -const RotatingMachine = require("../../rotatingMachine/src/specificClass"); -const Measurement = require("../../measurement/src/specificClass"); - -//Helpers -function createPumpingStationConfig(name) { - return { - general: { - logging: { enabled: true, logLevel: "debug" }, - name, - id: `${name}-${Date.now()}`, - unit: "m3/h" - }, - functionality: { - softwareType: "pumpingStation", - role: "stationcontroller" - }, - basin: { - volume: 43.75, - height: 3.5, - heightInlet: 0.3, - heightOutlet: 0.2, - heightOverflow: 3.0 - }, - hydraulics: { - refHeight: "NAP", - basinBottomRef: 0 - } - }; -} - -function createLevelMeasurementConfig(name) { - return { - general: { - logging: { enabled: true, logLevel: "debug" }, - name, - id: `${name}-${Date.now()}`, - unit: "m" - }, - functionality: { - softwareType: "measurement", - role: "sensor", - positionVsParent: "atEquipment" - }, - asset: { - category: "sensor", - type: "level", - model: "demo-level", - supplier: "demoCo", - unit: "m" - }, - scaling: { enabled: false }, - smoothing: { smoothWindow: 5, smoothMethod: "none" } - }; -} - -function createFlowMeasurementConfig(name, position) { - return { - general: { - logging: { enabled: true, logLevel: "debug" }, - name, - id: `${name}-${Date.now()}`, - unit: "m3/s" - }, - functionality: { - softwareType: "measurement", - role: "sensor", - positionVsParent: position - }, - asset: { - category: "sensor", - type: "flow", - model: "demo-flow", - supplier: "demoCo", - unit: "m3/s" - }, - scaling: { enabled: false }, - smoothing: { smoothWindow: 5, smoothMethod: "none" } - }; -} - - -function createMachineConfig(name) { - - curve = require('C:/Users/zn375/.node-red/public/fallbackData.json'); - return { - - general: { - name: name, - logging: { - enabled: true, - logLevel: "warn", - } - }, - asset: { - supplier: "Hydrostal", - type: "pump", - category: "centrifugal", - model: "hidrostal-H05K-S03R", // Ensure this field is present. - } } } -function createMachineStateConfig() { - return { - general: { - logging: { - enabled: true, - logLevel: "debug", +module.exports = PumpingStationV2; + +/* ------------------------------------------------------------------------- */ +/* Example usage */ +/* ------------------------------------------------------------------------- */ + +if (require.main === module) { + const Measurement = require('../../measurement/src/specificClass'); + const RotatingMachine = require('../../rotatingMachine/src/specificClass'); + + function createPumpingStationConfig(name) { + return { + general: { + logging: { enabled: true, logLevel: 'debug' }, + name, + id: `${name}-${Date.now()}`, + flowThreshold: 1e-4 }, - }, - // Your custom config here (or leave empty for defaults) - movement: { - speed: 1, - }, - time: { - starting: 2, - warmingup: 3, - stopping: 2, - coolingdown: 3, - }, + functionality: { + softwareType: 'pumpingStation', + role: 'stationcontroller' + }, + basin: { + volume: 43.75, + height: 3.5, + heightInlet: 0.3, + heightOutlet: 0.2, + heightOverflow: 3.0 + }, + hydraulics: { + refHeight: 'NAP', + basinBottomRef: 0 + } + }; } -} -// convenience for seeding measurements -function pushSample(measurement, type, value, unit) { - const pos = measurement.config.functionality.positionVsParent; - measurement.measurements - .type(type) - .variant("measured") - .position(pos) - .value(value, Date.now(), unit); -} - -// Demo -(async function demoStationWithPump() { - const station = new PumpingStation(createPumpingStationConfig("PumpingStationDemo")); - const pump1 = new RotatingMachine(createMachineConfig("Pump1"), createMachineStateConfig()); - const pump2 = new RotatingMachine(createMachineConfig("Pump2"), createMachineStateConfig()); - - const levelSensor = new Measurement(createLevelMeasurementConfig("WetWellLevel")); - const upstreamFlow = new Measurement(createFlowMeasurementConfig("InfluentFlow", "upstream")); - const downstreamFlow = new Measurement(createFlowMeasurementConfig("PumpDischargeFlow", "downstream")); - - - // station uses the sensors - - station.childRegistrationUtils.registerChild(levelSensor, levelSensor.config.functionality.softwareType); - station.childRegistrationUtils.registerChild(upstreamFlow, upstreamFlow.config.functionality.softwareType); - station.childRegistrationUtils.registerChild(downstreamFlow, downstreamFlow.config.functionality.softwareType); - - - // pump owns the downstream flow sensor - //pump.childRegistrationUtils.registerChild(downstreamFlow, downstreamFlow.config.functionality.positionVsParent); - station.childRegistrationUtils.registerChild(pump1,"downstream"); - station.childRegistrationUtils.registerChild(pump2,"upstream"); - - setInterval(() => station.tick(), 1000); - - // seed a starting level & flow - /* - pushSample(levelSensor, "level", 1.8, "m"); - pushSample(upstreamFlow, "flow", 0.35, "m3/s"); - pushSample(downstreamFlow, "flow", 0.20, "m3/s"); - //*/ - - await new Promise(resolve => setTimeout(resolve, 20)); - - // pump increases discharge flow - /* - pushSample(downstreamFlow, "flow", 0.28, "m3/s"); - pushSample(upstreamFlow, "flow", 0.40, "m3/s"); - pushSample(levelSensor, "level", 1.85, "m"); - //*/ - -console.log("Station output:", station.getOutput()); -await pump1.handleInput("parent", "execSequence", "startup"); -await pump2.handleInput("parent", "execSequence", "startup"); -await pump1.handleInput("parent", "execMovement", 5); -await pump2.handleInput("parent", "execMovement", 5); - console.log("Station state:", station.state); - console.log("Station output:", station.getOutput()); - console.log("Pump state:", pump1.state.getCurrentState()); - console.log("Pump state:", pump2.state.getCurrentState()); -})(); - - -/* -//coolprop example -(async () => { - const PropsSI = await coolprop.getPropsSI(); - - // 👇 replace these with your real inputs - const tC_input = 25; // °C - const pPa_input = 101325; // Pa - - // Sanitize & convert - const T = Number(tC_input) + 273.15; // K - const P = Number(pPa_input); // Pa - const fluid = 'Water'; - - // Preconditions - if (!Number.isFinite(T) || !Number.isFinite(P)) { - throw new Error(`Bad inputs: T=${T} K, P=${P} Pa`); + function createLevelMeasurementConfig(name) { + return { + general: { + logging: { enabled: true, logLevel: 'debug' }, + name, + id: `${name}-${Date.now()}`, + unit: 'm' + }, + functionality: { + softwareType: 'measurement', + role: 'sensor', + positionVsParent: 'atequipment' + }, + asset: { + category: 'sensor', + type: 'level', + model: 'demo-level', + supplier: 'demoCo', + unit: 'm' + }, + scaling: { enabled: false }, + smoothing: { smoothWindow: 5, smoothMethod: 'none' } + }; } - if (T <= 0) throw new Error(`Temperature must be in Kelvin (>0). Got ${T}.`); - if (P <= 0) throw new Error(`Pressure must be >0 Pa. Got ${P}.`); - // Try T,P order - let rho = PropsSI('D', 'T', T, 'P', P, fluid); - // Fallback: P,T order (should be equivalent) - if (!Number.isFinite(rho)) rho = PropsSI('D', 'P', P, 'T', T, fluid); - - console.log({ T, P, rho }); - - if (!Number.isFinite(rho)) { - console.error('Still Infinity. Extra checks:'); - console.error('typeof T:', typeof T, 'typeof P:', typeof P); - console.error('Example known-good call:', PropsSI('D', 'T', 298.15, 'P', 101325, 'Water')); + function createFlowMeasurementConfig(name, position) { + return { + general: { + logging: { enabled: true, logLevel: 'debug' }, + name, + id: `${name}-${Date.now()}`, + unit: 'm3/s' + }, + functionality: { + softwareType: 'measurement', + role: 'sensor', + positionVsParent: position + }, + asset: { + category: 'sensor', + type: 'flow', + model: 'demo-flow', + supplier: 'demoCo', + unit: 'm3/s' + }, + scaling: { enabled: false }, + smoothing: { smoothWindow: 5, smoothMethod: 'none' } + }; } -})(); + + function createMachineConfig(name,position) { + return { + general: { + name, + logging: { enabled: false, logLevel: 'debug' } + }, + functionality: { + softwareType: "machine", + positionVsParent: position + }, + asset: { + supplier: 'Hydrostal', + type: 'pump', + category: 'centrifugal', + model: 'hidrostal-H05K-S03R' + } + }; + } + + function createMachineStateConfig() { + return { + general: { + logging: { + enabled: true, + logLevel: 'debug' + } + }, + movement: { speed: 1 }, + time: { + starting: 2, + warmingup: 3, + stopping: 2, + coolingdown: 3 + } + }; + } + + function seedSample(measurement, type, value, unit) { + const pos = measurement.config.functionality.positionVsParent; + measurement.measurements.type(type).variant('measured').position(pos).value(value, Date.now(), unit); + } + + (async function demo() { + const station = new PumpingStationV2(createPumpingStationConfig('PumpingStationDemo')); + const pump1 = new RotatingMachine(createMachineConfig('Pump1','downstream'), createMachineStateConfig()); + const pump2 = new RotatingMachine(createMachineConfig('Pump2','upstream'), createMachineStateConfig()); + + const levelSensor = new Measurement(createLevelMeasurementConfig('WetWellLevel')); + const inflowSensor = new Measurement(createFlowMeasurementConfig('InfluentFlow', 'in')); + const outflowSensor = new Measurement(createFlowMeasurementConfig('PumpDischargeFlow', 'out')); + + /* + station.childRegistrationUtils.registerChild(levelSensor, levelSensor.config.functionality.softwareType); + station.childRegistrationUtils.registerChild(inflowSensor, inflowSensor.config.functionality.softwareType); + station.childRegistrationUtils.registerChild(outflowSensor, outflowSensor.config.functionality.softwareType); + */ + station.childRegistrationUtils.registerChild(pump1, 'machine'); + station.childRegistrationUtils.registerChild(pump2, 'machine'); + + // Seed initial measurements + /* + seedSample(levelSensor, 'level', 1.8, 'm'); + seedSample(inflowSensor, 'flow', 0.35, 'm3/s'); + seedSample(outflowSensor, 'flow', 0.20, 'm3/s'); */ + setInterval( + () => station.tick(), 1000); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + console.log('Initial state:', station.state); + + await pump1.handleInput('parent', 'execSequence', 'startup'); + await pump1.handleInput('parent', 'execMovement', 10); + + await pump2.handleInput('parent', 'execSequence', 'startup'); + await pump2.handleInput('parent', 'execMovement', 10); + + + console.log('Station state:', station.state); + console.log('Station output:', station.getOutput()); + })().catch((err) => { + console.error('Demo failed:', err); + }); +} diff --git a/src/specificClass2.js b/src/specificClass2.js deleted file mode 100644 index f771840..0000000 --- a/src/specificClass2.js +++ /dev/null @@ -1,681 +0,0 @@ -const EventEmitter = require('events'); -const { - logger, - configUtils, - configManager, - childRegistrationUtils, - MeasurementContainer, - coolprop, - interpolation -} = require('generalFunctions'); - -const FLOW_VARIANTS = ['measured', 'predicted']; -const LEVEL_VARIANTS = ['measured', 'predicted']; -const FLOW_POSITIONS = { - inflow: ['in', 'upstream'], - outflow: ['out', 'downstream'] -}; - -class PumpingStationV2 { - constructor(config = {}) { - this.emitter = new EventEmitter(); - this.configManager = new configManager(); - this.defaultConfig = this.configManager.getConfig('pumpingStation'); - this.configUtils = new configUtils(this.defaultConfig); - this.config = this.configUtils.initConfig(config); - this.interpolate = new interpolation(); - - this.logger = new logger( - this.config.general.logging.enabled, - this.config.general.logging.logLevel, - this.config.general.name - ); - - this.measurements = new MeasurementContainer({ autoConvert: true }); - this.measurements.setPreferredUnit('flow', 'm3/s'); - this.measurements.setPreferredUnit('level', 'm'); - this.measurements.setPreferredUnit('volume', 'm3'); - this.childRegistrationUtils = new childRegistrationUtils(this); - this.machines = {}; - this.stations = {}; - - this.basin = {}; - this.state = { - direction: 'steady', - netFlow: 0, - flowSource: null, - seconds: null, - remainingSource: null - }; - - const thresholdFromConfig = Number(this.config.general?.flowThreshold); - this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4; - - this.initBasinProperties(); - this.logger.debug('PumpingStationV2 initialized'); - } - - registerChild(child, softwareType) { - this.logger.debug(`Registering child (${softwareType}) "${child.config.general.name}"`); - - if (softwareType === 'measurement') { - this._registerMeasurementChild(child); - return; - } - - if (softwareType === 'machine' || softwareType === 'pumpingStation') { - this._registerPredictedFlowChild(child); - return; - } - - this.logger.warn(`Unsupported child software type: ${softwareType}`); - } - - tick() { - const snapshot = this._takeMeasurementSnapshot(); - - this._updatePredictedVolume(snapshot); - - const netFlow = this._selectBestNetFlow(snapshot); - const remaining = this._computeRemainingTime(snapshot, netFlow); - - this.state = { - direction: netFlow.direction, - netFlow: netFlow.value, - flowSource: netFlow.source, - seconds: remaining.seconds, - remainingSource: remaining.source - }; - - this.logger.debug( - `Remaining time (${remaining.source ?? 'n/a'}): ${ - remaining.seconds != null ? `${Math.round((remaining.seconds / 60 / 60) * 10) / 10} h` : 'n/a' - }` - ); - } - - /* ------------------------------------------------------------------ */ - /* Helpers */ - /* ------------------------------------------------------------------ */ - - _registerMeasurementChild(child) { - const position = child.config.functionality.positionVsParent; - const measurementType = child.config.asset.type; - const eventName = `${measurementType}.measured.${position}`; - - child.measurements.emitter.on(eventName, (eventData) => { - this.logger.debug( - `Measurement update ${eventName} <- ${eventData.childName}: ${eventData.value} ${eventData.unit}` - ); - - this.measurements - .type(measurementType) - .variant('measured') - .position(position) - .value(eventData.value, eventData.timestamp, eventData.unit); - - this._handleMeasurement(measurementType, eventData.value, position, eventData); - }); - } - - _registerPredictedFlowChild(child) { - const position = child.config.functionality.positionVsParent; - const childName = child.config.general.name; - - const listener = (eventName, posKey) => { - child.measurements.emitter.on(eventName, (eventData) => { - this.logger.debug( - `Predicted flow update from ${childName} (${position}) -> ${eventData.value} ${eventData.unit}` - ); - this.measurements - .type('flow') - .variant('predicted') - .position(posKey) - .value(eventData.value, eventData.timestamp, eventData.unit); - }); - }; - - if (position === 'downstream' || position === 'atEquipment' || position === 'out') { - listener('flow.predicted.downstream', 'out'); - } else if (position === 'upstream' || position === 'in') { - listener('flow.predicted.downstream', 'in'); - } else { - this.logger.warn(`Unsupported predicted flow position "${position}" from ${childName}`); - } - } - - _handleMeasurement(measurementType, value, position, context) { - switch (measurementType) { - case 'level': - this._onLevelMeasurement(position, value, context); - break; - case 'pressure': - this._onPressureMeasurement(position, value, context); - break; - case 'flow': - // Additional flow-specific logic could go here if needed - break; - default: - this.logger.debug(`Unhandled measurement type "${measurementType}", storing only.`); - break; - } - } - - _onLevelMeasurement(position, value, context = {}) { - const levelSeries = this.measurements.type('level').variant('measured').position(position); - const levelMeters = levelSeries.getCurrentValue('m'); - if (levelMeters == null) return; - - const volume = this._calcVolumeFromLevel(levelMeters); - const percent = this.interpolate.interpolate_lin_single_point( - volume, - this.basin.minVol, - this.basin.maxVolOverflow, - 0, - 100 - ); - - this.measurements - .type('volume') - .variant('measured') - .position('atEquipment') - .value(volume, context.timestamp, 'm3'); - - this.measurements - .type('volume') - .variant('percent') - .position('atEquipment') - .value(percent, context.timestamp, '%'); - } - - _onPressureMeasurement(position, value, context = {}) { - let kelvinTemp = - this.measurements - .type('temperature') - .variant('measured') - .position('atEquipment') - .getCurrentValue('K') ?? null; - - if (kelvinTemp === null) { - this.logger.warn('No temperature measurement; assuming 15C for pressure to level conversion.'); - this.measurements - .type('temperature') - .variant('assumed') - .position('atEquipment') - .value(15, Date.now(), 'C'); - kelvinTemp = this.measurements - .type('temperature') - .variant('assumed') - .position('atEquipment') - .getCurrentValue('K'); - } - - if (kelvinTemp == null) return; - - const density = coolprop.PropsSI('D', 'T', kelvinTemp, 'P', 101325, 'Water'); - const pressurePa = this.measurements - .type('pressure') - .variant('measured') - .position(position) - .getCurrentValue('Pa'); - - if (!Number.isFinite(pressurePa) || !Number.isFinite(density)) return; - - const g = 9.80665; - const level = pressurePa / (density * g); - - this.measurements.type('level').variant('predicted').position(position).value(level, context.timestamp, 'm'); - } - - _takeMeasurementSnapshot() { - const snapshot = { - flows: {}, - levels: {}, - levelRates: {} - }; - - for (const variant of FLOW_VARIANTS) { - snapshot.flows[variant] = this._snapshotFlowsForVariant(variant); - } - - for (const variant of LEVEL_VARIANTS) { - snapshot.levels[variant] = this._snapshotLevelForVariant(variant); - snapshot.levelRates[variant] = this._estimateLevelRate(snapshot.levels[variant]); - } - - return snapshot; - } - - _snapshotFlowsForVariant(variant) { - const inflowSeries = this._locateSeries('flow', variant, FLOW_POSITIONS.inflow); - const outflowSeries = this._locateSeries('flow', variant, FLOW_POSITIONS.outflow); - - return { - variant, - inflow: this._seriesSamples(inflowSeries), - outflow: this._seriesSamples(outflowSeries) - }; - } - - _snapshotLevelForVariant(variant) { - const levelSeries = this._locateSeries('level', variant, ['atEquipment']); - return { - variant, - samples: this._seriesSamples(levelSeries) - }; - } - - _seriesSamples(seriesInfo) { - if (!seriesInfo) { - return { - exists: false, - series: null, - current: null, - previous: null - }; - } - - try { - const current = seriesInfo.series.getLaggedSample(0); - const previous = seriesInfo.series.getLaggedSample(1); - return { - exists: Boolean(current), - series: seriesInfo.series, - current, - previous - }; - } catch (err) { - this.logger.debug( - `Failed to read samples for ${seriesInfo.type}.${seriesInfo.variant}.${seriesInfo.position}: ${err.message}` - ); - return { - exists: false, - series: seriesInfo.series, - current: null, - previous: null - }; - } - } - - _locateSeries(type, variant, positions) { - for (const position of positions) { - try { - this.measurements.type(type).variant(variant); - const exists = this.measurements.exists({ position, requireValues: true }); - if (!exists) continue; - const series = this.measurements.type(type).variant(variant).position(position); - return { type, variant, position, series }; - } catch (err) { - // ignore missing combinations - } - } - return null; - } - - _estimateLevelRate(levelSnapshot) { - if (!levelSnapshot.samples.exists) return null; - const { current, previous } = levelSnapshot.samples; - if (!current || !previous || previous.timestamp == null) return null; - - const deltaT = (current.timestamp - previous.timestamp) / 1000; - if (!Number.isFinite(deltaT) || deltaT <= 0) return null; - - const deltaLevel = current.value - previous.value; - return deltaLevel / deltaT; - } - - _selectBestNetFlow(snapshot) { - for (const variant of FLOW_VARIANTS) { - const flow = snapshot.flows[variant]; - if (!flow.inflow.exists || !flow.outflow.exists) continue; - - const inflow = flow.inflow.current?.value ?? null; - const outflow = flow.outflow.current?.value ?? null; - if (!Number.isFinite(inflow) || !Number.isFinite(outflow)) continue; - - const net = inflow - outflow; // positive => filling - if (!Number.isFinite(net)) continue; - - return { - value: net, - source: variant, - direction: this._deriveDirection(net) - }; - } - - // fallback using level trend - for (const variant of LEVEL_VARIANTS) { - const levelRate = snapshot.levelRates[variant]; - if (!Number.isFinite(levelRate)) continue; - - const netFlow = levelRate * this.basin.surfaceArea; - return { - value: netFlow, - source: `level:${variant}`, - direction: this._deriveDirection(netFlow) - }; - } - - this.logger.warn('No usable measurements to compute net flow; assuming steady.'); - return { value: 0, source: null, direction: 'steady' }; - } - - _computeRemainingTime(snapshot, netFlow) { - if (!netFlow || Math.abs(netFlow.value) < this.flowThreshold) { - return { seconds: null, source: null }; - } - - const { heightOverflow, heightOutlet, surfaceArea } = this.basin; - if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) { - this.logger.warn('Invalid basin surface area.'); - return { seconds: null, source: null }; - } - - for (const variant of LEVEL_VARIANTS) { - const levelSnap = snapshot.levels[variant]; - const current = levelSnap.samples.current?.value ?? null; - if (!Number.isFinite(current)) continue; - - const remainingHeight = - netFlow.value > 0 - ? Math.max(heightOverflow - current, 0) - : Math.max(current - heightOutlet, 0); - - const seconds = (remainingHeight * surfaceArea) / Math.abs(netFlow.value); - if (!Number.isFinite(seconds)) continue; - - return { seconds, source: `${netFlow.source}/${variant}` }; - } - - this.logger.warn('No level data available to compute remaining time.'); - return { seconds: null, source: netFlow.source }; - } - - _updatePredictedVolume(snapshot) { - const predicted = snapshot.flows.predicted; - if (!predicted) return; - - const inflowCur = predicted.inflow.current; - const inflowPrev = predicted.inflow.previous ?? inflowCur; - const outflowCur = predicted.outflow.current; - const outflowPrev = predicted.outflow.previous ?? outflowCur; - - const timestampNow = - inflowCur?.timestamp ?? outflowCur?.timestamp ?? inflowPrev?.timestamp ?? outflowPrev?.timestamp; - const timestampPrev = inflowPrev?.timestamp ?? outflowPrev?.timestamp ?? timestampNow; - - if (!Number.isFinite(timestampNow) || !Number.isFinite(timestampPrev)) return; - - const deltaSeconds = (timestampNow - timestampPrev) / 1000; - if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return; - - const avgInflow = this._averageSampleValues(inflowCur, inflowPrev); - const avgOutflow = this._averageSampleValues(outflowCur, outflowPrev); - - const netVolumeChange = (avgInflow - avgOutflow) * deltaSeconds; - if (!Number.isFinite(netVolumeChange) || netVolumeChange === 0) return; - - const volumeSeries = this.measurements - .type('volume') - .variant('predicted') - .position('atEquipment'); - - const currentVolume = volumeSeries.getCurrentValue('m3') ?? this.basin.minVol; - const nextVolume = currentVolume + netVolumeChange; - - const writeTimestamp = Number.isFinite(timestampNow) ? timestampNow : Date.now(); - - volumeSeries.value(nextVolume, writeTimestamp, 'm3').unit('m3'); - - const nextLevel = this._calcLevelFromVolume(nextVolume); - this.measurements - .type('level') - .variant('predicted') - .position('atEquipment') - .value(nextLevel, writeTimestamp, 'm') - .unit('m'); - } - - _averageSampleValues(sampleA, sampleB) { - const values = [sampleA?.value, sampleB?.value].filter((v) => Number.isFinite(v)); - if (!values.length) return 0; - return values.reduce((acc, val) => acc + val, 0) / values.length; - } - - _deriveDirection(netFlow) { - if (netFlow > this.flowThreshold) return 'filling'; - if (netFlow < -this.flowThreshold) return 'draining'; - return 'steady'; - } - - /* ------------------------------------------------------------------ */ - /* Basin Calculations */ - /* ------------------------------------------------------------------ */ - - initBasinProperties() { - const volEmptyBasin = this.config.basin.volume; - const heightBasin = this.config.basin.height; - const heightInlet = this.config.basin.heightInlet; - const heightOutlet = this.config.basin.heightOutlet; - const heightOverflow = this.config.basin.heightOverflow; - - const surfaceArea = volEmptyBasin / heightBasin; - const maxVol = heightBasin * surfaceArea; - const maxVolOverflow = heightOverflow * surfaceArea; - const minVol = heightOutlet * surfaceArea; - const minVolOut = heightInlet * surfaceArea; - - this.basin = { - volEmptyBasin, - heightBasin, - heightInlet, - heightOutlet, - heightOverflow, - surfaceArea, - maxVol, - maxVolOverflow, - minVol, - minVolOut - }; - - this.measurements - .type('volume') - .variant('predicted') - .position('atEquipment') - .value(minVol) - .unit('m3'); - - this.logger.debug( - `Basin initialized | area=${surfaceArea.toFixed(2)} m2, max=${maxVol.toFixed(2)} m3, overflow=${maxVolOverflow.toFixed(2)} m3` - ); - } - - _calcVolumeFromLevel(level) { - return Math.max(level, 0) * this.basin.surfaceArea; - } - - _calcLevelFromVolume(volume) { - return Math.max(volume, 0) / this.basin.surfaceArea; - } - - /* ------------------------------------------------------------------ */ - /* Output */ - /* ------------------------------------------------------------------ */ - - getOutput() { - const output = {}; - Object.entries(this.measurements.measurements).forEach(([type, variants]) => { - Object.entries(variants).forEach(([variant, positions]) => { - Object.entries(positions).forEach(([position, measurement]) => { - output[`${type}.${variant}.${position}`] = measurement.getCurrentValue(); - }); - }); - }); - - output.state = this.state; - output.basin = this.basin; - return output; - } -} - -module.exports = PumpingStationV2; - -/* ------------------------------------------------------------------------- */ -/* Example usage */ -/* ------------------------------------------------------------------------- */ - -if (require.main === module) { - const Measurement = require('../../measurement/src/specificClass'); - const RotatingMachine = require('../../rotatingMachine/src/specificClass'); - - function createPumpingStationConfig(name) { - return { - general: { - logging: { enabled: true, logLevel: 'debug' }, - name, - id: `${name}-${Date.now()}`, - flowThreshold: 1e-4 - }, - functionality: { - softwareType: 'pumpingStation', - role: 'stationcontroller' - }, - basin: { - volume: 43.75, - height: 3.5, - heightInlet: 0.3, - heightOutlet: 0.2, - heightOverflow: 3.0 - }, - hydraulics: { - refHeight: 'NAP', - basinBottomRef: 0 - } - }; - } - - function createLevelMeasurementConfig(name) { - return { - general: { - logging: { enabled: true, logLevel: 'debug' }, - name, - id: `${name}-${Date.now()}`, - unit: 'm' - }, - functionality: { - softwareType: 'measurement', - role: 'sensor', - positionVsParent: 'atEquipment' - }, - asset: { - category: 'sensor', - type: 'level', - model: 'demo-level', - supplier: 'demoCo', - unit: 'm' - }, - scaling: { enabled: false }, - smoothing: { smoothWindow: 5, smoothMethod: 'none' } - }; - } - - function createFlowMeasurementConfig(name, position) { - return { - general: { - logging: { enabled: true, logLevel: 'debug' }, - name, - id: `${name}-${Date.now()}`, - unit: 'm3/s' - }, - functionality: { - softwareType: 'measurement', - role: 'sensor', - positionVsParent: position - }, - asset: { - category: 'sensor', - type: 'flow', - model: 'demo-flow', - supplier: 'demoCo', - unit: 'm3/s' - }, - scaling: { enabled: false }, - smoothing: { smoothWindow: 5, smoothMethod: 'none' } - }; - } - - function createMachineConfig(name) { - return { - general: { - name, - logging: { enabled: true, logLevel: 'debug' } - }, - functionality: { - positionVsParent: 'downstream' - }, - asset: { - supplier: 'Hydrostal', - type: 'pump', - category: 'centrifugal', - model: 'hidrostal-H05K-S03R' - } - }; - } - - function createMachineStateConfig() { - return { - general: { - logging: { - enabled: true, - logLevel: 'debug' - } - }, - movement: { speed: 1 }, - time: { - starting: 2, - warmingup: 3, - stopping: 2, - coolingdown: 3 - } - }; - } - - function seedSample(measurement, type, value, unit) { - const pos = measurement.config.functionality.positionVsParent; - measurement.measurements.type(type).variant('measured').position(pos).value(value, Date.now(), unit); - } - - (async function demo() { - const station = new PumpingStationV2(createPumpingStationConfig('PumpingStationDemo')); - const pump = new RotatingMachine(createMachineConfig('Pump1'), createMachineStateConfig()); - - const levelSensor = new Measurement(createLevelMeasurementConfig('WetWellLevel')); - const inflowSensor = new Measurement(createFlowMeasurementConfig('InfluentFlow', 'in')); - const outflowSensor = new Measurement(createFlowMeasurementConfig('PumpDischargeFlow', 'out')); - - station.childRegistrationUtils.registerChild(levelSensor, levelSensor.config.functionality.softwareType); - station.childRegistrationUtils.registerChild(inflowSensor, inflowSensor.config.functionality.softwareType); - station.childRegistrationUtils.registerChild(outflowSensor, outflowSensor.config.functionality.softwareType); - station.childRegistrationUtils.registerChild(pump, 'machine'); - - // Seed initial measurements - seedSample(levelSensor, 'level', 1.8, 'm'); - seedSample(inflowSensor, 'flow', 0.35, 'm3/s'); - seedSample(outflowSensor, 'flow', 0.20, 'm3/s'); - - setInterval(() => station.tick(), 1000); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - station.tick(); - console.log('Initial state:', station.state); - - await pump.handleInput('parent', 'execSequence', 'startup'); - await pump.handleInput('parent', 'execMovement', 50); - - console.log('Station state:', station.state); - console.log('Station output:', station.getOutput()); - })().catch((err) => { - console.error('Demo failed:', err); - }); -}