const EventEmitter = require('events'); const {logger,configUtils,configManager,childRegistrationUtils,MeasurementContainer,coolprop,interpolation,convert} = require('generalFunctions'); class PumpingStation { 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, preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' } }); this.childRegistrationUtils = new childRegistrationUtils(this); this.machines = {}; this.stations = {}; this.machineGroups = {}; //fetch control mode from config by default this.mode = this.config.control.mode; this._levelState = { crossed: new Set(), dwellUntil: null }; //variants in determining what gets priority this.flowVariants = ['measured', 'predicted']; this.levelVariants = ['measured', 'predicted']; this.volVariants = ['measured', 'predicted']; this.flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }; this.predictedFlowChildren = new Map(); // childId -> { in: 0, out: 0 } 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; } //for machines register them for control if(softwareType === 'machine'){ const childId = child.config.general.id; this.machines[childId] = child; this.logger.debug(`Registered machine child "${child.config.general.name}" with id "${childId}"`); } // for pumping stations register them for control if(softwareType === 'pumpingstation'){ const childId = child.config.general.id; this.stations[childId] = child; this.logger.debug(`Registered pumping station child "${child.config.general.name}" with id "${childId}"`); } // for machine group controllers register them for control if(softwareType === 'machinegroup'){ const childId = child.config.general.id; this.machineGroups[childId] = child; this._registerPredictedFlowChild(child); this.logger.debug(`Registered machine group child "${child.config.general.name}" with id "${childId}"`); } //for all childs that can provide predicted flow data if (softwareType === 'machine' || softwareType === 'pumpingstation' || softwareType === 'machinegroup') { this.logger.debug(`Registering predicted flow child ${child.config.general.name} with software type: ${softwareType}"`); this._registerPredictedFlowChild(child); } //this.logger.warn(`Unsupported child software type: ${softwareType}`); } _safetyController(remainingTime, direction){ this.safetyControllerActive = false; const vol = this._resolveVolume(); if(vol == null){ //if we cant get a volume we cant control blind turn all pumps off. Object.entries(this.machines).forEach(([machineId, machine]) => { machine.handleInput('parent', 'execSequence', 'shutdown'); }); this.logger.warn('No volume data available to safe guard system; shutting down all machines.'); this.safetyControllerActive = true; return; } const { enableDryRunProtection, dryRunThresholdPercent, enableOverfillProtection, overfillThresholdPercent, timeleftToFullOrEmptyThresholdSeconds } = this.config.safety || {}; const dryRunEnabled = Boolean(enableDryRunProtection); const overfillEnabled = Boolean(enableOverfillProtection); const timeProtectionEnabled = timeleftToFullOrEmptyThresholdSeconds > 0; const triggerHighVol = this.basin.maxVolOverflow * ((Number(overfillThresholdPercent) || 0) / 100); const triggerLowVol = this.basin.minVol * (1 + ((Number(dryRunThresholdPercent) || 0) / 100)); // trigger conditions for draining if(direction == "draining"){ this.logger.debug( `Safe-guard (draining): vol=${vol != null ? vol.toFixed(2) + ' m3' : 'N/A'}; ` + `remainingTime=${Number.isFinite(remainingTime) ? remainingTime.toFixed(1) + ' s' : 'N/A'}; ` + `direction=${String(direction)}; triggerLowVol=${Number.isFinite(triggerLowVol) ? triggerLowVol.toFixed(2) + ' m3' : 'N/A'}` ); const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds; const dryRunTriggered = dryRunEnabled && vol < triggerLowVol; if (timeTriggered || dryRunTriggered) { //shut down all downstream or atequipment machines,pumping stations and machine groups Object.entries(this.machines).forEach(([machineId, machine]) => { const position = machine?.config?.functionality?.positionVsParent; if ((position === 'downstream' || position === 'atEquipment') && machine._isOperationalState()) { machine.handleInput('parent', 'execSequence', 'shutdown'); this.logger.warn(`Safe guard triggered: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down machine "${machineId}"`); } }); Object.entries(this.stations).forEach(([stationId, station]) => { station.handleInput('parent', 'execSequence', 'shutdown'); this.logger.warn(`Safe guard triggered: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down station "${stationId}"`); }); Object.entries(this.machineGroups).forEach(([groupId, group]) => { group.turnOffAllMachines(); this.logger.warn(`Safe guard triggered: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down machine group "${groupId}"`); }); this.safetyControllerActive = true; } } if(direction == "filling"){ this.logger.debug(`Safe-guard (filling): vol=${vol != null ? vol.toFixed(2) + ' m3' : 'N/A'}; ` + `remainingTime=${Number.isFinite(remainingTime) ? remainingTime.toFixed(1) + ' s' : 'N/A'}; ` + `direction=${String(direction)}; triggerHighVol=${Number.isFinite(triggerHighVol) ? triggerHighVol.toFixed(2) + ' m3' : 'N/A'}` ); const timeTriggered =timeProtectionEnabled &&remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds; const overfillTriggered = overfillEnabled && vol > triggerHighVol; if (timeTriggered || overfillTriggered) { //shut down all upstream machines,pumping stations and machine groups Object.entries(this.machines).forEach(([machineId, machine]) => { const position = machine?.config?.functionality?.positionVsParent; if ((position === 'upstream' ) && machine._isOperationalState()) { machine.handleInput('parent', 'execSequence', 'shutdown'); } }); Object.entries(this.machineGroups).forEach(([groupId, group]) => { group.turnOffAllMachines(); }); Object.entries(this.stations).forEach(([stationId, station]) => { station.handleInput('parent', 'execSequence', 'shutdown'); }); this.logger.warn(`Safe guard triggered: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down all upstream machines/stations/groups`); this.safetyControllerActive = true; } } } changeMode(newMode){ if ( this.config.control.allowedModes.has(newMode) ){ const currentMode = this.mode; this.logger.info(`Control mode changing from ${currentMode} to ${newMode}`); this.mode = newMode; } else{ this.logger.warn(`Attempted to change to unsupported control mode: ${newMode}`); } } _scaleLevelToFlowPercent(level,minflow,maxflow) { const { minFlowLevel, maxFlowLevel } = this.config.control.levelbased; const output = this.interpolate.interpolate_lin_single_point(level,minFlowLevel,maxFlowLevel,minflow,maxflow); return output; } async _controlLevelBased(direction) { const { startLevel, stopLevel } = this.config.control.levelbased; const flowUnit = this.measurements.getUnit('flow'); // use container as source of truth const levelunit = this.measurements.getUnit('level'); // use container as source of truth let percControl = 0; for (const variant of this.levelVariants) { const level = this.measurements.type('level').variant(variant).postition('atEquipment').getCurrentValue(levelunit); if(!level) continue; } const levelVal = level(snapshot); if (levelVal == null || !Number.isFinite(levelVal)) { this.logger.warn('No valid level found'); return; } if (levelVal > startLevel && direction === 'filling') { let sumFlow = 0; let minTotalFlow = Infinity; Object.values(this.machines).forEach((m) => { const max = m.measurements.type('flow').variant('predicted').position('max').getCurrentValue(flowUnit); const min = m.measurements.type('flow').variant('predicted').position('min').getCurrentValue(flowUnit); if (Number.isFinite(max)) sumFlow += max; if (Number.isFinite(min) && min < minTotalFlow) minTotalFlow = min; }); this.logger.debug(`showing level : ${levelVal}, minTotalFlow: ${minTotalFlow}, sumFlow ${sumFlow}`); percControl = this._scaleLevelToFlowPercent(levelVal, minTotalFlow, sumFlow); await this._applyIdleMachineLevelControl(percControl); } if (levelVal < stopLevel && direction === 'draining') { Object.entries(this.machines).forEach(([machineId, machine]) => { const position = machine?.config?.functionality?.positionVsParent; if ((position === 'downstream' || position === 'atEquipment') && machine._isOperationalState()) { machine.handleInput('parent', 'execSequence', 'shutdown'); } }); Object.entries(this.stations).forEach(([stationId, station]) => { station.handleInput('parent', 'execSequence', 'shutdown'); }); Object.entries(this.machineGroups).forEach(([groupId, group]) => { group.turnOffAllMachines(); }); } } async _applyMachineGroupLevelControl(percentControl) { this.logger.debug(`Applying level control to machine groups: ${percentControl.toFixed(1)}% displaying machine groups ${Object.keys(this.machineGroups).join(', ')}`); if (!this.machineGroups || Object.keys(this.machineGroups).length === 0) return; await Promise.all( Object.values(this.machineGroups).map(async (group) => { try { await group.handleInput('parent', percentControl); } catch (err) { this.logger.error(`Failed to send level control to group "${group.config.general.name}": ${err.message}`); } }) ); } async _applyIdleMachineLevelControl(percentControl) { const idleMachines = Object.values(this.machines).filter((machine) => { const pos = machine?.config?.functionality?.positionVsParent; return (pos === 'downstream' || pos === 'atEquipment') && !machine._isOperationalState(); }); if (!idleMachines.length) return; const perMachine = percentControl / idleMachines.length; for (const machine of idleMachines) { try { await machine.handleInput('parent', 'execSequence', 'startup'); await machine.handleInput('parent', 'execMovement', perMachine); } catch (err) { this.logger.error(`Failed to start idle machine "${machine.config.general.name}": ${err.message}`); } } } _resolveVolume() { for (const variant of this.volVariants) { const type = 'volume'; const unit = this.measurements.getUnit(type); const volume = this.measurements.type(type).variant(variant).position('atEquipment').getCurrentValue(unit) || null; if (!volume) continue; return volume; } return null; } _nextIdleMachine() { return Object.values(this.machines).find((machine) => { const position = machine?.config?.functionality?.positionVsParent; return ( position === 'downstream' || position === 'atEquipment') && !machine._isOperationalState(); }); } //control logic _controlLogic(snapshot,direction){ const mode = this.mode; switch(mode){ case "levelbased": this.logger.debug(`Executing level-based control logic`); this._controlLevelBased(snapshot,direction); break; case "flowbased": this._controlFlowBased(); break; case "manual": this._manualControl(); break; default: this.logger.warn(`Unsupported control mode: ${mode}`); } } _manualControl() { // Nothing to do - manual mode } //calibrate the predicted volume to a known value calibratePredictedVolume(calibratedVol, timestamp = Date.now()){ const volumeChain = this.measurements .type('volume') .variant('predicted') .position('atequipment'); const levelChain = this.measurements .type('level') .variant('predicted') .position('atequipment'); //if we have existing values clear them out const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null; if (volumeMeasurement) { volumeMeasurement.values = []; volumeMeasurement.timestamps = []; } const levelMeasurement = levelChain.exists() ? levelChain.get() : null; if (levelMeasurement) { levelMeasurement.values = []; levelMeasurement.timestamps = []; } volumeChain.value(calibratedVol, timestamp, 'm3').unit('m3'); levelChain.value(this._calcLevelFromVolume(calibratedVol), timestamp, 'm'); this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp }; } calibratePredictedLevel(val,timestamp = Date.now(),unit = 'm'){ const volumeChain = this.measurements .type('volume') .variant('predicted') .position('atequipment'); const levelChain = this.measurements .type('level') .variant('predicted') .position('atequipment'); //if we have existing values clear them out const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null; if (volumeMeasurement) { volumeMeasurement.values = []; volumeMeasurement.timestamps = []; } const levelMeasurement = levelChain.exists() ? levelChain.get() : null; if (levelMeasurement) { levelMeasurement.values = []; levelMeasurement.timestamps = []; } levelChain.value(val, timestamp).unit(unit); volumeChain.value(this._calcVolumeFromLevel(val), timestamp, 'm3'); this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp }; } tick() { const snapshot = this._takeMeasurementSnapshot(); this._updatePredictedVolume(snapshot); const netFlow = this._selectBestNetFlow(snapshot); const remaining = this._computeRemainingTime(netFlow); //check safety conditions this._safetyController(snapshot,remaining.seconds,netFlow.direction); if(this.safetyControllerActive) return; //if safety not active proceed with normal control this._controlLogic(snapshot,netFlow.direction); 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`); } _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); }); } //register machines or pumping stations that can provide predicted flow data _registerPredictedFlowChild(child) { const position = (child.config.functionality.positionVsParent || '').toLowerCase(); const childName = child.config.general.name; const childId = child.config.general.id ?? childName; let posKey; let eventNames; switch (position) { case 'downstream': case 'out': case 'atequipment': posKey = 'out'; eventNames = [ 'flow.predicted.downstream', 'flow.predicted.atequipment' ]; break; case 'upstream': case 'in': posKey = 'in'; eventNames = [ 'flow.predicted.upstream', 'flow.predicted.atequipment' ]; break; default: this.logger.warn(`Unsupported predicted flow position "${position}" from ${childName}`); return; } if (!this.predictedFlowChildren.has(childId)) { this.predictedFlowChildren.set(childId, { in: 0, out: 0 }); this.logger.debug(`Initialized predicted flow tracking for child ${childName} (${childId})`); } const handler = (eventData = {}) => { const flowUnit = this.measurements.getUnit('flow'); const unit = eventData.unit || child.config?.general?.unit || flowUnit; const ts = eventData.timestamp || Date.now(); const posKeyBase = posKey; // 'in' or 'out' this.measurements .type('flow') .variant('predicted') .position(posKeyBase) .child(childId) .value(eventData.value, ts, unit); }; eventNames.forEach((eventName) => child.measurements.emitter.on(eventName, handler)); } setManualInflow(value, timestamp = Date.now(), unit) { const num = Number(value); // Write manual inflow into the aggregated bucket this.measurements .type('flow') .variant('predicted') .position('in') .child('manual-qin') .value(num, timestamp, unit); } _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 = {}) { this.measurements.type('level').variant('measured').position(position).value(value).unit(context.unit); 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('volumePercent') .variant('measured') .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: {}, vols:{}, }; for (const variant of this.flowVariants) { snapshot.flows[variant] = this._snapshotFlowsForVariant(variant); } for (const variant of this.volVariants){ snapshot.vols[variant] = this._snapshotVolsForVariant(variant); } for (const variant of this.levelVariants) { snapshot.levels[variant] = this._snapshotLevelForVariant(variant); snapshot.levelRates[variant] = this._estimateLevelRate(snapshot.levels[variant]); } return snapshot; } _snapshotVolsForVariant(variant) { const volumeSeries = this._locateSeries('volume', variant, ['atequipment']); return {variant,samples: this._seriesSamples(volumeSeries)}; } _snapshotFlowsForVariant(variant) { const inflowSeries = this._locateSeries('flow', variant, this.flowPositions.inflow); const outflowSeries = this._locateSeries('flow', variant, this.flowPositions.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, 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 }; } } _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; 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) { const type = 'flow'; const unit = this.measurements.getUnit(type) || 'm3/s'; for (const variant of this.flowVariants) { // Check if we have *any* flows for this variant at all const bucket = this.measurements.measurements?.[type]?.[variant]; if (!bucket || Object.keys(bucket).length === 0) { this.logger.debug(`No ${type}.${variant} data; skipping this variant`); continue; } // Sum all inflow/outflow positions (in/upstream vs out/downstream) const inflow = this.measurements.sum( type, variant, this.flowPositions.inflow, unit ) || 0; const outflow = this.measurements.sum( type, variant, this.flowPositions.outflow, unit ) || 0; // If absolutely nothing is flowing and bucket only just got created, // we can choose to skip this variant and try the next one. const absIn = Math.abs(inflow); const absOut = Math.abs(outflow); if (absIn < this.flowThreshold && absOut < this.flowThreshold) { this.logger.debug( `Flows for ${type}.${variant} below threshold; inflow=${inflow}, outflow=${outflow}, trying next variant` ); continue; } const net = inflow - outflow; // >0 = filling this.measurements .type('netFlowRate') .variant(variant) .position('atequipment') .value(net, Date.now(), unit); this.logger.debug( `netFlow (${variant}): inflow=${inflow} ${unit}, outflow=${outflow} ${unit}, net=${net} ${unit}` ); return { value: net, source: variant, direction: this._deriveDirection(net) }; } // --- Fallback: 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(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 }; } const type = 'level'; const unit = this.measurements.getUnit(type); for (const variant of this.levelVariants) { const lvl = this.measurements .type(type) .variant(variant) .position('atequipment') .getCurrentValue() if (!Number.isFinite(lvl)) continue; const remainingHeight = netFlow.value > 0 ? Math.max(heightOverflow - lvl, 0) : Math.max(lvl - 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 now = Date.now(); const inflowSample = predicted.inflow.current ?? predicted.inflow.previous ?? null; const outflowSample = predicted.outflow.current ?? predicted.outflow.previous ?? null; if (!this._predictedFlowState) { this._predictedFlowState = { inflow: inflowSample?.value ?? 0, outflow: outflowSample?.value ?? 0, lastTimestamp: inflowSample?.timestamp ?? outflowSample?.timestamp ?? now }; } 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; } let deltaSeconds = (timestampNow - timestampPrev) / 1000; if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) { deltaSeconds = 0; } 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; } const writeTimestamp = timestampPrev + Math.max(deltaSeconds, 0) * 1000; const volumeSeries = this.measurements.type('volume').variant('predicted').position('atEquipment'); 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'); //calc how full this is in procen using minVol vs maxVolOverflow const percent = this.interpolate.interpolate_lin_single_point( currentVolume, this.basin.minVol, this.basin.maxVolOverflow, 0, 100 ); //store this percent value this.measurements .type('volumePercent') .variant('predicted') .position('atequipment') .value(percent); this._predictedFlowState.lastTimestamp = writeTimestamp; } _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() { //is min height based on inlet or outlet elevation? const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn; const volEmptyBasin = this.config.basin.volume; //volume when basin is empty const heightBasin = this.config.basin.height; //total height of basin const heightInlet = this.config.basin.heightInlet; //height at which inlet is located const heightOutlet = this.config.basin.heightOutlet; //height at which outlet is located const heightOverflow = this.config.basin.heightOverflow; //height at which overflow occurs const surfaceArea = volEmptyBasin / heightBasin; //assume uniform cross section for now const maxVol = heightBasin * surfaceArea; //maximum volume when basin is full const maxVolOverflow = heightOverflow * surfaceArea; //maximum volume before overflow occurs const minVolOut = heightOutlet * surfaceArea; //minimum volume to have outlet just above basin bottom const minVolIn = heightInlet * surfaceArea; //minimum volume to have inlet just above waterline const minVol = (minHeightBasedOn === "inlet") ? minVolIn : minVolOut; this.logger.debug(`Basin min volume based on ${minHeightBasedOn} : ${minVol.toFixed(2)} m3`); this.basin = { volEmptyBasin, heightBasin, heightInlet, heightOutlet, heightOverflow, surfaceArea, maxVol, maxVolOverflow, minVolIn, minVolOut, minVol, minHeightBasedOn }; 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 = this.measurements.getFlattenedOutput(); output.direction = this.state.direction; output.flowSource = this.state.flowSource; output.timeleft = this.state.seconds; output.volEmptyBasin = this.basin.volEmptyBasin; output.heightInlet = this.basin.heightInlet; output.heightOverflow = this.basin.heightOverflow; output.maxVol = this.basin.maxVol; output.minVol = this.basin.minVol; output.maxVolOverflow = this.basin.maxVolOverflow; output.minVolOut = this.basin.minVolOut; output.minVolIn = this.basin.minVolIn; output.minHeightBasedOn = this.basin.minHeightBasedOn; return output; } } module.exports = PumpingStation; /* ------------------------------------------------------------------------- */ /* 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: 10, heightInlet: 3, heightOutlet: 0.2, heightOverflow: 3.2 }, 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,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 PumpingStation(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); station.setManualInflow(10,Date.now(),'l/s'); 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); }); } //*/