feat(python): implement python bindings for all components and solvers

This commit is contained in:
Sepehr
2026-02-21 20:34:56 +01:00
parent 8ef8cd2eba
commit 4440132b0a
310 changed files with 11577 additions and 397 deletions

View File

@@ -126,11 +126,11 @@ impl Condenser {
let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);
if quality <= 1.0 + 1e-6 {
if quality <= 0.0 + 1e-6 {
Ok(true)
} else {
Err(ComponentError::InvalidState(format!(
"Condenser outlet quality {} > 1 (superheated)",
"Condenser outlet quality {} > 0 (not fully condensed)",
quality
)))
}
@@ -145,6 +145,21 @@ impl Condenser {
pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.cold_inlet_state()
}
/// Returns the hot side fluid identifier, if set.
pub fn hot_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
self.inner.hot_fluid_id()
}
/// Sets the cold side boundary conditions.
pub fn set_cold_conditions(&mut self, conditions: super::exchanger::HxSideConditions) {
self.inner.set_cold_conditions(conditions);
}
/// Returns the cold side fluid identifier, if set.
pub fn cold_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
self.inner.cold_fluid_id()
}
}
impl Component for Condenser {
@@ -171,6 +186,10 @@ impl Component for Condenser {
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
}
impl StateManageable for Condenser {
@@ -216,6 +235,18 @@ mod tests {
fn test_validate_outlet_quality_fully_condensed() {
let condenser = Condenser::new(10_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 200_000.0;
let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
assert!(result.is_ok());
}
#[test]
fn test_validate_outlet_quality_subcooled() {
let condenser = Condenser::new(10_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 180_000.0;
@@ -224,6 +255,18 @@ mod tests {
assert!(result.is_ok());
}
#[test]
fn test_validate_outlet_quality_two_phase() {
let condenser = Condenser::new(10_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 300_000.0;
let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
assert!(result.is_err());
}
#[test]
fn test_validate_outlet_quality_superheated() {
let condenser = Condenser::new(10_000.0);

View File

@@ -38,6 +38,7 @@ use crate::state_machine::{CircuitId, OperationalState, StateManageable};
#[derive(Debug)]
pub struct CondenserCoil {
inner: Condenser,
air_validated: std::sync::atomic::AtomicBool,
}
impl CondenserCoil {
@@ -49,6 +50,7 @@ impl CondenserCoil {
pub fn new(ua: f64) -> Self {
Self {
inner: Condenser::new(ua),
air_validated: std::sync::atomic::AtomicBool::new(false),
}
}
@@ -56,6 +58,7 @@ impl CondenserCoil {
pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self {
Self {
inner: Condenser::with_saturation_temp(ua, saturation_temp),
air_validated: std::sync::atomic::AtomicBool::new(false),
}
}
@@ -86,6 +89,17 @@ impl Component for CondenserCoil {
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
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!(
"CondenserCoil requires Air on the cold side, found {}",
fluid_id.0.as_str()
)));
}
self.air_validated.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
self.inner.compute_residuals(state, residuals)
}
@@ -104,6 +118,10 @@ impl Component for CondenserCoil {
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
}
impl StateManageable for CondenserCoil {
@@ -161,6 +179,31 @@ mod tests {
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};
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",
));
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"));
} else {
panic!("Expected InvalidState error");
}
}
#[test]
fn test_condenser_coil_jacobian_entries() {
let coil = CondenserCoil::new(10_000.0);

View File

@@ -190,6 +190,7 @@ impl HeatTransferModel for EpsNtuModel {
_hot_outlet: &FluidState,
cold_inlet: &FluidState,
_cold_outlet: &FluidState,
dynamic_ua_scale: Option<f64>,
) -> Power {
let c_hot = hot_inlet.heat_capacity_rate();
let c_cold = cold_inlet.heat_capacity_rate();
@@ -205,7 +206,7 @@ impl HeatTransferModel for EpsNtuModel {
}
let c_r = c_min / c_max;
let ntu = self.effective_ua() / c_min;
let ntu = self.effective_ua(dynamic_ua_scale) / c_min;
let effectiveness = self.effectiveness(ntu, c_r);
@@ -221,9 +222,10 @@ impl HeatTransferModel for EpsNtuModel {
cold_inlet: &FluidState,
cold_outlet: &FluidState,
residuals: &mut ResidualVector,
dynamic_ua_scale: Option<f64>,
) {
let q = self
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet, dynamic_ua_scale)
.to_watts();
let q_hot =
@@ -253,8 +255,8 @@ impl HeatTransferModel for EpsNtuModel {
self.ua_scale = s;
}
fn effective_ua(&self) -> f64 {
self.ua * self.ua_scale
fn effective_ua(&self, dynamic_ua_scale: Option<f64>) -> f64 {
self.ua * dynamic_ua_scale.unwrap_or(self.ua_scale)
}
}
@@ -304,7 +306,7 @@ 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);
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None);
assert!(q.to_watts() > 0.0);
}

View File

@@ -140,7 +140,7 @@ impl Evaporator {
let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);
if quality >= 0.0 - 1e-6 {
if quality >= 1.0 - 1e-6 {
if outlet_enthalpy >= h_vapor {
let superheat = (outlet_enthalpy - h_vapor) / cp_vapor;
Ok(superheat)
@@ -149,7 +149,7 @@ impl Evaporator {
}
} else {
Err(ComponentError::InvalidState(format!(
"Evaporator outlet quality {} < 0 (subcooled)",
"Evaporator outlet quality {} < 1 (not fully evaporated)",
quality
)))
}
@@ -171,6 +171,21 @@ impl Evaporator {
pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.cold_inlet_state()
}
/// Returns the hot side fluid identifier, if set.
pub fn hot_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
self.inner.hot_fluid_id()
}
/// Sets the hot side boundary conditions.
pub fn set_hot_conditions(&mut self, conditions: super::exchanger::HxSideConditions) {
self.inner.set_hot_conditions(conditions);
}
/// Returns the cold side fluid identifier, if set.
pub fn cold_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
self.inner.cold_fluid_id()
}
}
impl Component for Evaporator {
@@ -197,6 +212,10 @@ impl Component for Evaporator {
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
}
impl StateManageable for Evaporator {
@@ -268,6 +287,19 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn test_validate_outlet_quality_two_phase() {
let evaporator = Evaporator::new(8_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 300_000.0;
let cp_vapor = 1000.0;
let result = evaporator.validate_outlet_quality(outlet_h, h_liquid, h_vapor, cp_vapor);
assert!(result.is_err());
}
#[test]
fn test_superheat_residual() {
let evaporator = Evaporator::with_superheat(8_000.0, 278.15, 5.0);

View File

@@ -38,6 +38,7 @@ use crate::state_machine::{CircuitId, OperationalState, StateManageable};
#[derive(Debug)]
pub struct EvaporatorCoil {
inner: Evaporator,
air_validated: std::sync::atomic::AtomicBool,
}
impl EvaporatorCoil {
@@ -49,6 +50,7 @@ impl EvaporatorCoil {
pub fn new(ua: f64) -> Self {
Self {
inner: Evaporator::new(ua),
air_validated: std::sync::atomic::AtomicBool::new(false),
}
}
@@ -56,6 +58,7 @@ impl EvaporatorCoil {
pub fn with_superheat(ua: f64, saturation_temp: f64, superheat_target: f64) -> Self {
Self {
inner: Evaporator::with_superheat(ua, saturation_temp, superheat_target),
air_validated: std::sync::atomic::AtomicBool::new(false),
}
}
@@ -96,6 +99,17 @@ impl Component for EvaporatorCoil {
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
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!(
"EvaporatorCoil requires Air on the hot side, found {}",
fluid_id.0.as_str()
)));
}
self.air_validated.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
self.inner.compute_residuals(state, residuals)
}
@@ -114,6 +128,10 @@ impl Component for EvaporatorCoil {
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
}
impl StateManageable for EvaporatorCoil {
@@ -172,6 +190,32 @@ mod tests {
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};
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",
));
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"));
} else {
panic!("Expected InvalidState error");
}
}
#[test]
fn test_evaporator_coil_jacobian_entries() {
let coil = EvaporatorCoil::new(8_000.0);

View File

@@ -157,6 +157,8 @@ pub struct HeatExchanger<Model: HeatTransferModel> {
name: String,
/// Calibration: f_dp for refrigerant-side ΔP when modeled, f_ua for UA scaling
calib: Calib,
/// Indices for dynamically extracting calibration factors from the system state
calib_indices: entropyk_core::CalibIndices,
operational_state: OperationalState,
circuit_id: CircuitId,
/// Optional fluid property backend for real thermodynamic calculations (Story 5.1).
@@ -190,6 +192,7 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
model,
name: name.into(),
calib,
calib_indices: entropyk_core::CalibIndices::default(),
operational_state: OperationalState::default(),
circuit_id: CircuitId::default(),
fluid_backend: None,
@@ -262,6 +265,16 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
self.fluid_backend.is_some()
}
/// Returns the hot side fluid identifier, if set.
pub fn hot_fluid_id(&self) -> Option<&FluidsFluidId> {
self.hot_conditions.as_ref().map(|c| c.fluid_id())
}
/// Returns the cold side fluid identifier, if set.
pub fn cold_fluid_id(&self) -> Option<&FluidsFluidId> {
self.cold_conditions.as_ref().map(|c| c.fluid_id())
}
/// 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()))?;
@@ -327,7 +340,7 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
/// Returns the effective UA value (f_ua × UA_nominal).
pub fn ua(&self) -> f64 {
self.model.effective_ua()
self.model.effective_ua(None)
}
/// Returns the current operational state.
@@ -487,12 +500,15 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
};
let dynamic_f_ua = self.calib_indices.f_ua.map(|idx| _state[idx]);
self.model.compute_residuals(
&hot_inlet,
&hot_outlet,
&cold_inlet,
&cold_outlet,
residuals,
dynamic_f_ua,
);
Ok(())
@@ -503,6 +519,67 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
_state: &SystemState,
_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.
// 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 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 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);
}
}
Ok(())
}
@@ -510,6 +587,10 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
self.model.n_equations()
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.calib_indices = indices;
}
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.

