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