Files
diffuser/src/specificClass.js
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

264 lines
10 KiB
JavaScript

'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);
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_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_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;
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 = 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;
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}`); }
}
}
// 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,
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 {
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;