dev-Rene #1

Merged
renederen merged 21 commits from dev-Rene into main 2025-12-08 10:00:07 +00:00
2 changed files with 564 additions and 1414 deletions
Showing only changes of commit 2a31c7ec69 - Show all commits

File diff suppressed because it is too large Load Diff

View File

@@ -1,681 +0,0 @@
const EventEmitter = require('events');
const {
logger,
configUtils,
configManager,
childRegistrationUtils,
MeasurementContainer,
coolprop,
interpolation
} = require('generalFunctions');
const FLOW_VARIANTS = ['measured', 'predicted'];
const LEVEL_VARIANTS = ['measured', 'predicted'];
const FLOW_POSITIONS = {
inflow: ['in', 'upstream'],
outflow: ['out', 'downstream']
};
class PumpingStationV2 {
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('level', 'm');
this.measurements.setPreferredUnit('volume', 'm3');
this.childRegistrationUtils = new childRegistrationUtils(this);
this.machines = {};
this.stations = {};
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;
}
if (softwareType === 'machine' || softwareType === 'pumpingStation') {
this._registerPredictedFlowChild(child);
return;
}
this.logger.warn(`Unsupported child software type: ${softwareType}`);
}
tick() {
const snapshot = this._takeMeasurementSnapshot();
this._updatePredictedVolume(snapshot);
const netFlow = this._selectBestNetFlow(snapshot);
const remaining = this._computeRemainingTime(snapshot, netFlow);
this.state = {
direction: netFlow.direction,
netFlow: netFlow.value,
flowSource: netFlow.source,
seconds: remaining.seconds,
remainingSource: remaining.source
};
this.logger.debug(
`Remaining time (${remaining.source ?? 'n/a'}): ${
remaining.seconds != null ? `${Math.round((remaining.seconds / 60 / 60) * 10) / 10} h` : 'n/a'
}`
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
_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);
});
}
_registerPredictedFlowChild(child) {
const position = child.config.functionality.positionVsParent;
const childName = child.config.general.name;
const listener = (eventName, posKey) => {
child.measurements.emitter.on(eventName, (eventData) => {
this.logger.debug(
`Predicted flow update from ${childName} (${position}) -> ${eventData.value} ${eventData.unit}`
);
this.measurements
.type('flow')
.variant('predicted')
.position(posKey)
.value(eventData.value, eventData.timestamp, eventData.unit);
});
};
if (position === 'downstream' || position === 'atEquipment' || position === 'out') {
listener('flow.predicted.downstream', 'out');
} else if (position === 'upstream' || position === 'in') {
listener('flow.predicted.downstream', 'in');
} else {
this.logger.warn(`Unsupported predicted flow position "${position}" from ${childName}`);
}
}
_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('volume')
.variant('percent')
.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: {}
};
for (const variant of FLOW_VARIANTS) {
snapshot.flows[variant] = this._snapshotFlowsForVariant(variant);
}
for (const variant of LEVEL_VARIANTS) {
snapshot.levels[variant] = this._snapshotLevelForVariant(variant);
snapshot.levelRates[variant] = this._estimateLevelRate(snapshot.levels[variant]);
}
return snapshot;
}
_snapshotFlowsForVariant(variant) {
const inflowSeries = this._locateSeries('flow', variant, FLOW_POSITIONS.inflow);
const outflowSeries = this._locateSeries('flow', variant, FLOW_POSITIONS.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,
series: null,
current: null,
previous: null
};
}
try {
const current = seriesInfo.series.getLaggedSample(0);
const previous = seriesInfo.series.getLaggedSample(1);
return {
exists: Boolean(current),
series: seriesInfo.series,
current,
previous
};
} catch (err) {
this.logger.debug(
`Failed to read samples for ${seriesInfo.type}.${seriesInfo.variant}.${seriesInfo.position}: ${err.message}`
);
return {
exists: false,
series: seriesInfo.series,
current: null,
previous: null
};
}
}
_locateSeries(type, variant, positions) {
for (const position of positions) {
try {
this.measurements.type(type).variant(variant);
const exists = this.measurements.exists({ position, requireValues: true });
if (!exists) continue;
const series = this.measurements.type(type).variant(variant).position(position);
return { type, variant, position, series };
} 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 FLOW_VARIANTS) {
const flow = snapshot.flows[variant];
if (!flow.inflow.exists || !flow.outflow.exists) continue;
const inflow = flow.inflow.current?.value ?? null;
const outflow = flow.outflow.current?.value ?? null;
if (!Number.isFinite(inflow) || !Number.isFinite(outflow)) continue;
const net = inflow - outflow; // positive => filling
if (!Number.isFinite(net)) continue;
return {
value: net,
source: variant,
direction: this._deriveDirection(net)
};
}
// fallback using level trend
for (const variant of LEVEL_VARIANTS) {
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 LEVEL_VARIANTS) {
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 inflowCur = predicted.inflow.current;
const inflowPrev = predicted.inflow.previous ?? inflowCur;
const outflowCur = predicted.outflow.current;
const outflowPrev = predicted.outflow.previous ?? outflowCur;
const timestampNow =
inflowCur?.timestamp ?? outflowCur?.timestamp ?? inflowPrev?.timestamp ?? outflowPrev?.timestamp;
const timestampPrev = inflowPrev?.timestamp ?? outflowPrev?.timestamp ?? timestampNow;
if (!Number.isFinite(timestampNow) || !Number.isFinite(timestampPrev)) return;
const deltaSeconds = (timestampNow - timestampPrev) / 1000;
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return;
const avgInflow = this._averageSampleValues(inflowCur, inflowPrev);
const avgOutflow = this._averageSampleValues(outflowCur, outflowPrev);
const netVolumeChange = (avgInflow - avgOutflow) * deltaSeconds;
if (!Number.isFinite(netVolumeChange) || netVolumeChange === 0) return;
const volumeSeries = this.measurements
.type('volume')
.variant('predicted')
.position('atEquipment');
const currentVolume = volumeSeries.getCurrentValue('m3') ?? this.basin.minVol;
const nextVolume = currentVolume + netVolumeChange;
const writeTimestamp = Number.isFinite(timestampNow) ? timestampNow : Date.now();
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');
}
_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() {
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;
const surfaceArea = volEmptyBasin / heightBasin;
const maxVol = heightBasin * surfaceArea;
const maxVolOverflow = heightOverflow * surfaceArea;
const minVol = heightOutlet * surfaceArea;
const minVolOut = heightInlet * surfaceArea;
this.basin = {
volEmptyBasin,
heightBasin,
heightInlet,
heightOutlet,
heightOverflow,
surfaceArea,
maxVol,
maxVolOverflow,
minVol,
minVolOut
};
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.state = this.state;
output.basin = this.basin;
return output;
}
}
module.exports = PumpingStationV2;
/* ------------------------------------------------------------------------- */
/* 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: 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) {
return {
general: {
name,
logging: { enabled: true, logLevel: 'debug' }
},
functionality: {
positionVsParent: 'downstream'
},
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 PumpingStationV2(createPumpingStationConfig('PumpingStationDemo'));
const pump = new RotatingMachine(createMachineConfig('Pump1'), 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(pump, '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));
station.tick();
console.log('Initial state:', station.state);
await pump.handleInput('parent', 'execSequence', 'startup');
await pump.handleInput('parent', 'execMovement', 50);
console.log('Station state:', station.state);
console.log('Station output:', station.getOutput());
})().catch((err) => {
console.error('Demo failed:', err);
});
}