Compare commits

...

8 Commits

Author SHA1 Message Date
znetsixe
f5fd8039f5 style: palette swatch → (domain-hue redesign 2026-05-21)
Sidebar swatch now follows function family rather than S88 level, so the
palette is visually identifiable instead of monochromatically blue. Editor-group
rectangles in flow.json still follow S88 — only the registerType color changed.
Full table + rationale: superproject .claude/rules/node-red-flow-layout.md §10.0
and .claude/refactor/OPEN_QUESTIONS.md (2026-05-21 entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:05:58 +02:00
znetsixe
bf645cfe68 docs: fix stale test/README claim — diffuser has runtime files
The README claimed "Placeholder structure (diffuser currently has no
runtime module files)" but `src/nodeClass.js`, `src/specificClass.js`
(284-line OTR/ΔP model), and `src/commands/` all exist. Updated to
describe the actual layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:59:17 +02:00
znetsixe
37ecfe5726 docs(wiki): regenerate topic-contract AUTOGEN block via wiki-gen
Replaces the agent-written placeholder inside Reference-Contracts.md with
the authoritative table generated from src/commands/index.js. Both the
BEGIN and END markers are normalized to the canonical form used by
`@evolv/wiki-gen`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:47 +02:00
znetsixe
8c03fe774c docs(wiki): full 5-page wiki matching the rotatingMachine reference format
Replaces the prior stub/partial wiki with a Home + Reference-{Architecture,
Contracts,Examples,Limitations} + _Sidebar structure. Topic-contract and
data-model sections wrapped in AUTOGEN markers for the future wiki-gen tool.
Source-vs-spec contradictions surfaced and flagged inline (not silently
fixed). Pending-review notes mark sections that need a full node review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:42:13 +02:00
znetsixe
4973a8bcfc docs: add Folder & File Layout section per EVOLV convention
Each repo can now be read standalone for the file-naming convention. Full rule:
.claude/rules/node-architecture.md in the EVOLV superproject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:30:21 +02:00
znetsixe
37a85690d1 refactor(diffuser): pass canonical specific flux Nm³/(h·m²) to curve lookup
Pairs with the generalFunctions curve-file rewrite — all supplier
curves now share one X-axis: specific air flux through the membrane,
Nm³/(h·m² membrane). specificClass._calcOtrPressure computes the flux
from the existing n_flow and i_n_elements plus a new i_membrane_area
field, and queries the OTR / DWP curves at that flux instead of at
flow per element.

i_membrane_area resolution (precedence top-down):
1. config.diffuser.membraneAreaPerElement (new optional override)
2. specs._meta.membraneArea_m2_per_element (curve-file source of truth)
3. 0.18 m² (Jäger TD-65 / GVA placeholder)

_loadSpecs now preserves the curve's full _meta block so the area
metadata reaches configure(). Output schema gains oFluxPerM2 alongside
the existing oFlowElement (kept for user-readability — both numbers
are useful on a dashboard).

_checkLimits compares specific flux against the curve's minX / maxX
(also in canonical units now), so warning / alarm thresholds remain
self-consistent.

8/8 tests pass. Verified across all 5 suppliers (gva-elastox-r,
jaeger-jetflex, aerostrip-phoenix, pik300, prk300) that the resolved
flux + SSOTR + DWP numbers match hand-computed expectations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:16:49 +02:00
znetsixe
2c5704b5c0 feat(diffuser): resolve supplier curves via assetResolver + wire asset menu
_loadSpecs() now calls loadCurve(model) instead of returning a hardcoded
literal. Default model 'gva-elastox-r' keeps the legacy GVA numbers; the
editor cascade (supplier → type → model → unit) lets users pick Jäger,
Aerostrip, or PIK/PRK once those curve files land in generalFunctions.

Editor changes:
- diffuser.js serves /diffuser/menu.js + /diffuser/configData.js
- diffuser.html loads the shared MenuManager scripts, includes
  asset-fields-placeholder + logger-fields-placeholder, and runs the
  shared init/save lifecycle.
- Density field re-labelled "Bottom coverage [%]" — semantics were
  always meant to be % surface-area coverage; "elements per m²" was a
  prior mis-conversion. Default flipped 2.4 → 15 (typical fine-bubble).
- New defaults: model, unit, assetTagNumber.

specificClass:
- buildDomainConfig now forwards uiConfig.model/unit/assetTagNumber
  under config.asset.* so _loadSpecs can resolve it.
- _loadSpecs walks config.asset.model || config.model || DEFAULT, falls
  through to GVA on a missing curve file (with a clear error if neither
  resolves to a usable otr_curve + p_curve).

All 8 unit + structure tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:11:50 +02:00
znetsixe
6372bdc926 P11.6 diffuser wiki: banner hash + section 9 form completeness + section 14 category/colour notes
- Bump banner hash from 15cfb228cc02ee (latest HEAD).
- Section 9: add missing `number` form field; mark localAtmPressure /
  waterDensity as hidden defaults with no form row; update mermaid diagram.
- Section 14: add issue #5 (category 'wbd typical' vs 'EVOLV') and issue
  #6 (S88 colour already set — §16 note now stale).
- npm run wiki:all run prior to edit; AUTOGEN markers untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:04:57 +02:00
12 changed files with 978 additions and 218 deletions

View File

