Skip to main content

sci_form/
periodic.rs

1//! Periodic molecular graph support for 3D crystalline systems.
2//!
3//! Extends the molecular graph with periodic boundary conditions (PBC),
4//! enabling representation of infinite crystalline structures and
5//! metallocene/hapticity detection for organometallic complexes.
6
7use crate::materials::UnitCell;
8use serde::{Deserialize, Serialize};
9
10/// A periodic molecular graph: molecule + unit cell + image bonds.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PeriodicMolecule {
13    /// Atomic numbers in the asymmetric unit.
14    pub elements: Vec<u8>,
15    /// Fractional coordinates in the unit cell.
16    pub frac_coords: Vec<[f64; 3]>,
17    /// Cartesian coordinates (Å).
18    pub cart_coords: Vec<[f64; 3]>,
19    /// Bonds within the unit cell: (atom_i, atom_j, order, image_vector).
20    pub bonds: Vec<PeriodicBond>,
21    /// Unit cell definition.
22    pub cell: UnitCell,
23    /// Number of atoms in the asymmetric unit.
24    pub n_atoms: usize,
25}
26
27/// A bond that may cross periodic boundaries.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PeriodicBond {
30    /// Source atom index (in asymmetric unit).
31    pub atom_i: usize,
32    /// Target atom index (in asymmetric unit).
33    pub atom_j: usize,
34    /// Bond order.
35    pub order: String,
36    /// Image vector [na, nb, nc] — [0,0,0] for bonds within the cell.
37    pub image: [i32; 3],
38    /// Bond length in Å.
39    pub distance: f64,
40}
41
42/// Hapticity descriptor for metal-ring interactions.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct HapticInteraction {
45    /// Metal atom index.
46    pub metal_index: usize,
47    /// Metal element (atomic number).
48    pub metal_element: u8,
49    /// Ring atom indices bound to the metal.
50    pub ring_atoms: Vec<usize>,
51    /// Hapticity (η number): number of contiguous ring atoms coordinated.
52    pub hapticity: usize,
53    /// Centroid of the ring atoms (Cartesian Å).
54    pub centroid: [f64; 3],
55    /// Metal-centroid distance (Å).
56    pub metal_centroid_distance: f64,
57}
58
59/// Result of metallocene/hapticity detection.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct HapticityAnalysis {
62    /// Detected haptic interactions.
63    pub interactions: Vec<HapticInteraction>,
64    /// Whether the molecule is a metallocene (two η5-Cp rings).
65    pub is_metallocene: bool,
66    /// Number of haptic interactions found.
67    pub n_interactions: usize,
68}
69
70/// Common transition metals for organometallic detection.
71fn is_transition_metal(z: u8) -> bool {
72    matches!(z, 21..=30 | 39..=48 | 57..=80)
73}
74
75/// Build a periodic molecular graph from elements, fractional coordinates, and a unit cell.
76///
77/// Automatically detects bonds using distance criteria with periodic images.
78pub fn build_periodic_molecule(
79    elements: &[u8],
80    frac_coords: &[[f64; 3]],
81    cell: &UnitCell,
82    bond_tolerance: Option<f64>,
83) -> PeriodicMolecule {
84    let tol = bond_tolerance.unwrap_or(0.3);
85    let n_atoms = elements.len();
86
87    let cart_coords: Vec<[f64; 3]> = frac_coords.iter().map(|f| cell.frac_to_cart(*f)).collect();
88
89    let mut bonds = Vec::new();
90
91    // Check all atom pairs including periodic images (-1, 0, +1 in each direction)
92    for i in 0..n_atoms {
93        for j in i..n_atoms {
94            for na in -1i32..=1 {
95                for nb in -1i32..=1 {
96                    for nc in -1i32..=1 {
97                        if i == j && na == 0 && nb == 0 && nc == 0 {
98                            continue;
99                        }
100
101                        let fj_image = [
102                            frac_coords[j][0] + na as f64,
103                            frac_coords[j][1] + nb as f64,
104                            frac_coords[j][2] + nc as f64,
105                        ];
106                        let cj_image = cell.frac_to_cart(fj_image);
107                        let dist = distance_3d(&cart_coords[i], &cj_image);
108
109                        let r_sum = crate::graph::get_covalent_radius(elements[i])
110                            + crate::graph::get_covalent_radius(elements[j]);
111
112                        // Tighter tolerance for TM-TM pairs to avoid spurious bonds
113                        let pair_tol = if is_transition_metal(elements[i])
114                            && is_transition_metal(elements[j])
115                        {
116                            tol * 0.3
117                        } else {
118                            tol
119                        };
120
121                        if dist < r_sum + pair_tol && dist > 0.4 {
122                            let order = if dist < r_sum * 0.85 {
123                                "DOUBLE"
124                            } else if dist < r_sum * 0.75 {
125                                "TRIPLE"
126                            } else {
127                                "SINGLE"
128                            };
129
130                            bonds.push(PeriodicBond {
131                                atom_i: i,
132                                atom_j: j,
133                                order: order.to_string(),
134                                image: [na, nb, nc],
135                                distance: dist,
136                            });
137                        }
138                    }
139                }
140            }
141        }
142    }
143
144    PeriodicMolecule {
145        elements: elements.to_vec(),
146        frac_coords: frac_coords.to_vec(),
147        cart_coords,
148        bonds,
149        cell: cell.clone(),
150        n_atoms,
151    }
152}
153
154/// Detect metallocene and haptic (η) metal-ring interactions.
155///
156/// Scans for transition metals bonded to contiguous ring atoms
157/// and determines the hapticity (η1–η8).
158pub fn detect_hapticity(
159    elements: &[u8],
160    coords: &[[f64; 3]],
161    bonds: &[(usize, usize)],
162) -> HapticityAnalysis {
163    let n_atoms = elements.len();
164
165    // Find ring atoms using simple cycle detection
166    let ring_atoms = find_ring_atoms(n_atoms, bonds);
167
168    // Find transition metals
169    let metals: Vec<usize> = (0..n_atoms)
170        .filter(|&i| is_transition_metal(elements[i]))
171        .collect();
172
173    let mut interactions = Vec::new();
174
175    for &metal_idx in &metals {
176        // Find all ring atoms within bonding distance of this metal
177        let metal_pos = coords[metal_idx];
178        let mut coordinated_ring_atoms: Vec<usize> = Vec::new();
179
180        for &ring_atom in &ring_atoms {
181            // Skip other metals — they aren't part of haptic rings
182            if ring_atom == metal_idx || is_transition_metal(elements[ring_atom]) {
183                continue;
184            }
185            let dist = distance_3d(&metal_pos, &coords[ring_atom]);
186            let max_dist = metal_ring_cutoff(elements[metal_idx], elements[ring_atom]);
187            if dist < max_dist {
188                coordinated_ring_atoms.push(ring_atom);
189            }
190        }
191
192        if coordinated_ring_atoms.is_empty() {
193            continue;
194        }
195
196        // Group contiguous ring atoms into haptic sets
197        let haptic_groups = group_contiguous_ring_atoms(&coordinated_ring_atoms, bonds);
198
199        for group in haptic_groups {
200            if group.is_empty() {
201                continue;
202            }
203
204            let centroid = compute_centroid(&group, coords);
205            let metal_centroid_dist = distance_3d(&metal_pos, &centroid);
206
207            interactions.push(HapticInteraction {
208                metal_index: metal_idx,
209                metal_element: elements[metal_idx],
210                ring_atoms: group.clone(),
211                hapticity: group.len(),
212                centroid,
213                metal_centroid_distance: metal_centroid_dist,
214            });
215        }
216    }
217
218    // A metallocene has exactly two η5-Cp rings on one metal
219    let is_metallocene = metals.len() == 1
220        && interactions.len() == 2
221        && interactions.iter().all(|h| h.hapticity == 5);
222
223    let n_interactions = interactions.len();
224
225    HapticityAnalysis {
226        interactions,
227        is_metallocene,
228        n_interactions,
229    }
230}
231
232/// Maximum metal-ring atom distance for haptic coordination (Å).
233fn metal_ring_cutoff(metal_z: u8, ring_z: u8) -> f64 {
234    let r_metal = crate::graph::get_covalent_radius(metal_z);
235    let r_ring = crate::graph::get_covalent_radius(ring_z);
236    // Haptic bonds are typically 0.3–0.5 Å longer than σ bonds
237    r_metal + r_ring + 0.8
238}
239
240/// Find atoms that belong to rings via simple DFS cycle detection.
241fn find_ring_atoms(n_atoms: usize, bonds: &[(usize, usize)]) -> Vec<usize> {
242    let mut adj = vec![vec![]; n_atoms];
243    for &(a, b) in bonds {
244        adj[a].push(b);
245        adj[b].push(a);
246    }
247
248    let mut in_ring = vec![false; n_atoms];
249    let mut visited = vec![false; n_atoms];
250    let mut parent = vec![usize::MAX; n_atoms];
251
252    for start in 0..n_atoms {
253        if visited[start] {
254            continue;
255        }
256        let mut stack = vec![(start, 0usize)];
257        while let Some((node, nb_idx)) = stack.last_mut() {
258            let node = *node;
259            if !visited[node] {
260                visited[node] = true;
261            }
262            if *nb_idx < adj[node].len() {
263                let neighbor = adj[node][*nb_idx];
264                *nb_idx += 1;
265                if !visited[neighbor] {
266                    parent[neighbor] = node;
267                    stack.push((neighbor, 0));
268                } else if neighbor != parent[node] {
269                    // Found a cycle — mark atoms on the path
270                    in_ring[neighbor] = true;
271                    let mut cur = node;
272                    while cur != neighbor && cur != usize::MAX {
273                        in_ring[cur] = true;
274                        cur = parent[cur];
275                    }
276                }
277            } else {
278                stack.pop();
279            }
280        }
281    }
282
283    (0..n_atoms).filter(|&i| in_ring[i]).collect()
284}
285
286/// Group ring atoms into contiguous sets based on bonding connectivity.
287fn group_contiguous_ring_atoms(ring_atoms: &[usize], bonds: &[(usize, usize)]) -> Vec<Vec<usize>> {
288    if ring_atoms.is_empty() {
289        return vec![];
290    }
291
292    let atom_set: std::collections::HashSet<usize> = ring_atoms.iter().copied().collect();
293    let mut visited = std::collections::HashSet::new();
294    let mut groups = Vec::new();
295
296    for &start in ring_atoms {
297        if visited.contains(&start) {
298            continue;
299        }
300        let mut group = Vec::new();
301        let mut queue = std::collections::VecDeque::new();
302        queue.push_back(start);
303        visited.insert(start);
304
305        while let Some(node) = queue.pop_front() {
306            group.push(node);
307            for &(a, b) in bonds {
308                let neighbor = if a == node {
309                    b
310                } else if b == node {
311                    a
312                } else {
313                    continue;
314                };
315                if atom_set.contains(&neighbor) && !visited.contains(&neighbor) {
316                    visited.insert(neighbor);
317                    queue.push_back(neighbor);
318                }
319            }
320        }
321
322        groups.push(group);
323    }
324
325    groups
326}
327
328fn compute_centroid(atoms: &[usize], coords: &[[f64; 3]]) -> [f64; 3] {
329    let n = atoms.len() as f64;
330    let mut c = [0.0, 0.0, 0.0];
331    for &i in atoms {
332        c[0] += coords[i][0];
333        c[1] += coords[i][1];
334        c[2] += coords[i][2];
335    }
336    [c[0] / n, c[1] / n, c[2] / n]
337}
338
339fn distance_3d(a: &[f64; 3], b: &[f64; 3]) -> f64 {
340    let dx = a[0] - b[0];
341    let dy = a[1] - b[1];
342    let dz = a[2] - b[2];
343    (dx * dx + dy * dy + dz * dz).sqrt()
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::materials::UnitCell;
350
351    #[test]
352    fn test_periodic_molecule_cubic() {
353        let cell = UnitCell::cubic(5.0);
354        let elements = vec![26, 8]; // Fe, O
355        let frac = vec![[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]];
356        let pm = build_periodic_molecule(&elements, &frac, &cell, None);
357        assert_eq!(pm.n_atoms, 2);
358    }
359
360    #[test]
361    fn test_hapticity_detection() {
362        // Ferrocene-like: Fe at origin, two Cp rings
363        let mut elements = vec![26u8]; // Fe
364        let mut coords = vec![[0.0, 0.0, 0.0f64]];
365        let mut bonds = Vec::new();
366
367        // Top Cp ring (5 carbons)
368        for i in 0..5 {
369            let angle = 2.0 * std::f64::consts::PI * i as f64 / 5.0;
370            let x = 1.2 * angle.cos();
371            let y = 1.2 * angle.sin();
372            elements.push(6); // C
373            coords.push([x, y, 1.65]);
374            // Bond C to Fe
375            bonds.push((0, i + 1));
376        }
377        // Ring bonds for top Cp
378        for i in 0..5 {
379            bonds.push((i + 1, (i + 1) % 5 + 1));
380        }
381
382        // Bottom Cp ring
383        for i in 0..5 {
384            let angle = 2.0 * std::f64::consts::PI * i as f64 / 5.0;
385            let x = 1.2 * angle.cos();
386            let y = 1.2 * angle.sin();
387            elements.push(6);
388            coords.push([x, y, -1.65]);
389            bonds.push((0, i + 6));
390        }
391        for i in 0..5 {
392            bonds.push((i + 6, (i + 1) % 5 + 6));
393        }
394
395        let result = detect_hapticity(&elements, &coords, &bonds);
396        assert!(result.n_interactions >= 2);
397        assert!(result.interactions.iter().any(|h| h.hapticity == 5));
398    }
399}