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