Skip to main content

oxiphysics_gpu/kernels/md_force/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use super::types::{
6    ForceBuffer, HarmonicAngle, HarmonicBond, LjPotential, NeighborList, VirialStressTensorKernel,
7    VirialTensor,
8};
9use crate::compute::ComputeKernel;
10
11#[cfg(test)]
12use super::types::*;
13
14/// Compute the Lennard-Jones potential energy and scalar force magnitude at
15/// separation `r`.
16///
17/// Returns `(energy, force_magnitude)` where:
18/// * `energy = 4·ε·[(σ/r)^12 − (σ/r)^6]`
19/// * `force_magnitude = 24·ε·[2(σ/r)^12 − (σ/r)^6] / r` (positive = repulsive)
20///
21/// The caller is responsible for applying the cutoff.
22pub fn compute_lj_force(r: f64, lj: &LjPotential) -> (f64, f64) {
23    if r < 1e-30 {
24        return (f64::INFINITY, f64::INFINITY);
25    }
26    let sr = lj.sigma / r;
27    let sr6 = sr.powi(6);
28    let sr12 = sr6 * sr6;
29    let energy = 4.0 * lj.epsilon * (sr12 - sr6);
30    let force_mag = 24.0 * lj.epsilon * (2.0 * sr12 - sr6) / r;
31    (energy, force_mag)
32}
33/// Compute shifted LJ energy at distance `r` with cutoff `rc`.
34///
35/// V_shifted(r) = V(r) - V(rc) for r < rc, 0 otherwise.
36pub fn compute_lj_shifted_energy(r: f64, lj: &LjPotential, cutoff: f64) -> f64 {
37    if r >= cutoff {
38        return 0.0;
39    }
40    let (e_r, _) = compute_lj_force(r, lj);
41    let (e_c, _) = compute_lj_force(cutoff, lj);
42    e_r - e_c
43}
44/// Compute Coulomb force between two charged particles.
45///
46/// Returns `(energy, force_magnitude)`.
47pub fn compute_coulomb_force(r: f64, qi: f64, qj: f64, k_e: f64) -> (f64, f64) {
48    if r < 1e-30 {
49        return (f64::INFINITY, f64::INFINITY);
50    }
51    let energy = k_e * qi * qj / r;
52    let force_mag = k_e * qi * qj / (r * r);
53    (energy, force_mag)
54}
55/// Compute LJ forces using a neighbor list.
56pub fn compute_lj_forces_neighborlist(
57    positions: &[[f64; 3]],
58    lj: &LjPotential,
59    nlist: &NeighborList,
60    buffer: &mut ForceBuffer,
61) {
62    let cutoff2 = nlist.cutoff * nlist.cutoff;
63    buffer.clear();
64    let n = positions.len();
65    for i in 0..n {
66        for &j in &nlist.neighbors[i] {
67            if j <= i {
68                continue;
69            }
70            let dx = [
71                positions[i][0] - positions[j][0],
72                positions[i][1] - positions[j][1],
73                positions[i][2] - positions[j][2],
74            ];
75            let r2 = dx[0] * dx[0] + dx[1] * dx[1] + dx[2] * dx[2];
76            if r2 >= cutoff2 || r2 < 1e-30 {
77                continue;
78            }
79            let r2_inv = 1.0 / r2;
80            let sr2 = lj.sigma * lj.sigma * r2_inv;
81            let sr6 = sr2 * sr2 * sr2;
82            let sr12 = sr6 * sr6;
83            let energy = 4.0 * lj.epsilon * (sr12 - sr6);
84            let f_mag = 24.0 * lj.epsilon * (2.0 * sr12 - sr6) * r2_inv;
85            let f_ij = [f_mag * dx[0], f_mag * dx[1], f_mag * dx[2]];
86            buffer.add_pair(i, j, f_ij, energy, dx);
87        }
88    }
89}
90/// Compute Coulomb forces using a neighbor list.
91pub fn compute_coulomb_forces_neighborlist(
92    positions: &[[f64; 3]],
93    charges: &[f64],
94    k_e: f64,
95    nlist: &NeighborList,
96    buffer: &mut ForceBuffer,
97) {
98    let cutoff2 = nlist.cutoff * nlist.cutoff;
99    let n = positions.len();
100    for i in 0..n {
101        for &j in &nlist.neighbors[i] {
102            if j <= i {
103                continue;
104            }
105            let dx = [
106                positions[i][0] - positions[j][0],
107                positions[i][1] - positions[j][1],
108                positions[i][2] - positions[j][2],
109            ];
110            let r2 = dx[0] * dx[0] + dx[1] * dx[1] + dx[2] * dx[2];
111            if r2 >= cutoff2 || r2 < 1e-30 {
112                continue;
113            }
114            let r = r2.sqrt();
115            let qi = charges[i];
116            let qj = charges[j];
117            let energy = k_e * qi * qj / r;
118            let f_mag = k_e * qi * qj / (r2 * r);
119            let f_ij = [f_mag * dx[0], f_mag * dx[1], f_mag * dx[2]];
120            buffer.add_pair(i, j, f_ij, energy, dx);
121        }
122    }
123}
124/// Compute Lennard-Jones forces for all particle pairs within `cutoff`.
125///
126/// Returns a `Vec<[f64;3]>` of forces, one per particle.
127/// Interactions beyond `cutoff` are ignored.
128pub fn compute_all_lj_forces(
129    positions: &[[f64; 3]],
130    _masses: &[f64],
131    lj: &LjPotential,
132    cutoff: f64,
133) -> Vec<[f64; 3]> {
134    let n = positions.len();
135    let cutoff2 = cutoff * cutoff;
136    let mut forces = vec![[0.0f64; 3]; n];
137    for i in 0..n {
138        for j in (i + 1)..n {
139            let dx = positions[i][0] - positions[j][0];
140            let dy = positions[i][1] - positions[j][1];
141            let dz = positions[i][2] - positions[j][2];
142            let r2 = dx * dx + dy * dy + dz * dz;
143            if r2 >= cutoff2 || r2 < 1e-30 {
144                continue;
145            }
146            let sr = lj.sigma / r2.sqrt();
147            let sr6 = sr.powi(6);
148            let sr12 = sr6 * sr6;
149            let f_mag = 24.0 * lj.epsilon * (2.0 * sr12 - sr6) / r2;
150            forces[i][0] += f_mag * dx;
151            forces[i][1] += f_mag * dy;
152            forces[i][2] += f_mag * dz;
153            forces[j][0] -= f_mag * dx;
154            forces[j][1] -= f_mag * dy;
155            forces[j][2] -= f_mag * dz;
156        }
157    }
158    forces
159}
160/// Compute Coulomb forces for all particle pairs within `cutoff`.
161///
162/// Returns a `Vec<[f64;3]>` of forces, one per particle.
163pub fn compute_all_coulomb_forces(
164    positions: &[[f64; 3]],
165    charges: &[f64],
166    k_e: f64,
167    cutoff: f64,
168) -> Vec<[f64; 3]> {
169    let n = positions.len();
170    let cutoff2 = cutoff * cutoff;
171    let mut forces = vec![[0.0f64; 3]; n];
172    for i in 0..n {
173        for j in (i + 1)..n {
174            let dx = positions[i][0] - positions[j][0];
175            let dy = positions[i][1] - positions[j][1];
176            let dz = positions[i][2] - positions[j][2];
177            let r2 = dx * dx + dy * dy + dz * dz;
178            if r2 >= cutoff2 || r2 < 1e-30 {
179                continue;
180            }
181            let r = r2.sqrt();
182            let f_mag = k_e * charges[i] * charges[j] / (r2 * r);
183            forces[i][0] += f_mag * dx;
184            forces[i][1] += f_mag * dy;
185            forces[i][2] += f_mag * dz;
186            forces[j][0] -= f_mag * dx;
187            forces[j][1] -= f_mag * dy;
188            forces[j][2] -= f_mag * dz;
189        }
190    }
191    forces
192}
193/// Complementary error function approximation (Abramowitz & Stegun 7.1.26).
194pub(super) fn erfc_approx(x: f64) -> f64 {
195    if x < 0.0 {
196        return 2.0 - erfc_approx(-x);
197    }
198    let t = 1.0 / (1.0 + 0.3275911 * x);
199    let poly = t
200        * (0.254829592
201            + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
202    poly * (-x * x).exp()
203}
204/// Self-energy correction for Ewald summation.
205///
206/// Returns -α/√π · Σ qi².
207pub fn ewald_self_energy(charges: &[f64], alpha: f64) -> f64 {
208    let sum_q2: f64 = charges.iter().map(|&q| q * q).sum();
209    -alpha / std::f64::consts::PI.sqrt() * sum_q2
210}
211/// Estimate PPPM mesh contribution to long-range energy from a charge mesh.
212///
213/// This is a simplified mock: computes Σ ρ(k)² * G(k) using a uniform
214/// Green's function G(k) = 1 / |k|² for k ≠ 0.
215pub fn pppm_mesh_energy_estimate(charge_mesh: &[f64], nx: usize, ny: usize, nz: usize) -> f64 {
216    if nx == 0 || ny == 0 || nz == 0 {
217        return 0.0;
218    }
219    let q2: f64 = charge_mesh.iter().map(|&q| q * q).sum();
220    q2 / (nx * ny * nz) as f64
221}
222/// Compute the full virial stress tensor from positions using LJ potential.
223///
224/// Convenience wrapper around `VirialStressTensorKernel`.
225pub fn compute_virial_stress_tensor(
226    positions: &[[f64; 3]],
227    lj: &LjPotential,
228    cutoff: f64,
229) -> VirialTensor {
230    let n = positions.len();
231    let flat_pos: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
232    let params = vec![lj.epsilon, lj.sigma, cutoff];
233    let mut outputs = vec![Vec::new()];
234    VirialStressTensorKernel.execute(&[&flat_pos, &params], &mut outputs, n);
235    if outputs[0].len() < 6 {
236        return VirialTensor::zero();
237    }
238    let mut c = [0.0f64; 6];
239    c.copy_from_slice(&outputs[0][..6]);
240    VirialTensor { components: c }
241}
242/// Compute harmonic bond forces and accumulate into a force buffer.
243///
244/// For each bond `(i, j)` with spring constant `k` and rest length `r0`:
245/// `F_i = -k*(r - r0)*r̂_ij`,  `F_j = +k*(r - r0)*r̂_ij`
246///
247/// Returns `(forces, total_bond_energy)`.
248pub fn compute_bond_forces(positions: &[[f64; 3]], bonds: &[HarmonicBond]) -> (Vec<[f64; 3]>, f64) {
249    let n = positions.len();
250    let mut forces = vec![[0.0f64; 3]; n];
251    let mut total_energy = 0.0f64;
252    for bond in bonds {
253        let i = bond.atom_i;
254        let j = bond.atom_j;
255        if i >= n || j >= n {
256            continue;
257        }
258        let dx = positions[j][0] - positions[i][0];
259        let dy = positions[j][1] - positions[i][1];
260        let dz = positions[j][2] - positions[i][2];
261        let r = (dx * dx + dy * dy + dz * dz).sqrt();
262        if r < 1e-30 {
263            continue;
264        }
265        let delta = r - bond.r0;
266        let energy = 0.5 * bond.k * delta * delta;
267        total_energy += energy;
268        let mag = bond.k * delta / r;
269        forces[i][0] += mag * dx;
270        forces[i][1] += mag * dy;
271        forces[i][2] += mag * dz;
272        forces[j][0] -= mag * dx;
273        forces[j][1] -= mag * dy;
274        forces[j][2] -= mag * dz;
275    }
276    (forces, total_energy)
277}
278/// Compute harmonic angle forces (CPU mock).
279///
280/// The angle θ at vertex `j` (between vectors `r_ij` and `r_kj`) is:
281/// `cos θ = (r_ij · r_kj) / (|r_ij| |r_kj|)`
282///
283/// Forces follow from the gradient of the harmonic angle potential.
284///
285/// Returns `(forces, total_angle_energy)`.
286pub fn compute_angle_forces(
287    positions: &[[f64; 3]],
288    angles: &[HarmonicAngle],
289) -> (Vec<[f64; 3]>, f64) {
290    let n = positions.len();
291    let mut forces = vec![[0.0f64; 3]; n];
292    let mut total_energy = 0.0f64;
293    for angle in angles {
294        let i = angle.atom_i;
295        let j = angle.atom_j;
296        let k = angle.atom_k;
297        if i >= n || j >= n || k >= n {
298            continue;
299        }
300        let rji = [
301            positions[i][0] - positions[j][0],
302            positions[i][1] - positions[j][1],
303            positions[i][2] - positions[j][2],
304        ];
305        let rjk = [
306            positions[k][0] - positions[j][0],
307            positions[k][1] - positions[j][1],
308            positions[k][2] - positions[j][2],
309        ];
310        let len_ji = (rji[0] * rji[0] + rji[1] * rji[1] + rji[2] * rji[2]).sqrt();
311        let len_jk = (rjk[0] * rjk[0] + rjk[1] * rjk[1] + rjk[2] * rjk[2]).sqrt();
312        if len_ji < 1e-30 || len_jk < 1e-30 {
313            continue;
314        }
315        let cos_theta = (rji[0] * rjk[0] + rji[1] * rjk[1] + rji[2] * rjk[2]) / (len_ji * len_jk);
316        let cos_theta = cos_theta.clamp(-1.0, 1.0);
317        let theta = cos_theta.acos();
318        let delta = theta - angle.theta0;
319        total_energy += 0.5 * angle.k_theta * delta * delta;
320        let sin_theta = (1.0 - cos_theta * cos_theta).sqrt().max(1e-12);
321        let d_prefactor = -angle.k_theta * delta / sin_theta;
322        for dim in 0..3 {
323            let d_cos_d_ri =
324                rjk[dim] / (len_ji * len_jk) - cos_theta * rji[dim] / (len_ji * len_ji);
325            let d_cos_d_rk =
326                rji[dim] / (len_ji * len_jk) - cos_theta * rjk[dim] / (len_jk * len_jk);
327            let fi = d_prefactor * d_cos_d_ri;
328            let fk = d_prefactor * d_cos_d_rk;
329            forces[i][dim] += fi;
330            forces[k][dim] += fk;
331            forces[j][dim] -= fi + fk;
332        }
333    }
334    (forces, total_energy)
335}
336/// Compute instantaneous kinetic temperature from particle velocities and masses.
337///
338/// `T = (2 * KE) / (N_dof * k_B)`, where `N_dof = 3*N - 3` (subtract COM).
339/// For simplicity, uses `N_dof = 3*N`.
340///
341/// # Arguments
342/// * `velocities` - Per-particle velocity vectors.
343/// * `masses`     - Per-particle masses.
344/// * `k_boltzmann` - Boltzmann constant in simulation units.
345pub fn kinetic_temperature(velocities: &[[f64; 3]], masses: &[f64], k_boltzmann: f64) -> f64 {
346    let n = velocities.len();
347    if n == 0 || k_boltzmann < 1e-30 {
348        return 0.0;
349    }
350    let ke2: f64 = velocities
351        .iter()
352        .zip(masses.iter())
353        .map(|(v, &m)| m * (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]))
354        .sum();
355    let n_dof = (3 * n) as f64;
356    ke2 / (n_dof * k_boltzmann)
357}
358/// Rescale all velocities to match the target temperature.
359///
360/// Applies the velocity-rescaling thermostat:
361/// `v_i ← v_i * sqrt(T_target / T_current)`
362///
363/// Does nothing if the current temperature is below a floor value.
364pub fn temperature_scale(
365    velocities: &mut [[f64; 3]],
366    masses: &[f64],
367    t_target: f64,
368    k_boltzmann: f64,
369) {
370    let t_current = kinetic_temperature(velocities, masses, k_boltzmann);
371    if t_current < 1e-30 || t_target < 0.0 {
372        return;
373    }
374    let scale = (t_target / t_current).sqrt();
375    for v in velocities.iter_mut() {
376        v[0] *= scale;
377        v[1] *= scale;
378        v[2] *= scale;
379    }
380}
381#[cfg(test)]
382mod tests {
383    use super::*;
384    #[test]
385    fn test_md_lj_force_repulsive_at_short_range() {
386        let sigma = 1.0_f64;
387        let epsilon = 1.0_f64;
388        let cutoff = 5.0_f64;
389        let r = 0.8_f64 * sigma;
390        let positions = vec![0.0, 0.0, 0.0, r, 0.0, 0.0];
391        let params = vec![epsilon, sigma, cutoff];
392        let mut outputs = vec![Vec::new(), Vec::new()];
393        LennardJonesKernel.execute(&[&positions, &params], &mut outputs, 2);
394        let fx0 = outputs[0][0];
395        let fx1 = outputs[0][3];
396        assert!(
397            fx0 < 0.0,
398            "at r < r_min, force on atom 0 should be negative (repulsive), got {fx0}"
399        );
400        assert!(
401            fx1 > 0.0,
402            "at r < r_min, force on atom 1 should be positive (repulsive), got {fx1}"
403        );
404        assert!(
405            (fx0 + fx1).abs() < 1e-10,
406            "forces should sum to zero (Newton III), got {fx0} + {fx1} = {}",
407            fx0 + fx1
408        );
409    }
410    #[test]
411    fn lj_kernel_correct_force_known_separation() {
412        let sigma = 1.0;
413        let epsilon = 1.0;
414        let cutoff = 3.0;
415        let positions = vec![0.0, 0.0, 0.0, sigma, 0.0, 0.0];
416        let params = vec![epsilon, sigma, cutoff];
417        let mut outputs = vec![Vec::new(), Vec::new()];
418        LennardJonesKernel.execute(&[&positions, &params], &mut outputs, 2);
419        let fx0 = outputs[0][0];
420        assert!(
421            (fx0 - (-24.0)).abs() < 1e-10,
422            "expected fx0 ~ -24.0, got {fx0}"
423        );
424        let pe = outputs[1][0];
425        assert!(pe.abs() < 1e-10, "expected PE ~ 0, got {pe}");
426    }
427    #[test]
428    fn lj_kernel_force_zero_beyond_cutoff() {
429        let sigma = 1.0;
430        let epsilon = 1.0;
431        let cutoff = 2.5;
432        let positions = vec![0.0, 0.0, 0.0, 3.0, 0.0, 0.0];
433        let params = vec![epsilon, sigma, cutoff];
434        let mut outputs = vec![Vec::new(), Vec::new()];
435        LennardJonesKernel.execute(&[&positions, &params], &mut outputs, 2);
436        for &f in &outputs[0] {
437            assert!(f.abs() < 1e-15, "expected zero force, got {f}");
438        }
439        assert!(outputs[1][0].abs() < 1e-15);
440    }
441    #[test]
442    fn lj_minimum_at_r_min() {
443        let lj = LjPotential::new(1.0, 1.0);
444        let r_min = lj.r_min();
445        let (energy, force_mag) = compute_lj_force(r_min, &lj);
446        assert!(
447            (energy - (-lj.epsilon)).abs() < 1e-10,
448            "energy at r_min should be -epsilon={}, got {energy}",
449            -lj.epsilon
450        );
451        assert!(
452            force_mag.abs() < 1e-10,
453            "force at r_min should be 0, got {force_mag}"
454        );
455    }
456    #[test]
457    fn compute_all_lj_forces_newtons_third_law() {
458        let lj = LjPotential::new(1.0, 1.0);
459        let positions = vec![[0.0, 0.0, 0.0], [1.2, 0.0, 0.0], [0.6, 1.0, 0.0]];
460        let masses = vec![1.0; 3];
461        let forces = compute_all_lj_forces(&positions, &masses, &lj, 5.0);
462        assert_eq!(forces.len(), 3);
463        for k in 0..3 {
464            let total: f64 = forces.iter().map(|f| f[k]).sum();
465            assert!(
466                total.abs() < 1e-10,
467                "total force component {k} should be 0, got {total}"
468            );
469        }
470    }
471    #[test]
472    fn compute_all_lj_forces_repulsive_at_short_range() {
473        let sigma = 1.0;
474        let lj = LjPotential::new(1.0, sigma);
475        let positions = vec![[0.0, 0.0, 0.0], [0.9 * sigma, 0.0, 0.0]];
476        let masses = vec![1.0; 2];
477        let forces = compute_all_lj_forces(&positions, &masses, &lj, 5.0);
478        assert!(
479            forces[0][0] < 0.0,
480            "repulsive: force[0].x should be < 0, got {}",
481            forces[0][0]
482        );
483        assert!(
484            forces[1][0] > 0.0,
485            "repulsive: force[1].x should be > 0, got {}",
486            forces[1][0]
487        );
488    }
489    #[test]
490    fn pair_force_kernel_new() {
491        let lj = LjPotential::new(2.0, 0.5);
492        let kern = PairForceKernel::new(lj, 3.0, true);
493        assert!((kern.lj.epsilon - 2.0).abs() < 1e-15);
494        assert!((kern.lj.sigma - 0.5).abs() < 1e-15);
495        assert!((kern.cutoff - 3.0).abs() < 1e-15);
496        assert!(kern.shift);
497        // Test shifted evaluation: at the cutoff boundary, energy should be zero (shifted)
498        let (e_at_cut, _f_at_cut) = kern.evaluate(kern.cutoff - 1e-10);
499        assert!(
500            e_at_cut.abs() < 0.1,
501            "energy near cutoff should be small when shifted"
502        );
503    }
504    #[test]
505    fn test_coulomb_potential() {
506        let cp = CoulombPotential::new(1.0);
507        let (e, f) = cp.compute(1.0, 1.0, 1.0);
508        assert!((e - 1.0).abs() < 1e-10);
509        assert!((f - 1.0).abs() < 1e-10);
510        let (e2, f2) = cp.compute(1.0, -1.0, 1.0);
511        assert!((e2 - (-1.0)).abs() < 1e-10);
512        assert!((f2 - (-1.0)).abs() < 1e-10);
513    }
514    #[test]
515    fn test_coulomb_force_function() {
516        let (e, f) = compute_coulomb_force(2.0, 1.0, 1.0, 1.0);
517        assert!((e - 0.5).abs() < 1e-10);
518        assert!((f - 0.25).abs() < 1e-10);
519    }
520    #[test]
521    fn test_coulomb_kernel_newton_iii() {
522        let positions = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
523        let charges = vec![1.0, -1.0];
524        let params = vec![1.0, 10.0];
525        let mut outputs = vec![Vec::new(), Vec::new()];
526        CoulombKernel.execute(&[&positions, &charges, &params], &mut outputs, 2);
527        for k in 0..3 {
528            let total = outputs[0][k] + outputs[0][3 + k];
529            assert!(
530                total.abs() < 1e-10,
531                "forces should sum to zero in dim {k}, got {total}"
532            );
533        }
534        assert!(
535            outputs[0][0] > 0.0,
536            "particle 0 should be attracted toward +x, got {}",
537            outputs[0][0]
538        );
539    }
540    #[test]
541    fn test_coulomb_kernel_same_charge_repulsive() {
542        let positions = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
543        let charges = vec![1.0, 1.0];
544        let params = vec![1.0, 10.0];
545        let mut outputs = vec![Vec::new(), Vec::new()];
546        CoulombKernel.execute(&[&positions, &charges, &params], &mut outputs, 2);
547        assert!(
548            outputs[0][0] < 0.0,
549            "particle 0 should be repelled in -x, got {}",
550            outputs[0][0]
551        );
552    }
553    #[test]
554    fn test_coulomb_kernel_beyond_cutoff() {
555        let positions = vec![0.0, 0.0, 0.0, 5.0, 0.0, 0.0];
556        let charges = vec![1.0, 1.0];
557        let params = vec![1.0, 3.0];
558        let mut outputs = vec![Vec::new(), Vec::new()];
559        CoulombKernel.execute(&[&positions, &charges, &params], &mut outputs, 2);
560        for &f in &outputs[0] {
561            assert!(
562                f.abs() < 1e-15,
563                "expected zero force beyond cutoff, got {f}"
564            );
565        }
566    }
567    #[test]
568    fn test_compute_all_coulomb_forces_newton_iii() {
569        let positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
570        let charges = vec![1.0, -1.0, 0.5];
571        let forces = compute_all_coulomb_forces(&positions, &charges, 1.0, 10.0);
572        for k in 0..3 {
573            let total: f64 = forces.iter().map(|f| f[k]).sum();
574            assert!(
575                total.abs() < 1e-10,
576                "total Coulomb force component {k} should be 0, got {total}"
577            );
578        }
579    }
580    #[test]
581    fn test_lj_shifted_energy() {
582        let lj = LjPotential::new(1.0, 1.0);
583        let cutoff = 2.5;
584        let e_at_cutoff = compute_lj_shifted_energy(cutoff, &lj, cutoff);
585        assert!(
586            e_at_cutoff.abs() < 1e-10,
587            "shifted energy at cutoff should be 0, got {e_at_cutoff}"
588        );
589        let e_beyond = compute_lj_shifted_energy(3.0, &lj, cutoff);
590        assert!(e_beyond.abs() < 1e-15);
591    }
592    #[test]
593    fn test_lj_well_depth() {
594        let lj = LjPotential::new(2.5, 1.0);
595        assert!((lj.well_depth() - (-2.5)).abs() < 1e-15);
596    }
597    #[test]
598    fn test_pair_force_kernel_evaluate() {
599        let lj = LjPotential::new(1.0, 1.0);
600        let kern = PairForceKernel::new(lj, 3.0, false);
601        let (e, f) = kern.evaluate(1.0);
602        assert!(e.abs() < 1e-10);
603        assert!((f - 24.0).abs() < 1e-10);
604        let (e2, f2) = kern.evaluate(5.0);
605        assert!(e2.abs() < 1e-15);
606        assert!(f2.abs() < 1e-15);
607    }
608    #[test]
609    fn test_cutoff_scheme_hard() {
610        let scheme = CutoffScheme::Hard { cutoff: 2.5 };
611        assert!((scheme.cutoff_distance() - 2.5).abs() < 1e-15);
612        assert!((scheme.switch_value(1.0) - 1.0).abs() < 1e-15);
613        assert!((scheme.switch_value(3.0) - 0.0).abs() < 1e-15);
614    }
615    #[test]
616    fn test_cutoff_scheme_switched() {
617        let scheme = CutoffScheme::Switched {
618            r_switch: 2.0,
619            r_cutoff: 3.0,
620        };
621        assert!((scheme.cutoff_distance() - 3.0).abs() < 1e-15);
622        assert!((scheme.switch_value(1.5) - 1.0).abs() < 1e-15);
623        assert!((scheme.switch_value(3.5) - 0.0).abs() < 1e-15);
624        assert!((scheme.switch_value(2.5) - 0.5).abs() < 1e-10);
625        let v1 = scheme.switch_value(2.2);
626        let v2 = scheme.switch_value(2.8);
627        assert!(
628            v1 > v2,
629            "switch should decrease: v(2.2)={}, v(2.8)={}",
630            v1,
631            v2
632        );
633    }
634    #[test]
635    fn test_neighbor_list_brute_force() {
636        let positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [5.0, 0.0, 0.0]];
637        let nlist = NeighborList::build_brute_force(&positions, 2.0, 0.5);
638        assert_eq!(nlist.num_particles(), 3);
639        assert!(nlist.neighbors[0].contains(&1));
640        assert!(nlist.neighbors[1].contains(&0));
641        assert!(!nlist.neighbors[0].contains(&2));
642        assert!(!nlist.neighbors[2].contains(&0));
643    }
644    #[test]
645    fn test_neighbor_list_num_pairs() {
646        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0], [1.0, 0.0, 0.0]];
647        let nlist = NeighborList::build_brute_force(&positions, 2.0, 0.0);
648        assert_eq!(nlist.num_pairs(), 3);
649    }
650    #[test]
651    fn test_neighbor_list_needs_rebuild() {
652        let nlist = NeighborList {
653            neighbors: vec![],
654            cutoff: 2.5,
655            skin: 0.4,
656        };
657        assert!(!nlist.needs_rebuild(0.1));
658        assert!(nlist.needs_rebuild(0.3));
659    }
660    #[test]
661    fn test_force_buffer_basic() {
662        let mut buf = ForceBuffer::new(3);
663        assert_eq!(buf.forces.len(), 3);
664        assert_eq!(buf.total_energy(), 0.0);
665        buf.add_pair(0, 1, [1.0, 0.0, 0.0], 2.0, [1.0, 0.0, 0.0]);
666        assert!((buf.forces[0][0] - 1.0).abs() < 1e-15);
667        assert!((buf.forces[1][0] - (-1.0)).abs() < 1e-15);
668        assert!((buf.energies[0] - 1.0).abs() < 1e-15);
669        assert!((buf.energies[1] - 1.0).abs() < 1e-15);
670        assert!((buf.total_energy() - 2.0).abs() < 1e-15);
671    }
672    #[test]
673    fn test_force_buffer_total_force_zero() {
674        let mut buf = ForceBuffer::new(3);
675        buf.add_pair(0, 1, [3.0, -1.0, 2.0], 1.0, [1.0, 0.0, 0.0]);
676        buf.add_pair(1, 2, [-1.0, 2.0, 0.5], 0.5, [0.0, 1.0, 0.0]);
677        let total = buf.total_force();
678        for (k, &tk) in total.iter().enumerate() {
679            assert!(tk.abs() < 1e-10, "total force[{k}] should be 0, got {}", tk);
680        }
681    }
682    #[test]
683    fn test_force_buffer_clear() {
684        let mut buf = ForceBuffer::new(2);
685        buf.add_pair(0, 1, [1.0, 2.0, 3.0], 5.0, [1.0, 0.0, 0.0]);
686        buf.clear();
687        assert!((buf.total_energy() - 0.0).abs() < 1e-15);
688        for f in &buf.forces {
689            for &c in f {
690                assert!(c.abs() < 1e-15);
691            }
692        }
693    }
694    #[test]
695    fn test_force_buffer_reduce() {
696        let mut main_buf = ForceBuffer::new(2);
697        main_buf.add_pair(0, 1, [1.0, 0.0, 0.0], 2.0, [1.0, 0.0, 0.0]);
698        let mut other = ForceBuffer::new(2);
699        other.add_pair(0, 1, [0.5, 0.0, 0.0], 1.0, [1.0, 0.0, 0.0]);
700        main_buf.reduce_from(&[other]);
701        assert!((main_buf.forces[0][0] - 1.5).abs() < 1e-15);
702        assert!((main_buf.total_energy() - 3.0).abs() < 1e-15);
703    }
704    #[test]
705    fn test_force_buffer_virial() {
706        let mut buf = ForceBuffer::new(2);
707        buf.add_pair(0, 1, [2.0, 0.0, 0.0], 1.0, [3.0, 0.0, 0.0]);
708        assert!((buf.virial[0][0] - 3.0).abs() < 1e-15);
709        assert!((buf.virial[1][0] - 3.0).abs() < 1e-15);
710        assert!((buf.total_virial() - 6.0).abs() < 1e-15);
711    }
712    #[test]
713    fn test_lj_forces_neighborlist() {
714        let lj = LjPotential::new(1.0, 1.0);
715        let positions = vec![[0.0, 0.0, 0.0], [1.2, 0.0, 0.0], [5.0, 0.0, 0.0]];
716        let nlist = NeighborList::build_brute_force(&positions, 2.5, 0.0);
717        let mut buf = ForceBuffer::new(3);
718        compute_lj_forces_neighborlist(&positions, &lj, &nlist, &mut buf);
719        let total = buf.total_force();
720        for (k, &tk) in total.iter().enumerate() {
721            assert!(tk.abs() < 1e-10, "total[{k}] = {}", tk);
722        }
723        for &fk in buf.forces[2].iter() {
724            assert!(fk.abs() < 1e-15);
725        }
726    }
727    #[test]
728    fn test_coulomb_forces_neighborlist() {
729        let positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
730        let charges = vec![1.0, -1.0];
731        let nlist = NeighborList::build_brute_force(&positions, 5.0, 0.0);
732        let mut buf = ForceBuffer::new(2);
733        compute_coulomb_forces_neighborlist(&positions, &charges, 1.0, &nlist, &mut buf);
734        assert!(buf.forces[0][0] > 0.0, "should attract toward +x");
735        assert!(buf.forces[1][0] < 0.0, "should attract toward -x");
736        let total = buf.total_force();
737        assert!(total[0].abs() < 1e-10);
738    }
739    #[test]
740    fn test_lj_forces_neighborlist_matches_brute_force() {
741        let lj = LjPotential::new(1.0, 1.0);
742        let positions = vec![[0.0, 0.0, 0.0], [1.1, 0.0, 0.0], [0.5, 1.0, 0.0]];
743        let cutoff = 5.0;
744        let masses = vec![1.0; 3];
745        let forces_bf = compute_all_lj_forces(&positions, &masses, &lj, cutoff);
746        let nlist = NeighborList::build_brute_force(&positions, cutoff, 0.0);
747        let mut buf = ForceBuffer::new(3);
748        compute_lj_forces_neighborlist(&positions, &lj, &nlist, &mut buf);
749        for (i, (buf_row, bf_row)) in buf.forces.iter().zip(forces_bf.iter()).enumerate() {
750            for (k, (&buf_val, &bf_val)) in buf_row.iter().zip(bf_row.iter()).enumerate() {
751                assert!(
752                    (buf_val - bf_val).abs() < 1e-10,
753                    "mismatch at particle {i}, dim {k}: nlist={}, brute={}",
754                    buf_val,
755                    bf_val
756                );
757            }
758        }
759    }
760    #[test]
761    fn test_erfc_approx_at_zero() {
762        let result = erfc_approx(0.0);
763        assert!((result - 1.0).abs() < 1e-4, "erfc(0) ~ 1, got {result}");
764    }
765    #[test]
766    fn test_erfc_approx_large_arg() {
767        let result = erfc_approx(5.0);
768        assert!(result < 1e-10, "erfc(5) ~ 0, got {result}");
769    }
770    #[test]
771    fn test_ewald_self_energy() {
772        let charges = vec![1.0, -1.0];
773        let alpha = 0.5;
774        let se = ewald_self_energy(&charges, alpha);
775        let expected = -2.0 * alpha / std::f64::consts::PI.sqrt();
776        assert!((se - expected).abs() < 1e-10);
777    }
778    #[test]
779    fn test_ewald_real_space_kernel_newton_iii() {
780        let pos = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
781        let charges = vec![1.0, -1.0];
782        let params = vec![0.5, 5.0, 20.0];
783        let mut outputs = vec![Vec::new(), Vec::new()];
784        EwaldRealSpaceKernel.execute(&[&pos, &charges, &params], &mut outputs, 2);
785        assert_eq!(outputs[0].len(), 6);
786        let total_fx = outputs[0][0] + outputs[0][3];
787        assert!(
788            total_fx.abs() < 1e-10,
789            "Ewald Newton III violated: {total_fx}"
790        );
791    }
792    #[test]
793    fn test_ewald_params_accuracy() {
794        let p = EwaldParams::new(0.5, 6.0, 100.0, 20.0);
795        let acc = p.real_space_accuracy();
796        assert!(acc < 0.01, "erfc(3) should be small, got {acc}");
797    }
798    #[test]
799    fn test_pppm_grid_spacing() {
800        let grid = PppmGrid::new(32, 32, 32, 10.0, 2);
801        assert!((grid.dx() - 10.0 / 32.0).abs() < 1e-12);
802        assert_eq!(grid.total_points(), 32768);
803    }
804    #[test]
805    fn test_pppm_charge_assign_single_particle() {
806        let pos = vec![0.5, 0.5, 0.5];
807        let charges = vec![1.0];
808        let grid_params = vec![4.0, 4.0, 4.0, 4.0];
809        let mut outputs = vec![Vec::new()];
810        PppmChargeAssignKernel.execute(&[&pos, &charges, &grid_params], &mut outputs, 1);
811        assert_eq!(outputs[0].len(), 64);
812        let total: f64 = outputs[0].iter().sum();
813        assert!(
814            (total - 1.0).abs() < 1e-10,
815            "total charge on mesh = {total}"
816        );
817    }
818    #[test]
819    fn test_pppm_charge_assign_conservation() {
820        let pos = vec![1.0, 2.0, 3.0, 5.0, 5.0, 5.0];
821        let charges = vec![2.0, -1.5];
822        let grid_params = vec![8.0, 8.0, 8.0, 8.0];
823        let mut outputs = vec![Vec::new()];
824        PppmChargeAssignKernel.execute(&[&pos, &charges, &grid_params], &mut outputs, 2);
825        let total: f64 = outputs[0].iter().sum();
826        assert!(
827            (total - 0.5).abs() < 1e-10,
828            "net charge should be 0.5, got {total}"
829        );
830    }
831    #[test]
832    fn test_pppm_mesh_energy_estimate_positive() {
833        let mesh = vec![1.0, -1.0, 2.0, 0.5];
834        let e = pppm_mesh_energy_estimate(&mesh, 2, 2, 1);
835        assert!(e >= 0.0, "mesh energy should be non-negative");
836    }
837    #[test]
838    fn test_nlist_update_kernel_no_rebuild() {
839        let pos = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
840        let ref_pos = pos.clone();
841        let params = vec![2.5, 0.4];
842        let mut outputs = vec![Vec::new(), Vec::new()];
843        NlistUpdateKernel.execute(&[&pos, &ref_pos, &params], &mut outputs, 2);
844        assert!(
845            (outputs[1][0] - 0.0).abs() < 1e-10,
846            "status should be Valid (0)"
847        );
848    }
849    #[test]
850    fn test_nlist_update_kernel_rebuild() {
851        let pos = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
852        let ref_pos = vec![0.0, 0.0, 0.0, 5.0, 0.0, 0.0];
853        let params = vec![2.5, 0.4];
854        let mut outputs = vec![Vec::new(), Vec::new()];
855        NlistUpdateKernel.execute(&[&pos, &ref_pos, &params], &mut outputs, 2);
856        assert!(
857            (outputs[1][0] - 1.0).abs() < 1e-10,
858            "status should be Rebuilt (1)"
859        );
860    }
861    #[test]
862    fn test_nlist_update_pairs_found() {
863        let pos = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 5.0, 0.0, 0.0];
864        let ref_pos = vec![100.0; 9];
865        let params = vec![2.5, 0.4];
866        let mut outputs = vec![Vec::new(), Vec::new()];
867        NlistUpdateKernel.execute(&[&pos, &ref_pos, &params], &mut outputs, 3);
868        let num_pairs = outputs[1][1] as usize;
869        assert_eq!(num_pairs, 1, "only 1 pair should be found, got {num_pairs}");
870    }
871    #[test]
872    fn test_pair_energy_accumulate_basic() {
873        let sigma = 1.0;
874        let pos = vec![0.0, 0.0, 0.0, sigma, 0.0, 0.0];
875        let pairs = vec![0.0, 1.0];
876        let params = vec![1.0, sigma, 5.0];
877        let mut outputs = vec![Vec::new(), Vec::new()];
878        PairEnergyAccumulateKernel.execute(&[&pos, &pairs, &params], &mut outputs, 2);
879        let total = outputs[1][0];
880        assert!(
881            total.abs() < 1e-10,
882            "energy at r=sigma should be 0, got {total}"
883        );
884    }
885    #[test]
886    fn test_pair_energy_accumulate_split_equally() {
887        let pos = vec![0.0, 0.0, 0.0, 0.9, 0.0, 0.0];
888        let pairs = vec![0.0, 1.0];
889        let params = vec![1.0, 1.0, 5.0];
890        let mut outputs = vec![Vec::new(), Vec::new()];
891        PairEnergyAccumulateKernel.execute(&[&pos, &pairs, &params], &mut outputs, 2);
892        let e0 = outputs[0][0];
893        let e1 = outputs[0][1];
894        assert!((e0 - e1).abs() < 1e-12, "energy should be split equally");
895        assert!(
896            outputs[1][0] > 0.0,
897            "total energy should be positive at r < r_min"
898        );
899    }
900    #[test]
901    fn test_pair_energy_beyond_cutoff_zero() {
902        let pos = vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0];
903        let pairs = vec![0.0, 1.0];
904        let params = vec![1.0, 1.0, 2.5];
905        let mut outputs = vec![Vec::new(), Vec::new()];
906        PairEnergyAccumulateKernel.execute(&[&pos, &pairs, &params], &mut outputs, 2);
907        assert!(
908            outputs[1][0].abs() < 1e-15,
909            "energy beyond cutoff should be 0"
910        );
911    }
912    #[test]
913    fn test_virial_tensor_trace() {
914        let vt = VirialTensor {
915            components: [1.0, 2.0, 3.0, 0.5, 0.2, 0.1],
916        };
917        assert!((vt.trace() - 6.0).abs() < 1e-12);
918    }
919    #[test]
920    fn test_virial_tensor_pressure() {
921        let vt = VirialTensor {
922            components: [-3.0, -3.0, -3.0, 0.0, 0.0, 0.0],
923        };
924        let p = vt.pressure_contribution(1.0);
925        assert!((p - 3.0).abs() < 1e-12);
926    }
927    #[test]
928    fn test_virial_tensor_add() {
929        let a = VirialTensor {
930            components: [1.0, 2.0, 3.0, 0.0, 0.0, 0.0],
931        };
932        let b = VirialTensor {
933            components: [4.0, 5.0, 6.0, 0.0, 0.0, 0.0],
934        };
935        let c = a.add(&b);
936        assert!((c.components[0] - 5.0).abs() < 1e-12);
937        assert!((c.trace() - 21.0).abs() < 1e-12);
938    }
939    #[test]
940    fn test_compute_virial_stress_tensor_symmetric() {
941        let lj = LjPotential::new(1.0, 1.0);
942        let positions = vec![[0.0, 0.0, 0.0], [1.2, 0.0, 0.0]];
943        let vt = compute_virial_stress_tensor(&positions, &lj, 5.0);
944        assert!(vt.components[1].abs() < 1e-10, "Wyy should be 0");
945        assert!(vt.components[2].abs() < 1e-10, "Wzz should be 0");
946    }
947    #[test]
948    fn test_virial_kernel_newton_iii_check() {
949        let lj = LjPotential::new(1.0, 1.0);
950        let positions = [[0.0, 0.0, 0.0], [1.1, 0.0, 0.0], [0.5, 1.0, 0.0]];
951        let flat_pos: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
952        let params = [lj.epsilon, lj.sigma, 5.0f64];
953        let mut outputs = vec![Vec::new()];
954        VirialStressTensorKernel.execute(&[&flat_pos, &params], &mut outputs, 3);
955        assert_eq!(
956            outputs[0].len(),
957            6,
958            "virial tensor should have 6 components"
959        );
960    }
961    #[test]
962    fn test_bond_force_equilibrium_no_force() {
963        let r0 = 1.5_f64;
964        let positions = vec![[0.0, 0.0, 0.0], [r0, 0.0, 0.0]];
965        let bonds = vec![HarmonicBond::new(0, 1, 100.0, r0)];
966        let (forces, energy) = compute_bond_forces(&positions, &bonds);
967        assert_eq!(forces.len(), 2);
968        for (&f0d, &f1d) in forces[0].iter().zip(forces[1].iter()) {
969            assert!(f0d.abs() < 1e-10, "force at equilibrium should be 0");
970            assert!(f1d.abs() < 1e-10, "force at equilibrium should be 0");
971        }
972        assert!(
973            energy.abs() < 1e-10,
974            "energy at equilibrium should be 0, got {energy}"
975        );
976    }
977    #[test]
978    fn test_bond_force_compressed() {
979        let r0 = 2.0_f64;
980        let r = 1.0_f64;
981        let k = 50.0_f64;
982        let positions = vec![[0.0, 0.0, 0.0], [r, 0.0, 0.0]];
983        let bonds = vec![HarmonicBond::new(0, 1, k, r0)];
984        let (forces, energy) = compute_bond_forces(&positions, &bonds);
985        assert!(forces[0][0] < 0.0, "atom 0 should be pushed away from bond");
986        assert!(forces[1][0] > 0.0, "atom 1 should be pushed away from bond");
987        for (dim, (&f0d, &f1d)) in forces[0].iter().zip(forces[1].iter()).enumerate() {
988            assert!(
989                (f0d + f1d).abs() < 1e-10,
990                "Newton III violated at dim {dim}"
991            );
992        }
993        let expected_e = 0.5 * k * (r - r0).powi(2);
994        assert!(
995            (energy - expected_e).abs() < 1e-10,
996            "energy mismatch: {energy} vs {expected_e}"
997        );
998    }
999    #[test]
1000    fn test_bond_force_kernel_executes() {
1001        let positions_flat = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
1002        let bond_data = vec![0.0, 1.0, 100.0, 1.0];
1003        let mut outputs = vec![Vec::new(), Vec::new()];
1004        BondForceKernel.execute(&[&positions_flat, &bond_data], &mut outputs, 2);
1005        assert_eq!(outputs[0].len(), 6, "forces should have 6 components (3*2)");
1006        assert_eq!(outputs[1].len(), 1, "energies should have 1 component");
1007        for &f in &outputs[0] {
1008            assert!(f.abs() < 1e-10, "force at equilibrium should be 0, got {f}");
1009        }
1010    }
1011    #[test]
1012    fn test_bond_force_kernel_stretched() {
1013        let positions_flat = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
1014        let bond_data = vec![0.0, 1.0, 10.0, 1.0];
1015        let mut outputs = vec![Vec::new(), Vec::new()];
1016        BondForceKernel.execute(&[&positions_flat, &bond_data], &mut outputs, 2);
1017        let fx0 = outputs[0][0];
1018        let fx1 = outputs[0][3];
1019        assert!(
1020            fx0 > 0.0,
1021            "atom 0 should be pulled toward atom 1 (positive x)"
1022        );
1023        assert!(
1024            fx1 < 0.0,
1025            "atom 1 should be pulled toward atom 0 (negative x)"
1026        );
1027        assert!(
1028            (fx0 + fx1).abs() < 1e-10,
1029            "Newton III: forces should cancel"
1030        );
1031    }
1032    #[test]
1033    fn test_angle_force_at_equilibrium() {
1034        let positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1035        let angles = vec![HarmonicAngle::new(0, 1, 2, 50.0, std::f64::consts::PI)];
1036        let (forces, energy) = compute_angle_forces(&positions, &angles);
1037        assert_eq!(forces.len(), 3);
1038        assert!(
1039            energy.abs() < 1e-8,
1040            "energy at equilibrium angle should be ~0, got {energy}"
1041        );
1042    }
1043    #[test]
1044    fn test_angle_force_finite_at_90_degrees() {
1045        let positions = vec![[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1046        let theta0 = std::f64::consts::PI / 2.0;
1047        let angles = vec![HarmonicAngle::new(0, 1, 2, 100.0, theta0)];
1048        let (forces, energy) = compute_angle_forces(&positions, &angles);
1049        assert_eq!(forces.len(), 3);
1050        assert!(energy.is_finite(), "angle energy should be finite");
1051        for f in &forces {
1052            for &c in f {
1053                assert!(c.is_finite(), "angle force component should be finite: {c}");
1054            }
1055        }
1056        assert!(
1057            energy.abs() < 1e-8,
1058            "at equilibrium angle energy should be ~0, got {energy}"
1059        );
1060    }
1061    #[test]
1062    fn test_angle_force_kernel_executes() {
1063        let positions_flat = vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0];
1064        let theta0 = std::f64::consts::PI / 2.0;
1065        let angle_data = vec![0.0, 1.0, 2.0, 100.0, theta0];
1066        let mut outputs = vec![Vec::new(), Vec::new()];
1067        AngleForceKernel.execute(&[&positions_flat, &angle_data], &mut outputs, 3);
1068        assert_eq!(outputs[0].len(), 9, "forces should have 9 components (3*3)");
1069        assert_eq!(outputs[1].len(), 1, "energies should have 1 element");
1070        for &f in &outputs[0] {
1071            assert!(f.is_finite(), "angle force not finite: {f}");
1072        }
1073    }
1074    #[test]
1075    fn test_kinetic_temperature_basic() {
1076        let velocities = vec![[3.0, 0.0, 0.0]];
1077        let masses = vec![1.0];
1078        let kb = 1.0;
1079        let t = kinetic_temperature(&velocities, &masses, kb);
1080        assert!((t - 3.0).abs() < 1e-10, "expected T=3, got {t}");
1081    }
1082    #[test]
1083    fn test_kinetic_temperature_zero_velocity() {
1084        let velocities = vec![[0.0; 3]; 5];
1085        let masses = vec![1.0; 5];
1086        let t = kinetic_temperature(&velocities, &masses, 1.0);
1087        assert!(
1088            t.abs() < 1e-15,
1089            "temperature of zero-velocity system should be 0"
1090        );
1091    }
1092    #[test]
1093    fn test_temperature_scale_reaches_target() {
1094        let mut velocities = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1095        let masses = vec![1.0, 1.0];
1096        let kb = 1.0;
1097        let t_before = kinetic_temperature(&velocities, &masses, kb);
1098        assert!(t_before > 0.0);
1099        let t_target = t_before * 4.0;
1100        temperature_scale(&mut velocities, &masses, t_target, kb);
1101        let t_after = kinetic_temperature(&velocities, &masses, kb);
1102        assert!(
1103            (t_after - t_target).abs() < 1e-8,
1104            "after scaling: expected T={t_target}, got T={t_after}"
1105        );
1106    }
1107    #[test]
1108    fn test_temperature_scale_kernel_rescales() {
1109        let vel_flat = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
1110        let masses = vec![1.0, 1.0];
1111        let kb = 1.0;
1112        let t_target = 2.0 / 3.0;
1113        let params = vec![t_target, kb];
1114        let mut outputs = vec![Vec::new(), Vec::new()];
1115        TemperatureScaleKernel.execute(&[&vel_flat, &masses, &params], &mut outputs, 2);
1116        assert_eq!(outputs[0].len(), 6);
1117        assert_eq!(outputs[1].len(), 2);
1118        let t_before = outputs[1][0];
1119        let t_after = outputs[1][1];
1120        assert!(t_before > 0.0, "t_before should be positive");
1121        assert!(
1122            (t_after - t_target).abs() < 1e-8,
1123            "t_after should be target {t_target}, got {t_after}"
1124        );
1125    }
1126    #[test]
1127    fn test_temperature_scale_kernel_outputs_finite() {
1128        let vel_flat = vec![2.0, 1.0, 0.5, 0.3, 0.7, 1.2, 0.1, 0.4, 0.9];
1129        let masses = vec![1.0, 2.0, 0.5];
1130        let params = vec![300.0, 1.0];
1131        let mut outputs = vec![Vec::new(), Vec::new()];
1132        TemperatureScaleKernel.execute(&[&vel_flat, &masses, &params], &mut outputs, 3);
1133        for &v in &outputs[0] {
1134            assert!(v.is_finite(), "scaled velocity not finite: {v}");
1135        }
1136        for &t in &outputs[1] {
1137            assert!(t.is_finite(), "temperature not finite: {t}");
1138        }
1139    }
1140}