Skip to main content

molrs_compute/diffraction/
direct.rs

1//! Direct k-grid evaluation of the static structure factor.
2//!
3//! Mirrors `freud.diffraction.StaticStructureFactorDirect`
4//! ([source](https://github.com/glotzerlab/freud/blob/main/freud/diffraction/StaticStructureFactorDirect.cc)).
5//!
6//! Evaluates
7//!
8//! ```text
9//!   S(k) = (1/N) | Σ_j exp(i k · r_j) |²
10//! ```
11//!
12//! on an explicit array of k-vectors. Two convenience constructors are
13//! offered:
14//!
15//! - [`StaticStructureFactorDirect::new`] — user-supplied k-vectors
16//!   (full freedom).
17//! - [`StaticStructureFactorDirect::isotropic`] — for orthorhombic boxes,
18//!   builds a 3-D reciprocal-lattice grid with k ≤ k_max and spherically
19//!   averages into uniformly spaced k-magnitude bins.
20//!
21//! Unlike [`super::debye`], this analyzer respects the supplied SimBox: the
22//! reciprocal-lattice spacing comes from `2π / L_d` along each axis.
23
24use molrs::spatial::region::simbox::BoxKind;
25use molrs::store::frame_access::FrameAccess;
26use molrs::types::F;
27use ndarray::Array1;
28
29use crate::error::ComputeError;
30use crate::result::ComputeResult;
31use crate::traits::Compute;
32use crate::util::get_positions_ref;
33
34const TWO_PI: F = 2.0 * std::f64::consts::PI;
35
36/// Per-frame direct-SSF result.
37///
38/// When the analyzer was constructed via [`StaticStructureFactorDirect::new`]
39/// (explicit k-vectors), `sk` holds `S(k_vec)` for each input k-vector and
40/// `k_magnitudes` holds their magnitudes. With
41/// [`isotropic`](StaticStructureFactorDirect::isotropic), `sk` is the
42/// spherically averaged `S(|k|)` and `k_magnitudes` is the bin-centres
43/// array.
44#[derive(Debug, Clone, Default)]
45pub struct StaticStructureFactorDirectResult {
46    pub k_magnitudes: Array1<F>,
47    pub sk: Array1<F>,
48    pub n_particles: usize,
49}
50
51impl ComputeResult for StaticStructureFactorDirectResult {}
52
53#[derive(Debug, Clone)]
54enum KMode {
55    /// User-supplied k-vectors. `sk[i]` = `S(k_vecs[i])`.
56    Explicit { k_vecs: Vec<[F; 3]> },
57    /// Spherically averaged S(|k|) on a reciprocal-lattice grid.
58    Isotropic { k_max: F, n_bins: usize },
59}
60
61#[derive(Debug, Clone)]
62pub struct StaticStructureFactorDirect {
63    mode: KMode,
64}
65
66impl StaticStructureFactorDirect {
67    /// Build from an explicit list of k-vectors (Å⁻¹).
68    pub fn new(k_vecs: &[[F; 3]]) -> Result<Self, ComputeError> {
69        if k_vecs.is_empty() {
70            return Err(ComputeError::OutOfRange {
71                field: "StaticStructureFactorDirect::k_vecs",
72                value: "empty".into(),
73            });
74        }
75        Ok(Self {
76            mode: KMode::Explicit {
77                k_vecs: k_vecs.to_vec(),
78            },
79        })
80    }
81
82    /// Build the spherically averaged form. `n_bins` magnitude bins between
83    /// 0 and `k_max`. Reciprocal-lattice points are read from the SimBox
84    /// during [`compute`].
85    pub fn isotropic(k_max: F, n_bins: usize) -> Result<Self, ComputeError> {
86        if k_max.is_nan() || k_max <= 0.0 || n_bins == 0 {
87            return Err(ComputeError::OutOfRange {
88                field: "StaticStructureFactorDirect::isotropic",
89                value: format!("k_max={k_max}, n_bins={n_bins}"),
90            });
91        }
92        Ok(Self {
93            mode: KMode::Isotropic { k_max, n_bins },
94        })
95    }
96
97    fn evaluate_explicit<FA: FrameAccess>(
98        frame: &FA,
99        k_vecs: &[[F; 3]],
100    ) -> Result<StaticStructureFactorDirectResult, ComputeError> {
101        let (xs_p, ys_p, zs_p) = get_positions_ref(frame)?;
102        let xs = xs_p.slice();
103        let ys = ys_p.slice();
104        let zs = zs_p.slice();
105        let n = xs.len();
106        let inv_n = if n > 0 { 1.0 / n as F } else { 0.0 };
107
108        let mut sk = Array1::<F>::zeros(k_vecs.len());
109        let mut kmags = Array1::<F>::zeros(k_vecs.len());
110        for (idx, k) in k_vecs.iter().enumerate() {
111            kmags[idx] = (k[0] * k[0] + k[1] * k[1] + k[2] * k[2]).sqrt();
112            let mut re: F = 0.0;
113            let mut im: F = 0.0;
114            for j in 0..n {
115                let phase = k[0] * xs[j] + k[1] * ys[j] + k[2] * zs[j];
116                re += phase.cos();
117                im += phase.sin();
118            }
119            sk[idx] = inv_n * (re * re + im * im);
120        }
121        Ok(StaticStructureFactorDirectResult {
122            k_magnitudes: kmags,
123            sk,
124            n_particles: n,
125        })
126    }
127
128    fn evaluate_isotropic<FA: FrameAccess>(
129        frame: &FA,
130        k_max: F,
131        n_bins: usize,
132    ) -> Result<StaticStructureFactorDirectResult, ComputeError> {
133        let simbox = frame.simbox_ref().ok_or(ComputeError::MissingSimBox)?;
134        let (lx, ly, lz) = match simbox.kind() {
135            BoxKind::Ortho { len, .. } => (len[0], len[1], len[2]),
136            BoxKind::Triclinic => {
137                return Err(ComputeError::OutOfRange {
138                    field: "StaticStructureFactorDirect::isotropic::simbox",
139                    value: "triclinic boxes not supported".into(),
140                });
141            }
142        };
143        let dkx = TWO_PI / lx;
144        let dky = TWO_PI / ly;
145        let dkz = TWO_PI / lz;
146        let nx = (k_max / dkx).ceil() as i32;
147        let ny = (k_max / dky).ceil() as i32;
148        let nz = (k_max / dkz).ceil() as i32;
149
150        let (xs_p, ys_p, zs_p) = get_positions_ref(frame)?;
151        let xs = xs_p.slice();
152        let ys = ys_p.slice();
153        let zs = zs_p.slice();
154        let n_atoms = xs.len();
155        let inv_n = if n_atoms > 0 { 1.0 / n_atoms as F } else { 0.0 };
156
157        let dk = k_max / n_bins as F;
158        let mut sk_sum = vec![0.0_f64; n_bins];
159        let mut counts = vec![0_u64; n_bins];
160
161        for ix in -nx..=nx {
162            for iy in -ny..=ny {
163                for iz in -nz..=nz {
164                    if ix == 0 && iy == 0 && iz == 0 {
165                        continue;
166                    }
167                    let kx = ix as F * dkx;
168                    let ky = iy as F * dky;
169                    let kz = iz as F * dkz;
170                    let kmag = (kx * kx + ky * ky + kz * kz).sqrt();
171                    if kmag > k_max || kmag <= 0.0 {
172                        continue;
173                    }
174                    let bin = ((kmag / dk) as usize).min(n_bins - 1);
175                    let mut re: F = 0.0;
176                    let mut im: F = 0.0;
177                    for j in 0..n_atoms {
178                        let phase = kx * xs[j] + ky * ys[j] + kz * zs[j];
179                        re += phase.cos();
180                        im += phase.sin();
181                    }
182                    sk_sum[bin] += inv_n * (re * re + im * im);
183                    counts[bin] += 1;
184                }
185            }
186        }
187
188        let mut sk = Array1::<F>::zeros(n_bins);
189        let mut kmags = Array1::<F>::zeros(n_bins);
190        for b in 0..n_bins {
191            kmags[b] = (b as F + 0.5) * dk;
192            if counts[b] > 0 {
193                sk[b] = sk_sum[b] / counts[b] as F;
194            }
195        }
196        Ok(StaticStructureFactorDirectResult {
197            k_magnitudes: kmags,
198            sk,
199            n_particles: n_atoms,
200        })
201    }
202}
203
204impl Compute for StaticStructureFactorDirect {
205    type Args<'a> = ();
206    type Output = Vec<StaticStructureFactorDirectResult>;
207
208    fn compute<'a, FA: FrameAccess + Sync + 'a>(
209        &self,
210        frames: &[&'a FA],
211        _: (),
212    ) -> Result<Vec<StaticStructureFactorDirectResult>, ComputeError> {
213        if frames.is_empty() {
214            return Err(ComputeError::EmptyInput);
215        }
216        let mut out = Vec::with_capacity(frames.len());
217        for f in frames {
218            let r = match &self.mode {
219                KMode::Explicit { k_vecs } => Self::evaluate_explicit(*f, k_vecs)?,
220                KMode::Isotropic { k_max, n_bins } => {
221                    Self::evaluate_isotropic(*f, *k_max, *n_bins)?
222                }
223            };
224            out.push(r);
225        }
226        Ok(out)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use molrs::Frame;
234    use molrs::spatial::region::simbox::SimBox;
235    use molrs::store::block::Block;
236    use ndarray::{Array1 as A1, array};
237
238    fn frame_with(positions: &[[F; 3]], box_len: F, pbc: [bool; 3]) -> Frame {
239        let x = A1::from_iter(positions.iter().map(|p| p[0]));
240        let y = A1::from_iter(positions.iter().map(|p| p[1]));
241        let z = A1::from_iter(positions.iter().map(|p| p[2]));
242        let mut block = Block::new();
243        block.insert("x", x.into_dyn()).unwrap();
244        block.insert("y", y.into_dyn()).unwrap();
245        block.insert("z", z.into_dyn()).unwrap();
246        let mut frame = Frame::new();
247        frame.insert("atoms", block);
248        frame.simbox =
249            Some(SimBox::cube(box_len, array![0.0 as F, 0.0 as F, 0.0 as F], pbc).unwrap());
250        frame
251    }
252
253    const TOL: F = 1e-10;
254
255    #[test]
256    fn s_at_zero_k_equals_n() {
257        // S(k=0) = (1/N) |Σ exp(0)|² = N
258        let frame = frame_with(
259            &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
260            10.0,
261            [false; 3],
262        );
263        let r = &StaticStructureFactorDirect::new(&[[0.0, 0.0, 0.0]])
264            .unwrap()
265            .compute(&[&frame], ())
266            .unwrap()[0];
267        assert!((r.sk[0] - 3.0).abs() < TOL);
268    }
269
270    #[test]
271    fn two_particle_analytic_explicit() {
272        // For two particles separated by d along x:
273        // S(k) = (1/2) |1 + exp(i k·d ê_x)|²
274        //      = (1/2) (2 + 2 cos(k_x d))
275        //      = 1 + cos(k_x d)
276        let d = 1.5_f64;
277        let frame = frame_with(&[[0.0, 0.0, 0.0], [d, 0.0, 0.0]], 10.0, [false; 3]);
278        let kx_vals = [0.5_f64, 1.2, 2.7];
279        let k_vecs: Vec<[F; 3]> = kx_vals.iter().map(|&k| [k, 0.0, 0.0]).collect();
280        let r = &StaticStructureFactorDirect::new(&k_vecs)
281            .unwrap()
282            .compute(&[&frame], ())
283            .unwrap()[0];
284        for (i, &k) in kx_vals.iter().enumerate() {
285            let expected = 1.0 + (k * d).cos();
286            assert!(
287                (r.sk[i] - expected).abs() < TOL,
288                "k={k}: got {}, expected {expected}",
289                r.sk[i]
290            );
291        }
292    }
293
294    #[test]
295    fn isotropic_has_bragg_peak_for_lattice() {
296        // 4 particles on a simple cubic motif inside a 4×4×4 box → strong
297        // peak at k = 2π/a with a = 1 (the lattice spacing). The (1,0,0)
298        // reciprocal-lattice vector has magnitude 2π/1 = 2π ≈ 6.28.
299        let mut positions = Vec::new();
300        for ix in 0..4 {
301            for iy in 0..4 {
302                for iz in 0..4 {
303                    positions.push([ix as F, iy as F, iz as F]);
304                }
305            }
306        }
307        let frame = frame_with(&positions, 4.0, [true, true, true]);
308        let r = &StaticStructureFactorDirect::isotropic(8.0, 16)
309            .unwrap()
310            .compute(&[&frame], ())
311            .unwrap()[0];
312        // Find the largest S(k) in the range [5.5, 7.0]
313        let mut max_sk = 0.0_f64;
314        let mut max_k = 0.0_f64;
315        for b in 0..16 {
316            let k = r.k_magnitudes[b];
317            if (5.5..=7.0).contains(&k) && r.sk[b] > max_sk {
318                max_sk = r.sk[b];
319                max_k = k;
320            }
321        }
322        // Expect the peak near k ≈ 2π and S(k) ≫ 1 for a perfect lattice.
323        assert!(
324            max_sk > 5.0,
325            "expected a Bragg peak near k = 2π with S ≫ 1, got S({max_k}) = {max_sk}",
326        );
327    }
328
329    #[test]
330    fn invalid_inputs_error() {
331        assert!(StaticStructureFactorDirect::new(&[]).is_err());
332        assert!(StaticStructureFactorDirect::isotropic(0.0, 10).is_err());
333        assert!(StaticStructureFactorDirect::isotropic(1.0, 0).is_err());
334    }
335
336    #[test]
337    fn empty_frame_returns_zero_sk() {
338        let frame = frame_with(&[], 10.0, [false; 3]);
339        let r = &StaticStructureFactorDirect::new(&[[1.0, 0.0, 0.0]])
340            .unwrap()
341            .compute(&[&frame], ())
342            .unwrap()[0];
343        assert_eq!(r.n_particles, 0);
344        assert_eq!(r.sk[0], 0.0);
345    }
346}