Files
pumpingStation/src/specificClass.js
2025-11-20 12:15:46 +01:00

1051 lines
36 KiB
JavaScript

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);
});
}
//*/