Compare commits
2 Commits
22927d24c4
...
7fbd207985
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbd207985 | ||
|
|
3ccac81acf |
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).
|
||||||
@@ -9,15 +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' },
|
processOutputFormat: { value: 'process' },
|
||||||
dbaseOutputFormat: { value: 'influxdb' },
|
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';
|
||||||
@@ -50,6 +51,10 @@ 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>
|
<h3>Output Formats</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||||
|
|||||||
@@ -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