chore: sync project state and current artifacts

This commit is contained in:
Sepehr
2026-02-22 23:27:31 +01:00
parent 1b6415776e
commit dd77089b22
232 changed files with 37056 additions and 4296 deletions

View File

@@ -6,11 +6,11 @@
use super::exchanger::HeatExchanger;
use super::lmtd::{FlowConfiguration, LmtdModel};
use entropyk_core::Calib;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::Calib;
/// Condenser heat exchanger.
///
@@ -165,7 +165,7 @@ impl Condenser {
impl Component for Condenser {
fn compute_residuals(
&self,
state: &SystemState,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
@@ -173,7 +173,7 @@ impl Component for Condenser {
fn jacobian_entries(
&self,
state: &SystemState,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
@@ -190,6 +190,27 @@ impl Component for Condenser {
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
self.inner.port_mass_flows(state)
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
self.inner.port_enthalpies(state)
}
fn energy_transfers(
&self,
state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
self.inner.energy_transfers(state)
}
}
impl StateManageable for Condenser {

View File

@@ -15,10 +15,10 @@
//! Use `FluidId::new("Air")` for air ports.
use super::condenser::Condenser;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
/// Condenser coil (air-side finned heat exchanger).
///
@@ -86,10 +86,13 @@ impl CondenserCoil {
impl Component for CondenserCoil {
fn compute_residuals(
&self,
state: &SystemState,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if !self.air_validated.load(std::sync::atomic::Ordering::Relaxed) {
if !self
.air_validated
.load(std::sync::atomic::Ordering::Relaxed)
{
if let Some(fluid_id) = self.inner.cold_fluid_id() {
if fluid_id.0.as_str() != "Air" {
return Err(ComponentError::InvalidState(format!(
@@ -97,7 +100,8 @@ impl Component for CondenserCoil {
fluid_id.0.as_str()
)));
}
self.air_validated.store(true, std::sync::atomic::Ordering::Relaxed);
self.air_validated
.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
self.inner.compute_residuals(state, residuals)
@@ -105,7 +109,7 @@ impl Component for CondenserCoil {
fn jacobian_entries(
&self,
state: &SystemState,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
@@ -122,6 +126,27 @@ impl Component for CondenserCoil {
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
self.inner.port_mass_flows(state)
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
self.inner.port_enthalpies(state)
}
fn energy_transfers(
&self,
state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
self.inner.energy_transfers(state)
}
}
impl StateManageable for CondenserCoil {
@@ -176,21 +201,27 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = coil.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(residuals.iter().all(|r| r.is_finite()), "residuals must be finite");
assert!(
residuals.iter().all(|r| r.is_finite()),
"residuals must be finite"
);
}
#[test]
fn test_condenser_coil_rejects_non_air() {
use crate::heat_exchanger::HxSideConditions;
use entropyk_core::{Temperature, Pressure, MassFlow};
use entropyk_core::{MassFlow, Pressure, Temperature};
let mut coil = CondenserCoil::new(10_000.0);
coil.inner.set_cold_conditions(HxSideConditions::new(
Temperature::from_celsius(20.0),
Pressure::from_bar(1.0),
MassFlow::from_kg_per_s(1.0),
"Water",
));
coil.inner.set_cold_conditions(
HxSideConditions::new(
Temperature::from_celsius(20.0),
Pressure::from_bar(1.0),
MassFlow::from_kg_per_s(1.0),
"Water",
)
.expect("Valid cold conditions"),
);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];

View File

@@ -7,7 +7,7 @@ use super::exchanger::HeatExchanger;
use super::lmtd::{FlowConfiguration, LmtdModel};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState, ResidualVector,
SystemState,
StateSlice,
};
/// Economizer (internal heat exchanger) with state machine support.
@@ -121,7 +121,7 @@ impl Economizer {
impl Component for Economizer {
fn compute_residuals(
&self,
state: &SystemState,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() < self.n_equations() {
@@ -146,7 +146,7 @@ impl Component for Economizer {
fn jacobian_entries(
&self,
state: &SystemState,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
match self.state {
@@ -162,6 +162,27 @@ impl Component for Economizer {
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
self.inner.port_mass_flows(state)
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
self.inner.port_enthalpies(state)
}
fn energy_transfers(
&self,
state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
self.inner.energy_transfers(state)
}
}
#[cfg(test)]

View File

@@ -225,7 +225,13 @@ impl HeatTransferModel for EpsNtuModel {
dynamic_ua_scale: Option<f64>,
) {
let q = self
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet, dynamic_ua_scale)
.compute_heat_transfer(
hot_inlet,
hot_outlet,
cold_inlet,
cold_outlet,
dynamic_ua_scale,
)
.to_watts();
let q_hot =
@@ -306,7 +312,8 @@ mod tests {
let cold_inlet = FluidState::new(20.0 + 273.15, 101_325.0, 80_000.0, 0.2, 4180.0);
let cold_outlet = FluidState::new(30.0 + 273.15, 101_325.0, 120_000.0, 0.2, 4180.0);
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None);
let q =
model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None);
assert!(q.to_watts() > 0.0);
}

View File

@@ -5,11 +5,11 @@
/// superheated vapor, absorbing heat from the hot side.
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::HeatExchanger;
use entropyk_core::Calib;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::Calib;
/// Evaporator heat exchanger.
///
@@ -191,7 +191,7 @@ impl Evaporator {
impl Component for Evaporator {
fn compute_residuals(
&self,
state: &SystemState,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
@@ -199,7 +199,7 @@ impl Component for Evaporator {
fn jacobian_entries(
&self,
state: &SystemState,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
@@ -216,6 +216,27 @@ impl Component for Evaporator {
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
self.inner.port_mass_flows(state)
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
self.inner.port_enthalpies(state)
}
fn energy_transfers(
&self,
state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
self.inner.energy_transfers(state)
}
}
impl StateManageable for Evaporator {

View File

@@ -15,10 +15,10 @@
//! Use `FluidId::new("Air")` for air ports.
use super::evaporator::Evaporator;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
/// Evaporator coil (air-side finned heat exchanger).
///
@@ -96,10 +96,13 @@ impl EvaporatorCoil {
impl Component for EvaporatorCoil {
fn compute_residuals(
&self,
state: &SystemState,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if !self.air_validated.load(std::sync::atomic::Ordering::Relaxed) {
if !self
.air_validated
.load(std::sync::atomic::Ordering::Relaxed)
{
if let Some(fluid_id) = self.inner.hot_fluid_id() {
if fluid_id.0.as_str() != "Air" {
return Err(ComponentError::InvalidState(format!(
@@ -107,7 +110,8 @@ impl Component for EvaporatorCoil {
fluid_id.0.as_str()
)));
}
self.air_validated.store(true, std::sync::atomic::Ordering::Relaxed);
self.air_validated
.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
self.inner.compute_residuals(state, residuals)
@@ -115,7 +119,7 @@ impl Component for EvaporatorCoil {
fn jacobian_entries(
&self,
state: &SystemState,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
@@ -132,6 +136,27 @@ impl Component for EvaporatorCoil {
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
self.inner.port_mass_flows(state)
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
self.inner.port_enthalpies(state)
}
fn energy_transfers(
&self,
state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
self.inner.energy_transfers(state)
}
}
impl StateManageable for EvaporatorCoil {
@@ -187,27 +212,33 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = coil.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(residuals.iter().all(|r| r.is_finite()), "residuals must be finite");
assert!(
residuals.iter().all(|r| r.is_finite()),
"residuals must be finite"
);
}
#[test]
fn test_evaporator_coil_rejects_non_air() {
use crate::heat_exchanger::HxSideConditions;
use entropyk_core::{Temperature, Pressure, MassFlow};
use entropyk_core::{MassFlow, Pressure, Temperature};
let mut coil = EvaporatorCoil::new(8_000.0);
coil.inner.set_hot_conditions(HxSideConditions::new(
Temperature::from_celsius(20.0),
Pressure::from_bar(1.0),
MassFlow::from_kg_per_s(1.0),
"Water",
));
coil.inner.set_hot_conditions(
HxSideConditions::new(
Temperature::from_celsius(20.0),
Pressure::from_bar(1.0),
MassFlow::from_kg_per_s(1.0),
"Water",
)
.expect("Valid hot conditions"),
);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = coil.compute_residuals(&state, &mut residuals);
assert!(result.is_err());
if let Err(ComponentError::InvalidState(msg)) = result {
assert!(msg.contains("requires Air"));

View File

@@ -12,12 +12,10 @@
use super::model::{FluidState, HeatTransferModel};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use entropyk_core::{Calib, Pressure, Temperature, MassFlow};
use entropyk_fluids::{
FluidBackend, FluidId as FluidsFluidId, Property, ThermoState,
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, MassFlow, Pressure, Temperature};
use entropyk_fluids::{FluidBackend, FluidId as FluidsFluidId, Property, ThermoState};
use std::marker::PhantomData;
use std::sync::Arc;
@@ -109,16 +107,23 @@ pub struct HxSideConditions {
impl HxSideConditions {
/// Returns the inlet temperature in Kelvin.
pub fn temperature_k(&self) -> f64 { self.temperature_k }
pub fn temperature_k(&self) -> f64 {
self.temperature_k
}
/// Returns the inlet pressure in Pascals.
pub fn pressure_pa(&self) -> f64 { self.pressure_pa }
pub fn pressure_pa(&self) -> f64 {
self.pressure_pa
}
/// Returns the mass flow rate in kg/s.
pub fn mass_flow_kg_s(&self) -> f64 { self.mass_flow_kg_s }
pub fn mass_flow_kg_s(&self) -> f64 {
self.mass_flow_kg_s
}
/// Returns a reference to the fluid identifier.
pub fn fluid_id(&self) -> &FluidsFluidId { &self.fluid_id }
pub fn fluid_id(&self) -> &FluidsFluidId {
&self.fluid_id
}
}
impl HxSideConditions {
/// Creates a new set of boundary conditions.
pub fn new(
@@ -126,22 +131,34 @@ impl HxSideConditions {
pressure: Pressure,
mass_flow: MassFlow,
fluid_id: impl Into<String>,
) -> Self {
) -> Result<Self, ComponentError> {
let t = temperature.to_kelvin();
let p = pressure.to_pascals();
let m = mass_flow.to_kg_per_s();
// Basic validation for physically plausible states
assert!(t > 0.0, "Temperature must be greater than 0 K");
assert!(p > 0.0, "Pressure must be strictly positive");
assert!(m >= 0.0, "Mass flow must be non-negative");
Self {
if t <= 0.0 {
return Err(ComponentError::InvalidState(
"Temperature must be greater than 0 K".to_string(),
));
}
if p <= 0.0 {
return Err(ComponentError::InvalidState(
"Pressure must be strictly positive".to_string(),
));
}
if m < 0.0 {
return Err(ComponentError::InvalidState(
"Mass flow must be non-negative".to_string(),
));
}
Ok(Self {
temperature_k: t,
pressure_pa: p,
mass_flow_kg_s: m,
fluid_id: FluidsFluidId::new(fluid_id),
}
})
}
}
@@ -208,7 +225,7 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
///
/// ```no_run
/// use entropyk_components::heat_exchanger::{HeatExchanger, LmtdModel, FlowConfiguration, HxSideConditions};
/// use entropyk_fluids::TestBackend;
/// use entropyk_fluids::{TestBackend, FluidId};
/// use entropyk_core::{Temperature, Pressure, MassFlow};
/// use std::sync::Arc;
///
@@ -220,13 +237,13 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
/// Pressure::from_bar(25.0),
/// MassFlow::from_kg_per_s(0.05),
/// "R410A",
/// ))
/// ).unwrap())
/// .with_cold_conditions(HxSideConditions::new(
/// Temperature::from_celsius(30.0),
/// Pressure::from_bar(1.5),
/// MassFlow::from_kg_per_s(0.2),
/// "Water",
/// ));
/// ).unwrap());
/// ```
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
self.fluid_backend = Some(backend);
@@ -277,26 +294,48 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
/// Computes the full thermodynamic state at the hot inlet.
pub fn hot_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| ComponentError::CalculationFailed("No FluidBackend configured".to_string()))?;
let conditions = self.hot_conditions.as_ref().ok_or_else(|| ComponentError::CalculationFailed("Hot conditions not set".to_string()))?;
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("No FluidBackend configured".to_string())
})?;
let conditions = self.hot_conditions.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("Hot conditions not set".to_string())
})?;
let h = self.query_enthalpy(conditions)?;
backend.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
).map_err(|e| ComponentError::CalculationFailed(format!("Failed to compute hot inlet state: {}", e)))
backend
.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"Failed to compute hot inlet state: {}",
e
))
})
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| ComponentError::CalculationFailed("No FluidBackend configured".to_string()))?;
let conditions = self.cold_conditions.as_ref().ok_or_else(|| ComponentError::CalculationFailed("Cold conditions not set".to_string()))?;
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("No FluidBackend configured".to_string())
})?;
let conditions = self.cold_conditions.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("Cold conditions not set".to_string())
})?;
let h = self.query_enthalpy(conditions)?;
backend.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
).map_err(|e| ComponentError::CalculationFailed(format!("Failed to compute cold inlet state: {}", e)))
backend
.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"Failed to compute cold inlet state: {}",
e
))
})
}
/// Queries Cp (J/(kg·K)) from the backend for a given side.
@@ -306,10 +345,18 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);
backend.property(conditions.fluid_id().clone(), Property::Cp, state) // Need to clone FluidId because trait signature requires it for now? Actually FluidId can be cloned cheaply depending on its implementation. We'll leave the clone if required by `property()`. Let's assume it is.
.map_err(|e| ComponentError::CalculationFailed(format!("FluidBackend Cp query failed: {}", e)))
backend
.property(conditions.fluid_id().clone(), Property::Cp, state) // Need to clone FluidId because trait signature requires it for now? Actually FluidId can be cloned cheaply depending on its implementation. We'll leave the clone if required by `property()`. Let's assume it is.
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"FluidBackend Cp query failed: {}",
e
))
})
} else {
Err(ComponentError::CalculationFailed("No FluidBackend configured".to_string()))
Err(ComponentError::CalculationFailed(
"No FluidBackend configured".to_string(),
))
}
}
@@ -320,10 +367,18 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);
backend.property(conditions.fluid_id().clone(), Property::Enthalpy, state)
.map_err(|e| ComponentError::CalculationFailed(format!("FluidBackend Enthalpy query failed: {}", e)))
backend
.property(conditions.fluid_id().clone(), Property::Enthalpy, state)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"FluidBackend Enthalpy query failed: {}",
e
))
})
} else {
Err(ComponentError::CalculationFailed("No FluidBackend configured".to_string()))
Err(ComponentError::CalculationFailed(
"No FluidBackend configured".to_string(),
))
}
}
@@ -389,7 +444,7 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
fn compute_residuals(
&self,
_state: &SystemState,
_state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() < self.n_equations() {
@@ -431,7 +486,7 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
// at the System level via Ports.
// Let's refine the approach: we still need to query properties. The original implementation
// was a placeholder because component port state pulling is part of Epic 1.3 / Epic 4.
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) =
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
&self.hot_conditions,
@@ -448,7 +503,7 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
hot_cond.mass_flow_kg_s(),
hot_cp,
);
// Extract current iteration values from `_state` if available, or fallback to heuristics.
// The `SystemState` passed here contains the global state variables.
// For a 3-equation heat exchanger, the state variables associated with it
@@ -457,7 +512,7 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
// we'll attempt a safe estimation that incorporates `_state` conceptually,
// but avoids direct indexing out of bounds. The real fix for "ignoring _state"
// is that the system solver maps global `_state` into port conditions.
// Estimate hot outlet enthalpy (will be refined by solver convergence):
let hot_dh = hot_cp * 5.0; // J/kg per degree
let hot_outlet = Self::create_fluid_state(
@@ -516,7 +571,7 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
fn jacobian_entries(
&self,
_state: &SystemState,
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// ∂r/∂f_ua = -∂Q/∂f_ua (Story 5.5)
@@ -524,7 +579,7 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
// 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.
// Re-use logic from compute_residuals but only for Q
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
&self.hot_conditions,
@@ -540,8 +595,8 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
hot_cond.mass_flow_kg_s(),
hot_cp,
);
let hot_dh = hot_cp * 5.0;
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,
@@ -568,9 +623,10 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
cold_cp,
);
let q_nominal = self.model.compute_heat_transfer(
&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None
).to_watts();
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
@@ -596,6 +652,75 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
// Port storage pending integration with Port<Connected> system from Story 1.3.
&[]
}
fn port_mass_flows(
&self,
_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 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));
}
Ok(flows)
}
fn port_enthalpies(
&self,
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 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));
}
Ok(enthalpies)
}
fn energy_transfers(
&self,
_state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
match self.operational_state {
OperationalState::Off | OperationalState::Bypass | OperationalState::On => {
// Internal heat exchange between tracked streams; adiabatic to macro-environment
Some((
entropyk_core::Power::from_watts(0.0),
entropyk_core::Power::from_watts(0.0),
))
}
}
}
}
impl<Model: HeatTransferModel + 'static> StateManageable for HeatExchanger<Model> {
@@ -684,11 +809,11 @@ mod tests {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchangerBuilder::new(model)
.name("Condenser")
.circuit_id(CircuitId::new("primary"))
.circuit_id(CircuitId::from_number(5))
.build();
assert_eq!(hx.name(), "Condenser");
assert_eq!(hx.circuit_id().as_str(), "primary");
assert_eq!(hx.circuit_id().as_number(), 5);
}
#[test]
@@ -723,7 +848,7 @@ mod tests {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert_eq!(hx.circuit_id().as_str(), "default");
assert_eq!(*hx.circuit_id(), CircuitId::ZERO);
}
#[test]
@@ -731,8 +856,8 @@ mod tests {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
hx.set_circuit_id(CircuitId::new("secondary"));
assert_eq!(hx.circuit_id().as_str(), "secondary");
hx.set_circuit_id(CircuitId::from_number(2));
assert_eq!(hx.circuit_id().as_number(), 2);
}
#[test]
@@ -775,18 +900,18 @@ mod tests {
fn test_circuit_id_via_builder() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchangerBuilder::new(model)
.circuit_id(CircuitId::new("circuit_1"))
.circuit_id(CircuitId::from_number(1))
.build();
assert_eq!(hx.circuit_id().as_str(), "circuit_1");
assert_eq!(hx.circuit_id().as_number(), 1);
}
#[test]
fn test_with_circuit_id() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test").with_circuit_id(CircuitId::new("main"));
let hx = HeatExchanger::new(model, "Test").with_circuit_id(CircuitId::from_number(3));
assert_eq!(hx.circuit_id().as_str(), "main");
assert_eq!(hx.circuit_id().as_number(), 3);
}
// ===== Story 5.1: FluidBackend Integration Tests =====
@@ -804,8 +929,7 @@ mod tests {
use std::sync::Arc;
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test")
.with_fluid_backend(Arc::new(TestBackend::new()));
let hx = HeatExchanger::new(model, "Test").with_fluid_backend(Arc::new(TestBackend::new()));
assert!(hx.has_fluid_backend());
}
@@ -819,7 +943,8 @@ mod tests {
Pressure::from_bar(25.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
);
)
.expect("Valid conditions should not fail");
assert!((conds.temperature_k() - 333.15).abs() < 0.01);
assert!((conds.pressure_pa() - 25.0e5).abs() < 1.0);
@@ -837,23 +962,32 @@ mod tests {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Condenser")
.with_fluid_backend(Arc::new(TestBackend::new()))
.with_hot_conditions(HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(20.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
))
.with_cold_conditions(HxSideConditions::new(
Temperature::from_celsius(30.0),
Pressure::from_pascals(102_000.0),
MassFlow::from_kg_per_s(0.2),
"Water",
));
.with_hot_conditions(
HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(20.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
)
.expect("Valid hot conditions"),
)
.with_cold_conditions(
HxSideConditions::new(
Temperature::from_celsius(30.0),
Pressure::from_pascals(102_000.0),
MassFlow::from_kg_per_s(0.2),
"Water",
)
.expect("Valid cold conditions"),
);
let state = vec![0.0f64; 10];
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");
assert!(
result.is_ok(),
"compute_residuals with FluidBackend should succeed"
);
}
#[test]
@@ -870,27 +1004,37 @@ mod tests {
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();
hx_no_backend
.compute_residuals(&state, &mut residuals_no_backend)
.unwrap();
// 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()))
.with_hot_conditions(HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(20.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
))
.with_cold_conditions(HxSideConditions::new(
Temperature::from_celsius(30.0),
Pressure::from_pascals(102_000.0),
MassFlow::from_kg_per_s(0.2),
"Water",
));
.with_hot_conditions(
HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(20.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
)
.expect("Valid hot conditions"),
)
.with_cold_conditions(
HxSideConditions::new(
Temperature::from_celsius(30.0),
Pressure::from_pascals(102_000.0),
MassFlow::from_kg_per_s(0.2),
"Water",
)
.expect("Valid cold conditions"),
);
let mut residuals_with_backend = vec![0.0f64; 3];
hx_with_backend.compute_residuals(&state, &mut residuals_with_backend).unwrap();
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.

View File

@@ -194,7 +194,13 @@ impl HeatTransferModel for LmtdModel {
dynamic_ua_scale: Option<f64>,
) {
let q = self
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet, dynamic_ua_scale)
.compute_heat_transfer(
hot_inlet,
hot_outlet,
cold_inlet,
cold_outlet,
dynamic_ua_scale,
)
.to_watts();
let q_hot =
@@ -301,7 +307,8 @@ mod tests {
let cold_inlet = FluidState::from_temperature(20.0 + 273.15);
let cold_outlet = FluidState::from_temperature(50.0 + 273.15);
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None);
let q =
model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None);
assert!(q.to_watts() > 0.0);
}

View File

@@ -33,9 +33,9 @@
pub mod condenser;
pub mod condenser_coil;
pub mod economizer;
pub mod evaporator_coil;
pub mod eps_ntu;
pub mod evaporator;
pub mod evaporator_coil;
pub mod exchanger;
pub mod lmtd;
pub mod model;
@@ -43,9 +43,9 @@ pub mod model;
pub use condenser::Condenser;
pub use condenser_coil::CondenserCoil;
pub use economizer::Economizer;
pub use evaporator_coil::EvaporatorCoil;
pub use eps_ntu::{EpsNtuModel, ExchangerType};
pub use evaporator::Evaporator;
pub use evaporator_coil::EvaporatorCoil;
pub use exchanger::{HeatExchanger, HeatExchangerBuilder, HxSideConditions};
pub use lmtd::{FlowConfiguration, LmtdModel};
pub use model::HeatTransferModel;