Skip to main content

molframe_spatial/queries/
search.rs

1//! Backend selection and public fixed-radius operations.
2
3use crate::brute;
4use crate::{
5    CellList, KdTree, NeighborList, NeighborPair, PeriodicBox, SpatialBackend, SpatialError,
6    SpatialSearchOptions,
7};
8use molframe_core::selection::AtomSelection;
9use molframe_core::{CoordinateGeneration, ExecutionContext};
10use std::borrow::Cow;
11
12#[derive(Clone, Copy)]
13enum PairOrder {
14    Sorted,
15    Unsorted,
16}
17
18/// How a pair enumeration should be produced.
19#[derive(Clone, Copy)]
20struct PairRequest {
21    options: SpatialSearchOptions,
22    order: PairOrder,
23}
24
25impl PairRequest {
26    const fn sorted(options: SpatialSearchOptions) -> Self {
27        Self {
28            options,
29            order: PairOrder::Sorted,
30        }
31    }
32    const fn unsorted(options: SpatialSearchOptions) -> Self {
33        Self {
34            options,
35            order: PairOrder::Unsorted,
36        }
37    }
38}
39
40#[derive(Clone, Copy)]
41struct PairDispatch {
42    plan: crate::SpatialPlan,
43    options: SpatialSearchOptions,
44    order: PairOrder,
45}
46
47/// Enumerates unique unordered pairs no further apart than `cutoff`.
48///
49/// Results are sorted by `(first, second)` for every backend. Self-pairs are
50/// excluded, including when the two selections overlap.
51///
52/// # Errors
53///
54/// Returns an error for an invalid cutoff or an out-of-range atom index.
55pub fn pairs_within(
56    positions: &[[f32; 3]],
57    left: &AtomSelection,
58    right: &AtomSelection,
59    cutoff: f32,
60    backend: SpatialBackend,
61    periodic: Option<&PeriodicBox>,
62    context: &ExecutionContext,
63) -> Result<Vec<NeighborPair>, SpatialError> {
64    pairs_within_with_options(
65        positions,
66        left,
67        right,
68        cutoff,
69        SpatialSearchOptions::with_backend(backend),
70        periodic,
71        context,
72    )
73}
74
75/// Enumerates unique same-selection pairs without sorting the returned vector.
76///
77/// The deterministic ordering contract of [`pairs_within`] remains unchanged.
78/// This variant is intended for reductions such as histograms and counts that
79/// do not consume pair order. For different left and right selections it keeps
80/// the canonical implementation so overlapping selections remain duplicate-free.
81///
82/// # Errors
83///
84/// Returns the same validation and backend errors as [`pairs_within`].
85pub fn pairs_within_unsorted(
86    positions: &[[f32; 3]],
87    left: &AtomSelection,
88    right: &AtomSelection,
89    cutoff: f32,
90    backend: SpatialBackend,
91    periodic: Option<&PeriodicBox>,
92    context: &ExecutionContext,
93) -> Result<Vec<NeighborPair>, SpatialError> {
94    pairs_within_unsorted_with_options(
95        positions,
96        left,
97        right,
98        cutoff,
99        SpatialSearchOptions::with_backend(backend),
100        periodic,
101        context,
102    )
103}
104
105/// Enumerates pairs under a complete, inspectable planning profile.
106///
107/// # Errors
108///
109/// Returns an error for invalid options, cutoff or atom indices.
110pub fn pairs_within_with_options(
111    positions: &[[f32; 3]],
112    left: &AtomSelection,
113    right: &AtomSelection,
114    cutoff: f32,
115    options: SpatialSearchOptions,
116    periodic: Option<&PeriodicBox>,
117    context: &ExecutionContext,
118) -> Result<Vec<NeighborPair>, SpatialError> {
119    pairs_with_order(
120        positions,
121        left,
122        right,
123        cutoff,
124        periodic,
125        PairRequest::sorted(options),
126        context,
127    )
128}
129
130/// Executes a fixed-radius query without sorting identical-selection results.
131///
132/// # Errors
133///
134/// Returns the same validation and backend errors as [`pairs_within`].
135pub fn pairs_within_unsorted_with_options(
136    positions: &[[f32; 3]],
137    left: &AtomSelection,
138    right: &AtomSelection,
139    cutoff: f32,
140    options: SpatialSearchOptions,
141    periodic: Option<&PeriodicBox>,
142    context: &ExecutionContext,
143) -> Result<Vec<NeighborPair>, SpatialError> {
144    pairs_with_order(
145        positions,
146        left,
147        right,
148        cutoff,
149        periodic,
150        PairRequest::unsorted(options),
151        context,
152    )
153}
154
155fn pairs_with_order(
156    positions: &[[f32; 3]],
157    left: &AtomSelection,
158    right: &AtomSelection,
159    cutoff: f32,
160    periodic: Option<&PeriodicBox>,
161    request: PairRequest,
162    context: &ExecutionContext,
163) -> Result<Vec<NeighborPair>, SpatialError> {
164    validate_cutoff(cutoff)?;
165
166    let indices = super::indices::IndexWorkspace::new(left, right, positions.len(), context)?;
167    let left_indices = indices.left();
168    let right_indices = indices.right();
169    let plan = request
170        .options
171        .plan(left_indices.len(), right_indices.len(), cutoff)?;
172
173    dispatch_pairs(
174        positions,
175        left_indices,
176        right_indices,
177        cutoff,
178        PairDispatch {
179            plan,
180            options: request.options,
181            order: request.order,
182        },
183        periodic,
184        context,
185    )
186}
187
188/// Dispatches already-validated index slices to one spatial backend.
189///
190/// Every plan contains a concrete backend selected before dispatch.
191fn dispatch_pairs(
192    positions: &[[f32; 3]],
193    left: &[u32],
194    right: &[u32],
195    cutoff: f32,
196    dispatch: PairDispatch,
197    periodic: Option<&PeriodicBox>,
198    context: &ExecutionContext,
199) -> Result<Vec<NeighborPair>, SpatialError> {
200    let same_selection = left == right;
201
202    match dispatch.plan.backend {
203        SpatialBackend::CellList => {
204            let index = CellList::build_in(
205                positions,
206                right,
207                cutoff,
208                periodic.copied(),
209                dispatch.options.cell_grid,
210                context,
211            )?;
212            if same_selection {
213                let sorted = matches!(dispatch.order, PairOrder::Sorted);
214                if context.worker_budget() > 1 {
215                    crate::backends::cell::pairs_same_selection_parallel(
216                        &index, left, cutoff, context, sorted,
217                    )
218                } else if sorted {
219                    crate::backends::cell::pairs_same_selection(&index, left, cutoff)
220                } else {
221                    crate::backends::cell::pairs_same_selection_unordered(&index, left, cutoff)
222                }
223            } else {
224                index.pairs(left, cutoff)
225            }
226        }
227        SpatialBackend::KdTree => KdTree::build_in(
228            positions,
229            right,
230            periodic.copied(),
231            dispatch.options.kd_periodic,
232            context,
233        )?
234        .pairs(left, cutoff),
235        SpatialBackend::NeighborList => neighbor_list_pairs(
236            positions,
237            left,
238            right,
239            cutoff,
240            dispatch.plan.neighbor_skin,
241            dispatch.options,
242            periodic,
243        ),
244        SpatialBackend::BruteForce => {
245            let cutoff_squared = cutoff * cutoff;
246            if same_selection {
247                if matches!(dispatch.order, PairOrder::Sorted) {
248                    Ok(brute::pairs_same_selection(
249                        positions,
250                        left,
251                        cutoff_squared,
252                        periodic,
253                    ))
254                } else {
255                    Ok(brute::pairs_same_selection_unordered(
256                        positions,
257                        left,
258                        cutoff_squared,
259                        periodic,
260                    ))
261                }
262            } else {
263                Ok(brute::pairs(
264                    positions,
265                    left,
266                    right,
267                    cutoff_squared,
268                    periodic,
269                ))
270            }
271        }
272        SpatialBackend::Auto => Err(SpatialError::InvalidOption(
273            crate::SpatialOption::UnresolvedBackend,
274        )),
275    }
276}
277
278/// Executes the one-shot neighbour-list backend.
279///
280/// The helper keeps backend dispatch small while preserving the explicit
281/// `NeighborList` backend semantics.
282fn neighbor_list_pairs(
283    positions: &[[f32; 3]],
284    left: &[u32],
285    right: &[u32],
286    cutoff: f32,
287    skin: Option<f32>,
288    options: SpatialSearchOptions,
289    periodic: Option<&PeriodicBox>,
290) -> Result<Vec<NeighborPair>, SpatialError> {
291    let Some(skin) = skin else {
292        return Err(SpatialError::InvalidOption(
293            crate::SpatialOption::NeighborSkinMinimum,
294        ));
295    };
296    let list = NeighborList::build_with_options(
297        positions,
298        left,
299        right,
300        cutoff,
301        periodic,
302        CoordinateGeneration::INITIAL,
303        crate::NeighborListOptions {
304            skin,
305            cell_grid: options.cell_grid,
306        },
307    )?;
308
309    list.pairs(positions, cutoff, periodic)
310}
311
312/// Validates an atom selection, borrowing existing sparse index storage.
313///
314/// Runtime is `O(N)`. Sparse and empty selections need no allocation; other
315/// representations expand into one contiguous index buffer.
316pub(super) fn checked_indices(
317    selection: &AtomSelection,
318    position_count: usize,
319) -> Result<Cow<'_, [u32]>, SpatialError> {
320    if let AtomSelection::Sparse(indices) = selection {
321        for &atom in indices {
322            if usize::try_from(atom).map_err(|_| SpatialError::NumericRangeExceeded)?
323                >= position_count
324            {
325                return Err(SpatialError::AtomOutOfBounds(atom));
326            }
327        }
328        return Ok(Cow::Borrowed(indices));
329    }
330    if selection.is_empty() {
331        return Ok(Cow::Borrowed(&[]));
332    }
333    let capacity =
334        usize::try_from(selection.len()).map_err(|_| SpatialError::NumericRangeExceeded)?;
335    let mut indices = Vec::with_capacity(capacity);
336
337    for atom in selection {
338        let position = usize::try_from(atom).map_err(|_| SpatialError::NumericRangeExceeded)?;
339        if position >= position_count {
340            return Err(SpatialError::AtomOutOfBounds(atom));
341        }
342
343        indices.push(atom);
344    }
345
346    Ok(Cow::Owned(indices))
347}
348
349/// Validates a non-negative finite cutoff.
350///
351/// Runtime and auxiliary space are `O(1)`.
352pub(super) fn validate_cutoff(cutoff: f32) -> Result<(), SpatialError> {
353    if cutoff.is_finite() && cutoff >= 0.0 {
354        Ok(())
355    } else {
356        Err(SpatialError::InvalidCutoff)
357    }
358}
359
360#[cfg(test)]
361#[path = "search_tests.rs"]
362mod tests;