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