const EventEmitter = require('events'); const { logger, configUtils, configManager, childRegistrationUtils, MeasurementContainer, coolprop, interpolation, POSITIONS } = require('generalFunctions'); class PumpingStation { /** * PumpingStation — S88 Process Cell. * * Models a wet-well basin with inflow/outflow and orchestrates child * equipment (pumps via rotatingMachine, pump groups via MGC, nested * stations) to keep the water level within safe bounds. * * Full behaviour, threshold semantics, control modes, and the basin * diagram are documented in the wiki: * wiki/functional-description.md + wiki/modes/*.md * * Tick loop (1 s): predicted volume → net flow → safety → control. */ constructor(config = {}) { // --- Dependency injection & config merge --- this.emitter = new EventEmitter(); this.configManager = new configManager(); this.defaultConfig = this.configManager.getConfig('pumpingStation'); this.configUtils = new configUtils(this.defaultConfig); // initConfig deep-merges user config over schema defaults so every // field is guaranteed present even if the caller omits it. 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); // --- Measurement store --- // autoConvert: incoming values in any unit are stored in their // original unit but getCurrentValue(targetUnit) converts on read. // preferredUnits: the canonical units used for ALL internal math. // Flow and netFlowRate MUST be m3/s because the volume integrator // multiplies flow × seconds to get m3. Level in m and volume in m3 // keep the basin geometry math unit-consistent. this.measurements = new MeasurementContainer({ autoConvert: true, preferredUnits: { flow: 'm3/s', netFlowRate: 'm3/s', level: 'm', volume: 'm3' } }); // --- Child registries --- // Children register via Port 2 handshake. Each dict is keyed by // the child's config.general.id. // machines : rotatingMachine instances (direct pumps, no MGC) // stations : nested pumpingStation instances (cascaded basins) // machineGroups : MGC instances (each manages its own pump pool) this.childRegistrationUtils = new childRegistrationUtils(this); this.machines = {}; this.stations = {}; this.machineGroups = {}; // predictedFlowChildren tracks predicted flow subscriptions per child. // Key = childId, value = { in: , out: }. // Only the highest-level aggregator is subscribed (MGC if present, // otherwise individual machines) to avoid double-counting. this.predictedFlowChildren = new Map(); // --- Variant priority --- // Order determines which variant is used for CONTROL decisions: // 'measured' is preferred; 'predicted' is the fallback. // // IMPORTANT — both variants are ALWAYS computed regardless of which // one drives control. The output exposes both values plus a flag // indicating which variant is currently driving control decisions. // This lets operators see the difference between measured and // predicted, which is valuable for: // - Detecting sensor drift (measured diverges from predicted) // - Validating the volume integrator (predicted tracks measured?) // - Diagnosing control issues (was the wrong source active?) // // Implementation: _selectBestNetFlow computes both and stores both // in MeasurementContainer; it returns the winning variant as the // control source. getOutput() exposes all variants. this.flowVariants = ['measured', 'predicted']; this.levelVariants = ['measured', 'predicted']; this.volVariants = ['measured', 'predicted']; // Position aliases — two naming conventions coexist because: // - Measurement children (sensors) store their raw // positionVsParent from config: 'upstream' / 'downstream' // - Predicted-flow children (MGC, machines) map positions to // shorthand: 'in' / 'out' (see _registerPredictedFlowChild) // // The .sum() helper aggregates across an array of position names, // so this map gives each logical direction ALL its aliases. This // way sum('flow', 'predicted', flowPositions.outflow) catches both // a measurement stored under 'downstream' AND a prediction stored // under 'out'. this.flowPositions = { inflow: ['in', 'upstream'], outflow: ['out', 'downstream'] }; // --- Runtime state --- this.mode = this.config.control.mode; // state is the public snapshot updated at the end of each tick(). // Consumers (nodeClass, dashboard) read this for display/telemetry. this.state = { direction: 'steady', netFlow: 0, flowSource: null, seconds: null, remainingSource: null }; // percControl: the 0-100% demand sent to MGC / direct machines in // levelbased mode. Exposed in getOutput() for dashboards. this.percControl = 0; // --- Flow dead-band --- // flowThreshold (m3/s) prevents control actions on noise. // Default 1e-4 m3/s ≈ 0.36 m3/h — below this, net flow is // treated as 'steady' (no filling, no draining). const thresholdFromConfig = Number(this.config.general?.flowThreshold); this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4; // Compute basin geometry from config and seed the predicted volume // at the basin's minimum volume (outflowLevel or inflowLevel based // on config.hydraulics.minHeightBasedOn). this.initBasinProperties(); this.logger.debug('PumpingStation initialized'); } /* --------------------------- Registration --------------------------- */ registerChild(child, softwareType) { this.logger.debug(`Registering child (${softwareType}) "${child.config.general.name}"`); if (softwareType === 'measurement') { this._registerMeasurementChild(child); return; } if (softwareType === 'machine') { this.machines[child.config.general.id] = child; } else if (softwareType === 'pumpingstation') { this.stations[child.config.general.id] = child; } else if (softwareType === 'machinegroup') { this.machineGroups[child.config.general.id] = child; } // Register predicted-flow subscription. Only register the HIGHEST- // level aggregator: if a machinegroup is present, subscribe to IT // (its flow.predicted already aggregates all child machines). Do NOT // also subscribe to individual machines — that would double-count // because each pump's flow is included in the group total. // // Individual machines (softwareType='machine') are only subscribed // when there is NO machinegroup parent — i.e., pumps wired directly // to the pumping station without an MGC in between. if (softwareType === 'machinegroup' || softwareType === 'pumpingstation') { this._registerPredictedFlowChild(child); } else if (softwareType === 'machine' && Object.keys(this.machineGroups).length === 0) { // Direct-child machine, no group above it — register its flow. this._registerPredictedFlowChild(child); } } _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 || child.config.general.name}: ${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 || '').toLowerCase(); const childName = child.config.general.name; const childId = child.config.general.id ?? childName; let posKey; let eventName; switch (position) { case 'downstream': case 'out': case 'atequipment': posKey = 'out'; // Subscribe to ONE event only. 'downstream' is the most specific // — avoids double-counting from 'atequipment' which carries the // same total flow on a different event name. eventName = 'flow.predicted.downstream'; break; case 'upstream': case 'in': posKey = 'in'; eventName = 'flow.predicted.upstream'; 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 }); } const handler = (eventData = {}) => { const unit = eventData.unit || child.config?.general?.unit; const ts = eventData.timestamp || Date.now(); this.logger.debug(`Emitting for child ${unit} `); this.measurements .type('flow') .variant('predicted') .position(posKey) .child(childId) .value(eventData.value, ts, unit); }; child.measurements.emitter.on(eventName, handler); } /* --------------------------- Calibration --------------------------- */ calibratePredictedVolume(calibratedVol, timestamp = Date.now()) { const volume = this.measurements.type('volume').variant('predicted').position('atequipment').get(); const level = this.measurements.type('level').variant('predicted').position('atequipment').get(); if (volume) { volume.values = []; volume.timestamps = []; } if (level) { level.values = []; level.timestamps = []; } this.measurements.type('volume').variant('predicted').position('atequipment').value(calibratedVol, timestamp, 'm3').unit('m3'); this.measurements.type('level').variant('predicted').position('atequipment').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'); 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 }; } setManualInflow(value, timestamp = Date.now(), unit) { const num = Number(value); this.measurements.type('flow').variant('predicted').position('in').child('manual-qin').value(num, timestamp, unit); } /* --------------------------- Tick / Control --------------------------- */ tick() { this._updatePredictedVolume(); const netFlow = this._selectBestNetFlow(); const remaining = this._computeRemainingTime(netFlow); this._safetyController(remaining.seconds, netFlow.direction); if (this.safetyControllerActive) return; this._controlLogic(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` ); } 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}`); } } _controlLogic(direction) { switch (this.mode) { case 'levelbased': this._controlLevelBased(); break; case 'flowbased': this._controlFlowBased?.(); break; case 'manual': break; default: this.logger.warn(`Unsupported control mode: ${this.mode}`); } } async _controlLevelBased() { const { startLevel, minLevel } = this.config.control.levelbased; const levelUnit = this.measurements.getUnit('level'); const level = this._pickVariant('level', this.levelVariants, 'atequipment', levelUnit); if (level == null) { this.logger.warn('No valid level found'); return; } // Level-based pump control via MGC — three zones: // level < minLevel → STOP (unconditional MGC shutdown) // minLevel ≤ level < startLevel → DEAD ZONE (hysteresis; keep last cmd) // level ≥ startLevel → RUN (linear [startLevel..maxLevel] → [0..100 %]) // See wiki/modes/levelbased.md for the full transfer-function diagram. // STOP — below minLevel, always shut down regardless of direction. if (level < minLevel) { this.percControl = 0; Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines()); return; } // DEAD ZONE — between minLevel and startLevel, do nothing. // Pumps that are running keep their last command; pumps that // are off stay off. This prevents rapid on/off cycling. if (level < startLevel) { return; } // RUN — above startLevel, compute demand and forward to MGC. // _scaleLevelToFlowPercent maps [startLevel..maxLevel] → [0..100]. // Above maxLevel the MGC clamps internally. const rawPercControl = this._scaleLevelToFlowPercent(level); const percControl = Math.max(0, rawPercControl); this.percControl = percControl; this.logger.debug(`Level-based control: level=${level} percControl=${percControl}`); await this._applyMachineGroupLevelControl(percControl); } _controlFlowBased() { // placeholder for flow-based logic } /** * Forward a manual demand value to all child machine groups + direct * machines. Called from the 'Qd' topic handler when PS is in manual * mode — mirrors how rotatingMachine gates commands by mode. * @param {number} demand - the operator-set demand (interpretation * depends on MGC scaling: 'absolute' = m³/h, 'normalized' = 0-100%) */ async forwardDemandToChildren(demand) { this.logger.info(`Manual demand forwarded: ${demand}`); // Forward to machine groups (MGC) if (this.machineGroups && Object.keys(this.machineGroups).length > 0) { await Promise.all( Object.values(this.machineGroups).map((group) => group.handleInput('parent', demand).catch((err) => { this.logger.error(`Failed to forward demand to group: ${err.message}`); }) ) ); } // Forward to direct machines (if any) if (this.machines && Object.keys(this.machines).length > 0) { const perMachine = demand / Object.keys(this.machines).length; for (const machine of Object.values(this.machines)) { try { await machine.handleInput('parent', 'execMovement', perMachine); } catch (err) { this.logger.error(`Failed to forward demand to machine: ${err.message}`); } } } } async _applyMachineGroupLevelControl(percentControl) { if (!this.machineGroups || Object.keys(this.machineGroups).length === 0) return; await Promise.all( Object.values(this.machineGroups).map((group) => group.handleInput('parent', percentControl).catch((err) => { this.logger.error(`Failed to send level control to group "${group.config.general.name}": ${err.message}`); }) ) ); } async _applyMachineLevelControl(percentControl) { const machines = Object.values(this.machines).filter((machine) => { const pos = machine?.config?.functionality?.positionVsParent; return (pos === 'downstream' || pos === 'atequipment'); }); if (!machines.length) return; const perMachine = percentControl / machines.length; for (const machine of machines) { try { await machine.handleInput('parent', 'execSequence', 'startup'); await machine.handleInput('parent', 'execMovement', perMachine); } catch (err) { this.logger.error(`Failed to start machine "${machine.config.general.name}": ${err.message}`); } } } /* --------------------------- Measurements --------------------------- */ _handleMeasurement(measurementType, value, position, context) { switch (measurementType) { case 'level': this._onLevelMeasurement(position, value, context); break; case 'pressure': this._onPressureMeasurement(position, value, context); break; default: 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.maxVolAtOverflow, 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'); } /* --------------------------- Core Calculations --------------------------- */ _pickVariant(type, variants, position, unit) { for (const variant of variants) { const val = this.measurements.type(type).variant(variant).position(position).getCurrentValue(unit); if (!Number.isFinite(val)) continue; return val; } return null; } _scaleLevelToFlowPercent(level) { const { startLevel, maxLevel } = this.config.control.levelbased; this.logger.debug(`Scaling startLevel=${startLevel} maxLevel=${maxLevel}`); return this.interpolate.interpolate_lin_single_point(level, startLevel, maxLevel, 0, 100); } _levelRate(variant) { const chain = this.measurements.type('level').variant(variant).position('atequipment'); if (!chain.exists({ requireValues: true })) return null; const m = chain.get(); const current = m?.getLaggedSample?.(0); const previous = m?.getLaggedSample?.(1); if (!current || !previous || previous.timestamp == null) return null; const dt = (current.timestamp - previous.timestamp) / 1000; if (!Number.isFinite(dt) || dt <= 0) return null; return (current.value - previous.value) / dt; } _updatePredictedVolume() { const flowUnit = 'm3/s'; // this has to be in m3/s for the actions below const now = Date.now(); const inflow = this.measurements.sum('flow', 'predicted', this.flowPositions.inflow, flowUnit) || 0; const outflow = this.measurements.sum('flow', 'predicted', this.flowPositions.outflow, flowUnit) || 0; if (!this._predictedFlowState) { this._predictedFlowState = { inflow, outflow, lastTimestamp: now }; } const timestampPrev = this._predictedFlowState.lastTimestamp ?? now; const deltaSeconds = Math.max((now - timestampPrev) / 1000, 0); const netVolumeChange = deltaSeconds > 0 ? (inflow - outflow) * deltaSeconds : 0; const volumeSeries = this.measurements.type('volume').variant('predicted').position('atequipment'); const currentVolume = volumeSeries.getCurrentValue('m3'); const nextVolume = currentVolume + netVolumeChange; const writeTimestamp = timestampPrev + deltaSeconds * 1000; volumeSeries.value(nextVolume, writeTimestamp, 'm3').unit('m3'); //olifant const nextLevel = this._calcLevelFromVolume(nextVolume); this.measurements .type('level') .variant('predicted') .position('atequipment') .value(nextLevel, writeTimestamp, 'm') .unit('m'); const percent = this.interpolate.interpolate_lin_single_point( nextVolume, this.basin.minVol, this.basin.maxVolAtOverflow, 0, 100 ); this.measurements .type('volumePercent') .variant('predicted') .position('atequipment') .value(percent, writeTimestamp, '%'); this._predictedFlowState = { inflow, outflow, lastTimestamp: writeTimestamp }; } _selectBestNetFlow() { const type = 'flow'; const unit = this.measurements.getUnit(type) || 'm3/s'; for (const variant of this.flowVariants) { const bucket = this.measurements.measurements?.[type]?.[variant]; if (!bucket || Object.keys(bucket).length === 0) continue; 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 (Math.abs(inflow) < this.flowThreshold && Math.abs(outflow) < this.flowThreshold) continue; const net = inflow - outflow; this.measurements.type('netFlowRate').variant(variant).position('atequipment').value(net, Date.now(), unit); return { value: net, source: variant, direction: this._deriveDirection(net) }; } // Fallback: level trend for (const variant of this.levelVariants) { const rate = this._levelRate(variant); if (!Number.isFinite(rate)) continue; const netFlow = rate * 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 { overflowLevel, outflowLevel, surfaceArea } = this.basin; if (!Number.isFinite(surfaceArea) || surfaceArea <= 0) return { seconds: null, source: null }; for (const variant of this.levelVariants) { const lvl = this.measurements.type('level').variant(variant).position('atequipment').getCurrentValue('m'); if (!Number.isFinite(lvl)) continue; const remainingHeight = netFlow.value > 0 ? Math.max(overflowLevel - lvl, 0) : Math.max(lvl - outflowLevel, 0); const seconds = (remainingHeight * surfaceArea) / Math.abs(netFlow.value); if (!Number.isFinite(seconds)) continue; return { seconds, source: `${netFlow.source}/${variant}` }; } return { seconds: null, source: netFlow.source }; } _deriveDirection(netFlow) { if (netFlow > this.flowThreshold) return 'filling'; if (netFlow < -this.flowThreshold) return 'draining'; return 'steady'; } /* --------------------------- Safety --------------------------- */ /** * Safety controller — two hard rules: * * 1. BELOW minLevel (dry-run): pumps CANNOT start. * Shuts down all downstream machines + machine groups. * Only a manual override or emergency can restart them. * safetyControllerActive = true → blocks _controlLogic. * * 2. ABOVE overflow level (overfill): pumps CANNOT stop. * Shuts down UPSTREAM equipment only (stop more water coming in). * Does NOT shut down downstream pumps or machine groups — they * must keep draining. Does NOT set safetyControllerActive — the * level-based control keeps running so pumps stay at the demand * dictated by the current level (which will be >100% near overflow, * meaning all pumps at maximum via the normal demand curve). * Only a manual override or emergency stop can shut pumps during * an overfill event. */ _safetyController(remainingTime, direction) { this.safetyControllerActive = false; const volUnit = this.measurements.getUnit('volume'); const vol = this._pickVariant('volume', this.volVariants, 'atequipment', volUnit); if (vol == null) { Object.values(this.machines).forEach((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.maxVolAtOverflow * ((Number(overfillThresholdPercent) || 0) / 100); const triggerLowVol = this.basin.minVol * (1 + ((Number(dryRunThresholdPercent) || 0) / 100)); // Rule 1: DRY-RUN — below minLevel, pumps cannot run. if (direction === 'draining') { const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds; const dryRunTriggered = dryRunEnabled && vol < triggerLowVol; if (timeTriggered || dryRunTriggered) { // Shut down all downstream equipment — pumps must stop. Object.values(this.machines).forEach((machine) => { const pos = machine?.config?.functionality?.positionVsParent; if ((pos === 'downstream' || pos === 'atequipment') && machine._isOperationalState()) { machine.handleInput('parent', 'execSequence', 'shutdown'); } }); Object.values(this.stations).forEach((station) => station.handleInput('parent', 'execSequence', 'shutdown')); Object.values(this.machineGroups).forEach((group) => group.turnOffAllMachines()); this.logger.warn( `Dry-run safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down downstream equipment` ); // Block _controlLogic so level-based control can't restart pumps. this.safetyControllerActive = true; } } // Rule 2: OVERFILL — above overflow level, pumps cannot stop. // Only shut down UPSTREAM equipment. Downstream pumps + machine // groups keep running at whatever the level control demands // (which will be >100% near overflow = all pumps at max). // Do NOT set safetyControllerActive — _controlLogic must keep // running to maintain pump demand. if (direction === 'filling') { const timeTriggered = timeProtectionEnabled && remainingTime != null && remainingTime < timeleftToFullOrEmptyThresholdSeconds; const overfillTriggered = overfillEnabled && vol > triggerHighVol; if (timeTriggered || overfillTriggered) { // Shut down UPSTREAM only — stop more water coming in. Object.values(this.machines).forEach((machine) => { const pos = machine?.config?.functionality?.positionVsParent; if (pos === 'upstream' && machine._isOperationalState()) { machine.handleInput('parent', 'execSequence', 'shutdown'); } }); Object.values(this.stations).forEach((station) => station.handleInput('parent', 'execSequence', 'shutdown')); // NOTE: machine groups (downstream pumps) are NOT shut down. // They must keep draining to prevent overflow from worsening. this.logger.warn( `Overfill safety: vol=${vol.toFixed(2)} m3, remainingTime=${remainingTime ? remainingTime.toFixed(1) : 'N/A'} s; shutting down upstream equipment only — pumps keep running` ); // NOTE: safetyControllerActive is NOT set — level control // keeps commanding pumps at maximum demand. } } } /* --------------------------- Basin --------------------------- */ /** * Compute basin geometry from config and seed the initial predicted * volume at the operational floor. * * Basin is modelled as a rectangular prism (constant cross-section), * so `volume = level × surfaceArea`. See the wiki's basin-model * diagram for the full threshold layout and naming conventions: * wiki/functional-description.md#basin-model * * `minHeightBasedOn` ('inlet' | 'outlet') selects which pipe height * defines `minVol` — the 0 % point of fill-percent and the default * dry-run reference. */ initBasinProperties() { const minHeightBasedOn = this.config.hydraulics.minHeightBasedOn; const volEmptyBasin = this.config.basin.volume; // m3 — total basin capacity const heightBasin = this.config.basin.height; // m — floor to rim const inflowLevel = this.config.basin.inflowLevel; // m — sewer feed pipe centre const outflowLevel = this.config.basin.outflowLevel; // m — pump suction pipe centre const overflowLevel = this.config.basin.overflowLevel; // m — overflow weir crest // Constant cross-section assumption: volume = level × area const surfaceArea = volEmptyBasin / heightBasin; // Volume at each critical height const maxVol = heightBasin * surfaceArea; // ≡ volEmptyBasin (see note above) const maxVolAtOverflow = overflowLevel * surfaceArea; // spill threshold const minVolAtOutflow = outflowLevel * surfaceArea; // dry-run threshold const minVolAtInflow = inflowLevel * surfaceArea; // gravity-feed threshold // Operational floor: which pipe defines "basin too low" const minVol = minHeightBasedOn === 'inlet' ? minVolAtInflow : minVolAtOutflow; this.basin = { volEmptyBasin, heightBasin, inflowLevel, outflowLevel, overflowLevel, surfaceArea, maxVol, maxVolAtOverflow, minVolAtInflow, minVolAtOutflow, minVol, minHeightBasedOn }; // Seed predicted volume at operational floor — the station assumes // the basin is at minimum until calibrated by a real measurement. this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3'); } /** Convert level (m from floor) → volume (m3). Clamps to 0. */ _calcVolumeFromLevel(level) { return Math.max(level, 0) * this.basin.surfaceArea; } /** Convert volume (m3) → level (m from floor). Clamps to 0. */ _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.inflowLevel = this.basin.inflowLevel; output.overflowLevel = this.basin.overflowLevel; output.maxVol = this.basin.maxVol; output.minVol = this.basin.minVol; output.maxVolAtOverflow = this.basin.maxVolAtOverflow; output.minVolAtOutflow = this.basin.minVolAtOutflow; output.minVolAtInflow = this.basin.minVolAtInflow; output.minHeightBasedOn = this.basin.minHeightBasedOn; output.percControl = this.percControl; 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, inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 3.2 }, hydraulics: { refHeight: 'NAP', basinBottomRef: 0 }, safety: { enableDryRunProtection:false, enableOverfillProtection:false } }; } 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(300,Date.now(),'l/s'); station.calibratePredictedVolume(3.4); //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); }); } //*/