Skip to main content

oxiphysics_gpu/
gpu_md_solver.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-side molecular dynamics (MD) solver — CPU mock backend.
5//!
6//! Implements a Lennard-Jones MD solver pipeline using plain Rust loops as
7//! a CPU fallback. Periodic boundary conditions (minimum image convention)
8//! are applied. The API mirrors a GPU kernel dispatch for easy substitution.
9
10#![allow(dead_code)]
11
12// ── Data structures ──────────────────────────────────────────────────────────
13
14/// A single MD atom stored in the GPU buffer.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct GpuMdAtom {
17    /// Atom position \[x, y, z\] in Angstroms.
18    pub pos: [f32; 3],
19    /// Atom velocity \[vx, vy, vz\] in Å/ps.
20    pub vel: [f32; 3],
21    /// Current force on atom \[fx, fy, fz\] in kJ/(mol·Å).
22    pub force: [f32; 3],
23    /// Atom mass in atomic mass units (amu).
24    pub mass: f32,
25    /// Partial charge in elementary charge units.
26    pub charge: f32,
27}
28
29/// Simulation parameters for the GPU MD solver.
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct GpuMdParams {
32    /// Lennard-Jones well depth ε (kJ/mol).
33    pub epsilon: f32,
34    /// Lennard-Jones radius σ (Å).
35    pub sigma: f32,
36    /// Interaction cutoff distance (Å).
37    pub cutoff: f32,
38    /// Periodic simulation box dimensions \[Lx, Ly, Lz\] in Å.
39    pub box_size: [f32; 3],
40    /// Number of atoms.
41    pub n_atoms: usize,
42}
43
44/// GPU-side buffer holding the full MD system state.
45#[derive(Debug, Clone)]
46pub struct GpuMdBuffer {
47    /// Per-atom data.
48    pub atoms: Vec<GpuMdAtom>,
49    /// Current simulation step counter.
50    pub step: usize,
51}
52
53impl GpuMdBuffer {
54    /// Allocate a new buffer for `n` atoms, all at origin with zero velocity.
55    pub fn new(n: usize) -> Self {
56        Self {
57            atoms: vec![
58                GpuMdAtom {
59                    pos: [0.0; 3],
60                    vel: [0.0; 3],
61                    force: [0.0; 3],
62                    mass: 1.0,
63                    charge: 0.0,
64                };
65                n
66            ],
67            step: 0,
68        }
69    }
70
71    /// Number of atoms in this buffer.
72    pub fn len(&self) -> usize {
73        self.atoms.len()
74    }
75
76    /// Returns `true` if the buffer is empty.
77    pub fn is_empty(&self) -> bool {
78        self.atoms.is_empty()
79    }
80}
81
82// ── LJ kernel functions ───────────────────────────────────────────────────────
83
84/// Lennard-Jones 12-6 force magnitude dU/dr (negative of force along r̂).
85///
86/// Returns `dU/dr = 4ε [ -12σ¹²/r¹³ + 6σ⁶/r⁷ ]`.
87/// Returns zero for `r >= cutoff` or `r < 1e-10`.
88pub fn lj_force_gpu(r: f32, params: &GpuMdParams) -> f32 {
89    if r >= params.cutoff || r < 1e-10 {
90        return 0.0;
91    }
92    let sr = params.sigma / r;
93    let sr6 = sr * sr * sr * sr * sr * sr;
94    let sr12 = sr6 * sr6;
95    // Return the force F = -dU/dr = 4ε(12σ¹²/r¹³ - 6σ⁶/r⁷)
96    4.0 * params.epsilon * (12.0 * sr12 - 6.0 * sr6) / r
97}
98
99/// Lennard-Jones 12-6 pair potential.
100///
101/// Returns `U(r) = 4ε [ (σ/r)¹² − (σ/r)⁶ ]`.
102/// Returns zero for `r >= cutoff`.
103pub fn lj_potential_gpu(r: f32, params: &GpuMdParams) -> f32 {
104    if r >= params.cutoff || r < 1e-10 {
105        return 0.0;
106    }
107    let sr = params.sigma / r;
108    let sr6 = sr * sr * sr * sr * sr * sr;
109    let sr12 = sr6 * sr6;
110    4.0 * params.epsilon * (sr12 - sr6)
111}
112
113// ── Periodic boundary conditions ──────────────────────────────────────────────
114
115/// Minimum-image distance between two atoms under periodic boundary conditions.
116///
117/// Applies the minimum image convention along each box axis.
118pub fn pbc_distance_gpu(a: [f32; 3], b: [f32; 3], box_size: [f32; 3]) -> f32 {
119    let mut r2 = 0.0_f32;
120    for k in 0..3 {
121        let mut d = a[k] - b[k];
122        let l = box_size[k];
123        if l > 0.0 {
124            d -= (d / l).round() * l;
125        }
126        r2 += d * d;
127    }
128    r2.sqrt()
129}
130
131/// Minimum-image displacement vector from atom `b` to atom `a`.
132fn pbc_displacement_gpu(a: [f32; 3], b: [f32; 3], box_size: [f32; 3]) -> [f32; 3] {
133    let mut disp = [0.0_f32; 3];
134    for k in 0..3 {
135        let mut d = a[k] - b[k];
136        let l = box_size[k];
137        if l > 0.0 {
138            d -= (d / l).round() * l;
139        }
140        disp[k] = d;
141    }
142    disp
143}
144
145// ── Force computation ─────────────────────────────────────────────────────────
146
147/// Compute all-pairs LJ forces on every atom and store in `atom.force`.
148///
149/// Clears previous forces before accumulating new ones.
150pub fn compute_forces_gpu(buf: &mut GpuMdBuffer, params: &GpuMdParams) {
151    let n = buf.atoms.len();
152    // Zero forces
153    for atom in buf.atoms.iter_mut() {
154        atom.force = [0.0; 3];
155    }
156    for i in 0..n {
157        for j in (i + 1)..n {
158            let pi = buf.atoms[i].pos;
159            let pj = buf.atoms[j].pos;
160            let disp = pbc_displacement_gpu(pi, pj, params.box_size);
161            let r2 = disp[0] * disp[0] + disp[1] * disp[1] + disp[2] * disp[2];
162            let r = r2.sqrt();
163            if r < 1e-10 || r >= params.cutoff {
164                continue;
165            }
166            let f_mag = lj_force_gpu(r, params);
167            // Force on i from j: F = f_mag * (disp / r) — but lj_force is dU/dr
168            // F_i = -(dU/dr) * r̂ = -(dU/dr) * disp/r
169            let scale = -f_mag / r;
170            buf.atoms[i].force[0] += scale * disp[0];
171            buf.atoms[i].force[1] += scale * disp[1];
172            buf.atoms[i].force[2] += scale * disp[2];
173            buf.atoms[j].force[0] -= scale * disp[0];
174            buf.atoms[j].force[1] -= scale * disp[1];
175            buf.atoms[j].force[2] -= scale * disp[2];
176        }
177    }
178}
179
180// ── Integration ───────────────────────────────────────────────────────────────
181
182/// Velocity-Verlet integration step.
183///
184/// Updates positions and velocities using the current forces.
185/// `v += (F/m) * dt`, `x += v * dt`.
186/// Increments the step counter.
187pub fn verlet_integrate_gpu(buf: &mut GpuMdBuffer, dt: f32) {
188    for atom in buf.atoms.iter_mut() {
189        let inv_mass = if atom.mass > 1e-10 {
190            1.0 / atom.mass
191        } else {
192            0.0
193        };
194        atom.vel[0] += atom.force[0] * inv_mass * dt;
195        atom.vel[1] += atom.force[1] * inv_mass * dt;
196        atom.vel[2] += atom.force[2] * inv_mass * dt;
197        atom.pos[0] += atom.vel[0] * dt;
198        atom.pos[1] += atom.vel[1] * dt;
199        atom.pos[2] += atom.vel[2] * dt;
200    }
201    buf.step += 1;
202}
203
204// ── Thermodynamic observables ─────────────────────────────────────────────────
205
206/// Compute total kinetic energy: `KE = Σ 0.5 * m_i * |v_i|²`.
207pub fn kinetic_energy_gpu(buf: &GpuMdBuffer) -> f32 {
208    buf.atoms
209        .iter()
210        .map(|a| 0.5 * a.mass * (a.vel[0] * a.vel[0] + a.vel[1] * a.vel[1] + a.vel[2] * a.vel[2]))
211        .sum()
212}
213
214/// Compute total LJ potential energy.
215pub fn potential_energy_gpu(buf: &GpuMdBuffer, params: &GpuMdParams) -> f32 {
216    let n = buf.atoms.len();
217    let mut pe = 0.0_f32;
218    for i in 0..n {
219        for j in (i + 1)..n {
220            let r = pbc_distance_gpu(buf.atoms[i].pos, buf.atoms[j].pos, params.box_size);
221            pe += lj_potential_gpu(r, params);
222        }
223    }
224    pe
225}
226
227/// Estimate instantaneous temperature from kinetic energy.
228///
229/// Uses the equipartition theorem: `T = 2 * KE / (3 * N * kB)`.
230/// Boltzmann constant `kB = 8.314e-3` kJ/(mol·K).
231pub fn temperature_gpu(buf: &GpuMdBuffer) -> f32 {
232    let n = buf.atoms.len();
233    if n == 0 {
234        return 0.0;
235    }
236    let kb = 8.314e-3_f32; // kJ/(mol·K)
237    let ke = kinetic_energy_gpu(buf);
238    2.0 * ke / (3.0 * n as f32 * kb)
239}
240
241// ── Thermostat ────────────────────────────────────────────────────────────────
242
243/// Rescale all atom velocities to achieve `target_temp` (velocity scaling).
244///
245/// No-op when the current temperature is zero.
246pub fn rescale_velocities_gpu(buf: &mut GpuMdBuffer, target_temp: f32) {
247    let t_curr = temperature_gpu(buf);
248    if t_curr < 1e-10 || target_temp < 0.0 {
249        return;
250    }
251    let scale = (target_temp / t_curr).sqrt();
252    for atom in buf.atoms.iter_mut() {
253        atom.vel[0] *= scale;
254        atom.vel[1] *= scale;
255        atom.vel[2] *= scale;
256    }
257}
258
259// ── Tests ─────────────────────────────────────────────────────────────────────
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn default_params() -> GpuMdParams {
266        GpuMdParams {
267            epsilon: 1.0,
268            sigma: 1.0,
269            cutoff: 3.5,
270            box_size: [10.0; 3],
271            n_atoms: 4,
272        }
273    }
274
275    fn make_buf_grid(n: usize) -> GpuMdBuffer {
276        let mut buf = GpuMdBuffer::new(n);
277        for i in 0..n {
278            buf.atoms[i].pos = [i as f32 * 1.5, 0.0, 0.0];
279            buf.atoms[i].mass = 1.0;
280        }
281        buf
282    }
283
284    #[test]
285    fn test_gpu_md_atom_fields() {
286        let a = GpuMdAtom {
287            pos: [1.0, 2.0, 3.0],
288            vel: [0.1, 0.2, 0.3],
289            force: [0.0; 3],
290            mass: 12.0,
291            charge: -0.5,
292        };
293        assert_eq!(a.mass, 12.0);
294        assert_eq!(a.charge, -0.5);
295    }
296
297    #[test]
298    fn test_gpu_md_params_fields() {
299        let p = default_params();
300        assert_eq!(p.n_atoms, 4);
301        assert!(p.cutoff > p.sigma);
302    }
303
304    #[test]
305    fn test_gpu_md_buffer_new() {
306        let buf = GpuMdBuffer::new(5);
307        assert_eq!(buf.len(), 5);
308        assert!(!buf.is_empty());
309        assert_eq!(buf.step, 0);
310    }
311
312    #[test]
313    fn test_gpu_md_buffer_empty() {
314        let buf = GpuMdBuffer::new(0);
315        assert!(buf.is_empty());
316    }
317
318    #[test]
319    fn test_lj_potential_minimum() {
320        let params = default_params();
321        // LJ minimum at r = 2^(1/6) * sigma
322        let r_min = 2.0_f32.powf(1.0 / 6.0) * params.sigma;
323        let u = lj_potential_gpu(r_min, &params);
324        // At minimum: U = -epsilon
325        assert!((u - (-params.epsilon)).abs() < 0.01);
326    }
327
328    #[test]
329    fn test_lj_potential_zero_beyond_cutoff() {
330        let params = default_params();
331        assert_eq!(lj_potential_gpu(params.cutoff + 0.1, &params), 0.0);
332    }
333
334    #[test]
335    fn test_lj_potential_zero_near_zero_r() {
336        let params = default_params();
337        assert_eq!(lj_potential_gpu(0.0, &params), 0.0);
338    }
339
340    #[test]
341    fn test_lj_force_repulsive_close() {
342        let params = default_params();
343        // At r < sigma the force is repulsive (dU/dr > 0)
344        let f = lj_force_gpu(0.8, &params);
345        assert!(f > 0.0);
346    }
347
348    #[test]
349    fn test_lj_force_attractive_far() {
350        let params = default_params();
351        // At r between sigma and 2^(1/6)*sigma force is attractive (dU/dr < 0)
352        let f = lj_force_gpu(1.2, &params);
353        assert!(f < 0.0);
354    }
355
356    #[test]
357    fn test_lj_force_zero_beyond_cutoff() {
358        let params = default_params();
359        assert_eq!(lj_force_gpu(params.cutoff + 1.0, &params), 0.0);
360    }
361
362    #[test]
363    fn test_pbc_distance_no_wrap() {
364        let params = default_params();
365        let a = [1.0, 0.0, 0.0];
366        let b = [2.0, 0.0, 0.0];
367        let d = pbc_distance_gpu(a, b, params.box_size);
368        assert!((d - 1.0).abs() < 1e-5);
369    }
370
371    #[test]
372    fn test_pbc_distance_wrap() {
373        let box_size = [10.0_f32; 3];
374        let a = [0.5, 0.0, 0.0];
375        let b = [9.5, 0.0, 0.0];
376        // Minimum image: 1.0, not 9.0
377        let d = pbc_distance_gpu(a, b, box_size);
378        assert!((d - 1.0).abs() < 1e-4);
379    }
380
381    #[test]
382    fn test_pbc_distance_self() {
383        let box_size = [10.0_f32; 3];
384        let a = [3.0, 4.0, 5.0];
385        let d = pbc_distance_gpu(a, a, box_size);
386        assert!(d < 1e-5);
387    }
388
389    #[test]
390    fn test_compute_forces_gpu_newton3() {
391        let mut buf = GpuMdBuffer::new(2);
392        buf.atoms[0].pos = [0.0; 3];
393        buf.atoms[1].pos = [1.2, 0.0, 0.0];
394        buf.atoms[0].mass = 1.0;
395        buf.atoms[1].mass = 1.0;
396        let params = default_params();
397        compute_forces_gpu(&mut buf, &params);
398        // Newton's third law
399        assert!((buf.atoms[0].force[0] + buf.atoms[1].force[0]).abs() < 1e-5);
400    }
401
402    #[test]
403    fn test_compute_forces_gpu_zero_beyond_cutoff() {
404        let mut buf = GpuMdBuffer::new(2);
405        buf.atoms[0].pos = [0.0; 3];
406        buf.atoms[1].pos = [5.0, 0.0, 0.0]; // beyond cutoff=3.5
407        let params = default_params();
408        compute_forces_gpu(&mut buf, &params);
409        assert!(buf.atoms[0].force[0].abs() < 1e-8);
410    }
411
412    #[test]
413    fn test_verlet_integrate_gpu_position() {
414        let mut buf = GpuMdBuffer::new(1);
415        buf.atoms[0].vel = [1.0, 0.0, 0.0];
416        buf.atoms[0].force = [0.0; 3];
417        verlet_integrate_gpu(&mut buf, 0.1);
418        assert!((buf.atoms[0].pos[0] - 0.1).abs() < 1e-5);
419    }
420
421    #[test]
422    fn test_verlet_integrate_gpu_step_counter() {
423        let mut buf = GpuMdBuffer::new(1);
424        verlet_integrate_gpu(&mut buf, 0.01);
425        assert_eq!(buf.step, 1);
426    }
427
428    #[test]
429    fn test_kinetic_energy_gpu_zero_vel() {
430        let buf = make_buf_grid(4);
431        let ke = kinetic_energy_gpu(&buf);
432        assert!(ke.abs() < 1e-8);
433    }
434
435    #[test]
436    fn test_kinetic_energy_gpu_nonzero() {
437        let mut buf = GpuMdBuffer::new(2);
438        buf.atoms[0].vel = [1.0, 0.0, 0.0];
439        buf.atoms[0].mass = 2.0;
440        buf.atoms[1].vel = [0.0, 0.0, 0.0];
441        buf.atoms[1].mass = 1.0;
442        let ke = kinetic_energy_gpu(&buf);
443        // 0.5 * 2 * 1^2 = 1.0
444        assert!((ke - 1.0).abs() < 1e-5);
445    }
446
447    #[test]
448    fn test_potential_energy_gpu_empty() {
449        let buf = GpuMdBuffer::new(0);
450        let params = default_params();
451        assert_eq!(potential_energy_gpu(&buf, &params), 0.0);
452    }
453
454    #[test]
455    fn test_potential_energy_gpu_single() {
456        let buf = GpuMdBuffer::new(1);
457        let params = default_params();
458        // Only one atom -> no pairs -> PE = 0
459        assert_eq!(potential_energy_gpu(&buf, &params), 0.0);
460    }
461
462    #[test]
463    fn test_temperature_gpu_zero_vel() {
464        let buf = make_buf_grid(4);
465        let t = temperature_gpu(&buf);
466        assert!(t < 1e-6);
467    }
468
469    #[test]
470    fn test_temperature_gpu_empty() {
471        let buf = GpuMdBuffer::new(0);
472        assert_eq!(temperature_gpu(&buf), 0.0);
473    }
474
475    #[test]
476    fn test_temperature_gpu_nonzero() {
477        let mut buf = GpuMdBuffer::new(3);
478        for a in buf.atoms.iter_mut() {
479            a.vel = [1.0, 1.0, 1.0];
480            a.mass = 1.0;
481        }
482        let t = temperature_gpu(&buf);
483        assert!(t > 0.0);
484    }
485
486    #[test]
487    fn test_rescale_velocities_gpu() {
488        let mut buf = GpuMdBuffer::new(4);
489        for a in buf.atoms.iter_mut() {
490            a.vel = [1.0, 0.5, 0.2];
491            a.mass = 1.0;
492        }
493        let target = 300.0;
494        rescale_velocities_gpu(&mut buf, target);
495        let t_after = temperature_gpu(&buf);
496        assert!((t_after - target).abs() < 1.0);
497    }
498
499    #[test]
500    fn test_rescale_velocities_gpu_zero_vel_noop() {
501        let mut buf = GpuMdBuffer::new(2);
502        // All velocities zero -> temperature is zero -> no rescaling
503        rescale_velocities_gpu(&mut buf, 300.0);
504        for a in &buf.atoms {
505            assert!(a.vel[0].abs() < 1e-8);
506        }
507    }
508
509    #[test]
510    fn test_buf_clone() {
511        let buf = make_buf_grid(3);
512        let buf2 = buf.clone();
513        assert_eq!(buf2.len(), 3);
514    }
515
516    #[test]
517    fn test_compute_forces_accumulate_many() {
518        let mut buf = make_buf_grid(4);
519        let params = default_params();
520        compute_forces_gpu(&mut buf, &params);
521        // Total force should be ~zero (Newton's 3rd law globally)
522        let fx_total: f32 = buf.atoms.iter().map(|a| a.force[0]).sum();
523        assert!(fx_total.abs() < 1e-4);
524    }
525
526    #[test]
527    fn test_lj_potential_positive_repulsive() {
528        let params = default_params();
529        // Very short r -> strongly repulsive -> U > 0
530        let u = lj_potential_gpu(0.5, &params);
531        assert!(u > 0.0);
532    }
533
534    #[test]
535    fn test_verlet_integrate_gpu_velocity_from_force() {
536        let mut buf = GpuMdBuffer::new(1);
537        buf.atoms[0].force = [2.0, 0.0, 0.0];
538        buf.atoms[0].mass = 1.0;
539        verlet_integrate_gpu(&mut buf, 0.5);
540        // v = 0 + (2/1)*0.5 = 1.0
541        assert!((buf.atoms[0].vel[0] - 1.0).abs() < 1e-5);
542    }
543
544    #[test]
545    fn test_pbc_distance_3d_wrap() {
546        let box_size = [5.0_f32; 3];
547        let a = [0.1, 0.1, 0.1];
548        let b = [4.9, 4.9, 4.9];
549        let d = pbc_distance_gpu(a, b, box_size);
550        // Minimum image: delta = (-0.2, -0.2, -0.2) => r = 0.2*sqrt(3)
551        let expected = 0.2 * 3.0_f32.sqrt();
552        assert!((d - expected).abs() < 1e-4);
553    }
554
555    #[test]
556    fn test_total_energy_two_atoms() {
557        let mut buf = GpuMdBuffer::new(2);
558        buf.atoms[0].pos = [0.0; 3];
559        buf.atoms[0].mass = 1.0;
560        buf.atoms[1].pos = [1.1, 0.0, 0.0]; // near LJ minimum
561        buf.atoms[1].mass = 1.0;
562        let params = default_params();
563        let pe = potential_energy_gpu(&buf, &params);
564        // Should be negative near minimum
565        assert!(pe < 0.0);
566    }
567}