1#![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};
45pub 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#[derive(Clone, Debug, Default)]
85pub enum ConformerForceField {
86 #[default]
88 Dreiding,
89 Mmff94,
92}
93
94#[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
121pub fn generate_and_minimize_dreiding(mol: &chematic_core::Molecule) -> Coords3D {
128 let coords = generate_coords(mol);
129 minimize_dreiding(mol, coords)
130}
131
132pub 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
146pub fn generate_and_minimize_uff(mol: &chematic_core::Molecule) -> Coords3D {
148 let coords = generate_coords(mol);
149 minimize_uff(mol, coords)
150}
151
152pub 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, ..ConformerConfig::default()
167 },
168 )
169}
170
171pub 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 if ensemble.is_duplicate(&minimized, config.rmsd_threshold) {
205 continue;
206 }
207
208 ensemble.add_conformer(minimized)?;
209 }
210
211 Ok(ensemble)
212}
213
214pub 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#[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 #[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]
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 #[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]
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]
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]
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 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(¢roid);
341 assert!(
342 d < 2.0,
343 "benzene atom {i} is {d:.3} Å from centroid, expected < 2.0"
344 );
345 }
346 }
347
348 #[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]
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 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 #[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]
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]
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]
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 #[test]
440 fn test_pdb_parse_minimal_hetatm() {
441 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]
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 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]
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 #[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); }
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 #[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 #[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"; let result = parse_xyz(xyz);
631 assert!(matches!(result, Err(XyzError::InvalidLine(_))));
632 }
633
634 #[test]
639 fn test_pdb_atom_record_parsed() {
640 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 #[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 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 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 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 assert!(
759 ensemble.conformer_count() >= 2,
760 "flexible molecule with Gaussian noise should produce diverse conformers, got {}",
761 ensemble.conformer_count()
762 );
763 }
764}