View File

@@ -168,6 +168,7 @@ impl HeatTransferModel for LmtdModel {
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
dynamic_ua_scale: Option<f64>,
) -> Power {
let lmtd = self.lmtd(
hot_inlet.temperature,
@@ -177,7 +178,7 @@ impl HeatTransferModel for LmtdModel {
);
let f = self.flow_config.correction_factor();
let ua_eff = self.effective_ua();
let ua_eff = self.effective_ua(dynamic_ua_scale);
let q = ua_eff * lmtd * f;
Power::from_watts(q)
@@ -190,9 +191,10 @@ impl HeatTransferModel for LmtdModel {
cold_inlet: &FluidState,
cold_outlet: &FluidState,
residuals: &mut ResidualVector,
dynamic_ua_scale: Option<f64>,
) {
let q = self
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet, dynamic_ua_scale)
.to_watts();
let q_hot =
@@ -222,8 +224,8 @@ impl HeatTransferModel for LmtdModel {
self.ua_scale = s;
}
fn effective_ua(&self) -> f64 {
self.ua * self.ua_scale
fn effective_ua(&self, dynamic_ua_scale: Option<f64>) -> f64 {
self.ua * dynamic_ua_scale.unwrap_or(self.ua_scale)
}
}
@@ -242,9 +244,9 @@ mod tests {
#[test]
fn test_f_ua_scales_heat_transfer() {
let mut model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
assert_relative_eq!(model.effective_ua(), 5000.0, epsilon = 1e-10);
assert_relative_eq!(model.effective_ua(None), 5000.0, epsilon = 1e-10);
model.set_ua_scale(1.1);
assert_relative_eq!(model.effective_ua(), 5500.0, epsilon = 1e-10);
assert_relative_eq!(model.effective_ua(None), 5500.0, epsilon = 1e-10);
}
#[test]
@@ -299,7 +301,7 @@ 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);
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None);
assert!(q.to_watts() > 0.0);
}
@@ -366,10 +368,10 @@ mod tests {
let cold_outlet = FluidState::new(313.0, 101_325.0, 170_000.0, 0.3, 4180.0);
let q_lmtd = lmtd_model
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet)
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None)
.to_watts();
let q_eps_ntu = eps_ntu_model
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet)
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None)
.to_watts();
// Both methods should give positive heat transfer

