Compare commits

...

1 Commits

Author SHA1 Message Date
znetsixe
0ec9dd15a7 P6: convert diffuser to BaseDomain + BaseNodeAdapter + concern split
Refactor of diffuser to use the platform infrastructure (BaseDomain, BaseNodeAdapter,
ChildRouter, commandRegistry, statusBadge). Extracts concerns into
focused modules per .claude/refactor/MODULE_SPLIT.md generic template.
Tests stay green; CONTRACT.md generated; legacy aliases preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:09:26 +02:00
6 changed files with 295 additions and 373 deletions

71
CONTRACT.md Normal file
View 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 (0100).
- `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.

View File

@@ -16,9 +16,9 @@ RED.nodes.registerType('diffuser', {
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'; return this.name ? `${this.name}_${this.number}` : 'diffuser';

13
src/commands/handlers.js Normal file
View 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);

47
src/commands/index.js Normal file
View File

@@ -0,0 +1,47 @@
'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' },
handler: handlers.setFlow,
},
{
topic: 'set.density',
aliases: ['density'],
payloadSchema: { type: 'number' },
handler: handlers.setDensity,
},
{
topic: 'set.water-height',
aliases: ['height_water'],
payloadSchema: { type: 'number' },
handler: handlers.setWaterHeight,
},
{
topic: 'set.header-pressure',
aliases: ['header_pressure'],
payloadSchema: { type: 'number' },
handler: handlers.setHeaderPressure,
},
{
topic: 'set.elements',
aliases: ['elements'],
payloadSchema: { type: 'number' },
handler: handlers.setElements,
},
{
topic: 'set.alfa-factor',
aliases: ['alfaFactor'],
payloadSchema: { type: 'number' },
handler: handlers.setAlfaFactor,
},
];

View File

@@ -1,140 +1,32 @@
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; return {
this.config = {
general: {
name: resolvedName,
id: this.node.id,
unit: uiConfig.unit || 'kg o2/h',
logging: {
enabled: uiConfig.enableLog,
logLevel: uiConfig.logLevel,
},
},
functionality: {
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
softwareType: this.name,
role: 'aeration diffuser',
},
diffuser: { 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, 2.4),
waterHeight: Number(uiConfig.i_m_water) || 0, waterHeight: n(uiConfig.i_m_water, 0),
alfaFactor: Number(uiConfig.alfaf ?? 0.7) || 0.7, alfaFactor: n(uiConfig.alfaf, 0.7),
headerPressure: Number(uiConfig.i_pressure) || 0, headerPressure: n(uiConfig.i_pressure, 0),
localAtmPressure: Number(uiConfig.i_local_atm_pressure) || 1013.25, localAtmPressure: n(uiConfig.i_local_atm_pressure, 1013.25),
waterDensity: Number(uiConfig.i_water_density) || 997, waterDensity: n(uiConfig.i_water_density, 997),
zoneVolume: Number(uiConfig.i_zone_volume) || 0, 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();
});
} }
} }

View File

@@ -1,293 +1,196 @@
const { logger, interpolation, gravity, convert } = require('generalFunctions'); 'use strict';
class Diffuser { const { BaseDomain, statusBadge, interpolation, gravity, convert } = 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,
);
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, 2.4);
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); 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_otr_min = 0; this.o_otr_max = 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;
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_flow_element = 0;
this.o_otr = 0;
this.o_p_flow = 0;
this.o_flow_element = 0;
this.o_p_total = this.o_p_water; this.o_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;
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_flow_element);
const pressure = this.interpolateCurveByDensity(this.specs.p_curve, 0, this.o_flow_element); const pressure = this._interpolateCurveByDensity(this.specs.p_curve, 0, this.o_flow_element);
this.o_otr_min = otr.minY; this.o_otr_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); const f = this.o_flow_element;
const maxHyst = maxFlow * (this.warning.flow.max.hyst / 100); for (const k of ['warning', 'alarm']) {
const band = this[k];
if (this.o_flow_element < minFlow - minHyst) { const lo = minFlow - minFlow * (band.flow.min.hyst / 100);
this.warning.state = true; const hi = maxFlow + maxFlow * (band.flow.max.hyst / 100);
this.warning.text.push(`Warning: flow per element ${this.o_flow_element} is below ${Math.round((minFlow - minHyst) * 100) / 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 > hi) { band.state = true; band.text.push(`${_cap(k)}: flow per element ${f} exceeds ${Math.round(hi * 100) / 100}`); }
if (this.o_flow_element > maxFlow + maxHyst) {
this.warning.state = true;
this.warning.text.push(`Warning: flow per element ${this.o_flow_element} exceeds ${Math.round((maxFlow + maxHyst) * 100) / 100}`);
} }
} }
alarmCheck(minFlow, maxFlow) { // 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() {
@@ -302,6 +205,7 @@ class Diffuser {
oFlowElement: this.o_flow_element, oFlowElement: this.o_flow_element,
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 +213,29 @@ 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() {
return { return {
supplier: 'GVA', supplier: 'GVA', type: 'ELASTOX-R',
type: 'ELASTOX-R', units: { Nm3: { temp: 20, pressure: 1.01325, RH: 0 } },
units: { 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] } },
Nm3: { temp: 20, pressure: 1.01325, RH: 0 }, 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] } },
},
otr_curve: {
2.4: {
x: [2, 3, 4, 5, 6, 7, 8, 9, 10],
y: [26, 25, 24, 23.5, 23, 22.75, 22.5, 22.25, 22],
},
},
p_curve: {
0: {
x: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
y: [40, 42.5, 45, 47.5, 50, 51.5, 53, 54.5, 56, 57.5, 59],
},
},
}; };
} }
} }
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;