Skip to main content

xtalforge_core/
bonding.rs

1//! Distance-based bond perception and the connectivity graph (Task 1.11).
2
3use std::collections::VecDeque;
4
5use crate::elements::element_by_symbol;
6use crate::{Error, Neighbor, Structure, neighbors};
7
8/// One perceived or explicit bond.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct Bond {
11    /// First atom.
12    pub i: usize,
13    /// Second atom.
14    pub j: usize,
15    /// Lattice image of `j`.
16    pub image: [i32; 3],
17    /// Distance in Angstrom.
18    pub distance: f64,
19    /// Bond order.
20    pub order: f64,
21    /// Whether the bond was stored explicitly rather than perceived.
22    pub explicit: bool,
23}
24
25/// An explicit bond stored by the user.
26#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
27pub struct ExplicitBond {
28    /// First atom index in the asymmetric unit or P1 cell.
29    pub i: usize,
30    /// Second atom index.
31    pub j: usize,
32    /// Lattice image of `j`.
33    pub image: [i32; 3],
34    /// Bond order.
35    pub order: f64,
36    /// When true, remove a perceived bond instead of adding one.
37    pub remove: bool,
38}
39
40/// A topology edge between node centres; never a chemical bond.
41#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
42pub struct TopologyBond {
43    /// First node.
44    pub i: usize,
45    /// Second node.
46    pub j: usize,
47    /// Lattice image of `j`.
48    pub image: [i32; 3],
49}
50
51/// Perception rules.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct BondRules {
54    /// Multiplier on the sum of covalent radii.
55    pub scale: f64,
56    /// Bonds shorter than this are ignored.
57    pub minimum_distance: f64,
58}
59
60impl Default for BondRules {
61    fn default() -> Self {
62        Self {
63            scale: 1.15,
64            minimum_distance: 0.4,
65        }
66    }
67}
68
69/// Connectivity graph over the P1 cell.
70#[derive(Debug, Clone, PartialEq)]
71pub struct BondGraph {
72    /// Perceived bonds.
73    pub bonds: Vec<Bond>,
74    /// Adjacency; entry `i` lists bond indices incident to atom `i`.
75    adjacency: Vec<Vec<usize>>,
76}
77
78impl BondGraph {
79    /// Connectivity from the explicit bonds only.
80    ///
81    /// This is the graph a builder states: distances are not perceived, so
82    /// a close contact across a pore is not silently made a bond. The
83    /// structure must be P1 (the carbon builders are), because an explicit
84    /// bond names an atom index in the P1 cell.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`Error::InvalidInput`] for a non-P1 structure or a failed
89    /// P1 expansion.
90    pub fn from_explicit(structure: &Structure) -> Result<Self, Error> {
91        if !structure.is_p1() {
92            return Err(Error::InvalidInput(
93                "an explicit-only bond graph needs a P1 structure".to_owned(),
94            ));
95        }
96        let p1 = structure.p1()?;
97        let mut bonds = Vec::new();
98        for explicit in structure.explicit_bonds() {
99            if explicit.remove {
100                continue;
101            }
102            let distance = explicit_distance(&p1, explicit).unwrap_or(0.0);
103            bonds.push(Bond {
104                i: explicit.i,
105                j: explicit.j,
106                image: explicit.image,
107                distance,
108                order: explicit.order,
109                explicit: true,
110            });
111        }
112        let mut adjacency = vec![Vec::new(); p1.atoms.len()];
113        for (index, bond) in bonds.iter().enumerate() {
114            if let Some(list) = adjacency.get_mut(bond.i) {
115                list.push(index);
116            }
117            if bond.j != bond.i
118                && let Some(list) = adjacency.get_mut(bond.j)
119            {
120                list.push(index);
121            }
122        }
123        Ok(Self { bonds, adjacency })
124    }
125
126    /// Number of atoms.
127    #[must_use]
128    pub fn n_atoms(&self) -> usize {
129        self.adjacency.len()
130    }
131
132    /// Coordination number of one atom.
133    #[must_use]
134    pub fn degree(&self, index: usize) -> usize {
135        self.adjacency.get(index).map_or(0, Vec::len)
136    }
137
138    /// Bond indices incident to one atom.
139    #[must_use]
140    pub fn bonds_of(&self, index: usize) -> &[usize] {
141        self.adjacency.get(index).map_or(&[], Vec::as_slice)
142    }
143
144    /// Connected components as sorted atom-index lists.
145    #[must_use]
146    pub fn components(&self) -> Vec<Vec<usize>> {
147        let mut seen = vec![false; self.n_atoms()];
148        let mut components = Vec::new();
149        for start in 0..self.n_atoms() {
150            if seen.get(start).copied().unwrap_or(true) {
151                continue;
152            }
153            let mut component = Vec::new();
154            let mut queue = VecDeque::from([start]);
155            while let Some(index) = queue.pop_front() {
156                if seen.get(index).copied().unwrap_or(true) {
157                    continue;
158                }
159                if let Some(slot) = seen.get_mut(index) {
160                    *slot = true;
161                }
162                component.push(index);
163                for bond_index in self.bonds_of(index) {
164                    if let Some(bond) = self.bonds.get(*bond_index) {
165                        let other = if bond.i == index { bond.j } else { bond.i };
166                        if !seen.get(other).copied().unwrap_or(true) {
167                            queue.push_back(other);
168                        }
169                    }
170                }
171            }
172            component.sort_unstable();
173            components.push(component);
174        }
175        components
176    }
177}
178
179/// Perceives distance-based bonds on the P1 cell.
180///
181/// # Errors
182///
183/// Returns [`Error::InvalidInput`] for invalid rules or a failed P1 expansion.
184pub fn perceive_bonds(structure: &Structure, rules: BondRules) -> Result<BondGraph, Error> {
185    if !rules.scale.is_finite() || rules.scale <= 0.0 {
186        return Err(Error::InvalidInput(
187            "bond scale must be positive and finite".to_owned(),
188        ));
189    }
190    if !rules.minimum_distance.is_finite() || rules.minimum_distance < 0.0 {
191        return Err(Error::InvalidInput(
192            "bond minimum distance must be non-negative and finite".to_owned(),
193        ));
194    }
195    let p1 = structure.p1()?;
196    let mut radii = Vec::with_capacity(p1.atoms.len());
197    let mut dummy = Vec::with_capacity(p1.atoms.len());
198    let mut metal = Vec::with_capacity(p1.atoms.len());
199    for atom in &p1.atoms {
200        let element = element_by_symbol(&atom.element)?;
201        radii.push(element.covalent_radius);
202        dummy.push(atom.element == "X");
203        metal.push(element.is_metal);
204    }
205    let maximum = radii.iter().copied().fold(0.0, f64::max);
206    let cutoff = 2.0 * maximum * rules.scale;
207    if cutoff <= 0.0 {
208        return Ok(BondGraph {
209            bonds: Vec::new(),
210            adjacency: vec![Vec::new(); p1.atoms.len()],
211        });
212    }
213    let found = neighbors(&p1_to_structure(&p1)?, cutoff)?;
214    let mut bonds = Vec::new();
215    for Neighbor {
216        i,
217        j,
218        image,
219        distance,
220    } in found
221    {
222        if i == j
223            || distance < rules.minimum_distance
224            || dummy.get(i).copied().unwrap_or(true)
225            || dummy.get(j).copied().unwrap_or(true)
226            || (metal.get(i).copied().unwrap_or(false) && metal.get(j).copied().unwrap_or(false))
227        {
228            continue;
229        }
230        let Some(radius_i) = radii.get(i) else {
231            continue;
232        };
233        let Some(radius_j) = radii.get(j) else {
234            continue;
235        };
236        if distance <= (radius_i + radius_j) * rules.scale {
237            bonds.push(Bond {
238                i,
239                j,
240                image,
241                distance,
242                order: 1.0,
243                explicit: false,
244            });
245        }
246    }
247    for explicit in structure.explicit_bonds() {
248        let distance = explicit_distance(&p1, explicit);
249        if explicit.remove {
250            bonds.retain(|bond| {
251                !same_bond(bond, explicit.i, explicit.j, explicit.image)
252                    && !same_contact(bond, explicit, distance)
253            });
254            continue;
255        }
256        if let Some(existing) = bonds
257            .iter_mut()
258            .find(|bond| same_bond(bond, explicit.i, explicit.j, explicit.image))
259        {
260            existing.order = explicit.order;
261            existing.explicit = true;
262            continue;
263        }
264        if let Some(existing) = bonds
265            .iter_mut()
266            .find(|bond| same_contact(bond, explicit, distance))
267        {
268            existing.order = explicit.order;
269            existing.explicit = true;
270            continue;
271        }
272        if !structure.is_p1() {
273            // A non-P1 record names a site and an operation; the P1 mapping
274            // may land on a different image representative. Only an existing
275            // perceived contact is safe to annotate; an unmatched record is
276            // kept in memory but does not invent a bond.
277            continue;
278        }
279        if let (Some(distance), Some(_)) = (distance, p1.atoms.get(explicit.j)) {
280            let (i, j, image) = canonical(explicit.i, explicit.j, explicit.image);
281            bonds.push(Bond {
282                i,
283                j,
284                image,
285                distance,
286                order: explicit.order,
287                explicit: true,
288            });
289        }
290    }
291    let mut adjacency = vec![Vec::new(); p1.atoms.len()];
292    for (index, bond) in bonds.iter().enumerate() {
293        if let Some(list) = adjacency.get_mut(bond.i) {
294            list.push(index);
295        }
296        if let Some(list) = adjacency.get_mut(bond.j) {
297            list.push(index);
298        }
299    }
300    Ok(BondGraph { bonds, adjacency })
301}
302
303impl Structure {
304    /// Perceives bonds with the default rules.
305    ///
306    /// # Errors
307    ///
308    /// Returns the errors of [`perceive_bonds`].
309    pub fn bond_graph(&self) -> Result<BondGraph, Error> {
310        perceive_bonds(self, BondRules::default())
311    }
312}
313
314fn p1_to_structure(cell: &crate::P1Cell) -> Result<Structure, Error> {
315    let mut builder = crate::StructureBuilder::new("p1", cell.lattice);
316    for (index, atom) in cell.atoms.iter().enumerate() {
317        builder = builder.site(
318            crate::Site::new(
319                format!("{}{}", atom.element, index + 1),
320                &atom.element,
321                atom.frac,
322            )?
323            .with_occupancy(atom.occupancy)?,
324        );
325    }
326    builder.build()
327}
328
329fn same_bond(bond: &Bond, i: usize, j: usize, image: [i32; 3]) -> bool {
330    (bond.i == i && bond.j == j && bond.image == image)
331        || (bond.i == j && bond.j == i && bond.image == [-image[0], -image[1], -image[2]])
332}
333
334fn canonical(i: usize, j: usize, image: [i32; 3]) -> (usize, usize, [i32; 3]) {
335    if i > j
336        || (i == j
337            && (image[0] < 0
338                || (image[0] == 0 && image[1] < 0)
339                || (image[0] == 0 && image[1] == 0 && image[2] < 0)))
340    {
341        (j, i, [-image[0], -image[1], -image[2]])
342    } else {
343        (i, j, image)
344    }
345}
346
347fn explicit_distance(cell: &crate::P1Cell, explicit: &ExplicitBond) -> Option<f64> {
348    let first = cell.atoms.get(explicit.i)?;
349    let second = cell.atoms.get(explicit.j)?;
350    let delta = [
351        second.frac[0] + f64::from(explicit.image[0]) - first.frac[0],
352        second.frac[1] + f64::from(explicit.image[1]) - first.frac[1],
353        second.frac[2] + f64::from(explicit.image[2]) - first.frac[2],
354    ];
355    let matrix = cell.lattice.matrix();
356    let cart = [
357        delta[0] * matrix[0][0] + delta[1] * matrix[1][0] + delta[2] * matrix[2][0],
358        delta[0] * matrix[0][1] + delta[1] * matrix[1][1] + delta[2] * matrix[2][1],
359        delta[0] * matrix[0][2] + delta[1] * matrix[1][2] + delta[2] * matrix[2][2],
360    ];
361    Some(cart.iter().map(|value| value * value).sum::<f64>().sqrt())
362}
363
364/// The same atom pair with a length that agrees to a milliangstrom.
365///
366/// A non-P1 CIF stores the operation and image of the second site, and the
367/// P1 expansion may choose the neighbouring cell's representative for one
368/// end. The pair and length identify the contact; the perceived image is kept
369/// because it is the one the graph is drawn with.
370fn same_contact(bond: &Bond, explicit: &ExplicitBond, distance: Option<f64>) -> bool {
371    let same_pair = (bond.i == explicit.i && bond.j == explicit.j)
372        || (bond.i == explicit.j && bond.j == explicit.i);
373    same_pair && distance.is_some_and(|value| (bond.distance - value).abs() <= 1e-3)
374}