use crate::compute::result::ComputeResult;
use ndarray::Array3;
use molrs::spatial::region::simbox::BoxKind;
use molrs::store::frame_access::FrameAccess;
use molrs::types::F;
use super::wrap_index;
use crate::compute::error::ComputeError;
use crate::compute::traits::Compute;
use crate::compute::util::get_positions_ref;
#[derive(Debug, Clone, Copy)]
pub struct GaussianDensity {
nx: usize,
ny: usize,
nz: usize,
sigma: F,
r_max: F,
}
impl GaussianDensity {
pub fn new(nx: usize, ny: usize, nz: usize, sigma: F) -> Result<Self, ComputeError> {
if nx == 0 || ny == 0 || nz == 0 {
return Err(ComputeError::OutOfRange {
field: "GaussianDensity::width",
value: format!("({nx}, {ny}, {nz})"),
});
}
if sigma.is_nan() || sigma <= 0.0 {
return Err(ComputeError::OutOfRange {
field: "GaussianDensity::sigma",
value: sigma.to_string(),
});
}
Ok(Self {
nx,
ny,
nz,
sigma,
r_max: 3.0 * sigma,
})
}
pub fn with_r_max(mut self, r_max: F) -> Self {
debug_assert!(r_max > 0.0);
self.r_max = r_max;
self
}
pub fn width(&self) -> (usize, usize, usize) {
(self.nx, self.ny, self.nz)
}
pub fn sigma(&self) -> F {
self.sigma
}
pub fn r_max(&self) -> F {
self.r_max
}
fn one_frame<FA: FrameAccess>(
&self,
frame: &FA,
) -> Result<GaussianDensityResult, ComputeError> {
let simbox = frame.simbox_ref().ok_or(ComputeError::MissingSimBox)?;
let (lx, ly, lz) = match simbox.kind() {
BoxKind::Ortho { len, .. } => (len[0], len[1], len[2]),
BoxKind::Triclinic => {
return Err(ComputeError::OutOfRange {
field: "GaussianDensity::simbox",
value: "triclinic boxes are not supported".into(),
});
}
};
let origin = simbox.origin_view();
let ox = origin[0];
let oy = origin[1];
let oz = origin[2];
let pbc = simbox.pbc();
let dx = lx / self.nx as F;
let dy = ly / self.ny as F;
let dz = lz / self.nz as F;
let (xs_p, ys_p, zs_p) = get_positions_ref(frame)?;
let xs = xs_p.slice();
let ys = ys_p.slice();
let zs = zs_p.slice();
let n = xs.len();
let mut density = Array3::<F>::zeros((self.nx, self.ny, self.nz));
let two_sigma_sq = 2.0 * self.sigma * self.sigma;
let pref = (two_sigma_sq * std::f64::consts::PI).powf(-1.5);
let r_max_sq = self.r_max * self.r_max;
let half_kx = (self.r_max / dx).floor() as isize;
let half_ky = (self.r_max / dy).floor() as isize;
let half_kz = (self.r_max / dz).floor() as isize;
for p in 0..n {
let px = xs[p];
let py = ys[p];
let pz = zs[p];
let cx = ((px - ox) / dx).floor() as isize;
let cy = ((py - oy) / dy).floor() as isize;
let cz = ((pz - oz) / dz).floor() as isize;
for ix in (cx - half_kx)..=(cx + half_kx) {
let (wx, gx_idx) = wrap_index(ix, self.nx as isize, pbc[0]);
if !wx {
continue;
}
let vx = ox + (ix as F + 0.5) * dx;
let ddx = vx - px;
if ddx.abs() > self.r_max {
continue;
}
for iy in (cy - half_ky)..=(cy + half_ky) {
let (wy, gy_idx) = wrap_index(iy, self.ny as isize, pbc[1]);
if !wy {
continue;
}
let vy = oy + (iy as F + 0.5) * dy;
let ddy = vy - py;
if ddy.abs() > self.r_max {
continue;
}
for iz in (cz - half_kz)..=(cz + half_kz) {
let (wz, gz_idx) = wrap_index(iz, self.nz as isize, pbc[2]);
if !wz {
continue;
}
let vz = oz + (iz as F + 0.5) * dz;
let ddz = vz - pz;
let r2 = ddx * ddx + ddy * ddy + ddz * ddz;
if r2 > r_max_sq {
continue;
}
let v = pref * (-r2 / two_sigma_sq).exp();
density[[gx_idx, gy_idx, gz_idx]] += v;
}
}
}
}
Ok(GaussianDensityResult { density })
}
}
impl Compute for GaussianDensity {
type Args<'a> = ();
type Output = Vec<GaussianDensityResult>;
fn compute<'a, FA: FrameAccess + Sync + 'a>(
&self,
frames: &[&'a FA],
_: (),
) -> Result<Vec<GaussianDensityResult>, ComputeError> {
if frames.is_empty() {
return Err(ComputeError::EmptyInput);
}
#[cfg(feature = "rayon")]
const PAR_THRESHOLD: usize = 2;
#[cfg(feature = "rayon")]
if frames.len() >= PAR_THRESHOLD {
use rayon::prelude::*;
return frames.par_iter().map(|f| self.one_frame(*f)).collect();
}
let mut out = Vec::with_capacity(frames.len());
for f in frames {
out.push(self.one_frame(*f)?);
}
Ok(out)
}
}
#[derive(Debug, Clone, Default)]
pub struct GaussianDensityResult {
pub density: Array3<F>,
}
impl ComputeResult for GaussianDensityResult {}
#[cfg(test)]
mod tests {
use super::*;
use molrs::Frame;
use molrs::spatial::region::simbox::SimBox;
use molrs::store::block::Block;
use ndarray::{Array1 as A1, array};
fn frame_with(positions: &[[F; 3]], box_len: F, pbc: [bool; 3]) -> Frame {
let x = A1::from_iter(positions.iter().map(|p| p[0]));
let y = A1::from_iter(positions.iter().map(|p| p[1]));
let z = A1::from_iter(positions.iter().map(|p| p[2]));
let mut block = Block::new();
block.insert("x", x.into_dyn()).unwrap();
block.insert("y", y.into_dyn()).unwrap();
block.insert("z", z.into_dyn()).unwrap();
let mut frame = Frame::new();
frame.insert("atoms", block);
frame.simbox =
Some(SimBox::cube(box_len, array![0.0 as F, 0.0 as F, 0.0 as F], pbc).unwrap());
frame
}
#[test]
fn integral_over_grid_recovers_particle_count() {
let frame = frame_with(&[[5.0, 5.0, 5.0]], 10.0, [false; 3]);
let gd = GaussianDensity::new(40, 40, 40, 0.5)
.unwrap()
.with_r_max(2.0);
let r = &gd.compute(&[&frame], ()).unwrap()[0];
let voxel_volume = (10.0_f64 / 40.0).powi(3);
let integral: F = r.density.iter().copied().sum::<F>() * voxel_volume;
assert!(
(integral - 1.0).abs() < 0.01,
"integral = {integral} (expected ≈ 1.0)"
);
}
#[test]
fn two_particles_integrate_to_two() {
let frame = frame_with(&[[3.0, 5.0, 5.0], [7.0, 5.0, 5.0]], 10.0, [false; 3]);
let gd = GaussianDensity::new(40, 40, 40, 0.4)
.unwrap()
.with_r_max(2.0);
let r = &gd.compute(&[&frame], ()).unwrap()[0];
let voxel_volume = (10.0_f64 / 40.0).powi(3);
let integral: F = r.density.iter().copied().sum::<F>() * voxel_volume;
assert!(
(integral - 2.0).abs() < 0.02,
"integral = {integral} (expected ≈ 2.0)"
);
}
#[test]
fn peak_is_near_particle_position() {
let frame = frame_with(&[[5.0, 5.0, 5.0]], 10.0, [false; 3]);
let gd = GaussianDensity::new(20, 20, 20, 0.5).unwrap();
let r = &gd.compute(&[&frame], ()).unwrap()[0];
let mut max_val = F::MIN;
let mut max_idx = (0, 0, 0);
for (idx, &v) in r.density.indexed_iter() {
if v > max_val {
max_val = v;
max_idx = idx;
}
}
assert!(
(max_idx.0 as isize - 10).abs() <= 1
&& (max_idx.1 as isize - 10).abs() <= 1
&& (max_idx.2 as isize - 10).abs() <= 1,
"peak at {max_idx:?}, expected near (10, 10, 10)"
);
}
#[test]
fn pbc_wraps_density_across_boundary() {
let frame = frame_with(&[[0.1, 5.0, 5.0]], 10.0, [true; 3]);
let gd = GaussianDensity::new(40, 40, 40, 0.4)
.unwrap()
.with_r_max(2.0);
let r = &gd.compute(&[&frame], ()).unwrap()[0];
let cx_right = ((9.9_f64 / 10.0) * 40.0) as usize;
let cy = ((5.0_f64 / 10.0) * 40.0) as usize;
let cz = ((5.0_f64 / 10.0) * 40.0) as usize;
assert!(
r.density[[cx_right, cy, cz]] > 1e-3,
"PBC wrap should give non-zero density at x ≈ 9.9 ({} found)",
r.density[[cx_right, cy, cz]]
);
}
#[test]
fn invalid_width_or_sigma_errors() {
assert!(GaussianDensity::new(0, 10, 10, 1.0).is_err());
assert!(GaussianDensity::new(10, 10, 10, -1.0).is_err());
assert!(GaussianDensity::new(10, 10, 10, 0.0).is_err());
}
#[test]
fn missing_simbox_is_error() {
let mut frame = frame_with(&[[5.0, 5.0, 5.0]], 10.0, [false; 3]);
frame.simbox = None;
let err = GaussianDensity::new(10, 10, 10, 0.5)
.unwrap()
.compute(&[&frame], ())
.unwrap_err();
assert!(matches!(err, ComputeError::MissingSimBox));
}
#[test]
fn parallel_matches_serial() {
let frame = frame_with(&[[3.0, 5.0, 5.0], [7.0, 5.0, 5.0]], 10.0, [false; 3]);
let gd = GaussianDensity::new(16, 16, 16, 0.5)
.unwrap()
.with_r_max(2.0);
let solo = gd.compute(&[&frame], ()).unwrap();
let par = gd.compute(&[&frame, &frame], ()).unwrap();
assert_eq!(par.len(), 2);
assert_eq!(par[0].density, solo[0].density);
assert_eq!(par[1].density, solo[0].density);
}
}