Restore diffuser in three-layer architecture

This commit is contained in:
Rene De Ren
2026-03-12 16:32:20 +01:00
parent 71d600b4dd
commit 0570df208c
6 changed files with 589 additions and 840 deletions

View File

@@ -1,89 +1,68 @@
<!--
brabantse delta kleuren:
#eaf4f1
#86bbdd
#bad33b
#0c99d9
#a9daee
#0f52a5
#50a8d9
#cade63
#4f8582
#c4cce0
-->
<script type="text/javascript">
RED.nodes.registerType('diffuser',{
category: 'digital twin',
color: '#a9daee',
defaults: {
name: {value:""},
number :{value:0, required:true},
i_elements: {value:0, required:true}, // number of diffusers in this particular zone or segment
i_diff_density: {value:0,required:true}, // what is the diffuser density in the zone in %
i_m_water: {value:0,required:true},
alfaf: {value:0.7,required:true}
},
inputs:1,
outputs:4,
inputLabels: "Usage see manual",
outputLabels: ["process","object","Dbase","parent"],
icon: "font-awesome/fa-tint",
//define label function
label: function() {
return this.name + "_" + this.number || "diffuser";
},
oneditprepare: function() {
},
oneditsave: function(){
}
});
</script>
<script type="text/html" data-template-name="diffuser">
<!-------------------------------------------INPUT NAME / TYPE ----------------------------------------------->
<div class="form-row">
<label for="node-input-name"><i class="fa fa-id-card-o"></i>Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-tips"><b>Tip:</b> Give this a name to identify different zones.</div>
<div class="form-row">
<label for="node-input-number"><i class="fa fa-list-ol"></i>Number</label>
<input type="number" id="node-input-number" placeholder="0">
</div>
<div class="form-row">
<label for="node-input-i_elements"><i class="fa fa-list-ol"></i>number of elements</label>
<input type="number" id="node-input-i_elements" placeholder="1">
</div>
<div class="form-tips"><b>Tip:</b> For this zone there are n amount of elements.</div>
<div class="form-row">
<label for="node-input-i_diff_density"><i class="fa fa-eercast"></i>% density of diffusers</label>
<input type="number" id="node-input-i_diff_density" placeholder="0 %">
</div>
<div class="form-tips"><b>Tip:</b> Identify how much surface area is covered with diffusers.</div>
<div class="form-row">
<label for="node-input-i_m_water"><i class="fa fa-eercast"></i>Water height above the diffuser</label>
<input type="number" id="node-input-i_m_water" placeholder="0 meter">
</div>
<div class="form-row">
<label for="node-input-alfaf"><i class="fa fa-eercast"></i>alfa factor to count for o2 transfer losses in dirty water</label>
<input type="number" id="node-input-alfaf" placeholder="0.7">
</div>
<div class="form-tips"><b>Tip:</b> What is the nominal water height in meters above the diffuser elements.</div>
<!-------------------------------------------INPUT TRANSLATION TO OUTPUT ----------------------------------------------->
</script>
<script type="text/html" data-help-name="diffuser">
<p>A diffuser node</p>
</script>
<script type="text/javascript">
RED.nodes.registerType('diffuser', {
category: 'wbd typical',
color: '#86bbdd',
defaults: {
name: { value: '', required: true },
number: { value: 1, required: true },
i_elements: { value: 1, required: true },
i_diff_density: { value: 2.4, required: true },
i_m_water: { value: 0, required: true },
alfaf: { value: 0.7, required: true },
enableLog: { value: false },
logLevel: { value: 'error' },
},
inputs: 1,
outputs: 3,
inputLabels: ['control'],
outputLabels: ['process', 'dbase', 'parent'],
icon: 'font-awesome/fa-tint',
label: function() {
return this.name ? `${this.name}_${this.number}` : 'diffuser';
},
});
</script>
<script type="text/html" data-template-name="diffuser">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Zone name">
</div>
<div class="form-row">
<label for="node-input-number"><i class="fa fa-list-ol"></i> Number</label>
<input type="number" id="node-input-number" min="1">
</div>
<div class="form-row">
<label for="node-input-i_elements"><i class="fa fa-th"></i> Elements</label>
<input type="number" id="node-input-i_elements" min="1">
</div>
<div class="form-row">
<label for="node-input-i_diff_density"><i class="fa fa-braille"></i> Density %</label>
<input type="number" id="node-input-i_diff_density" step="0.1" min="0">
</div>
<div class="form-row">
<label for="node-input-i_m_water"><i class="fa fa-arrows-v"></i> Water Height</label>
<input type="number" id="node-input-i_m_water" step="0.1" min="0">
</div>
<div class="form-row">
<label for="node-input-alfaf"><i class="fa fa-flask"></i> Alfa Factor</label>
<input type="number" id="node-input-alfaf" step="0.01" min="0">
</div>
<div class="form-row">
<label for="node-input-enableLog"><i class="fa fa-book"></i> Enable Log</label>
<input type="checkbox" id="node-input-enableLog" style="width: auto;">
</div>
<div class="form-row">
<label for="node-input-logLevel"><i class="fa fa-signal"></i> Log Level</label>
<select id="node-input-logLevel">
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</div>
</script>
<script type="text/html" data-help-name="diffuser">
<p>Diffused aeration device model.</p>
</script>

View File

@@ -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);
};
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);
});
};

View File

@@ -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');

123
src/nodeClass.js Normal file
View File

@@ -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;

316
src/specificClass.js Normal file
View File

@@ -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;

View File

@@ -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');
});
});