Compare commits

...

7 Commits

Author SHA1 Message Date
Rene De Ren
762770a063 Expose output format selectors in editor 2026-03-12 16:39:25 +01:00
Rene De Ren
3ff76228eb fix: guard demo IIFE with require.main check
Prevents demo code from executing when module is required by Node-RED,
which caused crashes due to missing measurement data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:38:08 +01:00
Rene De Ren
f01b0bcb19 fix: rename _calcTimeRemaining to _calcRemainingTime + add tests
Fix method name mismatch in tick() that called non-existent _calcTimeRemaining
instead of _calcRemainingTime. Add 27 unit tests for specificClass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:31:47 +01:00
Rene De Ren
4e098eefaa refactor: adopt POSITIONS constants and fix ESLint warnings
Replace hardcoded position strings with POSITIONS.* constants.
Prefix unused variables with _ to resolve no-unused-vars warnings.
Fix no-prototype-builtins where applicable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:35:28 +01:00
Rene De Ren
90f87bb538 Migrate _loadConfig to use ConfigManager.buildConfig()
Replaces manual base config construction with shared buildConfig() method.
Node now only specifies domain-specific config sections.

Part of #1: Extract base config schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:59:35 +01:00
Rene De Ren
8fe9c7ec05 Fix ESLint errors and bugs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:39:57 +01:00
znetsixe
6e9ae9fc7e Need to stich everything together then V1.0 is done. 2025-10-23 18:04:18 +02:00
4 changed files with 488 additions and 154 deletions

View File

@@ -24,6 +24,8 @@
heightInlet: { value: 0.8 }, // m, centre of inlet pipe above floor
heightOutlet: { value: 0.2 }, // m, centre of outlet pipe above floor
heightOverflow: { value: 0.9 }, // m, overflow elevation
processOutputFormat: { value: "process" },
dbaseOutputFormat: { value: "influxdb" },
// Advanced reference information
refHeight: { value: "NAP" }, // reference height
@@ -163,6 +165,25 @@
<input type="number" id="node-input-basinBottomRef" step="0.01" />
</div>
<hr>
<h3>Output Formats</h3>
<div class="form-row">
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
<select id="node-input-processOutputFormat" style="width:60%;">
<option value="process">process</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<div class="form-row">
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;">
<option value="influxdb">influxdb</option>
<option value="json">json</option>
<option value="csv">csv</option>
</select>
</div>
<!-- Shared asset/logger/position menus -->
<div id="asset-fields-placeholder"></div>
<div id="logger-fields-placeholder"></div>

View File

@@ -39,35 +39,20 @@ class nodeClass {
const cfgMgr = new configManager();
this.defaultConfig = cfgMgr.getConfig(this.name);
// Merge UI config over defaults
this.config = {
general: {
name: this.name,
id: node.id, // node.id is for the child registration process
unit: uiConfig.unit, // add converter options later to convert to default units (need like a model that defines this which units we are going to use and then conver to those standards)
logging: {
enabled: uiConfig.enableLog,
logLevel: uiConfig.logLevel
}
},
functionality: {
positionVsParent: uiConfig.positionVsParent,// Default to 'atEquipment' if not specified
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
},
basin:{
// Build config: base sections + pumpingStation-specific domain config
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
basin: {
volume: uiConfig.basinVolume,
height: uiConfig.basinHeight,
heightInlet: uiConfig.heightInlet,
heightOutlet: uiConfig.heightOutlet,
heightOverflow: uiConfig.heightOverflow,
},
hydraulics:{
hydraulics: {
refHeight: uiConfig.refHeight,
basinBottomRef: uiConfig.basinBottomRef,
}
};
console.log(`position vs child for ${this.name} is ${this.config.functionality.positionVsParent} the distance is ${this.config.functionality.distance}`);
});
// Utility for formatting outputs
this._output = new outputUtils();
@@ -203,12 +188,13 @@ class nodeClass {
this.source.handleInput(msg);
break;
*/
case 'registerChild':
case 'registerChild': {
// Register this node as a child of the parent node
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
const childObj = this.RED.nodes.getNode(childId);
this.source.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
break;
}
}
done();
});

View File

