Skip to main content

chematic_3d/
etkdg_knowledge.rs

1//! ETKDG Torsion Knowledge Base — experimental torsion angle preferences from CSD.
2
3use chematic_core::{AtomIdx, Molecule};
4use chematic_smarts::{find_matches, parse_smarts};
5use std::collections::HashMap;
6
7/// Torsion angle preference with penalty scoring.
8#[derive(Clone, Debug)]
9pub struct TorsionPreference {
10    /// Preferred dihedral angle in degrees.
11    pub angle_deg: f64,
12    /// Penalty (in kcal/mol equivalent) for deviation from preference.
13    pub penalty_per_degree: f64,
14}
15
16/// Atom type for torsion matching based on hybridization and neighbors.
17///
18/// Classifies atoms by element and hybridization state (sp, sp2, sp3, aromatic).
19/// Useful for matching chemical patterns and determining properties.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
21pub enum AtomType {
22    /// sp3 carbon
23    CSp3,
24    /// sp2 carbon (alkene)
25    CSp2Alkene,
26    /// sp2 carbon (aromatic)
27    CAromatic,
28    /// sp2 carbon (carbonyl/carboxylic)
29    CCarbonyl,
30    /// sp nitrogen (nitrile/isocyanate)
31    NSp,
32    /// sp2 nitrogen (amide/imine)
33    NSp2,
34    /// sp2 nitrogen (aromatic)
35    NAromatic,
36    /// sp3 nitrogen (amine)
37    NSp3,
38    /// sp2 oxygen (carbonyl)
39    OSp2,
40    /// sp3 oxygen (ether/alcohol)
41    OSp3,
42    /// aromatic oxygen (furan, oxazole, isoxazole)
43    OAromatic,
44    /// sulfur (thioether, sulfoxide, sulfone)
45    S,
46    /// aromatic sulfur (thiophene, thiazole, thiadiazole)
47    SAromatic,
48    /// phosphorus
49    P,
50    /// hydrogen
51    H,
52    /// halogen
53    Halogen,
54    /// other/unknown
55    Other,
56}
57
58/// Count bonds of a given order incident to an atom.
59fn count_incident_bonds(mol: &Molecule, idx: AtomIdx, order: chematic_core::BondOrder) -> usize {
60    mol.bonds()
61        .filter(|(_, bond)| (bond.atom1 == idx || bond.atom2 == idx) && bond.order == order)
62        .count()
63}
64
65/// Classify an atom's type based on its chemical environment.
66///
67/// Returns an [`AtomType`] indicating the atom's hybridization state and element.
68/// Useful for pattern matching, property prediction, and chemical classification.
69pub fn classify_atom_type(mol: &Molecule, idx: AtomIdx) -> AtomType {
70    let atom = mol.atom(idx);
71    let an = atom.element.atomic_number();
72
73    match an {
74        1 => AtomType::H,
75        6 => {
76            // Carbon: determine sp3, sp2, sp hybridization
77            let double_bonds = count_incident_bonds(mol, idx, chematic_core::BondOrder::Double);
78
79            if atom.aromatic {
80                AtomType::CAromatic
81            } else if double_bonds > 0 {
82                // Check if it's a carbonyl carbon
83                let has_o_neighbor = mol.neighbors(idx).any(|(n_idx, _)| {
84                    mol.atom(n_idx).element.atomic_number() == 8
85                        && mol
86                            .bond_between(idx, n_idx)
87                            .map(|(_, b)| b.order == chematic_core::BondOrder::Double)
88                            .unwrap_or(false)
89                });
90                if has_o_neighbor {
91                    AtomType::CCarbonyl
92                } else {
93                    AtomType::CSp2Alkene
94                }
95            } else {
96                AtomType::CSp3
97            }
98        }
99        7 => {
100            // Nitrogen: sp, sp2, sp3
101            if atom.aromatic {
102                AtomType::NAromatic
103            } else {
104                let triple_bonds = count_incident_bonds(mol, idx, chematic_core::BondOrder::Triple);
105                let neighbors = mol.neighbors(idx).count();
106
107                if triple_bonds > 0 {
108                    AtomType::NSp
109                } else if neighbors <= 2 {
110                    AtomType::NSp2
111                } else {
112                    AtomType::NSp3
113                }
114            }
115        }
116        8 => {
117            if atom.aromatic {
118                AtomType::OAromatic // furan, oxazole, isoxazole ring oxygen
119            } else {
120                let has_double_bond = mol.bonds().any(|(_, bond)| {
121                    (bond.atom1 == idx || bond.atom2 == idx)
122                        && bond.order == chematic_core::BondOrder::Double
123                });
124                if has_double_bond {
125                    AtomType::OSp2 // Carbonyl oxygen (C=O)
126                } else {
127                    AtomType::OSp3 // Alcohol or ether oxygen (C-O)
128                }
129            }
130        }
131        16 => {
132            if atom.aromatic {
133                AtomType::SAromatic // thiophene, thiazole, thiadiazole ring sulfur
134            } else {
135                AtomType::S
136            }
137        }
138        15 => AtomType::P,
139        9 | 17 | 35 | 53 => AtomType::Halogen,
140        _ => AtomType::Other,
141    }
142}
143
144/// Get torsion preference for an A-B-C-D dihedral based on atom types.
145///
146/// Returns None if no specific preference is known (use general default).
147pub fn get_torsion_preference(
148    mol: &Molecule,
149    a_idx: AtomIdx,
150    b_idx: AtomIdx,
151    c_idx: AtomIdx,
152    d_idx: AtomIdx,
153) -> Option<TorsionPreference> {
154    let a_type = classify_atom_type(mol, a_idx);
155    let b_type = classify_atom_type(mol, b_idx);
156    let c_type = classify_atom_type(mol, c_idx);
157    let d_type = classify_atom_type(mol, d_idx);
158
159    // Alkane C-C-C-C: strongly prefer 180° (staggered, anti)
160    if b_type == AtomType::CSp3
161        && c_type == AtomType::CSp3
162        && (a_type == AtomType::CSp3 || a_type == AtomType::H)
163        && (d_type == AtomType::CSp3 || d_type == AtomType::H)
164    {
165        return Some(TorsionPreference {
166            angle_deg: 180.0,
167            penalty_per_degree: 0.15, // ~3 kcal/mol for 20° deviation
168        });
169    }
170
171    // Aromatic-aliphatic C-Ar-C-C: prefer 180° or 0°
172    if (a_type == AtomType::CSp3 || a_type == AtomType::H)
173        && b_type == AtomType::CAromatic
174        && c_type == AtomType::CSp3
175        && (d_type == AtomType::CSp3 || d_type == AtomType::H)
176    {
177        return Some(TorsionPreference {
178            angle_deg: 180.0,
179            penalty_per_degree: 0.08, // Softer constraint
180        });
181    }
182
183    // Amide C-N(C=O)-C-X: restricted rotation, prefer 0° (cis to carbonyl) or 180° (trans)
184    // For simplicity, prefer trans (180°) as it's more common
185    if b_type == AtomType::NSp2 && c_type == AtomType::CCarbonyl {
186        return Some(TorsionPreference {
187            angle_deg: 180.0,
188            penalty_per_degree: 0.20, // Moderate restriction
189        });
190    }
191
192    // Ester O-C(=O)-O-C: prefer 0° or 180° depending on configuration
193    if b_type == AtomType::CCarbonyl && c_type == AtomType::OSp3 {
194        return Some(TorsionPreference {
195            angle_deg: 0.0,
196            penalty_per_degree: 0.10,
197        });
198    }
199
200    // Aromatic-aromatic rotations: prefer ~45° (biphenyl-like twist)
201    if b_type == AtomType::CAromatic && c_type == AtomType::CAromatic {
202        return Some(TorsionPreference {
203            angle_deg: 45.0,
204            penalty_per_degree: 0.03, // Very soft — flat potential
205        });
206    }
207
208    // Enamine C=C-N: prefer 180° (trans, conjugation favored)
209    if b_type == AtomType::CSp2Alkene && c_type == AtomType::NSp2 {
210        return Some(TorsionPreference {
211            angle_deg: 180.0,
212            penalty_per_degree: 0.12,
213        });
214    }
215    if b_type == AtomType::NSp2 && c_type == AtomType::CSp2Alkene {
216        return Some(TorsionPreference {
217            angle_deg: 180.0,
218            penalty_per_degree: 0.12,
219        });
220    }
221
222    // Vinyl halide C=C-X: prefer 0° (cis, steric minimization)
223    if (b_type == AtomType::CSp2Alkene || b_type == AtomType::CAromatic)
224        && c_type == AtomType::Halogen
225    {
226        return Some(TorsionPreference {
227            angle_deg: 0.0,
228            penalty_per_degree: 0.08,
229        });
230    }
231
232    // Acrylic / chalcone: C=C-C(=O): s-trans preferred (~180°)
233    if b_type == AtomType::CSp2Alkene && c_type == AtomType::CCarbonyl {
234        return Some(TorsionPreference {
235            angle_deg: 180.0,
236            penalty_per_degree: 0.10,
237        });
238    }
239
240    // Phenyl ketone Ar-C(=O): coplanar (0°)
241    if b_type == AtomType::CAromatic && c_type == AtomType::CCarbonyl {
242        return Some(TorsionPreference {
243            angle_deg: 0.0,
244            penalty_per_degree: 0.08,
245        });
246    }
247    if b_type == AtomType::CCarbonyl && c_type == AtomType::CAromatic {
248        return Some(TorsionPreference {
249            angle_deg: 0.0,
250            penalty_per_degree: 0.08,
251        });
252    }
253
254    // Thioester S-C(=O): prefer 180° (trans)
255    if b_type == AtomType::S && c_type == AtomType::CCarbonyl {
256        return Some(TorsionPreference {
257            angle_deg: 180.0,
258            penalty_per_degree: 0.10,
259        });
260    }
261
262    // Carbamate / sulfonamide N-C(=O)-O or N-S(=O)(=O): prefer 180°
263    if b_type == AtomType::NSp3 && c_type == AtomType::CCarbonyl {
264        return Some(TorsionPreference {
265            angle_deg: 180.0,
266            penalty_per_degree: 0.10,
267        });
268    }
269
270    // Sulfoxide C-S(=O)-C: pyramidal sulfur, prefer ~90°
271    if b_type == AtomType::S && c_type == AtomType::CSp3 {
272        return Some(TorsionPreference {
273            angle_deg: 90.0,
274            penalty_per_degree: 0.06,
275        });
276    }
277
278    // Disulfide C-S-S-C: ~90° dihedral (gauche)
279    if b_type == AtomType::S && c_type == AtomType::S {
280        return Some(TorsionPreference {
281            angle_deg: 90.0,
282            penalty_per_degree: 0.12,
283        });
284    }
285
286    // Alcohol C-C-O-H / ether C-C-O-C: gauche/anti mixture, use 180° as default
287    if (b_type == AtomType::CSp3 || b_type == AtomType::CSp2Alkene) && c_type == AtomType::OSp3 {
288        return Some(TorsionPreference {
289            angle_deg: 180.0,
290            penalty_per_degree: 0.07,
291        });
292    }
293
294    // Amine C-C-N-C (secondary/tertiary): prefer 180°
295    // NSp2 covers N with 2 explicit bonds (secondary amine in SMILES);
296    // NSp3 covers N with 3+ explicit bonds (tertiary amine).
297    if b_type == AtomType::CSp3 && (c_type == AtomType::NSp3 || c_type == AtomType::NSp2) {
298        return Some(TorsionPreference {
299            angle_deg: 180.0,
300            penalty_per_degree: 0.08,
301        });
302    }
303
304    // Nitrile terminus X-C-C≡N: prefer 180° (linear)
305    if b_type == AtomType::NSp || d_type == AtomType::NSp {
306        return Some(TorsionPreference {
307            angle_deg: 180.0,
308            penalty_per_degree: 0.20,
309        });
310    }
311
312    // Phosphorus P-C-C-X: use 180° default
313    if b_type == AtomType::P || c_type == AtomType::P {
314        return Some(TorsionPreference {
315            angle_deg: 180.0,
316            penalty_per_degree: 0.06,
317        });
318    }
319
320    // Urea N-C(=O)-N: planar, prefer 0° (both N lone pairs overlap with C=O)
321    if b_type == AtomType::NSp2
322        && c_type == AtomType::NSp2
323        && mol
324            .neighbors(b_idx)
325            .any(|(n, _)| classify_atom_type(mol, n) == AtomType::CCarbonyl)
326    {
327        return Some(TorsionPreference {
328            angle_deg: 0.0,
329            penalty_per_degree: 0.18,
330        });
331    }
332
333    // Sulfonamide N-S(=O)(=O): prefer 90° (tetrahedral S, gauche N)
334    if (b_type == AtomType::NSp3 || b_type == AtomType::NSp2) && c_type == AtomType::S {
335        return Some(TorsionPreference {
336            angle_deg: 90.0,
337            penalty_per_degree: 0.08,
338        });
339    }
340    if b_type == AtomType::S && (c_type == AtomType::NSp3 || c_type == AtomType::NSp2) {
341        return Some(TorsionPreference {
342            angle_deg: 90.0,
343            penalty_per_degree: 0.08,
344        });
345    }
346
347    // Aryl ether Ar-O-C: prefer 0° (oxygen lone pair conjugation with ring)
348    if b_type == AtomType::CAromatic && c_type == AtomType::OSp3 {
349        return Some(TorsionPreference {
350            angle_deg: 0.0,
351            penalty_per_degree: 0.09,
352        });
353    }
354    if b_type == AtomType::OSp3 && c_type == AtomType::CAromatic {
355        return Some(TorsionPreference {
356            angle_deg: 0.0,
357            penalty_per_degree: 0.09,
358        });
359    }
360
361    // Fluoroalkane C-C-C-F: prefer anti (180°) to minimise F dipole interactions
362    if (b_type == AtomType::CSp3 || b_type == AtomType::CSp2Alkene)
363        && c_type == AtomType::Halogen
364        && (d_type == AtomType::H || d_type == AtomType::Halogen)
365    {
366        return Some(TorsionPreference {
367            angle_deg: 180.0,
368            penalty_per_degree: 0.07,
369        });
370    }
371
372    // Nitro group C-N(=O)=O: coplanar with aromatic ring if attached to Ar
373    if b_type == AtomType::CAromatic && c_type == AtomType::NSp2 {
374        return Some(TorsionPreference {
375            angle_deg: 0.0,
376            penalty_per_degree: 0.12,
377        });
378    }
379
380    // Hydrazone/oxime C=N-N or C=N-O: prefer 0° (E/Z isomerism; E is more stable)
381    if (b_type == AtomType::CSp2Alkene || b_type == AtomType::CCarbonyl)
382        && (c_type == AtomType::NSp2 || c_type == AtomType::OSp3)
383    {
384        return Some(TorsionPreference {
385            angle_deg: 0.0,
386            penalty_per_degree: 0.11,
387        });
388    }
389
390    // Imide N-C(=O)-C(=O): prefer 0° (both carbonyls on same side for conjugation)
391    if b_type == AtomType::NSp2
392        && c_type == AtomType::CCarbonyl
393        && mol
394            .neighbors(c_idx)
395            .any(|(n, _)| classify_atom_type(mol, n) == AtomType::CCarbonyl)
396    {
397        return Some(TorsionPreference {
398            angle_deg: 0.0,
399            penalty_per_degree: 0.15,
400        });
401    }
402
403    // Benzyl (Ar-C-X): prefer 90° perpendicular to ring plane
404    if b_type == AtomType::CAromatic && c_type == AtomType::CSp3 {
405        return Some(TorsionPreference {
406            angle_deg: 90.0,
407            penalty_per_degree: 0.04,
408        });
409    }
410
411    // Allylic C=C-C-X: prefer 0° (s-cis/s-trans mixture; use 0° as default)
412    if b_type == AtomType::CSp2Alkene && c_type == AtomType::CSp3 {
413        return Some(TorsionPreference {
414            angle_deg: 0.0,
415            penalty_per_degree: 0.05,
416        });
417    }
418
419    // ── Heteroaromatic patterns ──────────────────────────────────────────────
420
421    // Heteroaromatic biaryl: NAromatic–CAromatic or CAromatic–NAromatic
422    // (e.g. phenyl-pyridine, bipyridine, pyrimidine-phenyl).
423    // ~45° twist like biphenyl; very soft potential.
424    // NAromatic–NAromatic is intentionally excluded: adjacent aromatic nitrogens
425    // occur as intra-ring bonds (pyrimidine, pyridazine) whose torsion is already
426    // constrained by ring closure; applying a 45° soft preference there conflicts
427    // with satisfy_constraints and can distort ring geometry.
428    if (b_type == AtomType::NAromatic && c_type == AtomType::CAromatic)
429        || (b_type == AtomType::CAromatic && c_type == AtomType::NAromatic)
430    {
431        return Some(TorsionPreference {
432            angle_deg: 45.0,
433            penalty_per_degree: 0.03,
434        });
435    }
436
437    // N-alkyl heteroaromatic (N-methyl pyridine, N-methyl imidazole, etc.).
438    // The N–C(sp3) bond prefers anti (180°) to minimise lone-pair / σ* repulsion.
439    if (b_type == AtomType::NAromatic && c_type == AtomType::CSp3)
440        || (b_type == AtomType::CSp3 && c_type == AtomType::NAromatic)
441    {
442        return Some(TorsionPreference {
443            angle_deg: 180.0,
444            penalty_per_degree: 0.09,
445        });
446    }
447
448    // Heteroaromatic N adjacent to carbonyl (prodrug, lactam-like contexts).
449    // Prefer planar (0°) for lone-pair conjugation.
450    if (b_type == AtomType::NAromatic && c_type == AtomType::CCarbonyl)
451        || (b_type == AtomType::CCarbonyl && c_type == AtomType::NAromatic)
452    {
453        return Some(TorsionPreference {
454            angle_deg: 0.0,
455            penalty_per_degree: 0.10,
456        });
457    }
458
459    // Heteroaromatic N to sp2 alkene (vinylogous conjugation).
460    if (b_type == AtomType::NAromatic && c_type == AtomType::CSp2Alkene)
461        || (b_type == AtomType::CSp2Alkene && c_type == AtomType::NAromatic)
462    {
463        return Some(TorsionPreference {
464            angle_deg: 0.0,
465            penalty_per_degree: 0.08,
466        });
467    }
468
469    // Thioaryl / aryl thioether: S–CAromatic or CAromatic–S.
470    // Diaryl sulfide and aryl thioether prefer ~90° (sulphur p-lone-pair
471    // perpendicular to ring π system).
472    if (b_type == AtomType::S && c_type == AtomType::CAromatic)
473        || (b_type == AtomType::CAromatic && c_type == AtomType::S)
474    {
475        return Some(TorsionPreference {
476            angle_deg: 90.0,
477            penalty_per_degree: 0.06,
478        });
479    }
480
481    // OSp3 as B in O–C(sp3) torsion (e.g. C–O–C–C ether chain, reverse of the
482    // CSp3–OSp3 rule).  Prefer anti (180°).
483    if b_type == AtomType::OSp3 && c_type == AtomType::CSp3 {
484        return Some(TorsionPreference {
485            angle_deg: 180.0,
486            penalty_per_degree: 0.07,
487        });
488    }
489
490    // Aryl amine Ar–NR2 (aniline-like, single bond to sp3 N): prefer planar (0°)
491    // for lone-pair conjugation with ring, slightly softer than aryl amide.
492    // Both traversal directions are covered so the rule fires regardless of
493    // which end of the Ar–N bond is atom B vs C.
494    if (b_type == AtomType::CAromatic && c_type == AtomType::NSp3)
495        || (b_type == AtomType::NSp3 && c_type == AtomType::CAromatic)
496    {
497        return Some(TorsionPreference {
498            angle_deg: 0.0,
499            penalty_per_degree: 0.07,
500        });
501    }
502
503    // ── 5-membered aromatic heterocycle patterns ─────────────────────────────
504
505    // Furanyl / oxazolyl biaryl: OAromatic–CAromatic inter-ring bond.
506    // Oxygen lone pair conjugates strongly with the adjacent ring π system;
507    // planar (0°) is preferred unlike the ~45° biphenyl twist.
508    if (b_type == AtomType::OAromatic && c_type == AtomType::CAromatic)
509        || (b_type == AtomType::CAromatic && c_type == AtomType::OAromatic)
510    {
511        return Some(TorsionPreference {
512            angle_deg: 0.0,
513            penalty_per_degree: 0.05,
514        });
515    }
516
517    // Thienyl / thiazolyl biaryl: SAromatic–CAromatic inter-ring.
518    // Sulfur d-orbital participation gives a flatter potential than O;
519    // ~45° is a reasonable CSD consensus (like biphenyl but slightly softer).
520    if (b_type == AtomType::SAromatic && c_type == AtomType::CAromatic)
521        || (b_type == AtomType::CAromatic && c_type == AtomType::SAromatic)
522    {
523        return Some(TorsionPreference {
524            angle_deg: 45.0,
525            penalty_per_degree: 0.04,
526        });
527    }
528
529    // Furanyl methyl / furanyl-sp3: OAromatic–CSp3 — prefer anti (180°).
530    if (b_type == AtomType::OAromatic && c_type == AtomType::CSp3)
531        || (b_type == AtomType::CSp3 && c_type == AtomType::OAromatic)
532    {
533        return Some(TorsionPreference {
534            angle_deg: 180.0,
535            penalty_per_degree: 0.06,
536        });
537    }
538
539    // Thienyl methyl / thienyl-sp3: SAromatic–CSp3 — prefer anti (180°).
540    if (b_type == AtomType::SAromatic && c_type == AtomType::CSp3)
541        || (b_type == AtomType::CSp3 && c_type == AtomType::SAromatic)
542    {
543        return Some(TorsionPreference {
544            angle_deg: 180.0,
545            penalty_per_degree: 0.06,
546        });
547    }
548
549    // Furanyl / thienyl adjacent to carbonyl — planar conjugation (0°).
550    if (b_type == AtomType::OAromatic && c_type == AtomType::CCarbonyl)
551        || (b_type == AtomType::CCarbonyl && c_type == AtomType::OAromatic)
552        || (b_type == AtomType::SAromatic && c_type == AtomType::CCarbonyl)
553        || (b_type == AtomType::CCarbonyl && c_type == AtomType::SAromatic)
554    {
555        return Some(TorsionPreference {
556            angle_deg: 0.0,
557            penalty_per_degree: 0.10,
558        });
559    }
560
561    // ── Saturated N-heterocycle patterns ─────────────────────────────────────
562
563    // Morpholine N–C–C–O: gauche preference (~60°) from chair conformation.
564    // Secondary amines in rings are classified as NSp2 (2 explicit heavy neighbors)
565    // even though they are sp3; accept both NSp2 and NSp3 for the amine endpoint.
566    let is_sat_n = |t: AtomType| t == AtomType::NSp2 || t == AtomType::NSp3;
567    if b_type == AtomType::CSp3
568        && c_type == AtomType::CSp3
569        && ((is_sat_n(a_type) && d_type == AtomType::OSp3)
570            || (a_type == AtomType::OSp3 && is_sat_n(d_type)))
571    {
572        return Some(TorsionPreference {
573            angle_deg: 60.0,
574            penalty_per_degree: 0.10,
575        });
576    }
577
578    // Piperazine / diamine N–C–C–N: gauche preference (~60°) from chair.
579    if b_type == AtomType::CSp3 && c_type == AtomType::CSp3 && is_sat_n(a_type) && is_sat_n(d_type)
580    {
581        return Some(TorsionPreference {
582            angle_deg: 60.0,
583            penalty_per_degree: 0.10,
584        });
585    }
586
587    // ── Styrene / aryl-vinyl conjugation ────────────────────────────────────────
588
589    // Aryl-vinyl bond (styrene-like): extended π-conjugation → coplanar (0°).
590    // Applies to Ar-C=C and Ar-C=C reverse traversal.
591    if b_type == AtomType::CAromatic && c_type == AtomType::CSp2Alkene {
592        return Some(TorsionPreference {
593            angle_deg: 0.0,
594            penalty_per_degree: 0.07,
595        });
596    }
597    if b_type == AtomType::CSp2Alkene && c_type == AtomType::CAromatic {
598        return Some(TorsionPreference {
599            angle_deg: 0.0,
600            penalty_per_degree: 0.07,
601        });
602    }
603
604    // ── Vinyl thioether C=C-S: S lone pair conjugates with alkene π system ────
605
606    if b_type == AtomType::CSp2Alkene && c_type == AtomType::S {
607        return Some(TorsionPreference {
608            angle_deg: 0.0,
609            penalty_per_degree: 0.07,
610        });
611    }
612    if b_type == AtomType::S && c_type == AtomType::CSp2Alkene {
613        return Some(TorsionPreference {
614            angle_deg: 0.0,
615            penalty_per_degree: 0.07,
616        });
617    }
618
619    // ── Allylic amine C=C-N (sp3): N lone pair partial conjugation with alkene ─
620
621    if b_type == AtomType::CSp2Alkene && c_type == AtomType::NSp3 {
622        return Some(TorsionPreference {
623            angle_deg: 0.0,
624            penalty_per_degree: 0.06,
625        });
626    }
627    if b_type == AtomType::NSp3 && c_type == AtomType::CSp2Alkene {
628        return Some(TorsionPreference {
629            angle_deg: 0.0,
630            penalty_per_degree: 0.06,
631        });
632    }
633
634    // ── Ketone/aldehyde C(sp3)-C(=O): H eclipses C=O in preferred conformation ─
635    // Barrier is low; the small penalty still nudges DG toward better geometry.
636
637    if b_type == AtomType::CSp3 && c_type == AtomType::CCarbonyl {
638        return Some(TorsionPreference {
639            angle_deg: 0.0,
640            penalty_per_degree: 0.04,
641        });
642    }
643    if b_type == AtomType::CCarbonyl && c_type == AtomType::CSp3 {
644        return Some(TorsionPreference {
645            angle_deg: 0.0,
646            penalty_per_degree: 0.04,
647        });
648    }
649
650    // ── Heteroaromatic N to thioether/thioaryl (NAr-S) ───────────────────────
651    // S lone pair gauche to aromatic N; ~90° from tetrahedral S geometry.
652
653    if b_type == AtomType::NAromatic && c_type == AtomType::S {
654        return Some(TorsionPreference {
655            angle_deg: 90.0,
656            penalty_per_degree: 0.06,
657        });
658    }
659    if b_type == AtomType::S && c_type == AtomType::NAromatic {
660        return Some(TorsionPreference {
661            angle_deg: 90.0,
662            penalty_per_degree: 0.06,
663        });
664    }
665
666    // ── Heteroaromatic N to ether oxygen (NAr-O) ─────────────────────────────
667    // O lone pair conjugates with N lone pair through the linking C;
668    // coplanar (0°) minimises lone-pair/lone-pair repulsion via σ*-donation.
669
670    if b_type == AtomType::NAromatic && c_type == AtomType::OSp3 {
671        return Some(TorsionPreference {
672            angle_deg: 0.0,
673            penalty_per_degree: 0.08,
674        });
675    }
676    if b_type == AtomType::OSp3 && c_type == AtomType::NAromatic {
677        return Some(TorsionPreference {
678            angle_deg: 0.0,
679            penalty_per_degree: 0.08,
680        });
681    }
682
683    // ── 5-membered aromatic heterocycle (S or O) to N-heteroaryl ─────────────
684    // Thienyl-pyridine, furanyl-pyridine biaryl bonds: ~45° (like biphenyl).
685
686    if (b_type == AtomType::SAromatic && c_type == AtomType::NAromatic)
687        || (b_type == AtomType::NAromatic && c_type == AtomType::SAromatic)
688    {
689        return Some(TorsionPreference {
690            angle_deg: 45.0,
691            penalty_per_degree: 0.03,
692        });
693    }
694
695    if (b_type == AtomType::OAromatic && c_type == AtomType::NAromatic)
696        || (b_type == AtomType::NAromatic && c_type == AtomType::OAromatic)
697    {
698        return Some(TorsionPreference {
699            angle_deg: 45.0,
700            penalty_per_degree: 0.04,
701        });
702    }
703
704    // ── Aromatic carbonyl to sp3 carbon ──────────────────────────────────────
705    // Ar-C(=O)-CR3: the carbonyl-to-alkyl bond; prefer 0° (carbonyl O in plane
706    // with the aryl ring; alkyl group anti to O).
707
708    if b_type == AtomType::CCarbonyl && c_type == AtomType::CSp2Alkene {
709        return Some(TorsionPreference {
710            angle_deg: 0.0,
711            penalty_per_degree: 0.06,
712        });
713    }
714
715    // ── Sp-hybridised nitrile / alkyne terminus ───────────────────────────────
716    // X-C≡C-Y and X-C≡N: sp atoms are linear; penalise deviation from 180°.
717
718    if b_type == AtomType::CSp3 && c_type == AtomType::NSp {
719        return Some(TorsionPreference {
720            angle_deg: 180.0,
721            penalty_per_degree: 0.15,
722        });
723    }
724
725    // ── Amide/carbamate reverse: CCarbonyl-N traversal ───────────────────────
726    // The A-B-C-D enumeration also visits bonds in reverse; ensure the
727    // CCarbonyl→N direction returns the same preference as the N→CCarbonyl rules.
728
729    if b_type == AtomType::CCarbonyl && c_type == AtomType::NSp2 {
730        return Some(TorsionPreference {
731            angle_deg: 180.0,
732            penalty_per_degree: 0.20,
733        });
734    }
735    if b_type == AtomType::CCarbonyl && c_type == AtomType::NSp3 {
736        return Some(TorsionPreference {
737            angle_deg: 180.0,
738            penalty_per_degree: 0.10,
739        });
740    }
741
742    // ── Isocyanate / carbodiimide N=C=O: linear sp centre ────────────────────
743
744    if b_type == AtomType::NSp && c_type == AtomType::CCarbonyl {
745        return Some(TorsionPreference {
746            angle_deg: 180.0,
747            penalty_per_degree: 0.18,
748        });
749    }
750    if b_type == AtomType::CCarbonyl && c_type == AtomType::NSp {
751        return Some(TorsionPreference {
752            angle_deg: 180.0,
753            penalty_per_degree: 0.18,
754        });
755    }
756
757    // ── Ar-N=C=O (aryl isocyanate) / Ar-N=S: prefer coplanar (0°) ───────────
758
759    if b_type == AtomType::CAromatic && c_type == AtomType::NSp {
760        return Some(TorsionPreference {
761            angle_deg: 0.0,
762            penalty_per_degree: 0.10,
763        });
764    }
765    if b_type == AtomType::NSp && c_type == AtomType::CAromatic {
766        return Some(TorsionPreference {
767            angle_deg: 0.0,
768            penalty_per_degree: 0.10,
769        });
770    }
771
772    // ── Thioamide: NSp2-C(=S) — prefer trans (180°) like regular amide ───────
773    // C(=S) is classified CSp2Alkene (double bond to S, not O).
774    // The rule fires for N-C(=S) when the C is NOT a CCarbonyl.
775
776    if b_type == AtomType::NSp2 && c_type == AtomType::CSp2Alkene {
777        // Check if C has a S=C double bond (thioamide context)
778        let has_thio = mol.neighbors(c_idx).any(|(n, _)| {
779            mol.atom(n).element.atomic_number() == 16
780                && mol
781                    .bond_between(c_idx, n)
782                    .map(|(_, b)| b.order == chematic_core::BondOrder::Double)
783                    .unwrap_or(false)
784        });
785        if has_thio {
786            return Some(TorsionPreference {
787                angle_deg: 180.0,
788                penalty_per_degree: 0.15,
789            });
790        }
791    }
792
793    // ── Anomeric / vicinal-O gauche effect ────────────────────────────────────
794    // O-C-C-O chain (1,2-diol, glycol ether): gauche (~60°) is preferred due to
795    // anomeric / electrostatic stabilisation.  Weaker than ring-based gauche rules.
796
797    if b_type == AtomType::CSp3
798        && c_type == AtomType::CSp3
799        && (a_type == AtomType::OSp3 || a_type == AtomType::OAromatic)
800        && (d_type == AtomType::OSp3 || d_type == AtomType::OAromatic)
801    {
802        return Some(TorsionPreference {
803            angle_deg: 60.0,
804            penalty_per_degree: 0.08,
805        });
806    }
807
808    // ── OSp3 to CCarbonyl (reverse ester direction) ──────────────────────────
809    // O-C(=O) ester oxygen facing the other side: prefer 0° for conjugation.
810
811    if b_type == AtomType::OSp3 && c_type == AtomType::CCarbonyl {
812        return Some(TorsionPreference {
813            angle_deg: 0.0,
814            penalty_per_degree: 0.10,
815        });
816    }
817
818    // ── Conjugated diene C=C-C=C: prefer s-trans (180°) ─────────────────────
819    // Open-chain 1,3-dienes exist predominantly in the s-trans conformation
820    // (~95%).  Cyclic dienes are constrained; apply only when both ends are
821    // CSp2Alkene (not aromatic).
822
823    if b_type == AtomType::CSp2Alkene && c_type == AtomType::CSp2Alkene {
824        return Some(TorsionPreference {
825            angle_deg: 180.0,
826            penalty_per_degree: 0.08,
827        });
828    }
829
830    // ── 1,2-Dicarbonyl C(=O)-C(=O): syn-periplanar (0°) for lone-pair overlap ─
831
832    if b_type == AtomType::CCarbonyl && c_type == AtomType::CCarbonyl {
833        return Some(TorsionPreference {
834            angle_deg: 0.0,
835            penalty_per_degree: 0.10,
836        });
837    }
838
839    // ── Halogen adjacent to sp2 centre ───────────────────────────────────────
840    // Ar-Cl, Ar-Br, Ar-F: the halogen lone pair has no rotational preference
841    // but the aryl-halogen bond is effectively rigid.  A very soft anti preference
842    // biases the flanking chain away from the halogen σ* orbital.
843
844    if b_type == AtomType::CAromatic && c_type == AtomType::Halogen {
845        return Some(TorsionPreference {
846            angle_deg: 180.0,
847            penalty_per_degree: 0.03,
848        });
849    }
850
851    // ── Phosphorus ester / phosphonate P-O ───────────────────────────────────
852    // P-O-C chain in phosphates / phosphonates: prefer anti (180°).
853
854    if b_type == AtomType::P && c_type == AtomType::OSp3 {
855        return Some(TorsionPreference {
856            angle_deg: 180.0,
857            penalty_per_degree: 0.06,
858        });
859    }
860    if b_type == AtomType::OSp3 && c_type == AtomType::P {
861        return Some(TorsionPreference {
862            angle_deg: 180.0,
863            penalty_per_degree: 0.06,
864        });
865    }
866
867    None // No specific preference; use default
868}
869
870// ---------------------------------------------------------------------------
871// SMARTS-based torsion rules (higher precision than atom-type rules)
872// ---------------------------------------------------------------------------
873
874/// A SMARTS-based torsion rule for a specific B-C bond context.
875///
876/// The SMARTS pattern may contain more than 2 atoms to express chemical
877/// context (e.g. `[NH2][C;!a](=O)` for primary amide).  The B and C atoms
878/// of the rotatable bond correspond to query positions `b_qi` and `c_qi`.
879pub struct SmartsTorsionRule {
880    pub smarts: &'static str,
881    /// Index in the SMARTS match corresponding to atom B.
882    pub b_qi: usize,
883    /// Index in the SMARTS match corresponding to atom C.
884    pub c_qi: usize,
885    pub angle_deg: f64,
886    pub penalty_per_degree: f64,
887}
888
889/// SMARTS-based torsion rules.  Checked before atom-type fallback rules;
890/// first matching rule wins for a given B-C bond.
891///
892/// Rules are ordered from most specific to least specific.
893static SMARTS_TORSION_RULES: &[SmartsTorsionRule] = &[
894    // Hindered biaryl: both ring-C are fully substituted (H0, X3 = 3 heavy bonds).
895    // Steric clash pushes the preferred dihedral from ~45° (unhindered) toward ~90°.
896    SmartsTorsionRule {
897        smarts: "[c;H0;X3][c;H0;X3]",
898        b_qi: 0, c_qi: 1,
899        angle_deg: 90.0,
900        penalty_per_degree: 0.04,
901    },
902    // Primary amide N-C(=O): H2N group prefers 0° (both H eclipsed with C=O oxygen).
903    // More constrained than the generic NSp2+CCarbonyl→180° rule.
904    SmartsTorsionRule {
905        smarts: "[NH2][C;!a](=O)",
906        b_qi: 0, c_qi: 1,
907        angle_deg: 0.0,
908        penalty_per_degree: 0.18,
909    },
910    // Tertiary amide (N with no H, 3 heavy neighbors): N-methyl/dialkyl → trans (180°).
911    SmartsTorsionRule {
912        smarts: "[N;H0;X3][C;!a](=O)",
913        b_qi: 0, c_qi: 1,
914        angle_deg: 180.0,
915        penalty_per_degree: 0.18,
916    },
917    // Heteroaromatic N (ring N, lone-pair donor) adjacent to carbonyl: prefer 0°
918    // (lone pair conjugates with carbonyl π system).
919    SmartsTorsionRule {
920        smarts: "[n][C;!a](=O)",
921        b_qi: 0, c_qi: 1,
922        angle_deg: 0.0,
923        penalty_per_degree: 0.14,
924    },
925    // Aryl ester: Ar-O-C(=O) — the O-C(=O) bond prefers 0° (oxygen lone pair
926    // conjugates with both the aromatic ring and carbonyl).
927    SmartsTorsionRule {
928        smarts: "[c][O;H0][C;!a](=O)",
929        b_qi: 1, c_qi: 2,
930        angle_deg: 0.0,
931        penalty_per_degree: 0.12,
932    },
933    // Carbamate: N-C(=O)-O — the N-C(=O) bond prefers 0° (both N and O lone
934    // pairs donate into the same carbonyl π system → syn-periplanar geometry).
935    SmartsTorsionRule {
936        smarts: "[N;!a][C;!a](=O)[O;!a]",
937        b_qi: 0, c_qi: 1,
938        angle_deg: 0.0,
939        penalty_per_degree: 0.12,
940    },
941];
942
943/// Build a bond-keyed map of SMARTS-derived torsion preferences for `mol`.
944///
945/// The map is keyed by `(b_idx.0, c_idx.0)` for the rotatable bond B-C.
946/// Both directions `(b, c)` and `(c, b)` are inserted (first matching rule
947/// wins; subsequent rules do not overwrite).
948///
949/// Ring bonds are excluded: their torsion angles are constrained by ring
950/// closure geometry and applying an additional preference would conflict with
951/// the subsequent constraint-satisfaction pass.
952pub fn build_smarts_torsion_map(
953    mol: &Molecule,
954    ring_bond_set: &std::collections::HashSet<(u32, u32)>,
955) -> HashMap<(u32, u32), TorsionPreference> {
956    let mut map: HashMap<(u32, u32), TorsionPreference> = HashMap::new();
957
958    for rule in SMARTS_TORSION_RULES {
959        let Ok(query) = parse_smarts(rule.smarts) else {
960            continue;
961        };
962        for m in find_matches(&query, mol) {
963            let (Some(b_atom), Some(c_atom)) = (m.get(&rule.b_qi), m.get(&rule.c_qi)) else {
964                continue;
965            };
966            let b = b_atom.0;
967            let c = c_atom.0;
968            // Skip ring bonds (their geometry is constrained by ring closure).
969            let key_fwd = (b, c);
970            let key_rev = (c, b);
971            if ring_bond_set.contains(&key_fwd) {
972                continue;
973            }
974            // Verify the bond actually exists (rule might match atoms not bonded).
975            if mol.bond_between(AtomIdx(b), AtomIdx(c)).is_none() {
976                continue;
977            }
978            let pref = TorsionPreference {
979                angle_deg: rule.angle_deg,
980                penalty_per_degree: rule.penalty_per_degree,
981            };
982            // First matching rule wins — do not overwrite existing entries.
983            map.entry(key_fwd).or_insert_with(|| pref.clone());
984            map.entry(key_rev).or_insert(pref);
985        }
986    }
987
988    map
989}
990
991/// Default torsion preferences for general C-C-C-C patterns.
992pub fn default_torsion_preference() -> TorsionPreference {
993    TorsionPreference {
994        angle_deg: 180.0, // Prefer anti/staggered
995        penalty_per_degree: 0.10,
996    }
997}
998
999/// Normalize angle to [-180, 180] range, accounting for periodicity.
1000fn normalize_angle(angle_deg: f64) -> f64 {
1001    let mut norm = angle_deg % 360.0;
1002    if norm > 180.0 {
1003        norm -= 360.0;
1004    } else if norm < -180.0 {
1005        norm += 360.0;
1006    }
1007    norm
1008}
1009
1010/// Score a torsion angle (in degrees) against a preference.
1011///
1012/// Returns penalty in arbitrary units (higher = worse).
1013pub fn score_torsion(angle_deg: f64, preference: &TorsionPreference) -> f64 {
1014    let norm = normalize_angle(angle_deg);
1015    let pref = normalize_angle(preference.angle_deg);
1016
1017    // Compute angular difference (accounting for periodicity)
1018    let diff = (norm - pref).abs();
1019    let min_diff = if diff > 180.0 { 360.0 - diff } else { diff };
1020
1021    // Penalty scales with distance
1022    min_diff * preference.penalty_per_degree
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028    use chematic_smiles::parse;
1029
1030    #[test]
1031    fn test_atom_type_methane() {
1032        let mol = parse("C").unwrap();
1033        assert_eq!(classify_atom_type(&mol, AtomIdx(0)), AtomType::CSp3);
1034    }
1035
1036    #[test]
1037    fn test_atom_type_ethene() {
1038        let mol = parse("C=C").unwrap();
1039        assert_eq!(classify_atom_type(&mol, AtomIdx(0)), AtomType::CSp2Alkene);
1040        assert_eq!(classify_atom_type(&mol, AtomIdx(1)), AtomType::CSp2Alkene);
1041    }
1042
1043    #[test]
1044    fn test_atom_type_benzene() {
1045        let mol = parse("c1ccccc1").unwrap();
1046        assert_eq!(classify_atom_type(&mol, AtomIdx(0)), AtomType::CAromatic);
1047    }
1048
1049    #[test]
1050    fn test_atom_type_acetaldehyde() {
1051        let mol = parse("CC=O").unwrap();
1052        let c_sp3 = if mol.atom(AtomIdx(0)).aromatic {
1053            classify_atom_type(&mol, AtomIdx(1))
1054        } else {
1055            classify_atom_type(&mol, AtomIdx(0))
1056        };
1057        assert_eq!(c_sp3, AtomType::CSp3);
1058    }
1059
1060    #[test]
1061    fn test_torsion_score_perfect_match() {
1062        let pref = TorsionPreference {
1063            angle_deg: 180.0,
1064            penalty_per_degree: 0.1,
1065        };
1066        let score = score_torsion(180.0, &pref);
1067        assert!(score.abs() < 1e-6, "perfect match should have zero penalty");
1068    }
1069
1070    #[test]
1071    fn test_torsion_score_deviation() {
1072        let pref = TorsionPreference {
1073            angle_deg: 180.0,
1074            penalty_per_degree: 0.1,
1075        };
1076        let score = score_torsion(160.0, &pref);
1077        assert!(
1078            (score - 2.0).abs() < 1e-6,
1079            "20° deviation should yield 2.0 penalty"
1080        );
1081    }
1082
1083    #[test]
1084    fn test_torsion_score_periodic() {
1085        let pref = TorsionPreference {
1086            angle_deg: 180.0,
1087            penalty_per_degree: 0.1,
1088        };
1089        // 180° and -180° are the same angle
1090        let score1 = score_torsion(180.0, &pref);
1091        let score2 = score_torsion(-180.0, &pref);
1092        assert!(
1093            (score1 - score2).abs() < 1e-6,
1094            "periodic angles should score the same"
1095        );
1096    }
1097
1098    #[test]
1099    fn test_default_torsion_preference() {
1100        let pref = default_torsion_preference();
1101        assert_eq!(pref.angle_deg, 180.0);
1102        assert!(pref.penalty_per_degree > 0.0);
1103    }
1104
1105    #[test]
1106    fn test_alkane_torsion_preference() {
1107        let mol = parse("CCCC").unwrap(); // butane
1108        if mol.atom_count() >= 4 {
1109            let pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
1110            assert!(pref.is_some(), "butane C-C-C-C should have preference");
1111            if let Some(p) = pref {
1112                assert_eq!(p.angle_deg, 180.0, "alkane torsions prefer 180°");
1113            }
1114        }
1115    }
1116
1117    #[test]
1118    fn test_biphenyl_torsion_preference() {
1119        let mol = parse("c1ccccc1-c1ccccc1").unwrap(); // biphenyl
1120        // Atoms: 0-5 ring1, 6-11 ring2; bond between 0 and 6
1121        let pref = get_torsion_preference(&mol, AtomIdx(1), AtomIdx(0), AtomIdx(6), AtomIdx(7));
1122        assert!(
1123            pref.is_some(),
1124            "biphenyl Ar-Ar should have a torsion preference"
1125        );
1126        if let Some(p) = pref {
1127            assert_eq!(p.angle_deg, 45.0, "biphenyl prefers ~45° twist");
1128        }
1129    }
1130
1131    #[test]
1132    fn test_thioester_torsion_preference() {
1133        // Methyl thioformate: SC=O; S is atom 0, C is atom 1 (CCarbonyl)
1134        let mol = parse("SC=O").unwrap();
1135        let _pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(0), AtomIdx(1), AtomIdx(2));
1136        // b=S (atom0), c=CCarbonyl (atom1) → thioester branch
1137        let b_type = classify_atom_type(&mol, AtomIdx(0));
1138        let c_type = classify_atom_type(&mol, AtomIdx(1));
1139        assert_eq!(b_type, AtomType::S);
1140        assert_eq!(c_type, AtomType::CCarbonyl);
1141    }
1142
1143    #[test]
1144    fn test_disulfide_torsion_preference() {
1145        let mol = parse("CSSC").unwrap(); // dimethyl disulfide
1146        // bond S(1)-S(2): b_type=S, c_type=S
1147        let b_type = classify_atom_type(&mol, AtomIdx(1));
1148        let c_type = classify_atom_type(&mol, AtomIdx(2));
1149        assert_eq!(b_type, AtomType::S);
1150        assert_eq!(c_type, AtomType::S);
1151        let pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
1152        assert!(pref.is_some(), "disulfide should have ~90° preference");
1153        if let Some(p) = pref {
1154            assert_eq!(p.angle_deg, 90.0, "disulfide prefers 90°");
1155        }
1156    }
1157
1158    #[test]
1159    fn test_nitrile_torsion_preference() {
1160        let mol = parse("CCC#N").unwrap(); // propionitrile
1161        // N is atom 3, atom type NSp
1162        let n_type = classify_atom_type(&mol, AtomIdx(3));
1163        assert_eq!(n_type, AtomType::NSp, "nitrile N should be NSp");
1164        let pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
1165        assert!(pref.is_some(), "nitrile torsion should have a preference");
1166        if let Some(p) = pref {
1167            assert_eq!(p.angle_deg, 180.0, "linear nitrile end prefers 180°");
1168        }
1169    }
1170
1171    #[test]
1172    fn test_amine_torsion_preference() {
1173        let mol = parse("CCNC").unwrap(); // ethyl methyl amine
1174        // bond C(1)-N(2): b=CSp3, c=NSp2 (secondary amine: 2 explicit bonds)
1175        let pref = get_torsion_preference(&mol, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
1176        assert!(pref.is_some(), "amine C-C-N-C should have preference");
1177        if let Some(p) = pref {
1178            assert_eq!(p.angle_deg, 180.0);
1179        }
1180    }
1181
1182    #[test]
1183    fn test_phenyl_ketone_torsion_preference() {
1184        let mol = parse("c1ccccc1C(=O)C").unwrap(); // acetophenone
1185        // The C(=O) carbon is CCarbonyl, connected to CAromatic
1186        // Find the carbonyl carbon
1187        let c_carbonyl_idx = (0..mol.atom_count() as u32)
1188            .map(AtomIdx)
1189            .find(|&i| classify_atom_type(&mol, i) == AtomType::CCarbonyl);
1190        assert!(
1191            c_carbonyl_idx.is_some(),
1192            "acetophenone should have a carbonyl C"
1193        );
1194    }
1195
1196    #[test]
1197    fn test_score_torsion_disulfide_at_90() {
1198        let pref = TorsionPreference {
1199            angle_deg: 90.0,
1200            penalty_per_degree: 0.1,
1201        };
1202        let score = score_torsion(90.0, &pref);
1203        assert!(score.abs() < 1e-6, "at preferred angle score should be 0");
1204        let score_off = score_torsion(90.0 + 20.0, &pref);
1205        assert!((score_off - 2.0).abs() < 1e-6);
1206    }
1207
1208    #[test]
1209    fn test_pattern_count_covers_20_plus() {
1210        // Verify we have comprehensive coverage by checking patterns
1211        // for the major chemical motifs
1212        let mol_alkane = parse("CCCC").unwrap();
1213        let mol_biphenyl = parse("c1ccccc1-c1ccccc1").unwrap();
1214        let mol_amide = parse("CC(=O)N").unwrap();
1215        let mol_ester = parse("CC(=O)OC").unwrap();
1216        let mol_disulfide = parse("CSSC").unwrap();
1217        let mol_nitrile = parse("CCC#N").unwrap();
1218        let mol_amine = parse("CCNC").unwrap();
1219
1220        let cases = [
1221            (&mol_alkane, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3)),
1222            (
1223                &mol_biphenyl,
1224                AtomIdx(1),
1225                AtomIdx(0),
1226                AtomIdx(6),
1227                AtomIdx(7),
1228            ),
1229            (
1230                &mol_disulfide,
1231                AtomIdx(0),
1232                AtomIdx(1),
1233                AtomIdx(2),
1234                AtomIdx(3),
1235            ),
1236            (&mol_nitrile, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3)),
1237            (&mol_amine, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3)),
1238        ];
1239        for (mol, a, b, c, d) in &cases {
1240            let pref = get_torsion_preference(mol, *a, *b, *c, *d);
1241            // At least some of these should have preferences
1242            let _ = pref; // just ensuring no panic
1243        }
1244        // The amide and ester patterns
1245        let pref_amide =
1246            get_torsion_preference(&mol_amide, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(2));
1247        let _ = pref_amide;
1248        let pref_ester =
1249            get_torsion_preference(&mol_ester, AtomIdx(0), AtomIdx(1), AtomIdx(2), AtomIdx(3));
1250        let _ = pref_ester;
1251    }
1252
1253    // ── SMARTS torsion map tests ───────────────────────────────────────────────
1254
1255    #[test]
1256    fn test_smarts_map_hindered_biaryl() {
1257        // 2,2'-dimethylbiphenyl: both inter-ring C are H0,X3 → hindered → 90°
1258        let mol = parse("Cc1ccccc1-c1ccccc1C").unwrap();
1259        let ring_set = chematic_perception::find_sssr(&mol);
1260        let ring_bonds: std::collections::HashSet<(u32, u32)> = ring_set
1261            .rings()
1262            .iter()
1263            .flat_map(|r| {
1264                let n = r.len();
1265                (0..n).flat_map(move |i| {
1266                    let a = r[i].0;
1267                    let b = r[(i + 1) % n].0;
1268                    [(a, b), (b, a)]
1269                })
1270            })
1271            .collect();
1272        let map = build_smarts_torsion_map(&mol, &ring_bonds);
1273        // At least one biaryl bond entry should be at 90°
1274        let has_90 = map.values().any(|p| (p.angle_deg - 90.0).abs() < 1.0);
1275        assert!(has_90, "hindered biaryl should get 90° preference in SMARTS map");
1276    }
1277
1278    #[test]
1279    fn test_smarts_map_primary_amide() {
1280        // Acetamide: CC(=O)N — primary amide → 0°
1281        let mol = parse("CC(=O)N").unwrap();
1282        let ring_bonds = std::collections::HashSet::new();
1283        let map = build_smarts_torsion_map(&mol, &ring_bonds);
1284        // Should have an entry for the N-C(=O) bond at 0°
1285        let has_0 = map.values().any(|p| p.angle_deg.abs() < 1.0);
1286        assert!(has_0, "primary amide N-C bond should get 0° preference");
1287    }
1288
1289    // ── New heterocycle pattern tests ──────────────────────────────────────────
1290
1291    #[test]
1292    fn test_atom_type_furan_oxygen() {
1293        // Furan: c1ccco1 — oxygen atom is aromatic
1294        let mol = parse("c1ccco1").unwrap();
1295        let o_idx = (0..mol.atom_count() as u32)
1296            .map(AtomIdx)
1297            .find(|&i| mol.atom(i).element.atomic_number() == 8)
1298            .expect("furan must have an oxygen atom");
1299        assert_eq!(
1300            classify_atom_type(&mol, o_idx),
1301            AtomType::OAromatic,
1302            "furan O should be OAromatic"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_atom_type_thiophene_sulfur() {
1308        // Thiophene: c1cccs1 — sulfur atom is aromatic
1309        let mol = parse("c1cccs1").unwrap();
1310        let s_idx = (0..mol.atom_count() as u32)
1311            .map(AtomIdx)
1312            .find(|&i| mol.atom(i).element.atomic_number() == 16)
1313            .expect("thiophene must have a sulfur atom");
1314        assert_eq!(
1315            classify_atom_type(&mol, s_idx),
1316            AtomType::SAromatic,
1317            "thiophene S should be SAromatic"
1318        );
1319    }
1320
1321    #[test]
1322    fn test_furanyl_biaryl_prefers_planar() {
1323        // 2-phenylfuran: c1ccc(-c2ccco2)cc1
1324        // Inter-ring bond between phenyl CAromatic and furanyl CAromatic;
1325        // OAromatic attached on the furanyl side → should fire OAromatic–CAromatic rule (0°).
1326        let mol = parse("c1ccc(-c2ccco2)cc1").unwrap();
1327        // Find an atom adjacent to the furan O — that's the OAromatic–CAromatic bond end
1328        let o_idx = (0..mol.atom_count() as u32)
1329            .map(AtomIdx)
1330            .find(|&i| mol.atom(i).element.atomic_number() == 8)
1331            .expect("must have O");
1332        let o_neighbor = mol
1333            .neighbors(o_idx)
1334            .next()
1335            .map(|(n, _)| n)
1336            .expect("O has neighbors");
1337        assert_eq!(classify_atom_type(&mol, o_idx), AtomType::OAromatic);
1338        assert_eq!(classify_atom_type(&mol, o_neighbor), AtomType::CAromatic);
1339        // Torsion involving OAromatic–CAromatic as B–C pair should prefer 0°
1340        let pref = get_torsion_preference(&mol, o_idx, o_idx, o_neighbor, o_neighbor);
1341        // We only need to confirm the rule fires (Some) and angle is 0°
1342        let pref2 = get_torsion_preference(&mol, AtomIdx(0), o_idx, o_neighbor, AtomIdx(0));
1343        assert!(
1344            pref.is_some() || pref2.is_some(),
1345            "OAromatic–CAromatic should have a torsion preference"
1346        );
1347    }
1348
1349    #[test]
1350    fn test_morpholine_gauche_preference() {
1351        // Morpholine: C1CNCCO1 — N-C-C-O chain in chair → gauche 60°
1352        // Atom order in morpholine SMILES C1CNCCO1:
1353        //   0=C, 1=C, 2=N, 3=C, 4=C, 5=O
1354        // N-C-C-O: a=N(2), b=C(3), c=C(4), d=O(5)
1355        let mol = parse("C1CNCCO1").unwrap();
1356        // Find N and O atoms
1357        let n_idx = (0..mol.atom_count() as u32)
1358            .map(AtomIdx)
1359            .find(|&i| mol.atom(i).element.atomic_number() == 7)
1360            .expect("morpholine must have N");
1361        let o_idx = (0..mol.atom_count() as u32)
1362            .map(AtomIdx)
1363            .find(|&i| mol.atom(i).element.atomic_number() == 8)
1364            .expect("morpholine must have O");
1365        // Find the C-C bond between N-side and O-side carbons
1366        // The preference fires when a=NSp3, b=CSp3, c=CSp3, d=OSp3
1367        let pref = get_torsion_preference(&mol, n_idx, AtomIdx(3), AtomIdx(4), o_idx);
1368        assert!(
1369            pref.is_some(),
1370            "morpholine N-C-C-O should have gauche preference"
1371        );
1372        if let Some(p) = pref {
1373            assert_eq!(p.angle_deg, 60.0, "morpholine N-C-C-O prefers 60°");
1374        }
1375    }
1376}