diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..5d4c0e4 --- /dev/null +++ b/CONTRACT.md @@ -0,0 +1,71 @@ +# diffuser — Contract + +Hand-maintained for Phase 6; the `## Inputs` table is generated from +`src/commands/index.js` (see Phase 9 generator). Keep ≤ 100 lines. + +## Inputs (msg.topic on Port 0) + +| Canonical | Aliases (deprecated) | Payload | Effect | +|---|---|---|---| +| `data.flow` | `air_flow` | `number` — airflow in Nm³/h | Calls `source.setFlow(payload)`; clamps to ≥ 0 and recomputes OTR. | +| `set.density` | `density` | `number` — diffuser density (per m²) | Calls `source.setDensity(payload)` and recomputes. | +| `set.water-height` | `height_water` | `number` — water column height in m | Calls `source.setWaterHeight(payload)`; clamps to ≥ 0 and recomputes head + total pressure. | +| `set.header-pressure` | `header_pressure` | `number` — header gauge pressure in mbar | Calls `source.setHeaderPressure(payload)` and recomputes. | +| `set.elements` | `elements` | `number` — element count (rounded; must be > 0) | Calls `source.setElementCount(payload)` and recomputes per-element flow. | +| `set.alfa-factor` | `alfaFactor` | `number` — alpha correction (≥ 0) | Calls `source.setAlfaFactor(payload)` and recomputes oxygen output. | + +Aliases log a one-time deprecation warning the first time they fire. + +## Outputs (msg.topic on Port 0/1/2) + +- **Port 0 (process):** `msg.topic = config.general.name`. Payload built + by `outputUtils.formatMsg(..., 'process')` from `getOutput()` — + delta-compressed (only changed fields are emitted). Fields: + - `iPressure`, `iMWater`, `iFlow` — echoed inputs. + - `nFlow` — normalised airflow (Nm³/h). + - `oOtr` — interpolated oxygen transfer rate (g O₂ / Nm³). + - `oPLoss` — total head loss (mbar) = static head + diffuser ΔP. + - `oKgo2H` — kg O₂ per hour at current operating point. + - `oFlowElement` — flow per element (Nm³/h/element). + - `efficiency` — combined OTR/ΔP efficiency (0–100). + - `slope` — local OTR-vs-flow slope. + - `oZoneOtr` — reactor zone OTR (kg O₂ / m³ / day) computed against + `diffuser.zoneVolume`; `0` when zone volume is unset. + - `idle` — true when `data.flow ≤ 0`. + - `warning`, `alarm` — string arrays describing flow-per-element band + excursions. +- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with + the `'influxdb'` formatter. +- **Port 2 (registration):** at startup the node sends one + `{ topic: 'child.register', payload: , 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. diff --git a/diffuser.html b/diffuser.html index c7ca6fc..969e73f 100644 --- a/diffuser.html +++ b/diffuser.html @@ -16,9 +16,9 @@ RED.nodes.registerType('diffuser', { logLevel: { value: 'error' }, }, inputs: 1, - outputs: 4, + outputs: 3, inputLabels: ['control'], - outputLabels: ['process', 'dbase', 'reactor control', 'parent'], + outputLabels: ['process', 'dbase', 'parent'], icon: 'font-awesome/fa-tint', label: function() { return this.name ? `${this.name}_${this.number}` : 'diffuser'; diff --git a/src/commands/handlers.js b/src/commands/handlers.js new file mode 100644 index 0000000..cf015cd --- /dev/null +++ b/src/commands/handlers.js @@ -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); diff --git a/src/commands/index.js b/src/commands/index.js new file mode 100644 index 0000000..169df0c --- /dev/null +++ b/src/commands/index.js @@ -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, + }, +]; diff --git a/src/nodeClass.js b/src/nodeClass.js index cbe33f6..7973638 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -1,140 +1,32 @@ -const { outputUtils } = require('generalFunctions'); -const Specific = require('./specificClass'); +'use strict'; -class nodeClass { - constructor(uiConfig, RED, nodeInstance, nameOfNode) { - this.node = nodeInstance; - this.RED = RED; - this.name = nameOfNode; +const { BaseNodeAdapter } = require('generalFunctions'); +const Diffuser = require('./specificClass'); +const commands = require('./commands'); - this._loadConfig(uiConfig); - this._setupSpecificClass(); - this._registerChild(); - this._startTickLoop(); - this._attachInputHandler(); - this._attachCloseHandler(); - } +// Event-driven: setter inputs trigger recalculate which emits +// 'output-changed'. No tick loop. Status badge polled every second. +class nodeClass extends BaseNodeAdapter { + static DomainClass = Diffuser; + static commands = commands; + static tickInterval = null; + static statusInterval = 1000; - _loadConfig(uiConfig) { - const suffix = uiConfig.number !== undefined && uiConfig.number !== '' ? `_${uiConfig.number}` : ''; - const resolvedName = uiConfig.name ? `${uiConfig.name}${suffix}` : this.name; - - 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', - }, + buildDomainConfig(uiConfig) { + const n = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb); + return { diffuser: { - number: Number(uiConfig.number) || 0, - elements: Number(uiConfig.i_elements) || 1, - density: Number(uiConfig.i_diff_density) || 2.4, - waterHeight: Number(uiConfig.i_m_water) || 0, - alfaFactor: Number(uiConfig.alfaf ?? 0.7) || 0.7, - headerPressure: Number(uiConfig.i_pressure) || 0, - localAtmPressure: Number(uiConfig.i_local_atm_pressure) || 1013.25, - waterDensity: Number(uiConfig.i_water_density) || 997, - zoneVolume: Number(uiConfig.i_zone_volume) || 0, + number: n(uiConfig.number, 1), + elements: n(uiConfig.i_elements, 1), + density: n(uiConfig.i_diff_density, 2.4), + waterHeight: n(uiConfig.i_m_water, 0), + alfaFactor: n(uiConfig.alfaf, 0.7), + headerPressure: n(uiConfig.i_pressure, 0), + localAtmPressure: n(uiConfig.i_local_atm_pressure, 1013.25), + waterDensity: n(uiConfig.i_water_density, 997), + zoneVolume: n(uiConfig.i_zone_volume, 0), }, }; - - this._output = new outputUtils(); - } - - _setupSpecificClass() { - this.source = new Specific(this.config); - this.node.source = this.source; - } - - _registerChild() { - setTimeout(() => { - this.node.send([ - null, - null, - null, - { - topic: 'registerChild', - payload: this.node.id, - positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment', - }, - ]); - }, 100); - } - - _startTickLoop() { - setTimeout(() => { - this._tickInterval = setInterval(() => this._tick(), 1000); - }, 1000); - } - - _tick() { - const raw = this.source.getOutput(); - const processMsg = this._output.formatMsg(raw, this.config, 'process'); - const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb'); - const reactorOtr = this.source.getReactorOtr(this.config.diffuser?.zoneVolume); - const controlMsg = { - topic: 'OTR', - payload: reactorOtr, - meta: { - source: 'diffuser', - diffuser: this.config.general?.name, - zoneVolume: this.config.diffuser?.zoneVolume, - oKgo2H: raw.oKgo2H, - }, - }; - this.node.status(this.source.getStatus()); - this.node.send([processMsg, influxMsg, controlMsg, null]); - } - - _attachInputHandler() { - this.node.on('input', (msg, send, done) => { - try { - switch (msg.topic) { - case 'density': - this.source.setDensity(msg.payload); - break; - case 'air_flow': - this.source.setFlow(msg.payload); - break; - case 'height_water': - this.source.setWaterHeight(msg.payload); - break; - case 'header_pressure': - this.source.setHeaderPressure(msg.payload); - break; - case 'elements': - this.source.setElementCount(msg.payload); - break; - case 'alfaFactor': - this.source.setAlfaFactor(msg.payload); - break; - default: - this.source.logger.warn(`Unknown topic: ${msg.topic}`); - break; - } - done(); - } catch (error) { - this.node.status({ fill: 'red', shape: 'ring', text: 'Bad request data' }); - this.node.error(`Bad request data: ${error.message}`, msg); - done(error); - } - }); - } - - _attachCloseHandler() { - this.node.on('close', (done) => { - clearInterval(this._tickInterval); - done(); - }); } } diff --git a/src/specificClass.js b/src/specificClass.js index dfe9b61..214dbb0 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -1,293 +1,196 @@ -const { logger, interpolation, gravity, convert } = require('generalFunctions'); +'use strict'; -class Diffuser { - constructor(config = {}) { - this.config = config; - this.logger = new logger( - this.config.general?.logging?.enabled, - this.config.general?.logging?.logLevel, - this.config.general?.name, - ); +const { BaseDomain, statusBadge, interpolation, gravity, convert } = require('generalFunctions'); +class Diffuser extends BaseDomain { + static name = 'diffuser'; + + configure() { + const d = this.config.diffuser || {}; this.interpolation = new interpolation({ type: 'linear' }); - this.convert = convert; - this.specs = this.loadSpecs(); + this.specs = this._loadSpecs(); this.idle = true; 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.i_pressure = this.config.diffuser?.headerPressure || 0; - this.i_local_atm_pressure = this.config.diffuser?.localAtmPressure || 1013.25; - this.i_water_density = this.config.diffuser?.waterDensity || 997; - this.i_alfa_factor = this.config.diffuser?.alfaFactor || 0.7; - this.i_n_elements = this.normalizePositiveInteger(this.config.diffuser?.elements, 1); - this.i_diff_density = this.normalizeNumber(this.config.diffuser?.density, 2.4); - this.i_m_water = this.normalizeNumber(this.config.diffuser?.waterHeight, 0); + this.i_pressure = _num(d.headerPressure, 0); + this.i_local_atm_pressure = _num(d.localAtmPressure, 1013.25); + this.i_water_density = _num(d.waterDensity, 997); + this.i_alfa_factor = _num(d.alfaFactor, 0.7); + this.i_n_elements = _posInt(d.elements, 1); + this.i_diff_density = _num(d.density, 2.4); + this.i_m_water = _num(d.waterHeight, 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.o_otr = 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_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_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_flow_element = 0; - this.o_otr_min = 0; - this.o_otr_max = 0; - this.o_p_min = 0; - this.o_p_max = 0; + this.o_otr_min = 0; this.o_otr_max = 0; + this.o_p_min = 0; this.o_p_max = 0; this.o_combined_eff = 0; this.o_slope = 0; } - normalizeNumber(value, fallback = 0) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; + setDensity(v) { this.i_diff_density = _num(v, this.i_diff_density); this._recalculate(); } + setFlow(v) { this.i_flow = Math.max(0, _num(v, 0)); this._recalculate(); } + 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) { - 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() { + _recalculate() { if (this.i_flow <= 0) { 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_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_combined_eff = 0; - this.o_slope = 0; - this.warning.text = []; - this.warning.state = false; - this.alarm.text = []; - this.alarm.state = false; - return; + 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.warning.text = []; this.warning.state = false; + this.alarm.text = []; this.alarm.state = false; + } else { + this.idle = false; + this._calcOtrPressure(this.i_flow); } - - this.idle = false; - this.calcOtrPressure(this.i_flow); + this.notifyOutputChanged(); } - getCurveKeys(curve) { - return Object.keys(curve) - .map(Number) - .sort((a, b) => a - b); - } + _getCurveKeys(c) { return Object.keys(c).map(Number).sort((a, b) => a - b); } - interpolateSeries(points, x) { - this.interpolation.load_spline(points.x, points.y, 'linear'); + _interpolateSeries(pts, x) { + this.interpolation.load_spline(pts.x, pts.y, 'linear'); return this.interpolation.interpolate(x); } - interpolateCurveByDensity(curve, density, x) { - const keys = this.getCurveKeys(curve); + _interpolateCurveByDensity(curve, density, x) { + const keys = this._getCurveKeys(curve); if (keys.length === 1) { const only = curve[keys[0]]; - return { - value: this.interpolateSeries(only, x), - minY: Math.min(...only.y), - maxY: Math.max(...only.y), - minX: Math.min(...only.x), - maxX: Math.max(...only.x), - slope: this.getSegmentSlope(only, x), - }; + return { value: this._interpolateSeries(only, x), + minY: Math.min(...only.y), maxY: Math.max(...only.y), + minX: Math.min(...only.x), maxX: Math.max(...only.x), + slope: this._getSegmentSlope(only, x) }; } - - const lowerKey = keys.reduce((acc, key) => (key <= density ? key : acc), keys[0]); - const upperKey = keys.find((key) => key >= density) ?? keys[keys.length - 1]; - const lowerCurve = curve[lowerKey]; - const upperCurve = curve[upperKey]; - + const lowerKey = keys.reduce((a, k) => (k <= density ? k : a), keys[0]); + const upperKey = keys.find((k) => k >= density) ?? keys[keys.length - 1]; + const lower = curve[lowerKey]; const upper = curve[upperKey]; if (lowerKey === upperKey) { - return { - value: this.interpolateSeries(lowerCurve, x), - minY: Math.min(...lowerCurve.y), - maxY: Math.max(...lowerCurve.y), - minX: Math.min(...lowerCurve.x), - maxX: Math.max(...lowerCurve.x), - slope: this.getSegmentSlope(lowerCurve, x), - }; + return { value: this._interpolateSeries(lower, x), + minY: Math.min(...lower.y), maxY: Math.max(...lower.y), + minX: Math.min(...lower.x), maxX: Math.max(...lower.x), + slope: this._getSegmentSlope(lower, x) }; } - - const lowerValue = this.interpolateSeries(lowerCurve, x); - const upperValue = this.interpolateSeries(upperCurve, x); - const ratio = (density - lowerKey) / (upperKey - lowerKey); - + const lv = this._interpolateSeries(lower, x); + const uv = this._interpolateSeries(upper, x); + const r = (density - lowerKey) / (upperKey - lowerKey); return { - value: lowerValue + (upperValue - lowerValue) * ratio, - minY: Math.min(...lowerCurve.y) + (Math.min(...upperCurve.y) - Math.min(...lowerCurve.y)) * ratio, - maxY: Math.max(...lowerCurve.y) + (Math.max(...upperCurve.y) - Math.max(...lowerCurve.y)) * ratio, - minX: Math.min(...lowerCurve.x), - maxX: Math.max(...lowerCurve.x), - slope: this.getSegmentSlope(lowerCurve, x), + value: lv + (uv - lv) * r, + minY: Math.min(...lower.y) + (Math.min(...upper.y) - Math.min(...lower.y)) * r, + maxY: Math.max(...lower.y) + (Math.max(...upper.y) - Math.max(...lower.y)) * r, + minX: Math.min(...lower.x), maxX: Math.max(...lower.x), + slope: this._getSegmentSlope(lower, x), }; } - getSegmentSlope(curvePoints, x) { - const xs = curvePoints.x; - const ys = curvePoints.y; + _getSegmentSlope(pts, x) { + const { x: xs, y: ys } = pts; for (let i = 0; i < xs.length - 1; i += 1) { - if (x <= xs[i + 1]) { - return (ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]); - } + if (x <= xs[i + 1]) return (ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]); } - const last = xs.length - 1; - return (ys[last] - ys[last - 1]) / (xs[last] - xs[last - 1]); + const n = xs.length - 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 pSpan = oPMax - oPMin; - const eff1 = otrSpan > 0 ? (oOtr - oOtrMin) / otrSpan : 0; - const eff2 = pSpan > 0 ? 1 - ((oPFlow - oPMin) / pSpan) : 0; - return Math.max(0, eff1 * eff2 * 100); + const e1 = otrSpan > 0 ? (oOtr - oOtrMin) / otrSpan : 0; + const e2 = pSpan > 0 ? 1 - ((oPFlow - oPMin) / pSpan) : 0; + return Math.max(0, e1 * e2 * 100); } - calcAirDensityMbar(pressureMbar, RH, tempC) { - const Rd = 287.05; - const Rv = 461.495; + _calcAirDensityMbar(pMbar, RH, tempC) { + const Rd = 287.05, Rv = 461.495; const T = tempC + 273.15; - const A = 8.07131; - const B = 1730.63; - const C = 233.426; - const e_s = Math.pow(10, (A - (B / (C + tempC)))); - const e = RH * e_s / 100; - const pressurePa = this.convert(pressureMbar).from('mbar').to('Pa'); - const p_d = pressurePa - (e * 100); - return (p_d / (Rd * T)) + ((e * 100) / (Rv * T)); + const es = Math.pow(10, (8.07131 - (1730.63 / (233.426 + tempC)))); + const e = RH * es / 100; + const pPa = convert(pMbar).from('mbar').to('Pa'); + const pd = pPa - (e * 100); + return (pd / (Rd * T)) + ((e * 100) / (Rv * T)); } - heightToPressureMbar(density, height) { - const pressurePa = gravity.getStandardGravity() * density * height; - return this.convert(pressurePa).from('Pa').to('mbar'); + _heightToPressureMbar(density, height) { + const pPa = gravity.getStandardGravity() * density * height; + return convert(pPa).from('Pa').to('mbar'); } - calcOtrPressure(flow) { + _calcOtrPressure(flow) { 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.n_flow = (this.o_kg / this.n_kg) * flow; 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 pressure = this.interpolateCurveByDensity(this.specs.p_curve, 0, 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); - 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_otr_min = otr.minY; this.o_otr_max = otr.maxY; + this.o_p_min = pressure.minY; this.o_p_max = pressure.maxY; this.o_otr = Math.round(otr.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_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; - 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_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; + 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 = kgo2(this.o_otr); + 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_combined_eff = Math.round(this.combineEff( - this.o_otr, - this.o_otr_min, - this.o_otr_max, - this.o_p_flow, - this.o_p_min, - this.o_p_max, + this.o_combined_eff = Math.round(this._combineEff( + this.o_otr, this.o_otr_min, this.o_otr_max, + this.o_p_flow, this.o_p_min, this.o_p_max, ) * 100) / 100; this.o_slope = Math.round(otr.slope * 1000) / 1000; - this.warningCheck(pressure.minX, pressure.maxX); - this.alarmCheck(pressure.minX, pressure.maxX); + this._checkLimits(pressure.minX, pressure.maxX); } - warningCheck(minFlow, maxFlow) { - this.warning.text = []; - this.warning.state = false; - const minHyst = minFlow * (this.warning.flow.min.hyst / 100); - const maxHyst = maxFlow * (this.warning.flow.max.hyst / 100); - - if (this.o_flow_element < minFlow - minHyst) { - this.warning.state = true; - this.warning.text.push(`Warning: flow per element ${this.o_flow_element} is below ${Math.round((minFlow - minHyst) * 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}`); + _checkLimits(minFlow, maxFlow) { + this.warning.text = []; this.warning.state = false; + this.alarm.text = []; this.alarm.state = false; + const f = this.o_flow_element; + for (const k of ['warning', 'alarm']) { + const band = this[k]; + const lo = minFlow - minFlow * (band.flow.min.hyst / 100); + const hi = maxFlow + maxFlow * (band.flow.max.hyst / 100); + if (f < lo) { band.state = true; band.text.push(`${_cap(k)}: 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}`); } } } - alarmCheck(minFlow, maxFlow) { - this.alarm.text = []; - this.alarm.state = false; - const minHyst = minFlow * (this.alarm.flow.min.hyst / 100); - const maxHyst = maxFlow * (this.alarm.flow.max.hyst / 100); - - if (this.o_flow_element < minFlow - minHyst) { - 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}`); - } + // Back-compat hooks for the legacy specificClass test suite. + getStatus() { return this._legacyStatus(); } + _legacyStatus() { + if (this.alarm.state) return { fill: 'red', shape: 'dot', text: this.alarm.text[0] }; + if (this.warning.state) return { fill: 'yellow', shape: 'dot', text: this.warning.text[0] }; + const fill = this.idle ? 'grey' : 'green'; + return { fill, shape: 'dot', text: `${this.o_kgo2_h} kg o2 / h` }; } - getStatus() { - if (this.alarm.state) { - return { fill: 'red', shape: 'dot', text: this.alarm.text[0] }; - } - if (this.warning.state) { - 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` }; + getStatusBadge() { + if (this.alarm.state) return statusBadge.error(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`; + return this.idle ? statusBadge.idle(text) : statusBadge.compose([`🟢 ${text}`]); } getOutput() { @@ -302,6 +205,7 @@ class Diffuser { oFlowElement: this.o_flow_element, efficiency: this.o_combined_eff, slope: this.o_slope, + oZoneOtr: this.getReactorOtr(this.zoneVolume), idle: this.idle, warning: [...this.warning.text], alarm: [...this.alarm.text], @@ -309,34 +213,29 @@ class Diffuser { } getReactorOtr(zoneVolumeM3) { - const volume = Number(zoneVolumeM3); - if (!Number.isFinite(volume) || volume <= 0) { - return 0; - } - return this.o_kgo2_h * 1000 * 24 / volume; + const v = Number(zoneVolumeM3); + if (!Number.isFinite(v) || v <= 0) return 0; + return this.o_kgo2_h * 1000 * 24 / v; } - loadSpecs() { + _loadSpecs() { return { - supplier: 'GVA', - type: 'ELASTOX-R', - 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], - }, - }, - 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], - }, - }, + supplier: 'GVA', type: 'ELASTOX-R', + 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] } }, + 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;