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