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