Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

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>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -0,0 +1,302 @@
//! 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 : 1015 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 : 1015 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(_))));
}
}