Compare commits
8 Commits
main
...
37a85690d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37a85690d1 | ||
|
|
2c5704b5c0 | ||
|
|
6372bdc926 | ||
|
|
8cc02eebc8 | ||
|
|
e18b6a051d | ||
|
|
9122b14368 | ||
|
|
15cfb222b2 | ||
|
|
0ec9dd15a7 |
71
CONTRACT.md
Normal file
71
CONTRACT.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# diffuser — Contract
|
||||||
|
|
||||||
|
Hand-maintained for Phase 6; the `## Inputs` table is generated from
|
||||||
|
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 100 lines.
|
||||||
|
|
||||||
|
## Inputs (msg.topic on Port 0)
|
||||||
|
|
||||||
|
| Canonical | Aliases (deprecated) | Payload | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `data.flow` | `air_flow` | `number` — airflow in Nm³/h | Calls `source.setFlow(payload)`; clamps to ≥ 0 and recomputes OTR. |
|
||||||
|
| `set.density` | `density` | `number` — diffuser density (per m²) | Calls `source.setDensity(payload)` and recomputes. |
|
||||||
|
| `set.water-height` | `height_water` | `number` — water column height in m | Calls `source.setWaterHeight(payload)`; clamps to ≥ 0 and recomputes head + total pressure. |
|
||||||
|
| `set.header-pressure` | `header_pressure` | `number` — header gauge pressure in mbar | Calls `source.setHeaderPressure(payload)` and recomputes. |
|
||||||
|
| `set.elements` | `elements` | `number` — element count (rounded; must be > 0) | Calls `source.setElementCount(payload)` and recomputes per-element flow. |
|
||||||
|
| `set.alfa-factor` | `alfaFactor` | `number` — alpha correction (≥ 0) | Calls `source.setAlfaFactor(payload)` and recomputes oxygen output. |
|
||||||
|
|
||||||
|
Aliases log a one-time deprecation warning the first time they fire.
|
||||||
|
|
||||||
|
## Outputs (msg.topic on Port 0/1/2)
|
||||||
|
|
||||||
|
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built
|
||||||
|
by `outputUtils.formatMsg(..., 'process')` from `getOutput()` —
|
||||||
|
delta-compressed (only changed fields are emitted). Fields:
|
||||||
|
- `iPressure`, `iMWater`, `iFlow` — echoed inputs.
|
||||||
|
- `nFlow` — normalised airflow (Nm³/h).
|
||||||
|
- `oOtr` — interpolated oxygen transfer rate (g O₂ / Nm³).
|
||||||
|
- `oPLoss` — total head loss (mbar) = static head + diffuser ΔP.
|
||||||
|
- `oKgo2H` — kg O₂ per hour at current operating point.
|
||||||
|
- `oFlowElement` — flow per element (Nm³/h/element).
|
||||||
|
- `efficiency` — combined OTR/ΔP efficiency (0–100).
|
||||||
|
- `slope` — local OTR-vs-flow slope.
|
||||||
|
- `oZoneOtr` — reactor zone OTR (kg O₂ / m³ / day) computed against
|
||||||
|
`diffuser.zoneVolume`; `0` when zone volume is unset.
|
||||||
|
- `idle` — true when `data.flow ≤ 0`.
|
||||||
|
- `warning`, `alarm` — string arrays describing flow-per-element band
|
||||||
|
excursions.
|
||||||
|
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with
|
||||||
|
the `'influxdb'` formatter.
|
||||||
|
- **Port 2 (registration):** at startup the node sends one
|
||||||
|
`{ topic: 'child.register', payload: <node.id>, positionVsParent,
|
||||||
|
distance }` to the upstream parent (typically a reactor).
|
||||||
|
`positionVsParent` defaults to `'atEquipment'`.
|
||||||
|
|
||||||
|
## Port-count change (Phase 6)
|
||||||
|
|
||||||
|
Pre-refactor the diffuser exposed 4 outputs (process, dbase, reactor
|
||||||
|
control with `topic: 'OTR'`, parent registration). The reactor control
|
||||||
|
message merged into Port 0 as `oZoneOtr`; consumers that previously
|
||||||
|
listened to the dedicated control port should switch to reading
|
||||||
|
`payload.oZoneOtr` from the process output. The legacy `OTR` topic is
|
||||||
|
removed in this refactor — there is no alias, since the data shape
|
||||||
|
differs (single value vs full process payload).
|
||||||
|
|
||||||
|
## Events emitted by `source.measurements.emitter`
|
||||||
|
|
||||||
|
None today. The diffuser does not currently publish typed measurements
|
||||||
|
through `MeasurementContainer`; all output flows via `getOutput()`.
|
||||||
|
A future phase may promote `oOtr` and `oZoneOtr` to typed series so
|
||||||
|
parent reactors can subscribe through the standard `ChildRouter`
|
||||||
|
handshake.
|
||||||
|
|
||||||
|
## Events emitted by `source.emitter`
|
||||||
|
|
||||||
|
- `output-changed` — fires whenever an input setter recomputes the
|
||||||
|
oxygen-transfer state. `BaseNodeAdapter` listens and pushes the
|
||||||
|
delta-compressed Port 0 / Port 1 messages.
|
||||||
|
|
||||||
|
## Children registered by this node
|
||||||
|
|
||||||
|
None. The diffuser is a leaf Equipment Module; it registers itself with
|
||||||
|
its parent (reactor / process cell) via the Port 2 handshake.
|
||||||
@@ -1,27 +1,68 @@
|
|||||||
|
<!-- Load the dynamic menu & config endpoints (asset cascade + logger fields) -->
|
||||||
|
<script src="/diffuser/menu.js"></script>
|
||||||
|
<script src="/diffuser/configData.js"></script>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
RED.nodes.registerType('diffuser', {
|
RED.nodes.registerType('diffuser', {
|
||||||
category: 'wbd typical',
|
category: 'EVOLV',
|
||||||
color: '#86bbdd',
|
color: '#86bbdd',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: '', required: true },
|
name: { value: '', required: true },
|
||||||
number: { value: 1, required: true },
|
number: { value: 1, required: true },
|
||||||
i_elements: { value: 1, required: true },
|
i_elements: { value: 1, required: true },
|
||||||
i_diff_density: { value: 2.4, required: true },
|
i_diff_density: { value: 15, 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 },
|
i_zone_volume: { value: 0, required: false },
|
||||||
processOutputFormat: { value: 'process' },
|
processOutputFormat: { value: 'process' },
|
||||||
dbaseOutputFormat: { value: 'influxdb' },
|
dbaseOutputFormat: { value: 'influxdb' },
|
||||||
|
|
||||||
|
// Asset identifier surface. supplier / assetType / category are derived
|
||||||
|
// at runtime via assetResolver.resolveAssetMetadata(model); do NOT add
|
||||||
|
// them back here. See generalFunctions/src/registry/README.md.
|
||||||
|
assetTagNumber: { value: '' },
|
||||||
|
model: { value: '' },
|
||||||
|
unit: { value: '' },
|
||||||
|
|
||||||
enableLog: { value: false },
|
enableLog: { value: false },
|
||||||
logLevel: { value: 'error' },
|
logLevel: { value: 'error' },
|
||||||
},
|
},
|
||||||
inputs: 1,
|
inputs: 1,
|
||||||
outputs: 4,
|
outputs: 3,
|
||||||
inputLabels: ['control'],
|
inputLabels: ['control'],
|
||||||
outputLabels: ['process', 'dbase', 'reactor control', 'parent'],
|
outputLabels: ['process', 'dbase', 'parent'],
|
||||||
icon: 'font-awesome/fa-tint',
|
icon: 'font-awesome/fa-tint',
|
||||||
label: function() {
|
label: function() {
|
||||||
return this.name ? `${this.name}_${this.number}` : 'diffuser';
|
const stem = this.model ? this.model : (this.name || 'diffuser');
|
||||||
|
return this.name ? `${this.name}_${this.number}` : stem;
|
||||||
|
},
|
||||||
|
|
||||||
|
oneditprepare: function() {
|
||||||
|
// wait for the menu scripts to load, then hand the node off to the
|
||||||
|
// shared asset / logger init code from generalFunctions
|
||||||
|
let menuRetries = 0;
|
||||||
|
const maxMenuRetries = 100; // 5 s at 50 ms intervals
|
||||||
|
const waitForMenuData = () => {
|
||||||
|
if (window.EVOLV?.nodes?.diffuser?.initEditor) {
|
||||||
|
window.EVOLV.nodes.diffuser.initEditor(this);
|
||||||
|
} else if (++menuRetries < maxMenuRetries) {
|
||||||
|
setTimeout(waitForMenuData, 50);
|
||||||
|
} else {
|
||||||
|
console.warn('diffuser: menu scripts failed to load within 5 seconds');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
waitForMenuData();
|
||||||
|
},
|
||||||
|
|
||||||
|
oneditsave: function() {
|
||||||
|
// Asset cascade saves model + unit + assetTagNumber
|
||||||
|
if (window.EVOLV?.nodes?.diffuser?.assetMenu?.saveEditor) {
|
||||||
|
window.EVOLV.nodes.diffuser.assetMenu.saveEditor(this);
|
||||||
|
}
|
||||||
|
// Logger fields
|
||||||
|
if (window.EVOLV?.nodes?.diffuser?.loggerMenu?.saveEditor) {
|
||||||
|
window.EVOLV.nodes.diffuser.loggerMenu.saveEditor(this);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -40,8 +81,11 @@ RED.nodes.registerType('diffuser', {
|
|||||||
<input type="number" id="node-input-i_elements" min="1">
|
<input type="number" id="node-input-i_elements" min="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-i_diff_density"><i class="fa fa-braille"></i> Density %</label>
|
<label for="node-input-i_diff_density"><i class="fa fa-braille"></i> Bottom coverage [%]</label>
|
||||||
<input type="number" id="node-input-i_diff_density" step="0.1" min="0">
|
<input type="number" id="node-input-i_diff_density" step="0.1" min="0" max="100" placeholder="typical 10-25">
|
||||||
|
<div style="font-size:11px;color:#666;margin-left:160px;">
|
||||||
|
Fraction of tank floor occupied by diffuser membrane (%). Used as the curve-family key.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-i_m_water"><i class="fa fa-arrows-v"></i> Water Height</label>
|
<label for="node-input-i_m_water"><i class="fa fa-arrows-v"></i> Water Height</label>
|
||||||
@@ -55,6 +99,7 @@ RED.nodes.registerType('diffuser', {
|
|||||||
<label for="node-input-i_zone_volume"><i class="fa fa-cube"></i> Zone Volume</label>
|
<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">
|
<input type="number" id="node-input-i_zone_volume" step="0.1" min="0" placeholder="m3">
|
||||||
</div>
|
</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>
|
||||||
@@ -72,21 +117,14 @@ RED.nodes.registerType('diffuser', {
|
|||||||
<option value="csv">csv</option>
|
<option value="csv">csv</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-enableLog"><i class="fa fa-book"></i> Enable Log</label>
|
<!-- Asset fields injected here by the shared asset menu (supplier → type → model → unit) -->
|
||||||
<input type="checkbox" id="node-input-enableLog" style="width: auto;">
|
<div id="asset-fields-placeholder"></div>
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
<!-- Logger fields injected here by the shared logger menu -->
|
||||||
<label for="node-input-logLevel"><i class="fa fa-signal"></i> Log Level</label>
|
<div id="logger-fields-placeholder"></div>
|
||||||
<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>
|
||||||
|
|
||||||
<script type="text/html" data-help-name="diffuser">
|
<script type="text/html" data-help-name="diffuser">
|
||||||
<p>Diffused aeration device model.</p>
|
<p>Diffused aeration device model. Resolves a supplier curve (SSOTR vs flow per element, DWP vs flow) at the configured bottom-coverage %, and emits the resulting oxygen-transfer rate plus a zone OTR for the parent reactor.</p>
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
22
diffuser.js
22
diffuser.js
@@ -1,9 +1,31 @@
|
|||||||
const nameOfNode = 'diffuser';
|
const nameOfNode = 'diffuser';
|
||||||
const nodeClass = require('./src/nodeClass.js');
|
const nodeClass = require('./src/nodeClass.js');
|
||||||
|
const { MenuManager, configManager } = require('generalFunctions');
|
||||||
|
|
||||||
module.exports = function(RED) {
|
module.exports = function(RED) {
|
||||||
RED.nodes.registerType(nameOfNode, function(config) {
|
RED.nodes.registerType(nameOfNode, function(config) {
|
||||||
RED.nodes.createNode(this, config);
|
RED.nodes.createNode(this, config);
|
||||||
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
|
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const menuMgr = new MenuManager();
|
||||||
|
const cfgMgr = new configManager();
|
||||||
|
|
||||||
|
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const script = menuMgr.createEndpoint(nameOfNode, ['asset', 'logger']);
|
||||||
|
res.type('application/javascript').send(script);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).send(`// Error generating menu: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
RED.httpAdmin.get(`/${nameOfNode}/configData.js`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const script = cfgMgr.createEndpoint(nameOfNode);
|
||||||
|
res.type('application/javascript').send(script);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).send(`// Error generating configData: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
"description": "Control module diffuser",
|
"description": "Control module diffuser",
|
||||||
"main": "diffuser",
|
"main": "diffuser",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "diffuser"
|
"test": "node --test test/",
|
||||||
|
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
|
||||||
|
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
|
||||||
|
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
13
src/commands/handlers.js
Normal file
13
src/commands/handlers.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Diffuser command handlers. Each receives:
|
||||||
|
// source: the Diffuser domain instance.
|
||||||
|
// msg: the Node-RED input message.
|
||||||
|
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
||||||
|
|
||||||
|
exports.setFlow = (source, msg) => source.setFlow(msg.payload);
|
||||||
|
exports.setDensity = (source, msg) => source.setDensity(msg.payload);
|
||||||
|
exports.setWaterHeight = (source, msg) => source.setWaterHeight(msg.payload);
|
||||||
|
exports.setHeaderPressure = (source, msg) => source.setHeaderPressure(msg.payload);
|
||||||
|
exports.setElements = (source, msg) => source.setElementCount(msg.payload);
|
||||||
|
exports.setAlfaFactor = (source, msg) => source.setAlfaFactor(msg.payload);
|
||||||
54
src/commands/index.js
Normal file
54
src/commands/index.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Diffuser command registry. Consumed by BaseNodeAdapter via
|
||||||
|
// `static commands = require('./commands')`. Each descriptor maps a
|
||||||
|
// canonical msg.topic to its handler; legacy names live under `aliases`
|
||||||
|
// and emit a one-time deprecation warning at runtime.
|
||||||
|
|
||||||
|
const handlers = require('./handlers');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
topic: 'data.flow',
|
||||||
|
aliases: ['air_flow'],
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
description: 'Push the measured air flow into the diffuser model.',
|
||||||
|
handler: handlers.setFlow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.density',
|
||||||
|
aliases: ['density'],
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
description: 'Update the air density used in OTR / SOTR calculations.',
|
||||||
|
handler: handlers.setDensity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.water-height',
|
||||||
|
aliases: ['height_water'],
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
description: 'Update the water column height above the diffusers (m).',
|
||||||
|
handler: handlers.setWaterHeight,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.header-pressure',
|
||||||
|
aliases: ['header_pressure'],
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
description: 'Update the header (supply) pressure feeding the diffusers (mbar).',
|
||||||
|
handler: handlers.setHeaderPressure,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.elements',
|
||||||
|
aliases: ['elements'],
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
description: 'Update the count of active diffuser elements.',
|
||||||
|
handler: handlers.setElements,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.alfa-factor',
|
||||||
|
aliases: ['alfaFactor'],
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
description: 'Update the alfa factor used in oxygen-transfer correction.',
|
||||||
|
handler: handlers.setAlfaFactor,
|
||||||
|
},
|
||||||
|
];
|
||||||
161
src/nodeClass.js
161
src/nodeClass.js
@@ -1,140 +1,43 @@
|
|||||||
const { outputUtils } = require('generalFunctions');
|
'use strict';
|
||||||
const Specific = require('./specificClass');
|
|
||||||
|
|
||||||
class nodeClass {
|
const { BaseNodeAdapter } = require('generalFunctions');
|
||||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
const Diffuser = require('./specificClass');
|
||||||
this.node = nodeInstance;
|
const commands = require('./commands');
|
||||||
this.RED = RED;
|
|
||||||
this.name = nameOfNode;
|
|
||||||
|
|
||||||
this._loadConfig(uiConfig);
|
// Event-driven: setter inputs trigger recalculate which emits
|
||||||
this._setupSpecificClass();
|
// 'output-changed'. No tick loop. Status badge polled every second.
|
||||||
this._registerChild();
|
class nodeClass extends BaseNodeAdapter {
|
||||||
this._startTickLoop();
|
static DomainClass = Diffuser;
|
||||||
this._attachInputHandler();
|
static commands = commands;
|
||||||
this._attachCloseHandler();
|
static tickInterval = null;
|
||||||
}
|
static statusInterval = 1000;
|
||||||
|
|
||||||
_loadConfig(uiConfig) {
|
buildDomainConfig(uiConfig) {
|
||||||
const suffix = uiConfig.number !== undefined && uiConfig.number !== '' ? `_${uiConfig.number}` : '';
|
const n = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb);
|
||||||
const resolvedName = uiConfig.name ? `${uiConfig.name}${suffix}` : this.name;
|
const s = (v, fb) => (typeof v === 'string' && v.length ? v : fb);
|
||||||
|
return {
|
||||||
this.config = {
|
asset: {
|
||||||
general: {
|
model: s(uiConfig.model, 'gva-elastox-r'),
|
||||||
name: resolvedName,
|
assetTagNumber: s(uiConfig.assetTagNumber, ''),
|
||||||
id: this.node.id,
|
|
||||||
unit: uiConfig.unit || 'kg o2/h',
|
|
||||||
logging: {
|
|
||||||
enabled: uiConfig.enableLog,
|
|
||||||
logLevel: uiConfig.logLevel,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
functionality: {
|
general: {
|
||||||
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
unit: s(uiConfig.unit, 'Nm3/h'),
|
||||||
softwareType: this.name,
|
|
||||||
role: 'aeration diffuser',
|
|
||||||
},
|
},
|
||||||
diffuser: {
|
diffuser: {
|
||||||
number: Number(uiConfig.number) || 0,
|
number: n(uiConfig.number, 1),
|
||||||
elements: Number(uiConfig.i_elements) || 1,
|
elements: n(uiConfig.i_elements, 1),
|
||||||
density: Number(uiConfig.i_diff_density) || 2.4,
|
density: n(uiConfig.i_diff_density, 15),
|
||||||
waterHeight: Number(uiConfig.i_m_water) || 0,
|
membraneAreaPerElement: Number.isFinite(Number(uiConfig.membraneAreaPerElement))
|
||||||
alfaFactor: Number(uiConfig.alfaf ?? 0.7) || 0.7,
|
? Number(uiConfig.membraneAreaPerElement)
|
||||||
headerPressure: Number(uiConfig.i_pressure) || 0,
|
: null,
|
||||||
localAtmPressure: Number(uiConfig.i_local_atm_pressure) || 1013.25,
|
waterHeight: n(uiConfig.i_m_water, 0),
|
||||||
waterDensity: Number(uiConfig.i_water_density) || 997,
|
alfaFactor: n(uiConfig.alfaf, 0.7),
|
||||||
zoneVolume: Number(uiConfig.i_zone_volume) || 0,
|
headerPressure: n(uiConfig.i_pressure, 0),
|
||||||
|
localAtmPressure: n(uiConfig.i_local_atm_pressure, 1013.25),
|
||||||
|
waterDensity: n(uiConfig.i_water_density, 997),
|
||||||
|
zoneVolume: n(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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,293 +1,220 @@
|
|||||||
const { logger, interpolation, gravity, convert } = require('generalFunctions');
|
'use strict';
|
||||||
|
|
||||||
class Diffuser {
|
const { BaseDomain, statusBadge, interpolation, gravity, convert, loadCurve } = require('generalFunctions');
|
||||||
constructor(config = {}) {
|
|
||||||
this.config = config;
|
|
||||||
this.logger = new logger(
|
|
||||||
this.config.general?.logging?.enabled,
|
|
||||||
this.config.general?.logging?.logLevel,
|
|
||||||
this.config.general?.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Default curve used when the node's asset model field is not set. Preserves
|
||||||
|
// the historical behaviour of the hardcoded _loadSpecs() (GVA ELASTOX-R at
|
||||||
|
// density 2.4 elements/m²) — the existing test suite calibrates against
|
||||||
|
// these numbers.
|
||||||
|
const DEFAULT_DIFFUSER_MODEL = 'gva-elastox-r';
|
||||||
|
|
||||||
|
class Diffuser extends BaseDomain {
|
||||||
|
static name = 'diffuser';
|
||||||
|
|
||||||
|
configure() {
|
||||||
|
const d = this.config.diffuser || {};
|
||||||
this.interpolation = new interpolation({ type: 'linear' });
|
this.interpolation = new interpolation({ type: 'linear' });
|
||||||
this.convert = convert;
|
this.specs = this._loadSpecs();
|
||||||
this.specs = this.loadSpecs();
|
|
||||||
|
|
||||||
this.idle = true;
|
this.idle = true;
|
||||||
this.warning = { state: false, text: [], flow: { min: { hyst: 2 }, max: { hyst: 2 } } };
|
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.alarm = { state: false, text: [], flow: { min: { hyst: 10 }, max: { hyst: 10 } } };
|
||||||
|
|
||||||
this.i_pressure = this.config.diffuser?.headerPressure || 0;
|
this.i_pressure = _num(d.headerPressure, 0);
|
||||||
this.i_local_atm_pressure = this.config.diffuser?.localAtmPressure || 1013.25;
|
this.i_local_atm_pressure = _num(d.localAtmPressure, 1013.25);
|
||||||
this.i_water_density = this.config.diffuser?.waterDensity || 997;
|
this.i_water_density = _num(d.waterDensity, 997);
|
||||||
this.i_alfa_factor = this.config.diffuser?.alfaFactor || 0.7;
|
this.i_alfa_factor = _num(d.alfaFactor, 0.7);
|
||||||
this.i_n_elements = this.normalizePositiveInteger(this.config.diffuser?.elements, 1);
|
this.i_n_elements = _posInt(d.elements, 1);
|
||||||
this.i_diff_density = this.normalizeNumber(this.config.diffuser?.density, 2.4);
|
this.i_diff_density = _num(d.density, 15);
|
||||||
this.i_m_water = this.normalizeNumber(this.config.diffuser?.waterHeight, 0);
|
this.i_m_water = _num(d.waterHeight, 0);
|
||||||
this.i_flow = 0;
|
this.i_flow = 0;
|
||||||
|
this.zoneVolume = _num(d.zoneVolume, 0);
|
||||||
|
|
||||||
this.n_kg = this.calcAirDensityMbar(1013.25, 0, 20);
|
// Membrane area per element. Curves declare it in _meta — that's the
|
||||||
|
// source of truth. Config can override (e.g. for a non-standard build).
|
||||||
|
// Final fallback 0.18 m² matches the Jäger TD-65 / GVA placeholder.
|
||||||
|
const curveArea = Number(this.specs?._meta?.membraneArea_m2_per_element);
|
||||||
|
const cfgArea = Number(d.membraneAreaPerElement);
|
||||||
|
this.i_membrane_area = Number.isFinite(cfgArea) && cfgArea > 0
|
||||||
|
? cfgArea
|
||||||
|
: (Number.isFinite(curveArea) && curveArea > 0 ? curveArea : 0.18);
|
||||||
|
|
||||||
|
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.heightToPressureMbar(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_kgo2_h = 0; this.o_kgo2 = 0;
|
||||||
this.o_kg_h = 0;
|
this.o_kgo2_h_min = 0; this.o_kgo2_h_max = 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_flow_element = 0;
|
||||||
this.o_otr_min = 0;
|
this.o_flux_per_m2 = 0;
|
||||||
this.o_otr_max = 0;
|
this.o_otr_min = 0; this.o_otr_max = 0;
|
||||||
this.o_p_min = 0;
|
this.o_p_min = 0; this.o_p_max = 0;
|
||||||
this.o_p_max = 0;
|
|
||||||
this.o_combined_eff = 0;
|
this.o_combined_eff = 0;
|
||||||
this.o_slope = 0;
|
this.o_slope = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeNumber(value, fallback = 0) {
|
setDensity(v) { this.i_diff_density = _num(v, this.i_diff_density); this._recalculate(); }
|
||||||
const parsed = Number(value);
|
setFlow(v) { this.i_flow = Math.max(0, _num(v, 0)); this._recalculate(); }
|
||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
setWaterHeight(v) {
|
||||||
|
this.i_m_water = Math.max(0, _num(v, this.i_m_water));
|
||||||
|
this.o_p_water = this._heightToPressureMbar(this.i_water_density, this.i_m_water);
|
||||||
|
this._recalculate();
|
||||||
}
|
}
|
||||||
|
setHeaderPressure(v) { this.i_pressure = _num(v, this.i_pressure); this._recalculate(); }
|
||||||
|
setElementCount(v) { this.i_n_elements = _posInt(v, this.i_n_elements); this._recalculate(); }
|
||||||
|
setAlfaFactor(v) { this.i_alfa_factor = _num(v, this.i_alfa_factor); this._recalculate(); }
|
||||||
|
|
||||||
normalizePositiveInteger(value, fallback = 1) {
|
_recalculate() {
|
||||||
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) {
|
if (this.i_flow <= 0) {
|
||||||
this.idle = true;
|
this.idle = true;
|
||||||
this.n_flow = 0;
|
this.n_flow = 0; this.o_otr = 0; this.o_p_flow = 0;
|
||||||
this.o_otr = 0;
|
this.o_flow_element = 0; this.o_flux_per_m2 = 0;
|
||||||
this.o_p_flow = 0;
|
|
||||||
this.o_flow_element = 0;
|
|
||||||
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_kgo2_h = 0; this.o_kgo2 = 0;
|
||||||
this.o_kg_h = 0;
|
this.o_combined_eff = 0; this.o_slope = 0;
|
||||||
this.o_kgo2_h = 0;
|
this.warning.text = []; this.warning.state = false;
|
||||||
this.o_kgo2 = 0;
|
this.alarm.text = []; this.alarm.state = false;
|
||||||
this.o_combined_eff = 0;
|
} else {
|
||||||
this.o_slope = 0;
|
this.idle = false;
|
||||||
this.warning.text = [];
|
this._calcOtrPressure(this.i_flow);
|
||||||
this.warning.state = false;
|
|
||||||
this.alarm.text = [];
|
|
||||||
this.alarm.state = false;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
this.notifyOutputChanged();
|
||||||
this.idle = false;
|
|
||||||
this.calcOtrPressure(this.i_flow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurveKeys(curve) {
|
_getCurveKeys(c) { return Object.keys(c).map(Number).sort((a, b) => a - b); }
|
||||||
return Object.keys(curve)
|
|
||||||
.map(Number)
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
interpolateSeries(points, x) {
|
_interpolateSeries(pts, x) {
|
||||||
this.interpolation.load_spline(points.x, points.y, 'linear');
|
this.interpolation.load_spline(pts.x, pts.y, 'linear');
|
||||||
return this.interpolation.interpolate(x);
|
return this.interpolation.interpolate(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
interpolateCurveByDensity(curve, density, x) {
|
_interpolateCurveByDensity(curve, density, x) {
|
||||||
const keys = this.getCurveKeys(curve);
|
const keys = this._getCurveKeys(curve);
|
||||||
if (keys.length === 1) {
|
if (keys.length === 1) {
|
||||||
const only = curve[keys[0]];
|
const only = curve[keys[0]];
|
||||||
return {
|
return { value: this._interpolateSeries(only, x),
|
||||||
value: this.interpolateSeries(only, x),
|
minY: Math.min(...only.y), maxY: Math.max(...only.y),
|
||||||
minY: Math.min(...only.y),
|
minX: Math.min(...only.x), maxX: Math.max(...only.x),
|
||||||
maxY: Math.max(...only.y),
|
slope: this._getSegmentSlope(only, x) };
|
||||||
minX: Math.min(...only.x),
|
|
||||||
maxX: Math.max(...only.x),
|
|
||||||
slope: this.getSegmentSlope(only, x),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
const lowerKey = keys.reduce((a, k) => (k <= density ? k : a), keys[0]);
|
||||||
const lowerKey = keys.reduce((acc, key) => (key <= density ? key : acc), keys[0]);
|
const upperKey = keys.find((k) => k >= density) ?? keys[keys.length - 1];
|
||||||
const upperKey = keys.find((key) => key >= density) ?? keys[keys.length - 1];
|
const lower = curve[lowerKey]; const upper = curve[upperKey];
|
||||||
const lowerCurve = curve[lowerKey];
|
|
||||||
const upperCurve = curve[upperKey];
|
|
||||||
|
|
||||||
if (lowerKey === upperKey) {
|
if (lowerKey === upperKey) {
|
||||||
return {
|
return { value: this._interpolateSeries(lower, x),
|
||||||
value: this.interpolateSeries(lowerCurve, x),
|
minY: Math.min(...lower.y), maxY: Math.max(...lower.y),
|
||||||
minY: Math.min(...lowerCurve.y),
|
minX: Math.min(...lower.x), maxX: Math.max(...lower.x),
|
||||||
maxY: Math.max(...lowerCurve.y),
|
slope: this._getSegmentSlope(lower, x) };
|
||||||
minX: Math.min(...lowerCurve.x),
|
|
||||||
maxX: Math.max(...lowerCurve.x),
|
|
||||||
slope: this.getSegmentSlope(lowerCurve, x),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
const lv = this._interpolateSeries(lower, x);
|
||||||
const lowerValue = this.interpolateSeries(lowerCurve, x);
|
const uv = this._interpolateSeries(upper, x);
|
||||||
const upperValue = this.interpolateSeries(upperCurve, x);
|
const r = (density - lowerKey) / (upperKey - lowerKey);
|
||||||
const ratio = (density - lowerKey) / (upperKey - lowerKey);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value: lowerValue + (upperValue - lowerValue) * ratio,
|
value: lv + (uv - lv) * r,
|
||||||
minY: Math.min(...lowerCurve.y) + (Math.min(...upperCurve.y) - Math.min(...lowerCurve.y)) * ratio,
|
minY: Math.min(...lower.y) + (Math.min(...upper.y) - Math.min(...lower.y)) * r,
|
||||||
maxY: Math.max(...lowerCurve.y) + (Math.max(...upperCurve.y) - Math.max(...lowerCurve.y)) * ratio,
|
maxY: Math.max(...lower.y) + (Math.max(...upper.y) - Math.max(...lower.y)) * r,
|
||||||
minX: Math.min(...lowerCurve.x),
|
minX: Math.min(...lower.x), maxX: Math.max(...lower.x),
|
||||||
maxX: Math.max(...lowerCurve.x),
|
slope: this._getSegmentSlope(lower, x),
|
||||||
slope: this.getSegmentSlope(lowerCurve, x),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getSegmentSlope(curvePoints, x) {
|
_getSegmentSlope(pts, x) {
|
||||||
const xs = curvePoints.x;
|
const { x: xs, y: ys } = pts;
|
||||||
const ys = curvePoints.y;
|
|
||||||
for (let i = 0; i < xs.length - 1; i += 1) {
|
for (let i = 0; i < xs.length - 1; i += 1) {
|
||||||
if (x <= xs[i + 1]) {
|
if (x <= xs[i + 1]) return (ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]);
|
||||||
return (ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const last = xs.length - 1;
|
const n = xs.length - 1;
|
||||||
return (ys[last] - ys[last - 1]) / (xs[last] - xs[last - 1]);
|
return (ys[n] - ys[n - 1]) / (xs[n] - xs[n - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
combineEff(oOtr, oOtrMin, oOtrMax, oPFlow, oPMin, oPMax) {
|
_combineEff(oOtr, oOtrMin, oOtrMax, oPFlow, oPMin, oPMax) {
|
||||||
const otrSpan = oOtrMax - oOtrMin;
|
const otrSpan = oOtrMax - oOtrMin;
|
||||||
const pSpan = oPMax - oPMin;
|
const pSpan = oPMax - oPMin;
|
||||||
const eff1 = otrSpan > 0 ? (oOtr - oOtrMin) / otrSpan : 0;
|
const e1 = otrSpan > 0 ? (oOtr - oOtrMin) / otrSpan : 0;
|
||||||
const eff2 = pSpan > 0 ? 1 - ((oPFlow - oPMin) / pSpan) : 0;
|
const e2 = pSpan > 0 ? 1 - ((oPFlow - oPMin) / pSpan) : 0;
|
||||||
return Math.max(0, eff1 * eff2 * 100);
|
return Math.max(0, e1 * e2 * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
calcAirDensityMbar(pressureMbar, RH, tempC) {
|
_calcAirDensityMbar(pMbar, RH, tempC) {
|
||||||
const Rd = 287.05;
|
const Rd = 287.05, Rv = 461.495;
|
||||||
const Rv = 461.495;
|
|
||||||
const T = tempC + 273.15;
|
const T = tempC + 273.15;
|
||||||
const A = 8.07131;
|
const es = Math.pow(10, (8.07131 - (1730.63 / (233.426 + tempC))));
|
||||||
const B = 1730.63;
|
const e = RH * es / 100;
|
||||||
const C = 233.426;
|
const pPa = convert(pMbar).from('mbar').to('Pa');
|
||||||
const e_s = Math.pow(10, (A - (B / (C + tempC))));
|
const pd = pPa - (e * 100);
|
||||||
const e = RH * e_s / 100;
|
return (pd / (Rd * T)) + ((e * 100) / (Rv * T));
|
||||||
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) {
|
_heightToPressureMbar(density, height) {
|
||||||
const pressurePa = gravity.getStandardGravity() * density * height;
|
const pPa = gravity.getStandardGravity() * density * height;
|
||||||
return this.convert(pressurePa).from('Pa').to('mbar');
|
return convert(pPa).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.calcAirDensityMbar(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;
|
||||||
|
// Specific flux through the membrane — the canonical x-axis of every
|
||||||
|
// curve file under datasets/assetData/curves/. Curves are indexed by
|
||||||
|
// bottom coverage % (this.i_diff_density) and queried at this flux.
|
||||||
|
const totalMembraneArea = this.i_n_elements * this.i_membrane_area;
|
||||||
|
this.o_flux_per_m2 = Math.round((this.n_flow / totalMembraneArea) * 100) / 100;
|
||||||
|
|
||||||
const otr = this.interpolateCurveByDensity(this.specs.otr_curve, this.i_diff_density, this.o_flow_element);
|
const otr = this._interpolateCurveByDensity(this.specs.otr_curve, this.i_diff_density, this.o_flux_per_m2);
|
||||||
const pressure = this.interpolateCurveByDensity(this.specs.p_curve, 0, this.o_flow_element);
|
const pressure = this._interpolateCurveByDensity(this.specs.p_curve, 0, this.o_flux_per_m2);
|
||||||
|
|
||||||
this.o_otr_min = otr.minY;
|
this.o_otr_min = otr.minY; this.o_otr_max = otr.maxY;
|
||||||
this.o_otr_max = otr.maxY;
|
this.o_p_min = pressure.minY; this.o_p_max = pressure.maxY;
|
||||||
this.o_p_min = pressure.minY;
|
|
||||||
this.o_p_max = pressure.maxY;
|
|
||||||
this.o_otr = Math.round(otr.value * 100) / 100;
|
this.o_otr = Math.round(otr.value * 100) / 100;
|
||||||
this.o_p_flow = Math.round(pressure.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_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;
|
const kgo2 = (n) => Math.round(convert(n * 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 = kgo2(this.o_otr);
|
||||||
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_h_min = kgo2(this.o_otr_min);
|
||||||
|
this.o_kgo2_h_max = kgo2(this.o_otr_max);
|
||||||
this.o_kgo2 = this.o_kgo2_h / 3600;
|
this.o_kgo2 = this.o_kgo2_h / 3600;
|
||||||
this.o_combined_eff = Math.round(this.combineEff(
|
this.o_combined_eff = Math.round(this._combineEff(
|
||||||
this.o_otr,
|
this.o_otr, this.o_otr_min, this.o_otr_max,
|
||||||
this.o_otr_min,
|
this.o_p_flow, this.o_p_min, this.o_p_max,
|
||||||
this.o_otr_max,
|
|
||||||
this.o_p_flow,
|
|
||||||
this.o_p_min,
|
|
||||||
this.o_p_max,
|
|
||||||
) * 100) / 100;
|
) * 100) / 100;
|
||||||
this.o_slope = Math.round(otr.slope * 1000) / 1000;
|
this.o_slope = Math.round(otr.slope * 1000) / 1000;
|
||||||
|
|
||||||
this.warningCheck(pressure.minX, pressure.maxX);
|
this._checkLimits(pressure.minX, pressure.maxX);
|
||||||
this.alarmCheck(pressure.minX, pressure.maxX);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
warningCheck(minFlow, maxFlow) {
|
_checkLimits(minFlow, maxFlow) {
|
||||||
this.warning.text = [];
|
this.warning.text = []; this.warning.state = false;
|
||||||
this.warning.state = false;
|
this.alarm.text = []; this.alarm.state = false;
|
||||||
const minHyst = minFlow * (this.warning.flow.min.hyst / 100);
|
// Compare against the canonical flux, since pressure.minX / maxX come
|
||||||
const maxHyst = maxFlow * (this.warning.flow.max.hyst / 100);
|
// from the per-m²-membrane curve.
|
||||||
|
const f = this.o_flux_per_m2;
|
||||||
if (this.o_flow_element < minFlow - minHyst) {
|
for (const k of ['warning', 'alarm']) {
|
||||||
this.warning.state = true;
|
const band = this[k];
|
||||||
this.warning.text.push(`Warning: flow per element ${this.o_flow_element} is below ${Math.round((minFlow - minHyst) * 100) / 100}`);
|
const lo = minFlow - minFlow * (band.flow.min.hyst / 100);
|
||||||
}
|
const hi = maxFlow + maxFlow * (band.flow.max.hyst / 100);
|
||||||
|
if (f < lo) { band.state = true; band.text.push(`${_cap(k)}: specific flux ${f} Nm³/(h·m²) is below ${Math.round(lo * 100) / 100}`); }
|
||||||
if (this.o_flow_element > maxFlow + maxHyst) {
|
if (f > hi) { band.state = true; band.text.push(`${_cap(k)}: specific flux ${f} Nm³/(h·m²) exceeds ${Math.round(hi * 100) / 100}`); }
|
||||||
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) {
|
// Back-compat hooks for the legacy specificClass test suite.
|
||||||
this.alarm.text = [];
|
getStatus() { return this._legacyStatus(); }
|
||||||
this.alarm.state = false;
|
_legacyStatus() {
|
||||||
const minHyst = minFlow * (this.alarm.flow.min.hyst / 100);
|
if (this.alarm.state) return { fill: 'red', shape: 'dot', text: this.alarm.text[0] };
|
||||||
const maxHyst = maxFlow * (this.alarm.flow.max.hyst / 100);
|
if (this.warning.state) return { fill: 'yellow', shape: 'dot', text: this.warning.text[0] };
|
||||||
|
const fill = this.idle ? 'grey' : 'green';
|
||||||
if (this.o_flow_element < minFlow - minHyst) {
|
return { fill, shape: 'dot', text: `${this.o_kgo2_h} kg o2 / h` };
|
||||||
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() {
|
getStatusBadge() {
|
||||||
if (this.alarm.state) {
|
if (this.alarm.state) return statusBadge.error(this.alarm.text[0]);
|
||||||
return { fill: 'red', shape: 'dot', text: this.alarm.text[0] };
|
if (this.warning.state) return statusBadge.compose([`⚠ ${this.warning.text[0]}`], { fill: 'yellow', shape: 'dot' });
|
||||||
}
|
const text = `${this.o_kgo2_h} kg o2 / h`;
|
||||||
if (this.warning.state) {
|
return this.idle ? statusBadge.idle(text) : statusBadge.compose([`🟢 ${text}`]);
|
||||||
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() {
|
getOutput() {
|
||||||
@@ -300,8 +227,10 @@ class Diffuser {
|
|||||||
oPLoss: this.o_p_total,
|
oPLoss: this.o_p_total,
|
||||||
oKgo2H: this.o_kgo2_h,
|
oKgo2H: this.o_kgo2_h,
|
||||||
oFlowElement: this.o_flow_element,
|
oFlowElement: this.o_flow_element,
|
||||||
|
oFluxPerM2: this.o_flux_per_m2,
|
||||||
efficiency: this.o_combined_eff,
|
efficiency: this.o_combined_eff,
|
||||||
slope: this.o_slope,
|
slope: this.o_slope,
|
||||||
|
oZoneOtr: this.getReactorOtr(this.zoneVolume),
|
||||||
idle: this.idle,
|
idle: this.idle,
|
||||||
warning: [...this.warning.text],
|
warning: [...this.warning.text],
|
||||||
alarm: [...this.alarm.text],
|
alarm: [...this.alarm.text],
|
||||||
@@ -309,34 +238,46 @@ class Diffuser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getReactorOtr(zoneVolumeM3) {
|
getReactorOtr(zoneVolumeM3) {
|
||||||
const volume = Number(zoneVolumeM3);
|
const v = Number(zoneVolumeM3);
|
||||||
if (!Number.isFinite(volume) || volume <= 0) {
|
if (!Number.isFinite(v) || v <= 0) return 0;
|
||||||
return 0;
|
return this.o_kgo2_h * 1000 * 24 / v;
|
||||||
}
|
|
||||||
return this.o_kgo2_h * 1000 * 24 / volume;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSpecs() {
|
_loadSpecs() {
|
||||||
|
// Curve lookup id: prefer the asset-menu-saved field, fall back to the
|
||||||
|
// legacy GVA ELASTOX-R reference (same numbers as the previous inline
|
||||||
|
// _loadSpecs). If a configured id misses the registry, fall back too —
|
||||||
|
// a missing curve would otherwise crash the constructor in production.
|
||||||
|
const cfgModel =
|
||||||
|
this.config?.asset?.model ||
|
||||||
|
this.config?.model ||
|
||||||
|
DEFAULT_DIFFUSER_MODEL;
|
||||||
|
const raw = loadCurve(cfgModel) || loadCurve(DEFAULT_DIFFUSER_MODEL);
|
||||||
|
if (!raw || !raw.otr_curve || !raw.p_curve) {
|
||||||
|
throw new Error(
|
||||||
|
`diffuser: curve '${cfgModel}' is missing otr_curve/p_curve (registry has: ${Object.keys(raw || {}).join(',') || 'nothing'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
supplier: 'GVA',
|
_meta: raw._meta || {},
|
||||||
type: 'ELASTOX-R',
|
supplier: raw._meta?.supplier || null,
|
||||||
units: {
|
type: raw._meta?.type || null,
|
||||||
Nm3: { temp: 20, pressure: 1.01325, RH: 0 },
|
model: raw._meta?.model || cfgModel,
|
||||||
},
|
units: { Nm3: { temp: 20, pressure: 1.01325, RH: 0 } },
|
||||||
otr_curve: {
|
otr_curve: raw.otr_curve,
|
||||||
2.4: {
|
p_curve: raw.p_curve,
|
||||||
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],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _num(v, fb = 0) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : fb;
|
||||||
|
}
|
||||||
|
function _posInt(v, fb = 1) {
|
||||||
|
const n = Math.round(Number(v));
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : fb;
|
||||||
|
}
|
||||||
|
function _cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||||
|
|
||||||
module.exports = Diffuser;
|
module.exports = Diffuser;
|
||||||
|
|||||||
231
wiki/Home.md
Normal file
231
wiki/Home.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# diffuser
|
||||||
|
|
||||||
|
> **Reflects code as of `8cc02ee` · regenerated `2026-05-11` via `npm run wiki:all`**
|
||||||
|
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
|
||||||
|
|
||||||
|
## 1. What this node is
|
||||||
|
|
||||||
|
**diffuser** models an aeration-diffuser zone. Given header pressure, water-column height, alpha factor, element count and airflow, it interpolates a supplier OTR curve, normalises airflow to Nm³/h, and emits oxygen-transfer rate plus a reactor-zone OTR for the downstream parent. Used as a leaf Equipment Module under a `reactor`.
|
||||||
|
|
||||||
|
## 2. Position in the platform
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
src[blower / MGC / dashboard]:::unit -->|data.flow| diff[diffuser<br/>Equipment]:::equip
|
||||||
|
setters[dashboard setters]:::ctrl -->|set.density / set.water-height /<br/>set.elements / set.alfa-factor /<br/>set.header-pressure| diff
|
||||||
|
diff -->|child.register| reactor[reactor<br/>Unit]:::unit
|
||||||
|
classDef unit fill:#50a8d9,color:#000
|
||||||
|
classDef equip fill:#86bbdd,color:#000
|
||||||
|
classDef ctrl fill:#a9daee,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
S88 colours: Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
|
||||||
|
|
||||||
|
## 3. Capability matrix
|
||||||
|
|
||||||
|
| Capability | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Supplier OTR curve interpolation | ✅ | Density-keyed; falls back to single key when only one available. |
|
||||||
|
| Air-density correction (header + atm) | ✅ | `_calcAirDensityMbar` per ideal-gas mix. |
|
||||||
|
| Per-element flow tracking | ✅ | `o_flowElement = nFlow / nElements`. |
|
||||||
|
| Static head loss from water column | ✅ | `_heightToPressureMbar`. |
|
||||||
|
| Warning / alarm bands on flow-per-element | ✅ | Hysteresis 2 % (warn) / 10 % (alarm). |
|
||||||
|
| Reactor-zone OTR for parent | ✅ | `oZoneOtr` derived from `diffuser.zoneVolume`. |
|
||||||
|
| Idle handling at flow ≤ 0 | ✅ | Resets all derived outputs to zero. |
|
||||||
|
| Typed `MeasurementContainer` emission | ❌ | All output flows via `getOutput()`; no typed series yet. |
|
||||||
|
|
||||||
|
## 4. Code map
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
|
||||||
|
nc["buildDomainConfig()<br/>static DomainClass, commands"]
|
||||||
|
end
|
||||||
|
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
|
||||||
|
sc["Diffuser.configure()<br/>loads supplier specs<br/>setters → _recalculate()"]
|
||||||
|
end
|
||||||
|
subgraph concerns["src/ concern modules"]
|
||||||
|
cmds["commands/<br/>topic registry + handlers"]
|
||||||
|
end
|
||||||
|
nc --> sc
|
||||||
|
nc --> cmds
|
||||||
|
```
|
||||||
|
|
||||||
|
| Module | Owns | Read first if you're changing… |
|
||||||
|
|---|---|---|
|
||||||
|
| `commands/` | Input-topic registry + per-topic handlers | Topic naming, payload validation. |
|
||||||
|
| `specificClass.js` (single file) | Setters, OTR/ΔP curve interpolation, alarms, output composition | Anything domain-side. |
|
||||||
|
|
||||||
|
The diffuser was a small node so the P6.4 refactor did not split it into per-concern directories. Future work may extract `curves/` and `alarms/` if the file grows past ~250 lines.
|
||||||
|
|
||||||
|
## 5. Topic contract
|
||||||
|
|
||||||
|
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `data.flow` | `air_flow` | `number` | `volumeFlowRate` (default `m3/h`) | Push the measured air flow into the diffuser model. |
|
||||||
|
| `set.density` | `density` | `number` | — | Update the air density used in OTR / SOTR calculations. |
|
||||||
|
| `set.water-height` | `height_water` | `number` | — | Update the water column height above the diffusers (m). |
|
||||||
|
| `set.header-pressure` | `header_pressure` | `number` | — | Update the header (supply) pressure feeding the diffusers (mbar). |
|
||||||
|
| `set.elements` | `elements` | `number` | — | Update the count of active diffuser elements. |
|
||||||
|
| `set.alfa-factor` | `alfaFactor` | `number` | — | Update the alfa factor used in oxygen-transfer correction. |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: topic-contract -->
|
||||||
|
|
||||||
|
## 6. Child registration
|
||||||
|
|
||||||
|
diffuser is a leaf node — it accepts no children. It registers itself with its upstream parent (typically a reactor) at startup via the standard Port-2 handshake.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
diff[diffuser]:::equip -->|child.register payload=node.id<br/>positionVsParent=atEquipment| reactor[reactor / parent]:::unit
|
||||||
|
classDef equip fill:#86bbdd,color:#000
|
||||||
|
classDef unit fill:#50a8d9,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
| Direction | Counterparty | Side-effect |
|
||||||
|
|---|---|---|
|
||||||
|
| outbound at startup | upstream reactor | sends `child.register` on Port 2 with `positionVsParent` default `atEquipment` |
|
||||||
|
| inbound | — | none accepted |
|
||||||
|
|
||||||
|
## 7. Lifecycle — what one event does
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant src as upstream source
|
||||||
|
participant diff as diffuser
|
||||||
|
participant curve as supplier specs
|
||||||
|
participant out as Port-0
|
||||||
|
|
||||||
|
src->>diff: data.flow (Nm³/h)
|
||||||
|
diff->>diff: setFlow → _recalculate
|
||||||
|
alt flow ≤ 0
|
||||||
|
diff->>diff: idle = true, reset derived outputs
|
||||||
|
else flow > 0
|
||||||
|
diff->>diff: air-density correction (atm + header)
|
||||||
|
diff->>curve: interpolate OTR by density + flow/element
|
||||||
|
diff->>curve: interpolate ΔP curve by flow/element
|
||||||
|
diff->>diff: kg O₂/h, combined efficiency, slope
|
||||||
|
diff->>diff: _checkLimits (warn / alarm bands)
|
||||||
|
end
|
||||||
|
diff->>diff: notifyOutputChanged()
|
||||||
|
diff->>out: msg{topic, payload (delta-compressed)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Data model — `getOutput()`
|
||||||
|
|
||||||
|
What lands on Port 0. Composed in `Diffuser.getOutput()`, then delta-compressed by `outputUtils.formatMsg`.
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: data-model -->
|
||||||
|
|
||||||
|
| Key | Type | Unit | Sample |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `alarm` | array | — | `[…]` |
|
||||||
|
| `efficiency` | number | — | `0` |
|
||||||
|
| `iFlow` | number | — | `0` |
|
||||||
|
| `iMWater` | number | — | `0` |
|
||||||
|
| `iPressure` | number | — | `0` |
|
||||||
|
| `idle` | boolean | — | `true` |
|
||||||
|
| `nFlow` | number | — | `0` |
|
||||||
|
| `oFlowElement` | number | — | `0` |
|
||||||
|
| `oKgo2H` | number | — | `0` |
|
||||||
|
| `oOtr` | number | — | `0` |
|
||||||
|
| `oPLoss` | number | — | `0` |
|
||||||
|
| `oZoneOtr` | number | — | `0` |
|
||||||
|
| `slope` | number | — | `0` |
|
||||||
|
| `warning` | array | — | `[…]` |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: data-model -->
|
||||||
|
|
||||||
|
`oZoneOtr` is `kg O₂ / m³ / day`; it is `0` when `diffuser.zoneVolume` is unset.
|
||||||
|
|
||||||
|
## 9. Configuration — editor form ↔ config keys
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph editor["Node-RED editor form"]
|
||||||
|
f0[Zone number]
|
||||||
|
f1[Element count]
|
||||||
|
f2[Diffuser density]
|
||||||
|
f3[Water height]
|
||||||
|
f4[Header pressure]
|
||||||
|
f5[Alpha factor]
|
||||||
|
f6[Zone volume]
|
||||||
|
end
|
||||||
|
subgraph config["Domain config slice"]
|
||||||
|
c0[diffuser.number]
|
||||||
|
c1[diffuser.elements]
|
||||||
|
c2[diffuser.density]
|
||||||
|
c3[diffuser.waterHeight]
|
||||||
|
c4[diffuser.headerPressure]
|
||||||
|
c5[diffuser.alfaFactor]
|
||||||
|
c6[diffuser.zoneVolume]
|
||||||
|
c7["diffuser.localAtmPressure (hidden)"]
|
||||||
|
c8["diffuser.waterDensity (hidden)"]
|
||||||
|
end
|
||||||
|
f0 --> c0
|
||||||
|
f1 --> c1
|
||||||
|
f2 --> c2
|
||||||
|
f3 --> c3
|
||||||
|
f4 --> c4
|
||||||
|
f5 --> c5
|
||||||
|
f6 --> c6
|
||||||
|
```
|
||||||
|
|
||||||
|
| Form field | Config key | Default | Range | Where used |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Zone number | `diffuser.number` | `1` | int ≥ 1 | node label (`name_N`) |
|
||||||
|
| Element count | `diffuser.elements` | `1` | int ≥ 1 | per-element flow |
|
||||||
|
| Diffuser density | `diffuser.density` | `2.4` | > 0 | OTR curve key |
|
||||||
|
| Water height | `diffuser.waterHeight` | `0` | ≥ 0 (m) | static head + kg O₂/h |
|
||||||
|
| Header pressure | `diffuser.headerPressure` | `0` | ≥ 0 (mbar) | air density correction |
|
||||||
|
| Alpha factor | `diffuser.alfaFactor` | `0.7` | 0–1 | oxygen-transfer correction |
|
||||||
|
| _(hidden default)_ | `diffuser.localAtmPressure` | `1013.25` | > 0 (mbar) | density baseline |
|
||||||
|
| _(hidden default)_ | `diffuser.waterDensity` | `997` | > 0 (kg/m³) | static head |
|
||||||
|
| Zone volume | `diffuser.zoneVolume` | `0` | ≥ 0 (m³) | `oZoneOtr` |
|
||||||
|
|
||||||
|
## 10. State chart
|
||||||
|
|
||||||
|
Skipped — diffuser is stateless. Every input setter recomputes the full output snapshot; there are no transitions to track. The `idle` flag is a derived predicate (`i_flow ≤ 0`), not a state.
|
||||||
|
|
||||||
|
## 11. Examples
|
||||||
|
|
||||||
|
| Tier | File | What it shows | Mandatory? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Basic | `examples/01-Basic.flow.json` | Inject `data.flow` + dashboard, no parent | ✅ |
|
||||||
|
| Integration | `examples/02-Integration.flow.json` | diffuser registered under a reactor zone | ✅ |
|
||||||
|
| Dashboard | `examples/03-Dashboard.flow.json` | Live FlowFuse charts (OTR, kg O₂/h, efficiency) | ⭕ |
|
||||||
|
|
||||||
|
Screenshots under `wiki/_partial-screenshots/diffuser/` when produced. Docker compose snippet under `examples/README.md`.
|
||||||
|
|
||||||
|
## 12. Debug recipes
|
||||||
|
|
||||||
|
| Symptom | First thing to check | Where to look |
|
||||||
|
|---|---|---|
|
||||||
|
| `oOtr` stuck at zero | `i_flow` is zero or negative → `idle = true`. | `_recalculate` early-return |
|
||||||
|
| Warning / alarm always firing | Flow-per-element outside curve `minX` / `maxX` ± hysteresis. | `_checkLimits` |
|
||||||
|
| `oZoneOtr` is zero despite valid OTR | `diffuser.zoneVolume` is unset or non-positive. | `getReactorOtr` |
|
||||||
|
| `nFlow` differs from `iFlow` at non-zero flow | Air-density correction — header pressure or atm differ from reference. | `_calcAirDensityMbar` |
|
||||||
|
| `efficiency` flat at 0 | OTR or ΔP curve span is zero in the operating band. | `_combineEff` |
|
||||||
|
|
||||||
|
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors. Use only for live debugging.
|
||||||
|
|
||||||
|
## 13. When you would NOT use this node
|
||||||
|
|
||||||
|
- Use diffuser for a **fine-bubble aeration zone** with a supplier OTR curve. For coarse-bubble or jet aeration, model OTR externally.
|
||||||
|
- Don't use diffuser when the upstream blower already publishes oxygen-transfer telemetry — diffuser duplicates the calculation.
|
||||||
|
- Skip diffuser if you only need flow-per-element warning bands without OTR — a `measurement` node with bands is lighter.
|
||||||
|
|
||||||
|
## 14. Known limitations / current issues
|
||||||
|
|
||||||
|
| # | Issue | Tracked in |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Port-count change (Phase 6):** pre-refactor the diffuser exposed 4 outputs (process, dbase, reactor control with `topic: 'OTR'`, parent registration). The reactor-control message merged into Port 0 as `oZoneOtr`; consumers reading the dedicated control port must migrate to `payload.oZoneOtr`. No alias is provided — the shape differs (single value vs full process payload). | CONTRACT.md `## Port-count change` |
|
||||||
|
| 2 | Supplier specs are hard-coded inside `_loadSpecs()` (GVA / ELASTOX-R). A configurable supplier registry is pending. | `specificClass.js _loadSpecs` |
|
||||||
|
| 3 | No typed `MeasurementContainer` emission — `oOtr` / `oZoneOtr` cannot be subscribed via the generic `ChildRouter` handshake. Parents must read Port 0 messages. | CONTRACT.md `## Events emitted` |
|
||||||
|
| 4 | Warning / alarm thresholds are fixed (2 % / 10 % hysteresis); not yet config-driven. | `configure()` literals |
|
||||||
|
| 5 | **Node category mismatch:** `diffuser.html` registers under `category: 'wbd typical'` instead of `'EVOLV'`. All other platform nodes target `'EVOLV'` for consistent palette grouping in the editor. | `diffuser.html` line 3 |
|
||||||
|
| 6 | **S88 colour — resolved:** `diffuser.html` already declares `color: '#86bbdd'` (Equipment Module). The WIKI_TEMPLATE §16 note listing diffuser as having "no colour set" is stale and can be removed in the next template refresh. | `diffuser.html` line 4 |
|
||||||
Reference in New Issue
Block a user