const EventEmitter = require('events'); const {logger,configUtils,configManager,childRegistrationUtils,MeasurementContainer,coolprop,interpolation} = 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 }); this.measurements.setPreferredUnit('flow', 'm3/s'); this.measurements.setPreferredUnit('netFlowRate', 'm3/s'); this.measurements.setPreferredUnit('level', 'm'); this.measurements.setPreferredUnit('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(snapshot,remainingTime,direction){ this.safetyControllerActive = false; const vol = this._resolveVolume(snapshot); 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; } //get threshholds from config const timeThreshhold = this.config.safety.timeleftToFullOrEmptyThresholdSeconds; //seconds const triggerHighVol = this.basin.maxVolOverflow * ( this.config.safety.overfillThresholdPercent/100 ); const triggerLowVol = this.basin.minVol * ( this.config.safety.dryRunThresholdPercent/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'}` ); if( (remainingTime < timeThreshhold && remainingTime !== null) || vol < triggerLowVol || vol == null){ //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'}` ); if( (remainingTime < timeThreshhold && remainingTime !== null) || vol > triggerHighVol || vol == null){ //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}`); } } async _controlLevelBased(snapshot, remainingTime) { // current volume as a percentage of usable capacity const vol = this._resolveVolume(snapshot); if (vol == null) { this.logger.warn('No valid volume found for level-based control'); return; } const { thresholds, timeThresholdSeconds } = this.config.control.levelbased; const percentFull = (vol / this.basin.maxVolOverflow) * 100; // pick thresholds that are now crossed but were not crossed before const newlyCrossed = thresholds.filter(t => percentFull >= t && !this._levelState.crossed.has(t)); this.logger.debug(`Level-based control: vol=${vol.toFixed(2)} m³ (${percentFull.toFixed(1)}%), newly crossed thresholds: [${newlyCrossed.join(', ')}]`); if (!newlyCrossed.length) return; const now = Date.now(); if (!this._levelState.dwellUntil) { this._levelState.dwellUntil = now + timeThresholdSeconds * 1000; this.logger.debug(`Level-based control: waiting ${timeThresholdSeconds}s before acting`); return; } this.logger.debug(`Level-based control: dwelling for another ${Math.round((this._levelState.dwellUntil - now) / 1000)} seconds`); if (now < this._levelState.dwellUntil) return; // still waiting this._levelState.dwellUntil = null; // dwell satisfied, let pumps start this._levelState.dwellUntil = null; newlyCrossed.forEach((threshold) => this._levelState.crossed.add(threshold)); const percentControl = this._calculateLevelControlPercent(thresholds); if (percentControl <= 0) { this.logger.debug('Level-based control: percent control resolved to 0%, skipping commands'); return; } this.logger.info( `level-based control: thresholds [${newlyCrossed.join(', ')}]% reached, requesting ${percentControl.toFixed(1)}% control (vol=${vol.toFixed(2)} m³)` ); await this._applyMachineGroupLevelControl(percentControl); await this._applyIdleMachineLevelControl(percentControl); } 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}`); } } } _calculateLevelControlPercent(thresholds = []) { if (!thresholds.length) return 0; const total = thresholds.length; const crossed = this._levelState.crossed.size; const pct = (crossed / total) * 100; return Math.min(100, Math.max(0, pct)); } _resolveVolume(snapshot) { for (const variant of this.volVariants) { const volsnap = snapshot.vols[variant]; if (volsnap?.samples?.exists) return volsnap.samples.current?.value ?? null; } 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, remainingTime){ const mode = this.mode; switch(mode){ case "levelbased": this.logger.debug(`Executing level-based control logic`); this._controlLevelBased(snapshot, remainingTime); 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'); //if we have existing values clear them out const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null; if (volumeMeasurement) { volumeMeasurement.values = []; volumeMeasurement.timestamps = []; } volumeChain.value(calibratedVol, timestamp, 'm3').unit('m3'); const levelChain = this.measurements .type('level') .variant('predicted') .position('atequipment'); const levelMeasurement = levelChain.exists() ? levelChain.get() : null; if (levelMeasurement) { levelMeasurement.values = []; levelMeasurement.timestamps = []; } levelChain.value(this._calcLevelFromVolume(calibratedVol), timestamp, 'm'); 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(snapshot, 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,remaining.seconds); 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 value = Number.isFinite(eventData.value) ? eventData.value : 0; const timestamp = eventData.timestamp ?? Date.now(); const unit = eventData.unit ?? 'm3/s'; this.logger.debug(`Predicted flow update from ${childName} (${childId}, ${posKey}) -> ${value} ${unit}`); this.predictedFlowChildren.get(childId)[posKey] = value; this._refreshAggregatedPredictedFlow(posKey, timestamp, unit); }; eventNames.forEach((eventName) => child.measurements.emitter.on(eventName, handler)); } _refreshAggregatedPredictedFlow(direction, timestamp = Date.now(), unit = 'm3/s') { const sum = Array.from(this.predictedFlowChildren.values()) .map((entry) => (Number.isFinite(entry[direction]) ? entry[direction] : 0)) .reduce((acc, val) => acc + val, 0); this.measurements .type('flow') .variant('predicted') .position(direction) .value(sum, 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 = {}) { 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) { 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 this.measurements.type('netFlowRate').variant(variant).position('atequipment').value(net).unit('m3/s'); this.logger.debug(`inflow : ${inflow} - outflow : ${outflow}`); 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; if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) { this.logger.warn('Invalid basin surface area.'); return { seconds: null, source: null }; } for (const variant of this.levelVariants) { 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 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 = {}; 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.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); 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); }); } //*/