Skip to main content

molrs_compute/diffraction/
debye.rs

1//! Closed-form Debye static structure factor.
2//!
3//! Mirrors `freud.diffraction.StaticStructureFactorDebye`
4//! ([source](https://github.com/glotzerlab/freud/blob/main/freud/diffraction/StaticStructureFactorDebye.cc)).
5//!
6//! The spherically-averaged Debye scattering equation reads
7//!
8//! ```text
9//!   S(k) = (1/N) Σ_{i, j} sin(k r_ij) / (k r_ij)
10//! ```
11//!
12//! where the sum runs over **all ordered pairs** including `i = j`
13//! (which contributes `1` each, i.e. `N`). The implementation walks every
14//! pair once via an `O(N²)` loop — there is no cutoff in Debye's form, so
15//! a neighbor list does not help here. For sparse systems the upcoming
16//! Phase 9 `StaticStructureFactorDirect` (FFT-based) is preferred.
17//!
18//! # Conventions
19//!
20//! - `k` values are passed in as an explicit array of magnitudes (`Å⁻¹`).
21//! - `S(0) = N` exactly (per `lim_{k→0} sin(k r) / (k r) = 1`).
22//! - The asymptote `S(k → ∞) → 1` is approached as the off-diagonal sum
23//!   averages to zero.
24//! - Periodic boxes: distances are *not* minimum-imaged here. freud's
25//!   `Debye` uses the raw inter-particle distance and warns the user that
26//!   PBC should usually be turned off for a meaningful Debye calculation
27//!   (the formula assumes an open system).
28
29use molrs::store::frame_access::FrameAccess;
30use molrs::types::F;
31use ndarray::Array1;
32
33use crate::error::ComputeError;
34use crate::result::ComputeResult;
35use crate::traits::Compute;
36use crate::util::get_positions_ref;
37
38/// Per-frame Debye structure-factor result.
39#[derive(Debug, Clone, Default)]
40pub struct StaticStructureFactorDebyeResult {
41    /// k values (Å⁻¹), copied from input.
42    pub k_values: Array1<F>,
43    /// `S(k)` at each k.
44    pub sk: Array1<F>,
45    /// Particle count used in the normalisation.
46    pub n_particles: usize,
47}
48
49impl ComputeResult for StaticStructureFactorDebyeResult {}
50
51/// Debye structure-factor calculator.
52#[derive(Debug, Clone)]
53pub struct StaticStructureFactorDebye {
54    k_values: Array1<F>,
55}
56
57impl StaticStructureFactorDebye {
58    /// Construct from an explicit array of k magnitudes (Å⁻¹). Must be
59    /// non-empty.
60    pub fn new(k_values: &[F]) -> Result<Self, ComputeError> {
61        if k_values.is_empty() {
62            return Err(ComputeError::OutOfRange {
63                field: "StaticStructureFactorDebye::k_values",
64                value: "empty".into(),
65            });
66        }
67        Ok(Self {
68            k_values: Array1::from_vec(k_values.to_vec()),
69        })
70    }
71
72    /// Convenience: build a linearly-spaced k grid `[k_min, k_max]` with
73    /// `n` points (inclusive of both endpoints).
74    pub fn linspace(k_min: F, k_max: F, n: usize) -> Result<Self, ComputeError> {
75        if n == 0 {
76            return Err(ComputeError::OutOfRange {
77                field: "StaticStructureFactorDebye::linspace::n",
78                value: "0".into(),
79            });
80        }
81        if k_max < k_min {
82            return Err(ComputeError::OutOfRange {
83                field: "StaticStructureFactorDebye::linspace::k_max",
84                value: format!("k_max={k_max} < k_min={k_min}"),
85            });
86        }
87        let step = if n == 1 {
88            0.0
89        } else {
90            (k_max - k_min) / (n - 1) as F
91        };
92        let v: Vec<F> = (0..n).map(|i| k_min + i as F * step).collect();
93        Self::new(&v)
94    }
95
96    pub fn k_values(&self) -> &Array1<F> {
97        &self.k_values
98    }
99
100    fn one_frame<FA: FrameAccess>(
101        &self,
102        frame: &FA,
103    ) -> Result<StaticStructureFactorDebyeResult, ComputeError> {
104        let (xs_p, ys_p, zs_p) = get_positions_ref(frame)?;
105        let xs = xs_p.slice();
106        let ys = ys_p.slice();
107        let zs = zs_p.slice();
108        let n = xs.len();
109        let n_k = self.k_values.len();
110        let mut sk = Array1::<F>::zeros(n_k);
111
112        if n == 0 {
113            return Ok(StaticStructureFactorDebyeResult {
114                k_values: self.k_values.clone(),
115                sk,
116                n_particles: 0,
117            });
118        }
119
120        // Diagonal: every pair (i, i) contributes 1 → N to every S(k).
121        for v in sk.iter_mut() {
122            *v = n as F;
123        }
124
125        // Off-diagonal: each unordered pair contributes 2 · sin(k r) / (k r)
126        // (factor 2 from i↔j symmetry).
127        let inv_n = 1.0 / n as F;
128        for i in 0..n {
129            for j in (i + 1)..n {
130                let dx = xs[i] - xs[j];
131                let dy = ys[i] - ys[j];
132                let dz = zs[i] - zs[j];
133                let r = (dx * dx + dy * dy + dz * dz).sqrt();
134                if r == 0.0 {
135                    // Treat coincident pair as contributing 2 (lim sinx/x = 1).
136                    for v in sk.iter_mut() {
137                        *v += 2.0;
138                    }
139                    continue;
140                }
141                for (idx, &k) in self.k_values.iter().enumerate() {
142                    let kr = k * r;
143                    let term = if kr.abs() < 1e-9 { 1.0 } else { kr.sin() / kr };
144                    sk[idx] += 2.0 * term;
145                }
146            }
147        }
148
149        // Normalise by N: S(k) = (1/N) Σ_{i, j} sin(k r_ij) / (k r_ij)
150        for v in sk.iter_mut() {
151            *v *= inv_n;
152        }
153
154        Ok(StaticStructureFactorDebyeResult {
155            k_values: self.k_values.clone(),
156            sk,
157            n_particles: n,
158        })
159    }
160}
161
162impl Compute for StaticStructureFactorDebye {
163    type Args<'a> = ();
164    type Output = Vec<StaticStructureFactorDebyeResult>;
165
166    fn compute<'a, FA: FrameAccess + Sync + 'a>(
167        &self,
168        frames: &[&'a FA],
169        _: (),
170    ) -> Result<Vec<StaticStructureFactorDebyeResult>, ComputeError> {
171        if frames.is_empty() {
172            return Err(ComputeError::EmptyInput);
173        }
174        let mut out = Vec::with_capacity(frames.len());
175        for f in frames {
176            out.push(self.one_frame(*f)?);
177        }
178        Ok(out)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use molrs::Frame;
186    use molrs::spatial::region::simbox::SimBox;
187    use molrs::store::block::Block;
188    use ndarray::{Array1 as A1, array};
189
190    fn frame_with(positions: &[[F; 3]]) -> Frame {
191        let x = A1::from_iter(positions.iter().map(|p| p[0]));
192        let y = A1::from_iter(positions.iter().map(|p| p[1]));
193        let z = A1::from_iter(positions.iter().map(|p| p[2]));
194        let mut block = Block::new();
195        block.insert("x", x.into_dyn()).unwrap();
196        block.insert("y", y.into_dyn()).unwrap();
197        block.insert("z", z.into_dyn()).unwrap();
198        let mut frame = Frame::new();
199        frame.insert("atoms", block);
200        // freud's Debye is open-system; supply a generous non-PBC box so the
201        // FrameAccess plumbing is happy.
202        frame.simbox =
203            Some(SimBox::cube(1000.0, array![0.0 as F, 0.0 as F, 0.0 as F], [false; 3]).unwrap());
204        frame
205    }
206
207    const TOL: F = 1e-10;
208
209    #[test]
210    fn s_at_k_zero_equals_n() {
211        let positions = [[0.0_f64, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
212        let frame = frame_with(&positions);
213        let s = StaticStructureFactorDebye::new(&[0.0]).unwrap();
214        let r = &s.compute(&[&frame], ()).unwrap()[0];
215        // S(0) = (1/N) · N² = N
216        assert!((r.sk[0] - 3.0).abs() < TOL);
217        assert_eq!(r.n_particles, 3);
218    }
219
220    #[test]
221    fn s_large_k_approaches_one() {
222        // For random-ish positions and large k, the oscillating sin(k r)/(k r)
223        // off-diagonal terms average to ≈ 0 → S(k) → 1.
224        use rand::RngExt;
225        use rand::SeedableRng;
226        use rand::rngs::StdRng;
227        let mut rng = StdRng::seed_from_u64(7);
228        let n = 30;
229        let positions: Vec<[F; 3]> = (0..n)
230            .map(|_| {
231                [
232                    rng.random::<F>() * 10.0,
233                    rng.random::<F>() * 10.0,
234                    rng.random::<F>() * 10.0,
235                ]
236            })
237            .collect();
238        let frame = frame_with(&positions);
239        let s = StaticStructureFactorDebye::linspace(50.0, 60.0, 11).unwrap();
240        let r = &s.compute(&[&frame], ()).unwrap()[0];
241        let mean: F = r.sk.iter().copied().sum::<F>() / r.sk.len() as F;
242        assert!(
243            (mean - 1.0).abs() < 0.2,
244            "S(k → ∞) should average to ≈ 1; got {mean}"
245        );
246    }
247
248    #[test]
249    fn two_particle_analytic() {
250        // For two particles at distance d: S(k) = 1 + sin(k d) / (k d).
251        // (Factor 1 from each diagonal; factor 2·sin/(kd)/2 = sin/(kd) from
252        // the single unordered pair after dividing by N = 2.)
253        let d: F = 1.5;
254        let frame = frame_with(&[[0.0, 0.0, 0.0], [d, 0.0, 0.0]]);
255        let k_vals = [0.5_f64, 1.0, 2.0, 5.0];
256        let s = StaticStructureFactorDebye::new(&k_vals).unwrap();
257        let r = &s.compute(&[&frame], ()).unwrap()[0];
258        for (i, &k) in k_vals.iter().enumerate() {
259            let expected = 1.0 + (k * d).sin() / (k * d);
260            assert!(
261                (r.sk[i] - expected).abs() < 1e-12,
262                "k={k}: got {}, expected {expected}",
263                r.sk[i]
264            );
265        }
266    }
267
268    #[test]
269    fn coincident_particles_diagonal_only() {
270        // Two coincident particles: every off-diagonal term contributes
271        // sinc(0) = 1. S(k) = (1/2)(2 + 2·1) = 2 for any k.
272        let frame = frame_with(&[[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]);
273        let s = StaticStructureFactorDebye::new(&[1.0, 5.0, 10.0]).unwrap();
274        let r = &s.compute(&[&frame], ()).unwrap()[0];
275        for &v in r.sk.iter() {
276            assert!((v - 2.0).abs() < TOL);
277        }
278    }
279
280    #[test]
281    fn empty_frame_gives_zero_sk() {
282        let frame = frame_with(&[]);
283        let s = StaticStructureFactorDebye::new(&[1.0]).unwrap();
284        let r = &s.compute(&[&frame], ()).unwrap()[0];
285        assert_eq!(r.sk[0], 0.0);
286        assert_eq!(r.n_particles, 0);
287    }
288
289    #[test]
290    fn invalid_k_array_errors() {
291        assert!(StaticStructureFactorDebye::new(&[]).is_err());
292        assert!(StaticStructureFactorDebye::linspace(2.0, 1.0, 10).is_err());
293        assert!(StaticStructureFactorDebye::linspace(0.0, 1.0, 0).is_err());
294    }
295
296    #[test]
297    fn linspace_n_one_returns_single_k() {
298        let s = StaticStructureFactorDebye::linspace(2.5, 7.0, 1).unwrap();
299        assert_eq!(s.k_values().len(), 1);
300        assert_eq!(s.k_values()[0], 2.5);
301    }
302
303    #[test]
304    fn multi_frame_returns_one_result_per_frame() {
305        let f1 = frame_with(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
306        let f2 = frame_with(&[[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]);
307        let s = StaticStructureFactorDebye::new(&[1.0]).unwrap();
308        let r = s.compute(&[&f1, &f2], ()).unwrap();
309        assert_eq!(r.len(), 2);
310        // Different distances → different S(k) values.
311        assert!((r[0].sk[0] - r[1].sk[0]).abs() > 1e-3);
312    }
313}