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

@@ -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.