Skip to main content

oxirs_physics/
thermal_system.rs

1//! Thermal analysis for physical systems: heat conduction, convection, and radiation.
2//!
3//! Provides lumped-capacity models, Fourier / Newton / Stefan-Boltzmann calculations,
4//! Biot and Fourier numbers, 1-D steady-state temperature profiles, and a
5//! network-level heat-balance solver.
6
7use std::collections::HashMap;
8
9// ─────────────────────────────────────────────────────────────────────────────
10// Material
11// ─────────────────────────────────────────────────────────────────────────────
12
13/// Thermal material properties.
14#[derive(Debug, Clone, PartialEq)]
15pub struct ThermalMaterial {
16    /// Material name (e.g. "copper", "aluminium").
17    pub name: String,
18    /// Thermal conductivity k (W/(m·K)).
19    pub thermal_conductivity: f64,
20    /// Density ρ (kg/m³).
21    pub density: f64,
22    /// Specific heat capacity cp (J/(kg·K)).
23    pub specific_heat: f64,
24}
25
26impl ThermalMaterial {
27    /// Create a new material.
28    pub fn new(
29        name: impl Into<String>,
30        thermal_conductivity: f64,
31        density: f64,
32        specific_heat: f64,
33    ) -> Self {
34        Self {
35            name: name.into(),
36            thermal_conductivity,
37            density,
38            specific_heat,
39        }
40    }
41
42    /// Thermal diffusivity α = k / (ρ · cp)  [m²/s].
43    pub fn thermal_diffusivity(&self) -> f64 {
44        if self.density * self.specific_heat == 0.0 {
45            return 0.0;
46        }
47        self.thermal_conductivity / (self.density * self.specific_heat)
48    }
49}
50
51// ─────────────────────────────────────────────────────────────────────────────
52// Network model
53// ─────────────────────────────────────────────────────────────────────────────
54
55/// A node in the thermal network.
56#[derive(Debug, Clone, PartialEq)]
57pub struct ThermalNetNode {
58    /// Unique node identifier.
59    pub id: String,
60    /// Current temperature (K or °C, consistent within the model).
61    pub temperature: f64,
62    /// Internal heat generation (W). Positive = source.
63    pub heat_source: f64,
64}
65
66impl ThermalNetNode {
67    /// Create a new node.
68    pub fn new(id: impl Into<String>, temperature: f64, heat_source: f64) -> Self {
69        Self {
70            id: id.into(),
71            temperature,
72            heat_source,
73        }
74    }
75}
76
77/// A thermal conductance link between two nodes.
78#[derive(Debug, Clone, PartialEq)]
79pub struct ThermalEdge {
80    /// Source node id.
81    pub from: String,
82    /// Destination node id.
83    pub to: String,
84    /// Thermal conductance G (W/K). Heat flow = G × ΔT.
85    pub conductance: f64,
86}
87
88impl ThermalEdge {
89    /// Create a new edge.
90    pub fn new(from: impl Into<String>, to: impl Into<String>, conductance: f64) -> Self {
91        Self {
92            from: from.into(),
93            to: to.into(),
94            conductance,
95        }
96    }
97}
98
99/// A lumped thermal network of nodes connected by conductance edges.
100#[derive(Debug, Clone, Default)]
101pub struct ThermalSystem {
102    /// Nodes indexed by their id.
103    pub nodes: HashMap<String, ThermalNetNode>,
104    /// Directed conductance edges (bidirectional heat flow).
105    pub edges: Vec<ThermalEdge>,
106    /// Simulation time (s). Updated by transient solvers.
107    pub time: f64,
108}
109
110impl ThermalSystem {
111    /// Create an empty thermal system.
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Add a node. Returns `false` if a node with the same id already exists.
117    pub fn add_node(&mut self, node: ThermalNetNode) -> bool {
118        use std::collections::hash_map::Entry;
119        match self.nodes.entry(node.id.clone()) {
120            Entry::Vacant(e) => {
121                e.insert(node);
122                true
123            }
124            Entry::Occupied(_) => false,
125        }
126    }
127
128    /// Add a conductance edge.
129    pub fn add_edge(&mut self, edge: ThermalEdge) {
130        self.edges.push(edge);
131    }
132
133    /// Number of nodes.
134    pub fn node_count(&self) -> usize {
135        self.nodes.len()
136    }
137}
138
139// ─────────────────────────────────────────────────────────────────────────────
140// Constants
141// ─────────────────────────────────────────────────────────────────────────────
142
143/// Stefan-Boltzmann constant σ [W/(m²·K⁴)].
144pub const STEFAN_BOLTZMANN: f64 = 5.670_374_419e-8;
145
146// ─────────────────────────────────────────────────────────────────────────────
147// ThermalAnalysis
148// ─────────────────────────────────────────────────────────────────────────────
149
150/// Stateless heat-transfer analysis utilities.
151///
152/// All functions are pure; no mutable state is kept.
153pub struct ThermalAnalysis;
154
155impl ThermalAnalysis {
156    // ── Fourier's law of heat conduction ─────────────────────────────────────
157
158    /// Heat flux via Fourier's law: q = k · A · ΔT / L  (W).
159    ///
160    /// # Arguments
161    /// * `conductivity` – thermal conductivity k (W/(m·K))
162    /// * `area` – cross-sectional area A (m²)
163    /// * `temp_diff` – temperature difference ΔT (K or °C)
164    /// * `thickness` – slab thickness L (m)
165    pub fn fourier_heat_flux(conductivity: f64, area: f64, temp_diff: f64, thickness: f64) -> f64 {
166        if thickness == 0.0 {
167            return 0.0;
168        }
169        conductivity * area * temp_diff / thickness
170    }
171
172    // ── Newton's law of cooling ───────────────────────────────────────────────
173
174    /// Convective heat transfer: Q = h · A · (T_surface − T_fluid)  (W).
175    ///
176    /// # Arguments
177    /// * `h` – convective heat-transfer coefficient (W/(m²·K))
178    /// * `area` – surface area (m²)
179    /// * `t_surface` – surface temperature (K or °C)
180    /// * `t_fluid` – fluid (ambient) temperature (K or °C)
181    pub fn convective_heat_transfer(h: f64, area: f64, t_surface: f64, t_fluid: f64) -> f64 {
182        h * area * (t_surface - t_fluid)
183    }
184
185    // ── Stefan-Boltzmann radiation ────────────────────────────────────────────
186
187    /// Radiative heat transfer between two grey surfaces via Stefan-Boltzmann:
188    /// Q = ε · σ · A · (T_hot⁴ − T_cold⁴)  (W).
189    ///
190    /// Temperatures must be in **Kelvin**.
191    ///
192    /// # Arguments
193    /// * `emissivity` – surface emissivity ε (0–1)
194    /// * `area` – radiating area A (m²)
195    /// * `t_hot` – temperature of the hot surface (K)
196    /// * `t_cold` – temperature of the cold surface / surroundings (K)
197    pub fn radiative_heat_transfer(emissivity: f64, area: f64, t_hot: f64, t_cold: f64) -> f64 {
198        emissivity * STEFAN_BOLTZMANN * area * (t_hot.powi(4) - t_cold.powi(4))
199    }
200
201    // ── Thermal resistance ────────────────────────────────────────────────────
202
203    /// Thermal resistance of a slab: R = L / (k · A)  (K/W).
204    ///
205    /// # Arguments
206    /// * `thickness` – L (m)
207    /// * `conductivity` – k (W/(m·K))
208    /// * `area` – A (m²)
209    pub fn thermal_resistance(thickness: f64, conductivity: f64, area: f64) -> f64 {
210        if conductivity * area == 0.0 {
211            return f64::INFINITY;
212        }
213        thickness / (conductivity * area)
214    }
215
216    // ── Biot number ───────────────────────────────────────────────────────────
217
218    /// Biot number: Bi = h · Lc / k  (dimensionless).
219    ///
220    /// Bi < 0.1 justifies the lumped-capacity assumption.
221    ///
222    /// # Arguments
223    /// * `h` – convection coefficient (W/(m²·K))
224    /// * `lc` – characteristic length Lc = V/A (m)
225    /// * `k` – thermal conductivity (W/(m·K))
226    pub fn biot_number(h: f64, lc: f64, k: f64) -> f64 {
227        if k == 0.0 {
228            return f64::INFINITY;
229        }
230        h * lc / k
231    }
232
233    // ── Fourier number ────────────────────────────────────────────────────────
234
235    /// Fourier number: Fo = α · t / Lc²  (dimensionless).
236    ///
237    /// # Arguments
238    /// * `alpha` – thermal diffusivity α = k/(ρ·cp)  (m²/s)
239    /// * `t` – time (s)
240    /// * `lc` – characteristic length (m)
241    pub fn fourier_number(alpha: f64, t: f64, lc: f64) -> f64 {
242        if lc == 0.0 {
243            return f64::INFINITY;
244        }
245        alpha * t / (lc * lc)
246    }
247
248    // ── Lumped-capacity transient temperature ─────────────────────────────────
249
250    /// Transient temperature of a lumped body (Bi < 0.1):
251    ///
252    /// θ*(t) = (T(t) − T∞) / (T₀ − T∞) = exp(−Bi · Fo)
253    ///
254    /// Returns T(t).
255    ///
256    /// # Arguments
257    /// * `t0` – initial body temperature
258    /// * `t_inf` – ambient temperature (same units as t0)
259    /// * `biot` – Biot number
260    /// * `fourier` – Fourier number
261    pub fn transient_temperature(t0: f64, t_inf: f64, biot: f64, fourier: f64) -> f64 {
262        let theta_star = (-biot * fourier).exp();
263        t_inf + theta_star * (t0 - t_inf)
264    }
265
266    // ── 1-D steady-state conduction ───────────────────────────────────────────
267
268    /// Linear temperature profile for 1-D steady-state conduction in a homogeneous slab.
269    ///
270    /// Returns `length + 1` nodal temperatures evenly spaced from `t_left` to `t_right`.
271    ///
272    /// # Arguments
273    /// * `t_left` – temperature at the left boundary
274    /// * `t_right` – temperature at the right boundary
275    /// * `_conductivity` – conductivity (not needed for linear profile, kept for API symmetry)
276    /// * `length` – number of **intervals** (so `length + 1` nodes are returned)
277    pub fn steady_state_1d(
278        t_left: f64,
279        t_right: f64,
280        _conductivity: f64,
281        length: usize,
282    ) -> Vec<f64> {
283        if length == 0 {
284            return vec![t_left];
285        }
286        let n = length + 1;
287        (0..n)
288            .map(|i| t_left + (t_right - t_left) * (i as f64 / length as f64))
289            .collect()
290    }
291
292    // ── Network heat balance ──────────────────────────────────────────────────
293
294    /// Compute the net heat flow (W) into each node of a thermal network.
295    ///
296    /// Net heat flow at node i =
297    ///   Σ_{edges (i,j)} G_{ij} · (T_j − T_i)
298    /// + Σ_{edges (j,i)} G_{ji} · (T_j − T_i)
299    /// + Q_source,i
300    ///
301    /// Returns a `HashMap<node_id, net_heat_flow>`.
302    pub fn heat_balance(system: &ThermalSystem) -> HashMap<String, f64> {
303        let mut balance: HashMap<String, f64> = system
304            .nodes
305            .iter()
306            .map(|(id, node)| (id.clone(), node.heat_source))
307            .collect();
308
309        for edge in &system.edges {
310            if let (Some(from_node), Some(to_node)) =
311                (system.nodes.get(&edge.from), system.nodes.get(&edge.to))
312            {
313                let delta_t = to_node.temperature - from_node.temperature;
314                let q = edge.conductance * delta_t;
315                // Heat flows from hot to cold through the conductance
316                *balance.entry(edge.from.clone()).or_insert(0.0) += q;
317                *balance.entry(edge.to.clone()).or_insert(0.0) -= q;
318            }
319        }
320
321        balance
322    }
323
324    // ── Composite resistance ──────────────────────────────────────────────────
325
326    /// Series combination of two thermal resistances R1 + R2  (K/W).
327    pub fn series_resistance(r1: f64, r2: f64) -> f64 {
328        r1 + r2
329    }
330
331    /// Parallel combination of two thermal resistances (K/W).
332    pub fn parallel_resistance(r1: f64, r2: f64) -> f64 {
333        if r1 + r2 == 0.0 {
334            return 0.0;
335        }
336        r1 * r2 / (r1 + r2)
337    }
338
339    // ── Overall heat transfer coefficient ────────────────────────────────────
340
341    /// Overall heat-transfer coefficient U from component resistances:
342    /// U = 1 / (R_total · A)  (W/(m²·K)).
343    ///
344    /// R_total is the sum of all resistance values provided.
345    pub fn overall_htc(resistances: &[f64], area: f64) -> f64 {
346        let r_total: f64 = resistances.iter().sum();
347        if r_total * area == 0.0 {
348            return f64::INFINITY;
349        }
350        1.0 / (r_total * area)
351    }
352}
353
354// ─────────────────────────────────────────────────────────────────────────────
355// Tests
356// ─────────────────────────────────────────────────────────────────────────────
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    // ── ThermalMaterial ───────────────────────────────────────────────────────
363
364    #[test]
365    fn test_material_new() {
366        let m = ThermalMaterial::new("copper", 401.0, 8960.0, 385.0);
367        assert_eq!(m.name, "copper");
368        assert_eq!(m.thermal_conductivity, 401.0);
369        assert_eq!(m.density, 8960.0);
370        assert_eq!(m.specific_heat, 385.0);
371    }
372
373    #[test]
374    fn test_material_diffusivity_copper() {
375        // α_copper ≈ 1.16e-4 m²/s
376        let m = ThermalMaterial::new("copper", 401.0, 8960.0, 385.0);
377        let alpha = m.thermal_diffusivity();
378        assert!((alpha - 1.163e-4).abs() < 5e-7, "α = {alpha}");
379    }
380
381    #[test]
382    fn test_material_diffusivity_zero_rho() {
383        let m = ThermalMaterial::new("dummy", 1.0, 0.0, 1.0);
384        assert_eq!(m.thermal_diffusivity(), 0.0);
385    }
386
387    #[test]
388    fn test_material_clone() {
389        let m = ThermalMaterial::new("steel", 50.0, 7850.0, 490.0);
390        let m2 = m.clone();
391        assert_eq!(m, m2);
392    }
393
394    // ── ThermalNetNode ────────────────────────────────────────────────────────
395
396    #[test]
397    fn test_node_new() {
398        let n = ThermalNetNode::new("A", 300.0, 100.0);
399        assert_eq!(n.id, "A");
400        assert_eq!(n.temperature, 300.0);
401        assert_eq!(n.heat_source, 100.0);
402    }
403
404    #[test]
405    fn test_node_clone() {
406        let n = ThermalNetNode::new("B", 250.0, 0.0);
407        let n2 = n.clone();
408        assert_eq!(n, n2);
409    }
410
411    // ── ThermalEdge ───────────────────────────────────────────────────────────
412
413    #[test]
414    fn test_edge_new() {
415        let e = ThermalEdge::new("A", "B", 5.0);
416        assert_eq!(e.from, "A");
417        assert_eq!(e.to, "B");
418        assert_eq!(e.conductance, 5.0);
419    }
420
421    #[test]
422    fn test_edge_clone() {
423        let e = ThermalEdge::new("X", "Y", 2.5);
424        let e2 = e.clone();
425        assert_eq!(e, e2);
426    }
427
428    // ── ThermalSystem ─────────────────────────────────────────────────────────
429
430    #[test]
431    fn test_system_empty() {
432        let sys = ThermalSystem::new();
433        assert_eq!(sys.node_count(), 0);
434        assert!(sys.edges.is_empty());
435    }
436
437    #[test]
438    fn test_system_add_node() {
439        let mut sys = ThermalSystem::new();
440        assert!(sys.add_node(ThermalNetNode::new("A", 300.0, 0.0)));
441        assert_eq!(sys.node_count(), 1);
442    }
443
444    #[test]
445    fn test_system_duplicate_node_rejected() {
446        let mut sys = ThermalSystem::new();
447        assert!(sys.add_node(ThermalNetNode::new("A", 300.0, 0.0)));
448        assert!(!sys.add_node(ThermalNetNode::new("A", 400.0, 0.0)));
449        assert_eq!(sys.node_count(), 1);
450    }
451
452    #[test]
453    fn test_system_add_edge() {
454        let mut sys = ThermalSystem::new();
455        sys.add_edge(ThermalEdge::new("A", "B", 3.0));
456        assert_eq!(sys.edges.len(), 1);
457    }
458
459    // ── fourier_heat_flux ─────────────────────────────────────────────────────
460
461    #[test]
462    fn test_fourier_heat_flux_basic() {
463        // k=10, A=2, ΔT=50, L=0.1 → Q = 10·2·50/0.1 = 10000 W
464        let q = ThermalAnalysis::fourier_heat_flux(10.0, 2.0, 50.0, 0.1);
465        assert!((q - 10_000.0).abs() < 1e-9);
466    }
467
468    #[test]
469    fn test_fourier_heat_flux_zero_thickness() {
470        let q = ThermalAnalysis::fourier_heat_flux(10.0, 1.0, 100.0, 0.0);
471        assert_eq!(q, 0.0);
472    }
473
474    #[test]
475    fn test_fourier_heat_flux_positive() {
476        let q = ThermalAnalysis::fourier_heat_flux(50.0, 0.5, 30.0, 0.05);
477        assert!(q > 0.0);
478    }
479
480    #[test]
481    fn test_fourier_heat_flux_proportional_to_area() {
482        let q1 = ThermalAnalysis::fourier_heat_flux(1.0, 1.0, 10.0, 1.0);
483        let q2 = ThermalAnalysis::fourier_heat_flux(1.0, 2.0, 10.0, 1.0);
484        assert!((q2 - 2.0 * q1).abs() < 1e-10);
485    }
486
487    // ── convective_heat_transfer ──────────────────────────────────────────────
488
489    #[test]
490    fn test_convective_heat_transfer_basic() {
491        // h=25, A=1, Ts=80, Tf=20 → Q = 25·1·60 = 1500 W
492        let q = ThermalAnalysis::convective_heat_transfer(25.0, 1.0, 80.0, 20.0);
493        assert!((q - 1500.0).abs() < 1e-9);
494    }
495
496    #[test]
497    fn test_convective_heat_transfer_zero_diff() {
498        let q = ThermalAnalysis::convective_heat_transfer(10.0, 2.0, 50.0, 50.0);
499        assert_eq!(q, 0.0);
500    }
501
502    #[test]
503    fn test_convective_heat_transfer_negative_when_cooler() {
504        // Surface cooler than fluid → Q negative
505        let q = ThermalAnalysis::convective_heat_transfer(10.0, 1.0, 10.0, 30.0);
506        assert!(q < 0.0);
507    }
508
509    #[test]
510    fn test_convective_proportional_to_h() {
511        let q1 = ThermalAnalysis::convective_heat_transfer(5.0, 1.0, 100.0, 20.0);
512        let q2 = ThermalAnalysis::convective_heat_transfer(10.0, 1.0, 100.0, 20.0);
513        assert!((q2 - 2.0 * q1).abs() < 1e-10);
514    }
515
516    // ── radiative_heat_transfer ───────────────────────────────────────────────
517
518    #[test]
519    fn test_radiative_basic() {
520        // ε=1, A=1, T_hot=1000K, T_cold=300K
521        let q = ThermalAnalysis::radiative_heat_transfer(1.0, 1.0, 1000.0, 300.0);
522        // Expected ≈ 5.67e-8 * (1e12 - 8.1e9) ≈ 56_129 W
523        assert!(q > 50_000.0 && q < 60_000.0, "Q = {q}");
524    }
525
526    #[test]
527    fn test_radiative_zero_emissivity() {
528        let q = ThermalAnalysis::radiative_heat_transfer(0.0, 1.0, 1000.0, 300.0);
529        assert_eq!(q, 0.0);
530    }
531
532    #[test]
533    fn test_radiative_equal_temperatures() {
534        let q = ThermalAnalysis::radiative_heat_transfer(0.9, 2.0, 400.0, 400.0);
535        assert!(q.abs() < 1e-6);
536    }
537
538    #[test]
539    fn test_radiative_hot_exceeds_cold() {
540        let q = ThermalAnalysis::radiative_heat_transfer(0.8, 1.0, 600.0, 300.0);
541        assert!(q > 0.0);
542    }
543
544    #[test]
545    fn test_radiative_stefan_boltzmann_constant() {
546        // Single-surface verification: ε=1, A=1, T_hot=T, T_cold=0K
547        // Q = σ · T⁴
548        let t = 500.0_f64;
549        let q = ThermalAnalysis::radiative_heat_transfer(1.0, 1.0, t, 0.0);
550        let expected = STEFAN_BOLTZMANN * t.powi(4);
551        assert!((q - expected).abs() < 1e-4);
552    }
553
554    // ── thermal_resistance ────────────────────────────────────────────────────
555
556    #[test]
557    fn test_thermal_resistance_basic() {
558        // R = 0.1 / (10 * 2) = 0.005 K/W
559        let r = ThermalAnalysis::thermal_resistance(0.1, 10.0, 2.0);
560        assert!((r - 0.005).abs() < 1e-12);
561    }
562
563    #[test]
564    fn test_thermal_resistance_zero_conductivity() {
565        let r = ThermalAnalysis::thermal_resistance(0.1, 0.0, 1.0);
566        assert!(r.is_infinite());
567    }
568
569    #[test]
570    fn test_thermal_resistance_zero_area() {
571        let r = ThermalAnalysis::thermal_resistance(0.1, 10.0, 0.0);
572        assert!(r.is_infinite());
573    }
574
575    // ── biot_number ───────────────────────────────────────────────────────────
576
577    #[test]
578    fn test_biot_number_basic() {
579        // h=10, Lc=0.01, k=100 → Bi = 0.001
580        let bi = ThermalAnalysis::biot_number(10.0, 0.01, 100.0);
581        assert!((bi - 0.001).abs() < 1e-12);
582    }
583
584    #[test]
585    fn test_biot_number_lumped_condition() {
586        // Bi < 0.1 → lumped capacity valid
587        let bi = ThermalAnalysis::biot_number(5.0, 0.01, 200.0);
588        assert!(bi < 0.1);
589    }
590
591    #[test]
592    fn test_biot_number_zero_k() {
593        let bi = ThermalAnalysis::biot_number(10.0, 0.01, 0.0);
594        assert!(bi.is_infinite());
595    }
596
597    // ── fourier_number ────────────────────────────────────────────────────────
598
599    #[test]
600    fn test_fourier_number_basic() {
601        // α=1e-5, t=100, Lc=0.1 → Fo = 1e-5 * 100 / 0.01 = 0.1
602        let fo = ThermalAnalysis::fourier_number(1e-5, 100.0, 0.1);
603        assert!((fo - 0.1).abs() < 1e-12);
604    }
605
606    #[test]
607    fn test_fourier_number_zero_lc() {
608        let fo = ThermalAnalysis::fourier_number(1e-5, 100.0, 0.0);
609        assert!(fo.is_infinite());
610    }
611
612    #[test]
613    fn test_fourier_number_scales_with_time() {
614        let fo1 = ThermalAnalysis::fourier_number(1e-5, 10.0, 0.1);
615        let fo2 = ThermalAnalysis::fourier_number(1e-5, 20.0, 0.1);
616        assert!((fo2 - 2.0 * fo1).abs() < 1e-14);
617    }
618
619    // ── transient_temperature ─────────────────────────────────────────────────
620
621    #[test]
622    fn test_transient_temperature_at_zero_time() {
623        // At t=0 → Fo=0 → exp(0) = 1 → T = T0
624        let t = ThermalAnalysis::transient_temperature(200.0, 20.0, 0.05, 0.0);
625        assert!((t - 200.0).abs() < 1e-10);
626    }
627
628    #[test]
629    fn test_transient_temperature_approaches_ambient() {
630        // Very large Fo → temperature → T_inf
631        let t = ThermalAnalysis::transient_temperature(200.0, 20.0, 0.05, 1000.0);
632        assert!((t - 20.0).abs() < 1e-3);
633    }
634
635    #[test]
636    fn test_transient_temperature_monotone_cooling() {
637        let t_inf = 20.0;
638        let t0 = 200.0;
639        let bi = 0.05;
640        let fo_values = [0.0, 1.0, 5.0, 10.0];
641        let temps: Vec<f64> = fo_values
642            .iter()
643            .map(|&fo| ThermalAnalysis::transient_temperature(t0, t_inf, bi, fo))
644            .collect();
645        for w in temps.windows(2) {
646            assert!(
647                w[0] >= w[1],
648                "Temperature should decrease: {} >= {}",
649                w[0],
650                w[1]
651            );
652        }
653    }
654
655    #[test]
656    fn test_transient_temperature_no_cooling_when_equal() {
657        // If T0 == T_inf → T(t) = T_inf always
658        let t = ThermalAnalysis::transient_temperature(50.0, 50.0, 0.1, 10.0);
659        assert!((t - 50.0).abs() < 1e-10);
660    }
661
662    // ── steady_state_1d ───────────────────────────────────────────────────────
663
664    #[test]
665    fn test_steady_state_1d_length_zero() {
666        let profile = ThermalAnalysis::steady_state_1d(0.0, 100.0, 1.0, 0);
667        assert_eq!(profile, vec![0.0]);
668    }
669
670    #[test]
671    fn test_steady_state_1d_two_nodes() {
672        let profile = ThermalAnalysis::steady_state_1d(0.0, 100.0, 50.0, 1);
673        assert_eq!(profile.len(), 2);
674        assert!((profile[0] - 0.0).abs() < 1e-10);
675        assert!((profile[1] - 100.0).abs() < 1e-10);
676    }
677
678    #[test]
679    fn test_steady_state_1d_midpoint() {
680        let profile = ThermalAnalysis::steady_state_1d(0.0, 100.0, 1.0, 4);
681        // index 2 of 5 → 50.0
682        assert!((profile[2] - 50.0).abs() < 1e-10);
683    }
684
685    #[test]
686    fn test_steady_state_1d_boundary_values() {
687        let profile = ThermalAnalysis::steady_state_1d(20.0, 80.0, 1.0, 10);
688        assert!((profile[0] - 20.0).abs() < 1e-10);
689        assert!((profile[10] - 80.0).abs() < 1e-10);
690    }
691
692    #[test]
693    fn test_steady_state_1d_monotone() {
694        let profile = ThermalAnalysis::steady_state_1d(300.0, 100.0, 1.0, 5);
695        for w in profile.windows(2) {
696            assert!(w[0] >= w[1], "{} >= {}", w[0], w[1]);
697        }
698    }
699
700    #[test]
701    fn test_steady_state_1d_node_count() {
702        let n = 8_usize;
703        let profile = ThermalAnalysis::steady_state_1d(0.0, 1.0, 1.0, n);
704        assert_eq!(profile.len(), n + 1);
705    }
706
707    // ── heat_balance ──────────────────────────────────────────────────────────
708
709    #[test]
710    fn test_heat_balance_empty_system() {
711        let sys = ThermalSystem::new();
712        let bal = ThermalAnalysis::heat_balance(&sys);
713        assert!(bal.is_empty());
714    }
715
716    #[test]
717    fn test_heat_balance_single_node_source() {
718        let mut sys = ThermalSystem::new();
719        sys.add_node(ThermalNetNode::new("A", 300.0, 500.0));
720        let bal = ThermalAnalysis::heat_balance(&sys);
721        assert!((bal["A"] - 500.0).abs() < 1e-9);
722    }
723
724    #[test]
725    fn test_heat_balance_two_nodes_equal_temp() {
726        let mut sys = ThermalSystem::new();
727        sys.add_node(ThermalNetNode::new("A", 300.0, 0.0));
728        sys.add_node(ThermalNetNode::new("B", 300.0, 0.0));
729        sys.add_edge(ThermalEdge::new("A", "B", 10.0));
730        let bal = ThermalAnalysis::heat_balance(&sys);
731        assert!(bal["A"].abs() < 1e-9);
732        assert!(bal["B"].abs() < 1e-9);
733    }
734
735    #[test]
736    fn test_heat_balance_two_nodes_unequal_temp() {
737        // A=400K, B=300K, G=5 W/K → Q_A→B = 5*(300-400)= -500 W (A loses heat)
738        let mut sys = ThermalSystem::new();
739        sys.add_node(ThermalNetNode::new("A", 400.0, 0.0));
740        sys.add_node(ThermalNetNode::new("B", 300.0, 0.0));
741        sys.add_edge(ThermalEdge::new("A", "B", 5.0));
742        let bal = ThermalAnalysis::heat_balance(&sys);
743        assert!(bal["A"] < 0.0, "A should lose heat");
744        assert!(bal["B"] > 0.0, "B should gain heat");
745    }
746
747    #[test]
748    fn test_heat_balance_conservation() {
749        // Net heat of entire isolated system should sum to zero
750        let mut sys = ThermalSystem::new();
751        sys.add_node(ThermalNetNode::new("A", 500.0, 0.0));
752        sys.add_node(ThermalNetNode::new("B", 300.0, 0.0));
753        sys.add_edge(ThermalEdge::new("A", "B", 8.0));
754        let bal = ThermalAnalysis::heat_balance(&sys);
755        let total: f64 = bal.values().sum();
756        assert!(total.abs() < 1e-9, "total = {total}");
757    }
758
759    // ── series / parallel resistance ──────────────────────────────────────────
760
761    #[test]
762    fn test_series_resistance() {
763        let r = ThermalAnalysis::series_resistance(2.0, 3.0);
764        assert!((r - 5.0).abs() < 1e-12);
765    }
766
767    #[test]
768    fn test_parallel_resistance() {
769        // 1/(1/2 + 1/3) = 6/5
770        let r = ThermalAnalysis::parallel_resistance(2.0, 3.0);
771        assert!((r - 1.2).abs() < 1e-12);
772    }
773
774    #[test]
775    fn test_parallel_resistance_zero() {
776        let r = ThermalAnalysis::parallel_resistance(0.0, 0.0);
777        assert_eq!(r, 0.0);
778    }
779
780    // ── overall_htc ───────────────────────────────────────────────────────────
781
782    #[test]
783    fn test_overall_htc_basic() {
784        // R = 0.01 + 0.02 = 0.03, A = 2 → U = 1/(0.03*2) ≈ 16.67
785        let u = ThermalAnalysis::overall_htc(&[0.01, 0.02], 2.0);
786        assert!((u - 1.0 / 0.06).abs() < 1e-9);
787    }
788
789    #[test]
790    fn test_overall_htc_zero_area() {
791        let u = ThermalAnalysis::overall_htc(&[0.01], 0.0);
792        assert!(u.is_infinite());
793    }
794}