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