'use strict'; 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 { static name = 'diffuser'; configure() { const d = this.config.diffuser || {}; this.interpolation = new interpolation({ type: 'linear' }); 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 = _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, 15); this.i_m_water = _num(d.waterHeight, 0); this.i_flow = 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_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_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_flow_element = 0; this.o_flux_per_m2 = 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; } 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(); } _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.o_flux_per_m2 = 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; } else { this.idle = false; this._calcOtrPressure(this.i_flow); } this.notifyOutputChanged(); } _getCurveKeys(c) { return Object.keys(c).map(Number).sort((a, b) => a - b); } _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); 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) }; } 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(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 lv = this._interpolateSeries(lower, x); const uv = this._interpolateSeries(upper, x); const r = (density - lowerKey) / (upperKey - lowerKey); return { 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(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]); } const n = xs.length - 1; return (ys[n] - ys[n - 1]) / (xs[n] - xs[n - 1]); } _combineEff(oOtr, oOtrMin, oOtrMax, oPFlow, oPMin, oPMax) { const otrSpan = oOtrMax - oOtrMin; const pSpan = oPMax - oPMin; 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(pMbar, RH, tempC) { const Rd = 287.05, Rv = 461.495; const T = tempC + 273.15; 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 pPa = gravity.getStandardGravity() * density * height; return convert(pPa).from('Pa').to('mbar'); } _calcOtrPressure(flow) { const totalInputPressureMbar = this.i_local_atm_pressure + this.i_pressure; 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; // 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_flux_per_m2); 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_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; 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, ) * 100) / 100; this.o_slope = Math.round(otr.slope * 1000) / 1000; this._checkLimits(pressure.minX, pressure.maxX); } _checkLimits(minFlow, maxFlow) { this.warning.text = []; this.warning.state = false; this.alarm.text = []; this.alarm.state = false; // 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']) { 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)}: specific flux ${f} Nm³/(h·m²) is below ${Math.round(lo * 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}`); } } } // 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` }; } 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() { return { iPressure: this.i_pressure, iMWater: this.i_m_water, iFlow: this.i_flow, nFlow: Math.round(this.n_flow * 100) / 100, oOtr: this.o_otr, oPLoss: this.o_p_total, oKgo2H: this.o_kgo2_h, oFlowElement: this.o_flow_element, oFluxPerM2: this.o_flux_per_m2, 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], }; } getReactorOtr(zoneVolumeM3) { const v = Number(zoneVolumeM3); if (!Number.isFinite(v) || v <= 0) return 0; return this.o_kgo2_h * 1000 * 24 / v; } _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 { _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 } }, otr_curve: raw.otr_curve, p_curve: raw.p_curve, }; } } 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;