Compare commits
6 Commits
ce48389791
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbd207985 | ||
|
|
3ccac81acf | ||
|
|
22927d24c4 | ||
|
|
0570df208c | ||
| 71d600b4dd | |||
|
|
c4dda5955f |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# diffuser — Claude Code context
|
||||||
|
|
||||||
|
Aeration system control.
|
||||||
|
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||||
|
|
||||||
|
## S88 classification
|
||||||
|
|
||||||
|
| Level | Colour | Placement lane |
|
||||||
|
|---|---|---|
|
||||||
|
| **Equipment Module** | `#86bbdd` | L3 |
|
||||||
|
|
||||||
|
## Flow layout rules
|
||||||
|
|
||||||
|
When wiring this node into a multi-node demo or production flow, follow the
|
||||||
|
placement rule set in the **EVOLV superproject**:
|
||||||
|
|
||||||
|
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||||
|
|
||||||
|
Key points for this node:
|
||||||
|
- Place on lane **L3** (x-position per the lane table in the rule).
|
||||||
|
- Stack same-level siblings vertically.
|
||||||
|
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||||
|
- Wrap in a Node-RED group box coloured `#86bbdd` (Equipment Module).
|
||||||
92
diffuser.html
Normal file
92
diffuser.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<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 },
|
||||||
|
i_zone_volume: { value: 0, required: false },
|
||||||
|
processOutputFormat: { value: 'process' },
|
||||||
|
dbaseOutputFormat: { value: 'influxdb' },
|
||||||
|
enableLog: { value: false },
|
||||||
|
logLevel: { value: 'error' },
|
||||||
|
},
|
||||||
|
inputs: 1,
|
||||||
|
outputs: 4,
|
||||||
|
inputLabels: ['control'],
|
||||||
|
outputLabels: ['process', 'dbase', 'reactor control', '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-i_zone_volume"><i class="fa fa-cube"></i> Zone Volume</label>
|
||||||
|
<input type="number" id="node-input-i_zone_volume" step="0.1" min="0" placeholder="m3">
|
||||||
|
</div>
|
||||||
|
<h3>Output Formats</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||||
|
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||||
|
<option value="process">process</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
<option value="csv">csv</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||||
|
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||||
|
<option value="influxdb">influxdb</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
<option value="csv">csv</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
9
diffuser.js
Normal file
9
diffuser.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
1
diffuser_class.js
Normal file
1
diffuser_class.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require('./src/specificClass');
|
||||||
3
examples/README.md
Normal file
3
examples/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# diffuser Example Flows
|
||||||
|
|
||||||
|
Placeholder structure.
|
||||||
3
examples/basic.flow.json
Normal file
3
examples/basic.flow.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
{"id":"diffuser_basic_tab","type":"tab","label":"diffuser basic","disabled":false,"info":"placeholder"}
|
||||||
|
]
|
||||||
3
examples/edge.flow.json
Normal file
3
examples/edge.flow.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
{"id":"diffuser_edge_tab","type":"tab","label":"diffuser edge","disabled":false,"info":"placeholder"}
|
||||||
|
]
|
||||||
3
examples/integration.flow.json
Normal file
3
examples/integration.flow.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
{"id":"diffuser_integration_tab","type":"tab","label":"diffuser integration","disabled":false,"info":"placeholder"}
|
||||||
|
]
|
||||||
152
graph.js
Normal file
152
graph.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
Copyright:
|
||||||
|
Year : (c) 2023
|
||||||
|
Author/licensor : Rene De Ren
|
||||||
|
Contact details : zn375ix3@gmail.com
|
||||||
|
Location : The Netherlands
|
||||||
|
Licensee : Waterschap brabantse delta
|
||||||
|
Address: Bouvignelaan 5, 4836 AA Breda
|
||||||
|
|
||||||
|
Permission is hereby granted, to the licensee , to use this software only for the purposes of testing it in its R&D lab.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
//A class to interact and manipulate machines with a parabolic curve
|
||||||
|
class Graph {
|
||||||
|
/*------------------- Construct and set vars -------------------*/
|
||||||
|
constructor() {
|
||||||
|
|
||||||
|
// current x and y
|
||||||
|
this.x = 0;
|
||||||
|
this.y = 0;
|
||||||
|
|
||||||
|
//previous y and x
|
||||||
|
this.y_prev = 0;
|
||||||
|
this.x_prev = 0;
|
||||||
|
|
||||||
|
//slope of current value
|
||||||
|
this.slope = 0;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
set slope(value){
|
||||||
|
this._slope = Math.round( value * 100 ) / 100;
|
||||||
|
}
|
||||||
|
get slope(){
|
||||||
|
return this._slope;
|
||||||
|
}
|
||||||
|
|
||||||
|
calc(x,y){
|
||||||
|
|
||||||
|
//store old values
|
||||||
|
this.x_prev = this.x;
|
||||||
|
this.y_prev = this.y;
|
||||||
|
|
||||||
|
//store new values
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
|
||||||
|
//calc slope
|
||||||
|
this.slope = this.calc_slope(this.x,this.y,this.x_prev,this.y_prev);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
calc_slope(x,y,x_prev,y_prev){
|
||||||
|
|
||||||
|
let slope = 0;
|
||||||
|
let incline = false;
|
||||||
|
let decline = false;
|
||||||
|
|
||||||
|
if( x_prev > x){
|
||||||
|
decline = true;
|
||||||
|
incline = false;
|
||||||
|
}
|
||||||
|
else if( x_prev < x ){
|
||||||
|
decline = false;
|
||||||
|
incline = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//determine decline / incl
|
||||||
|
if( (!decline && !incline) ){
|
||||||
|
slope = 0;
|
||||||
|
}
|
||||||
|
else if(decline){
|
||||||
|
slope = -( (y - y_prev) / ( x - x_prev) ) ;
|
||||||
|
}
|
||||||
|
else if(incline){
|
||||||
|
slope = ( (y - y_prev) / ( x - x_prev) ) ;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slope;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 a dimensional peak ( THIS WILL FIND ONLY 1 PEAK!! so assuming the array only has one !)
|
||||||
|
one_dim_peak_finder(array){
|
||||||
|
|
||||||
|
//define array length
|
||||||
|
let array_length = array.length;
|
||||||
|
//start position in array is middle
|
||||||
|
let recursive_pos = array_length/2;
|
||||||
|
//define end conditions
|
||||||
|
let max_iterations = 100;
|
||||||
|
let done = false;
|
||||||
|
let iteration = 0;
|
||||||
|
let peak_found = false;
|
||||||
|
|
||||||
|
while(!done){
|
||||||
|
|
||||||
|
//find peak going left
|
||||||
|
if( array[recursive_pos-1] > array[recursive_pos] ){
|
||||||
|
//calc new pos
|
||||||
|
recursive_pos = (recursive_pos-1) / 2;
|
||||||
|
}
|
||||||
|
//find peak going right
|
||||||
|
else if( array[recursive_pos+1] > array[recursive_pos] ){
|
||||||
|
//calc new pos
|
||||||
|
recursive_pos = (recursive_pos+1) / 2;
|
||||||
|
}
|
||||||
|
//found peak!
|
||||||
|
else{
|
||||||
|
done = true;
|
||||||
|
peak_found = true;
|
||||||
|
position_peak = recursive_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
//end prematurely
|
||||||
|
if(max_iterations >= iteration){
|
||||||
|
done = true;
|
||||||
|
peak_found = false;
|
||||||
|
position_peak = null;
|
||||||
|
}
|
||||||
|
iteration++;
|
||||||
|
}
|
||||||
|
//build response
|
||||||
|
let obj_peak = { found:peak_found , value_of_peak: array[position_peak] , position_peak:position_peak };
|
||||||
|
|
||||||
|
return obj_peak;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} // end of class
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
var graph = new Graph();
|
||||||
|
|
||||||
|
|
||||||
|
//*/
|
||||||
|
|
||||||
|
module.exports = Graph;
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "diffuser",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Control module diffuser",
|
||||||
|
"main": "diffuser",
|
||||||
|
"scripts": {
|
||||||
|
"test": "diffuser"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "diffuser"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"diffuser"
|
||||||
|
],
|
||||||
|
"author": "Rene De Ren",
|
||||||
|
"license": "MIT",
|
||||||
|
"node-red" : {
|
||||||
|
"nodes": {
|
||||||
|
"diffuser": "diffuser.js"
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/nodeClass.js
Normal file
141
src/nodeClass.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
const { outputUtils } = 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 suffix = uiConfig.number !== undefined && uiConfig.number !== '' ? `_${uiConfig.number}` : '';
|
||||||
|
const resolvedName = uiConfig.name ? `${uiConfig.name}${suffix}` : this.name;
|
||||||
|
|
||||||
|
this.config = {
|
||||||
|
general: {
|
||||||
|
name: resolvedName,
|
||||||
|
id: this.node.id,
|
||||||
|
unit: uiConfig.unit || 'kg o2/h',
|
||||||
|
logging: {
|
||||||
|
enabled: uiConfig.enableLog,
|
||||||
|
logLevel: uiConfig.logLevel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
functionality: {
|
||||||
|
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
||||||
|
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,
|
||||||
|
zoneVolume: Number(uiConfig.i_zone_volume) || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this._output = new outputUtils();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupSpecificClass() {
|
||||||
|
this.source = new Specific(this.config);
|
||||||
|
this.node.source = this.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerChild() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.node.send([
|
||||||
|
null,
|
||||||
|
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');
|
||||||
|
const reactorOtr = this.source.getReactorOtr(this.config.diffuser?.zoneVolume);
|
||||||
|
const controlMsg = {
|
||||||
|
topic: 'OTR',
|
||||||
|
payload: reactorOtr,
|
||||||
|
meta: {
|
||||||
|
source: 'diffuser',
|
||||||
|
diffuser: this.config.general?.name,
|
||||||
|
zoneVolume: this.config.diffuser?.zoneVolume,
|
||||||
|
oKgo2H: raw.oKgo2H,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.node.status(this.source.getStatus());
|
||||||
|
this.node.send([processMsg, influxMsg, controlMsg, 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;
|
||||||
342
src/specificClass.js
Normal file
342
src/specificClass.js
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
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.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.calcAirDensityMbar(1013.25, 0, 20);
|
||||||
|
|
||||||
|
this.n_flow = 0;
|
||||||
|
this.o_otr = 0;
|
||||||
|
this.o_p_flow = 0;
|
||||||
|
this.o_p_water = this.heightToPressureMbar(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.heightToPressureMbar(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
calcAirDensityMbar(pressureMbar, RH, tempC) {
|
||||||
|
const Rd = 287.05;
|
||||||
|
const Rv = 461.495;
|
||||||
|
const T = tempC + 273.15;
|
||||||
|
const A = 8.07131;
|
||||||
|
const B = 1730.63;
|
||||||
|
const C = 233.426;
|
||||||
|
const e_s = Math.pow(10, (A - (B / (C + tempC))));
|
||||||
|
const e = RH * e_s / 100;
|
||||||
|
const pressurePa = this.convert(pressureMbar).from('mbar').to('Pa');
|
||||||
|
const p_d = pressurePa - (e * 100);
|
||||||
|
return (p_d / (Rd * T)) + ((e * 100) / (Rv * T));
|
||||||
|
}
|
||||||
|
|
||||||
|
heightToPressureMbar(density, height) {
|
||||||
|
const pressurePa = gravity.getStandardGravity() * density * height;
|
||||||
|
return this.convert(pressurePa).from('Pa').to('mbar');
|
||||||
|
}
|
||||||
|
|
||||||
|
calcOtrPressure(flow) {
|
||||||
|
const totalInputPressureMbar = this.i_local_atm_pressure + this.i_pressure;
|
||||||
|
this.o_kg = this.calcAirDensityMbar(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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getReactorOtr(zoneVolumeM3) {
|
||||||
|
const volume = Number(zoneVolumeM3);
|
||||||
|
if (!Number.isFinite(volume) || volume <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return this.o_kgo2_h * 1000 * 24 / volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
3
test/README.md
Normal file
3
test/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# diffuser Test Suite Layout
|
||||||
|
|
||||||
|
Placeholder structure (diffuser currently has no runtime module files).
|
||||||
6
test/basic/structure-placeholder.basic.test.js
Normal file
6
test/basic/structure-placeholder.basic.test.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
test('diffuser placeholder structure exists', () => {
|
||||||
|
assert.equal(true, true);
|
||||||
|
});
|
||||||
6
test/edge/structure-node.edge.test.js
Normal file
6
test/edge/structure-node.edge.test.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
test('diffuser edge placeholder', () => {
|
||||||
|
assert.equal(true, true);
|
||||||
|
});
|
||||||
12
test/integration/structure-examples.integration.test.js
Normal file
12
test/integration/structure-examples.integration.test.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const dir = path.resolve(__dirname, '../../examples');
|
||||||
|
|
||||||
|
test('diffuser placeholder example files exist', () => {
|
||||||
|
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||||
|
assert.equal(fs.existsSync(path.join(dir, file)), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
80
test/specificClass.test.js
Normal file
80
test/specificClass.test.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('diffuser starts idle with zero production', () => {
|
||||||
|
const diffuser = new Diffuser(makeConfig());
|
||||||
|
const output = diffuser.getOutput();
|
||||||
|
|
||||||
|
assert.equal(diffuser.idle, true);
|
||||||
|
assert.equal(output.oKgo2H, 0);
|
||||||
|
assert.equal(typeof output.oPLoss, 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diffuser calculates oxygen transfer and pressure once airflow is applied', () => {
|
||||||
|
const diffuser = new Diffuser(makeConfig());
|
||||||
|
diffuser.setFlow(24);
|
||||||
|
|
||||||
|
const output = diffuser.getOutput();
|
||||||
|
assert.equal(diffuser.idle, false);
|
||||||
|
assert.ok(output.oFlowElement > 0);
|
||||||
|
assert.ok(output.oOtr > 0);
|
||||||
|
assert.ok(output.oPLoss > diffuser.o_p_water);
|
||||||
|
assert.ok(output.oKgo2H > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diffuser 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;
|
||||||
|
|
||||||
|
assert.ok(highHeadLoss > lowHeadLoss);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diffuser raises warnings and alarms when flow per element is too low', () => {
|
||||||
|
const diffuser = new Diffuser(makeConfig({ elements: 1, waterHeight: 3 }));
|
||||||
|
diffuser.setFlow(0.5);
|
||||||
|
|
||||||
|
assert.equal(diffuser.warning.state, true);
|
||||||
|
assert.equal(diffuser.alarm.state, true);
|
||||||
|
assert.equal(diffuser.getStatus().fill, 'red');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diffuser converts oxygen output to reactor OTR per zone volume', () => {
|
||||||
|
const diffuser = new Diffuser(makeConfig({ waterHeight: 4.5 }));
|
||||||
|
diffuser.setFlow(24);
|
||||||
|
|
||||||
|
const expected = diffuser.getOutput().oKgo2H * 1000 * 24 / 500;
|
||||||
|
assert.ok(Math.abs(diffuser.getReactorOtr(500) - expected) < 1e-8);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user