Skip to main content

oxirs_physics/
heat_transfer.rs

1//! # Heat Transfer Simulation
2//!
3//! Provides fundamental heat transfer calculations covering conduction (Fourier's law),
4//! convection (Newton's law of cooling), and radiation (Stefan-Boltzmann law).
5//!
6//! # Examples
7//!
8//! ```rust
9//! use oxirs_physics::heat_transfer::{Material, ThermalLayer, HeatTransferCalc};
10//!
11//! let mat = Material::COPPER;
12//! let layer = ThermalLayer { material: mat, thickness: 0.01, area: 1.0 };
13//! let flux = HeatTransferCalc::conduction_heat_flux(&layer.material, 100.0, 20.0, 0.01);
14//! assert!((flux - 3_080_000.0).abs() < 1.0);
15//! ```
16
17/// Stefan-Boltzmann constant (W/m²·K⁴)
18pub const STEFAN_BOLTZMANN: f64 = 5.67e-8;
19
20/// Heat transfer mode
21#[derive(Debug, Clone, PartialEq)]
22pub enum HeatTransferMode {
23    /// Fourier conduction through a solid medium
24    Conduction,
25    /// Newton convection between a surface and fluid
26    Convection,
27    /// Stefan-Boltzmann thermal radiation
28    Radiation,
29    /// All three modes simultaneously
30    Combined,
31}
32
33/// Thermal material properties
34#[derive(Debug, Clone, PartialEq)]
35pub struct Material {
36    /// Human-readable material name
37    pub name: String,
38    /// Thermal conductivity k (W/m·K)
39    pub thermal_conductivity: f64,
40    /// Mass density ρ (kg/m³)
41    pub density: f64,
42    /// Specific heat capacity c_p (J/kg·K)
43    pub specific_heat: f64,
44}
45
46impl Material {
47    /// Copper — excellent electrical and thermal conductor
48    pub const COPPER: Material = Material {
49        name: String::new(), // replaced in impl below via a const fn approach
50        thermal_conductivity: 385.0,
51        density: 8_960.0,
52        specific_heat: 385.0,
53    };
54
55    /// Structural (carbon) steel
56    pub const STEEL: Material = Material {
57        name: String::new(),
58        thermal_conductivity: 50.0,
59        density: 7_850.0,
60        specific_heat: 490.0,
61    };
62
63    /// Normal-weight concrete
64    pub const CONCRETE: Material = Material {
65        name: String::new(),
66        thermal_conductivity: 1.0,
67        density: 2_300.0,
68        specific_heat: 880.0,
69    };
70
71    /// Dry softwood (pine)
72    pub const WOOD: Material = Material {
73        name: String::new(),
74        thermal_conductivity: 0.12,
75        density: 550.0,
76        specific_heat: 1_700.0,
77    };
78
79    /// Dry air at ~25 °C, 1 atm
80    pub const AIR: Material = Material {
81        name: String::new(),
82        thermal_conductivity: 0.026,
83        density: 1.184,
84        specific_heat: 1_005.0,
85    };
86
87    /// Return a named copper instance.
88    pub fn copper() -> Self {
89        Self {
90            name: "Copper".to_string(),
91            ..Self::COPPER
92        }
93    }
94
95    /// Return a named steel instance.
96    pub fn steel() -> Self {
97        Self {
98            name: "Steel".to_string(),
99            ..Self::STEEL
100        }
101    }
102
103    /// Return a named concrete instance.
104    pub fn concrete() -> Self {
105        Self {
106            name: "Concrete".to_string(),
107            ..Self::CONCRETE
108        }
109    }
110
111    /// Return a named wood instance.
112    pub fn wood() -> Self {
113        Self {
114            name: "Wood".to_string(),
115            ..Self::WOOD
116        }
117    }
118
119    /// Return a named air instance.
120    pub fn air() -> Self {
121        Self {
122            name: "Air".to_string(),
123            ..Self::AIR
124        }
125    }
126
127    /// Thermal diffusivity α = k / (ρ · c_p)  (m²/s)
128    pub fn thermal_diffusivity(&self) -> f64 {
129        self.thermal_conductivity / (self.density * self.specific_heat)
130    }
131}
132
133/// A single homogeneous thermal layer with geometry
134#[derive(Debug, Clone)]
135pub struct ThermalLayer {
136    /// Material properties
137    pub material: Material,
138    /// Layer thickness (m)
139    pub thickness: f64,
140    /// Cross-sectional area (m²)
141    pub area: f64,
142}
143
144impl ThermalLayer {
145    /// Thermal resistance of this layer: R = L / (k · A)  (K/W)
146    pub fn thermal_resistance(&self) -> f64 {
147        self.thickness / (self.material.thermal_conductivity * self.area)
148    }
149}
150
151/// Stateless collection of heat-transfer calculation functions
152pub struct HeatTransferCalc;
153
154impl HeatTransferCalc {
155    // -----------------------------------------------------------------------
156    // Conduction
157    // -----------------------------------------------------------------------
158
159    /// Fourier heat flux: q = k · ΔT / L  (W/m²)
160    ///
161    /// * `material`  – layer material
162    /// * `temp_hot`  – hot-side temperature (°C or K; only difference matters)
163    /// * `temp_cold` – cold-side temperature
164    /// * `thickness` – layer thickness (m)
165    pub fn conduction_heat_flux(
166        material: &Material,
167        temp_hot: f64,
168        temp_cold: f64,
169        thickness: f64,
170    ) -> f64 {
171        if thickness <= 0.0 {
172            return 0.0;
173        }
174        material.thermal_conductivity * (temp_hot - temp_cold) / thickness
175    }
176
177    /// Conduction power Q = q · A  (W) through a single layer
178    pub fn conduction_power(layer: &ThermalLayer, temp_hot: f64, temp_cold: f64) -> f64 {
179        let flux =
180            Self::conduction_heat_flux(&layer.material, temp_hot, temp_cold, layer.thickness);
181        flux * layer.area
182    }
183
184    // -----------------------------------------------------------------------
185    // Composite wall — resistance network
186    // -----------------------------------------------------------------------
187
188    /// Total thermal resistance for layers in *series*: R = Σ (L_i / (k_i · A_i))  (K/W)
189    pub fn series_resistance(layers: &[ThermalLayer]) -> f64 {
190        layers.iter().map(|l| l.thermal_resistance()).sum()
191    }
192
193    /// Total thermal resistance for layers in *parallel*: 1/R = Σ (1/R_i)  (K/W)
194    pub fn parallel_resistance(layers: &[ThermalLayer]) -> f64 {
195        let inv_sum: f64 = layers.iter().map(|l| 1.0 / l.thermal_resistance()).sum();
196        if inv_sum == 0.0 {
197            f64::INFINITY
198        } else {
199            1.0 / inv_sum
200        }
201    }
202
203    /// Heat flow through layers in series: Q = ΔT / R_total  (W)
204    pub fn heat_flow_series(layers: &[ThermalLayer], temp_hot: f64, temp_cold: f64) -> f64 {
205        let r = Self::series_resistance(layers);
206        if r == 0.0 {
207            return 0.0;
208        }
209        (temp_hot - temp_cold) / r
210    }
211
212    // -----------------------------------------------------------------------
213    // Convection
214    // -----------------------------------------------------------------------
215
216    /// Newton's law of convection: q = h · (T_s - T_f)  (W/m²)
217    ///
218    /// * `h`            – convective heat transfer coefficient (W/m²·K)
219    /// * `temp_surface` – surface temperature
220    /// * `temp_fluid`   – bulk fluid temperature
221    pub fn convection_heat_flux(h: f64, temp_surface: f64, temp_fluid: f64) -> f64 {
222        h * (temp_surface - temp_fluid)
223    }
224
225    // -----------------------------------------------------------------------
226    // Radiation
227    // -----------------------------------------------------------------------
228
229    /// Stefan-Boltzmann radiation flux: q = ε·σ·(T_s⁴ - T_a⁴)  (W/m²)
230    ///
231    /// Temperatures **must** be in Kelvin.
232    ///
233    /// * `emissivity`    – surface emissivity (0–1)
234    /// * `temp_surface`  – surface temperature (K)
235    /// * `temp_ambient`  – ambient temperature (K)
236    pub fn radiation_heat_flux(emissivity: f64, temp_surface: f64, temp_ambient: f64) -> f64 {
237        emissivity * STEFAN_BOLTZMANN * (temp_surface.powi(4) - temp_ambient.powi(4))
238    }
239
240    // -----------------------------------------------------------------------
241    // Combined surface loss
242    // -----------------------------------------------------------------------
243
244    /// Total heat loss from a surface by convection + radiation (W)
245    ///
246    /// * `layer`        – surface layer geometry (area used for power)
247    /// * `h_conv`       – convective coefficient (W/m²·K)
248    /// * `emissivity`   – surface emissivity
249    /// * `temp_surface` – surface temperature (K)
250    /// * `temp_ambient` – ambient temperature (K)
251    pub fn combined_heat_loss(
252        layer: &ThermalLayer,
253        h_conv: f64,
254        emissivity: f64,
255        temp_surface: f64,
256        temp_ambient: f64,
257    ) -> f64 {
258        let q_conv = Self::convection_heat_flux(h_conv, temp_surface, temp_ambient);
259        let q_rad = Self::radiation_heat_flux(emissivity, temp_surface, temp_ambient);
260        (q_conv + q_rad) * layer.area
261    }
262
263    // -----------------------------------------------------------------------
264    // Transient estimate
265    // -----------------------------------------------------------------------
266
267    /// Estimate the time for the mid-plane of a 1-D slab to reach `target_temp`.
268    ///
269    /// Uses the first-term Fourier series approximation for a symmetric slab with
270    /// Dirichlet boundary condition (isothermal walls):
271    ///
272    ///   θ(0, t) ≈ (4/π) · exp(−π²·Fo)  →  t = L² / (π²·α) · ln(4·θ_0 / (π·θ))
273    ///
274    /// where θ = (T − T_boundary) / (T_init − T_boundary), L is the half-thickness.
275    ///
276    /// Returns `f64::NAN` if the target is already at or beyond the boundary temperature,
277    /// or if the geometry/physics is degenerate.
278    ///
279    /// * `material`       – slab material
280    /// * `thickness`      – full slab thickness (m); half-thickness = thickness/2
281    /// * `temp_initial`   – uniform initial temperature
282    /// * `temp_boundary`  – constant wall temperature (boundary condition)
283    /// * `target_temp`    – desired centre temperature
284    pub fn time_to_equilibrium(
285        material: &Material,
286        thickness: f64,
287        temp_initial: f64,
288        temp_boundary: f64,
289        target_temp: f64,
290    ) -> f64 {
291        let alpha = material.thermal_diffusivity();
292        if alpha <= 0.0 || thickness <= 0.0 {
293            return f64::NAN;
294        }
295
296        let half = thickness / 2.0;
297        let delta_init = temp_initial - temp_boundary;
298        let delta_target = target_temp - temp_boundary;
299
300        // Degenerate / already reached
301        if delta_init.abs() < 1e-12 || delta_target.abs() < 1e-12 {
302            return 0.0;
303        }
304
305        // Ensure the target is between initial and boundary
306        let ratio = delta_target / delta_init;
307        if ratio <= 0.0 || ratio >= 1.0 {
308            return f64::NAN;
309        }
310
311        // θ = delta_target / delta_init
312        // First-term approximation: θ ≈ (4/π) exp(−π²·α·t / L²)
313        // ⇒ t = (L² / (π²·α)) · ln( (4/π) / θ )
314        let t = (half * half / (std::f64::consts::PI * std::f64::consts::PI * alpha))
315            * ((4.0 / std::f64::consts::PI) / ratio).ln();
316
317        if t < 0.0 {
318            0.0
319        } else {
320            t
321        }
322    }
323
324    // -----------------------------------------------------------------------
325    // Interface temperatures
326    // -----------------------------------------------------------------------
327
328    /// Compute temperatures at each interface in a series composite wall.
329    ///
330    /// Returns a `Vec` with `N-1` values for `N` layers (interior interfaces).
331    /// If there is only one layer there are no interior interfaces → empty Vec.
332    ///
333    /// Uses the fact that heat flux Q is uniform in series: T_i = T_{i-1} − Q·R_i
334    pub fn interface_temperature(
335        layers: &[ThermalLayer],
336        temp_hot: f64,
337        temp_cold: f64,
338    ) -> Vec<f64> {
339        if layers.len() < 2 {
340            return Vec::new();
341        }
342
343        let q = Self::heat_flow_series(layers, temp_hot, temp_cold);
344        let mut interfaces = Vec::with_capacity(layers.len() - 1);
345        let mut t_current = temp_hot;
346
347        for layer in layers.iter().take(layers.len() - 1) {
348            t_current -= q * layer.thermal_resistance();
349            interfaces.push(t_current);
350        }
351
352        interfaces
353    }
354}
355
356// =============================================================================
357// Tests
358// =============================================================================
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    const EPS: f64 = 1e-6;
365
366    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
367        (a - b).abs() < tol
368    }
369
370    // --- Material properties ---
371
372    #[test]
373    fn test_copper_diffusivity() {
374        let m = Material::copper();
375        // α = 385 / (8960 * 385) ≈ 1.116e-4 m²/s
376        let expected = 385.0 / (8_960.0 * 385.0);
377        assert!(approx_eq(m.thermal_diffusivity(), expected, EPS));
378    }
379
380    #[test]
381    fn test_steel_diffusivity() {
382        let m = Material::steel();
383        let expected = 50.0 / (7_850.0 * 490.0);
384        assert!(approx_eq(m.thermal_diffusivity(), expected, EPS));
385    }
386
387    #[test]
388    fn test_concrete_properties() {
389        let m = Material::concrete();
390        assert_eq!(m.thermal_conductivity, 1.0);
391        assert_eq!(m.density, 2_300.0);
392        assert_eq!(m.specific_heat, 880.0);
393    }
394
395    #[test]
396    fn test_wood_properties() {
397        let m = Material::wood();
398        assert_eq!(m.thermal_conductivity, 0.12);
399        assert_eq!(m.density, 550.0);
400    }
401
402    #[test]
403    fn test_air_properties() {
404        let m = Material::air();
405        assert_eq!(m.thermal_conductivity, 0.026);
406    }
407
408    #[test]
409    fn test_material_name() {
410        assert_eq!(Material::copper().name, "Copper");
411        assert_eq!(Material::steel().name, "Steel");
412        assert_eq!(Material::concrete().name, "Concrete");
413        assert_eq!(Material::wood().name, "Wood");
414        assert_eq!(Material::air().name, "Air");
415    }
416
417    // --- Conduction flux ---
418
419    #[test]
420    fn test_conduction_heat_flux_copper() {
421        // q = 385 * (100 - 20) / 0.01 = 3_080_000 W/m²
422        let flux = HeatTransferCalc::conduction_heat_flux(&Material::copper(), 100.0, 20.0, 0.01);
423        assert!(approx_eq(flux, 3_080_000.0, 1.0));
424    }
425
426    #[test]
427    fn test_conduction_heat_flux_zero_thickness() {
428        let flux = HeatTransferCalc::conduction_heat_flux(&Material::copper(), 100.0, 20.0, 0.0);
429        assert_eq!(flux, 0.0);
430    }
431
432    #[test]
433    fn test_conduction_heat_flux_negative_thickness() {
434        let flux = HeatTransferCalc::conduction_heat_flux(&Material::copper(), 100.0, 20.0, -0.01);
435        assert_eq!(flux, 0.0);
436    }
437
438    #[test]
439    fn test_conduction_heat_flux_air() {
440        // q = 0.026 * (50 - 20) / 0.1 = 7.8 W/m²
441        let flux = HeatTransferCalc::conduction_heat_flux(&Material::air(), 50.0, 20.0, 0.1);
442        assert!(approx_eq(flux, 7.8, EPS));
443    }
444
445    #[test]
446    fn test_conduction_power() {
447        let layer = ThermalLayer {
448            material: Material::steel(),
449            thickness: 0.05,
450            area: 2.0,
451        };
452        // flux = 50 * (200 - 25) / 0.05 = 175_000 W/m²; power = 175_000 * 2 = 350_000 W
453        let power = HeatTransferCalc::conduction_power(&layer, 200.0, 25.0);
454        assert!(approx_eq(power, 350_000.0, 1.0));
455    }
456
457    // --- Thermal resistance ---
458
459    #[test]
460    fn test_thermal_resistance_single_layer() {
461        let layer = ThermalLayer {
462            material: Material::concrete(),
463            thickness: 0.2,
464            area: 10.0,
465        };
466        // R = 0.2 / (1.0 * 10) = 0.02 K/W
467        assert!(approx_eq(layer.thermal_resistance(), 0.02, EPS));
468    }
469
470    #[test]
471    fn test_series_resistance_two_layers() {
472        let l1 = ThermalLayer {
473            material: Material::concrete(),
474            thickness: 0.2,
475            area: 10.0,
476        }; // R = 0.02
477        let l2 = ThermalLayer {
478            material: Material::wood(),
479            thickness: 0.05,
480            area: 10.0,
481        }; // R = 0.05/1.2 ≈ 0.04167
482        let r = HeatTransferCalc::series_resistance(&[l1, l2]);
483        assert!(approx_eq(r, 0.02 + 0.05 / (0.12 * 10.0), EPS));
484    }
485
486    #[test]
487    fn test_parallel_resistance_two_layers() {
488        let l1 = ThermalLayer {
489            material: Material::concrete(),
490            thickness: 0.1,
491            area: 5.0,
492        }; // R1 = 0.1 / (1*5) = 0.02
493        let l2 = ThermalLayer {
494            material: Material::concrete(),
495            thickness: 0.1,
496            area: 5.0,
497        }; // R2 = 0.02
498        let r = HeatTransferCalc::parallel_resistance(&[l1, l2]);
499        // 1/R = 1/0.02 + 1/0.02 = 100; R = 0.01
500        assert!(approx_eq(r, 0.01, EPS));
501    }
502
503    #[test]
504    fn test_heat_flow_series() {
505        let layer = ThermalLayer {
506            material: Material::concrete(),
507            thickness: 0.2,
508            area: 10.0,
509        }; // R = 0.02 K/W
510           // Q = (100 - 20) / 0.02 = 4000 W
511        let q = HeatTransferCalc::heat_flow_series(&[layer], 100.0, 20.0);
512        assert!(approx_eq(q, 4_000.0, EPS));
513    }
514
515    #[test]
516    fn test_heat_flow_series_empty() {
517        let q = HeatTransferCalc::heat_flow_series(&[], 100.0, 20.0);
518        assert_eq!(q, 0.0);
519    }
520
521    // --- Convection ---
522
523    #[test]
524    fn test_convection_heat_flux() {
525        // q = 25 * (80 - 20) = 1500 W/m²
526        let flux = HeatTransferCalc::convection_heat_flux(25.0, 80.0, 20.0);
527        assert!(approx_eq(flux, 1_500.0, EPS));
528    }
529
530    #[test]
531    fn test_convection_heat_flux_negative_diff() {
532        let flux = HeatTransferCalc::convection_heat_flux(10.0, 15.0, 25.0);
533        assert!(approx_eq(flux, -100.0, EPS));
534    }
535
536    // --- Radiation ---
537
538    #[test]
539    fn test_radiation_heat_flux_blackbody() {
540        // Perfect blackbody (ε=1) at 500 K radiating to 300 K ambient
541        let flux = HeatTransferCalc::radiation_heat_flux(1.0, 500.0, 300.0);
542        let expected = STEFAN_BOLTZMANN * (500.0_f64.powi(4) - 300.0_f64.powi(4));
543        assert!(approx_eq(flux, expected, 1e-3));
544    }
545
546    #[test]
547    fn test_radiation_heat_flux_partial_emissivity() {
548        let flux = HeatTransferCalc::radiation_heat_flux(0.5, 400.0, 300.0);
549        let expected = 0.5 * STEFAN_BOLTZMANN * (400.0_f64.powi(4) - 300.0_f64.powi(4));
550        assert!(approx_eq(flux, expected, 1e-3));
551    }
552
553    #[test]
554    fn test_radiation_heat_flux_zero_emissivity() {
555        let flux = HeatTransferCalc::radiation_heat_flux(0.0, 500.0, 300.0);
556        assert_eq!(flux, 0.0);
557    }
558
559    #[test]
560    fn test_radiation_heat_flux_equal_temps() {
561        let flux = HeatTransferCalc::radiation_heat_flux(0.9, 300.0, 300.0);
562        assert!(approx_eq(flux, 0.0, EPS));
563    }
564
565    // --- Combined ---
566
567    #[test]
568    fn test_combined_heat_loss() {
569        let layer = ThermalLayer {
570            material: Material::steel(),
571            thickness: 0.01,
572            area: 1.0,
573        };
574        // h=10, ε=0.8, T_s=400 K, T_a=300 K
575        let q_conv = HeatTransferCalc::convection_heat_flux(10.0, 400.0, 300.0); // = 1000 W/m²
576        let q_rad = HeatTransferCalc::radiation_heat_flux(0.8, 400.0, 300.0);
577        let expected = (q_conv + q_rad) * 1.0;
578        let actual = HeatTransferCalc::combined_heat_loss(&layer, 10.0, 0.8, 400.0, 300.0);
579        assert!(approx_eq(actual, expected, EPS));
580    }
581
582    #[test]
583    fn test_combined_heat_loss_area_scaling() {
584        let layer1 = ThermalLayer {
585            material: Material::steel(),
586            thickness: 0.01,
587            area: 1.0,
588        };
589        let layer2 = ThermalLayer {
590            material: Material::steel(),
591            thickness: 0.01,
592            area: 2.0,
593        };
594        let q1 = HeatTransferCalc::combined_heat_loss(&layer1, 5.0, 0.9, 350.0, 300.0);
595        let q2 = HeatTransferCalc::combined_heat_loss(&layer2, 5.0, 0.9, 350.0, 300.0);
596        assert!(approx_eq(q2, 2.0 * q1, 1e-6));
597    }
598
599    // --- Time to equilibrium ---
600
601    #[test]
602    fn test_time_to_equilibrium_basic() {
603        let m = Material::copper();
604        // Half-slab: copper, L=0.01 m, T_init=200, T_wall=20, target=21 (near boundary)
605        let t = HeatTransferCalc::time_to_equilibrium(&m, 0.02, 200.0, 20.0, 21.0);
606        assert!(t > 0.0, "time must be positive, got {t}");
607        assert!(t.is_finite(), "time must be finite");
608    }
609
610    #[test]
611    fn test_time_to_equilibrium_already_at_target() {
612        let m = Material::copper();
613        let t = HeatTransferCalc::time_to_equilibrium(&m, 0.02, 100.0, 20.0, 20.0);
614        assert_eq!(t, 0.0);
615    }
616
617    #[test]
618    fn test_time_to_equilibrium_degenerate_thickness() {
619        let m = Material::copper();
620        let t = HeatTransferCalc::time_to_equilibrium(&m, 0.0, 200.0, 20.0, 100.0);
621        assert!(t.is_nan());
622    }
623
624    #[test]
625    fn test_time_to_equilibrium_ordering() {
626        // A thicker slab takes longer
627        let m = Material::concrete();
628        let t1 = HeatTransferCalc::time_to_equilibrium(&m, 0.10, 100.0, 0.0, 50.0);
629        let t2 = HeatTransferCalc::time_to_equilibrium(&m, 0.20, 100.0, 0.0, 50.0);
630        assert!(t2 > t1, "thicker slab should take longer: t1={t1} t2={t2}");
631    }
632
633    #[test]
634    fn test_time_to_equilibrium_target_beyond_boundary() {
635        let m = Material::copper();
636        // target below boundary → NaN
637        let t = HeatTransferCalc::time_to_equilibrium(&m, 0.02, 200.0, 20.0, 10.0);
638        assert!(t.is_nan());
639    }
640
641    // --- Interface temperatures ---
642
643    #[test]
644    fn test_interface_temperature_single_layer() {
645        let layer = ThermalLayer {
646            material: Material::concrete(),
647            thickness: 0.1,
648            area: 1.0,
649        };
650        let interfaces = HeatTransferCalc::interface_temperature(&[layer], 100.0, 20.0);
651        assert!(interfaces.is_empty());
652    }
653
654    #[test]
655    fn test_interface_temperature_two_layers() {
656        // Two identical concrete layers → interface must be at mid-temperature
657        let l1 = ThermalLayer {
658            material: Material::concrete(),
659            thickness: 0.1,
660            area: 1.0,
661        };
662        let l2 = ThermalLayer {
663            material: Material::concrete(),
664            thickness: 0.1,
665            area: 1.0,
666        };
667        let interfaces = HeatTransferCalc::interface_temperature(&[l1, l2], 100.0, 0.0);
668        assert_eq!(interfaces.len(), 1);
669        // By symmetry, mid-temperature = 50 °C
670        assert!(approx_eq(interfaces[0], 50.0, 1e-9));
671    }
672
673    #[test]
674    fn test_interface_temperature_three_layers() {
675        let l1 = ThermalLayer {
676            material: Material::concrete(),
677            thickness: 0.1,
678            area: 1.0,
679        }; // R = 0.1
680        let l2 = ThermalLayer {
681            material: Material::wood(),
682            thickness: 0.12,
683            area: 1.0,
684        }; // R = 0.12/0.12 = 1.0
685        let l3 = ThermalLayer {
686            material: Material::concrete(),
687            thickness: 0.1,
688            area: 1.0,
689        }; // R = 0.1
690        let interfaces = HeatTransferCalc::interface_temperature(&[l1, l2, l3], 100.0, 0.0);
691        assert_eq!(interfaces.len(), 2);
692        // Total R = 0.1 + 1.0 + 0.1 = 1.2; Q = 100/1.2 ≈ 83.33 W
693        // T1 = 100 - 83.33*0.1 ≈ 91.67
694        // T2 = 91.67 - 83.33*1.0 ≈ 8.33
695        let q = 100.0 / 1.2;
696        assert!(approx_eq(interfaces[0], 100.0 - q * 0.1, 1e-6));
697        assert!(approx_eq(interfaces[1], interfaces[0] - q * 1.0, 1e-6));
698    }
699
700    #[test]
701    fn test_interface_temperature_monotone() {
702        let layers = vec![
703            ThermalLayer {
704                material: Material::steel(),
705                thickness: 0.01,
706                area: 1.0,
707            },
708            ThermalLayer {
709                material: Material::concrete(),
710                thickness: 0.2,
711                area: 1.0,
712            },
713            ThermalLayer {
714                material: Material::wood(),
715                thickness: 0.05,
716                area: 1.0,
717            },
718        ];
719        let temps = HeatTransferCalc::interface_temperature(&layers, 200.0, 10.0);
720        assert_eq!(temps.len(), 2);
721        // Must be monotonically decreasing
722        assert!(temps[0] > temps[1]);
723        assert!(temps[0] < 200.0);
724        assert!(temps[1] > 10.0);
725    }
726
727    // --- HeatTransferMode enum ---
728
729    #[test]
730    fn test_mode_variants() {
731        let modes = [
732            HeatTransferMode::Conduction,
733            HeatTransferMode::Convection,
734            HeatTransferMode::Radiation,
735            HeatTransferMode::Combined,
736        ];
737        for m in &modes {
738            let cloned = m.clone();
739            assert_eq!(&cloned, m);
740        }
741    }
742
743    #[test]
744    fn test_mode_debug() {
745        let s = format!("{:?}", HeatTransferMode::Conduction);
746        assert_eq!(s, "Conduction");
747    }
748
749    // --- Additional edge-case tests ---
750
751    #[test]
752    fn test_parallel_resistance_single_layer() {
753        let l = ThermalLayer {
754            material: Material::concrete(),
755            thickness: 0.1,
756            area: 1.0,
757        };
758        let r_par = HeatTransferCalc::parallel_resistance(std::slice::from_ref(&l));
759        let r_ser = HeatTransferCalc::series_resistance(std::slice::from_ref(&l));
760        assert!(approx_eq(r_par, r_ser, EPS));
761    }
762
763    #[test]
764    fn test_conduction_flux_symmetry() {
765        let flux_ab =
766            HeatTransferCalc::conduction_heat_flux(&Material::copper(), 100.0, 20.0, 0.01);
767        let flux_ba =
768            HeatTransferCalc::conduction_heat_flux(&Material::copper(), 20.0, 100.0, 0.01);
769        assert!(approx_eq(flux_ab, -flux_ba, EPS));
770    }
771
772    #[test]
773    fn test_radiation_stefan_boltzmann_value() {
774        assert!((STEFAN_BOLTZMANN - 5.67e-8).abs() < 1e-10);
775    }
776
777    #[test]
778    fn test_heat_flow_conservation_series() {
779        // Q_total must equal Q through each individual layer (series law)
780        let l1 = ThermalLayer {
781            material: Material::steel(),
782            thickness: 0.005,
783            area: 1.0,
784        };
785        let l2 = ThermalLayer {
786            material: Material::concrete(),
787            thickness: 0.15,
788            area: 1.0,
789        };
790        let q_total = HeatTransferCalc::heat_flow_series(&[l1.clone(), l2.clone()], 300.0, 20.0);
791        let r_total = HeatTransferCalc::series_resistance(&[l1, l2]);
792        assert!(approx_eq(q_total, (300.0 - 20.0) / r_total, 1e-9));
793    }
794}