Skip to main content

xtalforge_core/
rings.rs

1//! Ring perception on the periodic bond graph (Task 1.12).
2//!
3//! The search returns primitive (shortest-path) rings of Franzblau: a cycle is
4//! kept only if no two of its atoms are closer through the graph than they are
5//! round the ring. Rings carry the lattice image of every atom, so a ring that
6//! closes through a cell face is still one ring and a small cell can show the
7//! same atom more than once.
8
9mod search;
10
11use std::collections::BTreeSet;
12
13use crate::{BondGraph, Error, Structure};
14
15/// One atom of a ring path, with its lattice image relative to the first atom.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct RingStep {
18    /// Atom index in the P1 cell.
19    pub atom: usize,
20    /// Lattice image applied to this atom in the path.
21    pub image: [i32; 3],
22}
23
24/// One primitive ring.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
26pub struct Ring {
27    /// Unique atom indices in the ring, sorted.
28    pub atoms: Vec<usize>,
29    /// Atoms in ring order, each with its lattice image.
30    pub path: Vec<RingStep>,
31}
32
33impl Ring {
34    /// Number of atoms in the ring path (a small cell can repeat an atom).
35    #[must_use]
36    pub fn size(&self) -> usize {
37        self.path.len()
38    }
39}
40
41/// Finds primitive rings up to `max_size` atoms.
42///
43/// # Errors
44///
45/// Returns [`Error::InvalidInput`] for `max_size < 3`, a failed P1 expansion,
46/// or a bond graph too dense to search within the Franzblau budget.
47pub fn rings(structure: &Structure, max_size: usize) -> Result<Vec<Ring>, Error> {
48    if max_size < 3 {
49        return Err(Error::InvalidInput(
50            "ring size limit must be at least 3".to_owned(),
51        ));
52    }
53    search::find(structure, max_size)
54}
55
56/// Finds primitive rings using only the stored explicit bonds.
57///
58/// Builders state their bonds; this is the census those bonds describe,
59/// without distance perception adding a close contact across a pore.
60///
61/// # Errors
62///
63/// Returns [`Error::InvalidInput`] for `max_size < 3`, a non-P1 structure
64/// or a graph too dense to search.
65pub fn rings_explicit(structure: &Structure, max_size: usize) -> Result<Vec<Ring>, Error> {
66    if max_size < 3 {
67        return Err(Error::InvalidInput(
68            "ring size limit must be at least 3".to_owned(),
69        ));
70    }
71    let graph = BondGraph::from_explicit(structure)?;
72    search::find_graph(&graph, max_size)
73}
74
75/// Counts explicit-bond rings by size.
76///
77/// # Errors
78///
79/// Returns the errors of [`rings_explicit`].
80pub fn ring_counts_explicit(
81    structure: &Structure,
82    max_size: usize,
83) -> Result<Vec<(usize, usize)>, Error> {
84    let mut counts: Vec<(usize, usize)> = Vec::new();
85    for ring in rings_explicit(structure, max_size)? {
86        if let Some(entry) = counts.iter_mut().find(|(size, _)| *size == ring.size()) {
87            entry.1 += 1;
88        } else {
89            counts.push((ring.size(), 1));
90        }
91    }
92    Ok(counts)
93}
94
95/// Ring paths with the lattice image of every atom, in ring order.
96///
97/// This is the periodic form of [`rings`]; each path can be laid out in
98/// Cartesian space by adding `image` to the atom's fractional position.
99///
100/// # Errors
101///
102/// Returns the errors of [`rings`].
103pub fn ring_paths(structure: &Structure, max_size: usize) -> Result<Vec<Vec<RingStep>>, Error> {
104    Ok(rings(structure, max_size)?
105        .into_iter()
106        .map(|ring| ring.path)
107        .collect())
108}
109
110/// Counts rings by size.
111///
112/// # Errors
113///
114/// Returns the errors of [`rings`].
115pub fn ring_counts(structure: &Structure, max_size: usize) -> Result<Vec<(usize, usize)>, Error> {
116    let mut counts: Vec<(usize, usize)> = Vec::new();
117    for ring in rings(structure, max_size)? {
118        if let Some(entry) = counts.iter_mut().find(|(size, _)| *size == ring.size()) {
119            entry.1 += 1;
120        } else {
121            counts.push((ring.size(), 1));
122        }
123    }
124    Ok(counts)
125}
126
127/// Number of rings each P1 atom belongs to (`ring_membership`).
128///
129/// An atom repeated in a periodic ring is counted once for that ring.
130///
131/// # Errors
132///
133/// Returns the errors of [`rings`].
134pub fn ring_membership(structure: &Structure, max_size: usize) -> Result<Vec<usize>, Error> {
135    let p1 = structure.p1()?;
136    let mut membership = vec![0usize; p1.atoms.len()];
137    for ring in rings(structure, max_size)? {
138        for atom in ring.atoms {
139            if let Some(slot) = membership.get_mut(atom) {
140                *slot += 1;
141            }
142        }
143    }
144    Ok(membership)
145}
146
147/// Heuristic aromaticity for a ring: five- or six-membered and all C/N/O.
148///
149/// A real aromaticity check needs bond orders and planarity; this flag is a
150/// first approximation for the molecule builder and is labelled as such.
151///
152/// # Errors
153///
154/// Returns the errors of the P1 expansion.
155pub fn is_aromatic(structure: &Structure, ring: &Ring) -> Result<bool, Error> {
156    if !matches!(ring.size(), 5 | 6) {
157        return Ok(false);
158    }
159    let p1 = structure.p1()?;
160    Ok(ring.atoms.iter().all(|index| {
161        p1.atoms
162            .get(*index)
163            .is_some_and(|atom| matches!(atom.element.as_str(), "C" | "N" | "O"))
164    }))
165}
166
167/// Canonical ring key: translate the path so its least step is in the home
168/// cell, then forget the direction. Used by the search to list each ring once.
169pub(crate) fn translated_home(path: &[RingStep]) -> BTreeSet<RingStep> {
170    let Some(minimum) = path.iter().min() else {
171        return BTreeSet::new();
172    };
173    path.iter()
174        .map(|step| RingStep {
175            atom: step.atom,
176            image: [
177                step.image[0] - minimum.image[0],
178                step.image[1] - minimum.image[1],
179                step.image[2] - minimum.image[2],
180            ],
181        })
182        .collect()
183}