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