From c037bbc73ba7aeae1f3c3e945990fe00fff3b373 Mon Sep 17 00:00:00 2001 From: znetsixe <73483679+znetsixe@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:05:54 +0200 Subject: [PATCH] added basic basin class --- LICENSE | 4 +- basin.html | 244 +++++++++++++++++++++++++++++++++++++++++++ basin.js | 40 +++++++ package.json | 29 +++++ src/nodeClass.js | 153 +++++++++++++++++++++++++++ src/specificClass.js | 181 ++++++++++++++++++++++++++++++++ 6 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 basin.html create mode 100644 basin.js create mode 100644 package.json create mode 100644 src/nodeClass.js create mode 100644 src/specificClass.js diff --git a/LICENSE b/LICENSE index cd78b9d..0450130 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ -OPENBARE LICENTIE VAN DE EUROPESE UNIE v. 1.2. -EUPL © Europese Unie 2007, 2016 +OPENBARE LICENTIE VAN DE EUROPESE UNIE v. 1.2. +EUPL © Europese Unie 2007, 2016 Deze openbare licentie van de Europese Unie („EUPL”) is van toepassing op het werk (zoals hieronder gedefinieerd) dat onder de voorwaarden van deze licentie wordt verstrekt. Elk gebruik van het werk dat niet door deze licentie is toegestaan, is verboden (voor zover dit gebruik valt onder een recht van de houder van het auteursrecht op het werk). Het werk wordt verstrekt onder de voorwaarden van deze licentie wanneer de licentiegever (zoals hieronder gedefinieerd), direct volgend op de kennisgeving inzake het auteursrecht op het werk, de volgende kennisgeving opneemt: In licentie gegeven krachtens de EUPL of op een andere wijze zijn bereidheid te kennen heeft gegeven krachtens de EUPL in licentie te geven. diff --git a/basin.html b/basin.html new file mode 100644 index 0000000..1d3345c --- /dev/null +++ b/basin.html @@ -0,0 +1,244 @@ + + + + + + + + + + + + diff --git a/basin.js b/basin.js new file mode 100644 index 0000000..b01c27d --- /dev/null +++ b/basin.js @@ -0,0 +1,40 @@ +const nameOfNode = 'basin'; // this is the name of the node, it should match the file name and the node type in Node-RED +const nodeClass = require('./src/nodeClass.js'); // this is the specific node class +const { MenuManager, configManager } = require('generalFunctions'); + +// This is the main entry point for the Node-RED node, it will register the node and setup the endpoints +module.exports = function(RED) { + // Register the node type + RED.nodes.registerType(nameOfNode, function(config) { + // Initialize the Node-RED node first + RED.nodes.createNode(this, config); + // Then create your custom class and attach it + this.nodeClass = new nodeClass(config, RED, this, nameOfNode); + }); + + // Setup admin UIs + const menuMgr = new MenuManager(); //this will handle the menu endpoints so we can load them dynamically + const cfgMgr = new configManager(); // this will handle the config endpoints so we can load them dynamically + + // Register the different menu's for the measurement node (in the future we could automate this further by refering to the config) + RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => { + try { + const script = menuMgr.createEndpoint(nameOfNode, ['logger']); + res.type('application/javascript').send(script); + } catch (err) { + res.status(500).send(`// Error generating menu: ${err.message}`); + } + }); + + // Endpoint to get the configuration data for the specific node + RED.httpAdmin.get(`/${nameOfNode}/configData.js`, (req, res) => { + try { + const script = cfgMgr.createEndpoint(nameOfNode); + // Send the configuration data as JSON response + res.type('application/javascript').send(script); + } catch (err) { + res.status(500).send(`// Error generating configData: ${err.message}`); + } + }); + +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..719623c --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "basin", + "version": "1.0.0", + "description": "Control module", + "main": "basin.js", + "scripts": { + "test": "node basin.js" + }, + "repository": { + "type": "git", + "url": "https://gitea.centraal.wbd-rd.nl/RnD/basin.git" + }, + "keywords": [ + "basin", + "node-red", + "recipient", + "water" + ], + "author": "Rene De Ren", + "license": "SEE LICENSE", + "dependencies": { + "generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git" + }, + "node-red": { + "nodes": { + "basin": "basin.js" + } + } +} diff --git a/src/nodeClass.js b/src/nodeClass.js new file mode 100644 index 0000000..40796ff --- /dev/null +++ b/src/nodeClass.js @@ -0,0 +1,153 @@ +/** + * basin.class.js + * + * Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use. + * This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers. + */ +const { outputUtils, configManager } = require('generalFunctions'); +const Specific = require("./specificClass"); + +class nodeClass { + /** + * Create a node. + * @param {object} uiConfig - Node-RED node configuration. + * @param {object} RED - Node-RED runtime API. + * @param {object} nodeInstance - The Node-RED node instance. + * @param {string} nameOfNode - The name of the node, used for + */ + constructor(uiConfig, RED, nodeInstance, nameOfNode) { + + // Preserve RED reference for HTTP endpoints if needed + this.node = nodeInstance; + this.RED = RED; + this.name = nameOfNode; + + // Load default & UI config + this._loadConfig(uiConfig,this.node); + + // Instantiate core class + this._setupSpecificClass(); + + // Wire up event and lifecycle handlers + this._bindEvents(); + this._registerChild(); + this._startTickLoop(); + this._attachInputHandler(); + this._attachCloseHandler(); + } + + /** + * Load and merge default config with user-defined settings. + * @param {object} uiConfig - Raw config from Node-RED UI. + */ + _loadConfig(uiConfig,node) { + 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 + } + }; + + 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(); + } + + /** + * Instantiate the core logic and store as source. + */ + _setupSpecificClass() { + this.source = new Specific(this.config); + this.node.source = this.source; // Store the source in the node instance for easy access + } + + /** + * Bind Node-RED status updates. + */ + _bindEvents() { + + } + + /** + * Register this node as a child upstream and downstream. + * Delayed to avoid Node-RED startup race conditions. + */ + _registerChild() { + setTimeout(() => { + this.node.send([ + null, + null, + { topic: 'registerChild', payload: this.node.id , positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' , distance: this.config?.functionality?.distance || null}, + ]); + }, 100); + } + + /** + * Start the periodic tick loop to drive the Measurement class. + */ + _startTickLoop() { + setTimeout(() => { + this._tickInterval = setInterval(() => this._tick(), 1000); + }, 1000); + } + + /** + * Execute a single tick: update measurement, format and send outputs. + */ + _tick() { + this.source.tick(); + + const raw = this.source.getOutput(); + const processMsg = this._output.formatMsg(raw, this.config, 'process'); + const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb'); + + // Send only updated outputs on ports 0 & 1 + this.node.send([processMsg, influxMsg]); + } + + /** + * Attach the node's input handler, routing control messages to the class. + */ + _attachInputHandler() { + this.node.on('input', (msg, send, done) => { + switch (msg.topic) { + case 'simulator': this.source.toggleSimulation(); break; + case 'outlierDetection': this.source.toggleOutlierDetection(); break; + case 'calibrate': this.source.calibrate(); break; + case 'measurement': + if (typeof msg.payload === 'number') { + this.source.inputValue = parseFloat(msg.payload); + } + break; + } + done(); + }); + } + + /** + * Clean up timers and intervals when Node-RED stops the node. + */ + _attachCloseHandler() { + this.node.on('close', (done) => { + clearInterval(this._tickInterval); + //clearInterval(this._statusInterval); + done(); + }); + } +} + +module.exports = nodeClass; diff --git a/src/specificClass.js b/src/specificClass.js new file mode 100644 index 0000000..5d7edaa --- /dev/null +++ b/src/specificClass.js @@ -0,0 +1,181 @@ +const EventEmitter = require('events'); +const {logger,configUtils,configManager,MeasurementContainer,coolprop} = require('generalFunctions'); + +class Basin { + constructor(config={}) { + + this.emitter = new EventEmitter(); // Own EventEmitter + this.configManager = new configManager(); + this.defaultConfig = this.configManager.getConfig('basin'); + this.configUtils = new configUtils(this.defaultConfig); + this.config = this.configUtils.initConfig(config); + + // Init after config is set + this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name); + + // General properties + this.measurements = new MeasurementContainer({ + autoConvert: true, + windowSize: this.config.smoothing.smoothWindow + }); + + // Basin-specific properties + this.flowrate = null; // Function to calculate flow rate based on water level rise or fall + this.timeBeforeOverflow = null; // Time before the basin overflows at current inflow rate + this.timeBeforeEmpty = null; // Time before the basin empties at current outflow rate + this.heightInlet = null; // Height of the inlet pipe from the bottom of the basin + this.heightOutlet = null; // Height of the outlet pipe from the bottom of the basin + this.heightOverflow = null; // Height of the overflow point from the bottom of the basin + this.volume = null; // Total volume of water in the basin, calculated from water level and basin dimensions + this.emptyVolume = null; // Volume in the basin when empty (at level of outlet pipe) + this.fullVolume = null; // Volume in the basin when at level of overflow point + this.crossSectionalArea = null; // Cross-sectional area of the basin, used to calculate volume from water level + + // Initialize basin-specific properties from config + this.initBasinProperties(); + + } + + /*------------------- Register child events -------------------*/ + registerChild(child, softwareType) { + this.logger.debug('Setting up child event for softwaretype ' + softwareType); + + if(softwareType === "measurement"){ + const position = child.config.functionality.positionVsParent; + const distance = child.config.functionality.distanceVsParent || 0; + const measurementType = child.config.asset.type; + const key = `${measurementType}_${position}`; + //rebuild to measurementype.variant no position and then switch based on values not strings or names. + const eventName = `${measurementType}.measured.${position}`; + + this.logger.debug(`Setting up listener for ${eventName} from child ${child.config.general.name}`); + // Register event listener for measurement updates + child.measurements.emitter.on(eventName, (eventData) => { + this.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`); + + console.log(` Emitting... ${eventName} with data:`); + // Store directly in parent's measurement container + this.measurements + .type(measurementType) + .variant("measured") + .position(position) + .value(eventData.value, eventData.timestamp, eventData.unit); + + // Call the appropriate handler + this._callMeasurementHandler(measurementType, eventData.value, position, eventData); + }); + } + } + + _callMeasurementHandler(measurementType, value, position, context) { + switch (measurementType) { + case 'pressure': + this.updateMeasuredPressure(value, position, context); + break; + + case 'flow': + this.updateMeasuredFlow(value, position, context); + break; + + case 'temperature': + this.updateMeasuredTemperature(value, position, context); + break; + + case 'level': + this.updateMeasuredLevel(value, position, context); + break; + + default: + this.logger.warn(`No handler for measurement type: ${measurementType}`); + // Generic handler - just update position + this.updatePosition(); + break; + } +} + + // context handler for pressure updates + updateMeasuredPressure(value, position, context = {}) { + + let kelvinTemp = null; + + //pressure updates come from pressure boxes inside the basin they get converted to a level and stored as level measured at position inlet or outlet + this.logger.debug(`Pressure update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`); + + // Store in parent's measurement container for the first time + this.measurements.type("pressure").variant("measured").position(position).value(value, context.timestamp, context.unit); + + //convert pressure to level based on density of water and height of pressure sensor + const mTemp = this.measurements.type("temperature").variant("measured").position("atEquipment").getCurrentValue('K'); //default to 20C if no temperature measurement + + //prefer measured temp but otherwise assume nominal temp for wastewater + if(mTemp === null){ + this.logger.warn(`No temperature measurement available, defaulting to 15C for pressure to level conversion.`); + this.measurements.type("temperature").variant("assumed").position("atEquipment").value(15, Date.now(), "C"); + kelvinTemp = this.measurements.type('temperature').variant('assumed').position('atEquipment').getCurrentValue('K'); + } else { + kelvinTemp = mTemp; + } + this.logger.debug(`Using temperature: ${kelvinTemp} K for calculations`); + const density = coolprop.PropsSI('D','T',kelvinTemp,'P',101325,'Water'); //density in kg/m3 at temp and surface pressure + const g = + + //calculate how muc flow went in or out based on pressure difference + this.logger.debug(`Using pressure: ${pressure} for calculations`); + } + + initBasinProperties() { + // Initialize basin-specific properties from config + this.heightInlet = this.config.basin.heightInlet || 0; // Default to 0 if not specified + this.heightOutlet = this.config.basin.heightOutlet || 0; // Default to 0 if not specified + this.heightOverflow = this.config.basin.heightOverflow || 0; // Default to 0 if not specified + this.crossSectionalArea = this.config.basin.crossSectionalArea || 1; // Default to 1 m² if not specified + } + + measurement + + getOutput() { + return { + volume: this.volume, + }; + } +} + +module.exports = Basin; + +/* + +// */ + + +(async () => { + const PropsSI = await coolprop.getPropsSI(); + + // 👇 replace these with your real inputs + const tC_input = 25; // °C + const pPa_input = 101325; // Pa + + // Sanitize & convert + const T = Number(tC_input) + 273.15; // K + const P = Number(pPa_input); // Pa + const fluid = 'Water'; + + // Preconditions + if (!Number.isFinite(T) || !Number.isFinite(P)) { + throw new Error(`Bad inputs: T=${T} K, P=${P} Pa`); + } + if (T <= 0) throw new Error(`Temperature must be in Kelvin (>0). Got ${T}.`); + if (P <= 0) throw new Error(`Pressure must be >0 Pa. Got ${P}.`); + + // Try T,P order + let rho = PropsSI('D', 'T', T, 'P', P, fluid); + // Fallback: P,T order (should be equivalent) + if (!Number.isFinite(rho)) rho = PropsSI('D', 'P', P, 'T', T, fluid); + + console.log({ T, P, rho }); + + if (!Number.isFinite(rho)) { + console.error('Still Infinity. Extra checks:'); + console.error('typeof T:', typeof T, 'typeof P:', typeof P); + console.error('Example known-good call:', PropsSI('D', 'T', 298.15, 'P', 101325, 'Water')); + } +})();