Update project structure and configurations

This commit is contained in:
2026-05-23 10:19:55 +02:00
parent ab5dc7e568
commit 62efea0646
1832 changed files with 83568 additions and 51829 deletions

View File

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

View File

@@ -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
}
}
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -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};

View File

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

View File

@@ -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();

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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())
}
}
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -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(&params).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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")

View File

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

View File

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

View File

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

View File

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

View 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();
}
}

View File

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

View 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 its 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();
}

View File

@@ -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)]

View 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 {
&params.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(&params).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(&params).is_ok());
}
#[test]
fn test_create_evaporator() {
let params = ComponentParams::new("Evaporator").with_param("ua", 3000.0);
assert!(create_component(&params).is_ok());
}
#[test]
fn test_create_node() {
let params = ComponentParams::new("Node").with_param("name", "n1").with_param("fluid", "R134a");
let comp = create_component(&params).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(&params).is_ok());
}
}

View File

@@ -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
}
}
}
// ─────────────────────────────────────────────────────────────────────────────