1#![allow(clippy::too_many_arguments)]
32#![allow(clippy::needless_range_loop)]
33
34pub mod alignment;
35pub mod ani;
36pub mod canonical_smiles;
37pub mod charges;
38pub mod charges_eeq;
39pub mod clustering;
40pub mod conformer;
41pub mod dipole;
42pub mod dispersion;
43pub mod distgeom;
44pub mod dos;
45pub mod dynamics;
46pub mod eht;
47pub mod esp;
48pub mod etkdg;
49pub mod experimental_status;
50#[doc(hidden)]
51pub mod fixture_io;
52pub mod forcefield;
53pub mod gpu;
54pub mod graph;
55pub mod hf;
56pub mod ir;
57pub mod materials;
58pub mod mesh;
59pub mod ml;
60pub mod nmr;
61pub mod optimization;
62pub mod periodic;
63pub mod pm3;
64pub mod population;
65pub mod potentials;
66pub mod reactivity;
67pub mod rings;
68pub mod scf;
69pub mod smarts;
70pub mod smiles;
71pub mod smirks;
72pub mod solvation;
73pub mod solvation_alpb;
74pub mod spectroscopy;
75pub mod stereo;
76pub mod surface;
77pub mod topology;
78pub mod transport;
79pub mod xtb;
80
81#[cfg(any(
82 feature = "alpha-cga",
83 feature = "alpha-gsm",
84 feature = "alpha-sdr",
85 feature = "alpha-edl",
86 feature = "alpha-periodic-linear",
87 feature = "alpha-kinetics",
88 feature = "alpha-render-bridge"
89))]
90pub mod alpha;
91
92#[cfg(any(
93 feature = "beta-kpm",
94 feature = "beta-mbh",
95 feature = "beta-cpm",
96 feature = "beta-randnla",
97 feature = "beta-riemannian"
98))]
99pub mod beta;
100
101#[cfg(feature = "alpha-dynamics-live")]
104pub mod dynamics_live;
105
106#[cfg(feature = "alpha-dft")]
107pub mod dft;
108
109#[cfg(feature = "alpha-mlff")]
110pub mod mlff {
111 pub use crate::ml::inference::*;
113 pub use crate::ml::mlff::*;
114 pub use crate::ml::symmetry_functions::*;
115}
116
117use serde::{Deserialize, Serialize};
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ConformerResult {
124 pub smiles: String,
126 pub num_atoms: usize,
128 pub coords: Vec<f64>,
131 pub elements: Vec<u8>,
133 pub bonds: Vec<(usize, usize, String)>,
135 pub error: Option<String>,
137 pub time_ms: f64,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ConformerConfig {
144 pub seed: u64,
146 pub num_threads: usize,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct ConformerEnsembleMember {
153 pub seed: u64,
155 pub cluster_id: Option<usize>,
157 pub coords: Vec<f64>,
159 pub energy_kcal_mol: f64,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ConformerClusterSummary {
166 pub cluster_id: usize,
168 pub representative_seed: u64,
170 pub size: usize,
172 pub member_seeds: Vec<u64>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ConformerSearchResult {
179 pub generated: usize,
181 pub unique: usize,
183 pub rotatable_bonds: usize,
185 pub conformers: Vec<ConformerEnsembleMember>,
187 pub clusters: Vec<ConformerClusterSummary>,
189 pub notes: Vec<String>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct MethodCapability {
196 pub available: bool,
198 pub confidence: eht::SupportLevel,
200 pub unsupported_elements: Vec<u8>,
202 pub warnings: Vec<String>,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum ScientificMethod {
210 Embed,
211 Uff,
212 Eht,
213 Pm3,
214 Xtb,
215 Mmff94,
216 Ani,
217 Hf3c,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub enum PropertyRequest {
224 Geometry,
225 ForceFieldEnergy,
226 Orbitals,
227 Population,
228 OrbitalGrid,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct MethodMetadata {
234 pub method: ScientificMethod,
235 pub available: bool,
236 pub confidence: eht::SupportLevel,
237 pub confidence_score: f64,
238 pub limitations: Vec<String>,
239 pub warnings: Vec<String>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct PropertyMethodPlan {
245 pub property: PropertyRequest,
246 pub recommended: Option<ScientificMethod>,
247 pub fallback: Option<ScientificMethod>,
248 pub rationale: Vec<String>,
249 pub methods: Vec<MethodMetadata>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct SystemMethodPlan {
255 pub capabilities: SystemCapabilities,
256 pub geometry: PropertyMethodPlan,
257 pub force_field_energy: PropertyMethodPlan,
258 pub orbitals: PropertyMethodPlan,
259 pub population: PropertyMethodPlan,
260 pub orbital_grid: PropertyMethodPlan,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct SystemCapabilities {
266 pub embed: MethodCapability,
267 pub uff: MethodCapability,
268 pub eht: MethodCapability,
269 pub population: MethodCapability,
270 pub orbital_grid: MethodCapability,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(tag = "mode", rename_all = "snake_case")]
276pub enum ElectronicWorkflowResult {
277 Eht {
278 result: eht::EhtResult,
279 },
280 UffFallback {
281 energy_kcal_mol: f64,
282 reason: String,
283 support: eht::EhtSupport,
284 },
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum MethodComparisonStatus {
291 Success,
292 Unavailable,
293 Error,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(tag = "kind", rename_all = "snake_case")]
299pub enum MethodComparisonPayload {
300 Eht {
301 homo_energy: f64,
302 lumo_energy: f64,
303 gap: f64,
304 support: eht::EhtSupport,
305 },
306 Uff {
307 energy_kcal_mol: f64,
308 },
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct MethodComparisonEntry {
314 pub method: ScientificMethod,
315 pub status: MethodComparisonStatus,
316 pub available: bool,
317 pub confidence: eht::SupportLevel,
318 pub confidence_score: f64,
319 pub warnings: Vec<String>,
320 pub limitations: Vec<String>,
321 pub payload: Option<MethodComparisonPayload>,
322 pub error: Option<String>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct MethodComparisonResult {
328 pub plan: SystemMethodPlan,
329 pub comparisons: Vec<MethodComparisonEntry>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct AromaticityAnalysis {
335 pub aromatic_atoms: Vec<bool>,
336 pub aromatic_bonds: Vec<(usize, usize)>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct StereocenterAnalysis {
342 pub tagged_tetrahedral_centers: Vec<usize>,
343 pub inferred_tetrahedral_centers: Vec<usize>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct GraphFeatureAnalysis {
349 pub aromaticity: AromaticityAnalysis,
350 pub stereocenters: StereocenterAnalysis,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct GiaoNmrConfig {
356 pub charge: i32,
358 pub multiplicity: u32,
360 pub max_scf_iterations: usize,
362 pub use_parallel_eri: bool,
364 pub allow_basis_fallback: bool,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct GiaoNmrEntry {
371 pub atom_index: usize,
372 pub element: u8,
373 pub tensor: [[f64; 3]; 3],
374 pub isotropic: f64,
375 pub anisotropy: f64,
376 pub chemical_shift: f64,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct GiaoNmrResult {
382 pub nucleus: String,
383 pub target_atomic_number: u8,
384 pub method: String,
385 pub basis_set: String,
386 pub charge: i32,
387 pub multiplicity: u32,
388 pub scf_converged: bool,
389 pub scf_iterations: usize,
390 pub total_energy_hartree: f64,
391 pub n_basis: usize,
392 pub n_target_atoms: usize,
393 pub chemical_shifts: Vec<f64>,
394 pub shieldings: Vec<GiaoNmrEntry>,
395 pub fallback_elements: Vec<u8>,
396 pub notes: Vec<String>,
397}
398
399impl Default for ConformerConfig {
400 fn default() -> Self {
401 Self {
402 seed: 42,
403 num_threads: 0,
404 }
405 }
406}
407
408impl Default for GiaoNmrConfig {
409 fn default() -> Self {
410 Self {
411 charge: 0,
412 multiplicity: 1,
413 max_scf_iterations: 100,
414 use_parallel_eri: false,
415 allow_basis_fallback: false,
416 }
417 }
418}
419
420pub fn version() -> String {
424 format!("sci-form {}", env!("CARGO_PKG_VERSION"))
425}
426
427pub fn get_eht_support(elements: &[u8]) -> eht::EhtSupport {
429 eht::analyze_eht_support(elements)
430}
431
432fn is_uff_element_supported(z: u8) -> bool {
433 matches!(
434 z,
435 1 | 5
436 | 6
437 | 7
438 | 8
439 | 9
440 | 14
441 | 15
442 | 16
443 | 17
444 | 22
445 | 23
446 | 24
447 | 25
448 | 26
449 | 27
450 | 28
451 | 29
452 | 30
453 | 32
454 | 33
455 | 34
456 | 35
457 | 42
458 | 46
459 | 47
460 | 50
461 | 51
462 | 52
463 | 53
464 | 78
465 | 79
466 )
467}
468
469fn unique_sorted_unsupported(elements: &[u8], pred: impl Fn(u8) -> bool) -> Vec<u8> {
470 let mut out: Vec<u8> = elements.iter().copied().filter(|&z| !pred(z)).collect();
471 out.sort_unstable();
472 out.dedup();
473 out
474}
475
476pub fn get_system_capabilities(elements: &[u8]) -> SystemCapabilities {
478 let eht_support = get_eht_support(elements);
479 let uff_unsupported = unique_sorted_unsupported(elements, is_uff_element_supported);
480
481 let embed = MethodCapability {
482 available: !elements.is_empty(),
483 confidence: eht::SupportLevel::Experimental,
484 unsupported_elements: Vec::new(),
485 warnings: vec![
486 "Embed capability is inferred from element presence only; final success still depends on full molecular graph and geometry constraints.".to_string(),
487 ],
488 };
489
490 let uff = if uff_unsupported.is_empty() {
491 MethodCapability {
492 available: true,
493 confidence: eht::SupportLevel::Supported,
494 unsupported_elements: Vec::new(),
495 warnings: Vec::new(),
496 }
497 } else {
498 MethodCapability {
499 available: false,
500 confidence: eht::SupportLevel::Unsupported,
501 unsupported_elements: uff_unsupported.clone(),
502 warnings: vec![format!(
503 "UFF atom typing is unavailable for elements {:?}.",
504 uff_unsupported
505 )],
506 }
507 };
508
509 let eht = MethodCapability {
510 available: eht_support.unsupported_elements.is_empty(),
511 confidence: eht_support.level,
512 unsupported_elements: eht_support.unsupported_elements.clone(),
513 warnings: eht_support.warnings.clone(),
514 };
515
516 let population = MethodCapability {
517 available: eht.available,
518 confidence: eht.confidence,
519 unsupported_elements: eht.unsupported_elements.clone(),
520 warnings: eht.warnings.clone(),
521 };
522
523 let orbital_grid = MethodCapability {
524 available: eht.available,
525 confidence: eht.confidence,
526 unsupported_elements: eht.unsupported_elements.clone(),
527 warnings: eht.warnings.clone(),
528 };
529
530 SystemCapabilities {
531 embed,
532 uff,
533 eht,
534 population,
535 orbital_grid,
536 }
537}
538
539fn confidence_score_for_method(method: ScientificMethod, capability: &MethodCapability) -> f64 {
540 if !capability.available {
541 return 0.0;
542 }
543
544 match method {
545 ScientificMethod::Embed => 0.8,
546 ScientificMethod::Uff | ScientificMethod::Mmff94 => match capability.confidence {
547 eht::SupportLevel::Supported => 0.95,
548 eht::SupportLevel::Experimental => 0.75,
549 eht::SupportLevel::Unsupported => 0.0,
550 },
551 ScientificMethod::Eht | ScientificMethod::Pm3 | ScientificMethod::Xtb => {
552 match capability.confidence {
553 eht::SupportLevel::Supported => 0.95,
554 eht::SupportLevel::Experimental => 0.6,
555 eht::SupportLevel::Unsupported => 0.0,
556 }
557 }
558 ScientificMethod::Ani => match capability.confidence {
559 eht::SupportLevel::Supported => 0.90,
560 eht::SupportLevel::Experimental => 0.7,
561 eht::SupportLevel::Unsupported => 0.0,
562 },
563 ScientificMethod::Hf3c => match capability.confidence {
564 eht::SupportLevel::Supported => 0.85,
565 eht::SupportLevel::Experimental => 0.65,
566 eht::SupportLevel::Unsupported => 0.0,
567 },
568 }
569}
570
571fn build_method_metadata(
572 method: ScientificMethod,
573 capability: &MethodCapability,
574 extra_limitations: &[&str],
575) -> MethodMetadata {
576 let mut limitations: Vec<String> = extra_limitations.iter().map(|s| s.to_string()).collect();
577
578 if !capability.unsupported_elements.is_empty() {
579 limitations.push(format!(
580 "Unsupported elements for this method: {:?}.",
581 capability.unsupported_elements
582 ));
583 }
584
585 if matches!(method, ScientificMethod::Eht)
586 && matches!(capability.confidence, eht::SupportLevel::Experimental)
587 {
588 limitations.push(
589 "Transition-metal EHT parameters remain provisional and should be treated as experimental."
590 .to_string(),
591 );
592 }
593
594 MethodMetadata {
595 method,
596 available: capability.available,
597 confidence: capability.confidence,
598 confidence_score: confidence_score_for_method(method, capability),
599 limitations,
600 warnings: capability.warnings.clone(),
601 }
602}
603
604fn build_property_plan(
605 property: PropertyRequest,
606 recommended: Option<ScientificMethod>,
607 fallback: Option<ScientificMethod>,
608 rationale: Vec<String>,
609 methods: Vec<MethodMetadata>,
610) -> PropertyMethodPlan {
611 PropertyMethodPlan {
612 property,
613 recommended,
614 fallback,
615 rationale,
616 methods,
617 }
618}
619
620pub fn get_system_method_plan(elements: &[u8]) -> SystemMethodPlan {
622 let capabilities = get_system_capabilities(elements);
623
624 let geometry_method = build_method_metadata(
625 ScientificMethod::Embed,
626 &capabilities.embed,
627 &["Geometry generation still depends on graph topology, stereochemistry, and embedding constraints."],
628 );
629 let geometry = build_property_plan(
630 PropertyRequest::Geometry,
631 geometry_method.available.then_some(ScientificMethod::Embed),
632 None,
633 vec!["Embedding is the top-level geometry generation path in sci-form.".to_string()],
634 vec![geometry_method],
635 );
636
637 let uff_method = build_method_metadata(
638 ScientificMethod::Uff,
639 &capabilities.uff,
640 &["This recommendation applies to force-field energy evaluation, not molecular orbital analysis."],
641 );
642 let force_field_energy = build_property_plan(
643 PropertyRequest::ForceFieldEnergy,
644 uff_method.available.then_some(ScientificMethod::Uff),
645 None,
646 vec![
647 "UFF is the top-level force-field energy path when atom typing is available."
648 .to_string(),
649 ],
650 vec![uff_method],
651 );
652
653 let eht_method = build_method_metadata(
654 ScientificMethod::Eht,
655 &capabilities.eht,
656 &["EHT is the only current top-level orbital method in sci-form."],
657 );
658 let orbitals = build_property_plan(
659 PropertyRequest::Orbitals,
660 eht_method.available.then_some(ScientificMethod::Eht),
661 None,
662 vec!["Orbital energies and MO coefficients are produced by the EHT workflow.".to_string()],
663 vec![eht_method.clone()],
664 );
665
666 let population_method = build_method_metadata(
667 ScientificMethod::Eht,
668 &capabilities.population,
669 &["Population analysis is derived from the EHT density and overlap matrices."],
670 );
671 let population = build_property_plan(
672 PropertyRequest::Population,
673 population_method.available.then_some(ScientificMethod::Eht),
674 None,
675 vec!["Population analysis currently requires a successful EHT calculation.".to_string()],
676 vec![population_method],
677 );
678
679 let orbital_grid_method = build_method_metadata(
680 ScientificMethod::Eht,
681 &capabilities.orbital_grid,
682 &["Orbital-grid rendering currently depends on EHT molecular-orbital coefficients."],
683 );
684 let orbital_grid = build_property_plan(
685 PropertyRequest::OrbitalGrid,
686 orbital_grid_method
687 .available
688 .then_some(ScientificMethod::Eht),
689 None,
690 vec![
691 "Orbital-grid generation currently requires a successful EHT calculation.".to_string(),
692 ],
693 vec![orbital_grid_method],
694 );
695
696 SystemMethodPlan {
697 capabilities,
698 geometry,
699 force_field_energy,
700 orbitals,
701 population,
702 orbital_grid,
703 }
704}
705
706pub fn embed(smiles: &str, seed: u64) -> ConformerResult {
708 #[cfg(not(target_arch = "wasm32"))]
709 let start = std::time::Instant::now();
710
711 let mol = match graph::Molecule::from_smiles(smiles) {
712 Ok(m) => m,
713 Err(e) => {
714 return ConformerResult {
715 smiles: smiles.to_string(),
716 num_atoms: 0,
717 coords: vec![],
718 elements: vec![],
719 bonds: vec![],
720 error: Some(e),
721 #[cfg(not(target_arch = "wasm32"))]
722 time_ms: start.elapsed().as_secs_f64() * 1000.0,
723 #[cfg(target_arch = "wasm32")]
724 time_ms: 0.0,
725 };
726 }
727 };
728
729 let n = mol.graph.node_count();
730 let elements: Vec<u8> = (0..n)
731 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
732 .collect();
733 let bonds: Vec<(usize, usize, String)> = mol
734 .graph
735 .edge_indices()
736 .map(|e| {
737 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
738 let order = match mol.graph[e].order {
739 graph::BondOrder::Single => "SINGLE",
740 graph::BondOrder::Double => "DOUBLE",
741 graph::BondOrder::Triple => "TRIPLE",
742 graph::BondOrder::Aromatic => "AROMATIC",
743 graph::BondOrder::Unknown => "UNKNOWN",
744 };
745 (a.index(), b.index(), order.to_string())
746 })
747 .collect();
748
749 let max_seed_retries = 3u64;
752 let mut last_err = String::new();
753 for retry in 0..max_seed_retries {
754 let current_seed = seed.wrapping_add(retry.wrapping_mul(997));
755 match conformer::generate_3d_conformer(&mol, current_seed) {
756 Ok(coords) => {
757 let mut flat = Vec::with_capacity(n * 3);
758 for i in 0..n {
759 flat.push(coords[(i, 0)] as f64);
760 flat.push(coords[(i, 1)] as f64);
761 flat.push(coords[(i, 2)] as f64);
762 }
763 return ConformerResult {
764 smiles: smiles.to_string(),
765 num_atoms: n,
766 coords: flat,
767 elements,
768 bonds,
769 error: None,
770 #[cfg(not(target_arch = "wasm32"))]
771 time_ms: start.elapsed().as_secs_f64() * 1000.0,
772 #[cfg(target_arch = "wasm32")]
773 time_ms: 0.0,
774 };
775 }
776 Err(e) => {
777 last_err = e;
778 }
779 }
780 }
781 ConformerResult {
782 smiles: smiles.to_string(),
783 num_atoms: n,
784 coords: vec![],
785 elements,
786 bonds,
787 error: Some(last_err),
788 #[cfg(not(target_arch = "wasm32"))]
789 time_ms: start.elapsed().as_secs_f64() * 1000.0,
790 #[cfg(target_arch = "wasm32")]
791 time_ms: 0.0,
792 }
793}
794
795#[cfg(feature = "parallel")]
800pub fn embed_batch(smiles_list: &[&str], config: &ConformerConfig) -> Vec<ConformerResult> {
801 use rayon::prelude::*;
802
803 if config.num_threads > 0 {
804 let pool = rayon::ThreadPoolBuilder::new()
805 .num_threads(config.num_threads)
806 .build()
807 .unwrap();
808 pool.install(|| {
809 smiles_list
810 .par_iter()
811 .map(|smi| embed(smi, config.seed))
812 .collect()
813 })
814 } else {
815 smiles_list
816 .par_iter()
817 .map(|smi| embed(smi, config.seed))
818 .collect()
819 }
820}
821
822#[cfg(not(feature = "parallel"))]
824pub fn embed_batch(smiles_list: &[&str], config: &ConformerConfig) -> Vec<ConformerResult> {
825 smiles_list
826 .iter()
827 .map(|smi| embed(smi, config.seed))
828 .collect()
829}
830
831pub fn embed_diverse(
842 smiles: &str,
843 n_conformers: usize,
844 rmsd_cutoff: f64,
845 base_seed: u64,
846) -> Vec<ConformerResult> {
847 let all_results: Vec<ConformerResult> = (0..n_conformers as u64)
848 .map(|i| embed(smiles, base_seed.wrapping_add(i)))
849 .collect();
850
851 let successful: Vec<(usize, &ConformerResult)> = all_results
852 .iter()
853 .enumerate()
854 .filter(|(_, r)| r.error.is_none() && !r.coords.is_empty())
855 .collect();
856
857 if successful.len() <= 1 {
858 return all_results
859 .into_iter()
860 .filter(|r| r.error.is_none())
861 .collect();
862 }
863
864 let coords_vecs: Vec<Vec<f64>> = successful.iter().map(|(_, r)| r.coords.clone()).collect();
865 let cluster_result = clustering::butina_cluster(&coords_vecs, rmsd_cutoff);
866
867 cluster_result
868 .centroid_indices
869 .iter()
870 .map(|&ci| {
871 let (orig_idx, _) = successful[ci];
872 all_results[orig_idx].clone()
873 })
874 .collect()
875}
876
877pub fn parse(smiles: &str) -> Result<graph::Molecule, String> {
879 graph::Molecule::from_smiles(smiles)
880}
881
882pub fn compute_charges(smiles: &str) -> Result<charges::gasteiger::ChargeResult, String> {
887 let mol = graph::Molecule::from_smiles(smiles)?;
888 let n = mol.graph.node_count();
889 let elements: Vec<u8> = (0..n)
890 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
891 .collect();
892 let formal_charges: Vec<i8> = (0..n)
893 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].formal_charge)
894 .collect();
895 let bonds: Vec<(usize, usize)> = mol
896 .graph
897 .edge_indices()
898 .map(|e| {
899 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
900 (a.index(), b.index())
901 })
902 .collect();
903 charges::gasteiger::gasteiger_marsili_charges(&elements, &bonds, &formal_charges, 6)
904}
905
906pub fn compute_sasa(
910 elements: &[u8],
911 coords_flat: &[f64],
912 probe_radius: Option<f64>,
913) -> Result<surface::sasa::SasaResult, String> {
914 if coords_flat.len() != elements.len() * 3 {
915 return Err(format!(
916 "coords length {} != 3 * elements {}",
917 coords_flat.len(),
918 elements.len()
919 ));
920 }
921 let positions: Vec<[f64; 3]> = coords_flat
922 .chunks_exact(3)
923 .map(|c| [c[0], c[1], c[2]])
924 .collect();
925
926 #[cfg(feature = "parallel")]
927 {
928 Ok(surface::sasa::compute_sasa_parallel(
929 elements,
930 &positions,
931 probe_radius,
932 None,
933 ))
934 }
935
936 #[cfg(not(feature = "parallel"))]
937 {
938 Ok(surface::sasa::compute_sasa(
939 elements,
940 &positions,
941 probe_radius,
942 None,
943 ))
944 }
945}
946
947pub fn compute_population(
949 elements: &[u8],
950 positions: &[[f64; 3]],
951) -> Result<population::PopulationResult, String> {
952 let eht_result = eht::solve_eht(elements, positions, None)?;
953 Ok(population::compute_population(
954 elements,
955 positions,
956 &eht_result.coefficients,
957 eht_result.n_electrons,
958 ))
959}
960
961pub fn compute_dipole(
963 elements: &[u8],
964 positions: &[[f64; 3]],
965) -> Result<dipole::DipoleResult, String> {
966 let eht_result = eht::solve_eht(elements, positions, None)?;
967 Ok(dipole::compute_dipole_from_eht(
968 elements,
969 positions,
970 &eht_result.coefficients,
971 eht_result.n_electrons,
972 ))
973}
974
975pub fn compute_frontier_descriptors(
977 elements: &[u8],
978 positions: &[[f64; 3]],
979) -> Result<reactivity::FrontierDescriptors, String> {
980 let eht_result = eht::solve_eht(elements, positions, None)?;
981 Ok(reactivity::compute_frontier_descriptors(
982 elements,
983 positions,
984 &eht_result,
985 ))
986}
987
988pub fn compute_fukui_descriptors(
990 elements: &[u8],
991 positions: &[[f64; 3]],
992) -> Result<reactivity::FukuiDescriptors, String> {
993 let eht_result = eht::solve_eht(elements, positions, None)?;
994 Ok(reactivity::compute_fukui_descriptors(
995 elements,
996 positions,
997 &eht_result,
998 ))
999}
1000
1001pub fn compute_reactivity_ranking(
1003 elements: &[u8],
1004 positions: &[[f64; 3]],
1005) -> Result<reactivity::ReactivityRanking, String> {
1006 let eht_result = eht::solve_eht(elements, positions, None)?;
1007 let fukui = reactivity::compute_fukui_descriptors(elements, positions, &eht_result);
1008 let pop = population::compute_population(
1009 elements,
1010 positions,
1011 &eht_result.coefficients,
1012 eht_result.n_electrons,
1013 );
1014 Ok(reactivity::rank_reactivity_sites(
1015 &fukui,
1016 &pop.mulliken_charges,
1017 ))
1018}
1019
1020pub fn compute_uv_vis_spectrum(
1022 elements: &[u8],
1023 positions: &[[f64; 3]],
1024 sigma: f64,
1025 e_min: f64,
1026 e_max: f64,
1027 n_points: usize,
1028) -> Result<reactivity::UvVisSpectrum, String> {
1029 let eht_result = eht::solve_eht(elements, positions, None)?;
1030 Ok(reactivity::compute_uv_vis_like_spectrum(
1031 &eht_result,
1032 sigma,
1033 e_min,
1034 e_max,
1035 n_points,
1036 ))
1037}
1038
1039pub fn compute_bond_orders(
1041 elements: &[u8],
1042 positions: &[[f64; 3]],
1043) -> Result<population::BondOrderResult, String> {
1044 let eht_result = eht::solve_eht(elements, positions, None)?;
1045 Ok(population::compute_bond_orders(
1046 elements,
1047 positions,
1048 &eht_result.coefficients,
1049 eht_result.n_electrons,
1050 ))
1051}
1052
1053pub fn compute_topology(
1055 elements: &[u8],
1056 positions: &[[f64; 3]],
1057) -> topology::TopologyAnalysisResult {
1058 topology::analyze_topology(elements, positions)
1059}
1060
1061pub fn analyze_graph_features(smiles: &str) -> Result<GraphFeatureAnalysis, String> {
1063 use petgraph::visit::EdgeRef;
1064
1065 let mol = parse(smiles)?;
1066 let n_atoms = mol.graph.node_count();
1067 let mut aromatic_atoms = vec![false; n_atoms];
1068 let mut aromatic_bonds = Vec::new();
1069
1070 for edge in mol.graph.edge_references() {
1071 if matches!(edge.weight().order, graph::BondOrder::Aromatic) {
1072 let i = edge.source().index();
1073 let j = edge.target().index();
1074 aromatic_atoms[i] = true;
1075 aromatic_atoms[j] = true;
1076 aromatic_bonds.push((i, j));
1077 }
1078 }
1079
1080 let mut tagged_tetrahedral_centers = Vec::new();
1081 let mut inferred_tetrahedral_centers = Vec::new();
1082 for i in 0..n_atoms {
1083 let idx = petgraph::graph::NodeIndex::new(i);
1084 let atom = &mol.graph[idx];
1085 if matches!(
1086 atom.chiral_tag,
1087 graph::ChiralType::TetrahedralCW | graph::ChiralType::TetrahedralCCW
1088 ) {
1089 tagged_tetrahedral_centers.push(i);
1090 }
1091
1092 let neighbors: Vec<_> = mol.graph.neighbors(idx).collect();
1093 if neighbors.len() == 4 && matches!(atom.hybridization, graph::Hybridization::SP3) {
1094 let mut signature: Vec<u8> = neighbors.iter().map(|n| mol.graph[*n].element).collect();
1095 signature.sort_unstable();
1096 signature.dedup();
1097 if signature.len() >= 3 {
1098 inferred_tetrahedral_centers.push(i);
1099 }
1100 }
1101 }
1102
1103 Ok(GraphFeatureAnalysis {
1104 aromaticity: AromaticityAnalysis {
1105 aromatic_atoms,
1106 aromatic_bonds,
1107 },
1108 stereocenters: StereocenterAnalysis {
1109 tagged_tetrahedral_centers,
1110 inferred_tetrahedral_centers,
1111 },
1112 })
1113}
1114
1115pub fn compute_eht_or_uff_fallback(
1120 smiles: &str,
1121 elements: &[u8],
1122 positions: &[[f64; 3]],
1123 allow_experimental_eht: bool,
1124) -> Result<ElectronicWorkflowResult, String> {
1125 let support = get_eht_support(elements);
1126 let should_fallback = match support.level {
1127 eht::SupportLevel::Unsupported => true,
1128 eht::SupportLevel::Experimental => !allow_experimental_eht,
1129 eht::SupportLevel::Supported => false,
1130 };
1131
1132 if should_fallback {
1133 let coords_flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1134 let energy = compute_uff_energy(smiles, &coords_flat).map_err(|e| {
1135 format!(
1136 "EHT is not appropriate for this system (support: {:?}) and UFF fallback failed: {}",
1137 support.level, e
1138 )
1139 })?;
1140 return Ok(ElectronicWorkflowResult::UffFallback {
1141 energy_kcal_mol: energy,
1142 reason: if matches!(support.level, eht::SupportLevel::Unsupported) {
1143 "EHT unsupported for one or more elements; routed to UFF-only workflow.".to_string()
1144 } else {
1145 "EHT confidence is experimental and experimental mode is disabled; routed to UFF-only workflow."
1146 .to_string()
1147 },
1148 support,
1149 });
1150 }
1151
1152 let result = eht::solve_eht(elements, positions, None)?;
1153 Ok(ElectronicWorkflowResult::Eht { result })
1154}
1155
1156pub fn compare_methods(
1161 smiles: &str,
1162 elements: &[u8],
1163 positions: &[[f64; 3]],
1164 allow_experimental_eht: bool,
1165) -> MethodComparisonResult {
1166 let plan = get_system_method_plan(elements);
1167 let mut comparisons = Vec::new();
1168
1169 let coords_flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1170
1171 {
1172 let meta = build_method_metadata(
1173 ScientificMethod::Uff,
1174 &plan.capabilities.uff,
1175 &["Comparison uses UFF force-field energy as the UFF observable."],
1176 );
1177 if !meta.available {
1178 comparisons.push(MethodComparisonEntry {
1179 method: ScientificMethod::Uff,
1180 status: MethodComparisonStatus::Unavailable,
1181 available: false,
1182 confidence: meta.confidence,
1183 confidence_score: meta.confidence_score,
1184 warnings: meta.warnings,
1185 limitations: meta.limitations,
1186 payload: None,
1187 error: Some("UFF is unavailable for this element set.".to_string()),
1188 });
1189 } else {
1190 match compute_uff_energy(smiles, &coords_flat) {
1191 Ok(energy) => comparisons.push(MethodComparisonEntry {
1192 method: ScientificMethod::Uff,
1193 status: MethodComparisonStatus::Success,
1194 available: true,
1195 confidence: meta.confidence,
1196 confidence_score: meta.confidence_score,
1197 warnings: meta.warnings,
1198 limitations: meta.limitations,
1199 payload: Some(MethodComparisonPayload::Uff {
1200 energy_kcal_mol: energy,
1201 }),
1202 error: None,
1203 }),
1204 Err(err) => comparisons.push(MethodComparisonEntry {
1205 method: ScientificMethod::Uff,
1206 status: MethodComparisonStatus::Error,
1207 available: true,
1208 confidence: meta.confidence,
1209 confidence_score: meta.confidence_score,
1210 warnings: meta.warnings,
1211 limitations: meta.limitations,
1212 payload: None,
1213 error: Some(err),
1214 }),
1215 }
1216 }
1217 }
1218
1219 {
1220 let meta = build_method_metadata(
1221 ScientificMethod::Eht,
1222 &plan.capabilities.eht,
1223 &["Comparison uses frontier orbital energies and gap as the EHT observable."],
1224 );
1225
1226 if !meta.available {
1227 comparisons.push(MethodComparisonEntry {
1228 method: ScientificMethod::Eht,
1229 status: MethodComparisonStatus::Unavailable,
1230 available: false,
1231 confidence: meta.confidence,
1232 confidence_score: meta.confidence_score,
1233 warnings: meta.warnings,
1234 limitations: meta.limitations,
1235 payload: None,
1236 error: Some("EHT is unavailable for this element set.".to_string()),
1237 });
1238 } else if matches!(meta.confidence, eht::SupportLevel::Experimental)
1239 && !allow_experimental_eht
1240 {
1241 comparisons.push(MethodComparisonEntry {
1242 method: ScientificMethod::Eht,
1243 status: MethodComparisonStatus::Unavailable,
1244 available: true,
1245 confidence: meta.confidence,
1246 confidence_score: meta.confidence_score,
1247 warnings: meta.warnings,
1248 limitations: meta.limitations,
1249 payload: None,
1250 error: Some(
1251 "EHT confidence is experimental and allow_experimental_eht=false.".to_string(),
1252 ),
1253 });
1254 } else {
1255 match eht::solve_eht(elements, positions, None) {
1256 Ok(result) => comparisons.push(MethodComparisonEntry {
1257 method: ScientificMethod::Eht,
1258 status: MethodComparisonStatus::Success,
1259 available: true,
1260 confidence: meta.confidence,
1261 confidence_score: meta.confidence_score,
1262 warnings: meta.warnings,
1263 limitations: meta.limitations,
1264 payload: Some(MethodComparisonPayload::Eht {
1265 homo_energy: result.homo_energy,
1266 lumo_energy: result.lumo_energy,
1267 gap: result.gap,
1268 support: result.support,
1269 }),
1270 error: None,
1271 }),
1272 Err(err) => comparisons.push(MethodComparisonEntry {
1273 method: ScientificMethod::Eht,
1274 status: MethodComparisonStatus::Error,
1275 available: true,
1276 confidence: meta.confidence,
1277 confidence_score: meta.confidence_score,
1278 warnings: meta.warnings,
1279 limitations: meta.limitations,
1280 payload: None,
1281 error: Some(err),
1282 }),
1283 }
1284 }
1285 }
1286
1287 MethodComparisonResult { plan, comparisons }
1288}
1289
1290pub fn compute_esp(
1292 elements: &[u8],
1293 positions: &[[f64; 3]],
1294 spacing: f64,
1295 padding: f64,
1296) -> Result<esp::EspGrid, String> {
1297 let pop = compute_population(elements, positions)?;
1298 let (grid, _report) = gpu::esp_grid_gpu::compute_esp_grid_with_report(
1299 elements,
1300 positions,
1301 &pop.mulliken_charges,
1302 spacing,
1303 padding,
1304 );
1305 Ok(grid)
1306}
1307
1308pub fn compute_dos(
1310 elements: &[u8],
1311 positions: &[[f64; 3]],
1312 sigma: f64,
1313 e_min: f64,
1314 e_max: f64,
1315 n_points: usize,
1316) -> Result<dos::DosResult, String> {
1317 let eht_result = eht::solve_eht(elements, positions, None)?;
1318 let flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1319
1320 #[cfg(feature = "parallel")]
1321 {
1322 Ok(dos::compute_pdos_parallel(
1323 elements,
1324 &flat,
1325 &eht_result.energies,
1326 &eht_result.coefficients,
1327 eht_result.n_electrons,
1328 sigma,
1329 e_min,
1330 e_max,
1331 n_points,
1332 ))
1333 }
1334
1335 #[cfg(not(feature = "parallel"))]
1336 {
1337 Ok(dos::compute_pdos(
1338 elements,
1339 &flat,
1340 &eht_result.energies,
1341 &eht_result.coefficients,
1342 eht_result.n_electrons,
1343 sigma,
1344 e_min,
1345 e_max,
1346 n_points,
1347 ))
1348 }
1349}
1350
1351pub fn compute_rmsd(coords: &[f64], reference: &[f64]) -> f64 {
1353 alignment::compute_rmsd(coords, reference)
1354}
1355
1356pub fn compute_uff_energy(smiles: &str, coords: &[f64]) -> Result<f64, String> {
1358 let mol = graph::Molecule::from_smiles(smiles)?;
1359 let n = mol.graph.node_count();
1360 if coords.len() != n * 3 {
1361 return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1362 }
1363 let ff = forcefield::builder::build_uff_force_field(&mol);
1364 let mut gradient = vec![0.0f64; n * 3];
1365 let energy = ff.compute_system_energy_and_gradients(coords, &mut gradient);
1366 Ok(energy)
1367}
1368
1369pub fn compute_mmff94_energy(smiles: &str, coords: &[f64]) -> Result<f64, String> {
1376 let mol = graph::Molecule::from_smiles(smiles)?;
1377 let n = mol.graph.node_count();
1378 if coords.len() != n * 3 {
1379 return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1380 }
1381 let elements: Vec<u8> = (0..n)
1382 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
1383 .collect();
1384 let bonds: Vec<(usize, usize, u8)> = mol
1385 .graph
1386 .edge_indices()
1387 .map(|e| {
1388 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
1389 let order = match mol.graph[e].order {
1390 graph::BondOrder::Single => 1u8,
1391 graph::BondOrder::Double => 2,
1392 graph::BondOrder::Triple => 3,
1393 graph::BondOrder::Aromatic => 2,
1394 graph::BondOrder::Unknown => 1,
1395 };
1396 (a.index(), b.index(), order)
1397 })
1398 .collect();
1399 let terms = forcefield::mmff94::Mmff94Builder::build(&elements, &bonds);
1400 let (energy, _grad) = forcefield::mmff94::Mmff94Builder::total_energy(&terms, coords);
1401 Ok(energy)
1402}
1403
1404pub fn compute_pm3(elements: &[u8], positions: &[[f64; 3]]) -> Result<pm3::Pm3Result, String> {
1411 pm3::solve_pm3(elements, positions)
1412}
1413
1414pub fn compute_pm3_gradient(
1419 elements: &[u8],
1420 positions: &[[f64; 3]],
1421) -> Result<pm3::Pm3GradientResult, String> {
1422 pm3::compute_pm3_gradient(elements, positions)
1423}
1424
1425pub fn compute_xtb(elements: &[u8], positions: &[[f64; 3]]) -> Result<xtb::XtbResult, String> {
1433 let gfn2 = xtb::gfn2::solve_gfn2(elements, positions)?;
1434 Ok(xtb::XtbResult {
1435 orbital_energies: gfn2.orbital_energies,
1436 electronic_energy: gfn2.electronic_energy,
1437 repulsive_energy: gfn2.repulsive_energy,
1438 total_energy: gfn2.total_energy,
1439 n_basis: gfn2.n_basis,
1440 n_electrons: gfn2.n_electrons,
1441 homo_energy: gfn2.homo_energy,
1442 lumo_energy: gfn2.lumo_energy,
1443 gap: gfn2.gap,
1444 mulliken_charges: gfn2.mulliken_charges,
1445 scc_iterations: gfn2.scc_iterations,
1446 converged: gfn2.converged,
1447 })
1448}
1449
1450pub fn compute_xtb_gradient(
1455 elements: &[u8],
1456 positions: &[[f64; 3]],
1457) -> Result<xtb::XtbGradientResult, String> {
1458 xtb::compute_xtb_gradient(elements, positions)
1459}
1460
1461pub fn compute_stda_uvvis(
1468 elements: &[u8],
1469 positions: &[[f64; 3]],
1470 sigma: f64,
1471 e_min: f64,
1472 e_max: f64,
1473 n_points: usize,
1474 broadening: reactivity::BroadeningType,
1475) -> Result<reactivity::StdaUvVisSpectrum, String> {
1476 reactivity::compute_stda_uvvis_spectrum(
1477 elements, positions, sigma, e_min, e_max, n_points, broadening,
1478 )
1479}
1480
1481pub fn compute_vibrational_analysis(
1488 elements: &[u8],
1489 positions: &[[f64; 3]],
1490 method: &str,
1491 step_size: Option<f64>,
1492) -> Result<ir::VibrationalAnalysis, String> {
1493 let hessian_method =
1494 match method {
1495 "eht" => ir::HessianMethod::Eht,
1496 "pm3" => ir::HessianMethod::Pm3,
1497 "xtb" => ir::HessianMethod::Xtb,
1498 "uff" => return Err(
1499 "UFF vibrational analysis requires SMILES; use compute_vibrational_analysis_uff"
1500 .to_string(),
1501 ),
1502 _ => return Err(format!("Unknown method '{}', use eht/pm3/xtb/uff", method)),
1503 };
1504 ir::compute_vibrational_analysis(elements, positions, hessian_method, step_size)
1505}
1506
1507pub fn compute_vibrational_analysis_uff(
1512 smiles: &str,
1513 elements: &[u8],
1514 positions: &[[f64; 3]],
1515 step_size: Option<f64>,
1516) -> Result<ir::VibrationalAnalysis, String> {
1517 ir::vibrations::compute_vibrational_analysis_uff(smiles, elements, positions, step_size)
1518}
1519
1520pub fn compute_ir_spectrum(
1526 analysis: &ir::VibrationalAnalysis,
1527 gamma: f64,
1528 wn_min: f64,
1529 wn_max: f64,
1530 n_points: usize,
1531) -> ir::IrSpectrum {
1532 ir::compute_ir_spectrum(analysis, gamma, wn_min, wn_max, n_points)
1533}
1534
1535pub fn predict_nmr_shifts(smiles: &str) -> Result<nmr::NmrShiftResult, String> {
1537 let mol = graph::Molecule::from_smiles(smiles)?;
1538 Ok(nmr::predict_chemical_shifts(&mol))
1539}
1540
1541pub fn predict_nmr_shifts_for_nucleus(
1543 smiles: &str,
1544 nucleus: &str,
1545) -> Result<Vec<nmr::ChemicalShift>, String> {
1546 let mol = graph::Molecule::from_smiles(smiles)?;
1547 let nucleus = parse_nmr_nucleus(nucleus)?;
1548 Ok(nmr::predict_chemical_shifts_for_nucleus(&mol, nucleus))
1549}
1550
1551pub fn predict_nmr_couplings(
1556 smiles: &str,
1557 positions: &[[f64; 3]],
1558) -> Result<Vec<nmr::JCoupling>, String> {
1559 let mol = graph::Molecule::from_smiles(smiles)?;
1560 Ok(nmr::predict_j_couplings(&mol, positions))
1561}
1562
1563fn parse_nmr_nucleus(nucleus: &str) -> Result<nmr::NmrNucleus, String> {
1564 nmr::NmrNucleus::parse(nucleus)
1565}
1566
1567struct GiaoNmrPreflight {
1568 nucleus: nmr::NmrNucleus,
1569 system: scf::types::MolecularSystem,
1570 basis: scf::basis::BasisSet,
1571 fallback_elements: Vec<u8>,
1572}
1573
1574const EXPLICIT_GIAO_STO3G_ELEMENTS: &[u8] =
1575 &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 15, 16, 17, 35, 53];
1576
1577fn has_explicit_giao_sto3g_support(z: u8) -> bool {
1578 EXPLICIT_GIAO_STO3G_ELEMENTS.contains(&z)
1579}
1580
1581fn format_element_label(z: u8) -> String {
1582 nmr::NmrNucleus::default_for_element(z)
1583 .map(|nucleus| format!("{}({})", nucleus.element_symbol(), z))
1584 .unwrap_or_else(|| format!("Z{}", z))
1585}
1586
1587fn format_element_set(elements: &[u8]) -> String {
1588 let mut unique = elements.to_vec();
1589 unique.sort_unstable();
1590 unique.dedup();
1591 unique
1592 .into_iter()
1593 .map(format_element_label)
1594 .collect::<Vec<_>>()
1595 .join(", ")
1596}
1597
1598fn preflight_giao_nmr(
1599 elements: &[u8],
1600 positions: &[[f64; 3]],
1601 nucleus: &str,
1602 config: &GiaoNmrConfig,
1603) -> Result<GiaoNmrPreflight, String> {
1604 if elements.is_empty() {
1605 return Err("GIAO NMR requires at least one atom.".to_string());
1606 }
1607 if elements.len() != positions.len() {
1608 return Err(format!(
1609 "GIAO NMR requires one 3D coordinate per atom; got {} elements and {} positions.",
1610 elements.len(),
1611 positions.len()
1612 ));
1613 }
1614 if config.max_scf_iterations == 0 {
1615 return Err("GIAO NMR requires max_scf_iterations > 0.".to_string());
1616 }
1617 if config.multiplicity != 1 {
1618 return Err(format!(
1619 "The public GIAO NMR API currently supports singlet closed-shell systems only; got multiplicity {}.",
1620 config.multiplicity
1621 ));
1622 }
1623
1624 let parsed_nucleus = parse_nmr_nucleus(nucleus)?;
1625 if !elements
1626 .iter()
1627 .any(|&z| z == parsed_nucleus.atomic_number())
1628 {
1629 return Err(format!(
1630 "Requested nucleus {} is not present in the provided element list.",
1631 parsed_nucleus.canonical()
1632 ));
1633 }
1634
1635 let system = scf::types::MolecularSystem::from_angstrom(
1636 elements,
1637 positions,
1638 config.charge,
1639 config.multiplicity,
1640 );
1641 if !system.n_electrons().is_multiple_of(2) {
1642 return Err(format!(
1643 "The public GIAO NMR API currently supports even-electron closed-shell systems only; got {} electrons.",
1644 system.n_electrons()
1645 ));
1646 }
1647
1648 let fallback_elements: Vec<u8> = elements
1649 .iter()
1650 .copied()
1651 .filter(|&z| !has_explicit_giao_sto3g_support(z))
1652 .collect();
1653
1654 if !config.allow_basis_fallback && !fallback_elements.is_empty() {
1655 return Err(format!(
1656 "The public GIAO NMR SCF route only has explicit STO-3G basis data for {}. This system includes fallback-only elements: {}. Re-run with GiaoNmrConfig.allow_basis_fallback=true for a screening-only attempt.",
1657 format_element_set(EXPLICIT_GIAO_STO3G_ELEMENTS),
1658 format_element_set(&fallback_elements)
1659 ));
1660 }
1661
1662 let basis = scf::basis::BasisSet::sto3g(&system.atomic_numbers, &system.positions_bohr);
1663 let n_occupied = system.n_electrons() / 2;
1664 if n_occupied > basis.n_basis {
1665 return Err(format!(
1666 "GIAO NMR preflight failed for {}: STO-3G supplies {} basis functions for {} occupied orbitals. This usually means one or more elements only have the hydrogen-like fallback basis in the current SCF path.",
1667 parsed_nucleus.canonical(),
1668 basis.n_basis,
1669 n_occupied
1670 ));
1671 }
1672
1673 Ok(GiaoNmrPreflight {
1674 nucleus: parsed_nucleus,
1675 system,
1676 basis,
1677 fallback_elements,
1678 })
1679}
1680
1681pub fn compute_giao_nmr(
1683 elements: &[u8],
1684 positions: &[[f64; 3]],
1685 nucleus: &str,
1686) -> Result<GiaoNmrResult, String> {
1687 compute_giao_nmr_configured(elements, positions, nucleus, &GiaoNmrConfig::default())
1688}
1689
1690pub fn compute_giao_nmr_configured(
1692 elements: &[u8],
1693 positions: &[[f64; 3]],
1694 nucleus: &str,
1695 config: &GiaoNmrConfig,
1696) -> Result<GiaoNmrResult, String> {
1697 let request = preflight_giao_nmr(elements, positions, nucleus, config)?;
1698
1699 let scf_config = scf::scf_loop::ScfConfig {
1700 max_iterations: config.max_scf_iterations,
1701 use_parallel_eri: config.use_parallel_eri,
1702 parallel_threshold: if config.use_parallel_eri { 0 } else { 20 },
1703 ..scf::scf_loop::ScfConfig::default()
1704 };
1705
1706 let scf = scf::scf_loop::run_scf(&request.system, &scf_config);
1707 let shieldings = spectroscopy::compute_nmr_shieldings_for_nucleus(
1708 request.nucleus,
1709 &request.system.atomic_numbers,
1710 &request.system.positions_bohr,
1711 &spectroscopy::ScfInput::from(&scf),
1712 &request.basis.function_to_atom,
1713 );
1714 if shieldings.is_empty() {
1715 return Err(format!(
1716 "Requested nucleus {} is not present in the provided element list.",
1717 request.nucleus.canonical()
1718 ));
1719 }
1720
1721 let entries: Vec<GiaoNmrEntry> = shieldings
1722 .iter()
1723 .map(|entry| GiaoNmrEntry {
1724 atom_index: entry.atom_index,
1725 element: entry.element,
1726 tensor: entry.tensor,
1727 isotropic: entry.isotropic,
1728 anisotropy: entry.anisotropy,
1729 chemical_shift: entry.chemical_shift,
1730 })
1731 .collect();
1732
1733 let mut notes = vec![format!(
1734 "GIAO NMR for {} via the public SCF route (RHF/STO-3G).",
1735 request.nucleus.unicode_label()
1736 )];
1737 if scf.converged {
1738 notes.push(format!(
1739 "SCF converged in {} iterations with total energy {:.6} Hartree.",
1740 scf.scf_iterations, scf.total_energy
1741 ));
1742 } else {
1743 notes.push(format!(
1744 "SCF did not reach the requested threshold after {} iterations; treat the returned shieldings as screening-level only.",
1745 scf.scf_iterations
1746 ));
1747 }
1748 if request.fallback_elements.is_empty() {
1749 notes.push(
1750 "All elements in this system use explicit STO-3G basis data in the current SCF path."
1751 .to_string(),
1752 );
1753 } else {
1754 notes.push(format!(
1755 "Fallback basis enabled for {}. Heavy-element shieldings are qualitative in this mode.",
1756 format_element_set(&request.fallback_elements)
1757 ));
1758 }
1759 if request.nucleus.is_quadrupolar() {
1760 notes.push(
1761 "Quadrupolar nucleus selected: this API returns isotropic shieldings and chemical shifts only; relaxation-driven broadening still needs experimental lineshape modeling.".to_string(),
1762 );
1763 }
1764 if !request.nucleus.is_primary_target() {
1765 notes.push(
1766 "Non-1H/13C nuclei remain sensitive to the reference shielding and basis choice; benchmark the target family before quantitative use.".to_string(),
1767 );
1768 }
1769
1770 Ok(GiaoNmrResult {
1771 nucleus: request.nucleus.canonical().to_string(),
1772 target_atomic_number: request.nucleus.atomic_number(),
1773 method: "RHF/GIAO".to_string(),
1774 basis_set: "STO-3G".to_string(),
1775 charge: config.charge,
1776 multiplicity: config.multiplicity,
1777 scf_converged: scf.converged,
1778 scf_iterations: scf.scf_iterations,
1779 total_energy_hartree: scf.total_energy,
1780 n_basis: scf.n_basis,
1781 n_target_atoms: entries.len(),
1782 chemical_shifts: entries.iter().map(|entry| entry.chemical_shift).collect(),
1783 shieldings: entries,
1784 fallback_elements: request.fallback_elements,
1785 notes,
1786 })
1787}
1788
1789pub fn compute_nmr_spectrum(
1796 smiles: &str,
1797 nucleus: &str,
1798 gamma: f64,
1799 ppm_min: f64,
1800 ppm_max: f64,
1801 n_points: usize,
1802) -> Result<nmr::NmrSpectrum, String> {
1803 compute_nmr_spectrum_with_coords(smiles, &[], nucleus, gamma, ppm_min, ppm_max, n_points)
1804}
1805
1806pub fn compute_nmr_spectrum_with_coords(
1809 smiles: &str,
1810 positions: &[[f64; 3]],
1811 nucleus: &str,
1812 gamma: f64,
1813 ppm_min: f64,
1814 ppm_max: f64,
1815 n_points: usize,
1816) -> Result<nmr::NmrSpectrum, String> {
1817 let mol = graph::Molecule::from_smiles(smiles)?;
1818 let couplings = nmr::predict_j_couplings(&mol, positions);
1819 let nuc = parse_nmr_nucleus(nucleus)?;
1820 let shifts = nmr::predict_chemical_shifts_for_nucleus(&mol, nuc);
1821 Ok(nmr::spectrum::compute_nmr_spectrum_for_shifts(
1822 &shifts, &couplings, nuc, gamma, ppm_min, ppm_max, n_points,
1823 ))
1824}
1825
1826pub fn compute_hose_codes(smiles: &str, max_radius: usize) -> Result<Vec<nmr::HoseCode>, String> {
1828 let mol = graph::Molecule::from_smiles(smiles)?;
1829 Ok(nmr::hose::generate_hose_codes(&mol, max_radius))
1830}
1831
1832pub fn compute_orbital_mesh(
1844 elements: &[u8],
1845 positions: &[[f64; 3]],
1846 method: &str,
1847 mo_index: usize,
1848 spacing: f64,
1849 padding: f64,
1850 isovalue: f32,
1851) -> Result<mesh::OrbitalMeshResult, String> {
1852 let m = match method.to_lowercase().as_str() {
1853 "eht" | "huckel" => mesh::MeshMethod::Eht,
1854 "pm3" => mesh::MeshMethod::Pm3,
1855 "xtb" | "gfn0" | "gfn-xtb" => mesh::MeshMethod::Xtb,
1856 "hf3c" | "hf-3c" | "hf" => mesh::MeshMethod::Hf3c,
1857 _ => {
1858 return Err(format!(
1859 "Unknown method '{}'. Supported: eht, pm3, xtb, hf3c",
1860 method
1861 ))
1862 }
1863 };
1864 mesh::compute_orbital_mesh(elements, positions, m, mo_index, spacing, padding, isovalue)
1865}
1866
1867pub fn compute_ml_descriptors(
1874 elements: &[u8],
1875 bonds: &[(usize, usize, u8)],
1876 charges: &[f64],
1877 aromatic_atoms: &[bool],
1878) -> ml::MolecularDescriptors {
1879 ml::compute_descriptors(elements, bonds, charges, aromatic_atoms)
1880}
1881
1882pub fn predict_ml_properties(desc: &ml::MolecularDescriptors) -> ml::MlPropertyResult {
1887 ml::predict_properties(desc)
1888}
1889
1890pub fn predict_ensemble(
1895 elements: &[u8],
1896 bonds: &[(usize, usize, u8)],
1897 charges: &[f64],
1898 aromatic_atoms: &[bool],
1899) -> ml::EnsembleResult {
1900 let desc = ml::compute_descriptors(elements, bonds, charges, aromatic_atoms);
1901 ml::predict_ensemble(&desc, elements, bonds)
1902}
1903
1904pub fn compute_tpsa(elements: &[u8], bonds: &[(usize, usize, u8)]) -> f64 {
1906 ml::compute_tpsa(elements, bonds)
1907}
1908
1909pub fn compute_md_trajectory(
1911 smiles: &str,
1912 coords: &[f64],
1913 n_steps: usize,
1914 dt_fs: f64,
1915 seed: u64,
1916) -> Result<dynamics::MdTrajectory, String> {
1917 dynamics::simulate_velocity_verlet_uff(smiles, coords, n_steps, dt_fs, seed, None)
1918}
1919
1920pub fn compute_md_trajectory_nvt(
1922 smiles: &str,
1923 coords: &[f64],
1924 n_steps: usize,
1925 dt_fs: f64,
1926 seed: u64,
1927 target_temp_k: f64,
1928 thermostat_tau_fs: f64,
1929) -> Result<dynamics::MdTrajectory, String> {
1930 dynamics::simulate_velocity_verlet_uff(
1931 smiles,
1932 coords,
1933 n_steps,
1934 dt_fs,
1935 seed,
1936 Some((target_temp_k, thermostat_tau_fs)),
1937 )
1938}
1939
1940pub fn compute_simplified_neb_path(
1942 smiles: &str,
1943 start_coords: &[f64],
1944 end_coords: &[f64],
1945 n_images: usize,
1946 n_iter: usize,
1947 spring_k: f64,
1948 step_size: f64,
1949) -> Result<dynamics::NebPathResult, String> {
1950 dynamics::compute_simplified_neb_path(
1951 smiles,
1952 start_coords,
1953 end_coords,
1954 n_images,
1955 n_iter,
1956 spring_k,
1957 step_size,
1958 )
1959}
1960
1961pub fn compute_simplified_neb_path_configurable(
1965 smiles: &str,
1966 start_coords: &[f64],
1967 end_coords: &[f64],
1968 n_images: usize,
1969 n_iter: usize,
1970 spring_k: f64,
1971 step_size: f64,
1972 method: &str,
1973) -> Result<dynamics::NebPathResult, String> {
1974 dynamics::compute_simplified_neb_path_configurable(
1975 smiles,
1976 start_coords,
1977 end_coords,
1978 n_images,
1979 n_iter,
1980 spring_k,
1981 step_size,
1982 method,
1983 )
1984}
1985
1986pub fn neb_backend_energy_kcal(method: &str, smiles: &str, coords: &[f64]) -> Result<f64, String> {
1990 dynamics::neb_backend_energy_kcal(method, smiles, coords)
1991}
1992
1993pub fn neb_backend_energy_and_gradient(
1997 method: &str,
1998 smiles: &str,
1999 coords: &[f64],
2000) -> Result<(f64, Vec<f64>), String> {
2001 dynamics::neb_backend_energy_and_gradient(method, smiles, coords)
2002}
2003
2004fn coords_flat_to_matrix_f32(coords: &[f64], n_atoms: usize) -> nalgebra::DMatrix<f32> {
2005 let mut m = nalgebra::DMatrix::<f32>::zeros(n_atoms, 3);
2006 for i in 0..n_atoms {
2007 m[(i, 0)] = coords[3 * i] as f32;
2008 m[(i, 1)] = coords[3 * i + 1] as f32;
2009 m[(i, 2)] = coords[3 * i + 2] as f32;
2010 }
2011 m
2012}
2013
2014fn coords_matrix_f32_to_flat(m: &nalgebra::DMatrix<f32>) -> Vec<f64> {
2015 let mut out = Vec::with_capacity(m.nrows() * 3);
2016 for i in 0..m.nrows() {
2017 out.push(m[(i, 0)] as f64);
2018 out.push(m[(i, 1)] as f64);
2019 out.push(m[(i, 2)] as f64);
2020 }
2021 out
2022}
2023
2024pub fn search_conformers_with_uff(
2033 smiles: &str,
2034 n_samples: usize,
2035 seed: u64,
2036 rmsd_threshold: f64,
2037) -> Result<ConformerSearchResult, String> {
2038 if n_samples == 0 {
2039 return Err("n_samples must be > 0".to_string());
2040 }
2041 if rmsd_threshold <= 0.0 {
2042 return Err("rmsd_threshold must be > 0".to_string());
2043 }
2044
2045 let mol = graph::Molecule::from_smiles(smiles)?;
2046 let n_atoms = mol.graph.node_count();
2047 let bounds = distgeom::smooth_bounds_matrix(distgeom::calculate_bounds_matrix(&mol));
2048
2049 let mut generated = Vec::new();
2050 let mut notes = Vec::new();
2051 let mut rotatable_bonds = 0usize;
2052
2053 for i in 0..n_samples {
2054 let sample_seed = seed.wrapping_add(i as u64);
2055 let conf = embed(smiles, sample_seed);
2056
2057 if conf.error.is_some() || conf.coords.len() != n_atoms * 3 {
2058 continue;
2059 }
2060
2061 let mut coords = coords_flat_to_matrix_f32(&conf.coords, n_atoms);
2062 let rot_mc = forcefield::optimize_torsions_monte_carlo_bounds(
2063 &mut coords,
2064 &mol,
2065 &bounds,
2066 sample_seed ^ 0x9E37_79B9_7F4A_7C15,
2067 64,
2068 0.4,
2069 );
2070 let rot_greedy = forcefield::optimize_torsions_bounds(&mut coords, &mol, &bounds, 2);
2071 let rot = rot_mc.max(rot_greedy);
2072 rotatable_bonds = rot;
2073 let coords_flat = coords_matrix_f32_to_flat(&coords);
2074
2075 match compute_uff_energy(smiles, &coords_flat) {
2076 Ok(energy_kcal_mol) => generated.push(ConformerEnsembleMember {
2077 seed: sample_seed,
2078 cluster_id: None,
2079 coords: coords_flat,
2080 energy_kcal_mol,
2081 }),
2082 Err(_) => continue,
2083 }
2084 }
2085
2086 if generated.is_empty() {
2087 return Err("failed to generate any valid conformers".to_string());
2088 }
2089
2090 generated.sort_by(|a, b| {
2091 a.energy_kcal_mol
2092 .partial_cmp(&b.energy_kcal_mol)
2093 .unwrap_or(std::cmp::Ordering::Equal)
2094 });
2095
2096 let generated_count = generated.len();
2097
2098 let mut unique = Vec::new();
2099 let mut cluster_members: Vec<Vec<u64>> = Vec::new();
2100 for candidate in generated {
2101 let existing_cluster = unique.iter().position(|u: &ConformerEnsembleMember| {
2102 compute_rmsd(&candidate.coords, &u.coords) < rmsd_threshold
2103 });
2104
2105 if let Some(cluster_id) = existing_cluster {
2106 cluster_members[cluster_id].push(candidate.seed);
2107 } else {
2108 unique.push(candidate.clone());
2109 cluster_members.push(vec![candidate.seed]);
2110 }
2111 }
2112
2113 for (cluster_id, member) in unique.iter_mut().enumerate() {
2114 member.cluster_id = Some(cluster_id);
2115 }
2116
2117 let clusters: Vec<ConformerClusterSummary> = unique
2118 .iter()
2119 .enumerate()
2120 .map(|(cluster_id, representative)| ConformerClusterSummary {
2121 cluster_id,
2122 representative_seed: representative.seed,
2123 size: cluster_members[cluster_id].len(),
2124 member_seeds: cluster_members[cluster_id].clone(),
2125 })
2126 .collect();
2127
2128 notes.push(
2129 "Conformers are preconditioned with Monte Carlo torsion sampling + greedy torsion refinement, ranked by UFF energy, deduplicated by Kabsch-aligned RMSD threshold, and summarized as explicit RMSD clusters."
2130 .to_string(),
2131 );
2132
2133 Ok(ConformerSearchResult {
2134 generated: generated_count,
2135 unique: unique.len(),
2136 rotatable_bonds,
2137 conformers: unique,
2138 clusters,
2139 notes,
2140 })
2141}
2142
2143pub fn compute_uff_energy_with_aromatic_heuristics(
2145 smiles: &str,
2146 coords: &[f64],
2147) -> Result<reactivity::UffHeuristicEnergy, String> {
2148 let mol = graph::Molecule::from_smiles(smiles)?;
2149 let n = mol.graph.node_count();
2150 if coords.len() != n * 3 {
2151 return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
2152 }
2153
2154 let ff = forcefield::builder::build_uff_force_field(&mol);
2155 let mut gradient = vec![0.0f64; n * 3];
2156 let raw = ff.compute_system_energy_and_gradients(coords, &mut gradient);
2157 Ok(reactivity::apply_aromatic_uff_correction(&mol, raw))
2158}
2159
2160pub fn compute_empirical_pka(smiles: &str) -> Result<reactivity::EmpiricalPkaResult, String> {
2162 let mol = graph::Molecule::from_smiles(smiles)?;
2163 let charges = compute_charges(smiles)?;
2164 Ok(reactivity::estimate_empirical_pka(&mol, &charges.charges))
2165}
2166
2167pub fn create_unit_cell(
2169 a: f64,
2170 b: f64,
2171 c: f64,
2172 alpha: f64,
2173 beta: f64,
2174 gamma: f64,
2175) -> materials::UnitCell {
2176 materials::UnitCell::from_parameters(&materials::CellParameters {
2177 a,
2178 b,
2179 c,
2180 alpha,
2181 beta,
2182 gamma,
2183 })
2184}
2185
2186pub fn assemble_framework(
2190 node: &materials::Sbu,
2191 linker: &materials::Sbu,
2192 topology: &materials::Topology,
2193 cell: &materials::UnitCell,
2194) -> materials::CrystalStructure {
2195 materials::assemble_framework(node, linker, topology, cell)
2196}
2197
2198pub fn parse_cif(input: &str) -> Result<materials::CifStructure, String> {
2204 materials::cif::parse_cif(input)
2205}
2206
2207pub fn write_cif(structure: &materials::CifStructure) -> String {
2209 materials::cif::write_cif(structure)
2210}
2211
2212pub fn compute_uhf(
2223 elements: &[u8],
2224 positions: &[[f64; 3]],
2225 charge: i32,
2226 multiplicity: u32,
2227) -> Result<scf::uhf::UhfResult, String> {
2228 let system =
2229 scf::types::MolecularSystem::from_angstrom(elements, positions, charge, multiplicity);
2230 Ok(scf::uhf::run_uhf_parallel(&system))
2231}
2232
2233pub fn compute_rohf(
2243 elements: &[u8],
2244 positions: &[[f64; 3]],
2245 charge: i32,
2246 multiplicity: u32,
2247) -> Result<scf::uhf::UhfResult, String> {
2248 let system =
2249 scf::types::MolecularSystem::from_angstrom(elements, positions, charge, multiplicity);
2250 Ok(scf::uhf::run_rohf(&system))
2251}
2252
2253pub fn compute_uhf_configured(
2255 elements: &[u8],
2256 positions: &[[f64; 3]],
2257 charge: i32,
2258 multiplicity: u32,
2259 config: &scf::uhf::UhfConfig,
2260) -> Result<scf::uhf::UhfResult, String> {
2261 let system =
2262 scf::types::MolecularSystem::from_angstrom(elements, positions, charge, multiplicity);
2263 Ok(scf::uhf::run_uhf(&system, config))
2264}
2265
2266pub fn compute_hf3c(
2274 elements: &[u8],
2275 positions: &[[f64; 3]],
2276 config: &hf::HfConfig,
2277) -> Result<hf::Hf3cResult, String> {
2278 hf::solve_hf3c(elements, positions, config)
2279}
2280
2281pub fn compute_ani(elements: &[u8], positions: &[[f64; 3]]) -> Result<ani::AniResult, String> {
2289 ani::api::compute_ani_test(elements, positions)
2290}
2291
2292pub fn compute_ani_with_models(
2299 elements: &[u8],
2300 positions: &[[f64; 3]],
2301 config: &ani::AniConfig,
2302 models: &std::collections::HashMap<u8, ani::nn::FeedForwardNet>,
2303) -> Result<ani::AniResult, String> {
2304 ani::compute_ani(elements, positions, config, models)
2305}
2306
2307pub fn compute_esp_grid(
2311 elements: &[u8],
2312 positions: &[[f64; 3]],
2313 spacing: f64,
2314 padding: f64,
2315) -> Result<esp::EspGrid, String> {
2316 compute_esp(elements, positions, spacing, padding)
2317}
2318
2319pub fn to_canonical_smiles(smiles: &str) -> Result<String, String> {
2326 let mol = graph::Molecule::from_smiles(smiles)?;
2327 Ok(canonical_smiles::to_canonical_smiles(&mol))
2328}
2329
2330pub fn analyze_stereo(smiles: &str, coords: &[f64]) -> Result<stereo::StereoAnalysis, String> {
2334 let mol = graph::Molecule::from_smiles(smiles)?;
2335 let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2336 Ok(stereo::analyze_stereo(&mol, &positions))
2337}
2338
2339pub fn compute_nonpolar_solvation(
2343 elements: &[u8],
2344 positions: &[[f64; 3]],
2345 probe_radius: Option<f64>,
2346) -> solvation::NonPolarSolvation {
2347 solvation::compute_nonpolar_solvation(elements, positions, probe_radius)
2348}
2349
2350pub fn compute_gb_solvation(
2352 elements: &[u8],
2353 positions: &[[f64; 3]],
2354 charges: &[f64],
2355 solvent_dielectric: Option<f64>,
2356 solute_dielectric: Option<f64>,
2357 probe_radius: Option<f64>,
2358) -> solvation::GbSolvation {
2359 solvation::compute_gb_solvation(
2360 elements,
2361 positions,
2362 charges,
2363 solvent_dielectric,
2364 solute_dielectric,
2365 probe_radius,
2366 )
2367}
2368
2369pub fn butina_cluster(conformers: &[Vec<f64>], rmsd_cutoff: f64) -> clustering::ClusterResult {
2373 clustering::butina_cluster(conformers, rmsd_cutoff)
2374}
2375
2376pub fn compute_rmsd_matrix(conformers: &[Vec<f64>]) -> Vec<Vec<f64>> {
2378 clustering::compute_rmsd_matrix(conformers)
2379}
2380
2381pub fn compute_sssr(smiles: &str) -> Result<rings::sssr::SssrResult, String> {
2385 let mol = graph::Molecule::from_smiles(smiles)?;
2386 Ok(rings::sssr::compute_sssr(&mol))
2387}
2388
2389pub fn compute_ecfp(
2391 smiles: &str,
2392 radius: usize,
2393 n_bits: usize,
2394) -> Result<rings::ecfp::ECFPFingerprint, String> {
2395 let mol = graph::Molecule::from_smiles(smiles)?;
2396 Ok(rings::ecfp::compute_ecfp(&mol, radius, n_bits))
2397}
2398
2399pub fn compute_ecfp_batch(
2401 smiles_list: &[&str],
2402 radius: usize,
2403 n_bits: usize,
2404) -> Result<Vec<rings::ecfp::ECFPFingerprint>, String> {
2405 let mols: Result<Vec<_>, _> = smiles_list
2406 .iter()
2407 .map(|s| graph::Molecule::from_smiles(s))
2408 .collect();
2409 let mols = mols?;
2410 Ok(rings::ecfp::compute_ecfp_batch(&mols, radius, n_bits))
2411}
2412
2413pub fn compute_tanimoto(
2415 fp1: &rings::ecfp::ECFPFingerprint,
2416 fp2: &rings::ecfp::ECFPFingerprint,
2417) -> f64 {
2418 rings::ecfp::compute_tanimoto(fp1, fp2)
2419}
2420
2421pub fn compute_charges_configured(
2425 smiles: &str,
2426 config: &charges::gasteiger::GasteigerConfig,
2427) -> Result<charges::gasteiger::ChargeResult, String> {
2428 let mol = graph::Molecule::from_smiles(smiles)?;
2429 let n = mol.graph.node_count();
2430 let elements: Vec<u8> = (0..n)
2431 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
2432 .collect();
2433 let bonds: Vec<(usize, usize)> = mol
2434 .graph
2435 .edge_indices()
2436 .map(|e| {
2437 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
2438 (a.index(), b.index())
2439 })
2440 .collect();
2441 let formal_charges: Vec<i8> = (0..n)
2442 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].formal_charge)
2443 .collect();
2444 charges::gasteiger::gasteiger_marsili_charges_configured(
2445 &elements,
2446 &bonds,
2447 &formal_charges,
2448 config,
2449 )
2450}
2451
2452pub fn compute_ensemble_j_couplings(
2456 smiles: &str,
2457 conformer_coords: &[Vec<f64>],
2458 energies_kcal: &[f64],
2459 temperature_k: f64,
2460) -> Result<Vec<nmr::JCoupling>, String> {
2461 let mol = graph::Molecule::from_smiles(smiles)?;
2462 let positions: Vec<Vec<[f64; 3]>> = conformer_coords
2463 .iter()
2464 .map(|c| c.chunks(3).map(|p| [p[0], p[1], p[2]]).collect())
2465 .collect();
2466 Ok(nmr::coupling::ensemble_averaged_j_couplings(
2467 &mol,
2468 &positions,
2469 energies_kcal,
2470 temperature_k,
2471 ))
2472}
2473
2474pub fn compute_ir_spectrum_broadened(
2478 analysis: &ir::VibrationalAnalysis,
2479 gamma: f64,
2480 wn_min: f64,
2481 wn_max: f64,
2482 n_points: usize,
2483 broadening: &str,
2484) -> ir::IrSpectrum {
2485 let bt = match broadening {
2486 "gaussian" | "Gaussian" => ir::BroadeningType::Gaussian,
2487 _ => ir::BroadeningType::Lorentzian,
2488 };
2489 ir::vibrations::compute_ir_spectrum_with_broadening(
2490 analysis, gamma, wn_min, wn_max, n_points, bt,
2491 )
2492}
2493
2494#[cfg(test)]
2495mod tests {
2496 use super::*;
2497
2498 #[test]
2499 fn test_cisplatin_style_support_metadata() {
2500 let smiles = "[Pt](Cl)(Cl)([NH3])[NH3]";
2501 let mol = parse(smiles).expect("Cisplatin-style example should parse");
2502 let elements: Vec<u8> = (0..mol.graph.node_count())
2503 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
2504 .collect();
2505
2506 let caps = get_system_capabilities(&elements);
2507 assert!(caps.eht.available);
2508 assert!(matches!(
2509 caps.eht.confidence,
2510 eht::SupportLevel::Experimental
2511 ));
2512 }
2513
2514 #[test]
2515 fn test_pt_system_routes_to_uff_when_experimental_disabled() {
2516 let smiles = "[Pt](Cl)(Cl)([NH3])[NH3]";
2517 let mol = parse(smiles).expect("Pt example should parse");
2518 let n = mol.graph.node_count();
2519 let elements: Vec<u8> = (0..n)
2520 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
2521 .collect();
2522 let positions = vec![[0.0, 0.0, 0.0]; n];
2523
2524 let result = compute_eht_or_uff_fallback(smiles, &elements, &positions, false)
2525 .expect("Fallback workflow should return a result");
2526
2527 assert!(matches!(
2528 result,
2529 ElectronicWorkflowResult::UffFallback { .. }
2530 ));
2531 }
2532
2533 #[test]
2534 fn test_method_plan_prefers_supported_workflows_for_organic_systems() {
2535 let plan = get_system_method_plan(&[6, 1, 1, 1, 1]);
2536
2537 assert_eq!(plan.geometry.recommended, Some(ScientificMethod::Embed));
2538 assert_eq!(
2539 plan.force_field_energy.recommended,
2540 Some(ScientificMethod::Uff)
2541 );
2542 assert_eq!(plan.orbitals.recommended, Some(ScientificMethod::Eht));
2543 assert_eq!(plan.population.recommended, Some(ScientificMethod::Eht));
2544 assert_eq!(plan.orbital_grid.recommended, Some(ScientificMethod::Eht));
2545 assert!(plan.orbitals.methods[0].confidence_score > 0.9);
2546 }
2547
2548 #[test]
2549 fn test_method_plan_marks_metal_orbital_workflow_experimental() {
2550 let plan = get_system_method_plan(&[78, 17, 17, 7, 7]);
2551
2552 assert_eq!(plan.orbitals.recommended, Some(ScientificMethod::Eht));
2553 assert_eq!(
2554 plan.force_field_energy.recommended,
2555 Some(ScientificMethod::Uff)
2556 );
2557 assert!(matches!(
2558 plan.orbitals.methods[0].confidence,
2559 eht::SupportLevel::Experimental
2560 ));
2561 assert!(plan.orbitals.methods[0].confidence_score < 0.9);
2562 assert!(!plan.orbitals.methods[0].warnings.is_empty());
2563 }
2564
2565 #[test]
2566 fn test_method_plan_reports_unavailable_workflows_for_unsupported_elements() {
2567 let plan = get_system_method_plan(&[92]);
2568
2569 assert_eq!(plan.force_field_energy.recommended, None);
2570 assert_eq!(plan.orbitals.recommended, None);
2571 assert_eq!(plan.population.recommended, None);
2572 assert_eq!(plan.orbital_grid.recommended, None);
2573 assert!(!plan.orbitals.methods[0].limitations.is_empty());
2574 }
2575
2576 #[test]
2577 fn test_compare_methods_supported_system_returns_success_rows() {
2578 let result = compare_methods("CC", &[6, 6], &[[0.0, 0.0, 0.0], [1.54, 0.0, 0.0]], true);
2579 assert_eq!(result.comparisons.len(), 2);
2580 assert!(result
2581 .comparisons
2582 .iter()
2583 .any(|entry| matches!(entry.method, ScientificMethod::Uff) && entry.available));
2584 assert!(result.comparisons.iter().any(|entry| matches!(
2585 entry.method,
2586 ScientificMethod::Eht
2587 ) && matches!(
2588 entry.status,
2589 MethodComparisonStatus::Success
2590 )));
2591 }
2592
2593 #[test]
2594 fn test_compare_methods_blocks_experimental_eht_when_disabled() {
2595 let result = compare_methods("[O]", &[78], &[[0.0, 0.0, 0.0]], false);
2596 let eht_row = result
2597 .comparisons
2598 .iter()
2599 .find(|entry| matches!(entry.method, ScientificMethod::Eht))
2600 .expect("EHT row must exist");
2601 assert!(matches!(
2602 eht_row.status,
2603 MethodComparisonStatus::Unavailable
2604 ));
2605 }
2606
2607 #[test]
2608 fn test_compare_methods_reports_unavailable_for_unsupported_elements() {
2609 let result = compare_methods("[O]", &[92], &[[0.0, 0.0, 0.0]], true);
2610 assert!(result
2611 .comparisons
2612 .iter()
2613 .all(|entry| matches!(entry.status, MethodComparisonStatus::Unavailable)));
2614 }
2615
2616 #[test]
2617 fn test_compute_fukui_descriptors_returns_atomwise_output() {
2618 let elements = [8u8, 1, 1];
2619 let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
2620 let result = compute_fukui_descriptors(&elements, &positions).unwrap();
2621 assert_eq!(result.num_atoms, 3);
2622 assert_eq!(result.condensed.len(), 3);
2623 }
2624
2625 #[test]
2626 fn test_compute_uv_vis_spectrum_returns_requested_grid_size() {
2627 let elements = [6u8, 6, 1, 1, 1, 1];
2628 let positions = [
2629 [0.0, 0.0, 0.0],
2630 [1.34, 0.0, 0.0],
2631 [-0.6, 0.92, 0.0],
2632 [-0.6, -0.92, 0.0],
2633 [1.94, 0.92, 0.0],
2634 [1.94, -0.92, 0.0],
2635 ];
2636 let spectrum = compute_uv_vis_spectrum(&elements, &positions, 0.2, 0.5, 8.0, 256).unwrap();
2637 assert_eq!(spectrum.energies_ev.len(), 256);
2638 assert_eq!(spectrum.intensities.len(), 256);
2639 }
2640
2641 #[test]
2642 fn test_analyze_graph_features_reports_benzene_aromaticity() {
2643 let analysis = analyze_graph_features("c1ccccc1").unwrap();
2644 assert_eq!(analysis.aromaticity.aromatic_atoms.len(), 12);
2645 assert_eq!(
2646 analysis
2647 .aromaticity
2648 .aromatic_atoms
2649 .iter()
2650 .filter(|v| **v)
2651 .count(),
2652 6
2653 );
2654 assert_eq!(analysis.aromaticity.aromatic_bonds.len(), 6);
2655 }
2656
2657 #[test]
2658 fn test_compute_empirical_pka_finds_acidic_site_for_acetic_acid() {
2659 let result = compute_empirical_pka("CC(=O)O").unwrap();
2660 assert!(!result.acidic_sites.is_empty());
2661 }
2662
2663 #[test]
2664 fn test_compute_uff_energy_with_aromatic_heuristics_applies_correction() {
2665 let conf = embed("c1ccccc1", 42);
2666 assert!(conf.error.is_none());
2667
2668 let result = compute_uff_energy_with_aromatic_heuristics("c1ccccc1", &conf.coords).unwrap();
2669 assert!(result.aromatic_bond_count >= 6);
2670 assert!(result.corrected_energy_kcal_mol <= result.raw_energy_kcal_mol);
2671 }
2672
2673 #[test]
2674 fn test_search_conformers_with_uff_returns_ranked_unique_ensemble() {
2675 let result = search_conformers_with_uff("CCCC", 10, 42, 0.2).unwrap();
2676 assert!(result.generated >= 1);
2677 assert!(result.unique >= 1);
2678 assert_eq!(result.unique, result.conformers.len());
2679 assert_eq!(result.unique, result.clusters.len());
2680 assert!(result.rotatable_bonds >= 1);
2681
2682 let mut total_members = 0usize;
2683 for (i, cluster) in result.clusters.iter().enumerate() {
2684 assert_eq!(cluster.cluster_id, i);
2685 assert!(cluster.size >= 1);
2686 total_members += cluster.size;
2687 assert_eq!(result.conformers[i].cluster_id, Some(i));
2688 assert_eq!(result.conformers[i].seed, cluster.representative_seed);
2689 }
2690 assert_eq!(total_members, result.generated);
2691
2692 for i in 1..result.conformers.len() {
2693 assert!(
2694 result.conformers[i - 1].energy_kcal_mol <= result.conformers[i].energy_kcal_mol
2695 );
2696 }
2697 }
2698
2699 #[test]
2700 fn test_search_conformers_with_uff_large_rmsd_threshold_collapses_duplicates() {
2701 let result = search_conformers_with_uff("CCCC", 8, 123, 10.0).unwrap();
2702 assert_eq!(result.unique, 1);
2703 assert_eq!(result.clusters.len(), 1);
2704 assert_eq!(result.clusters[0].size, result.generated);
2705 }
2706
2707 #[test]
2708 fn test_compute_md_trajectory_velocity_verlet_runs() {
2709 let conf = embed("CC", 42);
2710 assert!(conf.error.is_none());
2711
2712 let trj = compute_md_trajectory("CC", &conf.coords, 10, 0.25, 7).unwrap();
2713 assert_eq!(trj.frames.len(), 11);
2714 assert!(trj
2715 .frames
2716 .iter()
2717 .all(|f| f.coords.iter().all(|v| v.is_finite())));
2718 }
2719
2720 #[test]
2721 fn test_compute_md_trajectory_nvt_runs() {
2722 let conf = embed("CCO", 42);
2723 assert!(conf.error.is_none());
2724
2725 let trj =
2726 compute_md_trajectory_nvt("CCO", &conf.coords, 12, 0.25, 17, 300.0, 10.0).unwrap();
2727 assert_eq!(trj.frames.len(), 13);
2728 assert!(trj.frames.iter().all(|f| f.temperature_k.is_finite()));
2729 }
2730
2731 #[test]
2732 fn test_compute_simplified_neb_path_runs() {
2733 let c1 = embed("CC", 42);
2734 let c2 = embed("CC", 43);
2735 assert!(c1.error.is_none());
2736 assert!(c2.error.is_none());
2737
2738 let path =
2739 compute_simplified_neb_path("CC", &c1.coords, &c2.coords, 6, 20, 0.01, 1e-5).unwrap();
2740 assert_eq!(path.images.len(), 6);
2741 assert!(path
2742 .images
2743 .iter()
2744 .all(|img| img.potential_energy_kcal_mol.is_finite()));
2745 }
2746
2747 #[test]
2748 fn test_compute_hf3c_water() {
2749 let conf = embed("O", 42);
2750 assert!(conf.error.is_none());
2751 let pos: Vec<[f64; 3]> = conf.coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2752
2753 let result = compute_hf3c(&conf.elements, &pos, &hf::HfConfig::default());
2754 assert!(result.is_ok(), "HF-3c should succeed for water");
2755 let r = result.unwrap();
2756 assert!(r.energy.is_finite());
2757 assert!(!r.orbital_energies.is_empty());
2758 }
2759
2760 #[test]
2761 fn test_compute_ani_water() {
2762 let conf = embed("O", 42);
2763 assert!(conf.error.is_none());
2764 let pos: Vec<[f64; 3]> = conf.coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2765
2766 let result = compute_ani(&conf.elements, &pos);
2767 assert!(result.is_ok(), "ANI should succeed for water");
2768 let r = result.unwrap();
2769 assert!(r.energy.is_finite());
2770 assert_eq!(r.forces.len(), 3); assert_eq!(r.species.len(), 3);
2772 }
2773
2774 #[test]
2775 fn test_compute_esp_grid_water() {
2776 let conf = embed("O", 42);
2777 assert!(conf.error.is_none());
2778 let pos: Vec<[f64; 3]> = conf.coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2779
2780 let result = compute_esp_grid(&conf.elements, &pos, 0.5, 3.0);
2781 assert!(result.is_ok(), "ESP grid should succeed for water");
2782 let g = result.unwrap();
2783 assert!(!g.values.is_empty());
2784 assert!(g.spacing > 0.0);
2785 assert!(g.dims[0] > 0 && g.dims[1] > 0 && g.dims[2] > 0);
2786 }
2787}