Skip to main content

lindhard/ion/crystal/
search.rs

1//! Lattice neighbour search: the sites near a straight path segment, found
2//! by walking unit cells (step 20a of the M2 crystal plan).
3//!
4//! Given an ion position, a direction, a search radius `p_max` and a segment
5//! length `L`, [`LatticeSearch::search`] returns every lattice site whose
6//! impact parameter against the segment is at most `p_max`, together with
7//! its distance along the path, ordered along the path. This is the
8//! geometric first step of a binary-collision search on explicit atom
9//! positions (Robinson and Torrens, Phys. Rev. B 9, 5008 (1974),
10//! doi:10.1103/PhysRevB.9.5008, describe the method; only the paper is used
11//! here, no code). The crystal flight model of the binary-collision engine
12//! ([`crate::ion::bca::crystal`]) calls it for every flight segment.
13//!
14//! # Geometry and units
15//!
16//! Everything is in the **crystal frame, metres** (see the `crystal` module
17//! docs, "Frames"). The lattice is the infinite periodic crystal, tiled by
18//! the rectangular cell of [`Lattice::orthogonal_cell`] with edges
19//! `(e_x, e_y, e_z)`: the conventional cube (`e = (a, a, a)`) for a cubic
20//! lattice, the orthohexagonal cell (`e = (a, sqrt(3) a, c)`) for a
21//! hexagonal one. Cell `(i, j, k)` holds the atoms
22//! `(e_x (i + f_x), e_y (j + f_y), e_z (k + f_z))` for each fractional
23//! position `f` of that cell ([`site_position_in`]). There is no global atom
24//! list: the per-cell site list is built once in [`LatticeSearch::new`], and
25//! memory use does not depend on how far from the origin the path lies.
26//!
27//! For a unit direction `d`, a path start `r0` and a site at `r`:
28//!
29//! ```text
30//! s  = (r - r0) . d             distance along the path
31//! p² = | (r - r0) - s d |²      squared impact parameter
32//! ```
33//!
34//! [`path_metrics`] is the single implementation of these two expressions;
35//! the cell walk and any reference scan must both go through it so that they
36//! agree bit for bit.
37//!
38//! # Boundary rule
39//!
40//! A site is a candidate iff `0 <= s <= L` **and** `p² <= p_max²`
41//! (evaluated in floating point exactly as written, on the values returned by
42//! [`path_metrics`]). Both ends are closed: a site exactly at the start of
43//! the segment (`s = 0`, e.g. the atom the ion starts on), exactly at its end
44//! (`s = L`) or exactly at the radius (`p² = p_max²`) is included. Sites
45//! behind the start (`s < 0`) are never returned. An empty result is not an
46//! error.
47//!
48//! # Ordering
49//!
50//! Results are sorted by `s` ascending ([`f64::total_cmp`]); equal `s`
51//! (many sites at once for rays along low-index directions) is broken by the
52//! cell index `(i, j, k)` lexicographically and then the basis index. The
53//! order is a pure function of the inputs, so it is independent of thread
54//! count; a [`LatticeSearch`] is immutable after construction and holds no
55//! shared state.
56//!
57//! # Cell walk
58//!
59//! The cell axis most parallel to the path is swept slab by slab. For each
60//! slab the path parameter range that can reach it (a cylinder point lies
61//! within `p_max` of the axis in every coordinate) is clipped to `[0, L]`,
62//! which bounds the other two cell indices. Each cell in that box is skipped
63//! if its centre cannot be within `p_max` of the segment (the metrics are
64//! 1-Lipschitz in position, so a half-diagonal margin is conservative). All
65//! bounds are padded by `1e-6` times the longest cell edge so that rounding
66//! never drops a site that the exact comparison would keep.
67
68use super::{CrystalError, Lattice};
69
70/// Relative padding (in cell edges) on every conservative bound.
71const PAD_REL: f64 = 1e-6;
72
73/// One lattice site found near the path.
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct Candidate {
76    /// Atomic number of the atom on the site.
77    pub z: u8,
78    /// Integer index `(i, j, k)` of the cell holding the site (the
79    /// conventional cube for a cubic lattice, the orthohexagonal cell for a
80    /// hexagonal one; see [`Lattice::orthogonal_cell`]).
81    pub cell: [i64; 3],
82    /// Index of the site within the cell, into the site list of
83    /// [`Lattice::orthogonal_cell`] (for a cubic lattice,
84    /// [`Lattice::conventional_cell_sites`]).
85    pub basis: u8,
86    /// Site position, crystal frame, metres.
87    pub position: [f64; 3],
88    /// Distance along the path from its start, metres (`0 <= s <= L`).
89    pub s: f64,
90    /// Impact parameter, metres (`p <= p_max`).
91    pub p: f64,
92}
93
94/// Distance along the path and squared impact parameter of `site` for a path
95/// starting at `origin` with **unit** direction `dir`:
96/// `s = (site - origin) . dir`, `p² = |(site - origin) - s dir|²`.
97///
98/// This is the one formula used for the boundary comparisons; see the module
99/// docs.
100pub fn path_metrics(origin: [f64; 3], dir: [f64; 3], site: [f64; 3]) -> (f64, f64) {
101    let dr = [
102        site[0] - origin[0],
103        site[1] - origin[1],
104        site[2] - origin[2],
105    ];
106    let s = dr[0] * dir[0] + dr[1] * dir[1] + dr[2] * dir[2];
107    let q = [dr[0] - s * dir[0], dr[1] - s * dir[1], dr[2] - s * dir[2]];
108    (s, q[0] * q[0] + q[1] * q[1] + q[2] * q[2])
109}
110
111/// The unit direction the search uses for `direction` (any finite, non-zero
112/// vector), computed exactly as in [`LatticeSearch::search_into`], so that a
113/// caller can evaluate [`path_metrics`] on other points with the same bits.
114pub fn unit_direction(direction: [f64; 3]) -> [f64; 3] {
115    let norm2 = direction.iter().map(|x| x * x).sum::<f64>();
116    let n = norm2.sqrt();
117    [direction[0] / n, direction[1] / n, direction[2] / n]
118}
119
120/// Position of basis site `f` (fractions of the cell edge) in cell `cell` of
121/// a cubic cell with edge `a`; [`site_position_in`] with three equal edges.
122pub fn site_position(a: f64, cell: [i64; 3], f: [f64; 3]) -> [f64; 3] {
123    site_position_in([a; 3], cell, f)
124}
125
126/// Position of site `f` (fractions of the cell edges) in cell `cell` of the
127/// rectangular cell with edges `e`: `e_k (cell_k + f_k)` per axis. Shared by
128/// the search and by reference scans.
129pub fn site_position_in(e: [f64; 3], cell: [i64; 3], f: [f64; 3]) -> [f64; 3] {
130    [
131        e[0] * (cell[0] as f64 + f[0]),
132        e[1] * (cell[1] as f64 + f[1]),
133        e[2] * (cell[2] as f64 + f[2]),
134    ]
135}
136
137/// Candidate-site search through an infinite periodic [`Lattice`].
138#[derive(Debug, Clone)]
139pub struct LatticeSearch {
140    edges: [f64; 3],
141    sites: Vec<(u8, [f64; 3])>,
142}
143
144fn bad(name: &'static str, why: &'static str) -> CrystalError {
145    CrystalError::InvalidSearch { name, why }
146}
147
148impl LatticeSearch {
149    /// Prepare the per-cell site list of `lattice`
150    /// ([`Lattice::orthogonal_cell`]).
151    pub fn new(lattice: &Lattice) -> Self {
152        let (edges, sites) = lattice.orthogonal_cell();
153        Self { edges, sites }
154    }
155
156    /// Lattice constant `a`, metres (the first cell edge).
157    pub fn lattice_constant(&self) -> f64 {
158        self.edges[0]
159    }
160
161    /// Edges of the rectangular search cell along `x`, `y`, `z`, metres.
162    pub fn cell_edges(&self) -> [f64; 3] {
163        self.edges
164    }
165
166    /// Candidate sites within `p_max` of the segment of length `length`
167    /// starting at `origin` along `direction` (any non-zero length; it is
168    /// normalised), ordered along the path. See the module docs for the
169    /// boundary and ordering rules.
170    ///
171    /// # Errors
172    /// [`CrystalError::InvalidSearch`] for a non-finite origin, a zero or
173    /// non-finite direction, a `p_max` that is not finite and positive, or a
174    /// `length` that is not finite and non-negative.
175    pub fn search(
176        &self,
177        origin: [f64; 3],
178        direction: [f64; 3],
179        p_max: f64,
180        length: f64,
181    ) -> Result<Vec<Candidate>, CrystalError> {
182        let mut out = Vec::new();
183        self.search_into(origin, direction, p_max, length, &mut out)?;
184        Ok(out)
185    }
186
187    /// As [`search`](Self::search), but clears and fills `out`, reusing its
188    /// allocation.
189    pub fn search_into(
190        &self,
191        origin: [f64; 3],
192        direction: [f64; 3],
193        p_max: f64,
194        length: f64,
195        out: &mut Vec<Candidate>,
196    ) -> Result<(), CrystalError> {
197        if !origin.iter().all(|x| x.is_finite()) {
198            return Err(bad("origin", "must be finite"));
199        }
200        let norm2 = direction.iter().map(|x| x * x).sum::<f64>();
201        if !direction.iter().all(|x| x.is_finite()) || !norm2.is_finite() || norm2 == 0.0 {
202            return Err(bad("direction", "must be finite and non-zero"));
203        }
204        if !(p_max.is_finite() && p_max > 0.0) {
205            return Err(bad("p_max", "must be finite and positive"));
206        }
207        if !(length.is_finite() && length >= 0.0) {
208            return Err(bad("length", "must be finite and non-negative"));
209        }
210        out.clear();
211        let d = unit_direction(direction);
212        let e = self.edges;
213        let e_max = e[0].max(e[1]).max(e[2]);
214        let pad = p_max + PAD_REL * e_max;
215        let spad = PAD_REL * e_max;
216        // Half-diagonal of a cell, padded: bound on |site - cell centre|.
217        let h = 0.5 * (e[0] * e[0] + e[1] * e[1] + e[2] * e[2]).sqrt() * (1.0 + PAD_REL) + spad;
218
219        // Sweep along the axis most parallel to the path.
220        let ax = (0..3)
221            .max_by(|&i, &j| d[i].abs().total_cmp(&d[j].abs()).then(j.cmp(&i)))
222            .unwrap_or(0);
223        let (b, c) = match ax {
224            0 => (1, 2),
225            1 => (0, 2),
226            _ => (0, 1),
227        };
228        let x0 = origin[ax];
229        let x1 = x0 + length * d[ax];
230        let (xlo, xhi) = (x0.min(x1) - pad, x0.max(x1) + pad);
231        let ea = e[ax];
232        let i_lo = (xlo / ea).floor() as i64;
233        let i_hi = (xhi / ea).floor() as i64;
234
235        for ia in i_lo..=i_hi {
236            // Path parameters that can reach this slab (|offset| <= p_max in
237            // every coordinate), clipped to the segment.
238            let ta = ((ia as f64) * ea - pad - x0) / d[ax];
239            let tb = (((ia + 1) as f64) * ea + pad - x0) / d[ax];
240            let (t_lo, t_hi) = if ta <= tb { (ta, tb) } else { (tb, ta) };
241            let t_lo = t_lo.max(-spad);
242            let t_hi = t_hi.min(length + spad);
243            if t_lo > t_hi {
244                continue;
245            }
246            let range = |k: usize| {
247                let u = origin[k] + t_lo * d[k];
248                let v = origin[k] + t_hi * d[k];
249                (
250                    ((u.min(v) - pad) / e[k]).floor() as i64,
251                    ((u.max(v) + pad) / e[k]).floor() as i64,
252                )
253            };
254            let (jb_lo, jb_hi) = range(b);
255            let (jc_lo, jc_hi) = range(c);
256            for ib in jb_lo..=jb_hi {
257                for ic in jc_lo..=jc_hi {
258                    let mut cell = [0i64; 3];
259                    cell[ax] = ia;
260                    cell[b] = ib;
261                    cell[c] = ic;
262                    let centre = site_position_in(e, cell, [0.5; 3]);
263                    let (sc, p2c) = path_metrics(origin, d, centre);
264                    if sc < -h || sc > length + h {
265                        continue;
266                    }
267                    let lim = p_max + h;
268                    if p2c > lim * lim {
269                        continue;
270                    }
271                    for (bi, &(z, f)) in self.sites.iter().enumerate() {
272                        let pos = site_position_in(e, cell, f);
273                        let (s, p2) = path_metrics(origin, d, pos);
274                        if s >= 0.0 && s <= length && p2 <= p_max * p_max {
275                            out.push(Candidate {
276                                z,
277                                cell,
278                                basis: bi as u8,
279                                position: pos,
280                                s,
281                                p: p2.sqrt(),
282                            });
283                        }
284                    }
285                }
286            }
287        }
288        out.sort_by(compare);
289        Ok(())
290    }
291}
292
293/// The documented order: `s`, then cell index, then basis index.
294pub fn compare(x: &Candidate, y: &Candidate) -> std::cmp::Ordering {
295    x.s.total_cmp(&y.s)
296        .then(x.cell.cmp(&y.cell))
297        .then(x.basis.cmp(&y.basis))
298}