1#![allow(clippy::too_many_arguments)]
3#![allow(clippy::needless_range_loop)]
4
5pub mod alignment;
6pub mod ani;
7pub mod charges;
8pub mod clustering;
9pub mod conformer;
10pub mod dipole;
11pub mod distgeom;
12pub mod dos;
13pub mod dynamics;
14pub mod eht;
15pub mod esp;
16pub mod etkdg;
17pub mod forcefield;
18pub mod graph;
19pub mod hf;
20pub mod ir;
21pub mod materials;
22pub mod mesh;
23pub mod ml;
24pub mod nmr;
25pub mod optimization;
26pub mod pm3;
27pub mod population;
28pub mod reactivity;
29pub mod rings;
30pub mod smarts;
31pub mod smiles;
32pub mod solvation;
33pub mod stereo;
34pub mod surface;
35pub mod topology;
36pub mod transport;
37pub mod xtb;
38
39use serde::{Deserialize, Serialize};
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ConformerResult {
46 pub smiles: String,
48 pub num_atoms: usize,
50 pub coords: Vec<f64>,
53 pub elements: Vec<u8>,
55 pub bonds: Vec<(usize, usize, String)>,
57 pub error: Option<String>,
59 pub time_ms: f64,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ConformerConfig {
66 pub seed: u64,
68 pub num_threads: usize,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ConformerEnsembleMember {
75 pub seed: u64,
77 pub cluster_id: Option<usize>,
79 pub coords: Vec<f64>,
81 pub energy_kcal_mol: f64,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ConformerClusterSummary {
88 pub cluster_id: usize,
90 pub representative_seed: u64,
92 pub size: usize,
94 pub member_seeds: Vec<u64>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ConformerSearchResult {
101 pub generated: usize,
103 pub unique: usize,
105 pub rotatable_bonds: usize,
107 pub conformers: Vec<ConformerEnsembleMember>,
109 pub clusters: Vec<ConformerClusterSummary>,
111 pub notes: Vec<String>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct MethodCapability {
118 pub available: bool,
120 pub confidence: eht::SupportLevel,
122 pub unsupported_elements: Vec<u8>,
124 pub warnings: Vec<String>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum ScientificMethod {
132 Embed,
133 Uff,
134 Eht,
135 Pm3,
136 Xtb,
137 Mmff94,
138 Ani,
139 Hf3c,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum PropertyRequest {
146 Geometry,
147 ForceFieldEnergy,
148 Orbitals,
149 Population,
150 OrbitalGrid,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct MethodMetadata {
156 pub method: ScientificMethod,
157 pub available: bool,
158 pub confidence: eht::SupportLevel,
159 pub confidence_score: f64,
160 pub limitations: Vec<String>,
161 pub warnings: Vec<String>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct PropertyMethodPlan {
167 pub property: PropertyRequest,
168 pub recommended: Option<ScientificMethod>,
169 pub fallback: Option<ScientificMethod>,
170 pub rationale: Vec<String>,
171 pub methods: Vec<MethodMetadata>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct SystemMethodPlan {
177 pub capabilities: SystemCapabilities,
178 pub geometry: PropertyMethodPlan,
179 pub force_field_energy: PropertyMethodPlan,
180 pub orbitals: PropertyMethodPlan,
181 pub population: PropertyMethodPlan,
182 pub orbital_grid: PropertyMethodPlan,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SystemCapabilities {
188 pub embed: MethodCapability,
189 pub uff: MethodCapability,
190 pub eht: MethodCapability,
191 pub population: MethodCapability,
192 pub orbital_grid: MethodCapability,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(tag = "mode", rename_all = "snake_case")]
198pub enum ElectronicWorkflowResult {
199 Eht {
200 result: eht::EhtResult,
201 },
202 UffFallback {
203 energy_kcal_mol: f64,
204 reason: String,
205 support: eht::EhtSupport,
206 },
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum MethodComparisonStatus {
213 Success,
214 Unavailable,
215 Error,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(tag = "kind", rename_all = "snake_case")]
221pub enum MethodComparisonPayload {
222 Eht {
223 homo_energy: f64,
224 lumo_energy: f64,
225 gap: f64,
226 support: eht::EhtSupport,
227 },
228 Uff {
229 energy_kcal_mol: f64,
230 },
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct MethodComparisonEntry {
236 pub method: ScientificMethod,
237 pub status: MethodComparisonStatus,
238 pub available: bool,
239 pub confidence: eht::SupportLevel,
240 pub confidence_score: f64,
241 pub warnings: Vec<String>,
242 pub limitations: Vec<String>,
243 pub payload: Option<MethodComparisonPayload>,
244 pub error: Option<String>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct MethodComparisonResult {
250 pub plan: SystemMethodPlan,
251 pub comparisons: Vec<MethodComparisonEntry>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct AromaticityAnalysis {
257 pub aromatic_atoms: Vec<bool>,
258 pub aromatic_bonds: Vec<(usize, usize)>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct StereocenterAnalysis {
264 pub tagged_tetrahedral_centers: Vec<usize>,
265 pub inferred_tetrahedral_centers: Vec<usize>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct GraphFeatureAnalysis {
271 pub aromaticity: AromaticityAnalysis,
272 pub stereocenters: StereocenterAnalysis,
273}
274
275impl Default for ConformerConfig {
276 fn default() -> Self {
277 Self {
278 seed: 42,
279 num_threads: 0,
280 }
281 }
282}
283
284pub fn version() -> String {
288 format!("sci-form {}", env!("CARGO_PKG_VERSION"))
289}
290
291pub fn get_eht_support(elements: &[u8]) -> eht::EhtSupport {
293 eht::analyze_eht_support(elements)
294}
295
296fn is_uff_element_supported(z: u8) -> bool {
297 matches!(
298 z,
299 1 | 5
300 | 6
301 | 7
302 | 8
303 | 9
304 | 14
305 | 15
306 | 16
307 | 17
308 | 22
309 | 23
310 | 24
311 | 25
312 | 26
313 | 27
314 | 28
315 | 29
316 | 30
317 | 32
318 | 33
319 | 34
320 | 35
321 | 42
322 | 46
323 | 47
324 | 50
325 | 51
326 | 52
327 | 53
328 | 78
329 | 79
330 )
331}
332
333fn unique_sorted_unsupported(elements: &[u8], pred: impl Fn(u8) -> bool) -> Vec<u8> {
334 let mut out: Vec<u8> = elements.iter().copied().filter(|&z| !pred(z)).collect();
335 out.sort_unstable();
336 out.dedup();
337 out
338}
339
340pub fn get_system_capabilities(elements: &[u8]) -> SystemCapabilities {
342 let eht_support = get_eht_support(elements);
343 let uff_unsupported = unique_sorted_unsupported(elements, is_uff_element_supported);
344
345 let embed = MethodCapability {
346 available: !elements.is_empty(),
347 confidence: eht::SupportLevel::Experimental,
348 unsupported_elements: Vec::new(),
349 warnings: vec![
350 "Embed capability is inferred from element presence only; final success still depends on full molecular graph and geometry constraints.".to_string(),
351 ],
352 };
353
354 let uff = if uff_unsupported.is_empty() {
355 MethodCapability {
356 available: true,
357 confidence: eht::SupportLevel::Supported,
358 unsupported_elements: Vec::new(),
359 warnings: Vec::new(),
360 }
361 } else {
362 MethodCapability {
363 available: false,
364 confidence: eht::SupportLevel::Unsupported,
365 unsupported_elements: uff_unsupported.clone(),
366 warnings: vec![format!(
367 "UFF atom typing is unavailable for elements {:?}.",
368 uff_unsupported
369 )],
370 }
371 };
372
373 let eht = MethodCapability {
374 available: eht_support.unsupported_elements.is_empty(),
375 confidence: eht_support.level,
376 unsupported_elements: eht_support.unsupported_elements.clone(),
377 warnings: eht_support.warnings.clone(),
378 };
379
380 let population = MethodCapability {
381 available: eht.available,
382 confidence: eht.confidence,
383 unsupported_elements: eht.unsupported_elements.clone(),
384 warnings: eht.warnings.clone(),
385 };
386
387 let orbital_grid = MethodCapability {
388 available: eht.available,
389 confidence: eht.confidence,
390 unsupported_elements: eht.unsupported_elements.clone(),
391 warnings: eht.warnings.clone(),
392 };
393
394 SystemCapabilities {
395 embed,
396 uff,
397 eht,
398 population,
399 orbital_grid,
400 }
401}
402
403fn confidence_score_for_method(method: ScientificMethod, capability: &MethodCapability) -> f64 {
404 if !capability.available {
405 return 0.0;
406 }
407
408 match method {
409 ScientificMethod::Embed => 0.8,
410 ScientificMethod::Uff | ScientificMethod::Mmff94 => match capability.confidence {
411 eht::SupportLevel::Supported => 0.95,
412 eht::SupportLevel::Experimental => 0.75,
413 eht::SupportLevel::Unsupported => 0.0,
414 },
415 ScientificMethod::Eht | ScientificMethod::Pm3 | ScientificMethod::Xtb => {
416 match capability.confidence {
417 eht::SupportLevel::Supported => 0.95,
418 eht::SupportLevel::Experimental => 0.6,
419 eht::SupportLevel::Unsupported => 0.0,
420 }
421 }
422 ScientificMethod::Ani => match capability.confidence {
423 eht::SupportLevel::Supported => 0.90,
424 eht::SupportLevel::Experimental => 0.7,
425 eht::SupportLevel::Unsupported => 0.0,
426 },
427 ScientificMethod::Hf3c => match capability.confidence {
428 eht::SupportLevel::Supported => 0.85,
429 eht::SupportLevel::Experimental => 0.65,
430 eht::SupportLevel::Unsupported => 0.0,
431 },
432 }
433}
434
435fn build_method_metadata(
436 method: ScientificMethod,
437 capability: &MethodCapability,
438 extra_limitations: &[&str],
439) -> MethodMetadata {
440 let mut limitations: Vec<String> = extra_limitations.iter().map(|s| s.to_string()).collect();
441
442 if !capability.unsupported_elements.is_empty() {
443 limitations.push(format!(
444 "Unsupported elements for this method: {:?}.",
445 capability.unsupported_elements
446 ));
447 }
448
449 if matches!(method, ScientificMethod::Eht)
450 && matches!(capability.confidence, eht::SupportLevel::Experimental)
451 {
452 limitations.push(
453 "Transition-metal EHT parameters remain provisional and should be treated as experimental."
454 .to_string(),
455 );
456 }
457
458 MethodMetadata {
459 method,
460 available: capability.available,
461 confidence: capability.confidence,
462 confidence_score: confidence_score_for_method(method, capability),
463 limitations,
464 warnings: capability.warnings.clone(),
465 }
466}
467
468fn build_property_plan(
469 property: PropertyRequest,
470 recommended: Option<ScientificMethod>,
471 fallback: Option<ScientificMethod>,
472 rationale: Vec<String>,
473 methods: Vec<MethodMetadata>,
474) -> PropertyMethodPlan {
475 PropertyMethodPlan {
476 property,
477 recommended,
478 fallback,
479 rationale,
480 methods,
481 }
482}
483
484pub fn get_system_method_plan(elements: &[u8]) -> SystemMethodPlan {
486 let capabilities = get_system_capabilities(elements);
487
488 let geometry_method = build_method_metadata(
489 ScientificMethod::Embed,
490 &capabilities.embed,
491 &["Geometry generation still depends on graph topology, stereochemistry, and embedding constraints."],
492 );
493 let geometry = build_property_plan(
494 PropertyRequest::Geometry,
495 geometry_method.available.then_some(ScientificMethod::Embed),
496 None,
497 vec!["Embedding is the top-level geometry generation path in sci-form.".to_string()],
498 vec![geometry_method],
499 );
500
501 let uff_method = build_method_metadata(
502 ScientificMethod::Uff,
503 &capabilities.uff,
504 &["This recommendation applies to force-field energy evaluation, not molecular orbital analysis."],
505 );
506 let force_field_energy = build_property_plan(
507 PropertyRequest::ForceFieldEnergy,
508 uff_method.available.then_some(ScientificMethod::Uff),
509 None,
510 vec![
511 "UFF is the top-level force-field energy path when atom typing is available."
512 .to_string(),
513 ],
514 vec![uff_method],
515 );
516
517 let eht_method = build_method_metadata(
518 ScientificMethod::Eht,
519 &capabilities.eht,
520 &["EHT is the only current top-level orbital method in sci-form."],
521 );
522 let orbitals = build_property_plan(
523 PropertyRequest::Orbitals,
524 eht_method.available.then_some(ScientificMethod::Eht),
525 None,
526 vec!["Orbital energies and MO coefficients are produced by the EHT workflow.".to_string()],
527 vec![eht_method.clone()],
528 );
529
530 let population_method = build_method_metadata(
531 ScientificMethod::Eht,
532 &capabilities.population,
533 &["Population analysis is derived from the EHT density and overlap matrices."],
534 );
535 let population = build_property_plan(
536 PropertyRequest::Population,
537 population_method.available.then_some(ScientificMethod::Eht),
538 None,
539 vec!["Population analysis currently requires a successful EHT calculation.".to_string()],
540 vec![population_method],
541 );
542
543 let orbital_grid_method = build_method_metadata(
544 ScientificMethod::Eht,
545 &capabilities.orbital_grid,
546 &["Orbital-grid rendering currently depends on EHT molecular-orbital coefficients."],
547 );
548 let orbital_grid = build_property_plan(
549 PropertyRequest::OrbitalGrid,
550 orbital_grid_method
551 .available
552 .then_some(ScientificMethod::Eht),
553 None,
554 vec![
555 "Orbital-grid generation currently requires a successful EHT calculation.".to_string(),
556 ],
557 vec![orbital_grid_method],
558 );
559
560 SystemMethodPlan {
561 capabilities,
562 geometry,
563 force_field_energy,
564 orbitals,
565 population,
566 orbital_grid,
567 }
568}
569
570pub fn embed(smiles: &str, seed: u64) -> ConformerResult {
572 #[cfg(not(target_arch = "wasm32"))]
573 let start = std::time::Instant::now();
574
575 let mol = match graph::Molecule::from_smiles(smiles) {
576 Ok(m) => m,
577 Err(e) => {
578 return ConformerResult {
579 smiles: smiles.to_string(),
580 num_atoms: 0,
581 coords: vec![],
582 elements: vec![],
583 bonds: vec![],
584 error: Some(e),
585 #[cfg(not(target_arch = "wasm32"))]
586 time_ms: start.elapsed().as_secs_f64() * 1000.0,
587 #[cfg(target_arch = "wasm32")]
588 time_ms: 0.0,
589 };
590 }
591 };
592
593 let n = mol.graph.node_count();
594 let elements: Vec<u8> = (0..n)
595 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
596 .collect();
597 let bonds: Vec<(usize, usize, String)> = mol
598 .graph
599 .edge_indices()
600 .map(|e| {
601 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
602 let order = match mol.graph[e].order {
603 graph::BondOrder::Single => "SINGLE",
604 graph::BondOrder::Double => "DOUBLE",
605 graph::BondOrder::Triple => "TRIPLE",
606 graph::BondOrder::Aromatic => "AROMATIC",
607 graph::BondOrder::Unknown => "UNKNOWN",
608 };
609 (a.index(), b.index(), order.to_string())
610 })
611 .collect();
612
613 let max_seed_retries = 3u64;
616 let mut last_err = String::new();
617 for retry in 0..max_seed_retries {
618 let current_seed = seed.wrapping_add(retry.wrapping_mul(997));
619 match conformer::generate_3d_conformer(&mol, current_seed) {
620 Ok(coords) => {
621 let mut flat = Vec::with_capacity(n * 3);
622 for i in 0..n {
623 flat.push(coords[(i, 0)] as f64);
624 flat.push(coords[(i, 1)] as f64);
625 flat.push(coords[(i, 2)] as f64);
626 }
627 return ConformerResult {
628 smiles: smiles.to_string(),
629 num_atoms: n,
630 coords: flat,
631 elements,
632 bonds,
633 error: None,
634 #[cfg(not(target_arch = "wasm32"))]
635 time_ms: start.elapsed().as_secs_f64() * 1000.0,
636 #[cfg(target_arch = "wasm32")]
637 time_ms: 0.0,
638 };
639 }
640 Err(e) => {
641 last_err = e;
642 }
643 }
644 }
645 ConformerResult {
646 smiles: smiles.to_string(),
647 num_atoms: n,
648 coords: vec![],
649 elements,
650 bonds,
651 error: Some(last_err),
652 #[cfg(not(target_arch = "wasm32"))]
653 time_ms: start.elapsed().as_secs_f64() * 1000.0,
654 #[cfg(target_arch = "wasm32")]
655 time_ms: 0.0,
656 }
657}
658
659#[cfg(feature = "parallel")]
664pub fn embed_batch(smiles_list: &[&str], config: &ConformerConfig) -> Vec<ConformerResult> {
665 use rayon::prelude::*;
666
667 if config.num_threads > 0 {
668 let pool = rayon::ThreadPoolBuilder::new()
669 .num_threads(config.num_threads)
670 .build()
671 .unwrap();
672 pool.install(|| {
673 smiles_list
674 .par_iter()
675 .map(|smi| embed(smi, config.seed))
676 .collect()
677 })
678 } else {
679 smiles_list
680 .par_iter()
681 .map(|smi| embed(smi, config.seed))
682 .collect()
683 }
684}
685
686#[cfg(not(feature = "parallel"))]
688pub fn embed_batch(smiles_list: &[&str], config: &ConformerConfig) -> Vec<ConformerResult> {
689 smiles_list
690 .iter()
691 .map(|smi| embed(smi, config.seed))
692 .collect()
693}
694
695pub fn embed_diverse(
706 smiles: &str,
707 n_conformers: usize,
708 rmsd_cutoff: f64,
709 base_seed: u64,
710) -> Vec<ConformerResult> {
711 let all_results: Vec<ConformerResult> = (0..n_conformers as u64)
712 .map(|i| embed(smiles, base_seed.wrapping_add(i)))
713 .collect();
714
715 let successful: Vec<(usize, &ConformerResult)> = all_results
716 .iter()
717 .enumerate()
718 .filter(|(_, r)| r.error.is_none() && !r.coords.is_empty())
719 .collect();
720
721 if successful.len() <= 1 {
722 return all_results
723 .into_iter()
724 .filter(|r| r.error.is_none())
725 .collect();
726 }
727
728 let coords_vecs: Vec<Vec<f64>> = successful.iter().map(|(_, r)| r.coords.clone()).collect();
729 let cluster_result = clustering::butina_cluster(&coords_vecs, rmsd_cutoff);
730
731 cluster_result
732 .centroid_indices
733 .iter()
734 .map(|&ci| {
735 let (orig_idx, _) = successful[ci];
736 all_results[orig_idx].clone()
737 })
738 .collect()
739}
740
741pub fn parse(smiles: &str) -> Result<graph::Molecule, String> {
743 graph::Molecule::from_smiles(smiles)
744}
745
746pub fn compute_charges(smiles: &str) -> Result<charges::gasteiger::ChargeResult, String> {
751 let mol = graph::Molecule::from_smiles(smiles)?;
752 let n = mol.graph.node_count();
753 let elements: Vec<u8> = (0..n)
754 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
755 .collect();
756 let formal_charges: Vec<i8> = (0..n)
757 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].formal_charge)
758 .collect();
759 let bonds: Vec<(usize, usize)> = mol
760 .graph
761 .edge_indices()
762 .map(|e| {
763 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
764 (a.index(), b.index())
765 })
766 .collect();
767 charges::gasteiger::gasteiger_marsili_charges(&elements, &bonds, &formal_charges, 6)
768}
769
770pub fn compute_sasa(
774 elements: &[u8],
775 coords_flat: &[f64],
776 probe_radius: Option<f64>,
777) -> Result<surface::sasa::SasaResult, String> {
778 if coords_flat.len() != elements.len() * 3 {
779 return Err(format!(
780 "coords length {} != 3 * elements {}",
781 coords_flat.len(),
782 elements.len()
783 ));
784 }
785 let positions: Vec<[f64; 3]> = coords_flat
786 .chunks_exact(3)
787 .map(|c| [c[0], c[1], c[2]])
788 .collect();
789
790 #[cfg(feature = "parallel")]
791 {
792 Ok(surface::sasa::compute_sasa_parallel(
793 elements,
794 &positions,
795 probe_radius,
796 None,
797 ))
798 }
799
800 #[cfg(not(feature = "parallel"))]
801 {
802 Ok(surface::sasa::compute_sasa(
803 elements,
804 &positions,
805 probe_radius,
806 None,
807 ))
808 }
809}
810
811pub fn compute_population(
813 elements: &[u8],
814 positions: &[[f64; 3]],
815) -> Result<population::PopulationResult, String> {
816 let eht_result = eht::solve_eht(elements, positions, None)?;
817 Ok(population::compute_population(
818 elements,
819 positions,
820 &eht_result.coefficients,
821 eht_result.n_electrons,
822 ))
823}
824
825pub fn compute_dipole(
827 elements: &[u8],
828 positions: &[[f64; 3]],
829) -> Result<dipole::DipoleResult, String> {
830 let eht_result = eht::solve_eht(elements, positions, None)?;
831 Ok(dipole::compute_dipole_from_eht(
832 elements,
833 positions,
834 &eht_result.coefficients,
835 eht_result.n_electrons,
836 ))
837}
838
839pub fn compute_frontier_descriptors(
841 elements: &[u8],
842 positions: &[[f64; 3]],
843) -> Result<reactivity::FrontierDescriptors, String> {
844 let eht_result = eht::solve_eht(elements, positions, None)?;
845 Ok(reactivity::compute_frontier_descriptors(
846 elements,
847 positions,
848 &eht_result,
849 ))
850}
851
852pub fn compute_fukui_descriptors(
854 elements: &[u8],
855 positions: &[[f64; 3]],
856) -> Result<reactivity::FukuiDescriptors, String> {
857 let eht_result = eht::solve_eht(elements, positions, None)?;
858 Ok(reactivity::compute_fukui_descriptors(
859 elements,
860 positions,
861 &eht_result,
862 ))
863}
864
865pub fn compute_reactivity_ranking(
867 elements: &[u8],
868 positions: &[[f64; 3]],
869) -> Result<reactivity::ReactivityRanking, String> {
870 let eht_result = eht::solve_eht(elements, positions, None)?;
871 let fukui = reactivity::compute_fukui_descriptors(elements, positions, &eht_result);
872 let pop = population::compute_population(
873 elements,
874 positions,
875 &eht_result.coefficients,
876 eht_result.n_electrons,
877 );
878 Ok(reactivity::rank_reactivity_sites(
879 &fukui,
880 &pop.mulliken_charges,
881 ))
882}
883
884pub fn compute_uv_vis_spectrum(
886 elements: &[u8],
887 positions: &[[f64; 3]],
888 sigma: f64,
889 e_min: f64,
890 e_max: f64,
891 n_points: usize,
892) -> Result<reactivity::UvVisSpectrum, String> {
893 let eht_result = eht::solve_eht(elements, positions, None)?;
894 Ok(reactivity::compute_uv_vis_like_spectrum(
895 &eht_result,
896 sigma,
897 e_min,
898 e_max,
899 n_points,
900 ))
901}
902
903pub fn compute_bond_orders(
905 elements: &[u8],
906 positions: &[[f64; 3]],
907) -> Result<population::BondOrderResult, String> {
908 let eht_result = eht::solve_eht(elements, positions, None)?;
909 Ok(population::compute_bond_orders(
910 elements,
911 positions,
912 &eht_result.coefficients,
913 eht_result.n_electrons,
914 ))
915}
916
917pub fn compute_topology(
919 elements: &[u8],
920 positions: &[[f64; 3]],
921) -> topology::TopologyAnalysisResult {
922 topology::analyze_topology(elements, positions)
923}
924
925pub fn analyze_graph_features(smiles: &str) -> Result<GraphFeatureAnalysis, String> {
927 use petgraph::visit::EdgeRef;
928
929 let mol = parse(smiles)?;
930 let n_atoms = mol.graph.node_count();
931 let mut aromatic_atoms = vec![false; n_atoms];
932 let mut aromatic_bonds = Vec::new();
933
934 for edge in mol.graph.edge_references() {
935 if matches!(edge.weight().order, graph::BondOrder::Aromatic) {
936 let i = edge.source().index();
937 let j = edge.target().index();
938 aromatic_atoms[i] = true;
939 aromatic_atoms[j] = true;
940 aromatic_bonds.push((i, j));
941 }
942 }
943
944 let mut tagged_tetrahedral_centers = Vec::new();
945 let mut inferred_tetrahedral_centers = Vec::new();
946 for i in 0..n_atoms {
947 let idx = petgraph::graph::NodeIndex::new(i);
948 let atom = &mol.graph[idx];
949 if matches!(
950 atom.chiral_tag,
951 graph::ChiralType::TetrahedralCW | graph::ChiralType::TetrahedralCCW
952 ) {
953 tagged_tetrahedral_centers.push(i);
954 }
955
956 let neighbors: Vec<_> = mol.graph.neighbors(idx).collect();
957 if neighbors.len() == 4 && matches!(atom.hybridization, graph::Hybridization::SP3) {
958 let mut signature: Vec<u8> = neighbors.iter().map(|n| mol.graph[*n].element).collect();
959 signature.sort_unstable();
960 signature.dedup();
961 if signature.len() >= 3 {
962 inferred_tetrahedral_centers.push(i);
963 }
964 }
965 }
966
967 Ok(GraphFeatureAnalysis {
968 aromaticity: AromaticityAnalysis {
969 aromatic_atoms,
970 aromatic_bonds,
971 },
972 stereocenters: StereocenterAnalysis {
973 tagged_tetrahedral_centers,
974 inferred_tetrahedral_centers,
975 },
976 })
977}
978
979pub fn compute_eht_or_uff_fallback(
984 smiles: &str,
985 elements: &[u8],
986 positions: &[[f64; 3]],
987 allow_experimental_eht: bool,
988) -> Result<ElectronicWorkflowResult, String> {
989 let support = get_eht_support(elements);
990 let should_fallback = match support.level {
991 eht::SupportLevel::Unsupported => true,
992 eht::SupportLevel::Experimental => !allow_experimental_eht,
993 eht::SupportLevel::Supported => false,
994 };
995
996 if should_fallback {
997 let coords_flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
998 let energy = compute_uff_energy(smiles, &coords_flat).map_err(|e| {
999 format!(
1000 "EHT is not appropriate for this system (support: {:?}) and UFF fallback failed: {}",
1001 support.level, e
1002 )
1003 })?;
1004 return Ok(ElectronicWorkflowResult::UffFallback {
1005 energy_kcal_mol: energy,
1006 reason: if matches!(support.level, eht::SupportLevel::Unsupported) {
1007 "EHT unsupported for one or more elements; routed to UFF-only workflow.".to_string()
1008 } else {
1009 "EHT confidence is experimental and experimental mode is disabled; routed to UFF-only workflow."
1010 .to_string()
1011 },
1012 support,
1013 });
1014 }
1015
1016 let result = eht::solve_eht(elements, positions, None)?;
1017 Ok(ElectronicWorkflowResult::Eht { result })
1018}
1019
1020pub fn compare_methods(
1025 smiles: &str,
1026 elements: &[u8],
1027 positions: &[[f64; 3]],
1028 allow_experimental_eht: bool,
1029) -> MethodComparisonResult {
1030 let plan = get_system_method_plan(elements);
1031 let mut comparisons = Vec::new();
1032
1033 let coords_flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1034
1035 {
1036 let meta = build_method_metadata(
1037 ScientificMethod::Uff,
1038 &plan.capabilities.uff,
1039 &["Comparison uses UFF force-field energy as the UFF observable."],
1040 );
1041 if !meta.available {
1042 comparisons.push(MethodComparisonEntry {
1043 method: ScientificMethod::Uff,
1044 status: MethodComparisonStatus::Unavailable,
1045 available: false,
1046 confidence: meta.confidence,
1047 confidence_score: meta.confidence_score,
1048 warnings: meta.warnings,
1049 limitations: meta.limitations,
1050 payload: None,
1051 error: Some("UFF is unavailable for this element set.".to_string()),
1052 });
1053 } else {
1054 match compute_uff_energy(smiles, &coords_flat) {
1055 Ok(energy) => comparisons.push(MethodComparisonEntry {
1056 method: ScientificMethod::Uff,
1057 status: MethodComparisonStatus::Success,
1058 available: true,
1059 confidence: meta.confidence,
1060 confidence_score: meta.confidence_score,
1061 warnings: meta.warnings,
1062 limitations: meta.limitations,
1063 payload: Some(MethodComparisonPayload::Uff {
1064 energy_kcal_mol: energy,
1065 }),
1066 error: None,
1067 }),
1068 Err(err) => comparisons.push(MethodComparisonEntry {
1069 method: ScientificMethod::Uff,
1070 status: MethodComparisonStatus::Error,
1071 available: true,
1072 confidence: meta.confidence,
1073 confidence_score: meta.confidence_score,
1074 warnings: meta.warnings,
1075 limitations: meta.limitations,
1076 payload: None,
1077 error: Some(err),
1078 }),
1079 }
1080 }
1081 }
1082
1083 {
1084 let meta = build_method_metadata(
1085 ScientificMethod::Eht,
1086 &plan.capabilities.eht,
1087 &["Comparison uses frontier orbital energies and gap as the EHT observable."],
1088 );
1089
1090 if !meta.available {
1091 comparisons.push(MethodComparisonEntry {
1092 method: ScientificMethod::Eht,
1093 status: MethodComparisonStatus::Unavailable,
1094 available: false,
1095 confidence: meta.confidence,
1096 confidence_score: meta.confidence_score,
1097 warnings: meta.warnings,
1098 limitations: meta.limitations,
1099 payload: None,
1100 error: Some("EHT is unavailable for this element set.".to_string()),
1101 });
1102 } else if matches!(meta.confidence, eht::SupportLevel::Experimental)
1103 && !allow_experimental_eht
1104 {
1105 comparisons.push(MethodComparisonEntry {
1106 method: ScientificMethod::Eht,
1107 status: MethodComparisonStatus::Unavailable,
1108 available: true,
1109 confidence: meta.confidence,
1110 confidence_score: meta.confidence_score,
1111 warnings: meta.warnings,
1112 limitations: meta.limitations,
1113 payload: None,
1114 error: Some(
1115 "EHT confidence is experimental and allow_experimental_eht=false.".to_string(),
1116 ),
1117 });
1118 } else {
1119 match eht::solve_eht(elements, positions, None) {
1120 Ok(result) => comparisons.push(MethodComparisonEntry {
1121 method: ScientificMethod::Eht,
1122 status: MethodComparisonStatus::Success,
1123 available: true,
1124 confidence: meta.confidence,
1125 confidence_score: meta.confidence_score,
1126 warnings: meta.warnings,
1127 limitations: meta.limitations,
1128 payload: Some(MethodComparisonPayload::Eht {
1129 homo_energy: result.homo_energy,
1130 lumo_energy: result.lumo_energy,
1131 gap: result.gap,
1132 support: result.support,
1133 }),
1134 error: None,
1135 }),
1136 Err(err) => comparisons.push(MethodComparisonEntry {
1137 method: ScientificMethod::Eht,
1138 status: MethodComparisonStatus::Error,
1139 available: true,
1140 confidence: meta.confidence,
1141 confidence_score: meta.confidence_score,
1142 warnings: meta.warnings,
1143 limitations: meta.limitations,
1144 payload: None,
1145 error: Some(err),
1146 }),
1147 }
1148 }
1149 }
1150
1151 MethodComparisonResult { plan, comparisons }
1152}
1153
1154pub fn compute_esp(
1156 elements: &[u8],
1157 positions: &[[f64; 3]],
1158 spacing: f64,
1159 padding: f64,
1160) -> Result<esp::EspGrid, String> {
1161 let pop = compute_population(elements, positions)?;
1162 #[cfg(feature = "parallel")]
1163 {
1164 Ok(esp::compute_esp_grid_parallel(
1165 elements,
1166 positions,
1167 &pop.mulliken_charges,
1168 spacing,
1169 padding,
1170 ))
1171 }
1172
1173 #[cfg(not(feature = "parallel"))]
1174 {
1175 Ok(esp::compute_esp_grid(
1176 elements,
1177 positions,
1178 &pop.mulliken_charges,
1179 spacing,
1180 padding,
1181 ))
1182 }
1183}
1184
1185pub fn compute_dos(
1187 elements: &[u8],
1188 positions: &[[f64; 3]],
1189 sigma: f64,
1190 e_min: f64,
1191 e_max: f64,
1192 n_points: usize,
1193) -> Result<dos::DosResult, String> {
1194 let eht_result = eht::solve_eht(elements, positions, None)?;
1195 let flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1196
1197 #[cfg(feature = "parallel")]
1198 {
1199 Ok(dos::compute_pdos_parallel(
1200 elements,
1201 &flat,
1202 &eht_result.energies,
1203 &eht_result.coefficients,
1204 eht_result.n_electrons,
1205 sigma,
1206 e_min,
1207 e_max,
1208 n_points,
1209 ))
1210 }
1211
1212 #[cfg(not(feature = "parallel"))]
1213 {
1214 Ok(dos::compute_pdos(
1215 elements,
1216 &flat,
1217 &eht_result.energies,
1218 &eht_result.coefficients,
1219 eht_result.n_electrons,
1220 sigma,
1221 e_min,
1222 e_max,
1223 n_points,
1224 ))
1225 }
1226}
1227
1228pub fn compute_rmsd(coords: &[f64], reference: &[f64]) -> f64 {
1230 alignment::compute_rmsd(coords, reference)
1231}
1232
1233pub fn compute_uff_energy(smiles: &str, coords: &[f64]) -> Result<f64, String> {
1235 let mol = graph::Molecule::from_smiles(smiles)?;
1236 let n = mol.graph.node_count();
1237 if coords.len() != n * 3 {
1238 return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1239 }
1240 let ff = forcefield::builder::build_uff_force_field(&mol);
1241 let mut gradient = vec![0.0f64; n * 3];
1242 let energy = ff.compute_system_energy_and_gradients(coords, &mut gradient);
1243 Ok(energy)
1244}
1245
1246pub fn compute_mmff94_energy(smiles: &str, coords: &[f64]) -> Result<f64, String> {
1253 let mol = graph::Molecule::from_smiles(smiles)?;
1254 let n = mol.graph.node_count();
1255 if coords.len() != n * 3 {
1256 return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1257 }
1258 let elements: Vec<u8> = (0..n)
1259 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
1260 .collect();
1261 let bonds: Vec<(usize, usize, u8)> = mol
1262 .graph
1263 .edge_indices()
1264 .map(|e| {
1265 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
1266 let order = match mol.graph[e].order {
1267 graph::BondOrder::Single => 1u8,
1268 graph::BondOrder::Double => 2,
1269 graph::BondOrder::Triple => 3,
1270 graph::BondOrder::Aromatic => 2,
1271 graph::BondOrder::Unknown => 1,
1272 };
1273 (a.index(), b.index(), order)
1274 })
1275 .collect();
1276 let terms = forcefield::mmff94::Mmff94Builder::build(&elements, &bonds);
1277 let (energy, _grad) = forcefield::mmff94::Mmff94Builder::total_energy(&terms, coords);
1278 Ok(energy)
1279}
1280
1281pub fn compute_pm3(elements: &[u8], positions: &[[f64; 3]]) -> Result<pm3::Pm3Result, String> {
1288 pm3::solve_pm3(elements, positions)
1289}
1290
1291pub fn compute_xtb(elements: &[u8], positions: &[[f64; 3]]) -> Result<xtb::XtbResult, String> {
1298 xtb::solve_xtb(elements, positions)
1299}
1300
1301pub fn compute_stda_uvvis(
1308 elements: &[u8],
1309 positions: &[[f64; 3]],
1310 sigma: f64,
1311 e_min: f64,
1312 e_max: f64,
1313 n_points: usize,
1314 broadening: reactivity::BroadeningType,
1315) -> Result<reactivity::StdaUvVisSpectrum, String> {
1316 reactivity::compute_stda_uvvis_spectrum(
1317 elements, positions, sigma, e_min, e_max, n_points, broadening,
1318 )
1319}
1320
1321pub fn compute_vibrational_analysis(
1328 elements: &[u8],
1329 positions: &[[f64; 3]],
1330 method: &str,
1331 step_size: Option<f64>,
1332) -> Result<ir::VibrationalAnalysis, String> {
1333 let hessian_method =
1334 match method {
1335 "eht" => ir::HessianMethod::Eht,
1336 "pm3" => ir::HessianMethod::Pm3,
1337 "xtb" => ir::HessianMethod::Xtb,
1338 "uff" => return Err(
1339 "UFF vibrational analysis requires SMILES; use compute_vibrational_analysis_uff"
1340 .to_string(),
1341 ),
1342 _ => return Err(format!("Unknown method '{}', use eht/pm3/xtb/uff", method)),
1343 };
1344 ir::compute_vibrational_analysis(elements, positions, hessian_method, step_size)
1345}
1346
1347pub fn compute_vibrational_analysis_uff(
1352 smiles: &str,
1353 elements: &[u8],
1354 positions: &[[f64; 3]],
1355 step_size: Option<f64>,
1356) -> Result<ir::VibrationalAnalysis, String> {
1357 ir::vibrations::compute_vibrational_analysis_uff(smiles, elements, positions, step_size)
1358}
1359
1360pub fn compute_ir_spectrum(
1366 analysis: &ir::VibrationalAnalysis,
1367 gamma: f64,
1368 wn_min: f64,
1369 wn_max: f64,
1370 n_points: usize,
1371) -> ir::IrSpectrum {
1372 ir::compute_ir_spectrum(analysis, gamma, wn_min, wn_max, n_points)
1373}
1374
1375pub fn predict_nmr_shifts(smiles: &str) -> Result<nmr::NmrShiftResult, String> {
1377 let mol = graph::Molecule::from_smiles(smiles)?;
1378 Ok(nmr::predict_chemical_shifts(&mol))
1379}
1380
1381pub fn predict_nmr_couplings(
1386 smiles: &str,
1387 positions: &[[f64; 3]],
1388) -> Result<Vec<nmr::JCoupling>, String> {
1389 let mol = graph::Molecule::from_smiles(smiles)?;
1390 Ok(nmr::predict_j_couplings(&mol, positions))
1391}
1392
1393pub fn compute_nmr_spectrum(
1400 smiles: &str,
1401 nucleus: &str,
1402 gamma: f64,
1403 ppm_min: f64,
1404 ppm_max: f64,
1405 n_points: usize,
1406) -> Result<nmr::NmrSpectrum, String> {
1407 let mol = graph::Molecule::from_smiles(smiles)?;
1408 let shifts = nmr::predict_chemical_shifts(&mol);
1409 let couplings = nmr::predict_j_couplings(&mol, &[]);
1410 let nuc = match nucleus {
1411 "1H" | "H1" | "h1" | "1h" | "proton" => nmr::NmrNucleus::H1,
1412 "13C" | "C13" | "c13" | "13c" | "carbon" => nmr::NmrNucleus::C13,
1413 "19F" | "F19" | "f19" | "19f" | "fluorine" => nmr::NmrNucleus::F19,
1414 "31P" | "P31" | "p31" | "31p" | "phosphorus" => nmr::NmrNucleus::P31,
1415 "15N" | "N15" | "n15" | "15n" | "nitrogen" => nmr::NmrNucleus::N15,
1416 "11B" | "B11" | "b11" | "11b" | "boron" => nmr::NmrNucleus::B11,
1417 "29Si" | "Si29" | "si29" | "29si" | "silicon" => nmr::NmrNucleus::Si29,
1418 "77Se" | "Se77" | "se77" | "77se" | "selenium" => nmr::NmrNucleus::Se77,
1419 "17O" | "O17" | "o17" | "17o" | "oxygen" => nmr::NmrNucleus::O17,
1420 "33S" | "S33" | "s33" | "33s" | "sulfur" => nmr::NmrNucleus::S33,
1421 _ => {
1422 return Err(format!(
1423 "Unknown nucleus '{}'. Supported: 1H, 13C, 19F, 31P, 15N, 11B, 29Si, 77Se, 17O, 33S",
1424 nucleus
1425 ))
1426 }
1427 };
1428 Ok(nmr::compute_nmr_spectrum(
1429 &shifts, &couplings, nuc, gamma, ppm_min, ppm_max, n_points,
1430 ))
1431}
1432
1433pub fn compute_hose_codes(smiles: &str, max_radius: usize) -> Result<Vec<nmr::HoseCode>, String> {
1435 let mol = graph::Molecule::from_smiles(smiles)?;
1436 Ok(nmr::hose::generate_hose_codes(&mol, max_radius))
1437}
1438
1439pub fn compute_orbital_mesh(
1451 elements: &[u8],
1452 positions: &[[f64; 3]],
1453 method: &str,
1454 mo_index: usize,
1455 spacing: f64,
1456 padding: f64,
1457 isovalue: f32,
1458) -> Result<mesh::OrbitalMeshResult, String> {
1459 let m = match method.to_lowercase().as_str() {
1460 "eht" | "huckel" => mesh::MeshMethod::Eht,
1461 "pm3" => mesh::MeshMethod::Pm3,
1462 "xtb" | "gfn0" | "gfn-xtb" => mesh::MeshMethod::Xtb,
1463 "hf3c" | "hf-3c" | "hf" => mesh::MeshMethod::Hf3c,
1464 _ => {
1465 return Err(format!(
1466 "Unknown method '{}'. Supported: eht, pm3, xtb, hf3c",
1467 method
1468 ))
1469 }
1470 };
1471 mesh::compute_orbital_mesh(elements, positions, m, mo_index, spacing, padding, isovalue)
1472}
1473
1474pub fn compute_ml_descriptors(
1481 elements: &[u8],
1482 bonds: &[(usize, usize, u8)],
1483 charges: &[f64],
1484 aromatic_atoms: &[bool],
1485) -> ml::MolecularDescriptors {
1486 ml::compute_descriptors(elements, bonds, charges, aromatic_atoms)
1487}
1488
1489pub fn predict_ml_properties(desc: &ml::MolecularDescriptors) -> ml::MlPropertyResult {
1494 ml::predict_properties(desc)
1495}
1496
1497pub fn compute_md_trajectory(
1499 smiles: &str,
1500 coords: &[f64],
1501 n_steps: usize,
1502 dt_fs: f64,
1503 seed: u64,
1504) -> Result<dynamics::MdTrajectory, String> {
1505 dynamics::simulate_velocity_verlet_uff(smiles, coords, n_steps, dt_fs, seed, None)
1506}
1507
1508pub fn compute_md_trajectory_nvt(
1510 smiles: &str,
1511 coords: &[f64],
1512 n_steps: usize,
1513 dt_fs: f64,
1514 seed: u64,
1515 target_temp_k: f64,
1516 thermostat_tau_fs: f64,
1517) -> Result<dynamics::MdTrajectory, String> {
1518 dynamics::simulate_velocity_verlet_uff(
1519 smiles,
1520 coords,
1521 n_steps,
1522 dt_fs,
1523 seed,
1524 Some((target_temp_k, thermostat_tau_fs)),
1525 )
1526}
1527
1528pub fn compute_simplified_neb_path(
1530 smiles: &str,
1531 start_coords: &[f64],
1532 end_coords: &[f64],
1533 n_images: usize,
1534 n_iter: usize,
1535 spring_k: f64,
1536 step_size: f64,
1537) -> Result<dynamics::NebPathResult, String> {
1538 dynamics::compute_simplified_neb_path(
1539 smiles,
1540 start_coords,
1541 end_coords,
1542 n_images,
1543 n_iter,
1544 spring_k,
1545 step_size,
1546 )
1547}
1548
1549fn coords_flat_to_matrix_f32(coords: &[f64], n_atoms: usize) -> nalgebra::DMatrix<f32> {
1550 let mut m = nalgebra::DMatrix::<f32>::zeros(n_atoms, 3);
1551 for i in 0..n_atoms {
1552 m[(i, 0)] = coords[3 * i] as f32;
1553 m[(i, 1)] = coords[3 * i + 1] as f32;
1554 m[(i, 2)] = coords[3 * i + 2] as f32;
1555 }
1556 m
1557}
1558
1559fn coords_matrix_f32_to_flat(m: &nalgebra::DMatrix<f32>) -> Vec<f64> {
1560 let mut out = Vec::with_capacity(m.nrows() * 3);
1561 for i in 0..m.nrows() {
1562 out.push(m[(i, 0)] as f64);
1563 out.push(m[(i, 1)] as f64);
1564 out.push(m[(i, 2)] as f64);
1565 }
1566 out
1567}
1568
1569pub fn search_conformers_with_uff(
1578 smiles: &str,
1579 n_samples: usize,
1580 seed: u64,
1581 rmsd_threshold: f64,
1582) -> Result<ConformerSearchResult, String> {
1583 if n_samples == 0 {
1584 return Err("n_samples must be > 0".to_string());
1585 }
1586 if rmsd_threshold <= 0.0 {
1587 return Err("rmsd_threshold must be > 0".to_string());
1588 }
1589
1590 let mol = graph::Molecule::from_smiles(smiles)?;
1591 let n_atoms = mol.graph.node_count();
1592 let bounds = distgeom::smooth_bounds_matrix(distgeom::calculate_bounds_matrix(&mol));
1593
1594 let mut generated = Vec::new();
1595 let mut notes = Vec::new();
1596 let mut rotatable_bonds = 0usize;
1597
1598 for i in 0..n_samples {
1599 let sample_seed = seed.wrapping_add(i as u64);
1600 let conf = embed(smiles, sample_seed);
1601
1602 if conf.error.is_some() || conf.coords.len() != n_atoms * 3 {
1603 continue;
1604 }
1605
1606 let mut coords = coords_flat_to_matrix_f32(&conf.coords, n_atoms);
1607 let rot_mc = forcefield::optimize_torsions_monte_carlo_bounds(
1608 &mut coords,
1609 &mol,
1610 &bounds,
1611 sample_seed ^ 0x9E37_79B9_7F4A_7C15,
1612 64,
1613 0.4,
1614 );
1615 let rot_greedy = forcefield::optimize_torsions_bounds(&mut coords, &mol, &bounds, 2);
1616 let rot = rot_mc.max(rot_greedy);
1617 rotatable_bonds = rot;
1618 let coords_flat = coords_matrix_f32_to_flat(&coords);
1619
1620 match compute_uff_energy(smiles, &coords_flat) {
1621 Ok(energy_kcal_mol) => generated.push(ConformerEnsembleMember {
1622 seed: sample_seed,
1623 cluster_id: None,
1624 coords: coords_flat,
1625 energy_kcal_mol,
1626 }),
1627 Err(_) => continue,
1628 }
1629 }
1630
1631 if generated.is_empty() {
1632 return Err("failed to generate any valid conformers".to_string());
1633 }
1634
1635 generated.sort_by(|a, b| {
1636 a.energy_kcal_mol
1637 .partial_cmp(&b.energy_kcal_mol)
1638 .unwrap_or(std::cmp::Ordering::Equal)
1639 });
1640
1641 let generated_count = generated.len();
1642
1643 let mut unique = Vec::new();
1644 let mut cluster_members: Vec<Vec<u64>> = Vec::new();
1645 for candidate in generated {
1646 let existing_cluster = unique.iter().position(|u: &ConformerEnsembleMember| {
1647 compute_rmsd(&candidate.coords, &u.coords) < rmsd_threshold
1648 });
1649
1650 if let Some(cluster_id) = existing_cluster {
1651 cluster_members[cluster_id].push(candidate.seed);
1652 } else {
1653 unique.push(candidate.clone());
1654 cluster_members.push(vec![candidate.seed]);
1655 }
1656 }
1657
1658 for (cluster_id, member) in unique.iter_mut().enumerate() {
1659 member.cluster_id = Some(cluster_id);
1660 }
1661
1662 let clusters: Vec<ConformerClusterSummary> = unique
1663 .iter()
1664 .enumerate()
1665 .map(|(cluster_id, representative)| ConformerClusterSummary {
1666 cluster_id,
1667 representative_seed: representative.seed,
1668 size: cluster_members[cluster_id].len(),
1669 member_seeds: cluster_members[cluster_id].clone(),
1670 })
1671 .collect();
1672
1673 notes.push(
1674 "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."
1675 .to_string(),
1676 );
1677
1678 Ok(ConformerSearchResult {
1679 generated: generated_count,
1680 unique: unique.len(),
1681 rotatable_bonds,
1682 conformers: unique,
1683 clusters,
1684 notes,
1685 })
1686}
1687
1688pub fn compute_uff_energy_with_aromatic_heuristics(
1690 smiles: &str,
1691 coords: &[f64],
1692) -> Result<reactivity::UffHeuristicEnergy, String> {
1693 let mol = graph::Molecule::from_smiles(smiles)?;
1694 let n = mol.graph.node_count();
1695 if coords.len() != n * 3 {
1696 return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1697 }
1698
1699 let ff = forcefield::builder::build_uff_force_field(&mol);
1700 let mut gradient = vec![0.0f64; n * 3];
1701 let raw = ff.compute_system_energy_and_gradients(coords, &mut gradient);
1702 Ok(reactivity::apply_aromatic_uff_correction(&mol, raw))
1703}
1704
1705pub fn compute_empirical_pka(smiles: &str) -> Result<reactivity::EmpiricalPkaResult, String> {
1707 let mol = graph::Molecule::from_smiles(smiles)?;
1708 let charges = compute_charges(smiles)?;
1709 Ok(reactivity::estimate_empirical_pka(&mol, &charges.charges))
1710}
1711
1712pub fn create_unit_cell(
1714 a: f64,
1715 b: f64,
1716 c: f64,
1717 alpha: f64,
1718 beta: f64,
1719 gamma: f64,
1720) -> materials::UnitCell {
1721 materials::UnitCell::from_parameters(&materials::CellParameters {
1722 a,
1723 b,
1724 c,
1725 alpha,
1726 beta,
1727 gamma,
1728 })
1729}
1730
1731pub fn assemble_framework(
1735 node: &materials::Sbu,
1736 linker: &materials::Sbu,
1737 topology: &materials::Topology,
1738 cell: &materials::UnitCell,
1739) -> materials::CrystalStructure {
1740 materials::assemble_framework(node, linker, topology, cell)
1741}
1742
1743pub fn compute_hf3c(
1751 elements: &[u8],
1752 positions: &[[f64; 3]],
1753 config: &hf::HfConfig,
1754) -> Result<hf::Hf3cResult, String> {
1755 hf::solve_hf3c(elements, positions, config)
1756}
1757
1758pub fn compute_ani(elements: &[u8], positions: &[[f64; 3]]) -> Result<ani::AniResult, String> {
1766 ani::api::compute_ani_test(elements, positions)
1767}
1768
1769pub fn compute_ani_with_models(
1776 elements: &[u8],
1777 positions: &[[f64; 3]],
1778 config: &ani::AniConfig,
1779 models: &std::collections::HashMap<u8, ani::nn::FeedForwardNet>,
1780) -> Result<ani::AniResult, String> {
1781 ani::compute_ani(elements, positions, config, models)
1782}
1783
1784pub fn compute_esp_grid(
1788 elements: &[u8],
1789 positions: &[[f64; 3]],
1790 spacing: f64,
1791 padding: f64,
1792) -> Result<esp::EspGrid, String> {
1793 compute_esp(elements, positions, spacing, padding)
1794}
1795
1796pub fn analyze_stereo(smiles: &str, coords: &[f64]) -> Result<stereo::StereoAnalysis, String> {
1800 let mol = graph::Molecule::from_smiles(smiles)?;
1801 let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
1802 Ok(stereo::analyze_stereo(&mol, &positions))
1803}
1804
1805pub fn compute_nonpolar_solvation(
1809 elements: &[u8],
1810 positions: &[[f64; 3]],
1811 probe_radius: Option<f64>,
1812) -> solvation::NonPolarSolvation {
1813 solvation::compute_nonpolar_solvation(elements, positions, probe_radius)
1814}
1815
1816pub fn compute_gb_solvation(
1818 elements: &[u8],
1819 positions: &[[f64; 3]],
1820 charges: &[f64],
1821 solvent_dielectric: Option<f64>,
1822 solute_dielectric: Option<f64>,
1823 probe_radius: Option<f64>,
1824) -> solvation::GbSolvation {
1825 solvation::compute_gb_solvation(
1826 elements,
1827 positions,
1828 charges,
1829 solvent_dielectric,
1830 solute_dielectric,
1831 probe_radius,
1832 )
1833}
1834
1835pub fn butina_cluster(conformers: &[Vec<f64>], rmsd_cutoff: f64) -> clustering::ClusterResult {
1839 clustering::butina_cluster(conformers, rmsd_cutoff)
1840}
1841
1842pub fn compute_rmsd_matrix(conformers: &[Vec<f64>]) -> Vec<Vec<f64>> {
1844 clustering::compute_rmsd_matrix(conformers)
1845}
1846
1847pub fn compute_sssr(smiles: &str) -> Result<rings::sssr::SssrResult, String> {
1851 let mol = graph::Molecule::from_smiles(smiles)?;
1852 Ok(rings::sssr::compute_sssr(&mol))
1853}
1854
1855pub fn compute_ecfp(
1857 smiles: &str,
1858 radius: usize,
1859 n_bits: usize,
1860) -> Result<rings::ecfp::ECFPFingerprint, String> {
1861 let mol = graph::Molecule::from_smiles(smiles)?;
1862 Ok(rings::ecfp::compute_ecfp(&mol, radius, n_bits))
1863}
1864
1865pub fn compute_tanimoto(
1867 fp1: &rings::ecfp::ECFPFingerprint,
1868 fp2: &rings::ecfp::ECFPFingerprint,
1869) -> f64 {
1870 rings::ecfp::compute_tanimoto(fp1, fp2)
1871}
1872
1873pub fn compute_charges_configured(
1877 smiles: &str,
1878 config: &charges::gasteiger::GasteigerConfig,
1879) -> Result<charges::gasteiger::ChargeResult, String> {
1880 let mol = graph::Molecule::from_smiles(smiles)?;
1881 let n = mol.graph.node_count();
1882 let elements: Vec<u8> = (0..n)
1883 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
1884 .collect();
1885 let bonds: Vec<(usize, usize)> = mol
1886 .graph
1887 .edge_indices()
1888 .map(|e| {
1889 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
1890 (a.index(), b.index())
1891 })
1892 .collect();
1893 let formal_charges: Vec<i8> = (0..n)
1894 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].formal_charge)
1895 .collect();
1896 charges::gasteiger::gasteiger_marsili_charges_configured(
1897 &elements,
1898 &bonds,
1899 &formal_charges,
1900 config,
1901 )
1902}
1903
1904pub fn compute_ensemble_j_couplings(
1908 smiles: &str,
1909 conformer_coords: &[Vec<f64>],
1910 energies_kcal: &[f64],
1911 temperature_k: f64,
1912) -> Result<Vec<nmr::JCoupling>, String> {
1913 let mol = graph::Molecule::from_smiles(smiles)?;
1914 let positions: Vec<Vec<[f64; 3]>> = conformer_coords
1915 .iter()
1916 .map(|c| c.chunks(3).map(|p| [p[0], p[1], p[2]]).collect())
1917 .collect();
1918 Ok(nmr::coupling::ensemble_averaged_j_couplings(
1919 &mol,
1920 &positions,
1921 energies_kcal,
1922 temperature_k,
1923 ))
1924}
1925
1926pub fn compute_ir_spectrum_broadened(
1930 analysis: &ir::VibrationalAnalysis,
1931 gamma: f64,
1932 wn_min: f64,
1933 wn_max: f64,
1934 n_points: usize,
1935 broadening: &str,
1936) -> ir::IrSpectrum {
1937 let bt = match broadening {
1938 "gaussian" | "Gaussian" => ir::BroadeningType::Gaussian,
1939 _ => ir::BroadeningType::Lorentzian,
1940 };
1941 ir::vibrations::compute_ir_spectrum_with_broadening(
1942 analysis, gamma, wn_min, wn_max, n_points, bt,
1943 )
1944}
1945
1946#[cfg(test)]
1947mod tests {
1948 use super::*;
1949
1950 #[test]
1951 fn test_cisplatin_style_support_metadata() {
1952 let smiles = "[Pt](Cl)(Cl)([NH3])[NH3]";
1953 let mol = parse(smiles).expect("Cisplatin-style example should parse");
1954 let elements: Vec<u8> = (0..mol.graph.node_count())
1955 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
1956 .collect();
1957
1958 let caps = get_system_capabilities(&elements);
1959 assert!(caps.eht.available);
1960 assert!(matches!(
1961 caps.eht.confidence,
1962 eht::SupportLevel::Experimental
1963 ));
1964 }
1965
1966 #[test]
1967 fn test_pt_system_routes_to_uff_when_experimental_disabled() {
1968 let smiles = "[Pt](Cl)(Cl)([NH3])[NH3]";
1969 let mol = parse(smiles).expect("Pt example should parse");
1970 let n = mol.graph.node_count();
1971 let elements: Vec<u8> = (0..n)
1972 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
1973 .collect();
1974 let positions = vec![[0.0, 0.0, 0.0]; n];
1975
1976 let result = compute_eht_or_uff_fallback(smiles, &elements, &positions, false)
1977 .expect("Fallback workflow should return a result");
1978
1979 assert!(matches!(
1980 result,
1981 ElectronicWorkflowResult::UffFallback { .. }
1982 ));
1983 }
1984
1985 #[test]
1986 fn test_method_plan_prefers_supported_workflows_for_organic_systems() {
1987 let plan = get_system_method_plan(&[6, 1, 1, 1, 1]);
1988
1989 assert_eq!(plan.geometry.recommended, Some(ScientificMethod::Embed));
1990 assert_eq!(
1991 plan.force_field_energy.recommended,
1992 Some(ScientificMethod::Uff)
1993 );
1994 assert_eq!(plan.orbitals.recommended, Some(ScientificMethod::Eht));
1995 assert_eq!(plan.population.recommended, Some(ScientificMethod::Eht));
1996 assert_eq!(plan.orbital_grid.recommended, Some(ScientificMethod::Eht));
1997 assert!(plan.orbitals.methods[0].confidence_score > 0.9);
1998 }
1999
2000 #[test]
2001 fn test_method_plan_marks_metal_orbital_workflow_experimental() {
2002 let plan = get_system_method_plan(&[78, 17, 17, 7, 7]);
2003
2004 assert_eq!(plan.orbitals.recommended, Some(ScientificMethod::Eht));
2005 assert_eq!(
2006 plan.force_field_energy.recommended,
2007 Some(ScientificMethod::Uff)
2008 );
2009 assert!(matches!(
2010 plan.orbitals.methods[0].confidence,
2011 eht::SupportLevel::Experimental
2012 ));
2013 assert!(plan.orbitals.methods[0].confidence_score < 0.9);
2014 assert!(!plan.orbitals.methods[0].warnings.is_empty());
2015 }
2016
2017 #[test]
2018 fn test_method_plan_reports_unavailable_workflows_for_unsupported_elements() {
2019 let plan = get_system_method_plan(&[92]);
2020
2021 assert_eq!(plan.force_field_energy.recommended, None);
2022 assert_eq!(plan.orbitals.recommended, None);
2023 assert_eq!(plan.population.recommended, None);
2024 assert_eq!(plan.orbital_grid.recommended, None);
2025 assert!(!plan.orbitals.methods[0].limitations.is_empty());
2026 }
2027
2028 #[test]
2029 fn test_compare_methods_supported_system_returns_success_rows() {
2030 let result = compare_methods("CC", &[6, 6], &[[0.0, 0.0, 0.0], [1.54, 0.0, 0.0]], true);
2031 assert_eq!(result.comparisons.len(), 2);
2032 assert!(result
2033 .comparisons
2034 .iter()
2035 .any(|entry| matches!(entry.method, ScientificMethod::Uff) && entry.available));
2036 assert!(result.comparisons.iter().any(|entry| matches!(
2037 entry.method,
2038 ScientificMethod::Eht
2039 ) && matches!(
2040 entry.status,
2041 MethodComparisonStatus::Success
2042 )));
2043 }
2044
2045 #[test]
2046 fn test_compare_methods_blocks_experimental_eht_when_disabled() {
2047 let result = compare_methods("[O]", &[78], &[[0.0, 0.0, 0.0]], false);
2048 let eht_row = result
2049 .comparisons
2050 .iter()
2051 .find(|entry| matches!(entry.method, ScientificMethod::Eht))
2052 .expect("EHT row must exist");
2053 assert!(matches!(
2054 eht_row.status,
2055 MethodComparisonStatus::Unavailable
2056 ));
2057 }
2058
2059 #[test]
2060 fn test_compare_methods_reports_unavailable_for_unsupported_elements() {
2061 let result = compare_methods("[O]", &[92], &[[0.0, 0.0, 0.0]], true);
2062 assert!(result
2063 .comparisons
2064 .iter()
2065 .all(|entry| matches!(entry.status, MethodComparisonStatus::Unavailable)));
2066 }
2067
2068 #[test]
2069 fn test_compute_fukui_descriptors_returns_atomwise_output() {
2070 let elements = [8u8, 1, 1];
2071 let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
2072 let result = compute_fukui_descriptors(&elements, &positions).unwrap();
2073 assert_eq!(result.num_atoms, 3);
2074 assert_eq!(result.condensed.len(), 3);
2075 }
2076
2077 #[test]
2078 fn test_compute_uv_vis_spectrum_returns_requested_grid_size() {
2079 let elements = [6u8, 6, 1, 1, 1, 1];
2080 let positions = [
2081 [0.0, 0.0, 0.0],
2082 [1.34, 0.0, 0.0],
2083 [-0.6, 0.92, 0.0],
2084 [-0.6, -0.92, 0.0],
2085 [1.94, 0.92, 0.0],
2086 [1.94, -0.92, 0.0],
2087 ];
2088 let spectrum = compute_uv_vis_spectrum(&elements, &positions, 0.2, 0.5, 8.0, 256).unwrap();
2089 assert_eq!(spectrum.energies_ev.len(), 256);
2090 assert_eq!(spectrum.intensities.len(), 256);
2091 }
2092
2093 #[test]
2094 fn test_analyze_graph_features_reports_benzene_aromaticity() {
2095 let analysis = analyze_graph_features("c1ccccc1").unwrap();
2096 assert_eq!(analysis.aromaticity.aromatic_atoms.len(), 12);
2097 assert_eq!(
2098 analysis
2099 .aromaticity
2100 .aromatic_atoms
2101 .iter()
2102 .filter(|v| **v)
2103 .count(),
2104 6
2105 );
2106 assert_eq!(analysis.aromaticity.aromatic_bonds.len(), 6);
2107 }
2108
2109 #[test]
2110 fn test_compute_empirical_pka_finds_acidic_site_for_acetic_acid() {
2111 let result = compute_empirical_pka("CC(=O)O").unwrap();
2112 assert!(!result.acidic_sites.is_empty());
2113 }
2114
2115 #[test]
2116 fn test_compute_uff_energy_with_aromatic_heuristics_applies_correction() {
2117 let conf = embed("c1ccccc1", 42);
2118 assert!(conf.error.is_none());
2119
2120 let result = compute_uff_energy_with_aromatic_heuristics("c1ccccc1", &conf.coords).unwrap();
2121 assert!(result.aromatic_bond_count >= 6);
2122 assert!(result.corrected_energy_kcal_mol <= result.raw_energy_kcal_mol);
2123 }
2124
2125 #[test]
2126 fn test_search_conformers_with_uff_returns_ranked_unique_ensemble() {
2127 let result = search_conformers_with_uff("CCCC", 10, 42, 0.2).unwrap();
2128 assert!(result.generated >= 1);
2129 assert!(result.unique >= 1);
2130 assert_eq!(result.unique, result.conformers.len());
2131 assert_eq!(result.unique, result.clusters.len());
2132 assert!(result.rotatable_bonds >= 1);
2133
2134 let mut total_members = 0usize;
2135 for (i, cluster) in result.clusters.iter().enumerate() {
2136 assert_eq!(cluster.cluster_id, i);
2137 assert!(cluster.size >= 1);
2138 total_members += cluster.size;
2139 assert_eq!(result.conformers[i].cluster_id, Some(i));
2140 assert_eq!(result.conformers[i].seed, cluster.representative_seed);
2141 }
2142 assert_eq!(total_members, result.generated);
2143
2144 for i in 1..result.conformers.len() {
2145 assert!(
2146 result.conformers[i - 1].energy_kcal_mol <= result.conformers[i].energy_kcal_mol
2147 );
2148 }
2149 }
2150
2151 #[test]
2152 fn test_search_conformers_with_uff_large_rmsd_threshold_collapses_duplicates() {
2153 let result = search_conformers_with_uff("CCCC", 8, 123, 10.0).unwrap();
2154 assert_eq!(result.unique, 1);
2155 assert_eq!(result.clusters.len(), 1);
2156 assert_eq!(result.clusters[0].size, result.generated);
2157 }
2158
2159 #[test]
2160 fn test_compute_md_trajectory_velocity_verlet_runs() {
2161 let conf = embed("CC", 42);
2162 assert!(conf.error.is_none());
2163
2164 let trj = compute_md_trajectory("CC", &conf.coords, 10, 0.25, 7).unwrap();
2165 assert_eq!(trj.frames.len(), 11);
2166 assert!(trj
2167 .frames
2168 .iter()
2169 .all(|f| f.coords.iter().all(|v| v.is_finite())));
2170 }
2171
2172 #[test]
2173 fn test_compute_md_trajectory_nvt_runs() {
2174 let conf = embed("CCO", 42);
2175 assert!(conf.error.is_none());
2176
2177 let trj =
2178 compute_md_trajectory_nvt("CCO", &conf.coords, 12, 0.25, 17, 300.0, 10.0).unwrap();
2179 assert_eq!(trj.frames.len(), 13);
2180 assert!(trj.frames.iter().all(|f| f.temperature_k.is_finite()));
2181 }
2182
2183 #[test]
2184 fn test_compute_simplified_neb_path_runs() {
2185 let c1 = embed("CC", 42);
2186 let c2 = embed("CC", 43);
2187 assert!(c1.error.is_none());
2188 assert!(c2.error.is_none());
2189
2190 let path =
2191 compute_simplified_neb_path("CC", &c1.coords, &c2.coords, 6, 20, 0.01, 1e-5).unwrap();
2192 assert_eq!(path.images.len(), 6);
2193 assert!(path
2194 .images
2195 .iter()
2196 .all(|img| img.potential_energy_kcal_mol.is_finite()));
2197 }
2198
2199 #[test]
2200 fn test_compute_hf3c_water() {
2201 let conf = embed("O", 42);
2202 assert!(conf.error.is_none());
2203 let pos: Vec<[f64; 3]> = conf.coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2204
2205 let result = compute_hf3c(&conf.elements, &pos, &hf::HfConfig::default());
2206 assert!(result.is_ok(), "HF-3c should succeed for water");
2207 let r = result.unwrap();
2208 assert!(r.energy.is_finite());
2209 assert!(!r.orbital_energies.is_empty());
2210 }
2211
2212 #[test]
2213 fn test_compute_ani_water() {
2214 let conf = embed("O", 42);
2215 assert!(conf.error.is_none());
2216 let pos: Vec<[f64; 3]> = conf.coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2217
2218 let result = compute_ani(&conf.elements, &pos);
2219 assert!(result.is_ok(), "ANI should succeed for water");
2220 let r = result.unwrap();
2221 assert!(r.energy.is_finite());
2222 assert_eq!(r.forces.len(), 3); assert_eq!(r.species.len(), 3);
2224 }
2225
2226 #[test]
2227 fn test_compute_esp_grid_water() {
2228 let conf = embed("O", 42);
2229 assert!(conf.error.is_none());
2230 let pos: Vec<[f64; 3]> = conf.coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2231
2232 let result = compute_esp_grid(&conf.elements, &pos, 0.5, 3.0);
2233 assert!(result.is_ok(), "ESP grid should succeed for water");
2234 let g = result.unwrap();
2235 assert!(!g.values.is_empty());
2236 assert!(g.spacing > 0.0);
2237 assert!(g.dims[0] > 0 && g.dims[1] > 0 && g.dims[2] > 0);
2238 }
2239}