feat(components): add ThermoState generators and Eurovent backend demo

This commit is contained in:
Sepehr
2026-02-20 22:01:38 +01:00
parent 375d288950
commit 4a40fddfe3
271 changed files with 28614 additions and 447 deletions

View File

@@ -0,0 +1,435 @@
//! Thermal coupling between circuits for heat transfer.
//!
//! This module provides the infrastructure for modeling heat exchange between
//! independent fluid circuits. Thermal couplings represent heat exchangers
//! that transfer heat from a "hot" circuit to a "cold" circuit without
//! fluid mixing.
//!
//! ## Sign Convention
//!
//! Heat transfer Q > 0 means heat flows INTO the cold circuit (out of hot circuit).
//! This follows the convention that the cold circuit receives heat.
//!
//! ## Coupling Graph and Circular Dependencies
//!
//! Thermal couplings form a directed graph where:
//! - Nodes are circuits (CircuitId)
//! - Edges point from hot_circuit to cold_circuit (direction of heat flow)
//!
//! Circular dependencies occur when circuits mutually heat each other (A→B and B→A).
//! Circuits in circular dependencies must be solved simultaneously by the solver.
use entropyk_core::{Temperature, ThermalConductance};
use petgraph::algo::{is_cyclic_directed, kosaraju_scc};
use petgraph::graph::{DiGraph, NodeIndex};
use std::collections::HashMap;
use crate::system::CircuitId;
/// Thermal coupling between two circuits via a heat exchanger.
///
/// Heat flows from `hot_circuit` to `cold_circuit` proportional to the
/// temperature difference and thermal conductance (UA value).
#[derive(Debug, Clone, PartialEq)]
pub struct ThermalCoupling {
/// Circuit that supplies heat (higher temperature side).
pub hot_circuit: CircuitId,
/// Circuit that receives heat (lower temperature side).
pub cold_circuit: CircuitId,
/// Thermal conductance (UA) in W/K. Higher values = more heat transfer.
pub ua: ThermalConductance,
/// Efficiency factor (0.0 to 1.0). Default is 1.0 (no losses).
pub efficiency: f64,
}
impl ThermalCoupling {
/// Creates a new thermal coupling between two circuits.
///
/// # Arguments
///
/// * `hot_circuit` - Circuit at higher temperature (heat source)
/// * `cold_circuit` - Circuit at lower temperature (heat sink)
/// * `ua` - Thermal conductance in W/K
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId};
/// use entropyk_core::ThermalConductance;
///
/// let coupling = ThermalCoupling::new(
/// CircuitId(0),
/// CircuitId(1),
/// ThermalConductance::from_watts_per_kelvin(1000.0),
/// );
/// ```
pub fn new(hot_circuit: CircuitId, cold_circuit: CircuitId, ua: ThermalConductance) -> Self {
Self {
hot_circuit,
cold_circuit,
ua,
efficiency: 1.0,
}
}
/// Sets the efficiency factor for the coupling.
///
/// Efficiency accounts for heat losses in the heat exchanger.
/// A value of 0.9 means 90% of theoretical heat is transferred.
pub fn with_efficiency(mut self, efficiency: f64) -> Self {
self.efficiency = efficiency.clamp(0.0, 1.0);
self
}
}
/// Computes heat transfer for a thermal coupling.
///
/// # Formula
///
/// Q = η × UA × (T_hot - T_cold)
///
/// Where:
/// - Q is the heat transfer rate (W), positive means heat INTO cold circuit
/// - η is the efficiency factor
/// - UA is the thermal conductance (W/K)
/// - T_hot, T_cold are temperatures (K)
///
/// # Sign Convention
///
/// - Q > 0: Heat flows from hot to cold (normal operation)
/// - Q = 0: No temperature difference
/// - Q < 0: Cold is hotter than hot (reverse flow, unusual)
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId, compute_coupling_heat};
/// use entropyk_core::{Temperature, ThermalConductance};
///
/// let coupling = ThermalCoupling::new(
/// CircuitId(0),
/// CircuitId(1),
/// ThermalConductance::from_watts_per_kelvin(1000.0),
/// );
///
/// let t_hot = Temperature::from_kelvin(350.0);
/// let t_cold = Temperature::from_kelvin(300.0);
///
/// let q = compute_coupling_heat(&coupling, t_hot, t_cold);
/// assert!(q > 0.0, "Heat should flow from hot to cold");
/// ```
pub fn compute_coupling_heat(
coupling: &ThermalCoupling,
t_hot: Temperature,
t_cold: Temperature,
) -> f64 {
coupling.efficiency
* coupling.ua.to_watts_per_kelvin()
* (t_hot.to_kelvin() - t_cold.to_kelvin())
}
/// Builds a coupling graph for dependency analysis.
///
/// Returns a directed graph where:
/// - Nodes are CircuitIds present in any coupling
/// - Edges point from hot_circuit to cold_circuit
fn build_coupling_graph(couplings: &[ThermalCoupling]) -> DiGraph<CircuitId, ()> {
let mut graph = DiGraph::new();
let mut circuit_to_node: HashMap<CircuitId, NodeIndex> = HashMap::new();
for coupling in couplings {
// Add hot_circuit node if not present
let hot_node = *circuit_to_node
.entry(coupling.hot_circuit)
.or_insert_with(|| graph.add_node(coupling.hot_circuit));
// Add cold_circuit node if not present
let cold_node = *circuit_to_node
.entry(coupling.cold_circuit)
.or_insert_with(|| graph.add_node(coupling.cold_circuit));
// Add directed edge: hot -> cold
graph.add_edge(hot_node, cold_node, ());
}
graph
}
/// Checks if the coupling graph contains circular dependencies.
///
/// Circular dependencies occur when circuits are mutually thermally coupled
/// (e.g., A heats B, and B heats A). When circular dependencies exist,
/// the solver must solve those circuits simultaneously rather than sequentially.
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId, has_circular_dependencies};
/// use entropyk_core::ThermalConductance;
///
/// // No circular dependency: A → B → C
/// let couplings = vec![
/// ThermalCoupling::new(CircuitId(0), CircuitId(1), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ThermalCoupling::new(CircuitId(1), CircuitId(2), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ];
/// assert!(!has_circular_dependencies(&couplings));
///
/// // Circular dependency: A → B and B → A
/// let couplings_circular = vec![
/// ThermalCoupling::new(CircuitId(0), CircuitId(1), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ThermalCoupling::new(CircuitId(1), CircuitId(0), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ];
/// assert!(has_circular_dependencies(&couplings_circular));
/// ```
pub fn has_circular_dependencies(couplings: &[ThermalCoupling]) -> bool {
if couplings.is_empty() {
return false;
}
let graph = build_coupling_graph(couplings);
is_cyclic_directed(&graph)
}
/// Returns groups of circuits that must be solved simultaneously.
///
/// Groups are computed using strongly connected components (SCC) analysis
/// of the coupling graph. Circuits in the same SCC have circular thermal
/// dependencies and must be solved together.
///
/// # Returns
///
/// A vector of vectors, where each inner vector contains CircuitIds that
/// must be solved simultaneously. Single-element vectors indicate circuits
/// that can be solved independently (in topological order).
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId, coupling_groups};
/// use entropyk_core::ThermalConductance;
///
/// // A → B, B and C independent
/// let couplings = vec![
/// ThermalCoupling::new(CircuitId(0), CircuitId(1), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ];
/// let groups = coupling_groups(&couplings);
/// // Groups will contain individual circuits since there's no cycle
/// ```
pub fn coupling_groups(couplings: &[ThermalCoupling]) -> Vec<Vec<CircuitId>> {
if couplings.is_empty() {
return Vec::new();
}
let graph = build_coupling_graph(couplings);
let sccs = kosaraju_scc(&graph);
sccs.into_iter()
.map(|node_indices| node_indices.into_iter().map(|idx| graph[idx]).collect())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn make_coupling(hot: u8, cold: u8, ua_w_per_k: f64) -> ThermalCoupling {
ThermalCoupling::new(
CircuitId(hot),
CircuitId(cold),
ThermalConductance::from_watts_per_kelvin(ua_w_per_k),
)
}
#[test]
fn test_thermal_coupling_creation() {
let coupling = ThermalCoupling::new(
CircuitId(0),
CircuitId(1),
ThermalConductance::from_watts_per_kelvin(1000.0),
);
assert_eq!(coupling.hot_circuit, CircuitId(0));
assert_eq!(coupling.cold_circuit, CircuitId(1));
assert_relative_eq!(coupling.ua.to_watts_per_kelvin(), 1000.0, epsilon = 1e-10);
assert_relative_eq!(coupling.efficiency, 1.0, epsilon = 1e-10);
}
#[test]
fn test_thermal_coupling_with_efficiency() {
let coupling = ThermalCoupling::new(
CircuitId(0),
CircuitId(1),
ThermalConductance::from_watts_per_kelvin(1000.0),
)
.with_efficiency(0.85);
assert_relative_eq!(coupling.efficiency, 0.85, epsilon = 1e-10);
}
#[test]
fn test_efficiency_clamped() {
let coupling = make_coupling(0, 1, 100.0).with_efficiency(1.5);
assert_relative_eq!(coupling.efficiency, 1.0, epsilon = 1e-10);
let coupling = make_coupling(0, 1, 100.0).with_efficiency(-0.5);
assert_relative_eq!(coupling.efficiency, 0.0, epsilon = 1e-10);
}
#[test]
fn test_compute_coupling_heat_positive() {
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(350.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
// Q = 1.0 * 1000 * (350 - 300) = 50000 W
assert_relative_eq!(q, 50000.0, epsilon = 1e-10);
assert!(q > 0.0, "Heat should be positive (into cold circuit)");
}
#[test]
fn test_compute_coupling_heat_zero() {
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(300.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
assert_relative_eq!(q, 0.0, epsilon = 1e-10);
}
#[test]
fn test_compute_coupling_heat_negative() {
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(280.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
// Q = 1000 * (280 - 300) = -20000 W (reverse flow)
assert_relative_eq!(q, -20000.0, epsilon = 1e-10);
assert!(q < 0.0, "Heat should be negative (reverse flow)");
}
#[test]
fn test_compute_coupling_heat_with_efficiency() {
let coupling = make_coupling(0, 1, 1000.0).with_efficiency(0.9);
let t_hot = Temperature::from_kelvin(350.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
// Q = 0.9 * 1000 * 50 = 45000 W
assert_relative_eq!(q, 45000.0, epsilon = 1e-10);
}
#[test]
fn test_energy_conservation() {
// For two circuits coupled, Q_hot = -Q_cold
// This means the heat leaving hot circuit equals heat entering cold circuit
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(350.0);
let t_cold = Temperature::from_kelvin(300.0);
let q_into_cold = compute_coupling_heat(&coupling, t_hot, t_cold);
let q_out_of_hot = -q_into_cold; // By convention
// Heat into cold = - (heat out of hot)
assert_relative_eq!(q_into_cold, -q_out_of_hot, epsilon = 1e-10);
assert!(q_into_cold > 0.0, "Cold circuit receives heat");
assert!(q_out_of_hot < 0.0, "Hot circuit loses heat");
}
#[test]
fn test_no_circular_dependency() {
// Linear chain: A → B → C
let couplings = vec![make_coupling(0, 1, 100.0), make_coupling(1, 2, 100.0)];
assert!(!has_circular_dependencies(&couplings));
}
#[test]
fn test_circular_dependency_detection() {
// Mutual: A → B and B → A
let couplings = vec![make_coupling(0, 1, 100.0), make_coupling(1, 0, 100.0)];
assert!(has_circular_dependencies(&couplings));
}
#[test]
fn test_circular_dependency_complex() {
// Triangle: A → B → C → A
let couplings = vec![
make_coupling(0, 1, 100.0),
make_coupling(1, 2, 100.0),
make_coupling(2, 0, 100.0),
];
assert!(has_circular_dependencies(&couplings));
}
#[test]
fn test_empty_couplings_no_cycle() {
let couplings: Vec<ThermalCoupling> = vec![];
assert!(!has_circular_dependencies(&couplings));
}
#[test]
fn test_single_coupling_no_cycle() {
let couplings = vec![make_coupling(0, 1, 100.0)];
assert!(!has_circular_dependencies(&couplings));
}
#[test]
fn test_coupling_groups_no_cycle() {
// A → B, C independent
let couplings = vec![make_coupling(0, 1, 100.0)];
let groups = coupling_groups(&couplings);
// With no cycles, each circuit is its own group
assert_eq!(groups.len(), 2);
// Each group should have exactly one circuit
for group in &groups {
assert_eq!(group.len(), 1);
}
// Collect all circuit IDs
let all_circuits: std::collections::HashSet<CircuitId> =
groups.iter().flat_map(|g| g.iter().copied()).collect();
assert!(all_circuits.contains(&CircuitId(0)));
assert!(all_circuits.contains(&CircuitId(1)));
}
#[test]
fn test_coupling_groups_with_cycle() {
// A ↔ B (mutual), C → D
let couplings = vec![
make_coupling(0, 1, 100.0),
make_coupling(1, 0, 100.0),
make_coupling(2, 3, 100.0),
];
let groups = coupling_groups(&couplings);
// Should have 3 groups: [A, B] as one, C as one, D as one
assert_eq!(groups.len(), 3);
// Find the group with 2 circuits (A and B)
let large_group: Vec<&Vec<CircuitId>> = groups.iter().filter(|g| g.len() == 2).collect();
assert_eq!(large_group.len(), 1);
let ab_group = large_group[0];
assert!(ab_group.contains(&CircuitId(0)));
assert!(ab_group.contains(&CircuitId(1)));
}
#[test]
fn test_coupling_groups_empty() {
let couplings: Vec<ThermalCoupling> = vec![];
let groups = coupling_groups(&couplings);
assert!(groups.is_empty());
}
}