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