1pub const STEFAN_BOLTZMANN: f64 = 5.67e-8;
19
20#[derive(Debug, Clone, PartialEq)]
22pub enum HeatTransferMode {
23 Conduction,
25 Convection,
27 Radiation,
29 Combined,
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct Material {
36 pub name: String,
38 pub thermal_conductivity: f64,
40 pub density: f64,
42 pub specific_heat: f64,
44}
45
46impl Material {
47 pub const COPPER: Material = Material {
49 name: String::new(), thermal_conductivity: 385.0,
51 density: 8_960.0,
52 specific_heat: 385.0,
53 };
54
55 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 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 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 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 pub fn copper() -> Self {
89 Self {
90 name: "Copper".to_string(),
91 ..Self::COPPER
92 }
93 }
94
95 pub fn steel() -> Self {
97 Self {
98 name: "Steel".to_string(),
99 ..Self::STEEL
100 }
101 }
102
103 pub fn concrete() -> Self {
105 Self {
106 name: "Concrete".to_string(),
107 ..Self::CONCRETE
108 }
109 }
110
111 pub fn wood() -> Self {
113 Self {
114 name: "Wood".to_string(),
115 ..Self::WOOD
116 }
117 }
118
119 pub fn air() -> Self {
121 Self {
122 name: "Air".to_string(),
123 ..Self::AIR
124 }
125 }
126
127 pub fn thermal_diffusivity(&self) -> f64 {
129 self.thermal_conductivity / (self.density * self.specific_heat)
130 }
131}
132
133#[derive(Debug, Clone)]
135pub struct ThermalLayer {
136 pub material: Material,
138 pub thickness: f64,
140 pub area: f64,
142}
143
144impl ThermalLayer {
145 pub fn thermal_resistance(&self) -> f64 {
147 self.thickness / (self.material.thermal_conductivity * self.area)
148 }
149}
150
151pub struct HeatTransferCalc;
153
154impl HeatTransferCalc {
155 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 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 pub fn series_resistance(layers: &[ThermalLayer]) -> f64 {
190 layers.iter().map(|l| l.thermal_resistance()).sum()
191 }
192
193 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 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 pub fn convection_heat_flux(h: f64, temp_surface: f64, temp_fluid: f64) -> f64 {
222 h * (temp_surface - temp_fluid)
223 }
224
225 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 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 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 if delta_init.abs() < 1e-12 || delta_target.abs() < 1e-12 {
302 return 0.0;
303 }
304
305 let ratio = delta_target / delta_init;
307 if ratio <= 0.0 || ratio >= 1.0 {
308 return f64::NAN;
309 }
310
311 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 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#[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 #[test]
373 fn test_copper_diffusivity() {
374 let m = Material::copper();
375 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 #[test]
420 fn test_conduction_heat_flux_copper() {
421 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 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 let power = HeatTransferCalc::conduction_power(&layer, 200.0, 25.0);
454 assert!(approx_eq(power, 350_000.0, 1.0));
455 }
456
457 #[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 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 }; let l2 = ThermalLayer {
478 material: Material::wood(),
479 thickness: 0.05,
480 area: 10.0,
481 }; 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 }; let l2 = ThermalLayer {
494 material: Material::concrete(),
495 thickness: 0.1,
496 area: 5.0,
497 }; let r = HeatTransferCalc::parallel_resistance(&[l1, l2]);
499 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 }; 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 #[test]
524 fn test_convection_heat_flux() {
525 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 #[test]
539 fn test_radiation_heat_flux_blackbody() {
540 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 #[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 let q_conv = HeatTransferCalc::convection_heat_flux(10.0, 400.0, 300.0); 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 #[test]
602 fn test_time_to_equilibrium_basic() {
603 let m = Material::copper();
604 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 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 let t = HeatTransferCalc::time_to_equilibrium(&m, 0.02, 200.0, 20.0, 10.0);
638 assert!(t.is_nan());
639 }
640
641 #[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 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 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 }; let l2 = ThermalLayer {
681 material: Material::wood(),
682 thickness: 0.12,
683 area: 1.0,
684 }; let l3 = ThermalLayer {
686 material: Material::concrete(),
687 thickness: 0.1,
688 area: 1.0,
689 }; let interfaces = HeatTransferCalc::interface_temperature(&[l1, l2, l3], 100.0, 0.0);
691 assert_eq!(interfaces.len(), 2);
692 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 assert!(temps[0] > temps[1]);
723 assert!(temps[0] < 200.0);
724 assert!(temps[1] > 10.0);
725 }
726
727 #[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 #[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 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}