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(); this.defaultConfig = this.configManager.getConfig('pumpingStation'); this.configUtils = new configUtils(this.defaultConfig); this.config = this.configUtils.initConfig(config); this.interpolate = new interpolation(); // Init after config is set this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name); // General properties this.measurements = new MeasurementContainer({ autoConvert: true }); // init basin object in pumping station this.basin = {}; this.state = { direction:"", netDownstream:0, netUpstream:0, seconds:0}; // init state object of pumping station to see whats going on // 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.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility this.logger.debug('pumpstation Initialized with all helpers'); } /*------------------- Register child events -------------------*/ registerChild(child, softwareType) { this.logger.debug('Setting up child event for softwaretype ' + softwareType); //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}`); //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('atEquipment').value(eventData.value,eventData.timestamp,eventData.unit); }); } // add one for group later if( softwareType == "machineGroup" ){ } } //update prediction in outgoing downstream flow _updateDownstreamFlowPrediction(){ //get downflow const downFlowExists = this.measurements.type("flow").variant("predicted").position("atEquipment").exists(); if(!downFlowExists){return}; const downFlow = this.measurements.type("flow").variant("predicted").position("atEquipment"); const currDownFlow = downFlow.getLaggedValue(0, "m3/s"); // { value, timestamp, unit } const prevDownFlow = downFlow.getLaggedValue(1, "m3/s"); // { value, timestamp, unit } if (!currDownFlow || !prevDownFlow) return; this.logger.debug(`currDownflow = ${currDownFlow.value} , prevDownFlow = ${prevDownFlow.value}`); // calc difference in time const deltaT = currDownFlow.timestamp - prevDownFlow.timestamp; const deltaSeconds = deltaT / 1000; if (deltaSeconds <= 0) { this.logger.warn(`Flow integration aborted; invalid Δt=${deltaSeconds}s.`); return; } const avgFlow = (currDownFlow.value + prevDownFlow.value) / 2; const volumeSubstracted = 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'); const newVol = currVolume - volumeSubstracted; 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} `); } //update prediction in incomming upstream flow _updateUpstreamFlowPrediction(){ } //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._updateDownstreamFlowPrediction(); } _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; 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; } } // 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}`); } else { kelvinTemp = mTemp; } 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 const g = 9.80665; const pressure_Pa = this.measurements.type("pressure").variant("measured").position(position).getCurrentValue('Pa'); const level = pressure_Pa / 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`); } 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); //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}`); 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); //calc the most important values back to determine state and net up or downstream flow this._calcNetFlow(); } _calcNetFlow() { const { heightOverflow, heightOutlet, surfaceArea } = this.basin; const flowBased = this._calcNetFlowFromMeasurements({ heightOverflow, heightOutlet, surfaceArea }); const levelBased = this._calcNetFlowFromLevel({ heightOverflow, heightOutlet, surfaceArea }); if (flowBased && levelBased) { this.logger.debug( `Flow vs Level comparison | flow=${flowBased.netFlowRate.value.toFixed(3)} ` + `m3/s, level=${levelBased.netFlowRate.toFixed(3)} m3/s` ); } const effective = flowBased || levelBased; if (effective) { this.state = effective.state; this.state.netFlowSource = flowBased ? (levelBased ? "flow+level" : "flow") : "level"; this.logger.debug(`Net-flow state: ${JSON.stringify(this.state)}`); } else { this.logger.debug("Net-flow state: insufficient data"); } return effective; } _calcNetFlowFromMeasurements({ heightOverflow, heightOutlet, surfaceArea }) { const flowDiff = this.measurements.type("flow").variant("measured").difference({ from: "downstream", to: "upstream", unit: "m3/s" }); const level = this.measurements.type("level").variant("measured").position("atEquipment").getCurrentValue("m"); const flowUpstream = this.measurements.type("flow").variant("measured").position("upstream").getCurrentValue("m3/s"); const flowDownstream = this.measurements.type("flow").variant("measured").position("downstream").getCurrentValue("m3/s"); if (flowDiff === null || level === null) { this.logger.warn(`no flowdiff ${flowDiff} or level ${level} found escaping`); return null; } const flowThreshold = 0.1; // m³/s const state = { direction: "stable", seconds: 0, netUpstream: flowUpstream ?? 0, netDownstream: flowDownstream ?? 0 }; if (flowDiff > flowThreshold) { state.direction = "filling"; const remainingHeight = Math.max(heightOverflow - level, 0); state.seconds = remainingHeight * surfaceArea / flowDiff; } else if (flowDiff < -flowThreshold) { state.direction = "draining"; const remainingHeight = Math.max(level - heightOutlet, 0); state.seconds = remainingHeight * surfaceArea / Math.abs(flowDiff); } this.measurements.type("netFlowRate").variant("predicted").position("atEquipment").value(flowDiff).unit("m3/s"); this.logger.debug( `Flow-based net flow | diff=${flowDiff.value.toFixed(3)} m3/s, level=${level.toFixed(3)} m` ); return { source: "flow", netFlowRate: flowDiff, state }; } _calcNetFlowFromLevel({ heightOverflow, heightOutlet, surfaceArea }) { const levelObj = this.measurements.type("level").variant("measured").position("atEquipment"); const level = levelObj.getCurrentValue("m"); const prevLevel = levelObj.getLaggedValue(2, "m"); // { value, timestamp, unit } const measurement = levelObj.get(); const latestTimestamp = measurement?.getLatestTimestamp(); if (level === null || !prevLevel || latestTimestamp == null) { this.logger.warn(`no flowdiff ${level}, previous level ${prevLevel}, latestTimestamp ${latestTimestamp} found escaping`); return null; } const deltaSeconds = (latestTimestamp - prevLevel.timestamp) / 1000; if (deltaSeconds <= 0) { this.logger.warn(`Level fallback: invalid Δt=${deltaSeconds} , LatestTimestamp : ${latestTimestamp}, PrevTimestamp : ${prevLevel.timestamp}`); return null; } const lvlDiff = level - prevLevel.value; const lvlRate = lvlDiff / deltaSeconds; // m/s const levelRateThreshold = 0.1 / surfaceArea; // same 0.1 m³/s threshold translated to height const state = { direction: "stable", seconds: 0, netUpstream: 0, netDownstream: 0 }; if (lvlRate > levelRateThreshold) { state.direction = "filling"; const remainingHeight = Math.max(heightOverflow - level, 0); state.seconds = remainingHeight / lvlRate; } else if (lvlRate < -levelRateThreshold) { state.direction = "draining"; const remainingHeight = Math.max(level - heightOutlet, 0); state.seconds = remainingHeight / Math.abs(lvlRate); } const netFlowRate = lvlRate * surfaceArea; // m³/s inferred from level trend this.measurements.type("netFlowRate").variant("predicted").position("atEquipment").value(netFlowRate).unit("m3/s"); this.logger.warn( `Level-based net flow | rate=${lvlRate.toExponential(3)} m/s, inferred=${netFlowRate.toFixed(3)} m3/s` ); return { source: "level", netFlowRate, state }; } 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; //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. 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 ; //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.logger.debug(` Basin initialized | area=${surfaceArea.toFixed(2)} m², max=${maxVol.toFixed(2)} m³, overflow=${maxVolOverflow.toFixed(2)} m³` ); } _calcVolumeFromLevel(level) { const surfaceArea = this.basin.surfaceArea; return Math.max(level, 0) * surfaceArea; } _calcLevelFromVolume(vol){ const surfaceArea = this.basin.surfaceArea; return Math.max(vol, 0) / surfaceArea; } getOutput() { return { volume_m3: this.measurements.type("volume").variant("measured").position("atEquipment").getCurrentValue('m3') , }; } } 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", }, }, // Your custom config here (or leave empty for defaults) movement: { speed: 1, }, time: { starting: 2, warmingup: 3, stopping: 2, coolingdown: 3, }, } } // 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 pump = new RotatingMachine(createMachineConfig("Pump1"), 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(pump,"downstream"); 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"); */ await pump.handleInput("parent", "execSequence", "startup"); await pump.handleInput("parent", "execMovement", 50); console.log("Station state:", station.state); console.log("Station output:", station.getOutput()); console.log("Pump state:", pump.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`); } 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')); } })(); */