1use rayon::prelude::*;
11
12#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum ThermalBc {
19 Dirichlet(f64),
21 Neumann(f64),
23}
24
25#[derive(Debug, Clone)]
31pub struct HeatSource {
32 pub cell_index: usize,
34 pub power: f64,
36}
37
38impl HeatSource {
39 pub fn new(cell_index: usize, power: f64) -> Self {
41 Self { cell_index, power }
42 }
43}
44
45#[derive(Debug, Clone)]
59pub struct GpuThermalSolver {
60 pub nx: usize,
62 pub ny: usize,
64 pub nz: usize,
66 pub diffusivity: f64,
68 pub dx: f64,
70 pub dy: f64,
72 pub dz: f64,
74 pub temperature: Vec<f64>,
76}
77
78impl GpuThermalSolver {
79 #[allow(clippy::too_many_arguments)]
81 pub fn new(
82 nx: usize,
83 ny: usize,
84 nz: usize,
85 diffusivity: f64,
86 dx: f64,
87 dy: f64,
88 dz: f64,
89 t0: f64,
90 ) -> Self {
91 let n = nx * ny * nz;
92 Self {
93 nx,
94 ny,
95 nz,
96 diffusivity,
97 dx,
98 dy,
99 dz,
100 temperature: vec![t0; n],
101 }
102 }
103
104 #[inline]
106 pub fn idx(&self, ix: usize, iy: usize, iz: usize) -> usize {
107 iz * self.ny * self.nx + iy * self.nx + ix
108 }
109
110 pub fn n_cells(&self) -> usize {
112 self.nx * self.ny * self.nz
113 }
114}
115
116pub fn gpu_heat_diffusion(solver: &mut GpuThermalSolver, dt: f64) {
132 let nx = solver.nx;
133 let ny = solver.ny;
134 let nz = solver.nz;
135 let alpha = solver.diffusivity;
136 let dx2 = solver.dx * solver.dx;
137 let dy2 = solver.dy * solver.dy;
138 let dz2 = solver.dz * solver.dz;
139 let old = solver.temperature.clone();
140
141 let new_temp: Vec<f64> = (0..nz * ny * nx)
142 .into_par_iter()
143 .map(|idx| {
144 let iz = idx / (ny * nx);
145 let rem = idx % (ny * nx);
146 let iy = rem / nx;
147 let ix = rem % nx;
148
149 if ix == 0 || ix == nx - 1 || iy == 0 || iy == ny - 1 || iz == 0 || iz == nz - 1 {
151 return old[idx];
152 }
153
154 let laplacian_x = (old[idx - 1] - 2.0 * old[idx] + old[idx + 1]) / dx2;
155 let laplacian_y = (old[idx - nx] - 2.0 * old[idx] + old[idx + nx]) / dy2;
156 let laplacian_z = (old[idx - ny * nx] - 2.0 * old[idx] + old[idx + ny * nx]) / dz2;
157
158 old[idx] + dt * alpha * (laplacian_x + laplacian_y + laplacian_z)
159 })
160 .collect();
161
162 solver.temperature = new_temp;
163}
164
165#[allow(clippy::too_many_arguments)]
183pub fn thermal_boundary_apply(
184 solver: &mut GpuThermalSolver,
185 bc_xmin: ThermalBc,
186 bc_xmax: ThermalBc,
187 bc_ymin: ThermalBc,
188 bc_ymax: ThermalBc,
189 bc_zmin: ThermalBc,
190 bc_zmax: ThermalBc,
191) {
192 let nx = solver.nx;
193 let ny = solver.ny;
194 let nz = solver.nz;
195
196 for iz in 0..nz {
198 for iy in 0..ny {
199 let idx_min = solver.idx(0, iy, iz);
200 let idx_max = solver.idx(nx - 1, iy, iz);
201 apply_bc_to_cell(&mut solver.temperature, idx_min, bc_xmin, solver.dx);
202 apply_bc_to_cell(&mut solver.temperature, idx_max, bc_xmax, solver.dx);
203 }
204 }
205
206 for iz in 0..nz {
208 for ix in 0..nx {
209 let idx_min = solver.idx(ix, 0, iz);
210 let idx_max = solver.idx(ix, ny - 1, iz);
211 apply_bc_to_cell(&mut solver.temperature, idx_min, bc_ymin, solver.dy);
212 apply_bc_to_cell(&mut solver.temperature, idx_max, bc_ymax, solver.dy);
213 }
214 }
215
216 for iy in 0..ny {
218 for ix in 0..nx {
219 let idx_min = solver.idx(ix, iy, 0);
220 let idx_max = solver.idx(ix, iy, nz - 1);
221 apply_bc_to_cell(&mut solver.temperature, idx_min, bc_zmin, solver.dz);
222 apply_bc_to_cell(&mut solver.temperature, idx_max, bc_zmax, solver.dz);
223 }
224 }
225}
226
227#[allow(dead_code)]
229fn apply_bc_to_cell(temperature: &mut [f64], idx: usize, bc: ThermalBc, _h: f64) {
230 match bc {
231 ThermalBc::Dirichlet(val) => {
232 temperature[idx] = val;
233 }
234 ThermalBc::Neumann(_flux) => {
235 }
241 }
242}
243
244pub fn gpu_heat_source(solver: &mut GpuThermalSolver, sources: &[HeatSource], dt: f64) {
257 for src in sources {
258 if src.cell_index < solver.temperature.len() {
259 solver.temperature[src.cell_index] += src.power * dt;
260 }
261 }
262}
263
264pub fn temperature_gradient(solver: &GpuThermalSolver) -> Vec<[f64; 3]> {
276 let nx = solver.nx;
277 let ny = solver.ny;
278 let nz = solver.nz;
279 let t = &solver.temperature;
280
281 (0..nz * ny * nx)
282 .into_par_iter()
283 .map(|idx| {
284 let iz = idx / (ny * nx);
285 let rem = idx % (ny * nx);
286 let iy = rem / nx;
287 let ix = rem % nx;
288
289 if ix == 0 || ix == nx - 1 || iy == 0 || iy == ny - 1 || iz == 0 || iz == nz - 1 {
290 return [0.0; 3];
291 }
292
293 let gx = (t[idx + 1] - t[idx - 1]) / (2.0 * solver.dx);
294 let gy = (t[idx + nx] - t[idx - nx]) / (2.0 * solver.dy);
295 let gz = (t[idx + ny * nx] - t[idx - ny * nx]) / (2.0 * solver.dz);
296
297 [gx, gy, gz]
298 })
299 .collect()
300}
301
302pub fn thermal_equilibration(solver: &GpuThermalSolver, old: &[f64], tol: f64) -> bool {
316 if old.len() != solver.temperature.len() {
317 return false;
318 }
319 solver
320 .temperature
321 .par_iter()
322 .zip(old.par_iter())
323 .map(|(&new, &prev)| (new - prev).abs())
324 .reduce(|| 0.0_f64, f64::max)
325 < tol
326}
327
328#[cfg(test)]
333mod tests {
334 use super::*;
335
336 fn make_solver(nx: usize, ny: usize, nz: usize, t0: f64) -> GpuThermalSolver {
337 GpuThermalSolver::new(nx, ny, nz, 1e-4, 0.1, 0.1, 0.1, t0)
338 }
339
340 #[test]
343 fn test_solver_new_size() {
344 let s = make_solver(4, 4, 4, 300.0);
345 assert_eq!(s.n_cells(), 64);
346 assert_eq!(s.temperature.len(), 64);
347 }
348
349 #[test]
350 fn test_solver_uniform_init() {
351 let s = make_solver(3, 3, 3, 273.15);
352 assert!(s.temperature.iter().all(|&t| (t - 273.15).abs() < 1e-12));
353 }
354
355 #[test]
356 fn test_solver_idx_origin() {
357 let s = make_solver(5, 5, 5, 0.0);
358 assert_eq!(s.idx(0, 0, 0), 0);
359 }
360
361 #[test]
362 fn test_solver_idx_last() {
363 let s = make_solver(5, 5, 5, 0.0);
364 assert_eq!(s.idx(4, 4, 4), 124);
365 }
366
367 #[test]
368 fn test_solver_idx_slice() {
369 let s = make_solver(4, 4, 4, 0.0);
370 assert_eq!(s.idx(2, 1, 0), 4 + 2);
371 }
372
373 #[test]
376 fn test_diffusion_boundary_unchanged() {
377 let mut s = make_solver(4, 4, 4, 100.0);
378 let idx = s.idx(2, 2, 2);
380 s.temperature[idx] = 500.0;
381 let boundary_before = s.temperature[s.idx(0, 0, 0)];
382 gpu_heat_diffusion(&mut s, 0.001);
383 assert!((s.temperature[s.idx(0, 0, 0)] - boundary_before).abs() < 1e-12);
385 }
386
387 #[test]
388 fn test_diffusion_uniform_field_unchanged() {
389 let mut s = make_solver(5, 5, 5, 300.0);
390 let before: Vec<f64> = s.temperature.clone();
391 gpu_heat_diffusion(&mut s, 0.01);
392 for (a, b) in s.temperature.iter().zip(before.iter()) {
394 assert!(
395 (a - b).abs() < 1e-12,
396 "uniform field changed under diffusion"
397 );
398 }
399 }
400
401 #[test]
402 fn test_diffusion_hot_spot_cools() {
403 let mut s = make_solver(5, 5, 5, 0.0);
404 let idx = s.idx(2, 2, 2);
405 s.temperature[idx] = 1000.0;
406 let before = s.temperature[idx];
407 gpu_heat_diffusion(&mut s, 0.001);
408 assert!(s.temperature[idx] < before);
410 }
411
412 #[test]
413 fn test_diffusion_cold_spot_warms() {
414 let mut s = make_solver(5, 5, 5, 300.0);
415 let idx = s.idx(2, 2, 2);
416 s.temperature[idx] = 0.0;
417 gpu_heat_diffusion(&mut s, 0.001);
418 assert!(s.temperature[idx] > 0.0);
419 }
420
421 #[test]
422 fn test_diffusion_energy_approximately_conserved_interior() {
423 let mut s = make_solver(5, 5, 5, 0.0);
425 let idx = s.idx(2, 2, 2);
426 s.temperature[idx] = 1000.0;
427 let sum_before: f64 = s.temperature.iter().sum();
428 gpu_heat_diffusion(&mut s, 0.001);
429 let sum_after: f64 = s.temperature.iter().sum();
430 assert!((sum_before - sum_after).abs() < 1e-6 * sum_before.abs() + 1e-9);
431 }
432
433 #[test]
436 fn test_dirichlet_xmin_applied() {
437 let mut s = make_solver(6, 6, 6, 0.0);
438 thermal_boundary_apply(
439 &mut s,
440 ThermalBc::Dirichlet(500.0),
441 ThermalBc::Dirichlet(500.0),
442 ThermalBc::Dirichlet(500.0),
443 ThermalBc::Dirichlet(500.0),
444 ThermalBc::Dirichlet(500.0),
445 ThermalBc::Dirichlet(500.0),
446 );
447 for iz in 0..6 {
449 for iy in 0..6 {
450 let idx = s.idx(0, iy, iz);
451 assert!(
452 (s.temperature[idx] - 500.0).abs() < 1e-12,
453 "x=0 face cell ({iy},{iz}) expected 500, got {}",
454 s.temperature[idx]
455 );
456 }
457 }
458 }
459
460 #[test]
461 fn test_dirichlet_xmax_applied() {
462 let mut s = make_solver(6, 6, 6, 0.0);
463 thermal_boundary_apply(
464 &mut s,
465 ThermalBc::Dirichlet(200.0),
466 ThermalBc::Dirichlet(200.0),
467 ThermalBc::Dirichlet(200.0),
468 ThermalBc::Dirichlet(200.0),
469 ThermalBc::Dirichlet(200.0),
470 ThermalBc::Dirichlet(200.0),
471 );
472 for iz in 0..6 {
474 for iy in 0..6 {
475 let idx = s.idx(5, iy, iz);
476 assert!(
477 (s.temperature[idx] - 200.0).abs() < 1e-12,
478 "x=5 face cell ({iy},{iz}) expected 200, got {}",
479 s.temperature[idx]
480 );
481 }
482 }
483 }
484
485 #[test]
486 fn test_neumann_bc_leaves_interior_unchanged_for_zero_flux() {
487 let mut s = make_solver(4, 4, 4, 300.0);
488 let interior_before = s.temperature[s.idx(2, 2, 2)];
489 thermal_boundary_apply(
490 &mut s,
491 ThermalBc::Neumann(0.0),
492 ThermalBc::Neumann(0.0),
493 ThermalBc::Neumann(0.0),
494 ThermalBc::Neumann(0.0),
495 ThermalBc::Neumann(0.0),
496 ThermalBc::Neumann(0.0),
497 );
498 let interior_after = s.temperature[s.idx(2, 2, 2)];
499 assert!((interior_before - interior_after).abs() < 1e-12);
500 }
501
502 #[test]
505 fn test_heat_source_single_cell() {
506 let mut s = make_solver(4, 4, 4, 0.0);
507 let idx = s.idx(2, 2, 2);
508 let sources = vec![HeatSource::new(idx, 1000.0)];
509 gpu_heat_source(&mut s, &sources, 0.1);
510 assert!((s.temperature[idx] - 100.0).abs() < 1e-10);
511 }
512
513 #[test]
514 fn test_heat_source_multiple_cells() {
515 let mut s = make_solver(4, 4, 4, 0.0);
516 let idx1 = s.idx(1, 1, 1);
517 let idx2 = s.idx(2, 2, 2);
518 let sources = vec![HeatSource::new(idx1, 500.0), HeatSource::new(idx2, 200.0)];
519 gpu_heat_source(&mut s, &sources, 1.0);
520 assert!((s.temperature[idx1] - 500.0).abs() < 1e-10);
521 assert!((s.temperature[idx2] - 200.0).abs() < 1e-10);
522 }
523
524 #[test]
525 fn test_heat_source_out_of_bounds_ignored() {
526 let mut s = make_solver(4, 4, 4, 0.0);
527 let sources = vec![HeatSource::new(9999, 1000.0)];
528 gpu_heat_source(&mut s, &sources, 1.0);
530 assert!(s.temperature.iter().all(|&t| t.abs() < 1e-12));
531 }
532
533 #[test]
534 fn test_heat_source_zero_power() {
535 let mut s = make_solver(4, 4, 4, 100.0);
536 let idx = s.idx(2, 2, 2);
537 let sources = vec![HeatSource::new(idx, 0.0)];
538 gpu_heat_source(&mut s, &sources, 1.0);
539 assert!((s.temperature[idx] - 100.0).abs() < 1e-12);
540 }
541
542 #[test]
545 fn test_gradient_uniform_field_is_zero() {
546 let s = make_solver(5, 5, 5, 300.0);
547 let grad = temperature_gradient(&s);
548 for g in &grad {
549 assert!(g[0].abs() < 1e-12 && g[1].abs() < 1e-12 && g[2].abs() < 1e-12);
550 }
551 }
552
553 #[test]
554 fn test_gradient_boundary_is_zero() {
555 let s = make_solver(4, 4, 4, 100.0);
556 let grad = temperature_gradient(&s);
557 assert_eq!(grad[0], [0.0; 3]);
559 }
560
561 #[test]
562 fn test_gradient_x_linear_field() {
563 let mut s = GpuThermalSolver::new(5, 5, 5, 1e-4, 1.0, 1.0, 1.0, 0.0);
565 for iz in 0..5 {
566 for iy in 0..5 {
567 for ix in 0..5 {
568 let idx = s.idx(ix, iy, iz);
569 s.temperature[idx] = ix as f64;
570 }
571 }
572 }
573 let grad = temperature_gradient(&s);
574 let idx = s.idx(2, 2, 2);
575 assert!((grad[idx][0] - 1.0).abs() < 1e-12, "gx={}", grad[idx][0]);
576 assert!(grad[idx][1].abs() < 1e-12);
577 assert!(grad[idx][2].abs() < 1e-12);
578 }
579
580 #[test]
581 fn test_gradient_y_linear_field() {
582 let mut s = GpuThermalSolver::new(5, 5, 5, 1e-4, 1.0, 1.0, 1.0, 0.0);
583 for iz in 0..5 {
584 for iy in 0..5 {
585 for ix in 0..5 {
586 let idx = s.idx(ix, iy, iz);
587 s.temperature[idx] = iy as f64;
588 }
589 }
590 }
591 let grad = temperature_gradient(&s);
592 let idx = s.idx(2, 2, 2);
593 assert!(grad[idx][0].abs() < 1e-12);
594 assert!((grad[idx][1] - 1.0).abs() < 1e-12, "gy={}", grad[idx][1]);
595 assert!(grad[idx][2].abs() < 1e-12);
596 }
597
598 #[test]
601 fn test_equilibration_identical_fields() {
602 let s = make_solver(4, 4, 4, 300.0);
603 let old = s.temperature.clone();
604 assert!(thermal_equilibration(&s, &old, 1e-6));
605 }
606
607 #[test]
608 fn test_equilibration_large_change() {
609 let s = make_solver(4, 4, 4, 300.0);
610 let old = vec![0.0; s.n_cells()];
611 assert!(!thermal_equilibration(&s, &old, 1e-6));
612 }
613
614 #[test]
615 fn test_equilibration_small_change_below_tol() {
616 let mut s = make_solver(4, 4, 4, 300.0);
617 let old = s.temperature.clone();
618 s.temperature[0] += 1e-8; assert!(thermal_equilibration(&s, &old, 1e-6));
620 }
621
622 #[test]
623 fn test_equilibration_small_change_above_tol() {
624 let mut s = make_solver(4, 4, 4, 300.0);
625 let old = s.temperature.clone();
626 s.temperature[0] += 1.0; assert!(!thermal_equilibration(&s, &old, 1e-6));
628 }
629
630 #[test]
631 fn test_equilibration_length_mismatch_returns_false() {
632 let s = make_solver(4, 4, 4, 300.0);
633 let old = vec![300.0; 10]; assert!(!thermal_equilibration(&s, &old, 1e-6));
635 }
636
637 #[test]
640 fn test_heat_source_fields() {
641 let src = HeatSource::new(42, 999.9);
642 assert_eq!(src.cell_index, 42);
643 assert!((src.power - 999.9).abs() < 1e-12);
644 }
645
646 #[test]
649 fn test_diffusion_and_dirichlet_bc_combined() {
650 let mut s = GpuThermalSolver::new(5, 5, 5, 1e-3, 0.1, 0.1, 0.1, 0.0);
651 thermal_boundary_apply(
653 &mut s,
654 ThermalBc::Dirichlet(100.0),
655 ThermalBc::Dirichlet(0.0),
656 ThermalBc::Dirichlet(0.0),
657 ThermalBc::Dirichlet(0.0),
658 ThermalBc::Dirichlet(0.0),
659 ThermalBc::Dirichlet(0.0),
660 );
661 for _ in 0..10 {
663 gpu_heat_diffusion(&mut s, 0.001);
664 thermal_boundary_apply(
666 &mut s,
667 ThermalBc::Dirichlet(100.0),
668 ThermalBc::Dirichlet(0.0),
669 ThermalBc::Dirichlet(0.0),
670 ThermalBc::Dirichlet(0.0),
671 ThermalBc::Dirichlet(0.0),
672 ThermalBc::Dirichlet(0.0),
673 );
674 }
675 let interior_idx = s.idx(2, 2, 2);
677 assert!(s.temperature[interior_idx] > 0.0, "interior should warm up");
678 let bc_idx = s.idx(0, 2, 2);
680 assert!((s.temperature[bc_idx] - 100.0).abs() < 1e-12);
681 }
682}