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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user