1use crate::materials::UnitCell;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PeriodicMolecule {
13 pub elements: Vec<u8>,
15 pub frac_coords: Vec<[f64; 3]>,
17 pub cart_coords: Vec<[f64; 3]>,
19 pub bonds: Vec<PeriodicBond>,
21 pub cell: UnitCell,
23 pub n_atoms: usize,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PeriodicBond {
30 pub atom_i: usize,
32 pub atom_j: usize,
34 pub order: String,
36 pub image: [i32; 3],
38 pub distance: f64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct HapticInteraction {
45 pub metal_index: usize,
47 pub metal_element: u8,
49 pub ring_atoms: Vec<usize>,
51 pub hapticity: usize,
53 pub centroid: [f64; 3],
55 pub metal_centroid_distance: f64,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct HapticityAnalysis {
62 pub interactions: Vec<HapticInteraction>,
64 pub is_metallocene: bool,
66 pub n_interactions: usize,
68}
69
70fn is_transition_metal(z: u8) -> bool {
72 matches!(z, 21..=30 | 39..=48 | 57..=80)
73}
74
75pub 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 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 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
154pub fn detect_hapticity(
159 elements: &[u8],
160 coords: &[[f64; 3]],
161 bonds: &[(usize, usize)],
162) -> HapticityAnalysis {
163 let n_atoms = elements.len();
164
165 let ring_atoms = find_ring_atoms(n_atoms, bonds);
167
168 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 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 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 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, ¢roid);
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 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
232fn 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 r_metal + r_ring + 0.8
238}
239
240fn 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 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
286fn 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]; 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 let mut elements = vec![26u8]; let mut coords = vec![[0.0, 0.0, 0.0f64]];
365 let mut bonds = Vec::new();
366
367 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); coords.push([x, y, 1.65]);
374 bonds.push((0, i + 1));
376 }
377 for i in 0..5 {
379 bonds.push((i + 1, (i + 1) % 5 + 1));
380 }
381
382 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}