@@ -21,3 +21,20 @@ Key points for this node:
- Stack same-level siblings vertically. - Stack same-level siblings vertically.
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right). - 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). - Wrap in a Node-RED group box coloured `#86bbdd` (Equipment Module).
## Folder & File Layout
Every per-node file MUST use the folder name (`diffuser`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
| Path | Required name |
|---|---|
| Entry file | `diffuser.js` |
| Editor HTML | `diffuser.html` |
| Node adapter | `src/nodeClass.js` |
| Domain logic | `src/specificClass.js` |
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
| Tests | `test/{basic,integration,edge}/*.test.js` |
| Example flows | `examples/*.flow.json` |
When adding new files, read the rule above first to avoid drift.

View File

@@ -1,17 +1,29 @@
<!-- 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: '#6EB5E5',
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' },
}, },
@@ -21,7 +33,36 @@ RED.nodes.registerType('diffuser', {
outputLabels: ['process', 'dbase', '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>
@@ -68,25 +113,19 @@ RED.nodes.registerType('diffuser', {
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label> <label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
<select id="node-input-dbaseOutputFormat" style="width:60%;"> <select id="node-input-dbaseOutputFormat" style="width:60%;">
<option value="influxdb">influxdb</option> <option value="influxdb">influxdb</option>
<option value="frost">frost</option>
<option value="json">json</option> <option value="json">json</option>
<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>

View File

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

View File

@@ -14,11 +14,22 @@ class nodeClass extends BaseNodeAdapter {
buildDomainConfig(uiConfig) { buildDomainConfig(uiConfig) {
const n = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb); const n = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb);
const s = (v, fb) => (typeof v === 'string' && v.length ? v : fb);
return { return {
asset: {
model: s(uiConfig.model, 'gva-elastox-r'),
assetTagNumber: s(uiConfig.assetTagNumber, ''),
},
general: {
unit: s(uiConfig.unit, 'Nm3/h'),
},
diffuser: { diffuser: {
number: n(uiConfig.number, 1), number: n(uiConfig.number, 1),
elements: n(uiConfig.i_elements, 1), elements: n(uiConfig.i_elements, 1),
density: n(uiConfig.i_diff_density, 2.4), density: n(uiConfig.i_diff_density, 15),
membraneAreaPerElement: Number.isFinite(Number(uiConfig.membraneAreaPerElement))
? Number(uiConfig.membraneAreaPerElement)
: null,
waterHeight: n(uiConfig.i_m_water, 0), waterHeight: n(uiConfig.i_m_water, 0),
alfaFactor: n(uiConfig.alfaf, 0.7), alfaFactor: n(uiConfig.alfaf, 0.7),
headerPressure: n(uiConfig.i_pressure, 0), headerPressure: n(uiConfig.i_pressure, 0),

View File

@@ -1,6 +1,12 @@
'use strict'; 'use strict';
const { BaseDomain, statusBadge, interpolation, gravity, convert } = require('generalFunctions'); const { BaseDomain, statusBadge, interpolation, gravity, convert, loadCurve } = require('generalFunctions');
// 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 { class Diffuser extends BaseDomain {
static name = 'diffuser'; static name = 'diffuser';
@@ -19,11 +25,20 @@ class Diffuser extends BaseDomain {
this.i_water_density = _num(d.waterDensity, 997); this.i_water_density = _num(d.waterDensity, 997);
this.i_alfa_factor = _num(d.alfaFactor, 0.7); this.i_alfa_factor = _num(d.alfaFactor, 0.7);
this.i_n_elements = _posInt(d.elements, 1); this.i_n_elements = _posInt(d.elements, 1);
this.i_diff_density = _num(d.density, 2.4); this.i_diff_density = _num(d.density, 15);
this.i_m_water = _num(d.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.zoneVolume = _num(d.zoneVolume, 0);
// 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_kg = this._calcAirDensityMbar(1013.25, 0, 20);
this.n_flow = 0; this.n_flow = 0;
this.o_otr = 0; this.o_otr = 0;
@@ -33,6 +48,7 @@ class Diffuser extends BaseDomain {
this.o_kg = 0; this.o_kg_h = 0; this.o_kgo2_h = 0; this.o_kgo2 = 0; this.o_kg = 0; this.o_kg_h = 0; this.o_kgo2_h = 0; this.o_kgo2 = 0;
this.o_kgo2_h_min = 0; this.o_kgo2_h_max = 0; this.o_kgo2_h_min = 0; this.o_kgo2_h_max = 0;
this.o_flow_element = 0; this.o_flow_element = 0;
this.o_flux_per_m2 = 0;
this.o_otr_min = 0; this.o_otr_max = 0; this.o_otr_min = 0; this.o_otr_max = 0;
this.o_p_min = 0; this.o_p_max = 0; this.o_p_min = 0; this.o_p_max = 0;
this.o_combined_eff = 0; this.o_combined_eff = 0;
@@ -53,7 +69,8 @@ class Diffuser extends BaseDomain {
_recalculate() { _recalculate() {
if (this.i_flow <= 0) { if (this.i_flow <= 0) {
this.idle = true; this.idle = true;
this.n_flow = 0; this.o_otr = 0; this.o_p_flow = 0; this.o_flow_element = 0; this.n_flow = 0; this.o_otr = 0; this.o_p_flow = 0;
this.o_flow_element = 0; this.o_flux_per_m2 = 0;
this.o_p_total = this.o_p_water; this.o_p_total = this.o_p_water;
this.o_kg = 0; this.o_kg_h = 0; this.o_kgo2_h = 0; this.o_kgo2 = 0; this.o_kg = 0; this.o_kg_h = 0; this.o_kgo2_h = 0; this.o_kgo2 = 0;
this.o_combined_eff = 0; this.o_slope = 0; this.o_combined_eff = 0; this.o_slope = 0;
@@ -141,9 +158,14 @@ class Diffuser extends BaseDomain {
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_max = otr.maxY; this.o_otr_min = otr.minY; this.o_otr_max = otr.maxY;
this.o_p_min = pressure.minY; this.o_p_max = pressure.maxY; this.o_p_min = pressure.minY; this.o_p_max = pressure.maxY;
@@ -167,13 +189,15 @@ class Diffuser extends BaseDomain {
_checkLimits(minFlow, maxFlow) { _checkLimits(minFlow, maxFlow) {
this.warning.text = []; this.warning.state = false; this.warning.text = []; this.warning.state = false;
this.alarm.text = []; this.alarm.state = false; this.alarm.text = []; this.alarm.state = false;
const f = this.o_flow_element; // Compare against the canonical flux, since pressure.minX / maxX come
// from the per-m²-membrane curve.
const f = this.o_flux_per_m2;
for (const k of ['warning', 'alarm']) { for (const k of ['warning', 'alarm']) {
const band = this[k]; const band = this[k];
const lo = minFlow - minFlow * (band.flow.min.hyst / 100); const lo = minFlow - minFlow * (band.flow.min.hyst / 100);
const hi = maxFlow + maxFlow * (band.flow.max.hyst / 100); const hi = maxFlow + maxFlow * (band.flow.max.hyst / 100);
if (f < lo) { band.state = true; band.text.push(`${_cap(k)}: flow per element ${f} is below ${Math.round(lo * 100) / 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 (f > hi) { band.state = true; band.text.push(`${_cap(k)}: flow per element ${f} exceeds ${Math.round(hi * 100) / 100}`); } if (f > hi) { band.state = true; band.text.push(`${_cap(k)}: specific flux ${f} Nm³/(h·m²) exceeds ${Math.round(hi * 100) / 100}`); }
} }
} }
@@ -203,6 +227,7 @@ class Diffuser extends BaseDomain {
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), oZoneOtr: this.getReactorOtr(this.zoneVolume),
@@ -219,11 +244,28 @@ class Diffuser extends BaseDomain {
} }
_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', type: 'ELASTOX-R', _meta: raw._meta || {},
supplier: raw._meta?.supplier || null,
type: raw._meta?.type || null,
model: raw._meta?.model || cfgModel,
units: { Nm3: { temp: 20, pressure: 1.01325, RH: 0 } }, units: { Nm3: { temp: 20, pressure: 1.01325, RH: 0 } },
otr_curve: { 2.4: { x: [2, 3, 4, 5, 6, 7, 8, 9, 10], y: [26, 25, 24, 23.5, 23, 22.75, 22.5, 22.25, 22] } }, otr_curve: raw.otr_curve,
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] } }, p_curve: raw.p_curve,
}; };
} }
} }

View File

@@ -1,3 +1,11 @@
# diffuser Test Suite Layout # diffuser Test Suite Layout
Placeholder structure (diffuser currently has no runtime module files). `test/{basic,integration,edge}/` — standard EVOLV three-tier test
structure. The diffuser runtime modules live at `src/nodeClass.js`,
`src/specificClass.js`, and `src/commands/`; they implement the OTR /
ΔP aeration model and the `data.airFlow` / `set.kla` topic handlers
documented in `CONTRACT.md`.
Helpers shared across tiers belong in `test/helpers/`.
Run: `node --test test/basic test/integration test/edge`.

View File

@@ -1,223 +1,146 @@
# diffuser # diffuser
> **Reflects code as of `15cfb22` · regenerated `2026-05-11` via `npm run wiki:all`** ![code-ref](https://img.shields.io/badge/code--ref-4973a8b-blue) ![s88](https://img.shields.io/badge/S88-Equipment_Module-86bbdd) ![status](https://img.shields.io/badge/status-pending--review-yellow)
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
## 1. What this node is A `diffuser` models a single aeration-diffuser zone &mdash; the gas-side dynamics of a fine-bubble grid sitting under a reactor's water column. Given a header pressure, water height, alpha factor, element count and incoming air flow, it normalises the air to Nm³/h, interpolates a supplier OTR curve (oxygen transfer rate vs specific flux) plus a ΔP curve, and emits oxygen-transfer power (kg O₂/h), efficiency, per-element flow, and a reactor-zone OTR. Used as a leaf Equipment Module under a `reactor` (or any aeration train).
**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`. > [!NOTE]
> Pending full node review (2026-05). Content reflects `CONTRACT.md`, `src/commands/index.js`, `src/specificClass.js` and `generalFunctions/src/configs/diffuser.json` only. Test scaffolding under `test/basic/`, `test/integration/`, `test/edge/` is still placeholder-level &mdash; a domain-test pass remains TODO.
## 2. Position in the platform ---
## At a glance
| Thing | Value |
|:---|:---|
| What it represents | One aeration-diffuser zone &mdash; the gas-side OTR + ΔP model of a fine-bubble grid |
| S88 level | Equipment Module |
| Use it when | You have a supplier OTR / ΔP curve and need oxygen-transfer + head-loss telemetry from a measured air flow |
| Don't use it for | Coarse-bubble / jet aeration without a fine-bubble curve, or when the blower already publishes OTR (you'd duplicate the calc) |
| Children it accepts | None &mdash; diffuser is a leaf |
| Parents it talks to | `reactor` (typical), or any node that consumes `child.register` from Port 2 |
---
## How it fits
```mermaid ```mermaid
flowchart LR flowchart LR
src[blower / MGC / dashboard]:::unit -->|data.flow| diff[diffuser<br/>Equipment]:::equip blower[blower / MGC /<br/>dashboard slider]:::unit -->|data.flow<br/>Nm³/h| 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 setters[dashboard setters /<br/>setup tab]:::ctrl -->|set.density<br/>set.water-height<br/>set.header-pressure<br/>set.elements<br/>set.alfa-factor| diff
diff -->|child.register| reactor[reactor<br/>Unit]:::unit diff -->|child.register<br/>positionVsParent=atEquipment| reactor[reactor / parent<br/>Unit]:::unit
diff -.->|Port 0: oOtr,<br/>oKgo2H, oZoneOtr,<br/>efficiency, slope| reactor
classDef unit fill:#50a8d9,color:#000 classDef unit fill:#50a8d9,color:#000
classDef equip fill:#86bbdd,color:#000 classDef equip fill:#86bbdd,color:#000
classDef ctrl fill:#a9daee,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`. S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`. Per the layout rule, diffuser lives on lane **L3** of the Process Plant tab, wrapped in a `#86bbdd` Node-RED group.
## 3. Capability matrix ---
| Capability | Status | Notes | ## Try it &mdash; 3-minute demo
|---|---|---|
| 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 Import the basic example flow, deploy, and drive a single diffuser zone through the OTR curve.
```mermaid ```bash
flowchart TB curl -X POST -H 'Content-Type: application/json' \
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"] --data @nodes/diffuser/examples/basic.flow.json \
nc["buildDomainConfig()<br/>static DomainClass, commands"] http://localhost:1880/flow
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… | > [!NOTE]
|---|---|---| > Example flows currently shipped: `examples/basic.flow.json`, `examples/integration.flow.json`, `examples/edge.flow.json`. The `examples/README.md` is a one-line placeholder ("Placeholder structure"); a proper per-tier README following the rotatingMachine template is TODO.
| `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. What to send after deploy (each inject maps one-to-one to a topic in [Reference &mdash; Contracts](Reference-Contracts#topic-contract)):
## 5. Topic contract 1. `set.water-height = 5` &mdash; sets a 5 m column above the diffuser. Static head pressure is recomputed; `oPLoss` updates.
2. `set.elements = 100` &mdash; declares 100 elements in this zone. Per-element flow + specific flux now scale by this denominator.
3. `set.density = 15` &mdash; bottom-coverage percentage; selects which curve family is interpolated.
4. `set.header-pressure = 600` &mdash; gauge pressure above atmospheric (mbar). Air-density correction kicks in.
5. `set.alfa-factor = 0.7` &mdash; the α correction used in the kg O₂/h calculation.
6. `data.flow = 200` &mdash; push 200 Nm³/h into the model. Watch Port 0: `oOtr`, `oKgo2H`, `oFluxPerM2`, `efficiency`, `slope` all populate. Setting `data.flow = 0` flips `idle` back to true and resets the derived outputs.
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`. > [!IMPORTANT]
> **GIF needed.** Demo recording of steps 1&ndash;6 with the live status badge. Save as `wiki/_partial-gifs/diffuser/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
<!-- BEGIN AUTOGEN: topic-contract --> ---
| Canonical topic | Aliases | Payload | Unit | Effect | ## The six things you'll send
|---|---|---|---|---|
| `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 --> | Topic | Aliases | Payload | What it does |
|:---|:---|:---|:---|
| `data.flow` | `air_flow` | `number` &mdash; Nm³/h | Pushes the measured air flow into the model. Clamped to &ge; 0. Triggers a full recompute. |
| `set.density` | `density` | `number` &mdash; bottom-coverage % | Selects the curve family used for OTR interpolation. Curves multi-keyed by coverage get linearly interpolated; single-key curves are clamped. |
| `set.water-height` | `height_water` | `number` &mdash; m | Sets the water column above the diffuser. Recomputes static head + total ΔP. Clamped to &ge; 0. |
| `set.header-pressure` | `header_pressure` | `number` &mdash; mbar gauge | Header (supply) pressure above atmospheric. Feeds air-density correction. |
| `set.elements` | `elements` | `number` &mdash; integer > 0 | Active element count. Drives per-element flow + total membrane area used for specific flux. |
| `set.alfa-factor` | `alfaFactor` | `number` (typically 0&ndash;1) | Alpha correction used in the kg O₂/h calculation. |
## 6. Child registration Aliases log a one-time deprecation warning the first time they fire. There are no query topics today (the entire state is on Port 0).
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 ## What you'll see come out
flowchart LR
diff[diffuser]:::equip -->|child.register payload=node.id<br/>positionVsParent=atEquipment| reactor[reactor / parent]:::unit Sample Port 0 message (delta-compressed, while operational with flow = 200 Nm³/h, 100 elements, water height 5 m):
classDef equip fill:#86bbdd,color:#000
classDef unit fill:#50a8d9,color:#000 ```json
{
"topic": "diffuser_1",
"payload": {
"iFlow": 200,
"iPressure": 600,
"iMWater": 5,
"nFlow": 218.42,
"oFlowElement": 2.18,
"oFluxPerM2": 12.13,
"oOtr": 16.4,
"oPLoss": 540.7,
"oKgo2H": 12.51,
"oZoneOtr": 6.0,
"efficiency": 73.2,
"slope": 0.044,
"idle": false,
"warning": [],
"alarm": []
}
}
``` ```
| Direction | Counterparty | Side-effect | > [!NOTE]
|---|---|---| > The numbers above are illustrative &mdash; the exact values depend on the loaded supplier curve (`gva-elastox-r` by default). Run the basic example flow to see real values for your asset.
| outbound at startup | upstream reactor | sends `child.register` on Port 2 with `positionVsParent` default `atEquipment` |
| inbound | — | none accepted |
## 7. Lifecycle — what one event does | Field | Meaning |
|:---|:---|
| `iFlow` / `iPressure` / `iMWater` | Echo of the current setter inputs (Nm³/h, mbar, m). |
| `nFlow` | Air flow normalised to standard conditions (Nm³/h, T=20 °C, p=1.01325 bar, RH=0). |
| `oFlowElement` | `nFlow / elements` &mdash; flow per element. |
| `oFluxPerM2` | Specific flux through the membrane (Nm³/(h·m²)) &mdash; the canonical x-axis of every supplier curve. |
| `oOtr` | Interpolated oxygen transfer rate (g O₂ / Nm³). |
| `oPLoss` | Total head loss in mbar &mdash; static head from the water column plus diffuser ΔP. |
| `oKgo2H` | kg O₂ per hour at the current operating point. Uses α-factor and water height. |
| `oZoneOtr` | Reactor-zone OTR in kg O₂ / m³ / day. Computed from `oKgo2H` against `diffuser.zoneVolume`; zero when zone volume is unset. |
| `efficiency` | Combined OTR / ΔP efficiency (0&ndash;100). High OTR + low ΔP &rarr; high score. |
| `slope` | Local OTR-vs-flux slope (g O₂/Nm³ per Nm³/(h·m²)). Useful as a "we're near the curve knee" indicator. |
| `idle` | `true` when `iFlow ≤ 0` &mdash; derived predicates only, no FSM. |
| `warning` / `alarm` | String arrays describing flow-per-element band excursions (`±2 %` warn / `±10 %` alarm hysteresis, hardcoded). |
```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) ## Not the same shape as rotatingMachine / measurement
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()` Where rotatingMachine emits per-measurement keys (`<type>.<variant>.<position>.<childId>`), diffuser uses a flat camelCase schema (`oOtr`, `oKgo2H`, `oFlowElement`, &hellip;). The diffuser does **not** currently publish typed `MeasurementContainer` series &mdash; parents must consume Port 0 directly. Promoting `oOtr` / `oZoneOtr` to typed measurement series is tracked in [Reference &mdash; Limitations](Reference-Limitations#known-limitations).
What lands on Port 0. Composed in `Diffuser.getOutput()`, then delta-compressed by `outputUtils.formatMsg`. ---
<!-- BEGIN AUTOGEN: data-model --> ## Need more?
| Key | Type | Unit | Sample | | Page | What you'll find |
|---|---|---|---| |:---|:---|
| `alarm` | array | — | `[…]` | | [Reference &mdash; Contracts](Reference-Contracts) | Full topic contract, config schema, parent registration handshake |
| `efficiency` | number | — | `0` | | [Reference &mdash; Architecture](Reference-Architecture) | Code map, OTR / ΔP pipeline, idle behaviour, output ports |
| `iFlow` | number | — | `0` | | [Reference &mdash; Examples](Reference-Examples) | Shipped example flows + debug recipes |
| `iMWater` | number | — | `0` | | [Reference &mdash; Limitations](Reference-Limitations) | When not to use, known limitations, open questions |
| `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 --> [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) &middot; [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) &middot; [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
`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"]
f1[Element count]
f2[Diffuser density]
f3[Water height]
f4[Header pressure]
f5[Alpha factor]
f6[Zone volume]
end
subgraph config["Domain config slice"]
c1[diffuser.elements]
c2[diffuser.density]
c3[diffuser.waterHeight]
c4[diffuser.headerPressure]
c5[diffuser.alfaFactor]
c6[diffuser.zoneVolume]
end
f1 --> c1
f2 --> c2
f3 --> c3
f4 --> c4
f5 --> c5
f6 --> c6
```
| Form field | Config key | Default | Range | Where used |
|---|---|---|---|---|
| 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` | 01 | oxygen-transfer correction |
| Local atm. pressure | `diffuser.localAtmPressure` | `1013.25` | > 0 (mbar) | density baseline |
| Water density | `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 |

View File

@@ -0,0 +1,224 @@
# Reference &mdash; Architecture
![code-ref](https://img.shields.io/badge/code--ref-4973a8b-blue)
> [!NOTE]
> Pending full node review (2026-05). Content reflects the source files (`src/nodeClass.js`, `src/specificClass.js`, `src/commands/index.js`, `src/commands/handlers.js`) as currently checked into the submodule. The node has runtime implementation; the placeholder note in `test/README.md` ("diffuser currently has no runtime module files") is stale &mdash; verified 2026-05-19.
---
## Three-tier code layout
```
nodes/diffuser/
|
+-- diffuser.js entry: RED.nodes.registerType('diffuser', NodeClass)
+-- diffuser.html editor form (palette colour #86bbdd, Equipment Module)
+-- diffuser_class.js legacy single-file domain shim (pre-Phase-6)
+-- graph.js supplier-curve helper used by the editor preview
|
+-- src/
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
| specificClass.js extends BaseDomain (domain logic, single file)
| |
| +-- commands/
| index.js topic registry (6 canonical topics + aliases)
| handlers.js thin pass-through to specificClass setters
|
+-- examples/ basic.flow.json, integration.flow.json, edge.flow.json
+-- test/ basic/, integration/, edge/ — scaffolding only (see test/README.md)
+-- wiki/ this directory
```
### Tier responsibilities
| Tier | File | What it owns | Touches `RED.*` |
|:---|:---|:---|:---:|
| entry | `diffuser.js` | Type registration | Yes |
| nodeClass | `src/nodeClass.js` | `buildDomainConfig(uiConfig)` &mdash; coerces editor form values into the `diffuser.*` / `asset.*` / `general.*` config slice. Event-driven: no tick loop (`static tickInterval = null`), status badge polled every second (`static statusInterval = 1000`). | Yes |
| specificClass | `src/specificClass.js` | `configure()` sets seed state, loads supplier specs via `loadCurve`, then each setter (`setFlow`, `setDensity`, `setWaterHeight`, `setHeaderPressure`, `setElementCount`, `setAlfaFactor`) calls `_recalculate()` which runs the OTR / ΔP pipeline and emits `output-changed`. | No |
`specificClass.js` is currently a single file &mdash; the node is small enough that the P6 refactor did not split it into per-concern subdirectories. If `_calcOtrPressure` + `_checkLimits` + curve loading grow past ~250 lines, extracting `curves/` and `alarms/` is the natural split.
> [!NOTE]
> `diffuser_class.js` at the repo root is a legacy domain shim kept for backward compatibility with pre-Phase-6 consumers. New code paths should target `src/specificClass.js`.
---
## OTR + ΔP pipeline
```mermaid
flowchart TB
inFlow[data.flow]:::input --> setF[setFlow]
inPress[set.header-pressure]:::input --> setP[setHeaderPressure]
inH[set.water-height]:::input --> setH[setWaterHeight]
inD[set.density]:::input --> setD[setDensity]
inE[set.elements]:::input --> setE[setElementCount]
inA[set.alfa-factor]:::input --> setA[setAlfaFactor]
setF --> recalc
setP --> recalc
setH --> recalc
setD --> recalc
setE --> recalc
setA --> recalc
recalc{{_recalculate}} -->|idle: iFlow ≤ 0| zero[reset derived outputs<br/>idle = true]
recalc -->|active: iFlow > 0| pipe
subgraph pipe[_calcOtrPressure]
airDens[_calcAirDensityMbar<br/>atm + header → kg/m³]
nFlow[normalise flow → Nm³/h]
flux[flux/m² = nFlow / totalArea]
otrI[interpolate otr_curve<br/>by coverage % at flux]
pI[interpolate p_curve<br/>at flux]
oKgO2[kg O₂/h = otr × nFlow × m_water × α]
eff[combined efficiency]
slope[local OTR/flux slope]
airDens --> nFlow --> flux --> otrI --> oKgO2 --> eff --> slope
flux --> pI --> eff
end
pipe --> limits[_checkLimits<br/>warn ±2% / alarm ±10%]
limits --> notify[notifyOutputChanged]
zero --> notify
notify --> out[Port 0 / Port 1<br/>delta-compressed getOutput()]
classDef input fill:#a9daee,color:#000
```
### Curve loading
At `configure()` startup:
1. `_loadSpecs()` reads `config.asset.model` (default `'gva-elastox-r'`).
2. `loadCurve(model)` resolves the model id against the curve registry under `generalFunctions/datasets/assetData/curves/`.
3. If the requested model is missing, falls back to `loadCurve(DEFAULT_DIFFUSER_MODEL)` &mdash; the GVA ELASTOX-R reference. This avoids crashing the constructor in production when a freshly-saved flow references an asset id that hasn't been published yet.
4. The returned struct must carry `otr_curve` and `p_curve`; missing either throws.
5. `_meta.membraneArea_m2_per_element` from the curve is the source of truth for membrane area. `diffuser.membraneAreaPerElement` overrides it; the final fallback is `0.18` m² (Jäger TD-65 / GVA placeholder).
### Curve interpolation
`_interpolateCurveByDensity(curve, density, x)` handles both single-key (one coverage) and multi-key (interpolated across coverage) curve shapes. For multi-key curves it linearly interpolates between the two bracketing coverage keys; for single-key it clamps. Within a key the curve is a 1-D linear interpolation by flux per m² of membrane.
### Idle behaviour
When `iFlow ≤ 0`:
- `idle = true`
- `n_flow`, `o_otr`, `o_p_flow`, `o_flow_element`, `o_flux_per_m2`, `o_kg`, `o_kg_h`, `o_kgo2_h`, `o_kgo2`, `o_combined_eff`, `o_slope` &rarr; reset to 0.
- `o_p_total = o_p_water` (static head only).
- Warnings + alarms cleared.
The `idle` predicate is derived, not an FSM state &mdash; the diffuser is **stateless** by design (see [Limitations](Reference-Limitations#stateless-by-design)).
### Alarm bands
`_checkLimits(minFlow, maxFlow)` compares the current specific flux (`o_flux_per_m2`) against the loaded ΔP curve's x-axis limits, widened by hysteresis:
| Band | Hysteresis | Set in |
|:---|:---|:---|
| Warning | &pm; 2 % | `configure()` (literal) |
| Alarm | &pm; 10 % | `configure()` (literal) |
Outside the band, `warning.state` / `alarm.state` flip to `true` and a human-readable line is appended to `warning.text` / `alarm.text`. Surfaced on Port 0 as the `warning` / `alarm` string arrays.
---
## Lifecycle &mdash; what one event does
```mermaid
sequenceDiagram
autonumber
participant src as upstream (blower / dashboard)
participant nc as nodeClass (BaseNodeAdapter)
participant cmd as commands registry
participant dom as Diffuser (specificClass)
participant curve as supplier specs
participant out as Port 0 / 1
src->>nc: msg{topic:'data.flow', payload:200}
nc->>cmd: dispatch by topic
cmd->>dom: setFlow(200)
dom->>dom: i_flow = 200; _recalculate()
alt iFlow ≤ 0
dom->>dom: idle = true; zero derived outputs
else iFlow > 0
dom->>dom: _calcOtrPressure(flow)
dom->>curve: interpolate otr_curve(density, flux/m²)
dom->>curve: interpolate p_curve(0, flux/m²)
dom->>dom: kg O₂/h, efficiency, slope
dom->>dom: _checkLimits(minX, maxX)
end
dom->>nc: emit 'output-changed'
nc->>out: formatMsg(getOutput()) → Port 0 + Port 1
```
No tick loop, no scheduled work. Every recompute is the synchronous result of an input setter.
---
## Output ports
| Port | Carries | Sample shape |
|:---|:---|:---|
| 0 (process) | Delta-compressed state snapshot from `getOutput()` &mdash; flow echo, OTR, ΔP, kg O₂/h, efficiency, slope, warn/alarm strings | `{topic: 'diffuser_N', payload: {iFlow, nFlow, oOtr, oPLoss, oKgo2H, oFluxPerM2, efficiency, slope, oZoneOtr, idle, warning, alarm}}` |
| 1 (telemetry) | Same fields as Port 0, formatted with the `'influxdb'` formatter | InfluxDB line protocol |
| 2 (registration) | One `child.register` upward at startup | `{topic: 'child.register', payload: <node.id>, positionVsParent: 'atEquipment', distance}` |
### Pre-refactor port-count change (Phase 6)
Before Phase 6 the diffuser exposed **four** outputs: process, dbase, a dedicated reactor-control message with `topic: 'OTR'`, and parent registration. The reactor-control message was merged into Port 0 as `oZoneOtr`; consumers reading the dedicated control port must migrate to `payload.oZoneOtr`. No alias is provided &mdash; the shape differs (single value vs full process payload). See [Limitations](Reference-Limitations#migration-notes).
---
## Status badge
`getStatusBadge()` in `specificClass.js`:
| Condition | Symbol / colour | Text |
|:---|:---|:---|
| `alarm.state` | red dot | first alarm message |
| `warning.state` | yellow dot (`⚠`) | first warning message |
| `idle` (no alarm/warn) | grey dot | `<oKgo2H> kg o2 / h` |
| active (no alarm/warn) | green dot (`🟢`) | `<oKgo2H> kg o2 / h` |
`getStatus()` is the legacy shape kept for backward compatibility with the pre-Phase-6 test suite.
---
## Event sources
| Source | Where it fires | What it triggers |
|:---|:---|:---|
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch to the matching setter handler |
| `source.emitter` `'output-changed'` | `notifyOutputChanged()` at the end of every `_recalculate()` | `BaseNodeAdapter` pushes Port 0 + Port 1 deltas |
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
No `setInterval` on the domain itself. No `MeasurementContainer.emitter` subscribers either &mdash; the diffuser has no children.
---
## Where to start reading
| If you're changing... | Read first |
|:---|:---|
| Topic naming, alias deprecation | `src/commands/index.js` + `src/commands/handlers.js` |
| Editor form &harr; domain config mapping | `src/nodeClass.js` `buildDomainConfig` |
| OTR / ΔP math, curve interpolation, normalisation | `src/specificClass.js` `_calcOtrPressure`, `_interpolateCurveByDensity` |
| Alarm bands + hysteresis | `src/specificClass.js` `_checkLimits` + `configure()` literals |
| Output shape, status badge | `src/specificClass.js` `getOutput`, `getStatusBadge` |
| Curve loading + fallback | `src/specificClass.js` `_loadSpecs` |
| Schema defaults | `generalFunctions/src/configs/diffuser.json` |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child registration |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [reactor wiki](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Home) | The typical parent of a diffuser |
| [EVOLV &mdash; Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |

178
wiki/Reference-Contracts.md Normal file
View File

@@ -0,0 +1,178 @@
# Reference &mdash; Contracts
![code-ref](https://img.shields.io/badge/code--ref-4973a8b-blue)
> [!NOTE]
> Pending full node review (2026-05). Topic contract, output shape and configuration schema for `diffuser`. Sources of truth: `src/commands/index.js`, `src/specificClass.js` `getOutput()`, and `generalFunctions/src/configs/diffuser.json`.
>
> For an intuitive overview, return to the [Home](Home).
---
## Topic contract
The registry lives in `src/commands/index.js`. Each descriptor maps a canonical `msg.topic` to its handler in `src/commands/handlers.js`; aliases emit a one-time deprecation warning the first time they fire.
<!-- 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 -->
There are **no query topics** today (no `query.curves` / `query.cog` analogue). The full state is on Port 0 every time a setter fires.
There are **no mode / source allow-lists** &mdash; the diffuser has no operational modes (auto / virtualControl / fysicalControl). Every input topic is accepted from every source.
---
## Data model &mdash; `getOutput()` shape
Composed in `Diffuser.getOutput()` then delta-compressed by `outputUtils.formatMsg('process')`. Consumers see only the keys that changed since the last emit.
### Scalar keys
<!-- BEGIN AUTOGEN: data-model — populate via wiki-gen tool (TODO) -->
| Key | Type | Unit | Source | Notes |
|:---|:---|:---|:---|:---|
| `iPressure` | number | mbar (gauge) | `this.i_pressure` | Echo of last `set.header-pressure`. |
| `iMWater` | number | m | `this.i_m_water` | Echo of last `set.water-height`. |
| `iFlow` | number | Nm³/h (config-defaulted) | `this.i_flow` | Echo of last `data.flow`. |
| `nFlow` | number | Nm³/h | derived | Normalised air flow at standard conditions (T=20 °C, p=1.01325 bar, RH=0). Rounded 2 dp. |
| `oOtr` | number | g O₂ / Nm³ | curve interpolation | Oxygen transfer rate at current density + flux. Rounded 2 dp. |
| `oPLoss` | number | mbar | `o_p_water + o_p_flow` | Total head loss: static head from water column + diffuser ΔP. Rounded 2 dp. |
| `oKgo2H` | number | kg O₂ / h | derived | Mass-rate of oxygen transfer. Uses α-factor and water height. |
| `oFlowElement` | number | Nm³/h per element | `nFlow / elements` | Per-element air flow. Rounded 2 dp. |
| `oFluxPerM2` | number | Nm³ / (h · m² membrane) | `nFlow / totalMembraneArea` | Canonical curve x-axis. Rounded 2 dp. |
| `efficiency` | number | % (0&ndash;100) | `_combineEff` | Combined OTR / ΔP score: high OTR + low ΔP &rarr; high score. Rounded 2 dp. |
| `slope` | number | g O₂/Nm³ per Nm³/(h·m²) | curve segment | Local OTR-vs-flux slope at the operating point. Rounded 3 dp. |
| `oZoneOtr` | number | kg O₂ / m³ / day | `getReactorOtr(zoneVolume)` | Reactor-zone OTR. Zero when `diffuser.zoneVolume` is unset or non-positive. |
| `idle` | boolean | &mdash; | `this.i_flow ≤ 0` | Derived predicate, not an FSM state. |
| `warning` | array of strings | &mdash; | `this.warning.text` | Flow-per-element band excursions at &pm; 2 % hysteresis. |
| `alarm` | array of strings | &mdash; | `this.alarm.text` | Flow-per-element band excursions at &pm; 10 % hysteresis. |
<!-- END AUTOGEN: data-model -->
### Per-measurement keys
> [!NOTE]
> The diffuser does **not** emit typed `MeasurementContainer` keys. There is no `<type>.<variant>.<position>.<childId>` shape on this node. Parents that want OTR / ΔP via the standard `ChildRouter` handshake have to wait for the future phase that promotes `oOtr` / `oZoneOtr` to typed series &mdash; tracked in [Limitations](Reference-Limitations).
### Status badge
`getStatusBadge()` in `specificClass.js`:
| Condition | Symbol | Fill | Text |
|:---|:---:|:---|:---|
| `alarm.state` | (error compose) | red | first entry of `alarm.text` |
| `warning.state` | ⚠ | yellow | first entry of `warning.text` |
| `idle` (no alarm/warn) | (idle compose) | grey | `<oKgo2H> kg o2 / h` |
| active (no alarm/warn) | 🟢 | green (default) | `<oKgo2H> kg o2 / h` |
---
## Configuration schema &mdash; editor form to config keys
Source of truth: `generalFunctions/src/configs/diffuser.json` + `src/nodeClass.js` `buildDomainConfig`.
### General (`config.general`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Name | `general.name` | `"Diffuser"` | Human-readable label. |
| (auto-assigned) | `general.id` | `null` | Node-RED node id. |
| Default unit | `general.unit` | `"Nm3/h"` | Default airflow unit. |
| Enable logging | `general.logging.enabled` | `true` | Master switch. |
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
### Asset (`config.asset`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Asset model | `asset.model` | `"gva-elastox-r"` | Curve registry id. Resolved via `loadCurve(model)`. Falls back to the default on miss. |
| Asset tag number | `asset.assetTagNumber` | `""` | External asset registry tag (Bedrijfsmiddelenregister). |
### Functionality (`config.functionality`)
| Form field | Config key | Default | Notes |
|:---|:---|:---|:---|
| Software type | `functionality.softwareType` | `"diffuser"` | Constant. Used in the parent-register handshake. |
| Role | `functionality.role` | `"Aeration diffuser"` | Free-text role label. |
| Position vs parent | `functionality.positionVsParent` | `"atEquipment"` | One of `upstream` / `atEquipment` / `downstream`. Carried on the `child.register` Port-2 message to the reactor. |
### Diffuser (`config.diffuser`)
| Form field | Config key | Default | Range | Notes |
|:---|:---|:---|:---|:---|
| Zone number | `diffuser.number` | `1` | int &ge; 1 | Sequential zone number; used in the node label. |
| Element count | `diffuser.elements` | `1` | int &ge; 1 | Number of active diffuser elements. |
| Membrane area / element | `diffuser.membraneAreaPerElement` | `null` | m² > 0 | Overrides curve `_meta.membraneArea_m2_per_element`. Final fallback is 0.18 m² (Jäger TD-65 / GVA). |
| Diffuser density (bottom coverage) | `diffuser.density` | `15` | % > 0, typical 10&ndash;25 | Curve-family key. Multi-coverage curves are interpolated; single-coverage curves are clamped. Replaces the legacy "elements per m²" semantics &mdash; an earlier refactor mislabelled this column. |
| Water height | `diffuser.waterHeight` | `0` | m &ge; 0 | Static head + kg O₂/h factor. |
| Alpha factor | `diffuser.alfaFactor` | `0.7` | typically 0&ndash;1 | Oxygen-transfer correction. |
| Header pressure | `diffuser.headerPressure` | `0` | mbar &ge; 0 (gauge) | Above atmospheric. Feeds air-density correction. |
| Local atmospheric pressure | `diffuser.localAtmPressure` | `1013.25` | mbar > 0 | Density baseline (hidden by default). |
| Water density | `diffuser.waterDensity` | `997` | kg/m³ > 0 | Static head calculation (hidden by default). |
| Zone volume | `diffuser.zoneVolume` | `0` | m³ &ge; 0 | Aeration zone volume. When > 0, populates `oZoneOtr` (kg O₂ / m³ / day). |
### Unit policy
The diffuser uses a non-canonical, supplier-curve-friendly unit policy &mdash; airflow lives in **Nm³/h** (not m³/s) on the wire, and pressure stays in **mbar** (not Pa) at every boundary. Internal arithmetic converts mbar &harr; Pa where needed (`_heightToPressureMbar`, `_calcAirDensityMbar`).
| Quantity | Boundary unit | Internal | Notes |
|:---|:---|:---|:---|
| Air flow | `Nm3/h` | `Nm3/h` | Normalised internally; curves are in this unit. |
| Header pressure | `mbar` (gauge) | `Pa` (intermediate) | Converted in `_calcAirDensityMbar`. |
| Atmospheric pressure | `mbar` | `Pa` (intermediate) | Same. |
| Water height | `m` | `m` | Converted to `mbar` head via `_heightToPressureMbar`. |
| Membrane area | `m² / element` | `m² / element` | Curve metadata or config override. |
| Temperature | hardcoded 20 °C | `K` (internal in `_calcAirDensityMbar`) | No temperature input topic today. |
This deliberately diverges from rotatingMachine's canonical-Pa/m³·s⁻¹/W/K policy because the supplier curves and operator-facing dashboards are all in Nm³/h + mbar. The cost is reduced reuse with `MeasurementContainer` (see [Limitations](Reference-Limitations)).
---
## Child registration
The diffuser is a **leaf node** &mdash; it accepts no children. Itself, it registers with the upstream parent (typically a `reactor`) at startup via the Port-2 handshake.
```mermaid
flowchart LR
diff[diffuser]:::equip -->|child.register<br/>payload = node.id<br/>positionVsParent = atEquipment<br/>distance| 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 (Port 2) | sends `child.register` with `positionVsParent` default `atEquipment` and the configured `distance` |
| inbound | &mdash; | none accepted |
---
## Events emitted
| Emitter | Event | When |
|:---|:---|:---|
| `source.emitter` | `'output-changed'` | At the end of every `_recalculate()` &mdash; i.e. on every input setter. `BaseNodeAdapter` listens and pushes delta-compressed Port 0 + Port 1 messages. |
| `source.measurements.emitter` | (none) | The diffuser does not currently publish typed `MeasurementContainer` series. Future phase. |
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, OTR / ΔP pipeline, output ports |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [EVOLV &mdash; Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
| [EVOLV &mdash; Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |

150
wiki/Reference-Examples.md Normal file
View File

@@ -0,0 +1,150 @@
# Reference &mdash; Examples
![code-ref](https://img.shields.io/badge/code--ref-4973a8b-blue)
> [!NOTE]
> Pending full node review (2026-05). Example flows under `nodes/diffuser/examples/` are at the **legacy** `basic` / `integration` / `edge` filename tier &mdash; they predate the rotatingMachine `01 - …` numbered convention and have not yet been re-validated against the current `src/commands/index.js` topic registry. `examples/README.md` is a one-line placeholder.
---
## Shipped examples
| File | Tier | Dependencies | What it shows |
|:---|:---:|:---|:---|
| `examples/basic.flow.json` | 1 (legacy) | EVOLV only | Single-diffuser flow driven by inject buttons &mdash; setter topics for flow, density, water height, header pressure, elements, alpha factor. Debug taps on the process port. |
| `examples/integration.flow.json` | 2 (legacy) | EVOLV (+ `reactor` if you wire one) | Diffuser auto-registers with a parent (Port 2 `child.register`). |
| `examples/edge.flow.json` | 3 (legacy) | EVOLV only | Edge cases: flow = 0 idle reset, very large flow (alarm band), missing curve fallback to default. |
> [!IMPORTANT]
> **TODO &mdash; upgrade to numbered tier set.** Mirror rotatingMachine's convention:
>
> | New file | Tier | Replaces |
> |:---|:---:|:---|
> | `01 - Basic Manual Control.json` | 1 | `basic.flow.json` |
> | `02 - Integration with Reactor.json` | 2 | `integration.flow.json` |
> | `03 - Dashboard Visualization.json` | 3 | (new, requires `@flowfuse/node-red-dashboard`) |
>
> Keep the legacy files until the new tier set has been Docker-validated. Then update `examples/README.md` to per-tier descriptions.
---
## Loading a flow
### Via the editor
1. Open the Node-RED editor at `http://localhost:1880`.
2. Menu &rarr; Import &rarr; drag the JSON file.
3. Click Deploy.
### Via the Admin API
```bash
curl -X POST -H 'Content-Type: application/json' \
--data @nodes/diffuser/examples/basic.flow.json \
http://localhost:1880/flow
```
(Use `/flows` to bulk-deploy all tabs.)
---
## Example &mdash; Basic Manual Control (legacy `basic.flow.json`)
Single-diffuser flow with one inject per setter topic.
### What to do after deploy
1. Click `set.water-height = 5` &mdash; 5 m column above the diffuser. `oPLoss` updates to reflect static head.
2. Click `set.elements = 100` &mdash; 100 elements. `oFlowElement` and `oFluxPerM2` denominators update.
3. Click `set.density = 15` &mdash; 15 % bottom coverage, the default curve key.
4. Click `set.header-pressure = 600` &mdash; 600 mbar gauge. Air-density correction kicks in; `nFlow` shifts from `iFlow`.
5. Click `set.alfa-factor = 0.7` &mdash; the α correction. Influences `oKgo2H` only.
6. Click `data.flow = 200` &mdash; the model engages. Watch Port 0 in the debug pane: `oOtr`, `oKgo2H`, `oFluxPerM2`, `efficiency`, `slope` all populate; `idle` flips from `true` to `false`.
7. Click `data.flow = 0` &mdash; `idle` flips back; derived outputs reset to zero. `oPLoss` falls to just the static head.
> [!IMPORTANT]
> **GIF needed.** Demo recording of steps 1&ndash;7 with the status badge progression. Save as `wiki/_partial-gifs/diffuser/01-basic-demo.gif`, target &le; 1&nbsp;MB after `gifsicle -O3 --lossy=80`.
### Try the alarm band
After steps 1&ndash;6 above the diffuser is at `oFluxPerM2 ≈ 12 Nm³/(h·m²)`. Push it past the curve's `maxX`:
1. Set `set.elements = 10` (one tenth of the previous).
2. With the same `data.flow = 200`, `oFluxPerM2` jumps roughly tenfold. The `_checkLimits` band fires; `alarm` becomes a non-empty array and the status badge turns red.
3. Restore `set.elements = 100` &mdash; alarm clears.
---
## Example &mdash; Integration with Reactor (legacy `integration.flow.json`)
> [!IMPORTANT]
> **Screenshot needed.** Editor capture of `integration.flow.json`. Save as `wiki/_partial-screenshots/diffuser/02-integration.png`.
A diffuser wired to a parent reactor. Demonstrates:
- Auto-registration via Port 2 at deploy (the diffuser emits `child.register` with `positionVsParent='atEquipment'`).
- Reactor consumes `oZoneOtr` from the diffuser's Port 0 (legacy reactor-control port has been merged into Port 0 in Phase 6).
> [!NOTE]
> The reactor's side of this contract has not been verified against the latest reactor submodule in this audit. Reactor wiki: https://gitea.wbd-rd.nl/RnD/reactor/wiki/Home
---
## Example &mdash; Edge Cases (legacy `edge.flow.json`)
Drives the diffuser through:
- `data.flow = 0` &rarr; idle reset path.
- Very large `data.flow` &rarr; alarm band on `oFluxPerM2`.
- Asset model id pointing at an unknown curve &rarr; fallback to `gva-elastox-r` (no constructor crash).
---
## Docker compose snippet
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
```yaml
# docker-compose.yml (extract)
services:
nodered:
build: ./docker/nodered
ports: ['1880:1880']
volumes:
- ./docker/nodered/data:/data/evolv
influxdb:
image: influxdb:2.7
ports: ['8086:8086']
```
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
---
## Debug recipes
| Symptom | First thing to check | Where to look |
|:---|:---|:---|
| `oOtr` stays at `0` | `iFlow` is zero or negative &rarr; `idle = true`. Push a positive value via `data.flow`. | `_recalculate` early-return path. |
| `oZoneOtr` is `0` even though `oKgo2H > 0` | `diffuser.zoneVolume` is unset or non-positive. Set it in the editor or via config. | `getReactorOtr(zoneVolumeM3)`. |
| `warning` / `alarm` always firing | `oFluxPerM2` is outside the loaded curve's `[minX, maxX]` band ± hysteresis. Either the flow is wildly off the asset's design point or the wrong curve is loaded. | `_checkLimits`, `_loadSpecs`. |
| `nFlow` does not equal `iFlow` | Air-density correction. Header pressure and atmospheric pressure deviate from the standard baseline (T=20 °C, p=1.01325 bar, RH=0). | `_calcAirDensityMbar`. |
| `efficiency` is flat at `0` | OTR or ΔP curve span is zero in the operating band. Inspect the supplier curve. | `_combineEff`. |
| Constructor throws `curve '…' is missing otr_curve/p_curve` | The `asset.model` field references a curve id that exists in the registry but lacks one of the required curves. Pick a complete model in the editor. | `_loadSpecs`. |
| Editor palette colour wrong | `diffuser.html` already declares `color: '#86bbdd'` (Equipment Module). If a stale palette shows otherwise, hard-refresh the editor or check the Node-RED version. | `diffuser.html`. |
| Alias warns spammed every tick | A consumer is sending the legacy alias on every tick instead of the canonical topic. Aliases warn **once** per deploy &mdash; if you're seeing repeats, something is re-creating the node. | `src/commands/index.js` alias list. |
> Never ship `enableLog: 'debug'` in a demo &mdash; fills the container log within seconds and obscures real errors.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child registration |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, OTR / ΔP pipeline |
| [Reference &mdash; Limitations](Reference-Limitations) | Known issues and open questions |
| [reactor &mdash; Examples](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Reference-Examples) | Parent-side example flows |
| [EVOLV &mdash; Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where diffuser fits in a larger plant |

View File

@@ -0,0 +1,127 @@
# Reference &mdash; Limitations
![code-ref](https://img.shields.io/badge/code--ref-4973a8b-blue)
> [!NOTE]
> Pending full node review (2026-05). What `diffuser` does not do, current rough edges, and open questions. Some items below are auditor inferences from CONTRACT.md and the source &mdash; flagged where uncertain. Open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the superproject.
---
## When you would not use this node
| Scenario | Use instead |
|:---|:---|
| Coarse-bubble / jet / surface aeration without a fine-bubble OTR curve | Model OTR externally and feed the reactor's α-OTR endpoint directly. |
| The blower already publishes oxygen-transfer telemetry | Don't duplicate &mdash; pipe the blower's `oKgo2H` straight into the reactor. |
| You only need flow-per-element warning bands without OTR | A `measurement` node with bands is lighter and has typed series. |
| A diffuser zone with multiple distinct membrane types | Instantiate one diffuser node per type and sum the `oKgo2H` upstream. |
| A non-fine-bubble curve at non-standard membrane area | Configure `diffuser.membraneAreaPerElement` to override the curve metadata. |
---
## Known limitations
### Stateless by design &mdash; no FSM
The diffuser has no state machine. The `idle` flag is a derived predicate (`i_flow ≤ 0`), not a state. There is no startup / warmup / cooldown / e-stop concept &mdash; the model is purely algebraic. The blower upstream owns those concerns.
If you need a "diffuser is offline / under maintenance" indicator, today that has to be carried by the parent or by a separate metadata path. Open question whether a future revision should introduce a soft `mode` field for dashboards.
### No typed `MeasurementContainer` emission
All output flows via `getOutput()` and the delta-compressed Port 0 message. The diffuser does not publish typed measurement series (`otr.measured.atequipment.<id>`, etc.) through `MeasurementContainer.emitter`. Parents that want OTR via the standard `ChildRouter` handshake must wait for the future phase that promotes `oOtr` / `oZoneOtr` to typed series. Tracked in `CONTRACT.md` `## Events emitted by source.measurements.emitter`.
### No mode / source allow-lists
Unlike rotatingMachine and pumpingStation, the diffuser accepts every input topic from every source. There is no `auto` / `virtualControl` / `fysicalControl` distinction. Open question whether this matters in practice &mdash; the diffuser has no "physical" path today (no real OTR sensor wired in).
### Alarm hysteresis is hardcoded
`warning.flow.min.hyst = 2` and `alarm.flow.min.hyst = 10` are literals in `configure()` (specificClass.js). They are not config-driven. If a particular installation has wider acceptable bands, you currently have to edit the source. Tracked.
### Asset model fallback can mask configuration drift
`_loadSpecs()` falls back to `DEFAULT_DIFFUSER_MODEL` (`gva-elastox-r`) when the configured model is missing from the registry. This is a deliberate availability-first choice (don't crash the constructor in production) &mdash; but it means a typo or stale asset id silently runs against the GVA curve instead of erroring. The constructor logs the fallback; consumers should monitor logs at deploy.
### `data.flow` clamps silently
`setFlow(v)` clamps to `Math.max(0, ...)`. Negative payloads land as zero with no warn. This mostly does the right thing (negative airflow is nonsensical) but a payload bug upstream is invisible. Open question whether to log at warn on clamp.
### No typed `MeasurementContainer` consumption
The diffuser is a leaf node and accepts no children. There is no path for a real OTR sensor (a hypothetical dissolved-oxygen probe) to feed into this node and refine the curve-based prediction. The whole pipeline is open-loop. Tracked as a future scope item.
### Hidden config defaults
`diffuser.localAtmPressure` (1013.25 mbar) and `diffuser.waterDensity` (997 kg/m³) are config keys but typically not exposed in the editor form. If your plant runs at significant altitude or the process water has unusual density, you have to override them via the raw config or a Setup-tab inject. Cosmetic; tracked.
### Pre-Phase-6 port-count migration
Pre-refactor the diffuser exposed **four** outputs: process, dbase, a dedicated reactor-control message with `topic: 'OTR'`, and 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 &mdash; the shape differs (single value vs full process payload). See [Migration notes](#migration-notes).
### Test scaffolding is placeholder
`test/basic/`, `test/integration/`, `test/edge/` each contain a single `structure-…test.js` placeholder file. `test/README.md` still says "Placeholder structure (diffuser currently has no runtime module files)" &mdash; the README is **stale**; the runtime modules exist (`src/specificClass.js`, `src/nodeClass.js`, `src/commands/*.js`). A real domain-test pass against the supplier curves is TODO.
---
## Open questions (tracked)
| Question | Where it lives |
|:---|:---|
| Should `oOtr` / `oZoneOtr` be promoted to typed `MeasurementContainer` series so parents can subscribe via `ChildRouter`? | Internal &mdash; future phase |
| Should alarm hysteresis (2 % / 10 %) be config-driven? | Internal |
| Should `setFlow(v)` log a warn on negative clamp? | Internal |
| Should the editor expose `localAtmPressure` / `waterDensity` for altitude / non-water cases? | Internal |
| Should a soft `mode` field exist for "offline / maintenance" dashboards even without an FSM? | Internal |
| Should the curve-fallback path warn at deploy AND every N ticks for a configurable window? | Internal |
| Refresh `test/README.md` &mdash; it incorrectly claims the node has no runtime modules | Internal &mdash; stale doc |
| Refresh `examples/README.md` &mdash; it is a one-line "Placeholder structure" | Internal &mdash; stale doc |
---
## Migration notes
### From pre-Phase-6 four-port output
Pre-Phase-6 the diffuser emitted on four ports:
| Port (old) | Topic | Replacement |
|:---:|:---|:---|
| 0 | process | unchanged |
| 1 | dbase | unchanged |
| 2 | `OTR` (single value, reactor control) | **merged into Port 0 as `payload.oZoneOtr`**. No alias provided. |
| 3 | parent registration | now Port 2 |
If you have a downstream `function` or `link out` that listens for `msg.topic === 'OTR'` on the legacy port-2, switch it to reading `payload.oZoneOtr` from the Port-0 process message. The shape differs (single number vs full process payload) so the migration is not mechanical &mdash; you must update the downstream node's payload-extraction logic too.
### From legacy alias topics
Aliases are accepted but log a one-time deprecation warning the first time they fire:
| Alias (deprecated) | Canonical |
|:---|:---|
| `air_flow` | `data.flow` |
| `density` | `set.density` |
| `height_water` | `set.water-height` |
| `header_pressure` | `set.header-pressure` |
| `elements` | `set.elements` |
| `alfaFactor` | `set.alfa-factor` |
There is no deprecation-removal date set. Migrate flows opportunistically.
### From `diffuser.density` semantics drift
An earlier refactor mis-tagged `diffuser.density` as "elements per m²". The current semantics &mdash; reflected in the schema description and the curve files &mdash; is **bottom-coverage percentage** (the fraction of tank floor occupied by membrane). Typical fine-bubble installs run 10&ndash;25 %. If a saved flow still carries the legacy elements-per-m² value (typically 2.4), update it to the bottom-coverage % the asset is actually installed at. The default has been updated to `15`.
---
## Related pages
| Page | Why |
|:---|:---|
| [Home](Home) | Intuitive overview |
| [Reference &mdash; Contracts](Reference-Contracts) | Topic + config + child registration |
| [Reference &mdash; Architecture](Reference-Architecture) | Code map, OTR / ΔP pipeline, output ports |
| [Reference &mdash; Examples](Reference-Examples) | Shipped flows + debug recipes |
| [reactor &mdash; Limitations](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Reference-Limitations) | Where the typical parent currently consumes diffuser output |

19
wiki/_Sidebar.md Normal file
View File

@@ -0,0 +1,19 @@
### diffuser
- [Home](Home)
**Reference**
- [Contracts](Reference-Contracts)
- [Architecture](Reference-Architecture)
- [Examples](Reference-Examples)
- [Limitations](Reference-Limitations)
**Related**
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
- [reactor wiki](https://gitea.wbd-rd.nl/RnD/reactor/wiki/Home)
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)