diff --git a/diffuser.html b/diffuser.html index bfbb310..a5d9d39 100644 --- a/diffuser.html +++ b/diffuser.html @@ -1,89 +1,68 @@ - - - - - - + + + + + diff --git a/diffuser.js b/diffuser.js index bd2f31a..8674480 100644 --- a/diffuser.js +++ b/diffuser.js @@ -1,162 +1,9 @@ -module.exports = function (RED) { - function diffuser(config) { - //create node - RED.nodes.createNode(this, config); - - //call this => node so whenver you want to call a node function type node and the function behind it - var node = this; - - //fetch class - var Diffuser = require("./dependencies/diffuser_class"); - - //make new class on creation to work with. - var diffuser = new Diffuser(); - - //fetch name from node into the measurement code - diffuser.name = config.name; - diffuser.number = config.number; - diffuser.id = diffuser.name+diffuser.number; - diffuser.i_n_elements = config.i_elements; - diffuser.i_diff_density = Number(config.i_diff_density); - diffuser.i_m_water = config.i_m_water; - - // create internal vars - this.interval_id = null; - let internalTickRate = 1; - function update_node_state() { - //alarm has prio over warning so comes first in the if else statement - if (diffuser.alarm.state == true) { - //display status - node.status({ fill: "red", shape: "dot", text: diffuser.alarm.text[0] }); - } - else if(diffuser.warning.state == true ){ - //display status - node.status({ fill: "yellow", shape: "dot", text: diffuser.warning.text[0] }); - } - else if(diffuser.idle == true){ - node.status({ fill: "gray", shape: "dot", text: diffuser.o_kgo2_h + " Kg o2 / h"}); - } - else{ - node.status({fill: "green", shape: "dot", text: diffuser.o_kgo2_h + " Kg o2 / h"}); - } - } - - //update on creation - update_node_state(); - - function send_output(){ - - //define empty msgs - let msgs = []; - - let dynList = { - iPressure: diffuser.i_pressure, - iMWater: diffuser.i_m_water, - iFlow: diffuser.i_flow, - nFlow: diffuser.n_flow, - oOtr: diffuser.o_otr, - oPLoss: diffuser.o_p_total, - oKgo2H: diffuser.o_kgo2_h, - oFlowElement: diffuser.o_flow_element, - efficiency: diffuser.o_combined_eff, - //threshold: diffuser.threshold //EXPERIMENTAL! - } - - let specList = { - name: diffuser.name, - number: diffuser.number, - //supplier: diffuser.supplier, - //type: diffuser.type, - density: diffuser.i_diff_density, - nElements: diffuser.i_n_elements, - alfaF: diffuser.i_alfa_factor - } - - msgs[0] = {topic: "kgo2/h" , payload: {kgo2h: diffuser.o_kgo2_h , tot_p_loss : diffuser.o_p_total} ,id: diffuser.id }; - msgs[1] = {topic: "object", payload : diffuser}; - msgs[2] = { - topic:"outputdbase", - payload:[ - - { - measurement: diffuser.name+diffuser.number, - fields : dynList, - tags: { - group: "values", - }, - timestamp: new Date() - }, - { - measurement: diffuser.name+diffuser.number, - fields : specList, - tags: { - group: "specs", - }, - timestamp: new Date() - }, - ] - } // output to broker - - //send outputs - node.send(msgs); - } - - //never ending functions - function tick(){ - update_node_state(); - send_output(); - - } - - // register child on first output this timeout is needed because of node - red stuff - setTimeout( - () => { - - /*---execute code on first start----*/ - let msgs = []; - msgs[3] = { topic : "registerChild" , payload: diffuser }; - //send msg - this.send(msgs); - }, - 100 - ); - - //declare refresh interval internal node - setTimeout( - () => { - /*---execute code on first start----*/ - this.interval_id = setInterval(function(){ tick() },(internalTickRate * 1000)) - }, - 1000 - ); - - - //-------------------------------------------------------------------->>what to do on input - node.on("input", function (msg,send,done) { - - //change density of diffusers - if(msg.topic == "density"){ - diffuser.i_diff_density = Number(msg.payload); - } - - //change input flow - if(msg.topic == "air_flow"){ - diffuser.i_flow = Number(msg.payload); - } - - //change water height - if(msg.topic == "height_water"){ - diffuser.i_m_water = Number(msg.payload); - } - - done(); - - }); - - // tidy up any async code here - shutdown connections and so on. - node.on('close', function() { - clearTimeout(this.interval_id); - }); - } - RED.nodes.registerType("diffuser", diffuser); -}; \ No newline at end of file +const nameOfNode = 'diffuser'; +const nodeClass = require('./src/nodeClass.js'); + +module.exports = function(RED) { + RED.nodes.registerType(nameOfNode, function(config) { + RED.nodes.createNode(this, config); + this.nodeClass = new nodeClass(config, RED, this, nameOfNode); + }); +}; diff --git a/diffuser_class.js b/diffuser_class.js index f240940..46d375f 100644 --- a/diffuser_class.js +++ b/diffuser_class.js @@ -1,589 +1 @@ -/* -Copyright: -Year : (c) 2023 -Author : Rene De Ren -Contact details : zn375ix3@gmail.com -Location : The Netherlands - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files -(the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, -merge, publish, distribute, sublicense, and/or sell copies of the Software, -and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -The author shall be notified of any and all improvements or adaptations this software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -*/ - -//Diffised Aeration Devices (example fine bubble aeration) - -class Diffuser{ - - /*------------------- Construct and set vars -------------------*/ - constructor(supplier,type) { - - this.init = false; - - /* --------------------load depenencies ------------- */ - this.Interpolation = require('../../predict/dependencies/interpolation') ; //load class - this.interpolation = new this.Interpolation; //general use of interpolation object - - this.Fysics = require('./../../convert/dependencies/fysics') ; //load class - this.fysics = new this.Fysics; //general use of fysics - - this.Graph = require('./graph') ; //load class - this.graph = new this.Graph; //general use of fysics - - //load default pressure curve depending on type - this.specs = this.load_specs(); - - //after loading specs load curve builder - this.Predict = require('../../predict/dependencies/predict_class') ; //load class - this.predict_otr = new this.Predict(); //load otr curve - this.predict_otr.i_curve = this.specs.otr_curve; - - this.predict_p = new this.Predict(); - this.predict_p.i_curve = this.specs.p_curve; // load pressure curve - this.predict_p.i_f = 0; // set f to dim 0 because there is no other dim for pressure - - this.predictO2saturation = new this.Predict(); - this.predictO2saturation.i_curve = this.fysics.o2Solubility; // load solubility curve for o2 in water - - - //load converter ./dependencies/index - this.convert = require('./../../convert/dependencies/index'); - - /* ------------ static vars ---------------*/ - this.name = ""; // user defined name - this.number = 0 ; // user defined number - this.id = ""; // unique id from node red? - this.idle = true; // is this idle (not outputting kg o2 / h) - this.desc = "diffuser"; // description of the current object - this.supplier = supplier; // supplier of diffuser - this.type = type; // type of diffuser - - /* ---------- Inputs -------------- */ - this.i_flow = 0 ; // input actual flow rate expressed in Nm3/h - this.i_diff_density = 5; // intput actual density of diffusers - this.i_pressure = 0 ; // this is pressure in header expressed in mbar to calculated air density. - this.i_local_atm_pressure = 1013,25 ; // local atm pressure in mbar - this.i_alfa_factor = 0.7 ; // stnd alfa factor - this.i_water_density = 997 ; // this.water_molar_mass * this.num_moles_water // water density in kg/m3; - this.i_m_water = 0; // input actual height in meter water ABOVE the diffuser - this.i_n_elements = 1; // input for amount of diffusers we need to divide the flow over to get the nm per element - - /*---------calculated parameters ----------*/ - this.n_flow = 0 ; // converted input to normalized conditions of the diffusers flow parameter (x) - this.n_kg = this.fysics.calc_air_dens(this.specs.units.Nm3.pressure,this.specs.units.Nm3.RH,this.specs.units.Nm3.temp); // calculated normalized kg for air density depending on specs kg /m3 - - /* ---------- Outputs -------------- */ - this.o_otr = 0; // predicted oxygen transfer rate - this.o_p_flow = 0; // predicted pressure loss over flow rate - this.o_p_water = 0; // predicted pressure loss over meter water above diffuser - this.o_p_total = 0; // predicted total pressure loss - this.o_kg = 0; // predicted kg of air - this.o_kg_h = 0; // predicted kg per hour - this.o_kgo2_h = 0; // predicted oxygen transfer per hour - this.o_kgo2_h_min = 0; // predicted min kgo2 / hour for zone controller - this.o_kgo2_h_max = 0; // predicted max kgo2 / hour for zone controller - this.o_kgo2 = 0 ; // current oxygen input - this.o_flow_element = 0 ; // flow per element - this.o_otr_max = 0; // store max otr for easy access - this.o_otr_min = 0; // store min otr for easy access - this.o_combined_eff = 0; // combined efficiency - this.o_histogram = 0; // keep track of histogram x % of time for OTR (efficiency) over a max historical value ? or a counter which gets higher over time without. - this.o_slope = 0; - - /*-----------alarms---------------*/ - //putting alarms in 1 array always gets all the alarms that are currently active or inactive - //an alarm is a trigger to stop any process feeding the diffusers - this.alarm = { - text:[], - state:false, - flow:{ - min:{state:false,hyst:10}, //when there isnt enough flow to ensure the correct distribution of air - max:{state:false,hyst:10},//when there is to much flow per diffuser and exceeds the suppliers limits - }, - pressure:{ - min:{state:false,hyst:10}, // see min flow - max:{state:false,hyst:10}, // see max flow - }, - }; - - /*-----------warnings---------------*/ - //putting alarms in 1 array always gets all the alarms that are currently active or inactive - //a warning is a trigger to alert users on eradic behavior or some other activity that might cause damage in the future so users can investigate what is going on - this.warning = { - text:[], // fill this with the warnings we want to display - state:false, - deviation:{ - pressure:{state:false,hyst:2},//when there is a deviation versus expected pressures of the baseline that exceeds a hyst of x % of deviation before becomming true - }, - flow:{ - min:{state:false,hyst:2}, //when there isnt enough flow to ensure the correct distribution of air - max:{state:false,hyst:2},//when there is to much flow per diffuser and exceeds the suppliers limits - }, - pressure:{ - min:{state:false,hyst:2}, // see min flow - max:{state:false,hyst:2}, // see max flow - }, - }; - - /*-------------error handling -----------*/ - this.error = { - text:[], - state:false, - } - - this.init = true; - } - - /*------------------- GETTER/SETTERS -------------------*/ - - set i_n_elements(x){ - - //check if this input is a number - if(Number.isNaN(x)){ - this.error.state = true; - this.error.text.push("number of elements not of type number"); - } - - // you cant have partial elements - x = Math.round(x); - - //check if init has allready been exectued - if ( x <= 0 ) { - this.error.state = true; - this.error.text.push("0 elements input"); - } - - //set densi3ty to value - this._i_n_elements = x; - - } - - get i_n_elements(){ - return this._i_n_elements; - } - - get i_diff_density(){ - return this._i_diff_density; - } - - set i_diff_density(value){ - - //set densi3ty to value - this._i_diff_density = value; - - //check if init has allready been exectued - if(this.init == true){ - //submit new value to prediction - this.predict_otr.i_f = value; - //refresh predictions - this.o_otr = this.predict_otr.o_y; - - } - - } - - set o_otr(value){ - - //set density to value - this._o_otr = Math.round( value * 100 ) / 100; - - //check if init has allready been exectued - if(this.init == true){ - - //current output in kg o2 / h - this.o_kgo2_h = this.convert ( this.o_otr * this.n_flow * this.i_m_water ).from('g').to('kg') ; - - /*make max and min calculations to use them in a kg load zone controller*/ - this.o_kgo2_h_min = this.convert ( this.o_otr_min * this.n_flow * this.i_m_water ).from('g').to('kg') ; - this.o_kgo2_h_max = this.convert ( this.o_otr_max * this.n_flow * this.i_m_water ).from('g').to('kg') ; - - //divide by 3600 to get the current ouput in kg - this.o_kgo2 = this.o_kgo2_h / 3600 ; - - } - - } - - get o_otr(){ - return this._o_otr; - } - - //set meter water column above the diffuser in meters - set i_m_water(value){ - - //set density to value - this._i_m_water = value; - - //convert height to pressure in mbar - this.o_p_water = this.fysics.heigth_to_pressure(this.i_water_density,value); - this.o_p_total = this.o_p_water + this.o_p_flow ; - - //recalc values - this.i_flow = this.i_flow; - } - - get i_m_water(){ - return this._i_m_water - } - - set o_kgo2_h(value){ - this._o_kgo2_h = Math.round( value * 100 ) / 100; - } - - get o_kgo2_h(){ - return this._o_kgo2_h; - } - - set o_p_total(value){ - this._o_p_total = Math.round( value * 100 ) / 100; - } - - get o_p_total(){ - return this._o_p_total; - } - - set o_p_flow(value){ - - //set density to value - this._o_p_flow = Math.round( value * 100 ) / 100; - - //recalc total - this.o_p_total = this.o_p_water + value ; - } - - get o_p_flow(){ - return this._o_p_flow; - } - - set i_flow(value){ - //set density to value - this._i_flow = value; - - if(this.init == true){ - //any flow smaller or equal to zero means diffusers are not being supplied with air and are not active. - if(value > 0){ - - // idle to off - this.idle = false; - - //calc otr and p - this.calc_otr_p(value); - - } - else{ - this.idle = true; - this.o_otr = 0; - this.o_p_flow = 0; - this.o_flow_element = 0; - this.o_p_total = 0; - } - } - } - - get i_flow(){ - return this._i_flow; - } - - - /*------------------------- functions ----------------------------*/ - //a diffuser has a combined efficiency - combine_eff(o_otr,o_otr_min,o_otr_max,o_p_flow,o_p_min,o_p_max){ - //highest otr is best efficiency possible - let eff1 = this.interpolation.interpolate_lin_single_point(o_otr,o_otr_min,o_otr_max,0,1); - //lowest pressure is best pressure possible - let eff2 = this.interpolation.interpolate_lin_single_point(o_p_flow,o_p_min,o_p_max,1,0); - - let result = eff1 * eff2 * 100 ; - - return result; - - } - - // do all actions in order to calculate the outputs for flow and pressure related stuf - calc_otr_p(flow){ - //convert to kg using pressure,rh,temperature we need to get actual data to do this else we assume that normalized equals site - //total input pressure equals atm pressure + measured pressure in system - let tot_i_pressure = this.convert(this.i_local_atm_pressure + this.i_pressure).from('mbar').to('bar'); //calculated output in kg air - this.o_kg = this.fysics.calc_air_dens(tot_i_pressure,0,20); - this.o_kg_h = this.o_kg * flow ; - - //convert this to the normal m3 of diffuser density data - this.n_flow = ( this.o_kg / this.n_kg ) * flow; - - //calculate how much flow per element - this.o_flow_element = Math.round( ( this.n_flow / this.i_n_elements ) * 100 ) / 100 ; - - // input x values in predictors (we could make functions and return outputs dunno whats better) - this.predict_otr.i_x = this.o_flow_element; - this.predict_p.i_x = this.o_flow_element; - - //store otr max and min - this.o_otr_min = this.predict_otr.c_fxy_y_min; - this.o_otr_max = this.predict_otr.c_fxy_y_max; - - //store min and max pressure - this.o_p_min = this.predict_p.c_fxy_y_min; - this.o_p_max = this.predict_p.c_fxy_y_max; - - // predict oxygen transfer rate - this.o_otr = this.predict_otr.o_y; - // predict pressure output of diffuser - this.o_p_flow = this.predict_p.o_y; - - //calculated combined efficiency in % where 100 % is ideal efficieny and 0 is worst - this.o_combined_eff = Math.round(this.combine_eff(this.o_otr,this.o_otr_min,this.o_otr_max,this.o_p_flow,this.o_p_min,this.o_p_max) * 100 ) /100; - - //slope otr curve - this.graph.calc(this.predict_otr.i_x,this.predict_otr.o_y); - this.o_slope = this.graph.slope; - - //go through functions - this.warning_check(); - this.alarm_check(); - - } - - // calculate average saturation over the water depth of the diffuser - calcAvgSolubility(temp){ - - //calculate average pressure in water column - let avgPMbar = ( this.o_p_water + this.i_local_atm_pressure ) / 2; - let avgPBar = this.convert(avgPMbar).from('mbar').to('bar'); - - // input x values in predictors (we could make functions and return outputs dunno whats better) - this.predictO2saturation.i_f = avgPBar; // set f to temperature - this.predictO2saturation.i_x = temp; // set temperature - - // predict oxygen transfer rate - return this.predictO2saturation.o_y; //return average saturation value - - - } - - warning_check(){ - - //warnings do not require to be resetted. we need to log them somewhere in the database so its easy to check if these values are being exceeded - - //empty tekst before filling again and always reset state - this.warning.text = []; - this.warning.state = false; - - /* ------------------ Flow warnings ------------------*/ - //define hyst for flow - let min_flow_hyst = this.predict_p.c_fxy_x_min * 1 * ( this.warning.flow.min.hyst / 100); - let max_flow_hyst = this.predict_p.c_fxy_x_min * 1 * ( this.warning.flow.max.hyst / 100); - - //min flow - if(this.o_flow_element < this.predict_p.c_fxy_x_min - min_flow_hyst ){ - this.warning.flow.min.state = true; - this.warning.state = true; - this.warning.text.push(" Warning : input flow " + this.o_flow_element + " is less then minimum allowed flow of " + ( this.predict_p.c_fxy_x_min - min_flow_hyst )); - } - else{ //auto reset - this.warning.flow.min.state = false; - } - - //max flow - if(this.o_flow_element > this.predict_p.c_fxy_x_max + max_flow_hyst ){ - this.warning.flow.max.state = true; - this.warning.state = true; - this.warning.text.push("Warning input flow " + this.o_flow_element + " is exceeding nominal value of " + ( this.predict_p.c_fxy_x_max + max_flow_hyst )); - } - else{ //auto reset - this.warning.flow.max.state = false; - } - } - - alarm_check(){ - - //warnings do not require to be resetted. we need to log them somewhere in the database so its easy to check if these values are being exceeded - - //empty tekst before filling again and reset general state - this.alarm.text = []; - this.alarm.state = false; - - /* ------------------ Flow warnings ------------------*/ - //define hyst for flow - let min_flow_hyst = this.predict_p.c_fxy_x_min * 1 * ( this.alarm.flow.min.hyst / 100); - let max_flow_hyst = this.predict_p.c_fxy_x_min * 1 * ( this.alarm.flow.max.hyst / 100); - - //min flow - if(this.o_flow_element < this.predict_p.c_fxy_x_min - min_flow_hyst ){ - this.alarm.flow.min.state = true; - this.alarm.state = true; - this.alarm.text.push("Alarm input flow " + this.o_flow_element + " is less then minimum allowed flow of " + ( this.predict_p.c_fxy_x_min - min_flow_hyst )); - } - else{ //auto reset - this.warning.flow.min.state = false; - } - - //max flow - if(this.o_flow_element > this.predict_p.c_fxy_x_max + max_flow_hyst ){ - this.alarm.flow.max.state = true; - this.alarm.state = true; - this.alarm.text.push("Alarm input flow " + this.o_flow_element + " is exceeding an absolute max value of " + ( this.predict_p.c_fxy_x_max + max_flow_hyst )); - } - else{ //auto reset - this.alarm.flow.max.state = false; - } - - - } - - //fetch curve and load it into object var - load_specs(){ - - //for now hardcoded will do as example from sulzer - let Sulzerspecs = { - supplier : "sulzer", - type : "pik300", - units:{ - Nm3: { "temp": 20, "pressure" : 1.01325 , "RH" : 0 }, - t_otr_curve : { f : "diffuser density in % ", x : "Nm3/h", y: { o2_weight : "g", flow : "Nm3/h" , height: "m" } } , // according to DIN - t_p_curve : { x : "flow", y : "mbar" } - }, - otr_curve: { - 5: // diffuser density expressed in % - { - x:[1.5, 2, 3, 4, 5, 6, 7, 8, 9, 10], // flow expressed in normal m3/h - y:[28, 27, 26, 25, 24.5, 24, 23.5, 23.2, 23, 22.9], //oxygen transfer rate expressed in gram o2 / normal m3/h / per m - }, - 10: // diffuser density expressed in % - { - x:[1.5, 2, 3, 4, 5, 6, 7, 8, 9, 10], // flow expressed in normal m3/h - y:[31, 29.5, 28.5, 27, 26, 25.5, 25, 24.7, 24.2, 24], //oxygen transfer rate expressed in gram o2 / normal m3/h / per m - } - }, - p_curve:{ //difuser pressure loss curve - 0: // if curve doesnt have more than 1 dimension just fill in zero here or whatever - { - x:[1.5, 2, 3, 4, 5, 6, 7, 8, 9, 10], // Flow expressed in nm3/h - y:[25,26,28,30,35,38,42,48,55,68] //pressure expressed in mbar - } - }, - solubility_curve:{ - 1: // abs bar - { - x:[0,5,10,15,20,25,30,35,40,45,50], // temp in degrees celcius - y:[14.6,12.8,11.3,10.1,9.1,8.3,7.6,7,6.5,6,5.6], // mg/l - }, - 2: // abs bar - { - x:[0,5,10,15,20,25,30,35,40,45,50], // temp in degrees celcius - y:[29.2,25.5,22.6,20.2,18.2,16.5,15.2,14,12.9,12,11.3], // mg/l - }, - 4: // abs bar - { - x:[0,5,10,15,20,25,30,35,40,45,50], // temp in degrees celcius - y:[58.4,51.1,45.1,40.3,36.4,33.1,30.3,27.9,25.9,24,22.7], // mg/l - }, - } - } - - //for now hardcoded will do as example from sulzer - let specs = { - supplier : "GVA", - type : "ELASTOX-R", - units:{ - Nm3: { "temp": 20, "pressure" : 1.01325 , "RH" : 0 }, - t_otr_curve : { f : "diffuser density in % ", x : "Nm3/h", y: { o2_weight : "g", flow : "Nm3/h" , height: "m" } } , // according to DIN - t_p_curve : { x : "flow", y : "mbar" } - }, - otr_curve: { - 2.4: // diffuser density expressed in % - { - x:[2,3,4,5,6,7,8,9,10], // flow expressed in normal m3/h - y:[26,25,24,23.5,23,22.75,22.5,22.25,22], //oxygen transfer rate expressed in gram o2 / normal m3/h / per m - } - }, - p_curve:{ //difuser pressure loss curve - 0: // if curve doesnt have more than 1 dimension just fill in zero here or whatever - { - x:[2,3,4,5,6,7,8,9,10,11,12], // Flow expressed in nm3/h - y:[40,42.5,45,47.5,50,51.5,53,54.5,56,57.5,59] //pressure expressed in mbar - } - } - } - - return specs; - } - - - //testing converter function - converttest(){ - let test = this.convert(1).from('l').to('ml'); - return test ; - } - - -} // end of class - -/* -var diffuser = new Diffuser("sulzer","PIK300"); - -// define inputs for diffuser -diffuser.i_m_water = 5; // set water column above diffuser -diffuser.i_n_elements = 1; -diffuser.i_diff_density = 5; // set density in % -diffuser.i_flow = 4; // set flow rate in m3/h - -// outputs are -console.log("--------------Inputs--------------") -console.log("Diffuser density : "+ diffuser.i_diff_density + " %"); -console.log("Flow : " + diffuser.i_flow + " m3/h" ); -console.log("Flow rate per element : " + diffuser.o_flow_element + " m3/h"); -console.log("Number of elements : " + diffuser.i_n_elements ); -console.log("--------------Outputs--------------") -console.log("converted input flow to diffuser normalized flow : " + diffuser.n_flow + " m3/h "); -console.log("Oxygen transfer rate : " + diffuser.o_otr + " o2 / m3/h / meter"); -console.log("Pressure loss over diffusers : " + diffuser.o_p_flow + " mbar" ); -console.log("Pressure loss over water column:" + diffuser.o_p_water + " mbar" ); -console.log("Total pressure loss : " + diffuser.o_p_total + " mbar"); -console.log("predicted diffuser oxygen input for bio : " + diffuser.o_kgo2_h + " kg o2 / h"); -console.log("Predicted actual input o2 : " + diffuser.o_kgo2 + " kg o2 / s " ); -console.log("max otr " + diffuser.o_otr_max); -console.log("displaying warnings : " + JSON.stringify(diffuser.warning.text) ); - -//change flow -diffuser.i_diff_density = 5; // set density in % -//diffuser.i_flow = 4; - -console.log("--------------Inputs--------------") -console.log("Diffuser density : "+ diffuser.i_diff_density + " %"); -console.log("Flow : " + diffuser.i_flow + " m3/h" ); -console.log("--------------Outputs--------------") -console.log("Oxygen transfer rate : " + diffuser.o_otr + " o2 / m3/h / meter"); -console.log("Pressure loss: " + diffuser.o_p_flow + " mbar" ); -console.log("slope: " + diffuser.o_slope ); - -diffuser.i_flow = 5; -console.log("--------------Inputs--------------") -console.log("Diffuser density : "+ diffuser.i_diff_density + " %"); -console.log("Flow : " + diffuser.i_flow + " m3/h" ); -console.log("--------------Outputs--------------") -console.log("Oxygen transfer rate : " + diffuser.o_otr + " o2 / m3/h / meter"); -console.log("Pressure loss: " + diffuser.o_p_flow + " mbar" ); -console.log("slope: " + diffuser.o_slope ); - -diffuser.i_diff_density = 0; - -console.log("--------------Inputs--------------") -console.log("Diffuser density : "+ diffuser.i_diff_density + " %"); -console.log("Flow : " + diffuser.i_flow + " m3/h" ); -console.log("--------------Outputs--------------") -console.log("Oxygen transfer rate : " + diffuser.o_otr + " o2 / m3/h / meter"); -console.log("Pressure loss: " + diffuser.o_p_flow + " mbar" ); -// -//*/ -module.exports = Diffuser; +module.exports = require('./src/specificClass'); diff --git a/src/nodeClass.js b/src/nodeClass.js new file mode 100644 index 0000000..c2c55ee --- /dev/null +++ b/src/nodeClass.js @@ -0,0 +1,123 @@ +const { outputUtils, configManager } = require('generalFunctions'); +const Specific = require('./specificClass'); + +class nodeClass { + constructor(uiConfig, RED, nodeInstance, nameOfNode) { + this.node = nodeInstance; + this.RED = RED; + this.name = nameOfNode; + + this._loadConfig(uiConfig); + this._setupSpecificClass(); + this._registerChild(); + this._startTickLoop(); + this._attachInputHandler(); + this._attachCloseHandler(); + } + + _loadConfig(uiConfig) { + const cfgMgr = new configManager(); + const suffix = uiConfig.number !== undefined && uiConfig.number !== '' ? `_${uiConfig.number}` : ''; + const resolvedUiConfig = { + ...uiConfig, + name: uiConfig.name ? `${uiConfig.name}${suffix}` : this.name, + unit: uiConfig.unit || 'kg o2/h', + }; + + this.config = cfgMgr.buildConfig(this.name, resolvedUiConfig, this.node.id, { + functionality: { + softwareType: this.name, + role: 'aeration diffuser', + }, + diffuser: { + number: Number(uiConfig.number) || 0, + elements: Number(uiConfig.i_elements) || 1, + density: Number(uiConfig.i_diff_density) || 2.4, + waterHeight: Number(uiConfig.i_m_water) || 0, + alfaFactor: Number(uiConfig.alfaf ?? 0.7) || 0.7, + headerPressure: Number(uiConfig.i_pressure) || 0, + localAtmPressure: Number(uiConfig.i_local_atm_pressure) || 1013.25, + waterDensity: Number(uiConfig.i_water_density) || 997, + }, + }); + + this._output = new outputUtils(); + } + + _setupSpecificClass() { + this.source = new Specific(this.config); + this.node.source = this.source; + } + + _registerChild() { + setTimeout(() => { + this.node.send([ + null, + null, + { + topic: 'registerChild', + payload: this.node.id, + positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment', + }, + ]); + }, 100); + } + + _startTickLoop() { + setTimeout(() => { + this._tickInterval = setInterval(() => this._tick(), 1000); + }, 1000); + } + + _tick() { + const raw = this.source.getOutput(); + const processMsg = this._output.formatMsg(raw, this.config, 'process'); + const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb'); + this.node.status(this.source.getStatus()); + this.node.send([processMsg, influxMsg, null]); + } + + _attachInputHandler() { + this.node.on('input', (msg, send, done) => { + try { + switch (msg.topic) { + case 'density': + this.source.setDensity(msg.payload); + break; + case 'air_flow': + this.source.setFlow(msg.payload); + break; + case 'height_water': + this.source.setWaterHeight(msg.payload); + break; + case 'header_pressure': + this.source.setHeaderPressure(msg.payload); + break; + case 'elements': + this.source.setElementCount(msg.payload); + break; + case 'alfaFactor': + this.source.setAlfaFactor(msg.payload); + break; + default: + this.source.logger.warn(`Unknown topic: ${msg.topic}`); + break; + } + done(); + } catch (error) { + this.node.status({ fill: 'red', shape: 'ring', text: 'Bad request data' }); + this.node.error(`Bad request data: ${error.message}`, msg); + done(error); + } + }); + } + + _attachCloseHandler() { + this.node.on('close', (done) => { + clearInterval(this._tickInterval); + done(); + }); + } +} + +module.exports = nodeClass; diff --git a/src/specificClass.js b/src/specificClass.js new file mode 100644 index 0000000..dd3bd7a --- /dev/null +++ b/src/specificClass.js @@ -0,0 +1,316 @@ +const { logger, interpolation, gravity, convert } = require('generalFunctions'); + +class Diffuser { + constructor(config = {}) { + this.config = config; + this.logger = new logger( + this.config.general?.logging?.enabled, + this.config.general?.logging?.logLevel, + this.config.general?.name, + ); + + this.interpolation = new interpolation({ type: 'linear' }); + this.fysics = gravity.fysics; + this.convert = convert; + this.specs = this.loadSpecs(); + + this.idle = true; + this.warning = { state: false, text: [], flow: { min: { hyst: 2 }, max: { hyst: 2 } } }; + this.alarm = { state: false, text: [], flow: { min: { hyst: 10 }, max: { hyst: 10 } } }; + + this.i_pressure = this.config.diffuser?.headerPressure || 0; + this.i_local_atm_pressure = this.config.diffuser?.localAtmPressure || 1013.25; + this.i_water_density = this.config.diffuser?.waterDensity || 997; + this.i_alfa_factor = this.config.diffuser?.alfaFactor || 0.7; + this.i_n_elements = this.normalizePositiveInteger(this.config.diffuser?.elements, 1); + this.i_diff_density = this.normalizeNumber(this.config.diffuser?.density, 2.4); + this.i_m_water = this.normalizeNumber(this.config.diffuser?.waterHeight, 0); + this.i_flow = 0; + + this.n_kg = this.fysics.calc_air_dens(1013.25, 0, 20); + + this.n_flow = 0; + this.o_otr = 0; + this.o_p_flow = 0; + this.o_p_water = this.fysics.heigth_to_pressure(this.i_water_density, this.i_m_water); + this.o_p_total = this.o_p_water; + this.o_kg = 0; + this.o_kg_h = 0; + this.o_kgo2_h = 0; + this.o_kgo2 = 0; + this.o_kgo2_h_min = 0; + this.o_kgo2_h_max = 0; + this.o_flow_element = 0; + this.o_otr_min = 0; + this.o_otr_max = 0; + this.o_p_min = 0; + this.o_p_max = 0; + this.o_combined_eff = 0; + this.o_slope = 0; + } + + normalizeNumber(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + + normalizePositiveInteger(value, fallback = 1) { + const parsed = Math.round(Number(value)); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + setDensity(value) { + this.i_diff_density = this.normalizeNumber(value, this.i_diff_density); + this.recalculate(); + } + + setFlow(value) { + this.i_flow = Math.max(0, this.normalizeNumber(value, 0)); + this.recalculate(); + } + + setWaterHeight(value) { + this.i_m_water = Math.max(0, this.normalizeNumber(value, this.i_m_water)); + this.o_p_water = this.fysics.heigth_to_pressure(this.i_water_density, this.i_m_water); + this.recalculate(); + } + + setHeaderPressure(value) { + this.i_pressure = this.normalizeNumber(value, this.i_pressure); + this.recalculate(); + } + + setElementCount(value) { + this.i_n_elements = this.normalizePositiveInteger(value, this.i_n_elements); + this.recalculate(); + } + + setAlfaFactor(value) { + this.i_alfa_factor = this.normalizeNumber(value, this.i_alfa_factor); + this.recalculate(); + } + + recalculate() { + if (this.i_flow <= 0) { + this.idle = true; + this.n_flow = 0; + this.o_otr = 0; + this.o_p_flow = 0; + this.o_flow_element = 0; + this.o_p_total = this.o_p_water; + this.o_kg = 0; + this.o_kg_h = 0; + this.o_kgo2_h = 0; + this.o_kgo2 = 0; + this.o_combined_eff = 0; + this.o_slope = 0; + this.warning.text = []; + this.warning.state = false; + this.alarm.text = []; + this.alarm.state = false; + return; + } + + this.idle = false; + this.calcOtrPressure(this.i_flow); + } + + getCurveKeys(curve) { + return Object.keys(curve) + .map(Number) + .sort((a, b) => a - b); + } + + interpolateSeries(points, x) { + this.interpolation.load_spline(points.x, points.y, 'linear'); + return this.interpolation.interpolate(x); + } + + interpolateCurveByDensity(curve, density, x) { + const keys = this.getCurveKeys(curve); + if (keys.length === 1) { + const only = curve[keys[0]]; + return { + value: this.interpolateSeries(only, x), + minY: Math.min(...only.y), + maxY: Math.max(...only.y), + minX: Math.min(...only.x), + maxX: Math.max(...only.x), + slope: this.getSegmentSlope(only, x), + }; + } + + const lowerKey = keys.reduce((acc, key) => (key <= density ? key : acc), keys[0]); + const upperKey = keys.find((key) => key >= density) ?? keys[keys.length - 1]; + const lowerCurve = curve[lowerKey]; + const upperCurve = curve[upperKey]; + + if (lowerKey === upperKey) { + return { + value: this.interpolateSeries(lowerCurve, x), + minY: Math.min(...lowerCurve.y), + maxY: Math.max(...lowerCurve.y), + minX: Math.min(...lowerCurve.x), + maxX: Math.max(...lowerCurve.x), + slope: this.getSegmentSlope(lowerCurve, x), + }; + } + + const lowerValue = this.interpolateSeries(lowerCurve, x); + const upperValue = this.interpolateSeries(upperCurve, x); + const ratio = (density - lowerKey) / (upperKey - lowerKey); + + return { + value: lowerValue + (upperValue - lowerValue) * ratio, + minY: Math.min(...lowerCurve.y) + (Math.min(...upperCurve.y) - Math.min(...lowerCurve.y)) * ratio, + maxY: Math.max(...lowerCurve.y) + (Math.max(...upperCurve.y) - Math.max(...lowerCurve.y)) * ratio, + minX: Math.min(...lowerCurve.x), + maxX: Math.max(...lowerCurve.x), + slope: this.getSegmentSlope(lowerCurve, x), + }; + } + + getSegmentSlope(curvePoints, x) { + const xs = curvePoints.x; + const ys = curvePoints.y; + for (let i = 0; i < xs.length - 1; i += 1) { + if (x <= xs[i + 1]) { + return (ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]); + } + } + const last = xs.length - 1; + return (ys[last] - ys[last - 1]) / (xs[last] - xs[last - 1]); + } + + combineEff(oOtr, oOtrMin, oOtrMax, oPFlow, oPMin, oPMax) { + const otrSpan = oOtrMax - oOtrMin; + const pSpan = oPMax - oPMin; + const eff1 = otrSpan > 0 ? (oOtr - oOtrMin) / otrSpan : 0; + const eff2 = pSpan > 0 ? 1 - ((oPFlow - oPMin) / pSpan) : 0; + return Math.max(0, eff1 * eff2 * 100); + } + + calcOtrPressure(flow) { + const totalInputPressureMbar = this.i_local_atm_pressure + this.i_pressure; + this.o_kg = this.fysics.calc_air_dens(totalInputPressureMbar, 0, 20); + this.o_kg_h = this.o_kg * flow; + this.n_flow = (this.o_kg / this.n_kg) * flow; + this.o_flow_element = Math.round((this.n_flow / this.i_n_elements) * 100) / 100; + + const otr = this.interpolateCurveByDensity(this.specs.otr_curve, this.i_diff_density, this.o_flow_element); + const pressure = this.interpolateCurveByDensity(this.specs.p_curve, 0, this.o_flow_element); + + this.o_otr_min = otr.minY; + this.o_otr_max = otr.maxY; + this.o_p_min = pressure.minY; + this.o_p_max = pressure.maxY; + this.o_otr = Math.round(otr.value * 100) / 100; + this.o_p_flow = Math.round(pressure.value * 100) / 100; + this.o_p_total = Math.round((this.o_p_water + this.o_p_flow) * 100) / 100; + this.o_kgo2_h = Math.round(this.convert(this.o_otr * this.n_flow * this.i_m_water * this.i_alfa_factor).from('g').to('kg') * 100) / 100; + this.o_kgo2_h_min = Math.round(this.convert(this.o_otr_min * this.n_flow * this.i_m_water * this.i_alfa_factor).from('g').to('kg') * 100) / 100; + this.o_kgo2_h_max = Math.round(this.convert(this.o_otr_max * this.n_flow * this.i_m_water * this.i_alfa_factor).from('g').to('kg') * 100) / 100; + this.o_kgo2 = this.o_kgo2_h / 3600; + this.o_combined_eff = Math.round(this.combineEff( + this.o_otr, + this.o_otr_min, + this.o_otr_max, + this.o_p_flow, + this.o_p_min, + this.o_p_max, + ) * 100) / 100; + this.o_slope = Math.round(otr.slope * 1000) / 1000; + + this.warningCheck(pressure.minX, pressure.maxX); + this.alarmCheck(pressure.minX, pressure.maxX); + } + + warningCheck(minFlow, maxFlow) { + this.warning.text = []; + this.warning.state = false; + const minHyst = minFlow * (this.warning.flow.min.hyst / 100); + const maxHyst = maxFlow * (this.warning.flow.max.hyst / 100); + + if (this.o_flow_element < minFlow - minHyst) { + this.warning.state = true; + this.warning.text.push(`Warning: flow per element ${this.o_flow_element} is below ${Math.round((minFlow - minHyst) * 100) / 100}`); + } + + if (this.o_flow_element > maxFlow + maxHyst) { + this.warning.state = true; + this.warning.text.push(`Warning: flow per element ${this.o_flow_element} exceeds ${Math.round((maxFlow + maxHyst) * 100) / 100}`); + } + } + + alarmCheck(minFlow, maxFlow) { + this.alarm.text = []; + this.alarm.state = false; + const minHyst = minFlow * (this.alarm.flow.min.hyst / 100); + const maxHyst = maxFlow * (this.alarm.flow.max.hyst / 100); + + if (this.o_flow_element < minFlow - minHyst) { + this.alarm.state = true; + this.alarm.text.push(`Alarm: flow per element ${this.o_flow_element} is below ${Math.round((minFlow - minHyst) * 100) / 100}`); + } + + if (this.o_flow_element > maxFlow + maxHyst) { + this.alarm.state = true; + this.alarm.text.push(`Alarm: flow per element ${this.o_flow_element} exceeds ${Math.round((maxFlow + maxHyst) * 100) / 100}`); + } + } + + getStatus() { + if (this.alarm.state) { + return { fill: 'red', shape: 'dot', text: this.alarm.text[0] }; + } + if (this.warning.state) { + return { fill: 'yellow', shape: 'dot', text: this.warning.text[0] }; + } + if (this.idle) { + return { fill: 'grey', shape: 'dot', text: `${this.o_kgo2_h} kg o2 / h` }; + } + return { fill: 'green', shape: 'dot', text: `${this.o_kgo2_h} kg o2 / h` }; + } + + getOutput() { + return { + iPressure: this.i_pressure, + iMWater: this.i_m_water, + iFlow: this.i_flow, + nFlow: Math.round(this.n_flow * 100) / 100, + oOtr: this.o_otr, + oPLoss: this.o_p_total, + oKgo2H: this.o_kgo2_h, + oFlowElement: this.o_flow_element, + efficiency: this.o_combined_eff, + slope: this.o_slope, + idle: this.idle, + warning: [...this.warning.text], + alarm: [...this.alarm.text], + }; + } + + loadSpecs() { + return { + supplier: 'GVA', + type: 'ELASTOX-R', + units: { + Nm3: { temp: 20, pressure: 1.01325, RH: 0 }, + }, + otr_curve: { + 2.4: { + x: [2, 3, 4, 5, 6, 7, 8, 9, 10], + y: [26, 25, 24, 23.5, 23, 22.75, 22.5, 22.25, 22], + }, + }, + p_curve: { + 0: { + x: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + y: [40, 42.5, 45, 47.5, 50, 51.5, 53, 54.5, 56, 57.5, 59], + }, + }, + }; + } +} + +module.exports = Diffuser; diff --git a/test/specificClass.test.js b/test/specificClass.test.js new file mode 100644 index 0000000..44e96b1 --- /dev/null +++ b/test/specificClass.test.js @@ -0,0 +1,72 @@ +const Diffuser = require('../src/specificClass'); + +function makeConfig(overrides = {}) { + return { + general: { + name: 'Zone_1', + logging: { + enabled: false, + logLevel: 'error', + }, + }, + functionality: { + softwareType: 'diffuser', + role: 'aeration diffuser', + }, + diffuser: { + number: 1, + elements: 4, + density: 2.4, + waterHeight: 4.5, + alfaFactor: 0.7, + headerPressure: 0, + localAtmPressure: 1013.25, + waterDensity: 997, + ...overrides, + }, + }; +} + +describe('diffuser specificClass', () => { + it('starts idle with zero production', () => { + const diffuser = new Diffuser(makeConfig()); + + expect(diffuser.idle).toBe(true); + expect(diffuser.getOutput()).toEqual(expect.objectContaining({ + oKgo2H: 0, + oPLoss: expect.any(Number), + })); + }); + + it('calculates oxygen transfer and pressure once airflow is applied', () => { + const diffuser = new Diffuser(makeConfig()); + diffuser.setFlow(24); + + const output = diffuser.getOutput(); + expect(diffuser.idle).toBe(false); + expect(output.oFlowElement).toBeGreaterThan(0); + expect(output.oOtr).toBeGreaterThan(0); + expect(output.oPLoss).toBeGreaterThan(diffuser.o_p_water); + expect(output.oKgo2H).toBeGreaterThan(0); + }); + + it('increases total pressure when water height rises', () => { + const diffuser = new Diffuser(makeConfig()); + diffuser.setFlow(24); + const lowHeadLoss = diffuser.getOutput().oPLoss; + + diffuser.setWaterHeight(6); + const highHeadLoss = diffuser.getOutput().oPLoss; + + expect(highHeadLoss).toBeGreaterThan(lowHeadLoss); + }); + + it('raises warnings and alarms when flow per element is too low', () => { + const diffuser = new Diffuser(makeConfig({ elements: 1, waterHeight: 3 })); + diffuser.setFlow(0.5); + + expect(diffuser.warning.state).toBe(true); + expect(diffuser.alarm.state).toBe(true); + expect(diffuser.getStatus().fill).toBe('red'); + }); +});