View File

@@ -113,10 +113,10 @@ impl FluidState {
/// # use entropyk_core::Power;
/// struct SimpleModel { ua: f64 }
/// impl HeatTransferModel for SimpleModel {
/// fn compute_heat_transfer(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState) -> Power {
/// fn compute_heat_transfer(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState, _: Option<f64>) -> Power {
/// Power::from_watts(0.0)
/// }
/// fn compute_residuals(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState, _: &mut ResidualVector) {}
/// fn compute_residuals(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState, _: &mut ResidualVector, _: Option<f64>) {}
/// fn n_equations(&self) -> usize { 3 }
/// fn ua(&self) -> f64 { self.ua }
/// }
@@ -141,6 +141,7 @@ pub trait HeatTransferModel: Send + Sync {
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
dynamic_ua_scale: Option<f64>,
) -> Power;
/// Computes residuals for the solver.
@@ -154,6 +155,7 @@ pub trait HeatTransferModel: Send + Sync {
cold_inlet: &FluidState,
cold_outlet: &FluidState,
residuals: &mut ResidualVector,
dynamic_ua_scale: Option<f64>,
);
/// Returns the number of equations this model contributes.
@@ -170,9 +172,9 @@ pub trait HeatTransferModel: Send + Sync {
/// Sets the UA calibration scale (e.g. from Calib.f_ua).
fn set_ua_scale(&mut self, _s: f64) {}
/// Returns the effective UA used in heat transfer: ua_scale × ua_nominal.
fn effective_ua(&self) -> f64 {
self.ua() * self.ua_scale()
/// Returns the effective UA used in heat transfer. If dynamic_ua_scale is provided, it is used instead of ua_scale.
fn effective_ua(&self, dynamic_ua_scale: Option<f64>) -> f64 {
self.ua() * dynamic_ua_scale.unwrap_or_else(|| self.ua_scale())
}
}