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    pub fn new(
81        nx: usize,
82        ny: usize,
83        nz: usize,
84        diffusivity: f64,
85        dx: f64,
86        dy: f64,
87        dz: f64,
88        t0: f64,
89    ) -> Self {
90        let n = nx * ny * nz;
91        Self {
92            nx,
93            ny,
94            nz,
95            diffusivity,
96            dx,
97            dy,
98            dz,
99            temperature: vec![t0; n],
100        }
101    }
102
103    /// Linear index from 3-D grid coordinates.
104    #[inline]
105    pub fn idx(&self, ix: usize, iy: usize, iz: usize) -> usize {
106        iz * self.ny * self.nx + iy * self.nx + ix
107    }
108
109    /// Total number of cells.
110    pub fn n_cells(&self) -> usize {
111        self.nx * self.ny * self.nz
112    }
113}
114
115// ---------------------------------------------------------------------------
116// gpu_heat_diffusion
117// ---------------------------------------------------------------------------
118
119/// Perform one parallel heat-equation update step (mock GPU dispatch).
120///
121/// Applies the explicit finite-difference stencil:
122/// ```text
123///   T_new[i] = T[i] + dt * α * ∇²T[i]
124/// ```
125/// Interior cells only; boundary cells are left unchanged.
126///
127/// # Arguments
128/// * `solver` - The thermal solver (updated in place).
129/// * `dt` - Time step size (s).
130pub fn gpu_heat_diffusion(solver: &mut GpuThermalSolver, dt: f64) {
131    let nx = solver.nx;
132    let ny = solver.ny;
133    let nz = solver.nz;
134    let alpha = solver.diffusivity;
135    let dx2 = solver.dx * solver.dx;
136    let dy2 = solver.dy * solver.dy;
137    let dz2 = solver.dz * solver.dz;
138    let old = solver.temperature.clone();
139
140    let new_temp: Vec<f64> = (0..nz * ny * nx)
141        .into_par_iter()
142        .map(|idx| {
143            let iz = idx / (ny * nx);
144            let rem = idx % (ny * nx);
145            let iy = rem / nx;
146            let ix = rem % nx;
147
148            // Skip boundary cells
149            if ix == 0 || ix == nx - 1 || iy == 0 || iy == ny - 1 || iz == 0 || iz == nz - 1 {
150                return old[idx];
151            }
152
153            let laplacian_x = (old[idx - 1] - 2.0 * old[idx] + old[idx + 1]) / dx2;
154            let laplacian_y = (old[idx - nx] - 2.0 * old[idx] + old[idx + nx]) / dy2;
155            let laplacian_z = (old[idx - ny * nx] - 2.0 * old[idx] + old[idx + ny * nx]) / dz2;
156
157            old[idx] + dt * alpha * (laplacian_x + laplacian_y + laplacian_z)
158        })
159        .collect();
160
161    solver.temperature = new_temp;
162}
163
164// ---------------------------------------------------------------------------
165// thermal_boundary_apply
166// ---------------------------------------------------------------------------
167
168/// Apply boundary conditions to the temperature field.
169///
170/// Iterates over all six faces of the structured grid and applies either
171/// Dirichlet or Neumann boundary conditions.
172///
173/// # Arguments
174/// * `solver` - The thermal solver (updated in place).
175/// * `bc_xmin` - BC on the x = 0 face.
176/// * `bc_xmax` - BC on the x = nx-1 face.
177/// * `bc_ymin` - BC on the y = 0 face.
178/// * `bc_ymax` - BC on the y = ny-1 face.
179/// * `bc_zmin` - BC on the z = 0 face.
180/// * `bc_zmax` - BC on the z = nz-1 face.
181pub fn thermal_boundary_apply(
182    solver: &mut GpuThermalSolver,
183    bc_xmin: ThermalBc,
184    bc_xmax: ThermalBc,
185    bc_ymin: ThermalBc,
186    bc_ymax: ThermalBc,
187    bc_zmin: ThermalBc,
188    bc_zmax: ThermalBc,
189) {
190    let nx = solver.nx;
191    let ny = solver.ny;
192    let nz = solver.nz;
193
194    // x-faces
195    for iz in 0..nz {
196        for iy in 0..ny {
197            let idx_min = solver.idx(0, iy, iz);
198            let idx_max = solver.idx(nx - 1, iy, iz);
199            apply_bc_to_cell(&mut solver.temperature, idx_min, bc_xmin, solver.dx);
200            apply_bc_to_cell(&mut solver.temperature, idx_max, bc_xmax, solver.dx);
201        }
202    }
203
204    // y-faces
205    for iz in 0..nz {
206        for ix in 0..nx {
207            let idx_min = solver.idx(ix, 0, iz);
208            let idx_max = solver.idx(ix, ny - 1, iz);
209            apply_bc_to_cell(&mut solver.temperature, idx_min, bc_ymin, solver.dy);
210            apply_bc_to_cell(&mut solver.temperature, idx_max, bc_ymax, solver.dy);
211        }
212    }
213
214    // z-faces
215    for iy in 0..ny {
216        for ix in 0..nx {
217            let idx_min = solver.idx(ix, iy, 0);
218            let idx_max = solver.idx(ix, iy, nz - 1);
219            apply_bc_to_cell(&mut solver.temperature, idx_min, bc_zmin, solver.dz);
220            apply_bc_to_cell(&mut solver.temperature, idx_max, bc_zmax, solver.dz);
221        }
222    }
223}
224
225/// Internal helper: apply one BC to a cell.
226fn apply_bc_to_cell(temperature: &mut [f64], idx: usize, bc: ThermalBc, _h: f64) {
227    match bc {
228        ThermalBc::Dirichlet(val) => {
229            temperature[idx] = val;
230        }
231        ThermalBc::Neumann(_flux) => {
232            // For Neumann: T[ghost] = T[interior] + flux*h
233            // In a 1-D ghost-cell approach we just leave the boundary unchanged
234            // (zero-flux by default). A full implementation would require ghost
235            // cell indices; this mock sets the value to maintain the gradient.
236            // No modification needed for zero-flux; non-zero handled outside.
237        }
238    }
239}
240
241// ---------------------------------------------------------------------------
242// gpu_heat_source
243// ---------------------------------------------------------------------------
244
245/// Apply volumetric heat sources to the temperature field (mock GPU kernel).
246///
247/// Each source adds `source.power * dt` to the temperature of its cell.
248///
249/// # Arguments
250/// * `solver` - The thermal solver (updated in place).
251/// * `sources` - List of heat sources.
252/// * `dt` - Time step size (s).
253pub fn gpu_heat_source(solver: &mut GpuThermalSolver, sources: &[HeatSource], dt: f64) {
254    for src in sources {
255        if src.cell_index < solver.temperature.len() {
256            solver.temperature[src.cell_index] += src.power * dt;
257        }
258    }
259}
260
261// ---------------------------------------------------------------------------
262// temperature_gradient
263// ---------------------------------------------------------------------------
264
265/// Compute the temperature gradient at every interior cell using central differences.
266///
267/// Returns a `Vec<[f64; 3]>` of length `nx * ny * nz`.  Boundary cells get
268/// a gradient of `[0.0, 0.0, 0.0]`.
269///
270/// # Arguments
271/// * `solver` - The thermal solver.
272pub fn temperature_gradient(solver: &GpuThermalSolver) -> Vec<[f64; 3]> {
273    let nx = solver.nx;
274    let ny = solver.ny;
275    let nz = solver.nz;
276    let t = &solver.temperature;
277
278    (0..nz * ny * nx)
279        .into_par_iter()
280        .map(|idx| {
281            let iz = idx / (ny * nx);
282            let rem = idx % (ny * nx);
283            let iy = rem / nx;
284            let ix = rem % nx;
285
286            if ix == 0 || ix == nx - 1 || iy == 0 || iy == ny - 1 || iz == 0 || iz == nz - 1 {
287                return [0.0; 3];
288            }
289
290            let gx = (t[idx + 1] - t[idx - 1]) / (2.0 * solver.dx);
291            let gy = (t[idx + nx] - t[idx - nx]) / (2.0 * solver.dy);
292            let gz = (t[idx + ny * nx] - t[idx - ny * nx]) / (2.0 * solver.dz);
293
294            [gx, gy, gz]
295        })
296        .collect()
297}
298
299// ---------------------------------------------------------------------------
300// thermal_equilibration
301// ---------------------------------------------------------------------------
302
303/// Check whether the temperature field has reached thermal equilibrium.
304///
305/// Returns `true` when the maximum absolute change between `old` and the
306/// current field is below `tol`.
307///
308/// # Arguments
309/// * `solver` - The thermal solver (current state).
310/// * `old` - Temperature field from the previous iteration.
311/// * `tol` - Convergence tolerance (K or simulation units).
312pub fn thermal_equilibration(solver: &GpuThermalSolver, old: &[f64], tol: f64) -> bool {
313    if old.len() != solver.temperature.len() {
314        return false;
315    }
316    solver
317        .temperature
318        .par_iter()
319        .zip(old.par_iter())
320        .map(|(&new, &prev)| (new - prev).abs())
321        .reduce(|| 0.0_f64, f64::max)
322        < tol
323}
324
325// ---------------------------------------------------------------------------
326// Tests
327// ---------------------------------------------------------------------------
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn make_solver(nx: usize, ny: usize, nz: usize, t0: f64) -> GpuThermalSolver {
334        GpuThermalSolver::new(nx, ny, nz, 1e-4, 0.1, 0.1, 0.1, t0)
335    }
336
337    // ── GpuThermalSolver construction ─────────────────────────────────────
338
339    #[test]
340    fn test_solver_new_size() {
341        let s = make_solver(4, 4, 4, 300.0);
342        assert_eq!(s.n_cells(), 64);
343        assert_eq!(s.temperature.len(), 64);
344    }
345
346    #[test]
347    fn test_solver_uniform_init() {
348        let s = make_solver(3, 3, 3, 273.15);
349        assert!(s.temperature.iter().all(|&t| (t - 273.15).abs() < 1e-12));
350    }
351
352    #[test]
353    fn test_solver_idx_origin() {
354        let s = make_solver(5, 5, 5, 0.0);
355        assert_eq!(s.idx(0, 0, 0), 0);
356    }
357
358    #[test]
359    fn test_solver_idx_last() {
360        let s = make_solver(5, 5, 5, 0.0);
361        assert_eq!(s.idx(4, 4, 4), 124);
362    }
363
364    #[test]
365    fn test_solver_idx_slice() {
366        let s = make_solver(4, 4, 4, 0.0);
367        assert_eq!(s.idx(2, 1, 0), 4 + 2);
368    }
369
370    // ── gpu_heat_diffusion ────────────────────────────────────────────────
371
372    #[test]
373    fn test_diffusion_boundary_unchanged() {
374        let mut s = make_solver(4, 4, 4, 100.0);
375        // Set a spike in the interior
376        let idx = s.idx(2, 2, 2);
377        s.temperature[idx] = 500.0;
378        let boundary_before = s.temperature[s.idx(0, 0, 0)];
379        gpu_heat_diffusion(&mut s, 0.001);
380        // Corner boundary must stay at 100 (not updated by diffusion kernel)
381        assert!((s.temperature[s.idx(0, 0, 0)] - boundary_before).abs() < 1e-12);
382    }
383
384    #[test]
385    fn test_diffusion_uniform_field_unchanged() {
386        let mut s = make_solver(5, 5, 5, 300.0);
387        let before: Vec<f64> = s.temperature.clone();
388        gpu_heat_diffusion(&mut s, 0.01);
389        // Uniform field: Laplacian is zero, so nothing should change
390        for (a, b) in s.temperature.iter().zip(before.iter()) {
391            assert!(
392                (a - b).abs() < 1e-12,
393                "uniform field changed under diffusion"
394            );
395        }
396    }
397
398    #[test]
399    fn test_diffusion_hot_spot_cools() {
400        let mut s = make_solver(5, 5, 5, 0.0);
401        let idx = s.idx(2, 2, 2);
402        s.temperature[idx] = 1000.0;
403        let before = s.temperature[idx];
404        gpu_heat_diffusion(&mut s, 0.001);
405        // The hot spot should cool (energy spreads)
406        assert!(s.temperature[idx] < before);
407    }
408
409    #[test]
410    fn test_diffusion_cold_spot_warms() {
411        let mut s = make_solver(5, 5, 5, 300.0);
412        let idx = s.idx(2, 2, 2);
413        s.temperature[idx] = 0.0;
414        gpu_heat_diffusion(&mut s, 0.001);
415        assert!(s.temperature[idx] > 0.0);
416    }
417
418    #[test]
419    fn test_diffusion_energy_approximately_conserved_interior() {
420        // Total energy of interior cells should stay roughly the same
421        let mut s = make_solver(5, 5, 5, 0.0);
422        let idx = s.idx(2, 2, 2);
423        s.temperature[idx] = 1000.0;
424        let sum_before: f64 = s.temperature.iter().sum();
425        gpu_heat_diffusion(&mut s, 0.001);
426        let sum_after: f64 = s.temperature.iter().sum();
427        assert!((sum_before - sum_after).abs() < 1e-6 * sum_before.abs() + 1e-9);
428    }
429
430    // ── thermal_boundary_apply ────────────────────────────────────────────
431
432    #[test]
433    fn test_dirichlet_xmin_applied() {
434        let mut s = make_solver(6, 6, 6, 0.0);
435        thermal_boundary_apply(
436            &mut s,
437            ThermalBc::Dirichlet(500.0),
438            ThermalBc::Dirichlet(500.0),
439            ThermalBc::Dirichlet(500.0),
440            ThermalBc::Dirichlet(500.0),
441            ThermalBc::Dirichlet(500.0),
442            ThermalBc::Dirichlet(500.0),
443        );
444        // All boundary cells should be 500 when all faces have same Dirichlet value
445        for iz in 0..6 {
446            for iy in 0..6 {
447                let idx = s.idx(0, iy, iz);
448                assert!(
449                    (s.temperature[idx] - 500.0).abs() < 1e-12,
450                    "x=0 face cell ({iy},{iz}) expected 500, got {}",
451                    s.temperature[idx]
452                );
453            }
454        }
455    }
456
457    #[test]
458    fn test_dirichlet_xmax_applied() {
459        let mut s = make_solver(6, 6, 6, 0.0);
460        thermal_boundary_apply(
461            &mut s,
462            ThermalBc::Dirichlet(200.0),
463            ThermalBc::Dirichlet(200.0),
464            ThermalBc::Dirichlet(200.0),
465            ThermalBc::Dirichlet(200.0),
466            ThermalBc::Dirichlet(200.0),
467            ThermalBc::Dirichlet(200.0),
468        );
469        // All boundary cells should be 200 when all faces share the same value
470        for iz in 0..6 {
471            for iy in 0..6 {
472                let idx = s.idx(5, iy, iz);
473                assert!(
474                    (s.temperature[idx] - 200.0).abs() < 1e-12,
475                    "x=5 face cell ({iy},{iz}) expected 200, got {}",
476                    s.temperature[idx]
477                );
478            }
479        }
480    }
481
482    #[test]
483    fn test_neumann_bc_leaves_interior_unchanged_for_zero_flux() {
484        let mut s = make_solver(4, 4, 4, 300.0);
485        let interior_before = s.temperature[s.idx(2, 2, 2)];
486        thermal_boundary_apply(
487            &mut s,
488            ThermalBc::Neumann(0.0),
489            ThermalBc::Neumann(0.0),
490            ThermalBc::Neumann(0.0),
491            ThermalBc::Neumann(0.0),
492            ThermalBc::Neumann(0.0),
493            ThermalBc::Neumann(0.0),
494        );
495        let interior_after = s.temperature[s.idx(2, 2, 2)];
496        assert!((interior_before - interior_after).abs() < 1e-12);
497    }
498
499    // ── gpu_heat_source ───────────────────────────────────────────────────
500
501    #[test]
502    fn test_heat_source_single_cell() {
503        let mut s = make_solver(4, 4, 4, 0.0);
504        let idx = s.idx(2, 2, 2);
505        let sources = vec![HeatSource::new(idx, 1000.0)];
506        gpu_heat_source(&mut s, &sources, 0.1);
507        assert!((s.temperature[idx] - 100.0).abs() < 1e-10);
508    }
509
510    #[test]
511    fn test_heat_source_multiple_cells() {
512        let mut s = make_solver(4, 4, 4, 0.0);
513        let idx1 = s.idx(1, 1, 1);
514        let idx2 = s.idx(2, 2, 2);
515        let sources = vec![HeatSource::new(idx1, 500.0), HeatSource::new(idx2, 200.0)];
516        gpu_heat_source(&mut s, &sources, 1.0);
517        assert!((s.temperature[idx1] - 500.0).abs() < 1e-10);
518        assert!((s.temperature[idx2] - 200.0).abs() < 1e-10);
519    }
520
521    #[test]
522    fn test_heat_source_out_of_bounds_ignored() {
523        let mut s = make_solver(4, 4, 4, 0.0);
524        let sources = vec![HeatSource::new(9999, 1000.0)];
525        // Should not panic
526        gpu_heat_source(&mut s, &sources, 1.0);
527        assert!(s.temperature.iter().all(|&t| t.abs() < 1e-12));
528    }
529
530    #[test]
531    fn test_heat_source_zero_power() {
532        let mut s = make_solver(4, 4, 4, 100.0);
533        let idx = s.idx(2, 2, 2);
534        let sources = vec![HeatSource::new(idx, 0.0)];
535        gpu_heat_source(&mut s, &sources, 1.0);
536        assert!((s.temperature[idx] - 100.0).abs() < 1e-12);
537    }
538
539    // ── temperature_gradient ──────────────────────────────────────────────
540
541    #[test]
542    fn test_gradient_uniform_field_is_zero() {
543        let s = make_solver(5, 5, 5, 300.0);
544        let grad = temperature_gradient(&s);
545        for g in &grad {
546            assert!(g[0].abs() < 1e-12 && g[1].abs() < 1e-12 && g[2].abs() < 1e-12);
547        }
548    }
549
550    #[test]
551    fn test_gradient_boundary_is_zero() {
552        let s = make_solver(4, 4, 4, 100.0);
553        let grad = temperature_gradient(&s);
554        // Boundary cell (0,0,0)
555        assert_eq!(grad[0], [0.0; 3]);
556    }
557
558    #[test]
559    fn test_gradient_x_linear_field() {
560        // T = ix * dx (linear in x), so dT/dx = 1.0, dT/dy = 0, dT/dz = 0
561        let mut s = GpuThermalSolver::new(5, 5, 5, 1e-4, 1.0, 1.0, 1.0, 0.0);
562        for iz in 0..5 {
563            for iy in 0..5 {
564                for ix in 0..5 {
565                    let idx = s.idx(ix, iy, iz);
566                    s.temperature[idx] = ix as f64;
567                }
568            }
569        }
570        let grad = temperature_gradient(&s);
571        let idx = s.idx(2, 2, 2);
572        assert!((grad[idx][0] - 1.0).abs() < 1e-12, "gx={}", grad[idx][0]);
573        assert!(grad[idx][1].abs() < 1e-12);
574        assert!(grad[idx][2].abs() < 1e-12);
575    }
576
577    #[test]
578    fn test_gradient_y_linear_field() {
579        let mut s = GpuThermalSolver::new(5, 5, 5, 1e-4, 1.0, 1.0, 1.0, 0.0);
580        for iz in 0..5 {
581            for iy in 0..5 {
582                for ix in 0..5 {
583                    let idx = s.idx(ix, iy, iz);
584                    s.temperature[idx] = iy as f64;
585                }
586            }
587        }
588        let grad = temperature_gradient(&s);
589        let idx = s.idx(2, 2, 2);
590        assert!(grad[idx][0].abs() < 1e-12);
591        assert!((grad[idx][1] - 1.0).abs() < 1e-12, "gy={}", grad[idx][1]);
592        assert!(grad[idx][2].abs() < 1e-12);
593    }
594
595    // ── thermal_equilibration ─────────────────────────────────────────────
596
597    #[test]
598    fn test_equilibration_identical_fields() {
599        let s = make_solver(4, 4, 4, 300.0);
600        let old = s.temperature.clone();
601        assert!(thermal_equilibration(&s, &old, 1e-6));
602    }
603
604    #[test]
605    fn test_equilibration_large_change() {
606        let s = make_solver(4, 4, 4, 300.0);
607        let old = vec![0.0; s.n_cells()];
608        assert!(!thermal_equilibration(&s, &old, 1e-6));
609    }
610
611    #[test]
612    fn test_equilibration_small_change_below_tol() {
613        let mut s = make_solver(4, 4, 4, 300.0);
614        let old = s.temperature.clone();
615        s.temperature[0] += 1e-8; // tiny change
616        assert!(thermal_equilibration(&s, &old, 1e-6));
617    }
618
619    #[test]
620    fn test_equilibration_small_change_above_tol() {
621        let mut s = make_solver(4, 4, 4, 300.0);
622        let old = s.temperature.clone();
623        s.temperature[0] += 1.0; // large change
624        assert!(!thermal_equilibration(&s, &old, 1e-6));
625    }
626
627    #[test]
628    fn test_equilibration_length_mismatch_returns_false() {
629        let s = make_solver(4, 4, 4, 300.0);
630        let old = vec![300.0; 10]; // wrong length
631        assert!(!thermal_equilibration(&s, &old, 1e-6));
632    }
633
634    // ── HeatSource struct ─────────────────────────────────────────────────
635
636    #[test]
637    fn test_heat_source_fields() {
638        let src = HeatSource::new(42, 999.9);
639        assert_eq!(src.cell_index, 42);
640        assert!((src.power - 999.9).abs() < 1e-12);
641    }
642
643    // ── Integration: diffusion + BC ───────────────────────────────────────
644
645    #[test]
646    fn test_diffusion_and_dirichlet_bc_combined() {
647        let mut s = GpuThermalSolver::new(5, 5, 5, 1e-3, 0.1, 0.1, 0.1, 0.0);
648        // Hot left wall
649        thermal_boundary_apply(
650            &mut s,
651            ThermalBc::Dirichlet(100.0),
652            ThermalBc::Dirichlet(0.0),
653            ThermalBc::Dirichlet(0.0),
654            ThermalBc::Dirichlet(0.0),
655            ThermalBc::Dirichlet(0.0),
656            ThermalBc::Dirichlet(0.0),
657        );
658        // Run diffusion for several steps
659        for _ in 0..10 {
660            gpu_heat_diffusion(&mut s, 0.001);
661            // Re-apply BC each step
662            thermal_boundary_apply(
663                &mut s,
664                ThermalBc::Dirichlet(100.0),
665                ThermalBc::Dirichlet(0.0),
666                ThermalBc::Dirichlet(0.0),
667                ThermalBc::Dirichlet(0.0),
668                ThermalBc::Dirichlet(0.0),
669                ThermalBc::Dirichlet(0.0),
670            );
671        }
672        // Interior cells should be warming up
673        let interior_idx = s.idx(2, 2, 2);
674        assert!(s.temperature[interior_idx] > 0.0, "interior should warm up");
675        // x=0 boundary still fixed at 100
676        let bc_idx = s.idx(0, 2, 2);
677        assert!((s.temperature[bc_idx] - 100.0).abs() < 1e-12);
678    }
679}