Skip to main content

oxiphysics_gpu/
gpu_thermal.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-accelerated thermal computation (CPU mock backend via Rayon).
5//!
6//! Implements a parallel finite-difference heat equation solver that mirrors
7//! the structure of a GPU compute dispatch. The "GPU" here is simulated via
8//! Rayon parallel iterators, making it easy to swap in a real GPU backend later.
9
10use rayon::prelude::*;
11
12// ---------------------------------------------------------------------------
13// Boundary condition type
14// ---------------------------------------------------------------------------
15
16/// Boundary condition type for thermal simulations.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum ThermalBc {
19    /// Dirichlet: fixed temperature value at the boundary.
20    Dirichlet(f64),
21    /// Neumann: fixed heat flux (derivative) at the boundary.
22    Neumann(f64),
23}
24
25// ---------------------------------------------------------------------------
26// HeatSource
27// ---------------------------------------------------------------------------
28
29/// Volumetric heat source specification.
30#[derive(Debug, Clone)]
31pub struct HeatSource {
32    /// Linear index of the cell where the source is applied.
33    pub cell_index: usize,
34    /// Power per unit volume (W/m³ equivalent in simulation units).
35    pub power: f64,
36}
37
38impl HeatSource {
39    /// Create a new heat source at `cell_index` with the given `power`.
40    pub fn new(cell_index: usize, power: f64) -> Self {
41        Self { cell_index, power }
42    }
43}
44
45// ---------------------------------------------------------------------------
46// GpuThermalSolver
47// ---------------------------------------------------------------------------
48
49/// GPU-accelerated (CPU mock) thermal solver for 3-D structured grids.
50///
51/// Solves the heat equation:
52/// ```text
53///   ∂T/∂t = α ∇²T + Q
54/// ```
55/// where `α` is the thermal diffusivity and `Q` is a volumetric heat source.
56///
57/// Grid layout: row-major, index `[iz * ny * nx + iy * nx + ix]`.
58#[derive(Debug, Clone)]
59pub struct GpuThermalSolver {
60    /// Number of cells in the x-direction.
61    pub nx: usize,
62    /// Number of cells in the y-direction.
63    pub ny: usize,
64    /// Number of cells in the z-direction.
65    pub nz: usize,
66    /// Thermal diffusivity (m²/s or simulation units).
67    pub diffusivity: f64,
68    /// Grid spacing in x (m).
69    pub dx: f64,
70    /// Grid spacing in y (m).
71    pub dy: f64,
72    /// Grid spacing in z (m).
73    pub dz: f64,
74    /// Current temperature field, length `nx * ny * nz`.
75    pub temperature: Vec<f64>,
76}
77
78impl GpuThermalSolver {
79    /// Create a new solver with uniform initial temperature `t0`.
80    #[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    /// Linear index from 3-D grid coordinates.
105    #[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    /// Total number of cells.
111    pub fn n_cells(&self) -> usize {
112        self.nx * self.ny * self.nz
113    }
114}
115
116// ---------------------------------------------------------------------------
117// gpu_heat_diffusion
118// ---------------------------------------------------------------------------
119
120/// Perform one parallel heat-equation update step (mock GPU dispatch).
121///
122/// Applies the explicit finite-difference stencil:
123/// ```text
124///   T_new[i] = T[i] + dt * α * ∇²T[i]
125/// ```
126/// Interior cells only; boundary cells are left unchanged.
127///
128/// # Arguments
129/// * `solver` - The thermal solver (updated in place).
130/// * `dt` - Time step size (s).
131pub 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            // Skip boundary cells
150            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// ---------------------------------------------------------------------------
166// thermal_boundary_apply
167// ---------------------------------------------------------------------------
168
169/// Apply boundary conditions to the temperature field.
170///
171/// Iterates over all six faces of the structured grid and applies either
172/// Dirichlet or Neumann boundary conditions.
173///
174/// # Arguments
175/// * `solver` - The thermal solver (updated in place).
176/// * `bc_xmin` - BC on the x = 0 face.
177/// * `bc_xmax` - BC on the x = nx-1 face.
178/// * `bc_ymin` - BC on the y = 0 face.
179/// * `bc_ymax` - BC on the y = ny-1 face.
180/// * `bc_zmin` - BC on the z = 0 face.
181/// * `bc_zmax` - BC on the z = nz-1 face.
182#[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    // x-faces
197    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    // y-faces
207    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    // z-faces
217    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/// Internal helper: apply one BC to a cell.
228#[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            // For Neumann: T[ghost] = T[interior] + flux*h
236            // In a 1-D ghost-cell approach we just leave the boundary unchanged
237            // (zero-flux by default). A full implementation would require ghost
238            // cell indices; this mock sets the value to maintain the gradient.
239            // No modification needed for zero-flux; non-zero handled outside.
240        }
241    }
242}
243
244// ---------------------------------------------------------------------------
245// gpu_heat_source
246// ---------------------------------------------------------------------------
247
248/// Apply volumetric heat sources to the temperature field (mock GPU kernel).
249///
250/// Each source adds `source.power * dt` to the temperature of its cell.
251///
252/// # Arguments
253/// * `solver` - The thermal solver (updated in place).
254/// * `sources` - List of heat sources.
255/// * `dt` - Time step size (s).
256pub 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
264// ---------------------------------------------------------------------------
265// temperature_gradient
266// ---------------------------------------------------------------------------
267
268/// Compute the temperature gradient at every interior cell using central differences.
269///
270/// Returns a `Vec<[f64; 3]>` of length `nx * ny * nz`.  Boundary cells get
271/// a gradient of `[0.0, 0.0, 0.0]`.
272///
273/// # Arguments
274/// * `solver` - The thermal solver.
275pub 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
302// ---------------------------------------------------------------------------
303// thermal_equilibration
304// ---------------------------------------------------------------------------
305
306/// Check whether the temperature field has reached thermal equilibrium.
307///
308/// Returns `true` when the maximum absolute change between `old` and the
309/// current field is below `tol`.
310///
311/// # Arguments
312/// * `solver` - The thermal solver (current state).
313/// * `old` - Temperature field from the previous iteration.
314/// * `tol` - Convergence tolerance (K or simulation units).
315pub 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// ---------------------------------------------------------------------------
329// Tests
330// ---------------------------------------------------------------------------
331
332#[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    // ── GpuThermalSolver construction ─────────────────────────────────────
341
342    #[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    // ── gpu_heat_diffusion ────────────────────────────────────────────────
374
375    #[test]
376    fn test_diffusion_boundary_unchanged() {
377        let mut s = make_solver(4, 4, 4, 100.0);
378        // Set a spike in the interior
379        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        // Corner boundary must stay at 100 (not updated by diffusion kernel)
384        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        // Uniform field: Laplacian is zero, so nothing should change
393        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        // The hot spot should cool (energy spreads)
409        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        // Total energy of interior cells should stay roughly the same
424        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    // ── thermal_boundary_apply ────────────────────────────────────────────
434
435    #[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        // All boundary cells should be 500 when all faces have same Dirichlet value
448        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        // All boundary cells should be 200 when all faces share the same value
473        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    // ── gpu_heat_source ───────────────────────────────────────────────────
503
504    #[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        // Should not panic
529        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    // ── temperature_gradient ──────────────────────────────────────────────
543
544    #[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        // Boundary cell (0,0,0)
558        assert_eq!(grad[0], [0.0; 3]);
559    }
560
561    #[test]
562    fn test_gradient_x_linear_field() {
563        // T = ix * dx (linear in x), so dT/dx = 1.0, dT/dy = 0, dT/dz = 0
564        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    // ── thermal_equilibration ─────────────────────────────────────────────
599
600    #[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; // tiny change
619        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; // large change
627        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]; // wrong length
634        assert!(!thermal_equilibration(&s, &old, 1e-6));
635    }
636
637    // ── HeatSource struct ─────────────────────────────────────────────────
638
639    #[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    // ── Integration: diffusion + BC ───────────────────────────────────────
647
648    #[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        // Hot left wall
652        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        // Run diffusion for several steps
662        for _ in 0..10 {
663            gpu_heat_diffusion(&mut s, 0.001);
664            // Re-apply BC each step
665            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        // Interior cells should be warming up
676        let interior_idx = s.idx(2, 2, 2);
677        assert!(s.temperature[interior_idx] > 0.0, "interior should warm up");
678        // x=0 boundary still fixed at 100
679        let bc_idx = s.idx(0, 2, 2);
680        assert!((s.temperature[bc_idx] - 100.0).abs() < 1e-12);
681    }
682}