feat(components): add ThermoState generators and Eurovent backend demo
This commit is contained in:
435
crates/solver/src/coupling.rs
Normal file
435
crates/solver/src/coupling.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user