1use std::collections::HashMap;
8
9#[derive(Debug, Clone, PartialEq)]
15pub struct ThermalMaterial {
16 pub name: String,
18 pub thermal_conductivity: f64,
20 pub density: f64,
22 pub specific_heat: f64,
24}
25
26impl ThermalMaterial {
27 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 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#[derive(Debug, Clone, PartialEq)]
57pub struct ThermalNetNode {
58 pub id: String,
60 pub temperature: f64,
62 pub heat_source: f64,
64}
65
66impl ThermalNetNode {
67 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#[derive(Debug, Clone, PartialEq)]
79pub struct ThermalEdge {
80 pub from: String,
82 pub to: String,
84 pub conductance: f64,
86}
87
88impl ThermalEdge {
89 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#[derive(Debug, Clone, Default)]
101pub struct ThermalSystem {
102 pub nodes: HashMap<String, ThermalNetNode>,
104 pub edges: Vec<ThermalEdge>,
106 pub time: f64,
108}
109
110impl ThermalSystem {
111 pub fn new() -> Self {
113 Self::default()
114 }
115
116 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 pub fn add_edge(&mut self, edge: ThermalEdge) {
130 self.edges.push(edge);
131 }
132
133 pub fn node_count(&self) -> usize {
135 self.nodes.len()
136 }
137}
138
139pub const STEFAN_BOLTZMANN: f64 = 5.670_374_419e-8;
145
146pub struct ThermalAnalysis;
154
155impl ThermalAnalysis {
156 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 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 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 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 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 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 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 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 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 *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 pub fn series_resistance(r1: f64, r2: f64) -> f64 {
328 r1 + r2
329 }
330
331 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 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#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[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 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 #[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 #[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 #[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 #[test]
462 fn test_fourier_heat_flux_basic() {
463 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 #[test]
490 fn test_convective_heat_transfer_basic() {
491 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 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 #[test]
519 fn test_radiative_basic() {
520 let q = ThermalAnalysis::radiative_heat_transfer(1.0, 1.0, 1000.0, 300.0);
522 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 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 #[test]
557 fn test_thermal_resistance_basic() {
558 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 #[test]
578 fn test_biot_number_basic() {
579 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 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 #[test]
600 fn test_fourier_number_basic() {
601 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 #[test]
622 fn test_transient_temperature_at_zero_time() {
623 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 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 let t = ThermalAnalysis::transient_temperature(50.0, 50.0, 0.1, 10.0);
659 assert!((t - 50.0).abs() < 1e-10);
660 }
661
662 #[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 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 #[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 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 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 #[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 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 #[test]
783 fn test_overall_htc_basic() {
784 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}