Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
303 lines
8.8 KiB
Rust
303 lines
8.8 KiB
Rust
//! Air-Cooled Condenser Component
|
||
//!
|
||
//! A condenseur à condensation par air où l'ingénieur fournit directement :
|
||
//! - `oat_k` : Outdoor Air Temperature [K] (OAT = température de l'air extérieur)
|
||
//! - `approach_k` : Approach temperature [K], différence de température entre la
|
||
//! condensation et l'air extérieur. Valeur typique : 10–15 K.
|
||
//! Défaut : 12 K (représentatif d'un chiller air-cooled industriel).
|
||
//!
|
||
//! La température de condensation est calculée automatiquement :
|
||
//! ```text
|
||
//! T_cond = OAT + approach_k
|
||
//! ```
|
||
//!
|
||
//! ## Équations (2)
|
||
//!
|
||
//! - r0 = P_out − P_sat(T_cond) [drive outlet pressure to condensing saturation]
|
||
//! - r1 = H_out − H_sat_liq(T_cond) [drive outlet enthalpy to saturated-liquid]
|
||
//!
|
||
//! ## Jacobian (analytique)
|
||
//!
|
||
//! - ∂r0/∂P_out = 1
|
||
//! - ∂r1/∂H_out = 1
|
||
//!
|
||
//! ## Exemple JSON
|
||
//!
|
||
//! ```json
|
||
//! {
|
||
//! "type": "AirCooledCondenser",
|
||
//! "name": "cond",
|
||
//! "oat_k": 308.15,
|
||
//! "approach_k": 12.0
|
||
//! }
|
||
//! ```
|
||
//!
|
||
//! Soit : OAT = 35°C → T_cond = 47°C → P_cond_sat(R290) ≈ 16.8 bar
|
||
|
||
use super::condenser::Condenser;
|
||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||
use crate::{
|
||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||
};
|
||
use entropyk_core::Calib;
|
||
use entropyk_fluids::FluidBackend;
|
||
use std::sync::Arc;
|
||
|
||
/// Air-cooled condenser : T_cond = OAT + approach.
|
||
///
|
||
/// L'ingénieur saisit directement la température extérieure (OAT) et
|
||
/// l'approche de condensation (approach_k). Aucune conversion manuelle
|
||
/// en `t_sat_k` n'est nécessaire.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use entropyk_components::heat_exchanger::AirCooledCondenser;
|
||
/// use entropyk_components::Component;
|
||
///
|
||
/// // OAT = 35°C, approach = 12 K → T_cond = 47°C = 320.15 K
|
||
/// let cond = AirCooledCondenser::new(308.15, 12.0);
|
||
/// assert_eq!(cond.n_equations(), 3); // 2 thermo + 1 mass-flow (CM1.3)
|
||
/// assert!((cond.t_cond_k() - 320.15).abs() < 1e-9);
|
||
/// ```
|
||
#[derive(Debug)]
|
||
pub struct AirCooledCondenser {
|
||
inner: Condenser,
|
||
oat_k: f64,
|
||
approach_k: f64,
|
||
}
|
||
|
||
impl AirCooledCondenser {
|
||
/// Crée un condenseur à air.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `oat_k` — Outdoor Air Temperature \[K\]
|
||
/// * `approach_k` — Approach temperature = T_cond − OAT \[K\]. Valeur typique : 10–15 K.
|
||
pub fn new(oat_k: f64, approach_k: f64) -> Self {
|
||
let t_cond_k = oat_k + approach_k;
|
||
Self {
|
||
inner: Condenser::with_saturation_temp(0.0, t_cond_k),
|
||
oat_k,
|
||
approach_k,
|
||
}
|
||
}
|
||
|
||
/// Crée un condenseur à air avec UA connu.
|
||
///
|
||
/// Le UA n'est pas utilisé dans les équations du solver (approach-based),
|
||
/// mais il est disponible pour les calculs de performance post-traitement.
|
||
pub fn with_ua(oat_k: f64, approach_k: f64, ua: f64) -> Self {
|
||
let t_cond_k = oat_k + approach_k;
|
||
Self {
|
||
inner: Condenser::with_saturation_temp(ua, t_cond_k),
|
||
oat_k,
|
||
approach_k,
|
||
}
|
||
}
|
||
|
||
/// Attache un identifiant de fluide réfrigérant.
|
||
pub fn with_refrigerant(mut self, id: &str) -> Self {
|
||
self.inner = self.inner.with_refrigerant(id);
|
||
self
|
||
}
|
||
|
||
/// Attache un backend de propriétés thermodynamiques.
|
||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||
self.inner = self.inner.with_fluid_backend(backend);
|
||
self
|
||
}
|
||
|
||
/// Retourne la température de condensation calculée [K].
|
||
pub fn t_cond_k(&self) -> f64 {
|
||
self.oat_k + self.approach_k
|
||
}
|
||
|
||
/// Retourne la température extérieure (OAT) [K].
|
||
pub fn oat_k(&self) -> f64 {
|
||
self.oat_k
|
||
}
|
||
|
||
/// Retourne l'approche de condensation [K].
|
||
pub fn approach_k(&self) -> f64 {
|
||
self.approach_k
|
||
}
|
||
|
||
/// Retourne le UA [W/K].
|
||
pub fn ua(&self) -> f64 {
|
||
self.inner.ua()
|
||
}
|
||
|
||
/// Retourne les facteurs de calibration.
|
||
pub fn calib(&self) -> &Calib {
|
||
self.inner.calib()
|
||
}
|
||
|
||
/// Met à jour les facteurs de calibration.
|
||
pub fn set_calib(&mut self, calib: Calib) {
|
||
self.inner.set_calib(calib);
|
||
}
|
||
|
||
/// Met à jour OAT en runtime (ex. simulation paramétrique).
|
||
pub fn set_oat_k(&mut self, oat_k: f64) {
|
||
self.oat_k = oat_k;
|
||
self.inner.set_saturation_temp(self.oat_k + self.approach_k);
|
||
}
|
||
|
||
/// Met à jour l'approche en runtime.
|
||
pub fn set_approach_k(&mut self, approach_k: f64) {
|
||
self.approach_k = approach_k;
|
||
self.inner.set_saturation_temp(self.oat_k + self.approach_k);
|
||
}
|
||
}
|
||
|
||
impl Component for AirCooledCondenser {
|
||
fn set_system_context(
|
||
&mut self,
|
||
state_offset: usize,
|
||
external_edge_state_indices: &[(usize, usize, usize)],
|
||
) {
|
||
self.inner
|
||
.set_system_context(state_offset, external_edge_state_indices);
|
||
}
|
||
|
||
fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
self.inner.compute_residuals(state, residuals)
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
state: &StateSlice,
|
||
jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
self.inner.jacobian_entries(state, jacobian)
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
self.inner.n_equations()
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
self.inner.get_ports()
|
||
}
|
||
|
||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||
self.inner.set_fluid_backend_from_builder(backend);
|
||
}
|
||
|
||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||
self.inner.set_calib_indices(indices);
|
||
}
|
||
|
||
fn port_mass_flows(
|
||
&self,
|
||
state: &StateSlice,
|
||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||
self.inner.port_mass_flows(state)
|
||
}
|
||
|
||
fn port_enthalpies(
|
||
&self,
|
||
state: &StateSlice,
|
||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||
self.inner.port_enthalpies(state)
|
||
}
|
||
|
||
fn energy_transfers(
|
||
&self,
|
||
state: &StateSlice,
|
||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||
self.inner.energy_transfers(state)
|
||
}
|
||
|
||
fn signature(&self) -> String {
|
||
format!(
|
||
"AirCooledCondenser(oat={:.1}K, approach={:.1}K, t_cond={:.1}K)",
|
||
self.oat_k,
|
||
self.approach_k,
|
||
self.t_cond_k()
|
||
)
|
||
}
|
||
|
||
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 AirCooledCondenser {
|
||
fn state(&self) -> OperationalState {
|
||
self.inner.state()
|
||
}
|
||
|
||
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||
self.inner.set_state(state)
|
||
}
|
||
|
||
fn can_transition_to(&self, target: OperationalState) -> bool {
|
||
self.inner.can_transition_to(target)
|
||
}
|
||
|
||
fn circuit_id(&self) -> &CircuitId {
|
||
self.inner.circuit_id()
|
||
}
|
||
|
||
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||
self.inner.set_circuit_id(circuit_id);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_new_t_cond_computed() {
|
||
// OAT = 35°C = 308.15 K, approach = 12 K → T_cond = 47°C = 320.15 K
|
||
let cond = AirCooledCondenser::new(308.15, 12.0);
|
||
assert!((cond.t_cond_k() - 320.15).abs() < 1e-9);
|
||
assert_eq!(cond.oat_k(), 308.15);
|
||
assert_eq!(cond.approach_k(), 12.0);
|
||
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser)
|
||
assert_eq!(cond.n_equations(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn test_with_ua() {
|
||
let cond = AirCooledCondenser::with_ua(308.15, 12.0, 6000.0);
|
||
assert_eq!(cond.ua(), 6000.0);
|
||
assert!((cond.t_cond_k() - 320.15).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn test_set_oat_updates_t_cond() {
|
||
let mut cond = AirCooledCondenser::new(308.15, 12.0);
|
||
// Change OAT to 40°C = 313.15 K → T_cond = 52°C = 325.15 K
|
||
cond.set_oat_k(313.15);
|
||
assert!((cond.t_cond_k() - 325.15).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn test_set_approach_updates_t_cond() {
|
||
let mut cond = AirCooledCondenser::new(308.15, 12.0);
|
||
cond.set_approach_k(10.0);
|
||
assert!((cond.t_cond_k() - 318.15).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn test_no_backend_does_not_fabricate_residuals() {
|
||
let cond = AirCooledCondenser::new(308.15, 12.0);
|
||
let state = vec![0.0_f64; 10];
|
||
let mut residuals = vec![99.0_f64; 3];
|
||
let result = cond.compute_residuals(&state, &mut residuals);
|
||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||
}
|
||
}
|