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}