Skip to main content

chematic_perception/
lib.rs

1//! `chematic-perception` — molecular perception algorithms.
2//!
3//! Provides:
4//! - [`sssr`]: Smallest Set of Smallest Rings (SSSR) via Balducci-Pearlman algorithm.
5//! - [`aromaticity`]: Hückel aromaticity perception for kekulized molecules.
6
7#![forbid(unsafe_code)]
8
9pub mod aromaticity;
10pub mod cip_priority;
11pub mod pharmacophore;
12pub mod ring_family;
13pub mod sssr;
14pub mod stereo_validation;
15
16pub mod stereo2d;
17
18pub use aromaticity::{
19    AromaticityAlgorithm, AromaticityModel, RingAromaticity, all_ring_list, apply_aromaticity,
20    apply_aromaticity_ex, aromatic_ring_list, assign_aromaticity, assign_aromaticity_ex,
21    augmented_ring_set, count_aromatic_rings, ring_bonds_all_aromatic,
22};
23pub use chematic_core::{ValenceError, validate_valence};
24pub use pharmacophore::{Feature, FeatureType, detect_features, features_to_bitvec};
25pub use ring_family::{RingFamily, RingSystemKind, find_ring_families};
26pub use sssr::{RingSet, find_sssr};
27pub use stereo_validation::{
28    StereoCompleteness, StereoError, StereoErrorKind, stereo_completeness, validate_stereo,
29};
30pub use stereo2d::{
31    StereoAssignment2D, apply_stereo_from_2d, assign_ez_from_2d, assign_stereo_from_2d,
32    cip_ez_descriptor,
33};
34
35use chematic_core::{AtomIdx, Molecule};
36
37// ---------------------------------------------------------------------------
38// Ring system helper API
39// ---------------------------------------------------------------------------
40
41/// For each atom, return the list of SSSR ring indices that contain it.
42///
43/// The outer `Vec` is indexed by atom position; each inner `Vec` contains
44/// 0-based indices into the SSSR ring list (`find_sssr(mol).rings()`).
45/// Atoms that belong to no ring get an empty inner vec.
46pub fn ring_membership(mol: &Molecule) -> Vec<Vec<usize>> {
47    let ring_set = find_sssr(mol);
48    let rings = ring_set.rings();
49    let n = mol.atom_count();
50    let mut membership: Vec<Vec<usize>> = vec![Vec::new(); n];
51    for (ring_idx, ring) in rings.iter().enumerate() {
52        for &atom in ring {
53            membership[atom.0 as usize].push(ring_idx);
54        }
55    }
56    membership
57}
58
59/// Return the sizes of all SSSR rings that contain `atom_idx`.
60///
61/// Returns an empty vec for acyclic atoms.
62pub fn ring_sizes_for_atom(mol: &Molecule, atom_idx: usize) -> Vec<usize> {
63    let ring_set = find_sssr(mol);
64    let target = AtomIdx(atom_idx as u32);
65    ring_set
66        .rings()
67        .iter()
68        .filter(|ring| ring.contains(&target))
69        .map(|ring| ring.len())
70        .collect()
71}
72
73/// Return `true` if the molecule contains a fused ring system.
74///
75/// Two rings are fused when they share at least one bond (i.e. two adjacent
76/// atoms in both rings).  Spiro rings (sharing exactly one atom) return `false`.
77pub fn is_fused_ring_system(mol: &Molecule) -> bool {
78    let ring_set = find_sssr(mol);
79    let rings = ring_set.rings();
80    for i in 0..rings.len() {
81        for j in (i + 1)..rings.len() {
82            // Count shared atoms.
83            let shared = rings[i].iter().filter(|a| rings[j].contains(a)).count();
84            if shared >= 2 {
85                return true; // two rings share an edge → fused
86            }
87        }
88    }
89    false
90}
91
92/// Apply aromaticity to `mol` in-place (wrapper for [`apply_aromaticity`]).
93pub fn aromatize(mol: &mut Molecule) {
94    *mol = apply_aromaticity(mol);
95}
96
97/// Convert `mol` to Kekulé form in-place (wrapper for `kekulize` + `apply_kekule`).
98///
99/// Returns `Err` if kekulization fails (e.g. invalid aromatic system).
100pub fn kekulize_inplace(mol: &mut Molecule) -> Result<(), chematic_core::KekuleError> {
101    use chematic_core::{apply_kekule, kekulize};
102    let result = kekulize(mol)?;
103    *mol = apply_kekule(mol, &result);
104    Ok(())
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use chematic_smiles::parse;
111
112    fn mol(smiles: &str) -> Molecule {
113        parse(smiles).expect("valid SMILES")
114    }
115
116    #[test]
117    fn test_ring_membership_benzene() {
118        let m = mol("c1ccccc1");
119        let membership = ring_membership(&m);
120        assert_eq!(membership.len(), 6);
121        for atom_membership in membership.iter().take(6) {
122            assert_eq!(
123                atom_membership.len(),
124                1,
125                "each benzene atom in exactly 1 ring"
126            );
127            assert_eq!(atom_membership[0], 0, "all in ring index 0");
128        }
129    }
130
131    #[test]
132    fn test_ring_membership_naphthalene() {
133        let m = mol("c1ccc2ccccc2c1");
134        let membership = ring_membership(&m);
135        assert_eq!(membership.len(), 10);
136        // In naphthalene SSSR, some atoms appear in 2 rings depending on the ring decomposition
137        // Just verify all atoms are in at least 1 ring
138        for mem in &membership {
139            assert!(
140                !mem.is_empty(),
141                "all naphthalene atoms should be in at least 1 ring"
142            );
143        }
144    }
145
146    #[test]
147    fn test_ring_membership_acyclic() {
148        let m = mol("CC");
149        let membership = ring_membership(&m);
150        assert_eq!(membership.len(), 2);
151        for mem in &membership {
152            assert!(mem.is_empty(), "ethane atoms should not be in rings");
153        }
154    }
155
156    #[test]
157    fn test_ring_sizes_for_atom_benzene() {
158        let m = mol("c1ccccc1");
159        let sizes = ring_sizes_for_atom(&m, 0);
160        assert_eq!(sizes, vec![6]);
161    }
162
163    #[test]
164    fn test_ring_sizes_for_atom_naphthalene() {
165        let m = mol("c1ccc2ccccc2c1");
166        // Just verify naphthalene atoms are in rings of size 6
167        let sizes = ring_sizes_for_atom(&m, 0);
168        assert!(!sizes.is_empty());
169        assert!(sizes.contains(&6), "naphthalene has 6-membered rings");
170    }
171
172    #[test]
173    fn test_ring_sizes_for_atom_acyclic() {
174        let m = mol("CC");
175        let sizes = ring_sizes_for_atom(&m, 0);
176        assert!(sizes.is_empty());
177    }
178
179    #[test]
180    fn test_is_fused_ring_naphthalene() {
181        let m = mol("c1ccc2ccccc2c1");
182        assert!(is_fused_ring_system(&m), "naphthalene is fused");
183    }
184
185    #[test]
186    fn test_is_fused_ring_benzene() {
187        let m = mol("c1ccccc1");
188        assert!(
189            !is_fused_ring_system(&m),
190            "single benzene ring is not fused"
191        );
192    }
193
194    #[test]
195    fn test_is_fused_ring_spiro() {
196        // Spiro[4.4]nonane has two rings sharing only 1 atom
197        let m = mol("C1CCC2(C1)CCCC2");
198        assert!(
199            !is_fused_ring_system(&m),
200            "spiro compound shares only 1 atom, not fused"
201        );
202    }
203
204    #[test]
205    fn test_aromatize_benzene() {
206        let mut m = mol("c1ccccc1");
207        aromatize(&mut m);
208        for (_, atom) in m.atoms() {
209            assert!(atom.aromatic, "all benzene atoms should be aromatic");
210        }
211        for (_, bond) in m.bonds() {
212            assert_eq!(
213                bond.order,
214                chematic_core::BondOrder::Aromatic,
215                "all benzene bonds should be aromatic"
216            );
217        }
218    }
219
220    #[test]
221    fn test_kekulize_inplace_benzene() {
222        let mut m = mol("c1ccccc1");
223        kekulize_inplace(&mut m).expect("benzene should kekulize");
224        let mut single_count = 0;
225        let mut double_count = 0;
226        for (_, bond) in m.bonds() {
227            match bond.order {
228                chematic_core::BondOrder::Single => single_count += 1,
229                chematic_core::BondOrder::Double => double_count += 1,
230                _ => panic!("unexpected bond order after kekulization"),
231            }
232        }
233        assert_eq!(single_count, 3);
234        assert_eq!(double_count, 3);
235    }
236}