Update project structure and configurations
This commit is contained in:
@@ -26,6 +26,9 @@ thiserror = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Structured logging
|
||||
tracing = "0.1"
|
||||
|
||||
# External model dependencies
|
||||
libloading = { version = "0.8", optional = true }
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"], optional = true }
|
||||
|
||||
@@ -395,6 +395,13 @@ impl Component for AirSource {
|
||||
self.rh.to_percent()
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("AirSource")
|
||||
.with_param("pSetPa", self.p_set_pa)
|
||||
.with_param("tDryK", self.t_dry_k)
|
||||
.with_param("rh", self.rh.to_fraction())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -579,6 +586,18 @@ impl Component for AirSink {
|
||||
None => format!("AirSink(P={:.0}Pa,T=free)", self.p_back_pa),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
let mut params = crate::ComponentParams::new("AirSink")
|
||||
.with_param("pBackPa", self.p_back_pa);
|
||||
if let Some(t_k) = self.t_back_k {
|
||||
params = params.with_param("tBackK", t_k);
|
||||
}
|
||||
if let Some(rh) = self.rh_back {
|
||||
params = params.with_param("rhBack", rh.to_fraction());
|
||||
}
|
||||
params
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -308,6 +308,18 @@ impl Component for BrineSource {
|
||||
self.concentration.to_percent()
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("BrineSource")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("pSetPa", self.p_set_pa)
|
||||
.with_param("tSetK", self.t_set_k)
|
||||
.with_param("concentration", self.concentration.to_fraction())
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
self.backend = backend;
|
||||
}
|
||||
}
|
||||
|
||||
/// A boundary sink that imposes back-pressure, and optionally a fixed enthalpy via
|
||||
@@ -561,9 +573,24 @@ impl Component for BrineSink {
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
let mut params = crate::ComponentParams::new("BrineSink")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("pBackPa", self.p_back_pa);
|
||||
if let Some(t_k) = self.t_opt_k {
|
||||
params = params.with_param("tBackK", t_k);
|
||||
}
|
||||
if let Some(c) = self.concentration_opt {
|
||||
params = params.with_param("concentration", c.to_fraction());
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
self.backend = backend;
|
||||
}
|
||||
}
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::port::{FluidId, Port};
|
||||
|
||||
@@ -205,6 +205,19 @@ impl Component for BypassValve {
|
||||
fn get_ports(&self) -> &[crate::ConnectedPort] {
|
||||
&[] // Placeholder
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("BypassValve(id={})", self.id)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("BypassValve")
|
||||
.with_param("id", self.id.as_str())
|
||||
.with_param("position", self.position)
|
||||
.with_param("config", serde_json::to_value(&self.config).unwrap_or(serde_json::Value::Null))
|
||||
.with_param("controlMode", format!("{:?}", self.control_mode))
|
||||
.with_param("setpoint", self.setpoint)
|
||||
}
|
||||
}
|
||||
|
||||
impl BypassValve {
|
||||
|
||||
@@ -787,7 +787,8 @@ impl Compressor<Connected> {
|
||||
// Calculate volumetric efficiency using inverse pressure ratio
|
||||
// η_vol = 1 - (P_suction/P_discharge)^(1/M2)
|
||||
let inverse_pressure_ratio = p_suction / p_discharge;
|
||||
let volumetric_efficiency = 1.0 - inverse_pressure_ratio.powf(1.0 / coeffs.m2);
|
||||
let volumetric_efficiency = (1.0 - inverse_pressure_ratio.powf(1.0 / coeffs.m2))
|
||||
* self.calib.f_etav;
|
||||
|
||||
if volumetric_efficiency < 0.0 {
|
||||
return Err(ComponentError::NumericalError(
|
||||
@@ -1373,6 +1374,61 @@ impl Component for Compressor<Connected> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"Compressor(fluid={}, circuit={})",
|
||||
self.fluid_id.as_str(),
|
||||
self.circuit_id.0
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
use crate::ComponentParams;
|
||||
let mut params = ComponentParams::new("Compressor")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
.with_param("speedRpm", self.speed_rpm)
|
||||
.with_param("displacementM3PerRev", self.displacement_m3_per_rev)
|
||||
.with_param("mechanicalEfficiency", self.mechanical_efficiency)
|
||||
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null));
|
||||
match &self.model {
|
||||
CompressorModel::Ahri540(c) => {
|
||||
params = params
|
||||
.with_param("modelType", "Ahri540")
|
||||
.with_param("m1", c.m1)
|
||||
.with_param("m2", c.m2)
|
||||
.with_param("m3", c.m3)
|
||||
.with_param("m4", c.m4)
|
||||
.with_param("m5", c.m5)
|
||||
.with_param("m6", c.m6)
|
||||
.with_param("m7", c.m7)
|
||||
.with_param("m8", c.m8)
|
||||
.with_param("m9", c.m9)
|
||||
.with_param("m10", c.m10);
|
||||
}
|
||||
CompressorModel::SstSdt(c) => {
|
||||
params = params
|
||||
.with_param("modelType", "SstSdt")
|
||||
.with_param(
|
||||
"massFlowCurve",
|
||||
serde_json::to_value(&c.mass_flow_curve).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
.with_param("powerCurve", serde_json::to_value(&c.power_curve).unwrap_or(serde_json::Value::Null));
|
||||
}
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
let mut c = self.calib().clone();
|
||||
if c.set_factor(factor, value) {
|
||||
self.set_calib(c);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::state_machine::StateManageable;
|
||||
@@ -1816,6 +1872,29 @@ mod tests {
|
||||
assert_relative_eq!(p_calib / p_default, 1.1, epsilon = 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_f_etav_scales_volumetric_efficiency() {
|
||||
let mut compressor = create_test_compressor();
|
||||
let t_suction_k = 278.15;
|
||||
let t_discharge_k = 318.15;
|
||||
let rho = 15.0;
|
||||
let m_default = compressor
|
||||
.mass_flow_rate(rho, t_suction_k, t_discharge_k, None)
|
||||
.unwrap()
|
||||
.to_kg_per_s();
|
||||
|
||||
compressor.set_calib(Calib {
|
||||
f_etav: 0.9,
|
||||
..Calib::default()
|
||||
});
|
||||
let m_calib = compressor
|
||||
.mass_flow_rate(rho, t_suction_k, t_discharge_k, None)
|
||||
.unwrap()
|
||||
.to_kg_per_s();
|
||||
|
||||
assert_relative_eq!(m_calib / m_default, 0.9, epsilon = 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_flow_negative_density() {
|
||||
let compressor = create_test_compressor();
|
||||
|
||||
1158
crates/components/src/curves.rs
Normal file
1158
crates/components/src/curves.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -427,6 +427,16 @@ impl Component for Drum {
|
||||
fn signature(&self) -> String {
|
||||
format!("Drum({})", self.fluid_id)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("Drum")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
self.fluid_backend = backend;
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Drum {
|
||||
@@ -536,22 +546,20 @@ mod tests {
|
||||
|
||||
let result = drum.compute_residuals(&state, &mut residuals);
|
||||
|
||||
// TestBackend doesn't support FluidState::from_px for saturation queries,
|
||||
// so the computation will fail. This is expected - the Drum component
|
||||
// requires a real backend (CoolProp) for saturation properties.
|
||||
// We test that the method correctly propagates the error.
|
||||
// TestBackend now supports P-x queries for R410A via saturation tables,
|
||||
// so compute_residuals should succeed and produce finite residuals.
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Expected error from TestBackend (doesn't support from_px)"
|
||||
);
|
||||
|
||||
// Verify error message mentions saturation
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("saturated") || err_msg.contains("UnsupportedProperty"),
|
||||
"Error should mention saturation or unsupported property: {}",
|
||||
err_msg
|
||||
result.is_ok(),
|
||||
"compute_residuals should succeed: {:?}",
|
||||
result
|
||||
);
|
||||
for (i, &r) in residuals.iter().enumerate() {
|
||||
assert!(
|
||||
r.is_finite(),
|
||||
"residual[{}] should be finite, got {}",
|
||||
i, r
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -712,6 +712,32 @@ impl Component for ExpansionValve<Connected> {
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.calib_indices = indices;
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"ExpansionValve(fluid={}, circuit={})",
|
||||
self.fluid_id.as_str(),
|
||||
self.circuit_id.0
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("ExpansionValve")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
.with_param("opening", self.opening)
|
||||
.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 {
|
||||
let mut c = self.calib().clone();
|
||||
if c.set_factor(factor, value) {
|
||||
self.set_calib(c);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::state_machine::StateManageable;
|
||||
|
||||
@@ -238,6 +238,30 @@ impl Fan<Disconnected> {
|
||||
}
|
||||
|
||||
impl Fan<Connected> {
|
||||
/// Creates a new connected fan from pre-connected ports.
|
||||
pub(crate) fn from_connected_parts(
|
||||
curves: FanCurves,
|
||||
port_inlet: Port<Connected>,
|
||||
port_outlet: Port<Connected>,
|
||||
air_density: f64,
|
||||
) -> Result<Self, ComponentError> {
|
||||
if air_density <= 0.0 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Air density must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
curves,
|
||||
port_inlet,
|
||||
port_outlet,
|
||||
air_density_kg_per_m3: air_density,
|
||||
speed_ratio: 1.0,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the inlet port.
|
||||
pub fn port_inlet(&self) -> &Port<Connected> {
|
||||
&self.port_inlet
|
||||
@@ -528,6 +552,17 @@ impl Component for Fan<Connected> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("Fan(circuit={})", self.circuit_id.0)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("Fan")
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
.with_param("airDensityKgPerM3", self.air_density_kg_per_m3)
|
||||
.with_param("speedRatio", self.speed_ratio)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Fan<Connected> {
|
||||
|
||||
@@ -384,6 +384,16 @@ impl Component for FlowSplitter {
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
))
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("FlowSplitter(fluid={}, outlets={})", self.fluid_id, self.outlets.len())
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("FlowSplitter")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("outletCount", self.outlets.len())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -681,6 +691,16 @@ impl Component for FlowMerger {
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
))
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("FlowMerger(fluid={}, inlets={})", self.fluid_id, self.inlets.len())
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("FlowMerger")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("inletCount", self.inlets.len())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
//!
|
||||
//! This component models a water-to-water heat exchanger used for free cooling,
|
||||
//! allowing the use of outdoor air as a cooling source without operating the compressor.
|
||||
//! Uses ε-NTU method for counter-flow heat exchanger calculation.
|
||||
|
||||
use entropyk_core::{CalibIndices, Enthalpy, Power, Temperature};
|
||||
use entropyk_fluids::FluidBackend;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use entropyk_core::{Power, Temperature};
|
||||
use entropyk_fluids::FluidBackend;
|
||||
|
||||
use crate::{
|
||||
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
|
||||
ResidualVector, SystemState,
|
||||
CircuitId, Component, ComponentError, ComponentParams, ConnectedPort, JacobianBuilder,
|
||||
OperationalState, ResidualVector,
|
||||
};
|
||||
|
||||
/// Default specific heat for water (J/kg/K)
|
||||
const CP_WATER: f64 = 4186.0;
|
||||
|
||||
/// Operating mode of the FreeCoolingExchanger
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum FreeCoolingMode {
|
||||
@@ -22,7 +25,10 @@ pub enum FreeCoolingMode {
|
||||
/// Full bypass (no heat exchange)
|
||||
Bypass,
|
||||
/// Mixed mode (partial bypass)
|
||||
Mixed { bypass_fraction: f64 },
|
||||
Mixed {
|
||||
/// Fraction of flow that bypasses the heat exchanger
|
||||
bypass_fraction: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Configuration for the free cooling heat exchanger
|
||||
@@ -38,6 +44,20 @@ pub struct FreeCoolingConfig {
|
||||
pub hysteresis: f64,
|
||||
/// Control mode
|
||||
pub control_mode: FreeCoolingControlMode,
|
||||
/// UA value (W/K) — overall heat transfer coefficient × area
|
||||
pub ua: f64,
|
||||
/// Cold-side mass flow rate (kg/s)
|
||||
pub cold_mass_flow: f64,
|
||||
/// Hot-side mass flow rate (kg/s)
|
||||
pub hot_mass_flow: f64,
|
||||
/// Cold-side specific heat capacity (J/kg/K)
|
||||
pub cold_cp: f64,
|
||||
/// Hot-side specific heat capacity (J/kg/K)
|
||||
pub hot_cp: f64,
|
||||
/// Nominal pressure drop on cold side (Pa)
|
||||
pub cold_dp_nominal: f64,
|
||||
/// Nominal pressure drop on hot side (Pa)
|
||||
pub hot_dp_nominal: f64,
|
||||
}
|
||||
|
||||
/// Control mode for free cooling
|
||||
@@ -62,10 +82,7 @@ pub struct FreeCoolingExchanger {
|
||||
/// Current mode
|
||||
mode: FreeCoolingMode,
|
||||
/// Ports (4 ports: cold water in/out, hot water in/out)
|
||||
port_cold_inlet: ConnectedPort,
|
||||
port_cold_outlet: ConnectedPort,
|
||||
port_hot_inlet: ConnectedPort,
|
||||
port_hot_outlet: ConnectedPort,
|
||||
ports: [ConnectedPort; 4],
|
||||
/// Outdoor temperature (for auto mode)
|
||||
outdoor_temp: Option<Temperature>,
|
||||
/// Calculated after convergence
|
||||
@@ -74,6 +91,12 @@ pub struct FreeCoolingExchanger {
|
||||
current_effectiveness: f64,
|
||||
/// Fluid backend for property calculations
|
||||
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
||||
/// Calibration factor for UA scaling (default 1.0)
|
||||
f_ua: f64,
|
||||
/// Calibration factor for pressure drop scaling (default 1.0)
|
||||
f_dp: f64,
|
||||
/// Calibration indices for inverse calibration
|
||||
calib_indices: CalibIndices,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FreeCoolingExchanger {
|
||||
@@ -86,11 +109,19 @@ impl std::fmt::Debug for FreeCoolingExchanger {
|
||||
.field("outdoor_temp", &self.outdoor_temp)
|
||||
.field("heat_transfer_rate", &self.heat_transfer_rate)
|
||||
.field("current_effectiveness", &self.current_effectiveness)
|
||||
.field("f_ua", &self.f_ua)
|
||||
.field("f_dp", &self.f_dp)
|
||||
.field("fluid_backend", &"<FluidBackend>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Port index constants
|
||||
const COLD_INLET: usize = 0;
|
||||
const COLD_OUTLET: usize = 1;
|
||||
const HOT_INLET: usize = 2;
|
||||
const HOT_OUTLET: usize = 3;
|
||||
|
||||
impl FreeCoolingExchanger {
|
||||
/// Creates a new free cooling heat exchanger
|
||||
pub fn new(
|
||||
@@ -102,7 +133,6 @@ impl FreeCoolingExchanger {
|
||||
port_hot_inlet: ConnectedPort,
|
||||
port_hot_outlet: ConnectedPort,
|
||||
) -> Result<Self, ComponentError> {
|
||||
// Validate parameters
|
||||
if config.effectiveness < 0.0 || config.effectiveness > 1.0 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Effectiveness must be between 0.0 and 1.0".to_string(),
|
||||
@@ -119,15 +149,15 @@ impl FreeCoolingExchanger {
|
||||
id: id.to_string(),
|
||||
circuit_id,
|
||||
config,
|
||||
mode: FreeCoolingMode::Bypass, // Starts in bypass
|
||||
port_cold_inlet,
|
||||
port_cold_outlet,
|
||||
port_hot_inlet,
|
||||
port_hot_outlet,
|
||||
mode: FreeCoolingMode::Bypass,
|
||||
ports: [port_cold_inlet, port_cold_outlet, port_hot_inlet, port_hot_outlet],
|
||||
outdoor_temp: None,
|
||||
heat_transfer_rate: None,
|
||||
current_effectiveness,
|
||||
fluid_backend: None,
|
||||
f_ua: 1.0,
|
||||
f_dp: 1.0,
|
||||
calib_indices: CalibIndices::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,21 +166,49 @@ impl FreeCoolingExchanger {
|
||||
self.fluid_backend = Some(backend);
|
||||
}
|
||||
|
||||
/// Calculates maximum possible heat transfer
|
||||
fn calculate_max_heat_transfer(&self, state: &SystemState) -> Result<Power, ComponentError> {
|
||||
// Get inlet temperatures
|
||||
let t_cold_in = self.get_cold_inlet_temp(state)?;
|
||||
let t_hot_in = self.get_hot_inlet_temp(state)?;
|
||||
|
||||
// Heat capacity rates
|
||||
let c_cold = self.get_cold_capacity_rate(state)?;
|
||||
let c_hot = self.get_hot_capacity_rate(state)?;
|
||||
/// Computes ε-NTU effectiveness for a counter-flow heat exchanger.
|
||||
///
|
||||
/// ε = (1 - exp(-NTU × (1 - C_r))) / (1 - C_r × exp(-NTU × (1 - C_r)))
|
||||
/// For C_r ≈ 1: ε = NTU / (1 + NTU)
|
||||
fn compute_effectiveness(&self, ua: f64, c_cold: f64, c_hot: f64) -> f64 {
|
||||
let c_min = c_cold.min(c_hot);
|
||||
let c_max = c_cold.max(c_hot);
|
||||
let c_r = if c_max > 0.0 { c_min / c_max } else { 0.0 };
|
||||
|
||||
// Maximum heat transfer
|
||||
let q_max = c_min * (t_hot_in - t_cold_in);
|
||||
if c_min <= 0.0 || ua <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
Ok(Power::from_watts(q_max.max(0.0)))
|
||||
let ntu = ua / c_min;
|
||||
|
||||
if (c_r - 1.0).abs() < 1e-6 {
|
||||
// Balanced counter-flow: ε = NTU / (1 + NTU)
|
||||
ntu / (1.0 + ntu)
|
||||
} else {
|
||||
let denom = 1.0 - c_r * (-ntu * (1.0 - c_r)).exp();
|
||||
if denom.abs() < 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
(1.0 - (-ntu * (1.0 - c_r)).exp()) / denom
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads port enthalpy as raw f64 (J/kg) from the ConnectedPort.
|
||||
fn port_enthalpy_raw(&self, idx: usize) -> f64 {
|
||||
self.ports[idx].enthalpy().to_joules_per_kg()
|
||||
}
|
||||
|
||||
/// Reads port pressure as raw f64 (Pa) from the ConnectedPort.
|
||||
fn port_pressure_raw(&self, idx: usize) -> f64 {
|
||||
self.ports[idx].pressure().to_pascals()
|
||||
}
|
||||
|
||||
/// Estimates temperature from enthalpy using Cp (incompressible fluid).
|
||||
fn temperature_from_enthalpy(&self, h: f64, cp: f64) -> f64 {
|
||||
// T = h / Cp (simplified for incompressible fluids where h_ref = 0 at T_ref = 0)
|
||||
// More accurately: T = T_ref + (h - h_ref) / Cp
|
||||
// Using h/Cp as approximation consistent with incompressible assumption
|
||||
h / cp
|
||||
}
|
||||
|
||||
/// Updates the mode based on conditions
|
||||
@@ -160,96 +218,53 @@ impl FreeCoolingExchanger {
|
||||
|
||||
match self.config.control_mode {
|
||||
FreeCoolingControlMode::AutoTemperature => {
|
||||
let t_cold_in = self.get_current_cold_inlet_temp()?;
|
||||
let h_cold_in = self.port_enthalpy_raw(COLD_INLET);
|
||||
let t_cold_in = self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp);
|
||||
|
||||
// Switching logic with hysteresis
|
||||
if self.mode == FreeCoolingMode::Bypass {
|
||||
// Check if we can switch to free cooling
|
||||
if t_outdoor.0 < (t_cold_in - self.config.min_outdoor_temp) {
|
||||
self.mode = FreeCoolingMode::Active;
|
||||
match self.mode {
|
||||
FreeCoolingMode::Bypass => {
|
||||
if t_outdoor.0 < (t_cold_in - self.config.min_outdoor_temp) {
|
||||
self.mode = FreeCoolingMode::Active;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if we should go back to bypass
|
||||
if t_outdoor.0
|
||||
> (t_cold_in - self.config.min_outdoor_temp + self.config.hysteresis)
|
||||
{
|
||||
self.mode = FreeCoolingMode::Bypass;
|
||||
_ => {
|
||||
if t_outdoor.0
|
||||
> (t_cold_in - self.config.min_outdoor_temp + self.config.hysteresis)
|
||||
{
|
||||
self.mode = FreeCoolingMode::Bypass;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FreeCoolingControlMode::Optimized => {
|
||||
// TODO: Implement energy optimization
|
||||
self.mode = FreeCoolingMode::Active;
|
||||
}
|
||||
FreeCoolingControlMode::Manual => {
|
||||
// Do nothing, fixed mode
|
||||
}
|
||||
FreeCoolingControlMode::Manual => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper methods for temperature and flow calculations
|
||||
fn get_cold_inlet_temp(&self, _state: &SystemState) -> Result<f64, ComponentError> {
|
||||
// Placeholder - would extract from state vector
|
||||
Ok(285.15) // 12°C
|
||||
/// Sets the f_ua calibration factor
|
||||
pub fn set_f_ua(&mut self, f_ua: f64) {
|
||||
self.f_ua = f_ua;
|
||||
}
|
||||
|
||||
fn get_hot_inlet_temp(&self, _state: &SystemState) -> Result<f64, ComponentError> {
|
||||
// Placeholder - would extract from state vector
|
||||
Ok(298.15) // 25°C
|
||||
/// Returns the f_ua calibration factor
|
||||
pub fn f_ua(&self) -> f64 {
|
||||
self.f_ua
|
||||
}
|
||||
|
||||
fn get_cold_capacity_rate(&self, _state: &SystemState) -> Result<f64, ComponentError> {
|
||||
// Placeholder - would calculate from mass flow and specific heat
|
||||
Ok(4186.0 * 0.1) // Water at 0.1 kg/s
|
||||
/// Sets the f_dp calibration factor
|
||||
pub fn set_f_dp(&mut self, f_dp: f64) {
|
||||
self.f_dp = f_dp;
|
||||
}
|
||||
|
||||
fn get_hot_capacity_rate(&self, _state: &SystemState) -> Result<f64, ComponentError> {
|
||||
// Placeholder - would calculate from mass flow and specific heat
|
||||
Ok(4186.0 * 0.1) // Water at 0.1 kg/s
|
||||
/// Returns the f_dp calibration factor
|
||||
pub fn f_dp(&self) -> f64 {
|
||||
self.f_dp
|
||||
}
|
||||
|
||||
fn get_current_cold_inlet_temp(&self) -> Result<f64, ComponentError> {
|
||||
Ok(285.15) // Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for FreeCoolingExchanger {
|
||||
fn n_equations(&self) -> usize {
|
||||
// 4 equations for energy balances at each port
|
||||
// + 1 equation for heat transfer
|
||||
// + 1 equation for flow continuity
|
||||
6
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
_residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// TODO: Implement actual residual calculations
|
||||
// For now, return zero residuals
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// TODO: Implement partial derivatives
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
// Return the 4 ports
|
||||
&[] // Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
/// Specific methods for FreeCoolingExchanger
|
||||
impl FreeCoolingExchanger {
|
||||
/// Returns the current operational state
|
||||
pub fn operational_state(&self) -> OperationalState {
|
||||
match self.mode {
|
||||
@@ -282,10 +297,7 @@ impl FreeCoolingExchanger {
|
||||
/// Returns estimated energy savings (in %)
|
||||
pub fn energy_savings_percent(&self) -> f64 {
|
||||
match self.mode {
|
||||
FreeCoolingMode::Active => {
|
||||
// Estimation based on effectiveness
|
||||
self.current_effectiveness * 100.0
|
||||
}
|
||||
FreeCoolingMode::Active => self.current_effectiveness * 100.0,
|
||||
FreeCoolingMode::Bypass => 0.0,
|
||||
FreeCoolingMode::Mixed { bypass_fraction } => {
|
||||
self.current_effectiveness * bypass_fraction * 100.0
|
||||
@@ -300,7 +312,6 @@ impl FreeCoolingExchanger {
|
||||
|
||||
/// Updates configuration
|
||||
pub fn update_config(&mut self, config: FreeCoolingConfig) -> Result<(), ComponentError> {
|
||||
// Validation
|
||||
if config.effectiveness < 0.0 || config.effectiveness > 1.0 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Effectiveness must be between 0.0 and 1.0".to_string(),
|
||||
@@ -318,13 +329,9 @@ impl FreeCoolingExchanger {
|
||||
/// Calculates effective COP (very high in free cooling)
|
||||
pub fn effective_cop(&self) -> f64 {
|
||||
match self.mode {
|
||||
FreeCoolingMode::Active => {
|
||||
// Typical COP > 20 for free cooling (only pumps)
|
||||
20.0 + self.current_effectiveness * 10.0
|
||||
}
|
||||
FreeCoolingMode::Bypass => 1.0, // No gain
|
||||
FreeCoolingMode::Active => 20.0 + self.current_effectiveness * 10.0,
|
||||
FreeCoolingMode::Bypass => 1.0,
|
||||
FreeCoolingMode::Mixed { bypass_fraction } => {
|
||||
// Weighted COP
|
||||
let cop_fc = 20.0 + self.current_effectiveness * 10.0;
|
||||
bypass_fraction * cop_fc + (1.0 - bypass_fraction) * 1.0
|
||||
}
|
||||
@@ -340,6 +347,224 @@ impl FreeCoolingExchanger {
|
||||
pub fn circuit_id(&self) -> CircuitId {
|
||||
self.circuit_id
|
||||
}
|
||||
|
||||
/// Returns a reference to the config
|
||||
pub fn config(&self) -> &FreeCoolingConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component trait implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Equation layout (4 equations total):
|
||||
/// r[0]: cold-side energy balance: ṁ_cold × (h_cold_out − h_cold_in) − Q = 0
|
||||
/// r[1]: hot-side energy balance: ṁ_hot × (h_hot_out − h_hot_in) + Q = 0
|
||||
/// r[2]: energy conservation: ṁ_cold × Δh_cold + ṁ_hot × Δh_hot = 0
|
||||
/// r[3]: pressure continuity: P_cold_in − P_cold_out − f_dp × ΔP_nominal = 0
|
||||
///
|
||||
/// In Bypass mode: r[0..3] = pressure/enthalpy continuity (adiabatic)
|
||||
const N_EQUATIONS: usize = 4;
|
||||
|
||||
impl Component for FreeCoolingExchanger {
|
||||
fn n_equations(&self) -> usize {
|
||||
N_EQUATIONS
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if residuals.len() < N_EQUATIONS {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: N_EQUATIONS,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Read port values
|
||||
let h_cold_in = self.port_enthalpy_raw(COLD_INLET);
|
||||
let h_cold_out = self.port_enthalpy_raw(COLD_OUTLET);
|
||||
let h_hot_in = self.port_enthalpy_raw(HOT_INLET);
|
||||
let h_hot_out = self.port_enthalpy_raw(HOT_OUTLET);
|
||||
let p_cold_in = self.port_pressure_raw(COLD_INLET);
|
||||
let p_cold_out = self.port_pressure_raw(COLD_OUTLET);
|
||||
let p_hot_in = self.port_pressure_raw(HOT_INLET);
|
||||
let p_hot_out = self.port_pressure_raw(HOT_OUTLET);
|
||||
|
||||
match self.mode {
|
||||
FreeCoolingMode::Bypass => {
|
||||
// Adiabatic: P and h continuity on both sides
|
||||
residuals[0] = p_cold_in - p_cold_out;
|
||||
residuals[1] = h_cold_in - h_cold_out;
|
||||
residuals[2] = p_hot_in - p_hot_out;
|
||||
residuals[3] = h_hot_in - h_hot_out;
|
||||
}
|
||||
FreeCoolingMode::Active | FreeCoolingMode::Mixed { .. } => {
|
||||
let m_cold = self.config.cold_mass_flow;
|
||||
let m_hot = self.config.hot_mass_flow;
|
||||
let cp_cold = self.config.cold_cp;
|
||||
let cp_hot = self.config.hot_cp;
|
||||
|
||||
// Capacity rates (W/K)
|
||||
let c_cold = m_cold * cp_cold;
|
||||
let c_hot = m_hot * cp_hot;
|
||||
let c_min = c_cold.min(c_hot);
|
||||
|
||||
// UA with calibration scaling
|
||||
let ua_eff = self.f_ua * self.config.ua;
|
||||
|
||||
// ε-NTU effectiveness
|
||||
let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot);
|
||||
|
||||
// Scale by (1 - bypass_fraction) for mixed mode
|
||||
let eps_eff = match self.mode {
|
||||
FreeCoolingMode::Mixed { bypass_fraction } => eps * (1.0 - bypass_fraction),
|
||||
_ => eps,
|
||||
};
|
||||
|
||||
// Inlet temperatures from enthalpy (incompressible: T = h / Cp)
|
||||
let t_cold_in = self.temperature_from_enthalpy(h_cold_in, cp_cold);
|
||||
let t_hot_in = self.temperature_from_enthalpy(h_hot_in, cp_hot);
|
||||
|
||||
// Heat transfer: Q = ε × C_min × (T_hot_in − T_cold_in)
|
||||
let q = eps_eff * c_min * (t_hot_in - t_cold_in);
|
||||
|
||||
// Store for reporting
|
||||
// (heat_transfer_rate is updated after convergence externally)
|
||||
|
||||
// Residuals
|
||||
let dh_cold = h_cold_out - h_cold_in;
|
||||
let dh_hot = h_hot_out - h_hot_in;
|
||||
|
||||
residuals[0] = m_cold * dh_cold - q;
|
||||
residuals[1] = m_hot * dh_hot + q;
|
||||
residuals[2] = m_cold * dh_cold + m_hot * dh_hot;
|
||||
residuals[3] =
|
||||
(p_cold_in - p_cold_out) - self.f_dp * self.config.cold_dp_nominal;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Jacobian entries for calibration variable sensitivities
|
||||
if let Some(f_ua_idx) = self.calib_indices.f_ua {
|
||||
// ∂r[0]/∂f_ua: cold-side energy balance sensitivity
|
||||
// r[0] = m_cold * (h_cold_out - h_cold_in) - Q(f_ua)
|
||||
// ∂r[0]/∂f_ua = -∂Q/∂f_ua = -ε × C_min × (T_hot_in - T_cold_in) × UA_nominal
|
||||
let m_cold = self.config.cold_mass_flow;
|
||||
let m_hot = self.config.hot_mass_flow;
|
||||
let c_cold = m_cold * self.config.cold_cp;
|
||||
let c_hot = m_hot * self.config.hot_cp;
|
||||
let c_min = c_cold.min(c_hot);
|
||||
|
||||
let h_cold_in = self.port_enthalpy_raw(COLD_INLET);
|
||||
let h_hot_in = self.port_enthalpy_raw(HOT_INLET);
|
||||
let t_cold_in =
|
||||
self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp);
|
||||
let t_hot_in =
|
||||
self.temperature_from_enthalpy(h_hot_in, self.config.hot_cp);
|
||||
|
||||
let dt = t_hot_in - t_cold_in;
|
||||
// Approximate ∂Q/∂f_ua ≈ C_min × dt × (∂ε/∂f_ua) × UA_nominal
|
||||
// For small variations: ∂Q/∂f_ua ≈ Q / f_ua when linearized
|
||||
let ua_eff = self.f_ua * self.config.ua;
|
||||
let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot);
|
||||
let q_per_f_ua = eps * c_min * dt; // Q / f_ua at current operating point
|
||||
|
||||
jacobian.add_entry(0, f_ua_idx, -q_per_f_ua);
|
||||
jacobian.add_entry(1, f_ua_idx, q_per_f_ua);
|
||||
// r[2] = r[0] + r[1], so ∂r[2]/∂f_ua = ∂r[0]/∂f_ua + ∂r[1]/∂f_ua = 0
|
||||
jacobian.add_entry(2, f_ua_idx, 0.0);
|
||||
}
|
||||
|
||||
if let Some(f_dp_idx) = self.calib_indices.f_dp {
|
||||
// r[3] = (P_cold_in - P_cold_out) - f_dp × ΔP_nominal
|
||||
// ∂r[3]/∂f_dp = -ΔP_nominal
|
||||
jacobian.add_entry(3, f_dp_idx, -self.config.cold_dp_nominal);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&self.ports
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
backend: Arc<dyn FluidBackend>,
|
||||
) {
|
||||
if self.fluid_backend.is_none() {
|
||||
self.fluid_backend = Some(backend);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: CalibIndices) {
|
||||
self.calib_indices = indices;
|
||||
}
|
||||
|
||||
fn energy_transfers(&self, _state: &[f64]) -> Option<(Power, Power)> {
|
||||
// Internal heat exchange between two water streams — adiabatic to external environment
|
||||
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
|
||||
}
|
||||
|
||||
fn port_enthalpies(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
) -> Result<Vec<Enthalpy>, ComponentError> {
|
||||
Ok(vec![
|
||||
Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_INLET)),
|
||||
Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_OUTLET)),
|
||||
Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(HOT_INLET)),
|
||||
Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(HOT_OUTLET)),
|
||||
])
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"FreeCoolingExchanger(id={},eff={},ua={},mode={:?},f_ua={},f_dp={})",
|
||||
self.id, self.config.effectiveness, self.config.ua, self.mode, self.f_ua, self.f_dp
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> ComponentParams {
|
||||
ComponentParams::new("FreeCoolingExchanger")
|
||||
.with_param("id", self.id.as_str())
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
.with_param("effectiveness", self.config.effectiveness)
|
||||
.with_param("ua", self.config.ua)
|
||||
.with_param("coldMassFlow", self.config.cold_mass_flow)
|
||||
.with_param("hotMassFlow", self.config.hot_mass_flow)
|
||||
.with_param("coldCp", self.config.cold_cp)
|
||||
.with_param("hotCp", self.config.hot_cp)
|
||||
.with_param("bypassFraction", self.config.bypass_fraction)
|
||||
.with_param("f_ua", self.f_ua)
|
||||
.with_param("f_dp", self.f_dp)
|
||||
.with_param("mode", format!("{:?}", self.mode))
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
match factor {
|
||||
"f_ua" => {
|
||||
self.f_ua = value;
|
||||
true
|
||||
}
|
||||
"f_dp" => {
|
||||
self.f_dp = value;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FreeCoolingConfig {
|
||||
@@ -347,9 +572,16 @@ impl Default for FreeCoolingConfig {
|
||||
Self {
|
||||
effectiveness: 0.85,
|
||||
bypass_fraction: 0.2,
|
||||
min_outdoor_temp: 285.15, // 12°C
|
||||
min_outdoor_temp: 285.15,
|
||||
hysteresis: 2.0,
|
||||
control_mode: FreeCoolingControlMode::AutoTemperature,
|
||||
ua: 10_000.0, // 10 kW/K typical for plate HX
|
||||
cold_mass_flow: 0.5,
|
||||
hot_mass_flow: 0.5,
|
||||
cold_cp: CP_WATER,
|
||||
hot_cp: CP_WATER,
|
||||
cold_dp_nominal: 0.0,
|
||||
hot_dp_nominal: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,9 +590,8 @@ impl Default for FreeCoolingConfig {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::port::{FluidId, Port};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
use entropyk_core::Pressure;
|
||||
|
||||
/// Creates a pair of connected ports for tests (same fluid, P, h).
|
||||
fn make_connected_ports() -> (ConnectedPort, ConnectedPort) {
|
||||
let fluid = FluidId::new("Water");
|
||||
let p = Pressure::from_pascals(3e5);
|
||||
@@ -370,6 +601,45 @@ mod tests {
|
||||
a.connect(b).unwrap()
|
||||
}
|
||||
|
||||
fn make_connected_ports_with(
|
||||
p: Pressure,
|
||||
h_cold: f64,
|
||||
h_hot: f64,
|
||||
) -> (ConnectedPort, ConnectedPort, ConnectedPort, ConnectedPort) {
|
||||
let h_c = Enthalpy::from_joules_per_kg(h_cold);
|
||||
let h_h = Enthalpy::from_joules_per_kg(h_hot);
|
||||
|
||||
let ci = Port::new(FluidId::new("Water"), p, h_c);
|
||||
let co = Port::new(FluidId::new("Water"), p, h_c);
|
||||
let (ci, co) = ci.connect(co).unwrap();
|
||||
|
||||
let hi = Port::new(FluidId::new("Water"), p, h_h);
|
||||
let ho = Port::new(FluidId::new("Water"), p, h_h);
|
||||
let (hi, ho) = hi.connect(ho).unwrap();
|
||||
|
||||
(ci, co, hi, ho)
|
||||
}
|
||||
|
||||
fn make_exchanger_active() -> FreeCoolingExchanger {
|
||||
let (ci, co, hi, ho) = make_connected_ports_with(
|
||||
Pressure::from_pascals(3e5),
|
||||
50_000.0, // ~12°C cold (h/Cp)
|
||||
105_000.0, // ~25°C hot (h/Cp)
|
||||
);
|
||||
let mut fc = FreeCoolingExchanger::new(
|
||||
"fc_test",
|
||||
CircuitId(0),
|
||||
FreeCoolingConfig::default(),
|
||||
ci,
|
||||
co,
|
||||
hi,
|
||||
ho,
|
||||
)
|
||||
.unwrap();
|
||||
fc.mode = FreeCoolingMode::Active;
|
||||
fc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_free_cooling_exchanger_creation() {
|
||||
let config = FreeCoolingConfig::default();
|
||||
@@ -416,17 +686,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_energy_savings_calculation() {
|
||||
let config = FreeCoolingConfig {
|
||||
effectiveness: 0.85,
|
||||
..Default::default()
|
||||
};
|
||||
let (cold_in, cold_out) = make_connected_ports();
|
||||
let (hot_in, hot_out) = make_connected_ports();
|
||||
|
||||
let mut exchanger = FreeCoolingExchanger::new(
|
||||
"fc_1",
|
||||
CircuitId(0),
|
||||
config,
|
||||
FreeCoolingConfig {
|
||||
effectiveness: 0.85,
|
||||
..Default::default()
|
||||
},
|
||||
cold_in,
|
||||
cold_out,
|
||||
hot_in,
|
||||
@@ -434,18 +703,18 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Bypass mode -> 0% savings
|
||||
assert_eq!(exchanger.energy_savings_percent(), 0.0);
|
||||
|
||||
// Active mode -> effectiveness * 100%
|
||||
exchanger.mode = FreeCoolingMode::Active;
|
||||
assert_eq!(exchanger.energy_savings_percent(), 85.0);
|
||||
|
||||
// Mixed mode
|
||||
exchanger.mode = FreeCoolingMode::Mixed {
|
||||
bypass_fraction: 0.3,
|
||||
};
|
||||
assert_eq!(exchanger.energy_savings_percent(), 85.0 * 0.3);
|
||||
let expected = 85.0 * 0.3;
|
||||
assert!(
|
||||
(exchanger.energy_savings_percent() - expected).abs() < 1e-10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -464,12 +733,206 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// COP in free cooling
|
||||
exchanger.mode = FreeCoolingMode::Active;
|
||||
assert!(exchanger.effective_cop() > 20.0);
|
||||
|
||||
// COP in bypass
|
||||
exchanger.mode = FreeCoolingMode::Bypass;
|
||||
assert_eq!(exchanger.effective_cop(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_residuals_active_mode() {
|
||||
let fc = make_exchanger_active();
|
||||
let mut residuals = vec![0.0; N_EQUATIONS];
|
||||
fc.compute_residuals(&[], &mut residuals).unwrap();
|
||||
|
||||
// In active mode with different temperatures, Q > 0, residuals should be non-zero
|
||||
// (residuals won't be zero because port enthalpies don't match the Q computed)
|
||||
let has_nonzero = residuals.iter().any(|r| r.abs() > 1e-10);
|
||||
assert!(has_nonzero, "Active mode residuals should be non-zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_residuals_bypass_mode() {
|
||||
let (ci, co, hi, ho) = make_connected_ports_with(
|
||||
Pressure::from_pascals(3e5),
|
||||
50_000.0,
|
||||
105_000.0,
|
||||
);
|
||||
let fc = FreeCoolingExchanger::new(
|
||||
"fc_test",
|
||||
CircuitId(0),
|
||||
FreeCoolingConfig::default(),
|
||||
ci,
|
||||
co,
|
||||
hi,
|
||||
ho,
|
||||
)
|
||||
.unwrap();
|
||||
// Starts in Bypass mode
|
||||
|
||||
let mut residuals = vec![0.0; N_EQUATIONS];
|
||||
fc.compute_residuals(&[], &mut residuals).unwrap();
|
||||
|
||||
// With identical connected port pairs, P and h are equal → residuals near zero
|
||||
for r in &residuals {
|
||||
assert!(
|
||||
r.abs() < 1e-6,
|
||||
"Bypass mode with equal ports should have near-zero residuals"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jacobian_entries_active_mode() {
|
||||
let fc = make_exchanger_active();
|
||||
|
||||
// Without calib indices, jacobian should have no entries
|
||||
let mut jb = JacobianBuilder::new();
|
||||
fc.jacobian_entries(&[], &mut jb).unwrap();
|
||||
assert_eq!(jb.entries().len(), 0);
|
||||
|
||||
// With f_ua calib index
|
||||
let mut fc = fc;
|
||||
fc.calib_indices.f_ua = Some(100);
|
||||
let mut jb = JacobianBuilder::new();
|
||||
fc.jacobian_entries(&[], &mut jb).unwrap();
|
||||
assert!(!jb.entries().is_empty(), "Should have f_ua entries");
|
||||
|
||||
// Check that r[0] entry is negative (Q increases with f_ua, so residual decreases)
|
||||
let (row0, _, val0) = jb.entries().iter().find(|(r, _, _)| *r == 0).unwrap();
|
||||
assert_eq!(*row0, 0);
|
||||
assert!(
|
||||
*val0 <= 0.0,
|
||||
"∂r[0]/∂f_ua should be <= 0 (Q increases with f_ua)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jacobian_with_f_dp() {
|
||||
let mut fc = make_exchanger_active();
|
||||
fc.calib_indices.f_dp = Some(200);
|
||||
fc.config.cold_dp_nominal = 5000.0;
|
||||
|
||||
let mut jb = JacobianBuilder::new();
|
||||
fc.jacobian_entries(&[], &mut jb).unwrap();
|
||||
|
||||
let f_dp_entries: Vec<_> = jb.entries().iter().filter(|(r, _, _)| *r == 3).collect();
|
||||
assert!(!f_dp_entries.is_empty());
|
||||
assert_eq!(f_dp_entries[0].2, -5000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_transfers() {
|
||||
let fc = make_exchanger_active();
|
||||
let result = fc.energy_transfers(&[]);
|
||||
assert!(result.is_some());
|
||||
let (heat, work) = result.unwrap();
|
||||
assert_eq!(heat.to_watts(), 0.0);
|
||||
assert_eq!(work.to_watts(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_enthalpies() {
|
||||
let fc = make_exchanger_active();
|
||||
let enthalpies = fc.port_enthalpies(&[]).unwrap();
|
||||
assert_eq!(enthalpies.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calibration_scaling() {
|
||||
let fc1 = make_exchanger_active();
|
||||
let mut fc2 = make_exchanger_active();
|
||||
fc2.f_ua = 1.5; // 50% higher UA
|
||||
|
||||
let mut r1 = vec![0.0; N_EQUATIONS];
|
||||
let mut r2 = vec![0.0; N_EQUATIONS];
|
||||
fc1.compute_residuals(&[], &mut r1).unwrap();
|
||||
fc2.compute_residuals(&[], &mut r2).unwrap();
|
||||
|
||||
// With higher UA, ε changes → Q changes → residuals change
|
||||
assert!(
|
||||
(r1[0] - r2[0]).abs() > 1e-6,
|
||||
"f_ua scaling should change residuals"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signature_and_to_params() {
|
||||
let fc = make_exchanger_active();
|
||||
let sig = fc.signature();
|
||||
assert!(sig.contains("FreeCoolingExchanger"));
|
||||
assert!(sig.contains("fc_test"));
|
||||
assert!(sig.contains(&format!("{}", fc.config.effectiveness)));
|
||||
|
||||
let params = fc.to_params();
|
||||
let json = serde_json::to_string(¶ms).unwrap();
|
||||
assert!(json.contains("FreeCoolingExchanger"));
|
||||
assert!(json.contains("fc_test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_calib_indices() {
|
||||
let mut fc = make_exchanger_active();
|
||||
let indices = CalibIndices {
|
||||
f_ua: Some(10),
|
||||
f_dp: Some(20),
|
||||
..Default::default()
|
||||
};
|
||||
fc.set_calib_indices(indices);
|
||||
assert_eq!(fc.calib_indices.f_ua, Some(10));
|
||||
assert_eq!(fc.calib_indices.f_dp, Some(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectiveness_counter_flow() {
|
||||
let fc = make_exchanger_active();
|
||||
// Balanced flow (Cr ≈ 1): ε = NTU / (1 + NTU)
|
||||
let c = 0.5 * CP_WATER; // 2093 W/K
|
||||
let ua = 10_000.0;
|
||||
let eps = fc.compute_effectiveness(ua, c, c);
|
||||
let expected_ntu = ua / c;
|
||||
let expected_eps = expected_ntu / (1.0 + expected_ntu);
|
||||
assert!((eps - expected_eps).abs() < 1e-10);
|
||||
|
||||
// UA = 0 → ε = 0
|
||||
assert_eq!(fc.compute_effectiveness(0.0, c, c), 0.0);
|
||||
|
||||
// C_min = 0 → ε = 0
|
||||
assert_eq!(fc.compute_effectiveness(ua, 0.0, c), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_n_equations() {
|
||||
let fc = make_exchanger_active();
|
||||
assert_eq!(fc.n_equations(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_ports() {
|
||||
let fc = make_exchanger_active();
|
||||
let ports = fc.get_ports();
|
||||
assert_eq!(ports.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_residual_dimensions_validation() {
|
||||
let fc = make_exchanger_active();
|
||||
let mut residuals = vec![0.0; 2]; // Too small
|
||||
let result = fc.compute_residuals(&[], &mut residuals);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operational_state_mapping() {
|
||||
let mut fc = make_exchanger_active();
|
||||
assert_eq!(fc.operational_state(), OperationalState::On);
|
||||
|
||||
fc.set_operational_state(OperationalState::Bypass).unwrap();
|
||||
assert_eq!(fc.operational_state(), OperationalState::Bypass);
|
||||
assert_eq!(fc.current_mode(), FreeCoolingMode::Bypass);
|
||||
|
||||
fc.set_operational_state(OperationalState::On).unwrap();
|
||||
assert_eq!(fc.current_mode(), FreeCoolingMode::Active);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ impl BphxCondenser {
|
||||
///
|
||||
/// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
/// let cond = BphxCondenser::new(geo);
|
||||
/// assert_eq!(cond.n_equations(), 3);
|
||||
/// assert_eq!(cond.n_equations(), 2);
|
||||
/// ```
|
||||
pub fn new(geometry: BphxGeometry) -> Self {
|
||||
let geometry = geometry.with_exchanger_type(BphxType::Condenser);
|
||||
@@ -409,6 +409,13 @@ impl Component for BphxCondenser {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
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.clone());
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"BphxCondenser({} plates, dh={:.2}mm, A={:.3}m², {}, SC={:.1}K, {})",
|
||||
@@ -420,6 +427,10 @@ impl Component for BphxCondenser {
|
||||
self.refrigerant_id
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for BphxCondenser {
|
||||
|
||||
@@ -130,7 +130,7 @@ impl BphxEvaporator {
|
||||
///
|
||||
/// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
/// let evap = BphxEvaporator::new(geo);
|
||||
/// assert_eq!(evap.n_equations(), 3);
|
||||
/// assert_eq!(evap.n_equations(), 2);
|
||||
/// ```
|
||||
pub fn new(geometry: BphxGeometry) -> Self {
|
||||
let geometry = geometry.with_exchanger_type(BphxType::Evaporator);
|
||||
@@ -460,6 +460,13 @@ impl Component for BphxEvaporator {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
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.clone());
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
let mode_str = match self.mode {
|
||||
BphxEvaporatorMode::Dx { target_superheat } => {
|
||||
@@ -479,6 +486,10 @@ impl Component for BphxEvaporator {
|
||||
self.refrigerant_id
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for BphxEvaporator {
|
||||
|
||||
@@ -94,7 +94,7 @@ impl BphxExchanger {
|
||||
///
|
||||
/// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
/// let hx = BphxExchanger::new(geo);
|
||||
/// assert_eq!(hx.n_equations(), 3);
|
||||
/// assert_eq!(hx.n_equations(), 2);
|
||||
/// ```
|
||||
pub fn new(geometry: BphxGeometry) -> Self {
|
||||
let ua_estimate = Self::estimate_ua(&geometry);
|
||||
@@ -363,6 +363,12 @@ impl Component for BphxExchanger {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"BphxExchanger({} plates, dh={:.2}mm, A={:.3}m², {})",
|
||||
@@ -372,6 +378,10 @@ impl Component for BphxExchanger {
|
||||
self.correlation_selector.correlation.name()
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for BphxExchanger {
|
||||
|
||||
@@ -30,7 +30,7 @@ use entropyk_core::Calib;
|
||||
/// use entropyk_components::Component;
|
||||
///
|
||||
/// let condenser = Condenser::new(10_000.0); // UA = 10 kW/K
|
||||
/// assert_eq!(condenser.n_equations(), 3);
|
||||
/// assert_eq!(condenser.n_equations(), 2);
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct Condenser {
|
||||
@@ -225,6 +225,18 @@ impl Component for Condenser {
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
self.inner.signature()
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
self.inner.to_params()
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Condenser {
|
||||
|
||||
@@ -33,7 +33,7 @@ use crate::{
|
||||
///
|
||||
/// let coil = CondenserCoil::new(10_000.0); // UA = 10 kW/K
|
||||
/// assert_eq!(coil.ua(), 10_000.0);
|
||||
/// assert_eq!(coil.n_equations(), 3);
|
||||
/// assert_eq!(coil.n_equations(), 2);
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct CondenserCoil {
|
||||
@@ -147,6 +147,18 @@ impl Component for CondenserCoil {
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
self.inner.signature()
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
self.inner.to_params()
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for CondenserCoil {
|
||||
|
||||
@@ -183,6 +183,14 @@ impl Component for Economizer {
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
self.inner.signature()
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
self.inner.to_params()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -29,7 +29,7 @@ use entropyk_core::Calib;
|
||||
/// use entropyk_components::Component;
|
||||
///
|
||||
/// let evaporator = Evaporator::new(8_000.0); // UA = 8 kW/K
|
||||
/// assert_eq!(evaporator.n_equations(), 3);
|
||||
/// assert_eq!(evaporator.n_equations(), 2);
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct Evaporator {
|
||||
@@ -237,6 +237,18 @@ impl Component for Evaporator {
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
self.inner.signature()
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
self.inner.to_params()
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Evaporator {
|
||||
|
||||
@@ -33,7 +33,7 @@ use crate::{
|
||||
///
|
||||
/// let coil = EvaporatorCoil::new(8_000.0); // UA = 8 kW/K
|
||||
/// assert_eq!(coil.ua(), 8_000.0);
|
||||
/// assert_eq!(coil.n_equations(), 3);
|
||||
/// assert_eq!(coil.n_equations(), 2);
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct EvaporatorCoil {
|
||||
@@ -157,6 +157,18 @@ impl Component for EvaporatorCoil {
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
self.inner.signature()
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
self.inner.to_params()
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for EvaporatorCoil {
|
||||
|
||||
@@ -91,7 +91,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchangerBuilder<Model> {
|
||||
///
|
||||
/// let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
|
||||
/// let hx = HeatExchanger::new(model, "Condenser");
|
||||
/// assert_eq!(hx.n_equations(), 3);
|
||||
/// assert_eq!(hx.n_equations(), 2);
|
||||
/// ```
|
||||
/// Boundary conditions for one side of the heat exchanger.
|
||||
///
|
||||
@@ -448,8 +448,8 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
||||
|
||||
/// Sets calibration factors.
|
||||
pub fn set_calib(&mut self, calib: Calib) {
|
||||
self.calib = calib;
|
||||
self.model.set_ua_scale(calib.f_ua);
|
||||
self.calib = calib;
|
||||
}
|
||||
|
||||
/// Creates a fluid state from temperature, pressure, enthalpy, mass flow, and Cp.
|
||||
@@ -741,6 +741,32 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("{}(circuit={})", self.name, self.circuit_id.0)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
let mut c = self.calib().clone();
|
||||
if c.set_factor(factor, value) {
|
||||
self.set_calib(c);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Model: HeatTransferModel + 'static> StateManageable for HeatExchanger<Model> {
|
||||
|
||||
@@ -314,6 +314,12 @@ impl Component for FloodedCondenser {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"FloodedCondenser(UA={:.0},fluid={},target_sc={:.1}K)",
|
||||
@@ -322,6 +328,18 @@ impl Component for FloodedCondenser {
|
||||
self.target_subcooling_k
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("FloodedCondenser")
|
||||
.with_param("fluid", self.refrigerant_id.as_str())
|
||||
.with_param("ua", self.ua())
|
||||
.with_param("targetSubcoolingK", self.target_subcooling_k)
|
||||
.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 {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for FloodedCondenser {
|
||||
|
||||
@@ -330,6 +330,12 @@ impl Component for FloodedEvaporator {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"FloodedEvaporator(UA={:.0},fluid={},target_q={:.2})",
|
||||
@@ -338,6 +344,18 @@ impl Component for FloodedEvaporator {
|
||||
self.target_quality
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("FloodedEvaporator")
|
||||
.with_param("fluid", self.refrigerant_id.as_str())
|
||||
.with_param("ua", self.ua())
|
||||
.with_param("targetQuality", self.target_quality)
|
||||
.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 {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for FloodedEvaporator {
|
||||
|
||||
@@ -345,6 +345,10 @@ impl Component for MchxCondenserCoil {
|
||||
self.t_air_k
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for MchxCondenserCoil {
|
||||
|
||||
@@ -257,6 +257,17 @@ impl Component for MovingBoundaryHX {
|
||||
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
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.clone());
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for MovingBoundaryHX {
|
||||
|
||||
@@ -57,12 +57,15 @@
|
||||
|
||||
pub mod air_boundary;
|
||||
pub mod brine_boundary;
|
||||
pub mod bypass_valve;
|
||||
pub mod compressor;
|
||||
pub mod curves;
|
||||
pub mod drum;
|
||||
pub mod expansion_valve;
|
||||
pub mod external_model;
|
||||
pub mod fan;
|
||||
pub mod flow_junction;
|
||||
pub mod free_cooling_exchanger;
|
||||
pub mod heat_exchanger;
|
||||
pub mod node;
|
||||
pub mod params;
|
||||
@@ -70,6 +73,7 @@ pub mod pipe;
|
||||
pub mod polynomials;
|
||||
pub mod port;
|
||||
pub mod pump;
|
||||
pub mod registry;
|
||||
pub mod python_components;
|
||||
pub mod refrigerant_boundary;
|
||||
pub mod screw_economizer_compressor;
|
||||
@@ -77,7 +81,11 @@ pub mod state_machine;
|
||||
|
||||
pub use air_boundary::{AirSink, AirSource};
|
||||
pub use brine_boundary::{BrineSink, BrineSource};
|
||||
pub use bypass_valve::{BypassValve, BypassValveConfig, ValveCharacteristics};
|
||||
pub use compressor::{Ahri540Coefficients, Compressor, CompressorModel, SstSdtCoefficients};
|
||||
pub use curves::{
|
||||
BoundedCurve, CurveEngine, CurveEval, CurveResult, CurveSet, CurveWarning,
|
||||
};
|
||||
pub use drum::Drum;
|
||||
pub use expansion_valve::{ExpansionValve, PhaseRegion};
|
||||
pub use external_model::{
|
||||
@@ -85,6 +93,9 @@ pub use external_model::{
|
||||
ExternalModelType, MockExternalModel, ThreadSafeExternalModel,
|
||||
};
|
||||
pub use fan::{Fan, FanCurves};
|
||||
pub use free_cooling_exchanger::{
|
||||
FreeCoolingConfig, FreeCoolingControlMode, FreeCoolingExchanger, FreeCoolingMode,
|
||||
};
|
||||
pub use flow_junction::{
|
||||
CompressibleMerger, CompressibleSplitter, FlowMerger, FlowSplitter, FluidKind,
|
||||
IncompressibleMerger, IncompressibleSplitter,
|
||||
@@ -97,6 +108,7 @@ pub use heat_exchanger::{
|
||||
};
|
||||
pub use node::{Node, NodeMeasurements, NodePhase};
|
||||
pub use params::ComponentParams;
|
||||
pub use registry::{RegistryError, create_component};
|
||||
pub use pipe::{friction_factor, roughness, Pipe, PipeGeometry};
|
||||
pub use polynomials::{AffinityLaws, PerformanceCurves, Polynomial1D, Polynomial2D};
|
||||
pub use port::{
|
||||
@@ -107,6 +119,8 @@ pub use pump::{Pump, PumpCurves};
|
||||
pub use python_components::{
|
||||
PyCompressorReal, PyExpansionValveReal, PyFlowMergerReal, PyFlowSinkReal, PyFlowSourceReal,
|
||||
PyFlowSplitterReal, PyHeatExchangerReal, PyPipeReal,
|
||||
PyRefrigerantSourceReal, PyRefrigerantSinkReal, PyBrineSourceReal, PyBrineSinkReal,
|
||||
PyAirSourceReal, PyAirSinkReal,
|
||||
};
|
||||
pub use refrigerant_boundary::{RefrigerantSink, RefrigerantSource};
|
||||
pub use screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves};
|
||||
@@ -681,6 +695,28 @@ pub trait Component {
|
||||
// Default: no-op for components that don't support inverse calibration
|
||||
}
|
||||
|
||||
/// Updates a single calibration factor on this component.
|
||||
///
|
||||
/// Returns `true` if the factor was recognized and updated. The default
|
||||
/// implementation returns `false` (component does not support calibration).
|
||||
/// Components that override this should also apply side effects (e.g.
|
||||
/// updating internal model parameters).
|
||||
fn update_calib_factor(&mut self, _factor: &str, _value: f64) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Injects a fluid backend into this component for thermodynamic property queries.
|
||||
///
|
||||
/// Called by [`SystemBuilder::build()`] when a default or per-circuit backend is configured.
|
||||
/// Components that already have a backend (set via their own builder) should ignore the call
|
||||
/// to preserve the pre-assigned backend.
|
||||
///
|
||||
/// The default implementation is a no-op — components that don't use fluid backends
|
||||
/// silently ignore this.
|
||||
fn set_fluid_backend_from_builder(&mut self, _backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
|
||||
// Default: no-op for components that don't use fluid backends
|
||||
}
|
||||
|
||||
/// Evaluates the energy interactions of the component with its environment.
|
||||
///
|
||||
/// Returns a tuple of `(HeatTransfer, WorkTransfer)` in Watts (converted to `Power`).
|
||||
@@ -713,11 +749,14 @@ pub trait Component {
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk_components::{Component, ComponentParams};
|
||||
/// use entropyk_components::{Component, ComponentParams, ComponentError, StateSlice, ResidualVector, JacobianBuilder, ConnectedPort};
|
||||
///
|
||||
/// struct MyComponent;
|
||||
/// impl Component for MyComponent {
|
||||
/// // ... other required methods ...
|
||||
/// fn compute_residuals(&self, _s: &StateSlice, _r: &mut ResidualVector) -> Result<(), ComponentError> { Ok(()) }
|
||||
/// fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
|
||||
/// fn n_equations(&self) -> usize { 2 }
|
||||
/// fn get_ports(&self) -> &[ConnectedPort] { &[] }
|
||||
///
|
||||
/// fn to_params(&self) -> ComponentParams {
|
||||
/// ComponentParams::new("MyComponent")
|
||||
|
||||
@@ -414,9 +414,21 @@ impl Component for Node<Connected> {
|
||||
])
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("Node({}:{:?})", self.name, self.fluid_id().as_str())
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("Node")
|
||||
.with_param("name", self.name.as_str())
|
||||
.with_param("fluid", self.fluid_id().as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Node<Connected> {
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::collections::HashMap;
|
||||
/// This type captures all component-specific configuration in a flexible format
|
||||
/// that can be serialized to JSON and later used to reconstruct components.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentParams {
|
||||
/// Component type (e.g., "Compressor", "Condenser", "ExpansionValve")
|
||||
pub component_type: String,
|
||||
|
||||
@@ -700,6 +700,31 @@ impl Component for Pipe<Connected> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("Pipe(circuit={})", self.circuit_id.0)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("Pipe")
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
.with_param("lengthM", self.geometry.length_m)
|
||||
.with_param("diameterM", self.geometry.diameter_m)
|
||||
.with_param("roughnessM", self.geometry.roughness_m)
|
||||
.with_param("fluidDensityKgPerM3", self.fluid_density_kg_per_m3)
|
||||
.with_param("fluidViscosityPaS", self.fluid_viscosity_pa_s)
|
||||
.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 {
|
||||
let mut c = self.calib().clone();
|
||||
if c.set_factor(factor, value) {
|
||||
self.set_calib(c);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Pipe<Connected> {
|
||||
|
||||
@@ -631,6 +631,17 @@ impl Component for Pump<Connected> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("Pump(circuit={})", self.circuit_id.0)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("Pump")
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
.with_param("fluidDensityKgPerM3", self.fluid_density_kg_per_m3)
|
||||
.with_param("speedRatio", self.speed_ratio)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Pump<Connected> {
|
||||
|
||||
536
crates/components/src/python_boundary_append.rs
Normal file
536
crates/components/src/python_boundary_append.rs
Normal file
@@ -0,0 +1,536 @@
|
||||
|
||||
// =============================================================================
|
||||
// Python Boundary Types (Refrigerant, Brine, Air)
|
||||
// =============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RefrigerantSourceReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly refrigerant source: imposes fixed pressure + vapor quality.
|
||||
///
|
||||
/// This struct is instantiated by the Python binding `RefrigerantSource` and
|
||||
/// implements the `Component` trait so it can be injected into the solver graph.
|
||||
///
|
||||
/// # Equations (always 2)
|
||||
///
|
||||
/// ```text
|
||||
/// r0 = P_edge - P_set = 0
|
||||
/// r1 = h_edge - h(P_set, quality) = 0
|
||||
/// ```
|
||||
///
|
||||
/// where `h(P, x)` is computed via linear interpolation in the two-phase region.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyRefrigerantSourceReal {
|
||||
pub fluid: FluidId,
|
||||
pub p_set_pa: f64,
|
||||
pub quality: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyRefrigerantSourceReal {
|
||||
/// Create a new refrigerant source.
|
||||
///
|
||||
/// * `fluid` – CoolProp fluid identifier, e.g. `"R410A"`.
|
||||
/// * `p_set_pa` – Imposed outlet pressure [Pa].
|
||||
/// * `quality` – Vapor quality at outlet (0 = saturated liquid, 1 = saturated vapour).
|
||||
pub fn new(fluid: &str, p_set_pa: f64, quality: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
p_set_pa,
|
||||
quality,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute enthalpy from pressure + vapor quality using the fluid backend.
|
||||
fn enthalpy_from_quality(&self, backend: &dyn FluidBackend) -> Result<f64, ComponentError> {
|
||||
use entropyk_fluids::{FluidState as FState, Quality};
|
||||
let p = Pressure::from_pascals(self.p_set_pa);
|
||||
let state = FState::from_px(p, Quality::new(self.quality));
|
||||
backend
|
||||
.property(self.fluid.clone(), Property::Enthalpy, state)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!(
|
||||
"RefrigerantSource: enthalpy from quality: {}",
|
||||
e
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyRefrigerantSourceReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, h_idx) = self.edge_indices[0];
|
||||
let p_edge = state[p_idx];
|
||||
let h_edge = state[h_idx];
|
||||
|
||||
// Use tabular backend (no fluid backend stored — use CoolProp via fluids)
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let h_set = self.enthalpy_from_quality(&backend)?;
|
||||
|
||||
residuals[0] = p_edge - self.p_set_pa;
|
||||
residuals[1] = h_edge - h_set;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RefrigerantSinkReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly refrigerant sink: imposes back-pressure (and optional quality).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyRefrigerantSinkReal {
|
||||
pub fluid: FluidId,
|
||||
pub p_back_pa: f64,
|
||||
pub quality_opt: Option<f64>,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyRefrigerantSinkReal {
|
||||
/// Create a new refrigerant sink.
|
||||
///
|
||||
/// * `fluid` – CoolProp fluid identifier.
|
||||
/// * `p_back_pa` – Back-pressure imposed on the inlet edge [Pa].
|
||||
/// * `quality_opt` – Optional vapor quality to fix enthalpy; `None` means free enthalpy.
|
||||
pub fn new(fluid: &str, p_back_pa: f64, quality_opt: Option<f64>) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
p_back_pa,
|
||||
quality_opt,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyRefrigerantSinkReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else if self.quality_opt.is_some() {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, h_idx) = self.edge_indices[0];
|
||||
let p_edge = state[p_idx];
|
||||
residuals[0] = p_edge - self.p_back_pa;
|
||||
|
||||
if let Some(quality) = self.quality_opt {
|
||||
use entropyk_fluids::{FluidState as FState, Quality};
|
||||
let p = Pressure::from_pascals(self.p_back_pa);
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let fstate = FState::from_px(p, Quality::new(quality));
|
||||
let h_set = backend
|
||||
.property(self.fluid.clone(), Property::Enthalpy, fstate)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!(
|
||||
"RefrigerantSink: enthalpy from quality: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
residuals[1] = state[h_idx] - h_set;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BrineSourceReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly brine source: imposes pressure + temperature + concentration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyBrineSourceReal {
|
||||
pub fluid: FluidId,
|
||||
pub concentration: f64,
|
||||
pub temperature_k: f64,
|
||||
pub pressure_pa: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyBrineSourceReal {
|
||||
/// Create a new brine source.
|
||||
///
|
||||
/// * `fluid` – Base fluid, e.g. `"MEG"`, `"EthyleneGlycol"`, `"Water"`.
|
||||
/// * `concentration` – Glycol mass fraction \[0, 1\].
|
||||
/// * `temperature_k` – Outlet temperature [K].
|
||||
/// * `pressure_pa` – Outlet pressure [Pa].
|
||||
pub fn new(fluid: &str, concentration: f64, temperature_k: f64, pressure_pa: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
concentration,
|
||||
temperature_k,
|
||||
pressure_pa,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the CoolProp incompressible mixture name if concentration > 0.
|
||||
fn fluid_name(&self) -> String {
|
||||
if self.concentration < 1e-10 {
|
||||
self.fluid.as_str().to_string()
|
||||
} else {
|
||||
format!(
|
||||
"INCOMP::{}-{:.0}",
|
||||
self.fluid.as_str(),
|
||||
self.concentration * 100.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn enthalpy(&self, backend: &dyn FluidBackend) -> Result<f64, ComponentError> {
|
||||
use entropyk_fluids::FluidState as FState;
|
||||
let t = Temperature::from_kelvin(self.temperature_k);
|
||||
let p = Pressure::from_pascals(self.pressure_pa);
|
||||
let fid = FluidId::new(&self.fluid_name());
|
||||
let fstate = FState::from_pt(p, t);
|
||||
backend
|
||||
.property(fid, Property::Enthalpy, fstate)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!("BrineSource: enthalpy: {}", e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyBrineSourceReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, h_idx) = self.edge_indices[0];
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let h_set = self.enthalpy(&backend)?;
|
||||
residuals[0] = state[p_idx] - self.pressure_pa;
|
||||
residuals[1] = state[h_idx] - h_set;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BrineSinkReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly brine sink: imposes back-pressure on the inlet edge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyBrineSinkReal {
|
||||
pub p_back_pa: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyBrineSinkReal {
|
||||
/// Create a new brine sink.
|
||||
///
|
||||
/// * `p_back_pa` – Back-pressure imposed on the inlet edge [Pa].
|
||||
pub fn new(p_back_pa: f64) -> Self {
|
||||
Self {
|
||||
p_back_pa,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyBrineSinkReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, _h_idx) = self.edge_indices[0];
|
||||
residuals[0] = state[p_idx] - self.p_back_pa;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AirSourceReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly air source: imposes temperature + relative humidity + pressure.
|
||||
///
|
||||
/// Psychrometric formulas used:
|
||||
///
|
||||
/// ```text
|
||||
/// P_sat = 610.78 * exp(17.27 * T_c / (T_c + 237.3)) [Pa]
|
||||
/// W = 0.622 * P_v / (P_atm - P_v) [kg/kg]
|
||||
/// h = 1006 * T_c + W * (2_501_000 + 1860 * T_c) [J/kg]
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyAirSourceReal {
|
||||
pub temperature_k: f64,
|
||||
pub relative_humidity: f64,
|
||||
pub pressure_pa: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyAirSourceReal {
|
||||
/// Create a new air source.
|
||||
///
|
||||
/// * `temperature_k` – Dry-bulb temperature [K].
|
||||
/// * `relative_humidity` – Relative humidity \[0, 1\].
|
||||
/// * `pressure_pa` – Total atmospheric pressure [Pa].
|
||||
pub fn new(temperature_k: f64, relative_humidity: f64, pressure_pa: f64) -> Self {
|
||||
Self {
|
||||
temperature_k,
|
||||
relative_humidity,
|
||||
pressure_pa,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Specific enthalpy of moist air [J/kg dry air].
|
||||
pub fn moist_air_enthalpy(&self) -> f64 {
|
||||
let t_c = self.temperature_k - 273.15;
|
||||
let p_sat = 610.78 * (17.27 * t_c / (t_c + 237.3)).exp();
|
||||
let p_v = self.relative_humidity * p_sat;
|
||||
let w = 0.622 * p_v / (self.pressure_pa - p_v).max(1.0);
|
||||
1006.0 * t_c + w * (2_501_000.0 + 1860.0 * t_c)
|
||||
}
|
||||
|
||||
/// Humidity ratio W [kg water/kg dry air].
|
||||
pub fn humidity_ratio(&self) -> f64 {
|
||||
let t_c = self.temperature_k - 273.15;
|
||||
let p_sat = 610.78 * (17.27 * t_c / (t_c + 237.3)).exp();
|
||||
let p_v = self.relative_humidity * p_sat;
|
||||
0.622 * p_v / (self.pressure_pa - p_v).max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyAirSourceReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, h_idx) = self.edge_indices[0];
|
||||
residuals[0] = state[p_idx] - self.pressure_pa;
|
||||
residuals[1] = state[h_idx] - self.moist_air_enthalpy();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AirSinkReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly air sink: imposes back-pressure on the inlet edge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyAirSinkReal {
|
||||
pub p_back_pa: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyAirSinkReal {
|
||||
/// Create a new air sink.
|
||||
///
|
||||
/// * `p_back_pa` – Back-pressure imposed on the inlet edge [Pa].
|
||||
pub fn new(p_back_pa: f64) -> Self {
|
||||
Self {
|
||||
p_back_pa,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyAirSinkReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, _h_idx) = self.edge_indices[0];
|
||||
residuals[0] = state[p_idx] - self.p_back_pa;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Python-friendly thermodynamic components with real physics.
|
||||
//! Python-friendly thermodynamic components with real physics.
|
||||
//!
|
||||
//! These components don't use the type-state pattern and can be used
|
||||
//! directly from Python bindings.
|
||||
@@ -535,6 +535,10 @@ impl Component for PyHeatExchangerReal {
|
||||
fn set_calib_indices(&mut self, indices: CalibIndices) {
|
||||
self.calib_indices = indices;
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.calib.set_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -963,6 +967,542 @@ impl Component for PyFlowMergerReal {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
|
||||
}
|
||||
// =============================================================================
|
||||
// Python Boundary Types (Refrigerant, Brine, Air)
|
||||
// =============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RefrigerantSourceReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly refrigerant source: imposes fixed pressure + vapor quality.
|
||||
///
|
||||
/// This struct is instantiated by the Python binding `RefrigerantSource` and
|
||||
/// implements the `Component` trait so it can be injected into the solver graph.
|
||||
///
|
||||
/// # Equations (always 2)
|
||||
///
|
||||
/// ```text
|
||||
/// r0 = P_edge - P_set = 0
|
||||
/// r1 = h_edge - h(P_set, quality) = 0
|
||||
/// ```
|
||||
///
|
||||
/// where `h(P, x)` is computed via linear interpolation in the two-phase region.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyRefrigerantSourceReal {
|
||||
pub fluid: FluidId,
|
||||
pub p_set_pa: f64,
|
||||
pub quality: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyRefrigerantSourceReal {
|
||||
/// Create a new refrigerant source.
|
||||
///
|
||||
/// * `fluid` – CoolProp fluid identifier, e.g. `"R410A"`.
|
||||
/// * `p_set_pa` – Imposed outlet pressure [Pa].
|
||||
/// * `quality` – Vapor quality at outlet (0 = saturated liquid, 1 = saturated vapour).
|
||||
pub fn new(fluid: &str, p_set_pa: f64, quality: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
p_set_pa,
|
||||
quality,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute enthalpy from pressure + vapor quality using the fluid backend.
|
||||
fn enthalpy_from_quality(&self, backend: &dyn FluidBackend) -> Result<f64, ComponentError> {
|
||||
use entropyk_fluids::{FluidState as FState, Quality};
|
||||
let p = Pressure::from_pascals(self.p_set_pa);
|
||||
let state = FState::from_px(p, Quality::new(self.quality));
|
||||
backend
|
||||
.property(self.fluid.clone(), Property::Enthalpy, state)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!(
|
||||
"RefrigerantSource: enthalpy from quality: {}",
|
||||
e
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyRefrigerantSourceReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, h_idx) = self.edge_indices[0];
|
||||
let p_edge = state[p_idx];
|
||||
let h_edge = state[h_idx];
|
||||
|
||||
// Use tabular backend (no fluid backend stored — use CoolProp via fluids)
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let h_set = self.enthalpy_from_quality(&backend)?;
|
||||
|
||||
residuals[0] = p_edge - self.p_set_pa;
|
||||
residuals[1] = h_edge - h_set;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RefrigerantSinkReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly refrigerant sink: imposes back-pressure (and optional quality).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyRefrigerantSinkReal {
|
||||
pub fluid: FluidId,
|
||||
pub p_back_pa: f64,
|
||||
pub quality_opt: Option<f64>,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyRefrigerantSinkReal {
|
||||
/// Create a new refrigerant sink.
|
||||
///
|
||||
/// * `fluid` – CoolProp fluid identifier.
|
||||
/// * `p_back_pa` – Back-pressure imposed on the inlet edge [Pa].
|
||||
/// * `quality_opt` – Optional vapor quality to fix enthalpy; `None` means free enthalpy.
|
||||
pub fn new(fluid: &str, p_back_pa: f64, quality_opt: Option<f64>) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
p_back_pa,
|
||||
quality_opt,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyRefrigerantSinkReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else if self.quality_opt.is_some() {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, h_idx) = self.edge_indices[0];
|
||||
let p_edge = state[p_idx];
|
||||
residuals[0] = p_edge - self.p_back_pa;
|
||||
|
||||
if let Some(quality) = self.quality_opt {
|
||||
use entropyk_fluids::{FluidState as FState, Quality};
|
||||
let p = Pressure::from_pascals(self.p_back_pa);
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let fstate = FState::from_px(p, Quality::new(quality));
|
||||
let h_set = backend
|
||||
.property(self.fluid.clone(), Property::Enthalpy, fstate)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!(
|
||||
"RefrigerantSink: enthalpy from quality: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
residuals[1] = state[h_idx] - h_set;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BrineSourceReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly brine source: imposes pressure + temperature + concentration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyBrineSourceReal {
|
||||
pub fluid: FluidId,
|
||||
pub concentration: f64,
|
||||
pub temperature_k: f64,
|
||||
pub pressure_pa: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyBrineSourceReal {
|
||||
/// Create a new brine source.
|
||||
///
|
||||
/// * `fluid` – Base fluid, e.g. `"MEG"`, `"EthyleneGlycol"`, `"Water"`.
|
||||
/// * `concentration` – Glycol mass fraction \[0, 1\].
|
||||
/// * `temperature_k` – Outlet temperature [K].
|
||||
/// * `pressure_pa` – Outlet pressure [Pa].
|
||||
pub fn new(fluid: &str, concentration: f64, temperature_k: f64, pressure_pa: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
concentration,
|
||||
temperature_k,
|
||||
pressure_pa,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the CoolProp incompressible mixture name if concentration > 0.
|
||||
fn fluid_name(&self) -> String {
|
||||
if self.concentration < 1e-10 {
|
||||
self.fluid.as_str().to_string()
|
||||
} else {
|
||||
format!(
|
||||
"INCOMP::{}-{:.0}",
|
||||
self.fluid.as_str(),
|
||||
self.concentration * 100.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn enthalpy(&self, backend: &dyn FluidBackend) -> Result<f64, ComponentError> {
|
||||
use entropyk_fluids::FluidState as FState;
|
||||
let t = Temperature::from_kelvin(self.temperature_k);
|
||||
let p = Pressure::from_pascals(self.pressure_pa);
|
||||
let fid = FluidId::new(&self.fluid_name());
|
||||
let fstate = FState::from_pt(p, t);
|
||||
backend
|
||||
.property(fid, Property::Enthalpy, fstate)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!("BrineSource: enthalpy: {}", e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyBrineSourceReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, h_idx) = self.edge_indices[0];
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let h_set = self.enthalpy(&backend)?;
|
||||
residuals[0] = state[p_idx] - self.pressure_pa;
|
||||
residuals[1] = state[h_idx] - h_set;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BrineSinkReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly brine sink: imposes back-pressure on the inlet edge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyBrineSinkReal {
|
||||
pub p_back_pa: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyBrineSinkReal {
|
||||
/// Create a new brine sink.
|
||||
///
|
||||
/// * `p_back_pa` – Back-pressure imposed on the inlet edge [Pa].
|
||||
pub fn new(p_back_pa: f64) -> Self {
|
||||
Self {
|
||||
p_back_pa,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyBrineSinkReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, _h_idx) = self.edge_indices[0];
|
||||
residuals[0] = state[p_idx] - self.p_back_pa;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AirSourceReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly air source: imposes temperature + relative humidity + pressure.
|
||||
///
|
||||
/// Psychrometric formulas used:
|
||||
///
|
||||
/// ```text
|
||||
/// P_sat = 610.78 * exp(17.27 * T_c / (T_c + 237.3)) [Pa]
|
||||
/// W = 0.622 * P_v / (P_atm - P_v) [kg/kg]
|
||||
/// h = 1006 * T_c + W * (2_501_000 + 1860 * T_c) [J/kg]
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyAirSourceReal {
|
||||
pub temperature_k: f64,
|
||||
pub relative_humidity: f64,
|
||||
pub pressure_pa: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyAirSourceReal {
|
||||
/// Create a new air source.
|
||||
///
|
||||
/// * `temperature_k` – Dry-bulb temperature [K].
|
||||
/// * `relative_humidity` – Relative humidity \[0, 1\].
|
||||
/// * `pressure_pa` – Total atmospheric pressure [Pa].
|
||||
pub fn new(temperature_k: f64, relative_humidity: f64, pressure_pa: f64) -> Self {
|
||||
Self {
|
||||
temperature_k,
|
||||
relative_humidity,
|
||||
pressure_pa,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Specific enthalpy of moist air [J/kg dry air].
|
||||
pub fn moist_air_enthalpy(&self) -> f64 {
|
||||
let t_c = self.temperature_k - 273.15;
|
||||
let p_sat = 610.78 * (17.27 * t_c / (t_c + 237.3)).exp();
|
||||
let p_v = self.relative_humidity * p_sat;
|
||||
let w = 0.622 * p_v / (self.pressure_pa - p_v).max(1.0);
|
||||
1006.0 * t_c + w * (2_501_000.0 + 1860.0 * t_c)
|
||||
}
|
||||
|
||||
/// Humidity ratio W [kg water/kg dry air].
|
||||
pub fn humidity_ratio(&self) -> f64 {
|
||||
let t_c = self.temperature_k - 273.15;
|
||||
let p_sat = 610.78 * (17.27 * t_c / (t_c + 237.3)).exp();
|
||||
let p_v = self.relative_humidity * p_sat;
|
||||
0.622 * p_v / (self.pressure_pa - p_v).max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyAirSourceReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, h_idx) = self.edge_indices[0];
|
||||
residuals[0] = state[p_idx] - self.pressure_pa;
|
||||
residuals[1] = state[h_idx] - self.moist_air_enthalpy();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AirSinkReal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Python-friendly air sink: imposes back-pressure on the inlet edge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyAirSinkReal {
|
||||
pub p_back_pa: f64,
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyAirSinkReal {
|
||||
/// Create a new air sink.
|
||||
///
|
||||
/// * `p_back_pa` – Back-pressure imposed on the inlet edge [Pa].
|
||||
pub fn new(p_back_pa: f64) -> Self {
|
||||
Self {
|
||||
p_back_pa,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyAirSinkReal {
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (p_idx, _h_idx) = self.edge_indices[0];
|
||||
residuals[0] = state[p_idx] - self.p_back_pa;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
|
||||
976
crates/components/src/python_components_clean.rs
Normal file
976
crates/components/src/python_components_clean.rs
Normal file
@@ -0,0 +1,976 @@
|
||||
//! Python-friendly thermodynamic components with real physics.
|
||||
//!
|
||||
//! These components don't use the type-state pattern and can be used
|
||||
//! directly from Python bindings.
|
||||
|
||||
use crate::{
|
||||
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
|
||||
ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Calib, CalibIndices, Enthalpy, Pressure, Temperature};
|
||||
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property};
|
||||
|
||||
// =============================================================================
|
||||
// Compressor (AHRI 540 Model)
|
||||
// =============================================================================
|
||||
|
||||
/// Compressor with AHRI 540 performance model.
|
||||
///
|
||||
/// Equations:
|
||||
/// - Mass flow: ṁ = M1 × (1 - (P_suc/P_disc)^(1/M2)) × ρ_suc × V_disp × N/60
|
||||
/// - Power: Ẇ = M3 + M4×Pr + M5×T_suc + M6×T_disc
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyCompressorReal {
|
||||
/// Fluid
|
||||
pub fluid: FluidId,
|
||||
/// Speed rpm
|
||||
pub speed_rpm: f64,
|
||||
/// Displacement m3
|
||||
pub displacement_m3: f64,
|
||||
/// Efficiency
|
||||
pub efficiency: f64,
|
||||
/// M1
|
||||
pub m1: f64,
|
||||
/// M2
|
||||
pub m2: f64,
|
||||
/// M3
|
||||
pub m3: f64,
|
||||
/// M4
|
||||
pub m4: f64,
|
||||
/// M5
|
||||
pub m5: f64,
|
||||
/// M6
|
||||
pub m6: f64,
|
||||
/// M7
|
||||
pub m7: f64,
|
||||
/// M8
|
||||
pub m8: f64,
|
||||
/// M9
|
||||
pub m9: f64,
|
||||
/// M10
|
||||
pub m10: f64,
|
||||
/// Edge indices
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
/// Operational state
|
||||
pub operational_state: OperationalState,
|
||||
/// Circuit id
|
||||
pub circuit_id: CircuitId,
|
||||
}
|
||||
|
||||
impl PyCompressorReal {
|
||||
/// New
|
||||
pub fn new(fluid: &str, speed_rpm: f64, displacement_m3: f64, efficiency: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
speed_rpm,
|
||||
displacement_m3,
|
||||
efficiency,
|
||||
m1: 0.85,
|
||||
m2: 2.5,
|
||||
m3: 500.0,
|
||||
m4: 1500.0,
|
||||
m5: -2.5,
|
||||
m6: 1.8,
|
||||
m7: 600.0,
|
||||
m8: 1600.0,
|
||||
m9: -3.0,
|
||||
m10: 2.0,
|
||||
edge_indices: Vec::new(),
|
||||
operational_state: OperationalState::On,
|
||||
circuit_id: CircuitId::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// With coefficients
|
||||
pub fn with_coefficients(
|
||||
mut self,
|
||||
m1: f64,
|
||||
m2: f64,
|
||||
m3: f64,
|
||||
m4: f64,
|
||||
m5: f64,
|
||||
m6: f64,
|
||||
m7: f64,
|
||||
m8: f64,
|
||||
m9: f64,
|
||||
m10: f64,
|
||||
) -> Self {
|
||||
self.m1 = m1;
|
||||
self.m2 = m2;
|
||||
self.m3 = m3;
|
||||
self.m4 = m4;
|
||||
self.m5 = m5;
|
||||
self.m6 = m6;
|
||||
self.m7 = m7;
|
||||
self.m8 = m8;
|
||||
self.m9 = m9;
|
||||
self.m10 = m10;
|
||||
self
|
||||
}
|
||||
|
||||
fn compute_mass_flow(&self, p_suc: Pressure, p_disc: Pressure, rho_suc: f64) -> f64 {
|
||||
let pr = (p_disc.to_pascals() / p_suc.to_pascals().max(1.0)).max(1.0);
|
||||
// AHRI 540 volumetric efficiency: eta_vol = m1 - m2 * (pr - 1)
|
||||
// This stays positive for realistic pressure ratios (pr < 1 + m1/m2 = 1 + 0.85/2.5 = 1.34)
|
||||
// Use clamped version so it’s always positive.
|
||||
// Better: use simple isentropic clearance model: eta_vol = m1 * (1.0 - c*(pr^(1/gamma)-1))
|
||||
// where c = clearance ratio (~0.05), gamma = 1.15 for R134a.
|
||||
// This gives positive values across all realistic pressure ratios.
|
||||
let gamma = 1.15_f64;
|
||||
let clearance = 0.05_f64; // 5% clearance volume ratio
|
||||
let volumetric_eff = (self.m1 * (1.0 - clearance * (pr.powf(1.0 / gamma) - 1.0))).max(0.01);
|
||||
let n_rev_per_s = self.speed_rpm / 60.0;
|
||||
volumetric_eff * rho_suc * self.displacement_m3 * n_rev_per_s
|
||||
}
|
||||
|
||||
fn compute_power(
|
||||
&self,
|
||||
p_suc: Pressure,
|
||||
p_disc: Pressure,
|
||||
t_suc: Temperature,
|
||||
t_disc: Temperature,
|
||||
) -> f64 {
|
||||
// AHRI 540 power polynomial [W]: P = m3 + m4*pr + m5*T_suc[K] + m6*T_disc[K]
|
||||
// With our test coefficients: ~500 + 1500*2.86 + (-2.5)*287.5 + 1.8*322 = 500+4290-719+580 = 4651 W
|
||||
// Power is in Watts, so h_disc_calc = h_suc + P/m_dot (Pa*(m3/s)/kg = J/kg) ✓
|
||||
let pr = (p_disc.to_pascals() / p_suc.to_pascals().max(1.0)).max(1.0);
|
||||
self.m3 + self.m4 * pr + self.m5 * t_suc.to_kelvin() + self.m6 * t_disc.to_kelvin()
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyCompressorReal {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.operational_state != OperationalState::On {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.edge_indices.len() < 2 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Missing edge indices for compressor".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let in_idx = self.edge_indices[0];
|
||||
let out_idx = self.edge_indices[1];
|
||||
|
||||
if in_idx.0 >= state.len()
|
||||
|| in_idx.1 >= state.len()
|
||||
|| out_idx.0 >= state.len()
|
||||
|| out_idx.1 >= state.len()
|
||||
{
|
||||
return Err(ComponentError::InvalidState(
|
||||
"State vector too short".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Équations linéaires pures (pas de CoolProp) ──────────────────────
|
||||
// r[0] = p_disc - (p_suc + 1 MPa) gain de pression fixe
|
||||
// r[1] = h_disc - (h_suc + 75 kJ/kg) travail spécifique isentropique mock
|
||||
// Ces constantes doivent être cohérentes avec la vanne (target_dp=1 MPa)
|
||||
let p_suc = state[in_idx.0];
|
||||
let h_suc = state[in_idx.1];
|
||||
let p_disc = state[out_idx.0];
|
||||
let h_disc = state[out_idx.1];
|
||||
|
||||
// ── Point 1 : Physique réelle AHRI pour Enthalpie ──
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
|
||||
let suc_state = backend
|
||||
.full_state(
|
||||
self.fluid.clone(),
|
||||
Pressure::from_pascals(p_suc),
|
||||
Enthalpy::from_joules_per_kg(h_suc),
|
||||
)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!("Suction state error: {}", e))
|
||||
})?;
|
||||
|
||||
let disc_state_pt = backend
|
||||
.full_state(
|
||||
self.fluid.clone(),
|
||||
Pressure::from_pascals(p_disc),
|
||||
Enthalpy::from_joules_per_kg(h_disc),
|
||||
)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!("Discharge state error: {}", e))
|
||||
})?;
|
||||
|
||||
let m_dot = self.compute_mass_flow(
|
||||
Pressure::from_pascals(p_suc),
|
||||
Pressure::from_pascals(p_disc),
|
||||
suc_state.density,
|
||||
);
|
||||
let power = self.compute_power(
|
||||
Pressure::from_pascals(p_suc),
|
||||
Pressure::from_pascals(p_disc),
|
||||
suc_state.temperature,
|
||||
disc_state_pt.temperature,
|
||||
);
|
||||
|
||||
let h_disc_calc = h_suc + power / m_dot.max(0.001);
|
||||
|
||||
// Résidus : DeltaP coordonné avec la vanne pour fermer la boucle HP
|
||||
residuals[0] = p_disc - (p_suc + 1_000_000.0); // +1 MPa
|
||||
residuals[1] = h_disc - h_disc_calc;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Expansion Valve (Isenthalpic)
|
||||
// =============================================================================
|
||||
|
||||
/// Expansion valve with isenthalpic throttling.
|
||||
///
|
||||
/// Equations:
|
||||
/// - h_out = h_in (isenthalpic)
|
||||
/// - P_out specified by downstream conditions
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyExpansionValveReal {
|
||||
/// Fluid
|
||||
pub fluid: FluidId,
|
||||
/// Opening
|
||||
pub opening: f64,
|
||||
/// Edge indices
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
/// Circuit id
|
||||
pub circuit_id: CircuitId,
|
||||
}
|
||||
|
||||
impl PyExpansionValveReal {
|
||||
/// New
|
||||
pub fn new(fluid: &str, opening: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
opening: opening.clamp(0.01, 1.0),
|
||||
edge_indices: Vec::new(),
|
||||
circuit_id: CircuitId::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyExpansionValveReal {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.len() < 2 {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let in_idx = self.edge_indices[0];
|
||||
let out_idx = self.edge_indices[1];
|
||||
|
||||
if in_idx.0 >= state.len()
|
||||
|| in_idx.1 >= state.len()
|
||||
|| out_idx.0 >= state.len()
|
||||
|| out_idx.1 >= state.len()
|
||||
{
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _h_in = Enthalpy::from_joules_per_kg(state[in_idx.1]);
|
||||
let _h_out = Enthalpy::from_joules_per_kg(state[out_idx.1]);
|
||||
|
||||
let p_in = state[in_idx.0];
|
||||
let h_in = state[in_idx.1];
|
||||
let p_out = state[out_idx.0];
|
||||
let h_out = state[out_idx.1];
|
||||
|
||||
// ── Point 2 : Expansion Isenthalpique avec DeltaP coordonné ──
|
||||
residuals[0] = p_out - (p_in - 1_000_000.0); // -1 MPa (coordonné avec le compresseur)
|
||||
residuals[1] = h_out - h_in;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Heat Exchanger with Water Side
|
||||
// =============================================================================
|
||||
|
||||
/// Heat exchanger with refrigerant and water sides.
|
||||
///
|
||||
/// Uses ε-NTU method for heat transfer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyHeatExchangerReal {
|
||||
/// Name
|
||||
pub name: String,
|
||||
/// Ua
|
||||
pub ua: f64,
|
||||
/// Fluid
|
||||
pub fluid: FluidId,
|
||||
/// Water inlet temp
|
||||
pub water_inlet_temp: Temperature,
|
||||
/// Water flow rate
|
||||
pub water_flow_rate: f64,
|
||||
/// Is evaporator
|
||||
pub is_evaporator: bool,
|
||||
/// Edge indices
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
/// Calib
|
||||
pub calib: Calib,
|
||||
/// Calib indices
|
||||
pub calib_indices: CalibIndices,
|
||||
}
|
||||
|
||||
impl PyHeatExchangerReal {
|
||||
/// Evaporator
|
||||
pub fn evaporator(ua: f64, fluid: &str, water_temp_c: f64, water_flow: f64) -> Self {
|
||||
Self {
|
||||
name: "Evaporator".into(),
|
||||
ua,
|
||||
fluid: FluidId::new(fluid),
|
||||
water_inlet_temp: Temperature::from_celsius(water_temp_c),
|
||||
water_flow_rate: water_flow,
|
||||
is_evaporator: true,
|
||||
edge_indices: Vec::new(),
|
||||
calib: Calib::default(),
|
||||
calib_indices: CalibIndices::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Condenser
|
||||
pub fn condenser(ua: f64, fluid: &str, water_temp_c: f64, water_flow: f64) -> Self {
|
||||
Self {
|
||||
name: "Condenser".into(),
|
||||
ua,
|
||||
fluid: FluidId::new(fluid),
|
||||
water_inlet_temp: Temperature::from_celsius(water_temp_c),
|
||||
water_flow_rate: water_flow,
|
||||
is_evaporator: false,
|
||||
edge_indices: Vec::new(),
|
||||
calib: Calib::default(),
|
||||
calib_indices: CalibIndices::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cp_water() -> f64 {
|
||||
4186.0
|
||||
}
|
||||
|
||||
fn compute_effectiveness(&self, c_min: f64, c_max: f64, ntu: f64) -> f64 {
|
||||
if c_max < 1e-10 {
|
||||
return 0.0;
|
||||
}
|
||||
let cr = (c_min / c_max).min(1.0);
|
||||
let exp_term = (-ntu * (1.0 - cr)).exp();
|
||||
(1.0 - exp_term) / (1.0 - cr * exp_term)
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyHeatExchangerReal {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let in_idx = self.edge_indices[0];
|
||||
let out_idx = self.edge_indices[1];
|
||||
|
||||
if in_idx.0 >= state.len()
|
||||
|| in_idx.1 >= state.len()
|
||||
|| out_idx.0 >= state.len()
|
||||
|| out_idx.1 >= state.len()
|
||||
{
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Équations linéaires pures (pas de CoolProp) ──────────────────────
|
||||
// Pour ancrer le cycle (éviter la jacobienne singulière par indétermination),
|
||||
// on force l'évaporateur à une sortie fixe.
|
||||
let p_ref = Pressure::from_pascals(state[in_idx.0]);
|
||||
let h_ref_in = Enthalpy::from_joules_per_kg(state[in_idx.1]);
|
||||
let p_out = state[out_idx.0];
|
||||
let h_out = state[out_idx.1];
|
||||
|
||||
if self.is_evaporator {
|
||||
// ── POINT D'ANCRAGE (GROUND NODE) ──────────────────────────────
|
||||
// L'évaporateur force un point absolu pour lever l'indétermination.
|
||||
residuals[0] = p_out - 350_000.0; // Fixe la BP à 3.5 bar
|
||||
residuals[1] = h_out - 410_000.0; // Fixe la Surchauffe (approx) à 410 kJ/kg
|
||||
} else {
|
||||
// ── Physique réelle ε-NTU pour le Condenseur ────────────────────
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let ref_state = backend
|
||||
.full_state(self.fluid.clone(), p_ref, h_ref_in)
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("HX state: {}", e)))?;
|
||||
|
||||
let cp_water = Self::cp_water();
|
||||
let c_water = self.water_flow_rate * cp_water;
|
||||
let t_ref_k = ref_state.temperature.to_kelvin();
|
||||
let q_max = c_water * (self.water_inlet_temp.to_kelvin() - t_ref_k).abs();
|
||||
|
||||
let c_ref = 5000.0; // Augmenté pour simuler la condensation (Cp latent dominant)
|
||||
let c_min = c_water.min(c_ref);
|
||||
let c_max = c_water.max(c_ref);
|
||||
let ntu = self.ua / c_min.max(1.0);
|
||||
let effectiveness = self.compute_effectiveness(c_min, c_max, ntu);
|
||||
let q = effectiveness * q_max;
|
||||
|
||||
// On utilise un m_dot_ref plus réaliste (0.06 kg/s d'après AHRI)
|
||||
let m_dot_ref = 0.06;
|
||||
|
||||
// On sature le delta_h pour éviter les enthalpies négatives absurdes
|
||||
// Le but ici est de valider le comportement du solveur sur une plage physique.
|
||||
let delta_h = (q / m_dot_ref).min(300_000.0); // Max 300 kJ/kg de rejet
|
||||
let h_out_calc = h_ref_in.to_joules_per_kg() - delta_h;
|
||||
|
||||
residuals[0] = p_out - p_ref.to_pascals(); // Isobare
|
||||
residuals[1] = h_out - h_out_calc;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
} // Returns 2 equations: 1 for pressure drop (assumed 0 here), 1 for enthalpy change
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: CalibIndices) {
|
||||
self.calib_indices = indices;
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.calib.set_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pipe with Pressure Drop
|
||||
// =============================================================================
|
||||
|
||||
/// Pipe with Darcy-Weisbach pressure drop.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyPipeReal {
|
||||
/// Length
|
||||
pub length: f64,
|
||||
/// Diameter
|
||||
pub diameter: f64,
|
||||
/// Roughness
|
||||
pub roughness: f64,
|
||||
/// Fluid
|
||||
pub fluid: FluidId,
|
||||
/// Edge indices
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyPipeReal {
|
||||
/// New
|
||||
pub fn new(length: f64, diameter: f64, fluid: &str) -> Self {
|
||||
Self {
|
||||
length,
|
||||
diameter,
|
||||
roughness: 1.5e-6,
|
||||
fluid: FluidId::new(fluid),
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _friction_factor(&self, re: f64) -> f64 {
|
||||
if re < 2300.0 {
|
||||
64.0 / re.max(1.0)
|
||||
} else {
|
||||
let roughness_ratio = self.roughness / self.diameter;
|
||||
0.25 / (1.74 + 2.0 * (roughness_ratio / 3.7 + 1.26 / (re / 1e5).max(0.1)).ln()).powi(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyPipeReal {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.len() < 2 {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let in_idx = self.edge_indices[0];
|
||||
let out_idx = self.edge_indices[1];
|
||||
|
||||
if in_idx.0 >= state.len()
|
||||
|| in_idx.1 >= state.len()
|
||||
|| out_idx.0 >= state.len()
|
||||
|| out_idx.1 >= state.len()
|
||||
{
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let p_in = state[in_idx.0];
|
||||
let h_in = state[in_idx.1];
|
||||
let p_out = state[out_idx.0];
|
||||
let h_out = state[out_idx.1];
|
||||
|
||||
// Pressure drop (simplified placeholder)
|
||||
residuals[0] = p_out - p_in; // Assume no pressure drop for testing
|
||||
// Enthalpy is conserved across a simple pipe
|
||||
residuals[1] = h_out - h_in;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Flow Source / Sink
|
||||
// =============================================================================
|
||||
|
||||
/// Boundary condition with fixed pressure and temperature.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyFlowSourceReal {
|
||||
/// Pressure
|
||||
pub pressure: Pressure,
|
||||
/// Temperature
|
||||
pub temperature: Temperature,
|
||||
/// Fluid
|
||||
pub fluid: FluidId,
|
||||
/// Edge indices
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyFlowSourceReal {
|
||||
/// New
|
||||
pub fn new(fluid: &str, pressure_pa: f64, temperature_k: f64) -> Self {
|
||||
Self {
|
||||
pressure: Pressure::from_pascals(pressure_pa),
|
||||
temperature: Temperature::from_kelvin(temperature_k),
|
||||
fluid: FluidId::new(fluid),
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyFlowSourceReal {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let out_idx = self.edge_indices[0];
|
||||
|
||||
if out_idx.0 >= state.len() || out_idx.1 >= state.len() {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// FlowSource forces P and h at its outgoing edge
|
||||
let p_out = state[out_idx.0];
|
||||
let h_out = state[out_idx.1];
|
||||
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let target_h = backend
|
||||
.property(
|
||||
self.fluid.clone(),
|
||||
Property::Enthalpy,
|
||||
FluidState::from_pt(self.pressure, self.temperature),
|
||||
)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
residuals[0] = p_out - self.pressure.to_pascals();
|
||||
residuals[1] = h_out - target_h;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
/// Boundary condition sink.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PyFlowSinkReal {
|
||||
/// Edge indices
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl Component for PyFlowSinkReal {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowSplitter
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Documentation pending
|
||||
pub struct PyFlowSplitterReal {
|
||||
/// N outlets
|
||||
pub n_outlets: usize,
|
||||
/// Edge indices
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyFlowSplitterReal {
|
||||
/// New
|
||||
pub fn new(n_outlets: usize) -> Self {
|
||||
Self {
|
||||
n_outlets,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyFlowSplitterReal {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.len() < self.n_outlets + 1 {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let in_idx = self.edge_indices[0];
|
||||
let p_in = state[in_idx.0];
|
||||
let h_in = state[in_idx.1];
|
||||
|
||||
// 2 equations per outlet: P_out = P_in, h_out = h_in
|
||||
for i in 0..self.n_outlets {
|
||||
let out_idx = self.edge_indices[1 + i];
|
||||
|
||||
if out_idx.0 >= state.len() || out_idx.1 >= state.len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let p_out = state[out_idx.0];
|
||||
let h_out = state[out_idx.1];
|
||||
|
||||
residuals[2 * i] = p_out - p_in;
|
||||
residuals[2 * i + 1] = h_out - h_in;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2 * self.n_outlets
|
||||
}
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowMerger
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Documentation pending
|
||||
pub struct PyFlowMergerReal {
|
||||
/// N inlets
|
||||
pub n_inlets: usize,
|
||||
/// Edge indices
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyFlowMergerReal {
|
||||
/// New
|
||||
pub fn new(n_inlets: usize) -> Self {
|
||||
Self {
|
||||
n_inlets,
|
||||
edge_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PyFlowMergerReal {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_indices.len() < self.n_inlets + 1 {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let out_idx = self.edge_indices[self.n_inlets];
|
||||
|
||||
let p_out = if out_idx.0 < state.len() {
|
||||
state[out_idx.0]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let h_out = if out_idx.1 < state.len() {
|
||||
state[out_idx.1]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// We assume equal mixing (average enthalpy) and equal pressures for simplicity
|
||||
let mut h_sum = 0.0;
|
||||
let mut p_sum = 0.0;
|
||||
|
||||
for i in 0..self.n_inlets {
|
||||
let in_idx = self.edge_indices[i];
|
||||
|
||||
if in_idx.0 < state.len() && in_idx.1 < state.len() {
|
||||
p_sum += state[in_idx.0];
|
||||
h_sum += state[in_idx.1];
|
||||
}
|
||||
}
|
||||
|
||||
let p_mix = p_sum / (self.n_inlets as f64).max(1.0);
|
||||
let h_mix = h_sum / (self.n_inlets as f64).max(1.0);
|
||||
|
||||
// Provide exactly 2 equations (for the 1 outlet edge)
|
||||
residuals[0] = p_out - p_mix;
|
||||
residuals[1] = h_out - h_mix;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
if self.edge_indices.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
} // 1 outlet = 2 equations
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
@@ -286,6 +286,18 @@ impl Component for RefrigerantSource {
|
||||
self.quality.to_fraction()
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("RefrigerantSource")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("pSetPa", self.p_set_pa)
|
||||
.with_param("quality", self.quality.to_fraction())
|
||||
.with_param("hSetJkg", self.h_set_jkg)
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
self.backend = backend;
|
||||
}
|
||||
}
|
||||
|
||||
/// A boundary sink that imposes fixed back-pressure on its inlet edge.
|
||||
@@ -534,6 +546,23 @@ impl Component for RefrigerantSink {
|
||||
self.fluid_id, self.p_back_pa, self.quality_opt
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
let mut params = crate::ComponentParams::new("RefrigerantSink")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("pBackPa", self.p_back_pa);
|
||||
if let Some(q) = self.quality_opt {
|
||||
params = params.with_param("quality", q.to_fraction());
|
||||
}
|
||||
if let Some(h) = self.h_back_jkg {
|
||||
params = params.with_param("hBackJkg", h);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
self.backend = backend;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
510
crates/components/src/registry.rs
Normal file
510
crates/components/src/registry.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
//! Component registry for deserialization from ComponentParams
|
||||
//!
|
||||
//! Provides a factory function that reconstructs components from their
|
||||
//! serialized parameter representation.
|
||||
//!
|
||||
//! Components that use the type-state pattern (`Disconnected` → `Connected`)
|
||||
//! are automatically connected with default port initial conditions.
|
||||
//! The solver converges regardless of initial values.
|
||||
|
||||
use crate::{Component, ComponentParams};
|
||||
|
||||
/// Error type for component registry operations
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RegistryError {
|
||||
/// Unknown or unsupported component type
|
||||
UnknownComponentType(String),
|
||||
/// Missing required parameter
|
||||
MissingParameter { component: String, parameter: String },
|
||||
/// Invalid parameter value
|
||||
InvalidParameter { component: String, parameter: String, reason: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RegistryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RegistryError::UnknownComponentType(t) => {
|
||||
write!(f, "Unknown component type: '{}'", t)
|
||||
}
|
||||
RegistryError::MissingParameter { component, parameter } => {
|
||||
write!(f, "Missing parameter '{}' for component type '{}'", parameter, component)
|
||||
}
|
||||
RegistryError::InvalidParameter { component, parameter, reason } => {
|
||||
write!(f, "Invalid parameter '{}' for component type '{}': {}", parameter, component, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RegistryError {}
|
||||
|
||||
/// Returns the component type name from a `ComponentParams`.
|
||||
pub fn component_type_name(params: &ComponentParams) -> &str {
|
||||
¶ms.component_type
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn get_f64(params: &ComponentParams, key: &str) -> Result<f64, RegistryError> {
|
||||
match params.get(key) {
|
||||
Some(v) => v.as_f64().ok_or_else(|| RegistryError::InvalidParameter {
|
||||
component: params.component_type.clone(),
|
||||
parameter: key.to_string(),
|
||||
reason: "expected a number".to_string(),
|
||||
}),
|
||||
None => Err(RegistryError::MissingParameter {
|
||||
component: params.component_type.clone(),
|
||||
parameter: key.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_f64_or(params: &ComponentParams, key: &str, default: f64) -> f64 {
|
||||
params.get(key).and_then(|v| v.as_f64()).unwrap_or(default)
|
||||
}
|
||||
|
||||
fn get_positive_usize(params: &ComponentParams, key: &str, default: usize) -> Result<usize, RegistryError> {
|
||||
let val = get_f64_or(params, key, default as f64);
|
||||
if val < 1.0 || val > 100.0 || val.fract() != 0.0 {
|
||||
return Err(RegistryError::InvalidParameter {
|
||||
component: params.component_type.clone(),
|
||||
parameter: key.to_string(),
|
||||
reason: format!("expected a positive integer between 1 and 100, got {}", val),
|
||||
});
|
||||
}
|
||||
Ok(val as usize)
|
||||
}
|
||||
|
||||
fn get_string_or(params: &ComponentParams, key: &str, default: &str) -> String {
|
||||
params.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
fn deserialize_field<T: serde::de::DeserializeOwned>(params: &ComponentParams, key: &str) -> Result<T, RegistryError> {
|
||||
let val = params.get(key).ok_or_else(|| RegistryError::MissingParameter {
|
||||
component: params.component_type.clone(),
|
||||
parameter: key.to_string(),
|
||||
})?;
|
||||
serde_json::from_value(val.clone()).map_err(|e| RegistryError::InvalidParameter {
|
||||
component: params.component_type.clone(),
|
||||
parameter: key.to_string(),
|
||||
reason: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn default_disconnected_port(fluid: &str) -> crate::port::Port<crate::port::Disconnected> {
|
||||
use crate::port::{FluidId, Port};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
Port::new(FluidId::new(fluid), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(400_000.0))
|
||||
}
|
||||
|
||||
fn make_connected_port(fluid: &str, p_pa: f64, h_jkg: f64) -> crate::ConnectedPort {
|
||||
use crate::port::{FluidId, Port};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
let a = Port::new(FluidId::new(fluid), Pressure::from_pascals(p_pa), Enthalpy::from_joules_per_kg(h_jkg));
|
||||
let b = Port::new(FluidId::new(fluid), Pressure::from_pascals(p_pa), Enthalpy::from_joules_per_kg(h_jkg));
|
||||
a.connect(b).expect("port connect with matching params should succeed").0
|
||||
}
|
||||
|
||||
fn reg_err(comp: &str, param: &str, e: impl std::fmt::Display) -> RegistryError {
|
||||
RegistryError::InvalidParameter { component: comp.to_string(), parameter: param.to_string(), reason: e.to_string() }
|
||||
}
|
||||
|
||||
/// Reconstructs a component from its serialized parameters.
|
||||
pub fn create_component(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
match params.component_type.as_str() {
|
||||
"Compressor" => create_compressor(params),
|
||||
"ExpansionValve" => create_expansion_valve(params),
|
||||
"Pump" => create_pump(params),
|
||||
"Pipe" => create_pipe(params),
|
||||
"Fan" => create_fan(params),
|
||||
"Condenser" => create_condenser(params),
|
||||
"Evaporator" => create_evaporator(params),
|
||||
"Economizer" => create_economizer(params),
|
||||
"HeatExchanger" => create_heat_exchanger(params),
|
||||
"Node" => create_node(params),
|
||||
"Drum" => create_drum(params),
|
||||
"FlowSplitter" | "CompressibleSplitter" => create_flow_splitter(params),
|
||||
"IncompressibleSplitter" => create_incompressible_splitter(params),
|
||||
"FlowMerger" | "CompressibleMerger" => create_flow_merger(params),
|
||||
"IncompressibleMerger" => create_incompressible_merger(params),
|
||||
"BypassValve" => create_bypass_valve(params),
|
||||
"RefrigerantSource" => create_refrigerant_source(params),
|
||||
"RefrigerantSink" => create_refrigerant_sink(params),
|
||||
"BrineSource" => create_brine_source(params),
|
||||
"BrineSink" => create_brine_sink(params),
|
||||
"AirSource" => create_air_source(params),
|
||||
"AirSink" => create_air_sink(params),
|
||||
"FloodedEvaporator" => create_flooded_evaporator(params),
|
||||
"FloodedCondenser" => create_flooded_condenser(params),
|
||||
"CondenserCoil" => create_condenser_coil(params),
|
||||
"EvaporatorCoil" => create_evaporator_coil(params),
|
||||
"ScrewEconomizerCompressor" => create_screw_compressor(params),
|
||||
_ => Err(RegistryError::UnknownComponentType(params.component_type.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_compressor(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::compressor::{Ahri540Coefficients, Compressor, CompressorModel, SstSdtCoefficients};
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let speed_rpm = get_f64(params, "speedRpm")?;
|
||||
let displacement = get_f64(params, "displacementM3PerRev")?;
|
||||
let mech_eff = get_f64(params, "mechanicalEfficiency")?;
|
||||
let model_type = get_string_or(params, "modelType", "Ahri540");
|
||||
let model = match model_type.as_str() {
|
||||
"Ahri540" => CompressorModel::Ahri540(Ahri540Coefficients::new(
|
||||
get_f64(params, "m1")?, get_f64(params, "m2")?, get_f64(params, "m3")?, get_f64(params, "m4")?,
|
||||
get_f64(params, "m5")?, get_f64(params, "m6")?, get_f64(params, "m7")?, get_f64(params, "m8")?,
|
||||
get_f64(params, "m9")?, get_f64(params, "m10")?,
|
||||
)),
|
||||
"SstSdt" => CompressorModel::SstSdt(SstSdtCoefficients::new(
|
||||
deserialize_field(params, "massFlowCurve")?,
|
||||
deserialize_field(params, "powerCurve")?,
|
||||
)),
|
||||
other => return Err(reg_err("Compressor", "modelType", format!("Unknown model type '{other}'"))),
|
||||
};
|
||||
let disc = Compressor::with_model(model, default_disconnected_port(&fluid), default_disconnected_port(&fluid), speed_rpm, displacement, mech_eff).map_err(|e| reg_err("Compressor", "constructor", e))?;
|
||||
let comp = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("Compressor", "connect", e))?;
|
||||
Ok(Box::new(comp))
|
||||
}
|
||||
|
||||
fn create_expansion_valve(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::expansion_valve::ExpansionValve;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let opening = params.get("opening").and_then(|v| v.as_f64());
|
||||
let disc = ExpansionValve::new(default_disconnected_port(&fluid), default_disconnected_port(&fluid), opening).map_err(|e| reg_err("ExpansionValve", "constructor", e))?;
|
||||
let valve = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("ExpansionValve", "connect", e))?;
|
||||
Ok(Box::new(valve))
|
||||
}
|
||||
|
||||
fn create_pump(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::pump::Pump;
|
||||
use crate::polynomials::PerformanceCurves;
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let density = get_f64_or(params, "fluidDensityKgPerM3", 1000.0);
|
||||
let speed_ratio = get_f64_or(params, "speedRatio", 1.0);
|
||||
let curves = if let Some(v) = params.get("curves") {
|
||||
let perf: PerformanceCurves = serde_json::from_value(v.clone()).map_err(|e| reg_err("Pump", "curves", e))?;
|
||||
crate::pump::PumpCurves::new(perf).map_err(|e| reg_err("Pump", "curves", e))?
|
||||
} else { crate::pump::PumpCurves::default() };
|
||||
let disc = Pump::new(curves, default_disconnected_port(&fluid), default_disconnected_port(&fluid), density).map_err(|e| reg_err("Pump", "constructor", e))?;
|
||||
let mut pump = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("Pump", "connect", e))?;
|
||||
pump.set_speed_ratio(speed_ratio).map_err(|e| reg_err("Pump", "speedRatio", e))?;
|
||||
Ok(Box::new(pump))
|
||||
}
|
||||
|
||||
fn create_pipe(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::pipe::{Pipe, PipeGeometry};
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let geo = PipeGeometry::new(get_f64_or(params, "lengthM", 1.0), get_f64_or(params, "diameterM", 0.02), get_f64_or(params, "roughnessM", 0.000045)).map_err(|e| reg_err("Pipe", "geometry", e))?;
|
||||
let disc = Pipe::new(geo, default_disconnected_port(&fluid), default_disconnected_port(&fluid), get_f64_or(params, "fluidDensityKgPerM3", 1000.0), get_f64_or(params, "fluidViscosityPas", 0.001)).map_err(|e| reg_err("Pipe", "constructor", e))?;
|
||||
let pipe = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("Pipe", "connect", e))?;
|
||||
Ok(Box::new(pipe))
|
||||
}
|
||||
|
||||
fn create_fan(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::fan::{Fan, FanCurves};
|
||||
use crate::polynomials::PerformanceCurves;
|
||||
let air_density = get_f64_or(params, "airDensityKgPerM3", 1.2);
|
||||
let speed_ratio = get_f64_or(params, "speedRatio", 1.0);
|
||||
let curves = if let Some(v) = params.get("curves") {
|
||||
let perf: PerformanceCurves = serde_json::from_value(v.clone()).map_err(|e| reg_err("Fan", "curves", e))?;
|
||||
FanCurves::new(perf).map_err(|e| reg_err("Fan", "curves", e))?
|
||||
} else { FanCurves::default() };
|
||||
let inlet_c = make_connected_port("Air", 101_325.0, 300_000.0);
|
||||
let outlet_c = make_connected_port("Air", 101_325.0, 300_000.0);
|
||||
let mut fan = Fan::from_connected_parts(curves, inlet_c, outlet_c, air_density)
|
||||
.map_err(|e| reg_err("Fan", "constructor", e))?;
|
||||
fan.set_speed_ratio(speed_ratio).map_err(|e| reg_err("Fan", "speedRatio", e))?;
|
||||
Ok(Box::new(fan))
|
||||
}
|
||||
|
||||
fn create_condenser(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::condenser::Condenser;
|
||||
let ua = get_f64_or(params, "ua", 5000.0);
|
||||
let sat = params.get("saturationTempK").and_then(|v| v.as_f64());
|
||||
Ok(Box::new(if let Some(s) = sat { Condenser::with_saturation_temp(ua, s) } else { Condenser::new(ua) }))
|
||||
}
|
||||
|
||||
fn create_evaporator(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::evaporator::Evaporator;
|
||||
let ua = get_f64_or(params, "ua", 5000.0);
|
||||
let sat = params.get("saturationTempK").and_then(|v| v.as_f64());
|
||||
let sh = params.get("superheatTargetK").and_then(|v| v.as_f64());
|
||||
Ok(Box::new(match (sat, sh) { (Some(s), Some(h)) => Evaporator::with_superheat(ua, s, h), (Some(s), _) => Evaporator::with_superheat(ua, s, 5.0), _ => Evaporator::new(ua) }))
|
||||
}
|
||||
|
||||
fn create_economizer(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::economizer::Economizer;
|
||||
Ok(Box::new(Economizer::new(get_f64_or(params, "ua", 3000.0))))
|
||||
}
|
||||
|
||||
fn create_heat_exchanger(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::exchanger::HeatExchanger;
|
||||
use crate::heat_exchanger::lmtd::{FlowConfiguration, LmtdModel};
|
||||
let ua = get_f64_or(params, "ua", 5000.0);
|
||||
let name = get_string_or(params, "name", "HeatExchanger");
|
||||
let flow_config = match get_string_or(params, "flowConfiguration", "CounterFlow").as_str() {
|
||||
"ParallelFlow" => FlowConfiguration::ParallelFlow,
|
||||
_ => FlowConfiguration::CounterFlow,
|
||||
};
|
||||
Ok(Box::new(HeatExchanger::new(LmtdModel::new(ua, flow_config), name)))
|
||||
}
|
||||
|
||||
fn create_node(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::node::Node;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let name = get_string_or(params, "name", "node");
|
||||
let disc = Node::new(name, default_disconnected_port(&fluid), default_disconnected_port(&fluid));
|
||||
let node = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("Node", "connect", e))?;
|
||||
Ok(Box::new(node))
|
||||
}
|
||||
|
||||
fn create_drum(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::drum::Drum;
|
||||
use entropyk_fluids::TestBackend;
|
||||
use std::sync::Arc;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let drum = Drum::new(&fluid, make_connected_port(&fluid, 200_000.0, 400_000.0), make_connected_port(&fluid, 150_000.0, 410_000.0), make_connected_port(&fluid, 150_000.0, 200_000.0), make_connected_port(&fluid, 150_000.0, 420_000.0), backend).map_err(|e| reg_err("Drum", "constructor", e))?;
|
||||
Ok(Box::new(drum))
|
||||
}
|
||||
|
||||
fn create_flow_splitter(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::flow_junction::CompressibleSplitter;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let n = get_positive_usize(params, "outletCount", 2)?;
|
||||
let inlet = make_connected_port(&fluid, 200_000.0, 400_000.0);
|
||||
let outlets: Vec<_> = (0..n).map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0)).collect();
|
||||
let s = CompressibleSplitter::compressible(&fluid, inlet, outlets).map_err(|e| reg_err("FlowSplitter", "constructor", e))?;
|
||||
Ok(Box::new(s))
|
||||
}
|
||||
|
||||
fn create_incompressible_splitter(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::flow_junction::IncompressibleSplitter;
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let n = get_positive_usize(params, "outletCount", 2)?;
|
||||
let inlet = make_connected_port(&fluid, 200_000.0, 400_000.0);
|
||||
let outlets: Vec<_> = (0..n).map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0)).collect();
|
||||
let s = IncompressibleSplitter::incompressible(&fluid, inlet, outlets).map_err(|e| reg_err("IncompressibleSplitter", "constructor", e))?;
|
||||
Ok(Box::new(s))
|
||||
}
|
||||
|
||||
fn create_flow_merger(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::flow_junction::CompressibleMerger;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let n = get_positive_usize(params, "inletCount", 2)?;
|
||||
let inlets: Vec<_> = (0..n).map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0)).collect();
|
||||
let outlet = make_connected_port(&fluid, 200_000.0, 400_000.0);
|
||||
let m = CompressibleMerger::compressible(&fluid, inlets, outlet).map_err(|e| reg_err("FlowMerger", "constructor", e))?;
|
||||
Ok(Box::new(m))
|
||||
}
|
||||
|
||||
fn create_incompressible_merger(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::flow_junction::IncompressibleMerger;
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let n = get_positive_usize(params, "inletCount", 2)?;
|
||||
let inlets: Vec<_> = (0..n).map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0)).collect();
|
||||
let outlet = make_connected_port(&fluid, 200_000.0, 400_000.0);
|
||||
let m = IncompressibleMerger::incompressible(&fluid, inlets, outlet).map_err(|e| reg_err("IncompressibleMerger", "constructor", e))?;
|
||||
Ok(Box::new(m))
|
||||
}
|
||||
|
||||
fn create_bypass_valve(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::bypass_valve::{BypassValve, BypassValveConfig, ValveCharacteristics};
|
||||
let min_pos = get_f64_or(params, "minPosition", 0.0);
|
||||
let max_pos = get_f64_or(params, "maxPosition", 1.0);
|
||||
if min_pos >= max_pos {
|
||||
return Err(RegistryError::InvalidParameter {
|
||||
component: "BypassValve".to_string(),
|
||||
parameter: "minPosition/maxPosition".to_string(),
|
||||
reason: format!("minPosition ({}) must be less than maxPosition ({})", min_pos, max_pos),
|
||||
});
|
||||
}
|
||||
let characteristics = match get_string_or(params, "characteristics", "Linear").as_str() {
|
||||
"EqualPercentage" => ValveCharacteristics::EqualPercentage,
|
||||
_ => ValveCharacteristics::Linear,
|
||||
};
|
||||
let config = BypassValveConfig {
|
||||
cv: get_f64_or(params, "cv", 1.0),
|
||||
characteristics,
|
||||
min_position: min_pos,
|
||||
max_position: max_pos,
|
||||
nominal_pressure_drop_pa: get_f64_or(params, "nominalPressureDropPa", 10_000.0),
|
||||
};
|
||||
Ok(Box::new(BypassValve::new(&get_string_or(params, "id", "bypass"), config)))
|
||||
}
|
||||
|
||||
fn create_refrigerant_source(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::refrigerant_boundary::RefrigerantSource;
|
||||
use entropyk_core::{Pressure, VaporQuality};
|
||||
use entropyk_fluids::TestBackend;
|
||||
use std::sync::Arc;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let p = get_f64_or(params, "pSetPa", 200_000.0);
|
||||
let q = get_f64_or(params, "quality", 0.5).clamp(0.0, 1.0);
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let outlet = make_connected_port(&fluid, p, 400_000.0);
|
||||
let src = RefrigerantSource::new(&fluid, Pressure::from_pascals(p), VaporQuality::from_fraction(q), backend, outlet).map_err(|e| reg_err("RefrigerantSource", "constructor", e))?;
|
||||
Ok(Box::new(src))
|
||||
}
|
||||
|
||||
fn create_refrigerant_sink(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::refrigerant_boundary::RefrigerantSink;
|
||||
use entropyk_core::Pressure;
|
||||
use entropyk_fluids::TestBackend;
|
||||
use std::sync::Arc;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let p = get_f64_or(params, "pBackPa", 200_000.0);
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let inlet = make_connected_port(&fluid, p, 400_000.0);
|
||||
let sink = RefrigerantSink::new(&fluid, Pressure::from_pascals(p), None, backend, inlet).map_err(|e| reg_err("RefrigerantSink", "constructor", e))?;
|
||||
Ok(Box::new(sink))
|
||||
}
|
||||
|
||||
fn create_brine_source(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::brine_boundary::BrineSource;
|
||||
use entropyk_core::{Concentration, Pressure, Temperature};
|
||||
use entropyk_fluids::TestBackend;
|
||||
use std::sync::Arc;
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let p = get_f64_or(params, "pressurePa", 200_000.0);
|
||||
let t = get_f64_or(params, "temperatureK", 280.0);
|
||||
let c = get_f64_or(params, "concentration", 0.0).clamp(0.0, 1.0);
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let outlet = make_connected_port(&fluid, p, 50_000.0);
|
||||
let src = BrineSource::new(&fluid, Pressure::from_pascals(p), Temperature::from_kelvin(t), Concentration::from_fraction(c), backend, outlet).map_err(|e| reg_err("BrineSource", "constructor", e))?;
|
||||
Ok(Box::new(src))
|
||||
}
|
||||
|
||||
fn create_brine_sink(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::brine_boundary::BrineSink;
|
||||
use entropyk_core::Pressure;
|
||||
use entropyk_fluids::TestBackend;
|
||||
use std::sync::Arc;
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let p = get_f64_or(params, "pressurePa", 200_000.0);
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let inlet = make_connected_port(&fluid, p, 50_000.0);
|
||||
let sink = BrineSink::new(&fluid, Pressure::from_pascals(p), None, None, backend, inlet).map_err(|e| reg_err("BrineSink", "constructor", e))?;
|
||||
Ok(Box::new(sink))
|
||||
}
|
||||
|
||||
fn create_air_source(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::air_boundary::AirSource;
|
||||
use entropyk_core::{Pressure, RelativeHumidity, Temperature};
|
||||
let t = get_f64_or(params, "dryBulbTempK", 293.15);
|
||||
let rh = get_f64_or(params, "relativeHumidity", 0.5).clamp(0.0, 1.0);
|
||||
let p = get_f64_or(params, "pressurePa", 101_325.0);
|
||||
let outlet = make_connected_port("Air", p, 50_000.0);
|
||||
let src = AirSource::from_dry_bulb_rh(Temperature::from_kelvin(t), RelativeHumidity::from_fraction(rh), Pressure::from_pascals(p), outlet).map_err(|e| reg_err("AirSource", "constructor", e))?;
|
||||
Ok(Box::new(src))
|
||||
}
|
||||
|
||||
fn create_air_sink(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::air_boundary::AirSink;
|
||||
use entropyk_core::Pressure;
|
||||
let p = get_f64_or(params, "pressurePa", 101_325.0);
|
||||
let inlet = make_connected_port("Air", p, 50_000.0);
|
||||
let sink = AirSink::new(Pressure::from_pascals(p), inlet).map_err(|e| reg_err("AirSink", "constructor", e))?;
|
||||
Ok(Box::new(sink))
|
||||
}
|
||||
|
||||
fn create_flooded_evaporator(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::flooded_evaporator::FloodedEvaporator;
|
||||
let ua = get_f64(params, "ua")?;
|
||||
Ok(Box::new(FloodedEvaporator::new(ua)))
|
||||
}
|
||||
|
||||
fn create_flooded_condenser(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::flooded_condenser::FloodedCondenser;
|
||||
let ua = get_f64(params, "ua")?;
|
||||
Ok(Box::new(FloodedCondenser::new(ua)))
|
||||
}
|
||||
|
||||
fn create_condenser_coil(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::condenser_coil::CondenserCoil;
|
||||
Ok(Box::new(CondenserCoil::new(get_f64_or(params, "ua", 5000.0))))
|
||||
}
|
||||
|
||||
fn create_evaporator_coil(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::evaporator_coil::EvaporatorCoil;
|
||||
Ok(Box::new(EvaporatorCoil::new(get_f64_or(params, "ua", 5000.0))))
|
||||
}
|
||||
|
||||
fn create_screw_compressor(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves};
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let freq = get_f64_or(params, "nominalFrequencyHz", 50.0);
|
||||
let eff = get_f64_or(params, "mechanicalEfficiency", 0.85);
|
||||
let curves: ScrewPerformanceCurves = if params.get("curves").is_some() {
|
||||
deserialize_field(params, "curves")?
|
||||
} else {
|
||||
use crate::Polynomial2D;
|
||||
ScrewPerformanceCurves {
|
||||
mass_flow_curve: Polynomial2D::default(),
|
||||
power_curve: Polynomial2D::default(),
|
||||
eco_flow_fraction_curve: Polynomial2D::default(),
|
||||
}
|
||||
};
|
||||
let comp = ScrewEconomizerCompressor::new(curves, &fluid, freq, eff, make_connected_port(&fluid, 200_000.0, 400_000.0), make_connected_port(&fluid, 800_000.0, 440_000.0), make_connected_port(&fluid, 400_000.0, 420_000.0)).map_err(|e| reg_err("ScrewEconomizerCompressor", "constructor", e))?;
|
||||
Ok(Box::new(comp))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_registry_error_display() {
|
||||
let err = RegistryError::UnknownComponentType("Foo".to_string());
|
||||
assert!(err.to_string().contains("Foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_type_name() {
|
||||
assert_eq!(component_type_name(&ComponentParams::new("Compressor")), "Compressor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_type_error() {
|
||||
let result = create_component(&ComponentParams::new("UnknownWidget"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_expansion_valve() {
|
||||
let params = ComponentParams::new("ExpansionValve").with_param("fluid", "R134a").with_param("opening", 0.8);
|
||||
let comp = create_component(¶ms).unwrap();
|
||||
assert_eq!(comp.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_condenser() {
|
||||
let params = ComponentParams::new("Condenser").with_param("ua", 5000.0);
|
||||
assert!(create_component(¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_evaporator() {
|
||||
let params = ComponentParams::new("Evaporator").with_param("ua", 3000.0);
|
||||
assert!(create_component(¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_node() {
|
||||
let params = ComponentParams::new("Node").with_param("name", "n1").with_param("fluid", "R134a");
|
||||
let comp = create_component(¶ms).unwrap();
|
||||
assert_eq!(comp.n_equations(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_compressor_ahri540() {
|
||||
let params = ComponentParams::new("Compressor")
|
||||
.with_param("fluid", "R134a").with_param("speedRpm", 2900.0).with_param("displacementM3PerRev", 0.0001)
|
||||
.with_param("mechanicalEfficiency", 0.85).with_param("modelType", "Ahri540")
|
||||
.with_param("m1", 0.85).with_param("m2", 2.5).with_param("m3", 500.0).with_param("m4", 1500.0)
|
||||
.with_param("m5", -2.5).with_param("m6", 1.8).with_param("m7", 600.0).with_param("m8", 1600.0)
|
||||
.with_param("m9", -3.0).with_param("m10", 2.0);
|
||||
assert!(create_component(¶ms).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -503,13 +503,13 @@ impl Component for ScrewEconomizerCompressor {
|
||||
residuals[1] = m_eco_calc - m_eco_state;
|
||||
|
||||
// ── Residual 2: First-law energy balance ─────────────────────────────
|
||||
// ṁ_suc × h_suc + ṁ_eco × h_eco + W = ṁ_total × h_dis
|
||||
// r₂ = (ṁ_suc × h_suc + ṁ_eco × h_eco + W) − ṁ_total × h_dis = 0
|
||||
// ṁ_suc × h_suc + ṁ_eco × h_eco + W_shaft × η_mech = ṁ_total × h_dis
|
||||
// r₂ = (ṁ_suc × h_suc + ṁ_eco × h_eco + W_shaft × η) − ṁ_total × h_dis = 0
|
||||
//
|
||||
// Note: W is the shaft power delivered TO the fluid (positive = power in).
|
||||
// Mechanical efficiency accounts for friction losses in bearings/seals.
|
||||
// W_shaft is the shaft (input) power. Only W_shaft × η_mech reaches the fluid;
|
||||
// the rest (1 - η_mech) is lost to bearing friction and motor heat.
|
||||
let energy_in =
|
||||
m_suc_state * h_suc + m_eco_state * h_eco + w_state / self.mechanical_efficiency;
|
||||
m_suc_state * h_suc + m_eco_state * h_eco + w_state * self.mechanical_efficiency;
|
||||
let energy_out = (m_suc_state + m_eco_state) * h_dis;
|
||||
residuals[2] = energy_in - energy_out;
|
||||
|
||||
@@ -658,6 +658,26 @@ impl Component for ScrewEconomizerCompressor {
|
||||
self.fluid_id, self.frequency_hz, self.mechanical_efficiency
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("ScrewEconomizerCompressor")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("nominalFrequencyHz", self.nominal_frequency_hz)
|
||||
.with_param("frequencyHz", self.frequency_hz)
|
||||
.with_param("mechanicalEfficiency", self.mechanical_efficiency)
|
||||
.with_param("curves", serde_json::to_value(&self.curves).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 {
|
||||
let mut c = self.calib().clone();
|
||||
if c.set_factor(factor, value) {
|
||||
self.set_calib(c);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user