Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -5,9 +5,9 @@
//!
//! ## Fluid Backend Integration (Story 5.1)
//!
//! When a `FluidBackend` is provided via `with_fluid_backend()`, `compute_residuals`
//! queries the backend for real Cp and enthalpy values at the boundary conditions
//! instead of using hardcoded placeholder values.
//! `compute_residuals` requires live four-port edge state. Inlet-only boundary
//! conditions may be used for property inspection, but they are not enough to
//! synthesize outlet states.
use super::model::{FluidState, HeatTransferModel};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
@@ -48,7 +48,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchangerBuilder<Model> {
self
}
/// Builds the heat exchanger with placeholder connected ports.
/// Builds the heat exchanger. Topology is injected later by name/context.
pub fn build(self) -> HeatExchanger<Model> {
HeatExchanger::new(self.model, self.name).with_circuit_id(self.circuit_id)
}
@@ -167,8 +167,8 @@ impl HxSideConditions {
/// Uses the Strategy Pattern for heat transfer calculations via the
/// `HeatTransferModel` trait. When a `FluidBackend` is attached via
/// [`with_fluid_backend`](Self::with_fluid_backend), the `compute_residuals`
/// method queries real thermodynamic properties (Cp, h) from the backend
/// instead of using hardcoded placeholder values.
/// method queries real thermodynamic properties (Cp, h) from the live edge
/// state instead of using hardcoded placeholder values.
pub struct HeatExchanger<Model: HeatTransferModel> {
model: Model,
name: String,
@@ -184,6 +184,23 @@ pub struct HeatExchanger<Model: HeatTransferModel> {
hot_conditions: Option<HxSideConditions>,
/// Boundary conditions for the cold side inlet.
cold_conditions: Option<HxSideConditions>,
// ── 4-port (Modelica-style) edge-driven mode ───────────────────────────
/// Hot inlet edge state indices (m, p, h). Wired by `set_port_context` port 0.
hot_in_idx: Option<(usize, usize, usize)>,
/// Hot outlet edge state indices. Wired by `set_port_context` port 1.
hot_out_idx: Option<(usize, usize, usize)>,
/// Cold inlet edge state indices. Wired by `set_port_context` port 2.
cold_in_idx: Option<(usize, usize, usize)>,
/// Cold outlet edge state indices. Wired by `set_port_context` port 3.
cold_out_idx: Option<(usize, usize, usize)>,
/// Hot-side fluid identifier ("Water", "Air", "INCOMP::MEG-30"…).
hot_fluid_id_str: String,
/// Cold-side fluid identifier.
cold_fluid_id_str: String,
/// Humidity ratio for moist-air hot side (0 = dry).
hot_humidity_ratio: f64,
/// Humidity ratio for moist-air cold side.
cold_humidity_ratio: f64,
_phantom: PhantomData<()>,
}
@@ -204,7 +221,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
/// Creates a new heat exchanger with the given model.
pub fn new(mut model: Model, name: impl Into<String>) -> Self {
let calib = Calib::default();
model.set_ua_scale(calib.f_ua);
model.set_ua_scale(calib.z_ua);
Self {
model,
name: name.into(),
@@ -215,6 +232,14 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
fluid_backend: None,
hot_conditions: None,
cold_conditions: None,
hot_in_idx: None,
hot_out_idx: None,
cold_in_idx: None,
cold_out_idx: None,
hot_fluid_id_str: String::new(),
cold_fluid_id_str: String::new(),
hot_humidity_ratio: 0.0,
cold_humidity_ratio: 0.0,
_phantom: PhantomData,
}
}
@@ -349,6 +374,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
}
/// Queries Cp (J/(kg·K)) from the backend for a given side.
#[allow(dead_code)]
fn query_cp(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = entropyk_fluids::FluidState::from_pt(
@@ -448,10 +474,261 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
/// Sets calibration factors.
pub fn set_calib(&mut self, calib: Calib) {
self.model.set_ua_scale(calib.f_ua);
self.model.set_ua_scale(calib.z_ua);
self.calib = calib;
}
// ── 4-port (Modelica-style) configuration ───────────────────────────────
/// Declares the hot-side fluid for edge-driven 4-port mode ("Water", "Air",
/// "INCOMP::MEG-30"…). When hot-side edges are wired (ports 0 and 1), the
/// HX reads T and cp from the live edge state via the backend.
pub fn with_hot_fluid(mut self, fluid: impl Into<String>) -> Self {
self.hot_fluid_id_str = fluid.into();
self
}
/// Declares the cold-side fluid for edge-driven 4-port mode.
pub fn with_cold_fluid(mut self, fluid: impl Into<String>) -> Self {
self.cold_fluid_id_str = fluid.into();
self
}
/// Sets the hot-side fluid identifier (see [`with_hot_fluid`]).
pub fn set_hot_fluid(&mut self, fluid: impl Into<String>) {
self.hot_fluid_id_str = fluid.into();
}
/// Sets the cold-side fluid identifier.
pub fn set_cold_fluid(&mut self, fluid: impl Into<String>) {
self.cold_fluid_id_str = fluid.into();
}
/// Sets the humidity ratio for the hot side (moist air).
pub fn set_hot_humidity_ratio(&mut self, w: f64) {
self.hot_humidity_ratio = w.max(0.0);
}
/// Sets the humidity ratio for the cold side (moist air).
pub fn set_cold_humidity_ratio(&mut self, w: f64) {
self.cold_humidity_ratio = w.max(0.0);
}
/// `true` when all 4 edges are wired (Modelica-style 4-port mode).
fn edges_ready(&self) -> bool {
self.hot_in_idx.is_some()
&& self.hot_out_idx.is_some()
&& self.cold_in_idx.is_some()
&& self.cold_out_idx.is_some()
&& !self.hot_fluid_id_str.is_empty()
&& !self.cold_fluid_id_str.is_empty()
}
fn live_state_required_error(&self) -> ComponentError {
ComponentError::InvalidState(format!(
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",
self.name
))
}
pub(crate) fn live_fluid_states(
&self,
state: &StateSlice,
) -> Result<(FluidState, FluidState, FluidState, FluidState), ComponentError> {
if !self.edges_ready() {
return Err(self.live_state_required_error());
}
let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
let (m_h_out, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
let (m_c_out, p_c_out, h_c_out) = self.cold_out_idx.unwrap();
let max_idx = [
m_h, p_h_in, h_h_in, m_h_out, p_h_out, h_h_out, m_c, p_c_in, h_c_in, m_c_out, p_c_out,
h_c_out,
]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
let hot_cp_in = self.hot_cp(state[p_h_in], state[h_h_in])?;
let hot_cp_out = self.hot_cp(state[p_h_out], state[h_h_out])?;
let cold_cp_in = self.cold_cp(state[p_c_in], state[h_c_in])?;
let cold_cp_out = self.cold_cp(state[p_c_out], state[h_c_out])?;
let hot_t_in = self.hot_temperature(state[p_h_in], state[h_h_in])?;
let hot_t_out = self.hot_temperature(state[p_h_out], state[h_h_out])?;
let cold_t_in = self.cold_temperature(state[p_c_in], state[h_c_in])?;
let cold_t_out = self.cold_temperature(state[p_c_out], state[h_c_out])?;
let m_hot = state[m_h].max(0.0);
let m_cold = state[m_c].max(0.0);
Ok((
Self::create_fluid_state(hot_t_in, state[p_h_in], state[h_h_in], m_hot, hot_cp_in),
Self::create_fluid_state(hot_t_out, state[p_h_out], state[h_h_out], m_hot, hot_cp_out),
Self::create_fluid_state(cold_t_in, state[p_c_in], state[h_c_in], m_cold, cold_cp_in),
Self::create_fluid_state(
cold_t_out,
state[p_c_out],
state[h_c_out],
m_cold,
cold_cp_out,
),
))
}
/// `true` when the hot-side fluid follows the moist-air convention.
fn hot_is_air(&self) -> bool {
let f = self.hot_fluid_id_str.trim();
f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
}
/// `true` when the cold-side fluid follows the moist-air convention.
fn cold_is_air(&self) -> bool {
let f = self.cold_fluid_id_str.trim();
f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
}
/// Hot-side cp [J/(kg·K)] at (P, h). Moist air uses the psychrometric cp;
/// other fluids query the backend.
fn hot_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.hot_is_air() {
return Ok(1006.0 + 1860.0 * self.hot_humidity_ratio);
}
self.query_live_property("hot", &self.hot_fluid_id_str, Property::Cp, p_pa, h_jkg)
.and_then(|cp| {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} hot-side Cp is invalid: {}",
self.name, cp
)))
}
})
}
/// Cold-side cp [J/(kg·K)] at (P, h).
fn cold_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.cold_is_air() {
return Ok(1006.0 + 1860.0 * self.cold_humidity_ratio);
}
self.query_live_property("cold", &self.cold_fluid_id_str, Property::Cp, p_pa, h_jkg)
.and_then(|cp| {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} cold-side Cp is invalid: {}",
self.name, cp
)))
}
})
}
/// Hot-side temperature [K] at (P, h). Moist air uses the linear psychrometric
/// inversion; other fluids query the backend T(P,h).
fn hot_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.hot_is_air() {
let w = self.hot_humidity_ratio;
let cp = 1006.0 + 1860.0 * w;
return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
}
self.query_live_property(
"hot",
&self.hot_fluid_id_str,
Property::Temperature,
p_pa,
h_jkg,
)
.and_then(|t| {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} hot-side temperature is invalid: {}",
self.name, t
)))
}
})
}
/// Cold-side temperature [K] at (P, h).
fn cold_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
if self.cold_is_air() {
let w = self.cold_humidity_ratio;
let cp = 1006.0 + 1860.0 * w;
return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
}
self.query_live_property(
"cold",
&self.cold_fluid_id_str,
Property::Temperature,
p_pa,
h_jkg,
)
.and_then(|t| {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} cold-side temperature is invalid: {}",
self.name, t
)))
}
})
}
fn query_live_property(
&self,
side: &str,
fluid_id: &str,
property: Property,
p_pa: f64,
h_jkg: f64,
) -> Result<f64, ComponentError> {
if !p_pa.is_finite() || p_pa <= 0.0 {
return Err(ComponentError::InvalidState(format!(
"{} {} side has invalid pressure: {} Pa",
self.name, side, p_pa
)));
}
if !h_jkg.is_finite() {
return Err(ComponentError::InvalidState(format!(
"{} {} side has invalid enthalpy: {} J/kg",
self.name, side, h_jkg
)));
}
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::InvalidState(format!(
"{} {} side fluid '{}' requires a FluidBackend; no simulation fallback is allowed",
self.name, side, fluid_id
))
})?;
backend
.property(
FluidsFluidId::new(fluid_id),
property,
entropyk_fluids::FluidState::PressureEnthalpy(
Pressure::from_pascals(p_pa),
entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"{} failed to evaluate {:?} for {} side fluid '{}': {}",
self.name, property, side, fluid_id, e
))
})
}
/// Creates a fluid state from temperature, pressure, enthalpy, mass flow, and Cp.
fn create_fluid_state(
temperature: f64,
@@ -509,63 +786,10 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
}
}
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) =
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
&self.hot_conditions,
&self.cold_conditions,
&self.fluid_backend,
) {
// Hot side from backend
let hot_cp = self.query_cp(hot_cond)?;
let hot_h_in = self.query_enthalpy(hot_cond)?;
let hot_inlet = Self::create_fluid_state(
hot_cond.temperature_k(),
hot_cond.pressure_pa(),
hot_h_in,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
let hot_dh = hot_cp * 5.0; // J/kg per degree
let hot_outlet = Self::create_fluid_state(
hot_cond.temperature_k() - 5.0,
hot_cond.pressure_pa() * 0.998,
hot_h_in - hot_dh,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
// Cold side from backend
let cold_cp = self.query_cp(cold_cond)?;
let cold_h_in = self.query_enthalpy(cold_cond)?;
let cold_inlet = Self::create_fluid_state(
cold_cond.temperature_k(),
cold_cond.pressure_pa(),
cold_h_in,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
let cold_dh = cold_cp * 5.0;
let cold_outlet = Self::create_fluid_state(
cold_cond.temperature_k() + 5.0,
cold_cond.pressure_pa() * 0.998,
cold_h_in + cold_dh,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
} else {
let hot_inlet = Self::create_fluid_state(350.0, 500_000.0, 400_000.0, 0.1, 1000.0);
let hot_outlet = Self::create_fluid_state(330.0, 490_000.0, 380_000.0, 0.1, 1000.0);
let cold_inlet = Self::create_fluid_state(290.0, 101_325.0, 80_000.0, 0.2, 4180.0);
let cold_outlet =
Self::create_fluid_state(300.0, 101_325.0, 120_000.0, 0.2, 4180.0);
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
};
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) = self.live_fluid_states(_state)?;
let dynamic_f_ua =
custom_ua_scale.or_else(|| self.calib_indices.f_ua.map(|idx| _state[idx]));
custom_ua_scale.or_else(|| self.calib_indices.z_ua.map(|idx| _state[idx]));
self.model.compute_residuals(
&hot_inlet,
@@ -594,68 +818,49 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// ∂r/∂f_ua = -∂Q/∂f_ua (Story 5.5)
if let Some(f_ua_idx) = self.calib_indices.f_ua {
// Need to compute Q_nominal (with UA_scale = 1.0)
// This requires repeating the residual calculation logic with dynamic_ua_scale = None
// For now, we'll use a finite difference approximation or a simplified nominal calculation.
// 4-port mode: numerical Jacobian via finite differences. Perturb each
// relevant state variable, recompute residuals, take the difference.
if self.edges_ready() {
let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
let (_, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
let (_, p_c_out, h_c_out) = self.cold_out_idx.unwrap();
// Re-use logic from compute_residuals but only for Q
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
&self.hot_conditions,
&self.cold_conditions,
&self.fluid_backend,
) {
let hot_cp = self.query_cp(hot_cond)?;
let hot_h_in = self.query_enthalpy(hot_cond)?;
let hot_inlet = Self::create_fluid_state(
hot_cond.temperature_k(),
hot_cond.pressure_pa(),
hot_h_in,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
let cols = [
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, p_c_out, h_c_out,
];
let unique_cols: Vec<usize> = {
let mut s: Vec<usize> =
cols.iter().copied().filter(|c| *c < _state.len()).collect();
s.sort_unstable();
s.dedup();
s
};
let hot_dh = hot_cp * 5.0;
let hot_outlet = Self::create_fluid_state(
hot_cond.temperature_k() - 5.0,
hot_cond.pressure_pa() * 0.998,
hot_h_in - hot_dh,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
let compute_res = |s: &[f64]| -> [f64; 2] {
let mut r = vec![0.0_f64; 2];
let _ = self.do_compute_residuals(s, &mut r, None);
[r[0], r[1]]
};
let cold_cp = self.query_cp(cold_cond)?;
let cold_h_in = self.query_enthalpy(cold_cond)?;
let cold_inlet = Self::create_fluid_state(
cold_cond.temperature_k(),
cold_cond.pressure_pa(),
cold_h_in,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
let cold_dh = cold_cp * 5.0;
let cold_outlet = Self::create_fluid_state(
cold_cond.temperature_k() + 5.0,
cold_cond.pressure_pa() * 0.998,
cold_h_in + cold_dh,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
let q_nominal = self
.model
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None)
.to_watts();
// r0 = Q_hot - Q -> ∂r0/∂f_ua = -Q_nominal
// r1 = Q_cold - Q -> ∂r1/∂f_ua = -Q_nominal
// r2 = Q_hot - Q_cold -> ∂r2/∂f_ua = 0
_jacobian.add_entry(0, f_ua_idx, -q_nominal);
_jacobian.add_entry(1, f_ua_idx, -q_nominal);
_jacobian.add_entry(2, f_ua_idx, 0.0);
for &col in &unique_cols {
let h = (_state[col].abs() * 1e-6).max(1e-3);
let mut sp = _state.to_vec();
sp[col] += h;
let rp = compute_res(&sp);
let mut sm = _state.to_vec();
sm[col] -= h;
let rm = compute_res(&sm);
for row in 0..2 {
let fd = (rp[row] - rm[row]) / (2.0 * h);
if fd.abs() > 1e-15 {
_jacobian.add_entry(row, col, fd);
}
}
}
return Ok(());
}
Ok(())
}
@@ -668,63 +873,93 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
}
fn get_ports(&self) -> &[ConnectedPort] {
// TODO: Return actual ports when port storage is implemented.
// Port storage pending integration with Port<Connected> system from Story 1.3.
&[]
}
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
if let Some(Some(triple)) = port_edges.first() {
self.hot_in_idx = Some(*triple);
}
if let Some(Some(triple)) = port_edges.get(1) {
self.hot_out_idx = Some(*triple);
}
if let Some(Some(triple)) = port_edges.get(2) {
self.cold_in_idx = Some(*triple);
}
if let Some(Some(triple)) = port_edges.get(3) {
self.cold_out_idx = Some(*triple);
}
}
fn port_names(&self) -> Vec<String> {
vec![
"hot_inlet".to_string(),
"hot_outlet".to_string(),
"cold_inlet".to_string(),
"cold_outlet".to_string(),
]
}
fn flow_paths(&self) -> Vec<(usize, usize)> {
vec![(0, 1), (2, 3)]
}
fn port_mass_flows(
&self,
_state: &StateSlice,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
// HeatExchanger has two sides: hot and cold, each with inlet and outlet.
// Mass balance: hot_in = hot_out, cold_in = cold_out (no mixing between sides)
//
// For now, we use the configured conditions if available.
// When port storage is implemented, this will use actual port state.
let mut flows = Vec::with_capacity(4);
if let Some(hot_cond) = &self.hot_conditions {
let m_hot = hot_cond.mass_flow_kg_s();
// Hot inlet (positive = entering), Hot outlet (negative = leaving)
flows.push(entropyk_core::MassFlow::from_kg_per_s(m_hot));
flows.push(entropyk_core::MassFlow::from_kg_per_s(-m_hot));
if !self.edges_ready() {
return Err(self.live_state_required_error());
}
if let Some(cold_cond) = &self.cold_conditions {
let m_cold = cold_cond.mass_flow_kg_s();
// Cold inlet (positive = entering), Cold outlet (negative = leaving)
flows.push(entropyk_core::MassFlow::from_kg_per_s(m_cold));
flows.push(entropyk_core::MassFlow::from_kg_per_s(-m_cold));
let (m_h_in, _, _) = self.hot_in_idx.unwrap();
let (m_h_out, _, _) = self.hot_out_idx.unwrap();
let (m_c_in, _, _) = self.cold_in_idx.unwrap();
let (m_c_out, _, _) = self.cold_out_idx.unwrap();
let max_idx = [m_h_in, m_h_out, m_c_in, m_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
Ok(flows)
Ok(vec![
entropyk_core::MassFlow::from_kg_per_s(state[m_h_in]),
entropyk_core::MassFlow::from_kg_per_s(-state[m_h_out]),
entropyk_core::MassFlow::from_kg_per_s(state[m_c_in]),
entropyk_core::MassFlow::from_kg_per_s(-state[m_c_out]),
])
}
fn port_enthalpies(
&self,
_state: &StateSlice,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
let mut enthalpies = Vec::with_capacity(4);
// This matches the order in port_mass_flows
if let Some(hot_cond) = &self.hot_conditions {
let h_in = self.query_enthalpy(hot_cond).unwrap_or(400_000.0);
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in));
// HACK: As mentioned in compute_residuals, proper port mappings are pending.
// We use a dummy 5 K delta for the outlet until full Port system integration.
let cp = self.query_cp(hot_cond).unwrap_or(1000.0);
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in - cp * 5.0));
if !self.edges_ready() {
return Err(self.live_state_required_error());
}
if let Some(cold_cond) = &self.cold_conditions {
let h_in = self.query_enthalpy(cold_cond).unwrap_or(80_000.0);
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in));
let cp = self.query_cp(cold_cond).unwrap_or(4180.0);
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in + cp * 5.0));
let (_, _, h_h_in) = self.hot_in_idx.unwrap();
let (_, _, h_h_out) = self.hot_out_idx.unwrap();
let (_, _, h_c_in) = self.cold_in_idx.unwrap();
let (_, _, h_c_out) = self.cold_out_idx.unwrap();
let max_idx = [h_h_in, h_h_out, h_c_in, h_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
Ok(enthalpies)
Ok(vec![
entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_in]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_out]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_in]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_out]),
])
}
fn energy_transfers(
@@ -742,7 +977,43 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
}
}
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
match kind {
crate::MeasuredOutput::Capacity | crate::MeasuredOutput::HeatTransferRate => {
if !self.edges_ready() {
return None;
}
let (m_h, _, h_h_in) = self.hot_in_idx?;
let (_, _, h_h_out) = self.hot_out_idx?;
let (m_c, _, h_c_in) = self.cold_in_idx?;
let (_, _, h_c_out) = self.cold_out_idx?;
let max_idx = [m_h, h_h_in, h_h_out, m_c, h_c_in, h_c_out]
.into_iter()
.max()?;
if max_idx >= state.len() {
return None;
}
let q_hot_w = state[m_h].abs() * (state[h_h_in] - state[h_h_out]).abs();
let q_cold_w = state[m_c].abs() * (state[h_c_out] - state[h_c_in]).abs();
if q_hot_w.is_finite() && q_cold_w.is_finite() {
Some(0.5 * (q_hot_w + q_cold_w))
} else if q_hot_w.is_finite() {
Some(q_hot_w)
} else if q_cold_w.is_finite() {
Some(q_cold_w)
} else {
None
}
}
_ => None,
}
}
fn set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
) {
if self.fluid_backend.is_none() {
self.fluid_backend = Some(backend);
}
@@ -755,7 +1026,10 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
fn to_params(&self) -> crate::ComponentParams {
crate::ComponentParams::new(&self.name)
.with_param("circuitId", self.circuit_id.0)
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null))
.with_param(
"calib",
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
)
}
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
@@ -808,6 +1082,10 @@ mod tests {
use crate::heat_exchanger::{FlowConfiguration, LmtdModel};
use crate::state_machine::StateManageable;
fn live_air_state(t_k: f64) -> f64 {
1006.0 * (t_k - 273.15)
}
#[test]
fn test_heat_exchanger_creation() {
let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
@@ -835,7 +1113,65 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
fn test_live_four_port_residuals_compute_from_state() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test")
.with_hot_fluid("Air")
.with_cold_fluid("Air");
hx.set_port_context(&[
Some((0, 1, 2)),
Some((0, 3, 4)),
Some((5, 6, 7)),
Some((5, 8, 9)),
]);
let state = vec![
0.5,
101_325.0,
live_air_state(350.0),
101_325.0,
live_air_state(330.0),
0.8,
101_325.0,
live_air_state(290.0),
101_325.0,
live_air_state(300.0),
];
let mut residuals = vec![0.0; hx.n_equations()];
hx.compute_residuals(&state, &mut residuals).unwrap();
assert!(residuals.iter().all(|r| r.is_finite()));
assert!(residuals.iter().any(|r| r.abs() > 1e-9));
assert_eq!(
hx.port_enthalpies(&state)
.unwrap()
.iter()
.map(|h| h.to_joules_per_kg())
.collect::<Vec<_>>(),
vec![
live_air_state(350.0),
live_air_state(330.0),
live_air_state(290.0),
live_air_state(300.0)
]
);
}
#[test]
fn test_four_port_metadata_is_name_based() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert!(hx.get_ports().is_empty());
assert_eq!(
hx.port_names(),
vec!["hot_inlet", "hot_outlet", "cold_inlet", "cold_outlet"]
);
assert_eq!(hx.flow_paths(), vec![(0, 1), (2, 3)]);
}
#[test]
@@ -999,8 +1335,7 @@ mod tests {
}
#[test]
fn test_compute_residuals_with_backend_succeeds() {
/// Using TestBackend: Water on cold side, R410A on hot side.
fn test_boundary_conditions_without_outlet_state_error() {
use entropyk_core::{MassFlow, Pressure, Temperature};
use entropyk_fluids::TestBackend;
use std::sync::Arc;
@@ -1031,30 +1366,27 @@ mod tests {
let mut residuals = vec![0.0f64; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(
result.is_ok(),
"compute_residuals with FluidBackend should succeed"
matches!(result, Err(ComponentError::InvalidState(_))),
"inlet-only boundary conditions must not fabricate outlet states"
);
}
#[test]
fn test_residuals_with_backend_vs_without_differ() {
/// Residuals computed with a real backend should differ from placeholder residuals
/// because real Cp and enthalpy values are used.
fn test_unwired_hx_never_returns_dummy_finite_residuals() {
use entropyk_core::{MassFlow, Pressure, Temperature};
use entropyk_fluids::TestBackend;
use std::sync::Arc;
// Without backend (placeholder values)
let model1 = LmtdModel::counter_flow(5000.0);
let hx_no_backend = HeatExchanger::new(model1, "HX_nobackend");
let state = vec![0.0f64; 10];
let mut residuals_no_backend = vec![0.0f64; 3];
hx_no_backend
.compute_residuals(&state, &mut residuals_no_backend)
.unwrap();
assert!(matches!(
hx_no_backend.compute_residuals(&state, &mut residuals_no_backend),
Err(ComponentError::InvalidState(_))
));
// With backend (real Water + R410A properties)
let model2 = LmtdModel::counter_flow(5000.0);
let hx_with_backend = HeatExchanger::new(model2, "HX_with_backend")
.with_fluid_backend(Arc::new(TestBackend::new()))
@@ -1078,22 +1410,10 @@ mod tests {
);
let mut residuals_with_backend = vec![0.0f64; 3];
hx_with_backend
.compute_residuals(&state, &mut residuals_with_backend)
.unwrap();
// The energy balance residual (index 2) should differ because real Cp differs
// from the 1000.0/4180.0 hardcoded fallback values.
// (TestBackend returns Cp=1500 for refrigerants and 4184 for water,
// but temperatures and flows differ, so the residual WILL differ)
let residuals_are_different = residuals_no_backend
.iter()
.zip(residuals_with_backend.iter())
.any(|(a, b)| (a - b).abs() > 1e-6);
assert!(
residuals_are_different,
"Residuals with FluidBackend should differ from placeholder residuals"
);
assert!(matches!(
hx_with_backend.compute_residuals(&state, &mut residuals_with_backend),
Err(ComponentError::InvalidState(_))
));
}
#[test]