Skip to main content

chematic_3d/
lib.rs

1//! `chematic-3d` — 3D coordinate generation and file formats for chematic.
2//!
3//! Provides:
4//! - [`generate_coords`]: rule-based 3D coordinate builder.
5//! - [`parse_pdb_atoms`], [`pdb_to_molecule`], [`write_pdb`]: PDB file support.
6//! - [`parse_xyz`], [`write_xyz`]: XYZ file support.
7//! - [`build_constraints`], [`satisfy_constraints`]: distance geometry constraint satisfaction.
8//! - [`generate_and_minimize_constrained`]: full pipeline with constraint projection.
9
10#![forbid(unsafe_code)]
11#![allow(clippy::needless_range_loop)]
12
13pub mod align;
14pub mod conformer;
15pub mod constraints;
16pub mod coords;
17pub mod descriptors_3d;
18pub mod determine_bonds;
19pub mod dg;
20pub mod dg_fft;
21pub mod etkdg;
22pub mod etkdg_knowledge;
23pub mod md;
24pub mod minimize;
25pub mod mol_transforms;
26pub mod o3a;
27pub mod pdb;
28pub mod pharmacophore_fp_3d;
29pub(crate) mod prng;
30pub mod sasa;
31pub mod shape_descriptors;
32pub mod spectrophores;
33pub mod stereo3d;
34pub mod usr;
35pub mod xyz;
36
37pub use align::{AlignResult, align_coords, apply_alignment, rmsd_no_align};
38pub use conformer::{ConformerEnsemble, ConformerError};
39pub use constraints::{
40    AngleConstraint, BondConstraint, ConstraintSet, build_constraints, satisfy_constraints,
41};
42pub use determine_bonds::{
43    DetermineError, MAX_ATOMS as DETERMINE_BONDS_MAX_ATOMS, determine_bonds,
44};
45// Note: ConformerConfig is defined in lib.rs and exported here
46pub use coords::{Coords3D, Point3};
47pub use descriptors_3d::{
48    autocorr_3d, getaway_descriptors, rdf_descriptors, whim_descriptors, whim_getaway_combined,
49};
50pub use dg::generate_coords;
51pub use etkdg::generate_coords_etkdg;
52pub use md::{MDConfig, MDFrame, MDTrajectory, Thermostat, run_md};
53pub use minimize::{
54    ForceField, MinimizeConfig, minimize, minimize_dreiding, minimize_dreiding_with_config,
55    minimize_mmff94, minimize_uff, minimize_with_config,
56};
57pub use mol_transforms::{
58    center_on_origin, compute_centroid, get_bond_angle, get_bond_angle_deg, get_bond_length,
59    get_dihedral, get_dihedral_deg, set_dihedral, transform_conformer,
60};
61pub use pdb::{PdbAtom, parse_pdb_atoms, pdb_to_molecule, write_pdb};
62pub use pharmacophore_fp_3d::{pharmacophore_fp_3d, tanimoto_pharmacophore_3d};
63pub use sasa::{
64    PerElementSasa, SasaDescriptor, calc_mol_sasa, calc_mol_sasa_with_probe, sasa, sasa_descriptor,
65    sasa_descriptor_from_dg, sasa_from_dg, sasa_per_atom, sasa_per_atom_from_dg, sasa_per_element,
66    sasa_per_element_from_dg, shrake_rupley_sasa,
67};
68pub use shape_descriptors::{
69    asphericity, eccentricity, npr1, npr2, plane_of_best_fit, pmi, pmi1, pmi2, pmi3,
70    radius_of_gyration,
71};
72pub use spectrophores::{
73    SpectrophoresConfig, SpectrophoresNorm, spectrophores, tanimoto_spectrophores,
74};
75pub use stereo3d::{StereoAssignment3D, assign_stereo_from_3d};
76pub use usr::{shape_screen, usr_descriptors, usr_from_dg, usr_similarity};
77pub use xyz::{XyzError, parse_xyz, write_xyz};
78
79// ---------------------------------------------------------------------------
80// Configuration types
81// ---------------------------------------------------------------------------
82
83/// Force field used for geometry minimization during conformer ensemble generation.
84#[derive(Clone, Debug, Default)]
85pub enum ConformerForceField {
86    /// DREIDING: fast, suitable for large-scale screening.
87    #[default]
88    Dreiding,
89    /// MMFF94 (Halgren 1996): higher accuracy for drug-like molecules.
90    /// Slower than DREIDING but produces better geometries.
91    Mmff94,
92}
93
94/// Configuration for conformer ensemble generation.
95///
96/// - `count`: number of conformers to attempt (before RMSD pruning).
97/// - `rmsd_threshold`: minimum Kabsch-aligned RMSD (Å) between retained conformers.
98///   Set to 0.0 to disable pruning.
99/// - `force_field`: minimization engine after ETKDG placement.
100/// - `noise_sigma_deg`: standard deviation of Gaussian torsion noise (degrees).
101///   Default 30°; set to 0.0 for deterministic single-conformer generation.
102#[derive(Clone, Debug)]
103pub struct ConformerConfig {
104    pub count: usize,
105    pub rmsd_threshold: f64,
106    pub force_field: ConformerForceField,
107    pub noise_sigma_deg: f64,
108}
109
110impl Default for ConformerConfig {
111    fn default() -> Self {
112        Self {
113            count: 1,
114            rmsd_threshold: 0.5,
115            force_field: ConformerForceField::Dreiding,
116            noise_sigma_deg: 30.0,
117        }
118    }
119}
120
121// ---------------------------------------------------------------------------
122// High-level 3D generation pipeline
123// ---------------------------------------------------------------------------
124
125/// Generate 3D coordinates and minimize geometry in one step.
126/// Uses distance geometry for initial placement + DREIDING force field.
127pub fn generate_and_minimize_dreiding(mol: &chematic_core::Molecule) -> Coords3D {
128    let coords = generate_coords(mol);
129    minimize_dreiding(mol, coords)
130}
131
132/// Generate 3D coordinates with constraint satisfaction and energy minimization.
133///
134/// Full pipeline:
135/// 1. Rule-based 3D placement (`generate_coords`)
136/// 2. Build bond/angle constraints from topology (`build_constraints`)
137/// 3. Iterative constraint projection (`satisfy_constraints`)
138/// 4. Energy minimization with DREIDING force field (`minimize_dreiding`)
139pub fn generate_and_minimize_constrained(mol: &chematic_core::Molecule) -> Coords3D {
140    let coords = generate_coords(mol);
141    let cs = build_constraints(mol);
142    let projected = satisfy_constraints(&coords, mol, &cs, 20);
143    minimize_dreiding(mol, projected)
144}
145
146/// Generate 3D coordinates and minimize using UFF force field.
147pub fn generate_and_minimize_uff(mol: &chematic_core::Molecule) -> Coords3D {
148    let coords = generate_coords(mol);
149    minimize_uff(mol, coords)
150}
151
152/// Generate multiple conformers with different initial geometries.
153/// Uses distance geometry for initial placement, then minimizes with DREIDING.
154/// Returns a ConformerEnsemble with all conformers.
155///
156/// Equivalent to `generate_conformer_ensemble_with_config(mol, ConformerConfig::default())`.
157pub fn generate_conformer_ensemble(
158    mol: chematic_core::Molecule,
159    count: usize,
160) -> Result<ConformerEnsemble, ConformerError> {
161    generate_conformer_ensemble_with_config(
162        mol,
163        &ConformerConfig {
164            count,
165            rmsd_threshold: 0.0, // No pruning for backward compatibility
166            ..ConformerConfig::default()
167        },
168    )
169}
170
171/// Generate multiple conformers with force-field minimization and Kabsch-RMSD pruning.
172///
173/// Pipeline for each attempt (up to `config.count`):
174/// 1. ETKDG placement with Gaussian torsion noise (`config.noise_sigma_deg`).
175/// 2. Energy minimization with the chosen force field (`config.force_field`).
176/// 3. Kabsch-superposition RMSD vs all retained conformers; discard if below
177///    `config.rmsd_threshold`.
178///
179/// Returns `ConformerEnsemble` with the retained set (may be fewer than `count`
180/// after pruning).
181pub fn generate_conformer_ensemble_with_config(
182    mol: chematic_core::Molecule,
183    config: &ConformerConfig,
184) -> Result<ConformerEnsemble, ConformerError> {
185    if config.count == 0 {
186        return Ok(ConformerEnsemble::new(mol));
187    }
188
189    let mut ensemble = ConformerEnsemble::new(mol);
190    let noise_sigma = if config.count > 1 {
191        config.noise_sigma_deg
192    } else {
193        0.0
194    };
195
196    for _ in 0..config.count {
197        let coords = etkdg::generate_coords_etkdg_with_noise(ensemble.mol(), noise_sigma);
198        let minimized = match config.force_field {
199            ConformerForceField::Dreiding => minimize_dreiding(ensemble.mol(), coords),
200            ConformerForceField::Mmff94 => minimize_mmff94(ensemble.mol(), coords),
201        };
202
203        // Kabsch-aligned RMSD pruning: discard near-duplicates.
204        if ensemble.is_duplicate(&minimized, config.rmsd_threshold) {
205            continue;
206        }
207
208        ensemble.add_conformer(minimized)?;
209    }
210
211    Ok(ensemble)
212}
213
214/// Generate multiple conformers minimized with MMFF94 (Halgren 1996).
215///
216/// Convenience wrapper around [`generate_conformer_ensemble_with_config`] with
217/// `ConformerForceField::Mmff94`.  Higher accuracy than the default DREIDING
218/// pipeline, at the cost of ~3–5× longer minimization time.
219///
220/// ```rust,ignore
221/// let ensemble = generate_conformer_ensemble_mmff94(mol, 20, 0.5)?;
222/// ```
223pub fn generate_conformer_ensemble_mmff94(
224    mol: chematic_core::Molecule,
225    count: usize,
226    rmsd_threshold: f64,
227) -> Result<ConformerEnsemble, ConformerError> {
228    generate_conformer_ensemble_with_config(
229        mol,
230        &ConformerConfig {
231            count,
232            rmsd_threshold,
233            force_field: ConformerForceField::Mmff94,
234            noise_sigma_deg: 30.0,
235        },
236    )
237}
238
239// ---------------------------------------------------------------------------
240// Tests
241// ---------------------------------------------------------------------------
242
243#[cfg(test)]
244mod tests {
245    use chematic_core::AtomIdx;
246    use chematic_smiles::parse;
247
248    use crate::{
249        coords::{Coords3D, Point3},
250        dg::generate_coords,
251        generate_conformer_ensemble, generate_conformer_ensemble_mmff94,
252        generate_conformer_ensemble_with_config,
253        pdb::{parse_pdb_atoms, pdb_to_molecule, write_pdb},
254        xyz::{XyzError, parse_xyz, write_xyz},
255    };
256
257    // -----------------------------------------------------------------------
258    // Coords / Point3 tests
259    // -----------------------------------------------------------------------
260
261    /// Test 1: Point3 distance.
262    #[test]
263    fn test_point3_distance() {
264        let a = Point3::new(3.0, 4.0, 0.0);
265        let b = Point3::zero();
266        let d = a.distance(&b);
267        assert!((d - 5.0).abs() < 1e-10, "expected 5.0, got {d}");
268    }
269
270    /// Test 2: Point3 cross product — (1,0,0) × (0,1,0) = (0,0,1).
271    #[test]
272    fn test_point3_cross_product() {
273        let x = Point3::new(1.0, 0.0, 0.0);
274        let y = Point3::new(0.0, 1.0, 0.0);
275        let z = x.cross(&y);
276        assert!((z.x - 0.0).abs() < 1e-10);
277        assert!((z.y - 0.0).abs() < 1e-10);
278        assert!((z.z - 1.0).abs() < 1e-10);
279    }
280
281    // -----------------------------------------------------------------------
282    // DG / generate_coords tests
283    // -----------------------------------------------------------------------
284
285    /// Test 3: Single atom placed at origin.
286    #[test]
287    fn test_single_atom_at_origin() {
288        let mol = parse("O").expect("oxygen SMILES");
289        let coords = generate_coords(&mol);
290        assert_eq!(coords.atom_count(), 1);
291        let p = coords.get(AtomIdx(0));
292        assert!((p.x).abs() < 1e-10 && (p.y).abs() < 1e-10 && (p.z).abs() < 1e-10);
293    }
294
295    /// Test 4: Ethane — 2 distinct atoms, distance ≈ 1.54 Å (±0.1).
296    #[test]
297    fn test_ethane_bond_length() {
298        let mol = parse("CC").expect("ethane SMILES");
299        let coords = generate_coords(&mol);
300        assert_eq!(coords.atom_count(), 2);
301        let p0 = coords.get(AtomIdx(0));
302        let p1 = coords.get(AtomIdx(1));
303        let d = p0.distance(&p1);
304        assert!(
305            (d - 1.54).abs() < 0.1,
306            "ethane C-C distance expected ~1.54, got {d}"
307        );
308    }
309
310    /// Test 5: Propane — 3 distinct atoms, no two identical.
311    #[test]
312    fn test_propane_distinct_atoms() {
313        let mol = parse("CCC").expect("propane SMILES");
314        let coords = generate_coords(&mol);
315        assert_eq!(coords.atom_count(), 3);
316        let positions: Vec<_> = (0..3).map(|i| coords.get(AtomIdx(i))).collect();
317        for i in 0..3 {
318            for j in (i + 1)..3 {
319                let d = positions[i].distance(&positions[j]);
320                assert!(d > 0.1, "atoms {i} and {j} are too close (d={d:.4})");
321            }
322        }
323    }
324
325    /// Test 6: Benzene — 6 distinct atoms, all within 2.0 Å of centroid.
326    #[test]
327    fn test_benzene_ring() {
328        let mol = parse("c1ccccc1").expect("benzene SMILES");
329        let coords = generate_coords(&mol);
330        assert_eq!(coords.atom_count(), 6);
331
332        // Compute centroid.
333        let cx = (0..6).map(|i| coords.get(AtomIdx(i)).x).sum::<f64>() / 6.0;
334        let cy = (0..6).map(|i| coords.get(AtomIdx(i)).y).sum::<f64>() / 6.0;
335        let cz = (0..6).map(|i| coords.get(AtomIdx(i)).z).sum::<f64>() / 6.0;
336        let centroid = Point3::new(cx, cy, cz);
337
338        for i in 0..6 {
339            let p = coords.get(AtomIdx(i));
340            let d = p.distance(&centroid);
341            assert!(
342                d < 2.0,
343                "benzene atom {i} is {d:.3} Å from centroid, expected < 2.0"
344            );
345        }
346    }
347
348    /// Test 7: Water — 1 heavy atom at origin (H are implicit).
349    #[test]
350    fn test_water_single_atom() {
351        let mol = parse("O").expect("water SMILES");
352        assert_eq!(mol.atom_count(), 1, "water has 1 heavy atom");
353        let coords = generate_coords(&mol);
354        assert_eq!(coords.atom_count(), 1);
355        let p = coords.get(AtomIdx(0));
356        assert!((p.x).abs() < 1e-10 && (p.y).abs() < 1e-10 && (p.z).abs() < 1e-10);
357    }
358
359    /// Test 8: Disconnected "CC.CC" — 4 distinct atoms.
360    #[test]
361    fn test_disconnected_four_atoms() {
362        let mol = parse("CC.CC").expect("disconnected ethane SMILES");
363        assert_eq!(mol.atom_count(), 4);
364        let coords = generate_coords(&mol);
365        assert_eq!(coords.atom_count(), 4);
366
367        // All four positions must be distinct.
368        let positions: Vec<_> = (0..4).map(|i| coords.get(AtomIdx(i))).collect();
369        for i in 0..4 {
370            for j in (i + 1)..4 {
371                let d = positions[i].distance(&positions[j]);
372                assert!(d > 0.1, "atoms {i} and {j} overlap (d={d:.4})");
373            }
374        }
375    }
376
377    // -----------------------------------------------------------------------
378    // XYZ tests
379    // -----------------------------------------------------------------------
380
381    /// Test 9: Write then parse roundtrip for methane — 1 atom, coord ≈ (0,0,0).
382    #[test]
383    fn test_xyz_roundtrip_methane() {
384        let mol = parse("C").expect("methane SMILES");
385        let coords = generate_coords(&mol);
386        let xyz_str = write_xyz(&mol, &coords, "methane");
387
388        let (mol2, coords2) = parse_xyz(&xyz_str).expect("roundtrip parse");
389        assert_eq!(mol2.atom_count(), 1);
390        let p = coords2.get(AtomIdx(0));
391        assert!((p.x).abs() < 1e-6 && (p.y).abs() < 1e-6 && (p.z).abs() < 1e-6);
392    }
393
394    /// Test 10: Write ethane, parse back — 2 atoms, distance preserved (±0.01).
395    #[test]
396    fn test_xyz_ethane_roundtrip_distance() {
397        let mol = parse("CC").expect("ethane SMILES");
398        let coords = generate_coords(&mol);
399        let orig_dist = coords.get(AtomIdx(0)).distance(&coords.get(AtomIdx(1)));
400
401        let xyz_str = write_xyz(&mol, &coords, "ethane");
402        let (mol2, coords2) = parse_xyz(&xyz_str).expect("roundtrip parse");
403        assert_eq!(mol2.atom_count(), 2);
404
405        let parsed_dist = coords2.get(AtomIdx(0)).distance(&coords2.get(AtomIdx(1)));
406        assert!(
407            (parsed_dist - orig_dist).abs() < 0.01,
408            "distance changed: orig={orig_dist:.6}, parsed={parsed_dist:.6}"
409        );
410    }
411
412    /// Test 11: parse_xyz returns error on invalid atom count line.
413    #[test]
414    fn test_xyz_invalid_atom_count() {
415        let bad = "not_a_number\ncomment\n";
416        let result = parse_xyz(bad);
417        assert!(
418            matches!(result, Err(XyzError::InvalidAtomCount)),
419            "expected InvalidAtomCount error, got {:?}",
420            result.err()
421        );
422    }
423
424    /// Test 12: write_xyz first line is the atom count as a string.
425    #[test]
426    fn test_xyz_first_line_is_count() {
427        let mol = parse("CCC").expect("propane SMILES");
428        let coords = generate_coords(&mol);
429        let xyz_str = write_xyz(&mol, &coords, "propane");
430        let first_line = xyz_str.lines().next().unwrap();
431        assert_eq!(first_line.trim(), "3");
432    }
433
434    // -----------------------------------------------------------------------
435    // PDB tests
436    // -----------------------------------------------------------------------
437
438    /// Test 13: parse_pdb_atoms on a minimal HETATM record.
439    #[test]
440    fn test_pdb_parse_minimal_hetatm() {
441        // Standard 80-column PDB HETATM line with known values.
442        let pdb_line =
443            "HETATM    1  C   LIG A   1       1.000   2.000   3.000  1.00  0.00           C\n";
444        let atoms = parse_pdb_atoms(pdb_line);
445        assert_eq!(atoms.len(), 1);
446        let a = &atoms[0];
447        assert_eq!(a.serial, 1);
448        assert!((a.x - 1.0).abs() < 1e-3, "x={}", a.x);
449        assert!((a.y - 2.0).abs() < 1e-3, "y={}", a.y);
450        assert!((a.z - 3.0).abs() < 1e-3, "z={}", a.z);
451        assert_eq!(a.element.trim(), "C");
452    }
453
454    /// Test 14: write_pdb then parse_pdb_atoms roundtrip preserves count and coords.
455    #[test]
456    fn test_pdb_write_parse_roundtrip() {
457        let mol = parse("CCO").expect("ethanol SMILES");
458        let coords = generate_coords(&mol);
459
460        let pdb_str = write_pdb(&mol, &coords);
461        let parsed = parse_pdb_atoms(&pdb_str);
462
463        assert_eq!(parsed.len(), mol.atom_count());
464
465        // Compare coordinates to within 0.001 Å.
466        for i in 0..mol.atom_count() {
467            let orig = coords.get(AtomIdx(i as u32));
468            let p = &parsed[i];
469            assert!(
470                (p.x - orig.x).abs() < 0.001,
471                "atom {i} x mismatch: orig={:.3} parsed={:.3}",
472                orig.x,
473                p.x
474            );
475            assert!(
476                (p.y - orig.y).abs() < 0.001,
477                "atom {i} y mismatch: orig={:.3} parsed={:.3}",
478                orig.y,
479                p.y
480            );
481            assert!(
482                (p.z - orig.z).abs() < 0.001,
483                "atom {i} z mismatch: orig={:.3} parsed={:.3}",
484                orig.z,
485                p.z
486            );
487        }
488    }
489
490    /// Test 15: pdb_to_molecule for two C atoms 1.54 Å apart — 2 atoms, 1 bond.
491    #[test]
492    fn test_pdb_to_molecule_bonding() {
493        let pdb = "HETATM    1  C   LIG A   1       0.000   0.000   0.000  1.00  0.00           C\n\
494                   HETATM    2  C   LIG A   1       1.540   0.000   0.000  1.00  0.00           C\n\
495                   END\n";
496        let atoms = parse_pdb_atoms(pdb);
497        let (mol, _coords) = pdb_to_molecule(&atoms);
498        assert_eq!(mol.atom_count(), 2);
499        assert_eq!(mol.bond_count(), 1);
500    }
501
502    // =========================================================================
503    // Point3 additional tests
504    // =========================================================================
505
506    #[test]
507    fn test_point3_zero() {
508        let p = Point3::zero();
509        assert_eq!(p.x, 0.0);
510        assert_eq!(p.y, 0.0);
511        assert_eq!(p.z, 0.0);
512    }
513
514    #[test]
515    fn test_point3_add() {
516        let p1 = Point3::new(1.0, 2.0, 3.0);
517        let p2 = Point3::new(4.0, 5.0, 6.0);
518        let sum = p1.add(&p2);
519        assert_eq!(sum.x, 5.0);
520        assert_eq!(sum.y, 7.0);
521        assert_eq!(sum.z, 9.0);
522    }
523
524    #[test]
525    fn test_point3_sub() {
526        let p1 = Point3::new(5.0, 7.0, 9.0);
527        let p2 = Point3::new(1.0, 2.0, 3.0);
528        let diff = p1.sub(&p2);
529        assert_eq!(diff.x, 4.0);
530        assert_eq!(diff.y, 5.0);
531        assert_eq!(diff.z, 6.0);
532    }
533
534    #[test]
535    fn test_point3_scale() {
536        let p = Point3::new(1.0, 2.0, 3.0);
537        let scaled = p.scale(2.0);
538        assert_eq!(scaled.x, 2.0);
539        assert_eq!(scaled.y, 4.0);
540        assert_eq!(scaled.z, 6.0);
541    }
542
543    #[test]
544    fn test_point3_dot() {
545        let p1 = Point3::new(1.0, 0.0, 0.0);
546        let p2 = Point3::new(0.0, 1.0, 0.0);
547        assert_eq!(
548            p1.dot(&p2),
549            0.0,
550            "perpendicular vectors have zero dot product"
551        );
552
553        let p3 = Point3::new(1.0, 2.0, 3.0);
554        let p4 = Point3::new(1.0, 2.0, 3.0);
555        assert_eq!(p3.dot(&p4), 14.0); // 1 + 4 + 9
556    }
557
558    #[test]
559    fn test_point3_norm() {
560        let p = Point3::new(3.0, 4.0, 0.0);
561        assert_eq!(p.norm(), 5.0, "3-4-5 triangle");
562    }
563
564    #[test]
565    fn test_point3_normalize() {
566        let p = Point3::new(3.0, 4.0, 0.0);
567        let unit = p.normalize();
568        assert!((unit.x - 0.6).abs() < 1e-9);
569        assert!((unit.y - 0.8).abs() < 1e-9);
570        assert_eq!(unit.z, 0.0);
571    }
572
573    #[test]
574    #[should_panic]
575    fn test_point3_normalize_zero_panics() {
576        let p = Point3::zero();
577        let _ = p.normalize();
578    }
579
580    // =========================================================================
581    // Coords3D additional tests
582    // =========================================================================
583
584    #[test]
585    fn test_coords3d_new_zeroed() {
586        let coords = Coords3D::new_zeroed(5);
587        assert_eq!(coords.atom_count(), 5);
588        for i in 0..5 {
589            let p = coords.get(AtomIdx(i as u32));
590            assert_eq!(p.x, 0.0);
591            assert_eq!(p.y, 0.0);
592            assert_eq!(p.z, 0.0);
593        }
594    }
595
596    #[test]
597    fn test_coords3d_get_set_roundtrip() {
598        let mut coords = Coords3D::new_zeroed(3);
599        let p = Point3::new(1.5, 2.5, 3.5);
600        coords.set(AtomIdx(1), p);
601        let retrieved = coords.get(AtomIdx(1));
602        assert_eq!(retrieved.x, 1.5);
603        assert_eq!(retrieved.y, 2.5);
604        assert_eq!(retrieved.z, 3.5);
605    }
606
607    #[test]
608    fn test_coords3d_atom_count() {
609        let coords = Coords3D::new_zeroed(10);
610        assert_eq!(coords.atom_count(), 10);
611    }
612
613    // =========================================================================
614    // XYZ edge cases
615    // =========================================================================
616
617    #[test]
618    fn test_xyz_unknown_element() {
619        let xyz = "2\n\nXx   0.0 0.0 0.0\nC    1.0 1.0 1.0\n";
620        let result = parse_xyz(xyz);
621        match result {
622            Err(XyzError::UnknownElement(_)) => (),
623            _ => panic!("expected UnknownElement error"),
624        }
625    }
626
627    #[test]
628    fn test_xyz_invalid_line() {
629        let xyz = "2\n\nC 0.0 0.0\nC 1.0 1.0 1.0\n"; // first atom line too short
630        let result = parse_xyz(xyz);
631        assert!(matches!(result, Err(XyzError::InvalidLine(_))));
632    }
633
634    // =========================================================================
635    // PDB edge cases
636    // =========================================================================
637
638    #[test]
639    fn test_pdb_atom_record_parsed() {
640        // ATOM record (not only HETATM)
641        let pdb =
642            "ATOM      1  C   ALA A   1       0.000   0.000   0.000  1.00  0.00           C\nEND\n";
643        let atoms = parse_pdb_atoms(pdb);
644        assert_eq!(atoms.len(), 1);
645        assert_eq!(atoms[0].element, "C");
646    }
647
648    #[test]
649    fn test_pdb_remark_skipped() {
650        let pdb = "REMARK This is a comment\nHETATM    1  C   LIG A   1       0.000   0.000   0.000  1.00  0.00           C\nEND\n";
651        let atoms = parse_pdb_atoms(pdb);
652        assert_eq!(atoms.len(), 1, "only HETATM/ATOM records should be parsed");
653    }
654
655    #[test]
656    fn test_pdb_write_ends_with_end() {
657        use chematic_core::{Atom, Element, MoleculeBuilder};
658        let mut builder = MoleculeBuilder::new();
659        let c = Atom::new(Element::from_atomic_number(6).unwrap());
660        builder.add_atom(c);
661        let mol = builder.build();
662        let coords = Coords3D::new_zeroed(1);
663        let pdb = write_pdb(&mol, &coords);
664        assert!(pdb.ends_with("END\n"), "PDB should end with 'END\\n'");
665    }
666
667    // =========================================================================
668    // Conformer ensemble tests
669    // =========================================================================
670
671    #[test]
672    fn test_conformer_ensemble_basic() {
673        use super::ConformerConfig;
674        let mol = parse("CC").expect("ethane SMILES");
675        let config = ConformerConfig {
676            count: 2,
677            rmsd_threshold: 0.0,
678            ..ConformerConfig::default()
679        };
680        let ensemble = generate_conformer_ensemble_with_config(mol, &config)
681            .expect("should generate ensemble");
682        assert_eq!(ensemble.conformer_count(), 2, "should have 2 conformers");
683    }
684
685    #[test]
686    fn test_conformer_ensemble_zero_count() {
687        use super::ConformerConfig;
688        let mol = parse("CC").expect("ethane SMILES");
689        let config = ConformerConfig {
690            count: 0,
691            rmsd_threshold: 0.0,
692            ..ConformerConfig::default()
693        };
694        let ensemble = generate_conformer_ensemble_with_config(mol, &config)
695            .expect("should create empty ensemble");
696        assert_eq!(
697            ensemble.conformer_count(),
698            0,
699            "empty config should yield no conformers"
700        );
701    }
702
703    #[test]
704    fn test_conformer_ensemble_rmsd_pruning() {
705        use super::ConformerConfig;
706        let mol = parse("C").expect("methane SMILES");
707        let config = ConformerConfig {
708            count: 5,
709            rmsd_threshold: 1.0,
710            ..ConformerConfig::default()
711        };
712        let ensemble = generate_conformer_ensemble_with_config(mol, &config)
713            .expect("should generate ensemble with pruning");
714        // With high threshold and simple molecule, should keep very few (often 1)
715        assert!(
716            ensemble.conformer_count() <= 3,
717            "high RMSD threshold should prune duplicates; got {}",
718            ensemble.conformer_count()
719        );
720    }
721
722    #[test]
723    fn test_conformer_backward_compatibility() {
724        let mol = parse("CC").expect("ethane SMILES");
725        let ensemble = generate_conformer_ensemble(mol, 2).expect("should generate ensemble");
726        assert_eq!(
727            ensemble.conformer_count(),
728            2,
729            "backward-compatible API should work"
730        );
731    }
732
733    #[test]
734    fn test_conformer_ensemble_mmff94() {
735        // MMFF94 ensemble must produce at least 1 conformer for a drug-like molecule.
736        let mol = parse("c1ccccc1CC(=O)O").expect("phenylacetic acid");
737        let ensemble = generate_conformer_ensemble_mmff94(mol, 5, 0.5)
738            .expect("MMFF94 ensemble should succeed");
739        assert!(
740            ensemble.conformer_count() >= 1,
741            "MMFF94 ensemble must produce at least 1 conformer"
742        );
743    }
744
745    #[test]
746    fn test_conformer_ensemble_gaussian_diversity() {
747        // With Gaussian noise and n>1, we expect diverse conformers for a flexible molecule.
748        let mol = parse("CCCCCC").expect("hexane");
749        use super::{ConformerConfig, ConformerForceField};
750        let config = ConformerConfig {
751            count: 10,
752            rmsd_threshold: 0.3,
753            force_field: ConformerForceField::Dreiding,
754            noise_sigma_deg: 30.0,
755        };
756        let ensemble = generate_conformer_ensemble_with_config(mol, &config).expect("ensemble ok");
757        // Hexane has 3 rotatable bonds; 10 attempts with Gaussian noise should yield ≥2 unique conformers.
758        assert!(
759            ensemble.conformer_count() >= 2,
760            "flexible molecule with Gaussian noise should produce diverse conformers, got {}",
761            ensemble.conformer_count()
762        );
763    }
764}