Compare commits
3 Commits
0570df208c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbd207985 | ||
|
|
3ccac81acf | ||
|
|
22927d24c4 |
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).
|
||||||
18
LICENSE
18
LICENSE
@@ -1,9 +1,9 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 RnD
|
Copyright (c) 2025 RnD
|
||||||
|
|
||||||
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:
|
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 above copyright notice and this permission notice shall be included in all copies or substantial portions of the 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, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 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, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# convert
|
# convert
|
||||||
|
|
||||||
Makes unit conversions
|
Makes unit conversions
|
||||||
@@ -9,13 +9,16 @@ RED.nodes.registerType('diffuser', {
|
|||||||
i_diff_density: { value: 2.4, required: true },
|
i_diff_density: { value: 2.4, required: true },
|
||||||
i_m_water: { value: 0, required: true },
|
i_m_water: { value: 0, required: true },
|
||||||
alfaf: { value: 0.7, required: true },
|
alfaf: { value: 0.7, required: true },
|
||||||
|
i_zone_volume: { value: 0, required: false },
|
||||||
|
processOutputFormat: { value: 'process' },
|
||||||
|
dbaseOutputFormat: { value: 'influxdb' },
|
||||||
enableLog: { value: false },
|
enableLog: { value: false },
|
||||||
logLevel: { value: 'error' },
|
logLevel: { value: 'error' },
|
||||||
},
|
},
|
||||||
inputs: 1,
|
inputs: 1,
|
||||||
outputs: 3,
|
outputs: 4,
|
||||||
inputLabels: ['control'],
|
inputLabels: ['control'],
|
||||||
outputLabels: ['process', 'dbase', 'parent'],
|
outputLabels: ['process', 'dbase', 'reactor control', 'parent'],
|
||||||
icon: 'font-awesome/fa-tint',
|
icon: 'font-awesome/fa-tint',
|
||||||
label: function() {
|
label: function() {
|
||||||
return this.name ? `${this.name}_${this.number}` : 'diffuser';
|
return this.name ? `${this.name}_${this.number}` : 'diffuser';
|
||||||
@@ -48,6 +51,27 @@ RED.nodes.registerType('diffuser', {
|
|||||||
<label for="node-input-alfaf"><i class="fa fa-flask"></i> Alfa Factor</label>
|
<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">
|
<input type="number" id="node-input-alfaf" step="0.01" min="0">
|
||||||
</div>
|
</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">
|
<div class="form-row">
|
||||||
<label for="node-input-enableLog"><i class="fa fa-book"></i> Enable Log</label>
|
<label for="node-input-enableLog"><i class="fa fa-book"></i> Enable Log</label>
|
||||||
<input type="checkbox" id="node-input-enableLog" style="width: auto;">
|
<input type="checkbox" id="node-input-enableLog" style="width: auto;">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { outputUtils, configManager } = require('generalFunctions');
|
const { outputUtils } = require('generalFunctions');
|
||||||
const Specific = require('./specificClass');
|
const Specific = require('./specificClass');
|
||||||
|
|
||||||
class nodeClass {
|
class nodeClass {
|
||||||
@@ -16,16 +16,21 @@ class nodeClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_loadConfig(uiConfig) {
|
_loadConfig(uiConfig) {
|
||||||
const cfgMgr = new configManager();
|
|
||||||
const suffix = uiConfig.number !== undefined && uiConfig.number !== '' ? `_${uiConfig.number}` : '';
|
const suffix = uiConfig.number !== undefined && uiConfig.number !== '' ? `_${uiConfig.number}` : '';
|
||||||
const resolvedUiConfig = {
|
const resolvedName = uiConfig.name ? `${uiConfig.name}${suffix}` : this.name;
|
||||||
...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, {
|
this.config = {
|
||||||
|
general: {
|
||||||
|
name: resolvedName,
|
||||||
|
id: this.node.id,
|
||||||
|
unit: uiConfig.unit || 'kg o2/h',
|
||||||
|
logging: {
|
||||||
|
enabled: uiConfig.enableLog,
|
||||||
|
logLevel: uiConfig.logLevel,
|
||||||
|
},
|
||||||
|
},
|
||||||
functionality: {
|
functionality: {
|
||||||
|
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
||||||
softwareType: this.name,
|
softwareType: this.name,
|
||||||
role: 'aeration diffuser',
|
role: 'aeration diffuser',
|
||||||
},
|
},
|
||||||
@@ -38,8 +43,9 @@ class nodeClass {
|
|||||||
headerPressure: Number(uiConfig.i_pressure) || 0,
|
headerPressure: Number(uiConfig.i_pressure) || 0,
|
||||||
localAtmPressure: Number(uiConfig.i_local_atm_pressure) || 1013.25,
|
localAtmPressure: Number(uiConfig.i_local_atm_pressure) || 1013.25,
|
||||||
waterDensity: Number(uiConfig.i_water_density) || 997,
|
waterDensity: Number(uiConfig.i_water_density) || 997,
|
||||||
|
zoneVolume: Number(uiConfig.i_zone_volume) || 0,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
this._output = new outputUtils();
|
this._output = new outputUtils();
|
||||||
}
|
}
|
||||||
@@ -52,6 +58,7 @@ class nodeClass {
|
|||||||
_registerChild() {
|
_registerChild() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.node.send([
|
this.node.send([
|
||||||
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
@@ -73,8 +80,19 @@ class nodeClass {
|
|||||||
const raw = this.source.getOutput();
|
const raw = this.source.getOutput();
|
||||||
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
const processMsg = this._output.formatMsg(raw, this.config, 'process');
|
||||||
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
|
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.status(this.source.getStatus());
|
||||||
this.node.send([processMsg, influxMsg, null]);
|
this.node.send([processMsg, influxMsg, controlMsg, null]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_attachInputHandler() {
|
_attachInputHandler() {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ class Diffuser {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.interpolation = new interpolation({ type: 'linear' });
|
this.interpolation = new interpolation({ type: 'linear' });
|
||||||
this.fysics = gravity.fysics;
|
|
||||||
this.convert = convert;
|
this.convert = convert;
|
||||||
this.specs = this.loadSpecs();
|
this.specs = this.loadSpecs();
|
||||||
|
|
||||||
@@ -27,12 +26,12 @@ class Diffuser {
|
|||||||
this.i_m_water = this.normalizeNumber(this.config.diffuser?.waterHeight, 0);
|
this.i_m_water = this.normalizeNumber(this.config.diffuser?.waterHeight, 0);
|
||||||
this.i_flow = 0;
|
this.i_flow = 0;
|
||||||
|
|
||||||
this.n_kg = this.fysics.calc_air_dens(1013.25, 0, 20);
|
this.n_kg = this.calcAirDensityMbar(1013.25, 0, 20);
|
||||||
|
|
||||||
this.n_flow = 0;
|
this.n_flow = 0;
|
||||||
this.o_otr = 0;
|
this.o_otr = 0;
|
||||||
this.o_p_flow = 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_water = this.heightToPressureMbar(this.i_water_density, this.i_m_water);
|
||||||
this.o_p_total = this.o_p_water;
|
this.o_p_total = this.o_p_water;
|
||||||
this.o_kg = 0;
|
this.o_kg = 0;
|
||||||
this.o_kg_h = 0;
|
this.o_kg_h = 0;
|
||||||
@@ -71,7 +70,7 @@ class Diffuser {
|
|||||||
|
|
||||||
setWaterHeight(value) {
|
setWaterHeight(value) {
|
||||||
this.i_m_water = Math.max(0, this.normalizeNumber(value, this.i_m_water));
|
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.o_p_water = this.heightToPressureMbar(this.i_water_density, this.i_m_water);
|
||||||
this.recalculate();
|
this.recalculate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,9 +189,28 @@ class Diffuser {
|
|||||||
return Math.max(0, eff1 * eff2 * 100);
|
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) {
|
calcOtrPressure(flow) {
|
||||||
const totalInputPressureMbar = this.i_local_atm_pressure + this.i_pressure;
|
const totalInputPressureMbar = this.i_local_atm_pressure + this.i_pressure;
|
||||||
this.o_kg = this.fysics.calc_air_dens(totalInputPressureMbar, 0, 20);
|
this.o_kg = this.calcAirDensityMbar(totalInputPressureMbar, 0, 20);
|
||||||
this.o_kg_h = this.o_kg * flow;
|
this.o_kg_h = this.o_kg * flow;
|
||||||
this.n_flow = (this.o_kg / this.n_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;
|
this.o_flow_element = Math.round((this.n_flow / this.i_n_elements) * 100) / 100;
|
||||||
@@ -290,6 +308,14 @@ class Diffuser {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getReactorOtr(zoneVolumeM3) {
|
||||||
|
const volume = Number(zoneVolumeM3);
|
||||||
|
if (!Number.isFinite(volume) || volume <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return this.o_kgo2_h * 1000 * 24 / volume;
|
||||||
|
}
|
||||||
|
|
||||||
loadSpecs() {
|
loadSpecs() {
|
||||||
return {
|
return {
|
||||||
supplier: 'GVA',
|
supplier: 'GVA',
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const Diffuser = require('../src/specificClass');
|
const Diffuser = require('../src/specificClass');
|
||||||
|
|
||||||
function makeConfig(overrides = {}) {
|
function makeConfig(overrides = {}) {
|
||||||
@@ -27,46 +30,51 @@ function makeConfig(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('diffuser specificClass', () => {
|
test('diffuser starts idle with zero production', () => {
|
||||||
it('starts idle with zero production', () => {
|
const diffuser = new Diffuser(makeConfig());
|
||||||
const diffuser = new Diffuser(makeConfig());
|
const output = diffuser.getOutput();
|
||||||
|
|
||||||
expect(diffuser.idle).toBe(true);
|
assert.equal(diffuser.idle, true);
|
||||||
expect(diffuser.getOutput()).toEqual(expect.objectContaining({
|
assert.equal(output.oKgo2H, 0);
|
||||||
oKgo2H: 0,
|
assert.equal(typeof output.oPLoss, 'number');
|
||||||
oPLoss: expect.any(Number),
|
});
|
||||||
}));
|
|
||||||
});
|
test('diffuser calculates oxygen transfer and pressure once airflow is applied', () => {
|
||||||
|
const diffuser = new Diffuser(makeConfig());
|
||||||
it('calculates oxygen transfer and pressure once airflow is applied', () => {
|
diffuser.setFlow(24);
|
||||||
const diffuser = new Diffuser(makeConfig());
|
|
||||||
diffuser.setFlow(24);
|
const output = diffuser.getOutput();
|
||||||
|
assert.equal(diffuser.idle, false);
|
||||||
const output = diffuser.getOutput();
|
assert.ok(output.oFlowElement > 0);
|
||||||
expect(diffuser.idle).toBe(false);
|
assert.ok(output.oOtr > 0);
|
||||||
expect(output.oFlowElement).toBeGreaterThan(0);
|
assert.ok(output.oPLoss > diffuser.o_p_water);
|
||||||
expect(output.oOtr).toBeGreaterThan(0);
|
assert.ok(output.oKgo2H > 0);
|
||||||
expect(output.oPLoss).toBeGreaterThan(diffuser.o_p_water);
|
});
|
||||||
expect(output.oKgo2H).toBeGreaterThan(0);
|
|
||||||
});
|
test('diffuser increases total pressure when water height rises', () => {
|
||||||
|
const diffuser = new Diffuser(makeConfig());
|
||||||
it('increases total pressure when water height rises', () => {
|
diffuser.setFlow(24);
|
||||||
const diffuser = new Diffuser(makeConfig());
|
const lowHeadLoss = diffuser.getOutput().oPLoss;
|
||||||
diffuser.setFlow(24);
|
|
||||||
const lowHeadLoss = diffuser.getOutput().oPLoss;
|
diffuser.setWaterHeight(6);
|
||||||
|
const highHeadLoss = diffuser.getOutput().oPLoss;
|
||||||
diffuser.setWaterHeight(6);
|
|
||||||
const highHeadLoss = diffuser.getOutput().oPLoss;
|
assert.ok(highHeadLoss > lowHeadLoss);
|
||||||
|
});
|
||||||
expect(highHeadLoss).toBeGreaterThan(lowHeadLoss);
|
|
||||||
});
|
test('diffuser raises warnings and alarms when flow per element is too low', () => {
|
||||||
|
const diffuser = new Diffuser(makeConfig({ elements: 1, waterHeight: 3 }));
|
||||||
it('raises warnings and alarms when flow per element is too low', () => {
|
diffuser.setFlow(0.5);
|
||||||
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);
|
||||||
expect(diffuser.warning.state).toBe(true);
|
assert.equal(diffuser.getStatus().fill, 'red');
|
||||||
expect(diffuser.alarm.state).toBe(true);
|
});
|
||||||
expect(diffuser.getStatus().fill).toBe('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