@@ -1,5 +1,5 @@
const EventEmitter = require('events');
const {logger,configUtils,configManager,childRegistrationUtils,MeasurementContainer,coolprop,interpolation} = require('generalFunctions');
const {logger,configUtils,configManager,childRegistrationUtils,MeasurementContainer,coolprop,interpolation, POSITIONS} = require('generalFunctions');
class pumpingStation {
constructor(config={}) {
@@ -28,6 +28,7 @@ class pumpingStation {
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.stations = {}; // object to hold station information
this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility
this.logger.debug('pumpstation Initialized with all helpers');
@@ -40,9 +41,7 @@ class pumpingStation {
//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}`;
@@ -68,65 +67,127 @@ class pumpingStation {
//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) => {
switch(child.config.functionality.positionVsParent){
case(POSITIONS.DOWNSTREAM):
case("atequipment"): //in case of atequipment we also assume downstream seeing as it is registered at this pumpingstation as part of it.
//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('out').value(eventData.value,eventData.timestamp,eventData.unit);
});
break;
case(POSITIONS.UPSTREAM):
//check for predicted outgoing flow at the connected child pumpingsation
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);
});
//register this then as upstream flow that arrives at the station
this.measurements.type('flow').variant('predicted').position('in').value(eventData.value,eventData.timestamp,eventData.unit);
});
break;
default:
this.logger.warn(`nu such position ${child.config.functionality.positionVsParent}`);
}
}
// add one for group later
if( softwareType == "machineGroup" ){
/* intentionally empty */
}
// add one for pumping station
if ( softwareType == "pumpingStation"){
// Check if the machine is already registered
this.stations[child.config.general.id] === undefined ? this.machistationsnes[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}`);
switch(child.config.functionality.positionVsParent){
case(POSITIONS.DOWNSTREAM):
//check for predicted outgoing flow at the connected child pumpingsation
child.measurements.emitter.on("flow.predicted.downstream", (eventData) => {
this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`);
//register this then as upstream flow that arrives at the station
this.measurements.type('flow').variant('predicted').position('out').value(eventData.value,eventData.timestamp,eventData.unit);
});
break;
case(POSITIONS.UPSTREAM):
//check for predicted outgoing flow at the connected child pumpingsation
child.measurements.emitter.on("flow.predicted.downstream", (eventData) => {
this.logger.debug(`Flow prediction update from ${child.config.general.id}: ${eventData.value} ${eventData.unit}`);
//register this then as upstream flow that arrives at the station
this.measurements.type('flow').variant('predicted').position('in').value(eventData.value,eventData.timestamp,eventData.unit);
});
break;
default:
// there is no such thing as atequipment from 1 pumpingstation to another....
this.logger.warn(`nu such position ${child.config.functionality.positionVsParent} for pumping station`);
}
}
}
//update prediction in outgoing downstream flow
_updateDownstreamFlowPrediction(){
//in or outgoing flow = direction
_updateVolumePrediction(flowDir){
//get downflow
const downFlowExists = this.measurements.type("flow").variant("predicted").position("atEquipment").exists();
if(!downFlowExists){return};
const seriesExists = this.measurements.type("flow").variant("predicted").position(flowDir).exists();
if(!seriesExists){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 }
const series = this.measurements.type("flow").variant("predicted").position(flowDir);
const currFLow = series.getLaggedValue(0, "m3/s"); // { value, timestamp, unit }
const prevFlow = series.getLaggedValue(1, "m3/s"); // { value, timestamp, unit }
if (!currDownFlow || !prevDownFlow) return;
if (!currFLow || !prevFlow) return;
this.logger.debug(`currDownflow = ${currDownFlow.value} , prevDownFlow = ${prevDownFlow.value}`);
this.logger.debug(`currDownflow = ${currFLow.value} , prevDownFlow = ${prevFlow.value}`);
// calc difference in time
const deltaT = currDownFlow.timestamp - prevDownFlow.timestamp;
const deltaT = currFLow.timestamp - prevFlow.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;
const avgFlow = (currFLow.value + prevFlow.value) / 2;
const calcVol = 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;
const currVolume = this.measurements.type('volume').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3');
let newVol = currVolume;
this.measurements.type('volume').variant('predicted').position('atEquipment').value(newVol).unit('m3');
switch(flowDir){
case("out"):
newVol = currVolume - calcVol;
break;
case("in"):
newVol = currVolume + calcVol;
break;
default:
this.logger.error('Flow must come in or out of the station!');
}
this.measurements.type('volume').variant('predicted').position(POSITIONS.AT_EQUIPMENT).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.measurements.type('level').variant('predicted').position(POSITIONS.AT_EQUIPMENT).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(){
@@ -146,7 +207,12 @@ class pumpingStation {
//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();
this._updateVolumePrediction("out"); //check for changes in outgoing flow
this._updateVolumePrediction("in"); // check for changes in incomming flow
//calc the most important values back to determine state and net up or downstream flow
this._calcNetFlow();
this._calcRemainingTime();
}
@@ -189,13 +255,13 @@ class pumpingStation {
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
const mTemp = this.measurements.type("temperature").variant("measured").position(POSITIONS.AT_EQUIPMENT).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.measurements.type("temperature").variant("assumed").position(POSITIONS.AT_EQUIPMENT).value(15, Date.now(), "C");
kelvinTemp = this.measurements.type('temperature').variant('assumed').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('K');
this.logger.debug(`Temperature is : ${kelvinTemp}`);
} else {
kelvinTemp = mTemp;
@@ -226,85 +292,86 @@ class pumpingStation {
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();
this.measurements.type("volume").variant("measured").position(POSITIONS.AT_EQUIPMENT).value(volume).unit('m3');
this.measurements.type("volume").variant("procent").position(POSITIONS.AT_EQUIPMENT).value(proc);
}
_calcNetFlow() {
const netFlow_FlowSensor = Math.abs(this.measurements.type("flow").variant("measured").difference({ from: POSITIONS.DOWNSTREAM, to: POSITIONS.UPSTREAM, unit: "m3/s" }));
const netFlow_LevelSensor = this._calcNetFlowFromLevelDiff();
const netFlow_PredictedFlow = Math.abs(this.measurements.type('flow').variant('predicted').difference({ from: "in", to: "out", unit: "m3/s" }));
switch (true){
//prefer flowsensor netflow
case (netFlow_FlowSensor!=null):
return netFlow_FlowSensor;
//try using level difference if possible to infer netflow
case (netFlow_LevelSensor!= null):
return netFlow_LevelSensor;
case (netFlow_PredictedFlow != null):
return netFlow_PredictedFlow;
default:
this.logger.warn(`Can't calculate netflow without the proper measurements or predictions`);
return null;
}
}
_calcRemainingTime(level,variant){
const { heightOverflow, heightOutlet, surfaceArea } = this.basin;
const flowDiff = this.measurements.type("flow").variant(variant).difference({ from: POSITIONS.DOWNSTREAM, to: POSITIONS.UPSTREAM, unit: "m3/s" });
const flowBased = this._calcNetFlowFromMeasurements({
heightOverflow,
heightOutlet,
surfaceArea
});
let remainingHeight;
switch(true){
case(flowDiff>0):
remainingHeight = Math.max(heightOverflow - level, 0);
this.state.seconds = remainingHeight * surfaceArea / flowDiff;
break;
const levelBased = this._calcNetFlowFromLevel({
heightOverflow,
heightOutlet,
surfaceArea
});
case(flowDiff<0):
remainingHeight = Math.max(level - heightOutlet, 0);
this.state.seconds = remainingHeight * surfaceArea / Math.abs(flowDiff);
break;
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`
);
}
default:
this.logger.debug(`doing nothing with level calc`)
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 }) {
_calcDirection(flowDiff){
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");
let direction = null;
const flowThreshold = 0.001;
switch (true){
case flowDiff > flowThreshold:
direction = "filling";
break;
case flowDiff < -flowThreshold:
direction = "draining";
break;
case flowDiff < flowThreshold && flowDiff > -flowThreshold:
direction = "stable";
break;
default:
this.logger.warn("Uknown state direction detected??");
return null;
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 };
return direction;
}
_calcNetFlowFromLevel({ heightOverflow, heightOutlet, surfaceArea }) {
const levelObj = this.measurements.type("level").variant("measured").position("atEquipment");
_calcNetFlowFromLevelDiff() {
const { surfaceArea } = this.basin;
const levelObj = this.measurements.type("level").variant("measured").position(POSITIONS.AT_EQUIPMENT);
const level = levelObj.getCurrentValue("m");
const prevLevel = levelObj.getLaggedValue(2, "m"); // { value, timestamp, unit }
const measurement = levelObj.get();
@@ -323,29 +390,9 @@ class pumpingStation {
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 };
return netFlowRate;
}
initBasinProperties() {
@@ -376,15 +423,13 @@ class pumpingStation {
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.measurements.type("volume").variant("predicted").position(POSITIONS.AT_EQUIPMENT).value(minVol).unit('m3');
this.logger.debug(`
Basin initialized | area=${surfaceArea.toFixed(2)} m²,
max=${maxVol.toFixed(2)} m³,
overflow=${maxVolOverflow.toFixed(2)}`
);
}
_calcVolumeFromLevel(level) {
@@ -398,12 +443,34 @@ _calcLevelFromVolume(vol){
}
getOutput() {
return {
volume_m3: this.measurements.type("volume").variant("measured").position("atEquipment").getCurrentValue('m3') ,
getOutput() {
// Improved output object generation
const output = {};
//build the output object
this.measurements.getTypes().forEach(type => {
this.measurements.getVariants(type).forEach(variant => {
this.measurements.getPositions(variant).forEach(position => {
const sample = this.measurements.type(type).variant(variant).position(position);
output[`${type}.${variant}.${position}`] = sample.getCurrentValue();
});
});
});
};
}
//fill in the rest of the output object
output["state"] = this.state;
output["basin"] = this.basin;
if(this.flowDrift != null){
const flowDrift = this.flowDrift;
output["flowNrmse"] = flowDrift.nrmse;
output["flowLongterNRMSD"] = flowDrift.longTermNRMSD;
output["flowImmediateLevel"] = flowDrift.immediateLevel;
output["flowLongTermLevel"] = flowDrift.longTermLevel;
}
return output;
}
}
module.exports = pumpingStation;
@@ -454,7 +521,7 @@ function createLevelMeasurementConfig(name) {
functionality: {
softwareType: "measurement",
role: "sensor",
positionVsParent: "atEquipment"
positionVsParent: POSITIONS.AT_EQUIPMENT
},
asset: {
category: "sensor",
@@ -496,7 +563,6 @@ function createFlowMeasurementConfig(name, position) {
function createMachineConfig(name) {
curve = require('C:/Users/zn375/.node-red/public/fallbackData.json');
return {
general: {
@@ -537,7 +603,7 @@ function createMachineStateConfig() {
}
// convenience for seeding measurements
function pushSample(measurement, type, value, unit) {
function pushSample(measurement, type, value, unit) { // eslint-disable-line no-unused-vars
const pos = measurement.config.functionality.positionVsParent;
measurement.measurements
.type(type)
@@ -547,13 +613,14 @@ function pushSample(measurement, type, value, unit) {
}
/** Demo *********************************************************************/
(async function demoStationWithPump() {
// Only run the demo when this file is executed directly (not when required as a module)
if (require.main === module) (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"));
const levelSensor = new Measurement(createLevelMeasurementConfig("WetWellLevel")); // eslint-disable-line no-unused-vars
const upstreamFlow = new Measurement(createFlowMeasurementConfig("InfluentFlow", POSITIONS.UPSTREAM)); // eslint-disable-line no-unused-vars
const downstreamFlow = new Measurement(createFlowMeasurementConfig("PumpDischargeFlow", POSITIONS.DOWNSTREAM));
// station uses the sensors
@@ -565,7 +632,7 @@ function pushSample(measurement, type, value, unit) {
// pump owns the downstream flow sensor
pump.childRegistrationUtils.registerChild(downstreamFlow, downstreamFlow.config.functionality.positionVsParent);
station.childRegistrationUtils.registerChild(pump,"downstream");
station.childRegistrationUtils.registerChild(pump, POSITIONS.DOWNSTREAM);
setInterval(() => station.tick(), 1000);
@@ -584,7 +651,7 @@ function pushSample(measurement, type, value, unit) {
pushSample(upstreamFlow, "flow", 0.40, "m3/s");
pushSample(levelSensor, "level", 1.85, "m");
*/
console.log("Station output:", station.getOutput());
await pump.handleInput("parent", "execSequence", "startup");
await pump.handleInput("parent", "execMovement", 50);
console.log("Station state:", station.state);

260
test/specificClass.test.js Normal file
View File

@@ -0,0 +1,260 @@
/**
* Tests for pumpingStation specificClass (domain logic).
*
* The pumpingStation class manages a basin (wet well):
* - initBasinProperties: derives surface area, volumes from config
* - _calcVolumeFromLevel / _calcLevelFromVolume: linear geometry
* - _calcDirection: filling / draining / stable from flow diff
* - _callMeasurementHandler: dispatches to type-specific handlers
* - getOutput: builds an output snapshot
*/
const PumpingStation = require('../src/specificClass');
// --------------- helpers ---------------
function makeConfig(overrides = {}) {
const base = {
general: {
name: 'TestStation',
id: 'ps-test-1',
unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' },
},
functionality: {
softwareType: 'pumpingStation',
role: 'stationcontroller',
positionVsParent: 'atEquipment',
},
basin: {
volume: 50, // m3 (empty basin volume)
height: 5, // m
heightInlet: 0.3, // m
heightOutlet: 0.2, // m
heightOverflow: 4.0, // m
},
hydraulics: {
refHeight: 'NAP',
basinBottomRef: 0,
},
};
for (const key of Object.keys(overrides)) {
if (typeof overrides[key] === 'object' && !Array.isArray(overrides[key]) && base[key]) {
base[key] = { ...base[key], ...overrides[key] };
} else {
base[key] = overrides[key];
}
}
return base;
}
// --------------- tests ---------------
describe('pumpingStation specificClass', () => {
describe('constructor / initialization', () => {
it('should create an instance with the given config', () => {
const ps = new PumpingStation(makeConfig());
expect(ps).toBeDefined();
expect(ps.config.general.name).toBe('teststation');
});
it('should initialize state object with default values', () => {
const ps = new PumpingStation(makeConfig());
expect(ps.state).toEqual({ direction: '', netDownstream: 0, netUpstream: 0, seconds: 0 });
});
it('should initialize empty machines, stations, child, parent objects', () => {
const ps = new PumpingStation(makeConfig());
expect(ps.machines).toEqual({});
expect(ps.stations).toEqual({});
expect(ps.child).toEqual({});
expect(ps.parent).toEqual({});
});
});
describe('initBasinProperties()', () => {
it('should calculate surfaceArea = volume / height', () => {
const ps = new PumpingStation(makeConfig());
// 50 / 5 = 10 m2
expect(ps.basin.surfaceArea).toBe(10);
});
it('should calculate maxVol = height * surfaceArea', () => {
const ps = new PumpingStation(makeConfig());
// 5 * 10 = 50
expect(ps.basin.maxVol).toBe(50);
});
it('should calculate maxVolOverflow = heightOverflow * surfaceArea', () => {
const ps = new PumpingStation(makeConfig());
// 4.0 * 10 = 40
expect(ps.basin.maxVolOverflow).toBe(40);
});
it('should calculate minVol = heightOutlet * surfaceArea', () => {
const ps = new PumpingStation(makeConfig());
// 0.2 * 10 = 2
expect(ps.basin.minVol).toBeCloseTo(2, 5);
});
it('should calculate minVolOut = heightInlet * surfaceArea', () => {
const ps = new PumpingStation(makeConfig());
// 0.3 * 10 = 3
expect(ps.basin.minVolOut).toBeCloseTo(3, 5);
});
it('should store the raw config values on basin', () => {
const ps = new PumpingStation(makeConfig());
expect(ps.basin.volEmptyBasin).toBe(50);
expect(ps.basin.heightBasin).toBe(5);
expect(ps.basin.heightInlet).toBe(0.3);
expect(ps.basin.heightOutlet).toBe(0.2);
expect(ps.basin.heightOverflow).toBe(4.0);
});
});
describe('_calcVolumeFromLevel()', () => {
let ps;
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
it('should return level * surfaceArea', () => {
// surfaceArea = 10, level = 2 => 20
expect(ps._calcVolumeFromLevel(2)).toBe(20);
});
it('should return 0 for level = 0', () => {
expect(ps._calcVolumeFromLevel(0)).toBe(0);
});
it('should clamp negative levels to 0', () => {
expect(ps._calcVolumeFromLevel(-3)).toBe(0);
});
});
describe('_calcLevelFromVolume()', () => {
let ps;
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
it('should return volume / surfaceArea', () => {
// surfaceArea = 10, vol = 20 => 2
expect(ps._calcLevelFromVolume(20)).toBe(2);
});
it('should return 0 for volume = 0', () => {
expect(ps._calcLevelFromVolume(0)).toBe(0);
});
it('should clamp negative volumes to 0', () => {
expect(ps._calcLevelFromVolume(-10)).toBe(0);
});
});
describe('volume/level roundtrip', () => {
it('should roundtrip level -> volume -> level', () => {
const ps = new PumpingStation(makeConfig());
const level = 2.7;
const vol = ps._calcVolumeFromLevel(level);
const levelBack = ps._calcLevelFromVolume(vol);
expect(levelBack).toBeCloseTo(level, 10);
});
});
describe('_calcDirection()', () => {
let ps;
beforeAll(() => { ps = new PumpingStation(makeConfig()); });
it('should return "filling" for positive flow above threshold', () => {
expect(ps._calcDirection(0.01)).toBe('filling');
});
it('should return "draining" for negative flow below negative threshold', () => {
expect(ps._calcDirection(-0.01)).toBe('draining');
});
it('should return "stable" for flow near zero (within threshold)', () => {
expect(ps._calcDirection(0.0005)).toBe('stable');
expect(ps._calcDirection(-0.0005)).toBe('stable');
expect(ps._calcDirection(0)).toBe('stable');
});
});
describe('_callMeasurementHandler()', () => {
it('should not throw for flow and temperature measurement types', () => {
const ps = new PumpingStation(makeConfig());
// flow and temperature handlers are empty stubs, safe to call
expect(() => ps._callMeasurementHandler('flow', 0.5, 'downstream', {})).not.toThrow();
expect(() => ps._callMeasurementHandler('temperature', 15, 'atEquipment', {})).not.toThrow();
});
it('should dispatch to the correct handler based on measurement type', () => {
const ps = new PumpingStation(makeConfig());
// Verify the switch dispatches by checking it does not warn for known types
// pressure handler stores values and attempts coolprop calculation
// level handler stores values and computes volume
// We verify the dispatch logic by calling with type and checking no unhandled error
const spy = jest.spyOn(ps, 'updateMeasuredFlow');
ps._callMeasurementHandler('flow', 0.5, 'downstream', {});
expect(spy).toHaveBeenCalledWith(0.5, 'downstream', {});
spy.mockRestore();
});
});
describe('getOutput()', () => {
it('should return an object containing state and basin', () => {
const ps = new PumpingStation(makeConfig());
const out = ps.getOutput();
expect(out).toHaveProperty('state');
expect(out).toHaveProperty('basin');
expect(out.state).toBe(ps.state);
expect(out.basin).toBe(ps.basin);
});
it('should include measurement keys in the output', () => {
const ps = new PumpingStation(makeConfig());
const out = ps.getOutput();
// After initialization the predicted volume is set
expect(typeof out).toBe('object');
});
});
describe('_calcRemainingTime()', () => {
it('should not throw when called with a level and variant', () => {
const ps = new PumpingStation(makeConfig());
// Should not throw even with no measurement data; it will just find null diffs
expect(() => ps._calcRemainingTime(2, 'predicted')).not.toThrow();
});
});
describe('tick()', () => {
it('should call _updateVolumePrediction and _calcNetFlow', () => {
const ps = new PumpingStation(makeConfig());
const spyVol = jest.spyOn(ps, '_updateVolumePrediction');
const spyNet = jest.spyOn(ps, '_calcNetFlow');
// stub _calcRemainingTime to avoid needing full measurement data
ps._calcRemainingTime = jest.fn();
ps.tick();
expect(spyVol).toHaveBeenCalledWith('out');
expect(spyVol).toHaveBeenCalledWith('in');
expect(spyNet).toHaveBeenCalled();
spyVol.mockRestore();
spyNet.mockRestore();
});
});
describe('edge cases', () => {
it('should handle basin with zero height gracefully', () => {
// surfaceArea = volume / height => division by 0 gives Infinity
const config = makeConfig({ basin: { volume: 50, height: 0, heightInlet: 0, heightOutlet: 0, heightOverflow: 0 } });
const ps = new PumpingStation(config);
expect(ps.basin.surfaceArea).toBe(Infinity);
});
it('should handle basin with very small dimensions', () => {
const config = makeConfig({ basin: { volume: 0.001, height: 0.001, heightInlet: 0, heightOutlet: 0, heightOverflow: 0.0005 } });
const ps = new PumpingStation(config);
expect(ps.basin.surfaceArea).toBeCloseTo(1, 5);
});
});
});