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>
This commit is contained in:
znetsixe
2026-05-12 18:16:49 +02:00
parent 2c5704b5c0
commit 37a85690d1
2 changed files with 29 additions and 6 deletions

View File

@@ -27,6 +27,9 @@ class nodeClass extends BaseNodeAdapter {
number: n(uiConfig.number, 1),
elements: n(uiConfig.i_elements, 1),
density: n(uiConfig.i_diff_density, 15),
membraneAreaPerElement: Number.isFinite(Number(uiConfig.membraneAreaPerElement))
? Number(uiConfig.membraneAreaPerElement)
: null,
waterHeight: n(uiConfig.i_m_water, 0),
alfaFactor: n(uiConfig.alfaf, 0.7),
headerPressure: n(uiConfig.i_pressure, 0),

View File

@@ -30,6 +30,15 @@ class Diffuser extends BaseDomain {
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;
@@ -39,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_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;
@@ -59,7 +69,8 @@ class Diffuser extends BaseDomain {
_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_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;
@@ -147,9 +158,14 @@ class Diffuser extends BaseDomain {
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_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_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;
@@ -173,13 +189,15 @@ class Diffuser extends BaseDomain {
_checkLimits(minFlow, maxFlow) {
this.warning.text = []; this.warning.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']) {
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}`); }
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}`); }
}
}
@@ -209,6 +227,7 @@ class Diffuser extends BaseDomain {
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),
@@ -240,6 +259,7 @@ class Diffuser extends BaseDomain {
);
}
return {
_meta: raw._meta || {},
supplier: raw._meta?.supplier || null,
type: raw._meta?.type || null,
model: raw._meta?.model || cfgModel,