1#![allow(clippy::too_many_arguments)]
3#![allow(clippy::needless_range_loop)]
4
5pub mod alignment;
6pub mod charges;
7pub mod conformer;
8pub mod dipole;
9pub mod distgeom;
10pub mod dos;
11pub mod dynamics;
12pub mod eht;
13pub mod esp;
14pub mod etkdg;
15pub mod forcefield;
16pub mod graph;
17pub mod materials;
18pub mod ml;
19pub mod optimization;
20pub mod pm3;
21pub mod population;
22pub mod reactivity;
23pub mod smarts;
24pub mod smiles;
25pub mod surface;
26pub mod topology;
27pub mod transport;
28pub mod xtb;
29
30use serde::{Deserialize, Serialize};
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ConformerResult {
37 pub smiles: String,
39 pub num_atoms: usize,
41 pub coords: Vec<f64>,
44 pub elements: Vec<u8>,
46 pub bonds: Vec<(usize, usize, String)>,
48 pub error: Option<String>,
50 pub time_ms: f64,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ConformerConfig {
57 pub seed: u64,
59 pub num_threads: usize,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ConformerEnsembleMember {
66 pub seed: u64,
68 pub cluster_id: Option<usize>,
70 pub coords: Vec<f64>,
72 pub energy_kcal_mol: f64,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ConformerClusterSummary {
79 pub cluster_id: usize,
81 pub representative_seed: u64,
83 pub size: usize,
85 pub member_seeds: Vec<u64>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ConformerSearchResult {
92 pub generated: usize,
94 pub unique: usize,
96 pub rotatable_bonds: usize,
98 pub conformers: Vec<ConformerEnsembleMember>,
100 pub clusters: Vec<ConformerClusterSummary>,
102 pub notes: Vec<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct MethodCapability {
109 pub available: bool,
111 pub confidence: eht::SupportLevel,
113 pub unsupported_elements: Vec<u8>,
115 pub warnings: Vec<String>,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum ScientificMethod {
123 Embed,
124 Uff,
125 Eht,
126 Pm3,
127 Xtb,
128 Mmff94,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum PropertyRequest {
135 Geometry,
136 ForceFieldEnergy,
137 Orbitals,
138 Population,
139 OrbitalGrid,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct MethodMetadata {
145 pub method: ScientificMethod,
146 pub available: bool,
147 pub confidence: eht::SupportLevel,
148 pub confidence_score: f64,
149 pub limitations: Vec<String>,
150 pub warnings: Vec<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct PropertyMethodPlan {
156 pub property: PropertyRequest,
157 pub recommended: Option<ScientificMethod>,
158 pub fallback: Option<ScientificMethod>,
159 pub rationale: Vec<String>,
160 pub methods: Vec<MethodMetadata>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct SystemMethodPlan {
166 pub capabilities: SystemCapabilities,
167 pub geometry: PropertyMethodPlan,
168 pub force_field_energy: PropertyMethodPlan,
169 pub orbitals: PropertyMethodPlan,
170 pub population: PropertyMethodPlan,
171 pub orbital_grid: PropertyMethodPlan,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct SystemCapabilities {
177 pub embed: MethodCapability,
178 pub uff: MethodCapability,
179 pub eht: MethodCapability,
180 pub population: MethodCapability,
181 pub orbital_grid: MethodCapability,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(tag = "mode", rename_all = "snake_case")]
187pub enum ElectronicWorkflowResult {
188 Eht {
189 result: eht::EhtResult,
190 },
191 UffFallback {
192 energy_kcal_mol: f64,
193 reason: String,
194 support: eht::EhtSupport,
195 },
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum MethodComparisonStatus {
202 Success,
203 Unavailable,
204 Error,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(tag = "kind", rename_all = "snake_case")]
210pub enum MethodComparisonPayload {
211 Eht {
212 homo_energy: f64,
213 lumo_energy: f64,
214 gap: f64,
215 support: eht::EhtSupport,
216 },
217 Uff {
218 energy_kcal_mol: f64,
219 },
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct MethodComparisonEntry {
225 pub method: ScientificMethod,
226 pub status: MethodComparisonStatus,
227 pub available: bool,
228 pub confidence: eht::SupportLevel,
229 pub confidence_score: f64,
230 pub warnings: Vec<String>,
231 pub limitations: Vec<String>,
232 pub payload: Option<MethodComparisonPayload>,
233 pub error: Option<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct MethodComparisonResult {
239 pub plan: SystemMethodPlan,
240 pub comparisons: Vec<MethodComparisonEntry>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct AromaticityAnalysis {
246 pub aromatic_atoms: Vec<bool>,
247 pub aromatic_bonds: Vec<(usize, usize)>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct StereocenterAnalysis {
253 pub tagged_tetrahedral_centers: Vec<usize>,
254 pub inferred_tetrahedral_centers: Vec<usize>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct GraphFeatureAnalysis {
260 pub aromaticity: AromaticityAnalysis,
261 pub stereocenters: StereocenterAnalysis,
262}
263
264impl Default for ConformerConfig {
265 fn default() -> Self {
266 Self {
267 seed: 42,
268 num_threads: 0,
269 }
270 }
271}
272
273pub fn version() -> String {
277 format!("sci-form {}", env!("CARGO_PKG_VERSION"))
278}
279
280pub fn get_eht_support(elements: &[u8]) -> eht::EhtSupport {
282 eht::analyze_eht_support(elements)
283}
284
285fn is_uff_element_supported(z: u8) -> bool {
286 matches!(
287 z,
288 1 | 5
289 | 6
290 | 7
291 | 8
292 | 9
293 | 14
294 | 15
295 | 16
296 | 17
297 | 22
298 | 23
299 | 24
300 | 25
301 | 26
302 | 27
303 | 28
304 | 29
305 | 30
306 | 32
307 | 33
308 | 34
309 | 35
310 | 42
311 | 46
312 | 47
313 | 50
314 | 51
315 | 52
316 | 53
317 | 78
318 | 79
319 )
320}
321
322fn unique_sorted_unsupported(elements: &[u8], pred: impl Fn(u8) -> bool) -> Vec<u8> {
323 let mut out: Vec<u8> = elements.iter().copied().filter(|&z| !pred(z)).collect();
324 out.sort_unstable();
325 out.dedup();
326 out
327}
328
329pub fn get_system_capabilities(elements: &[u8]) -> SystemCapabilities {
331 let eht_support = get_eht_support(elements);
332 let uff_unsupported = unique_sorted_unsupported(elements, is_uff_element_supported);
333
334 let embed = MethodCapability {
335 available: !elements.is_empty(),
336 confidence: eht::SupportLevel::Experimental,
337 unsupported_elements: Vec::new(),
338 warnings: vec![
339 "Embed capability is inferred from element presence only; final success still depends on full molecular graph and geometry constraints.".to_string(),
340 ],
341 };
342
343 let uff = if uff_unsupported.is_empty() {
344 MethodCapability {
345 available: true,
346 confidence: eht::SupportLevel::Supported,
347 unsupported_elements: Vec::new(),
348 warnings: Vec::new(),
349 }
350 } else {
351 MethodCapability {
352 available: false,
353 confidence: eht::SupportLevel::Unsupported,
354 unsupported_elements: uff_unsupported.clone(),
355 warnings: vec![format!(
356 "UFF atom typing is unavailable for elements {:?}.",
357 uff_unsupported
358 )],
359 }
360 };
361
362 let eht = MethodCapability {
363 available: eht_support.unsupported_elements.is_empty(),
364 confidence: eht_support.level,
365 unsupported_elements: eht_support.unsupported_elements.clone(),
366 warnings: eht_support.warnings.clone(),
367 };
368
369 let population = MethodCapability {
370 available: eht.available,
371 confidence: eht.confidence,
372 unsupported_elements: eht.unsupported_elements.clone(),
373 warnings: eht.warnings.clone(),
374 };
375
376 let orbital_grid = MethodCapability {
377 available: eht.available,
378 confidence: eht.confidence,
379 unsupported_elements: eht.unsupported_elements.clone(),
380 warnings: eht.warnings.clone(),
381 };
382
383 SystemCapabilities {
384 embed,
385 uff,
386 eht,
387 population,
388 orbital_grid,
389 }
390}
391
392fn confidence_score_for_method(method: ScientificMethod, capability: &MethodCapability) -> f64 {
393 if !capability.available {
394 return 0.0;
395 }
396
397 match method {
398 ScientificMethod::Embed => 0.8,
399 ScientificMethod::Uff | ScientificMethod::Mmff94 => match capability.confidence {
400 eht::SupportLevel::Supported => 0.95,
401 eht::SupportLevel::Experimental => 0.75,
402 eht::SupportLevel::Unsupported => 0.0,
403 },
404 ScientificMethod::Eht | ScientificMethod::Pm3 | ScientificMethod::Xtb => {
405 match capability.confidence {
406 eht::SupportLevel::Supported => 0.95,
407 eht::SupportLevel::Experimental => 0.6,
408 eht::SupportLevel::Unsupported => 0.0,
409 }
410 }
411 }
412}
413
414fn build_method_metadata(
415 method: ScientificMethod,
416 capability: &MethodCapability,
417 extra_limitations: &[&str],
418) -> MethodMetadata {
419 let mut limitations: Vec<String> = extra_limitations.iter().map(|s| s.to_string()).collect();
420
421 if !capability.unsupported_elements.is_empty() {
422 limitations.push(format!(
423 "Unsupported elements for this method: {:?}.",
424 capability.unsupported_elements
425 ));
426 }
427
428 if matches!(method, ScientificMethod::Eht)
429 && matches!(capability.confidence, eht::SupportLevel::Experimental)
430 {
431 limitations.push(
432 "Transition-metal EHT parameters remain provisional and should be treated as experimental."
433 .to_string(),
434 );
435 }
436
437 MethodMetadata {
438 method,
439 available: capability.available,
440 confidence: capability.confidence,
441 confidence_score: confidence_score_for_method(method, capability),
442 limitations,
443 warnings: capability.warnings.clone(),
444 }
445}
446
447fn build_property_plan(
448 property: PropertyRequest,
449 recommended: Option<ScientificMethod>,
450 fallback: Option<ScientificMethod>,
451 rationale: Vec<String>,
452 methods: Vec<MethodMetadata>,
453) -> PropertyMethodPlan {
454 PropertyMethodPlan {
455 property,
456 recommended,
457 fallback,
458 rationale,
459 methods,
460 }
461}
462
463pub fn get_system_method_plan(elements: &[u8]) -> SystemMethodPlan {
465 let capabilities = get_system_capabilities(elements);
466
467 let geometry_method = build_method_metadata(
468 ScientificMethod::Embed,
469 &capabilities.embed,
470 &["Geometry generation still depends on graph topology, stereochemistry, and embedding constraints."],
471 );
472 let geometry = build_property_plan(
473 PropertyRequest::Geometry,
474 geometry_method.available.then_some(ScientificMethod::Embed),
475 None,
476 vec!["Embedding is the top-level geometry generation path in sci-form.".to_string()],
477 vec![geometry_method],
478 );
479
480 let uff_method = build_method_metadata(
481 ScientificMethod::Uff,
482 &capabilities.uff,
483 &["This recommendation applies to force-field energy evaluation, not molecular orbital analysis."],
484 );
485 let force_field_energy = build_property_plan(
486 PropertyRequest::ForceFieldEnergy,
487 uff_method.available.then_some(ScientificMethod::Uff),
488 None,
489 vec![
490 "UFF is the top-level force-field energy path when atom typing is available."
491 .to_string(),
492 ],
493 vec![uff_method],
494 );
495
496 let eht_method = build_method_metadata(
497 ScientificMethod::Eht,
498 &capabilities.eht,
499 &["EHT is the only current top-level orbital method in sci-form."],
500 );
501 let orbitals = build_property_plan(
502 PropertyRequest::Orbitals,
503 eht_method.available.then_some(ScientificMethod::Eht),
504 None,
505 vec!["Orbital energies and MO coefficients are produced by the EHT workflow.".to_string()],
506 vec![eht_method.clone()],
507 );
508
509 let population_method = build_method_metadata(
510 ScientificMethod::Eht,
511 &capabilities.population,
512 &["Population analysis is derived from the EHT density and overlap matrices."],
513 );
514 let population = build_property_plan(
515 PropertyRequest::Population,
516 population_method.available.then_some(ScientificMethod::Eht),
517 None,
518 vec!["Population analysis currently requires a successful EHT calculation.".to_string()],
519 vec![population_method],
520 );
521
522 let orbital_grid_method = build_method_metadata(
523 ScientificMethod::Eht,
524 &capabilities.orbital_grid,
525 &["Orbital-grid rendering currently depends on EHT molecular-orbital coefficients."],
526 );
527 let orbital_grid = build_property_plan(
528 PropertyRequest::OrbitalGrid,
529 orbital_grid_method
530 .available
531 .then_some(ScientificMethod::Eht),
532 None,
533 vec![
534 "Orbital-grid generation currently requires a successful EHT calculation.".to_string(),
535 ],
536 vec![orbital_grid_method],
537 );
538
539 SystemMethodPlan {
540 capabilities,
541 geometry,
542 force_field_energy,
543 orbitals,
544 population,
545 orbital_grid,
546 }
547}
548
549pub fn embed(smiles: &str, seed: u64) -> ConformerResult {
551 #[cfg(not(target_arch = "wasm32"))]
552 let start = std::time::Instant::now();
553
554 let mol = match graph::Molecule::from_smiles(smiles) {
555 Ok(m) => m,
556 Err(e) => {
557 return ConformerResult {
558 smiles: smiles.to_string(),
559 num_atoms: 0,
560 coords: vec![],
561 elements: vec![],
562 bonds: vec![],
563 error: Some(e),
564 #[cfg(not(target_arch = "wasm32"))]
565 time_ms: start.elapsed().as_secs_f64() * 1000.0,
566 #[cfg(target_arch = "wasm32")]
567 time_ms: 0.0,
568 };
569 }
570 };
571
572 let n = mol.graph.node_count();
573 let elements: Vec<u8> = (0..n)
574 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
575 .collect();
576 let bonds: Vec<(usize, usize, String)> = mol
577 .graph
578 .edge_indices()
579 .map(|e| {
580 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
581 let order = match mol.graph[e].order {
582 graph::BondOrder::Single => "SINGLE",
583 graph::BondOrder::Double => "DOUBLE",
584 graph::BondOrder::Triple => "TRIPLE",
585 graph::BondOrder::Aromatic => "AROMATIC",
586 graph::BondOrder::Unknown => "UNKNOWN",
587 };
588 (a.index(), b.index(), order.to_string())
589 })
590 .collect();
591
592 match conformer::generate_3d_conformer(&mol, seed) {
593 Ok(coords) => {
594 let mut flat = Vec::with_capacity(n * 3);
595 for i in 0..n {
596 flat.push(coords[(i, 0)] as f64);
597 flat.push(coords[(i, 1)] as f64);
598 flat.push(coords[(i, 2)] as f64);
599 }
600 ConformerResult {
601 smiles: smiles.to_string(),
602 num_atoms: n,
603 coords: flat,
604 elements,
605 bonds,
606 error: None,
607 #[cfg(not(target_arch = "wasm32"))]
608 time_ms: start.elapsed().as_secs_f64() * 1000.0,
609 #[cfg(target_arch = "wasm32")]
610 time_ms: 0.0,
611 }
612 }
613 Err(e) => ConformerResult {
614 smiles: smiles.to_string(),
615 num_atoms: n,
616 coords: vec![],
617 elements,
618 bonds,
619 error: Some(e),
620 #[cfg(not(target_arch = "wasm32"))]
621 time_ms: start.elapsed().as_secs_f64() * 1000.0,
622 #[cfg(target_arch = "wasm32")]
623 time_ms: 0.0,
624 },
625 }
626}
627
628#[cfg(feature = "parallel")]
633pub fn embed_batch(smiles_list: &[&str], config: &ConformerConfig) -> Vec<ConformerResult> {
634 use rayon::prelude::*;
635
636 if config.num_threads > 0 {
637 let pool = rayon::ThreadPoolBuilder::new()
638 .num_threads(config.num_threads)
639 .build()
640 .unwrap();
641 pool.install(|| {
642 smiles_list
643 .par_iter()
644 .map(|smi| embed(smi, config.seed))
645 .collect()
646 })
647 } else {
648 smiles_list
649 .par_iter()
650 .map(|smi| embed(smi, config.seed))
651 .collect()
652 }
653}
654
655#[cfg(not(feature = "parallel"))]
657pub fn embed_batch(smiles_list: &[&str], config: &ConformerConfig) -> Vec<ConformerResult> {
658 smiles_list
659 .iter()
660 .map(|smi| embed(smi, config.seed))
661 .collect()
662}
663
664pub fn parse(smiles: &str) -> Result<graph::Molecule, String> {
666 graph::Molecule::from_smiles(smiles)
667}
668
669pub fn compute_charges(smiles: &str) -> Result<charges::gasteiger::ChargeResult, String> {
674 let mol = graph::Molecule::from_smiles(smiles)?;
675 let n = mol.graph.node_count();
676 let elements: Vec<u8> = (0..n)
677 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
678 .collect();
679 let formal_charges: Vec<i8> = (0..n)
680 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].formal_charge)
681 .collect();
682 let bonds: Vec<(usize, usize)> = mol
683 .graph
684 .edge_indices()
685 .map(|e| {
686 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
687 (a.index(), b.index())
688 })
689 .collect();
690 charges::gasteiger::gasteiger_marsili_charges(&elements, &bonds, &formal_charges, 6)
691}
692
693pub fn compute_sasa(
697 elements: &[u8],
698 coords_flat: &[f64],
699 probe_radius: Option<f64>,
700) -> Result<surface::sasa::SasaResult, String> {
701 if coords_flat.len() != elements.len() * 3 {
702 return Err(format!(
703 "coords length {} != 3 * elements {}",
704 coords_flat.len(),
705 elements.len()
706 ));
707 }
708 let positions: Vec<[f64; 3]> = coords_flat
709 .chunks_exact(3)
710 .map(|c| [c[0], c[1], c[2]])
711 .collect();
712
713 #[cfg(feature = "parallel")]
714 {
715 Ok(surface::sasa::compute_sasa_parallel(
716 elements,
717 &positions,
718 probe_radius,
719 None,
720 ))
721 }
722
723 #[cfg(not(feature = "parallel"))]
724 {
725 Ok(surface::sasa::compute_sasa(
726 elements,
727 &positions,
728 probe_radius,
729 None,
730 ))
731 }
732}
733
734pub fn compute_population(
736 elements: &[u8],
737 positions: &[[f64; 3]],
738) -> Result<population::PopulationResult, String> {
739 let eht_result = eht::solve_eht(elements, positions, None)?;
740 Ok(population::compute_population(
741 elements,
742 positions,
743 &eht_result.coefficients,
744 eht_result.n_electrons,
745 ))
746}
747
748pub fn compute_dipole(
750 elements: &[u8],
751 positions: &[[f64; 3]],
752) -> Result<dipole::DipoleResult, String> {
753 let eht_result = eht::solve_eht(elements, positions, None)?;
754 Ok(dipole::compute_dipole_from_eht(
755 elements,
756 positions,
757 &eht_result.coefficients,
758 eht_result.n_electrons,
759 ))
760}
761
762pub fn compute_frontier_descriptors(
764 elements: &[u8],
765 positions: &[[f64; 3]],
766) -> Result<reactivity::FrontierDescriptors, String> {
767 let eht_result = eht::solve_eht(elements, positions, None)?;
768 Ok(reactivity::compute_frontier_descriptors(
769 elements,
770 positions,
771 &eht_result,
772 ))
773}
774
775pub fn compute_fukui_descriptors(
777 elements: &[u8],
778 positions: &[[f64; 3]],
779) -> Result<reactivity::FukuiDescriptors, String> {
780 let eht_result = eht::solve_eht(elements, positions, None)?;
781 Ok(reactivity::compute_fukui_descriptors(
782 elements,
783 positions,
784 &eht_result,
785 ))
786}
787
788pub fn compute_reactivity_ranking(
790 elements: &[u8],
791 positions: &[[f64; 3]],
792) -> Result<reactivity::ReactivityRanking, String> {
793 let eht_result = eht::solve_eht(elements, positions, None)?;
794 let fukui = reactivity::compute_fukui_descriptors(elements, positions, &eht_result);
795 let pop = population::compute_population(
796 elements,
797 positions,
798 &eht_result.coefficients,
799 eht_result.n_electrons,
800 );
801 Ok(reactivity::rank_reactivity_sites(
802 &fukui,
803 &pop.mulliken_charges,
804 ))
805}
806
807pub fn compute_uv_vis_spectrum(
809 elements: &[u8],
810 positions: &[[f64; 3]],
811 sigma: f64,
812 e_min: f64,
813 e_max: f64,
814 n_points: usize,
815) -> Result<reactivity::UvVisSpectrum, String> {
816 let eht_result = eht::solve_eht(elements, positions, None)?;
817 Ok(reactivity::compute_uv_vis_like_spectrum(
818 &eht_result,
819 sigma,
820 e_min,
821 e_max,
822 n_points,
823 ))
824}
825
826pub fn compute_bond_orders(
828 elements: &[u8],
829 positions: &[[f64; 3]],
830) -> Result<population::BondOrderResult, String> {
831 let eht_result = eht::solve_eht(elements, positions, None)?;
832 Ok(population::compute_bond_orders(
833 elements,
834 positions,
835 &eht_result.coefficients,
836 eht_result.n_electrons,
837 ))
838}
839
840pub fn compute_topology(
842 elements: &[u8],
843 positions: &[[f64; 3]],
844) -> topology::TopologyAnalysisResult {
845 topology::analyze_topology(elements, positions)
846}
847
848pub fn analyze_graph_features(smiles: &str) -> Result<GraphFeatureAnalysis, String> {
850 use petgraph::visit::EdgeRef;
851
852 let mol = parse(smiles)?;
853 let n_atoms = mol.graph.node_count();
854 let mut aromatic_atoms = vec![false; n_atoms];
855 let mut aromatic_bonds = Vec::new();
856
857 for edge in mol.graph.edge_references() {
858 if matches!(edge.weight().order, graph::BondOrder::Aromatic) {
859 let i = edge.source().index();
860 let j = edge.target().index();
861 aromatic_atoms[i] = true;
862 aromatic_atoms[j] = true;
863 aromatic_bonds.push((i, j));
864 }
865 }
866
867 let mut tagged_tetrahedral_centers = Vec::new();
868 let mut inferred_tetrahedral_centers = Vec::new();
869 for i in 0..n_atoms {
870 let idx = petgraph::graph::NodeIndex::new(i);
871 let atom = &mol.graph[idx];
872 if matches!(
873 atom.chiral_tag,
874 graph::ChiralType::TetrahedralCW | graph::ChiralType::TetrahedralCCW
875 ) {
876 tagged_tetrahedral_centers.push(i);
877 }
878
879 let neighbors: Vec<_> = mol.graph.neighbors(idx).collect();
880 if neighbors.len() == 4 && matches!(atom.hybridization, graph::Hybridization::SP3) {
881 let mut signature: Vec<u8> = neighbors.iter().map(|n| mol.graph[*n].element).collect();
882 signature.sort_unstable();
883 signature.dedup();
884 if signature.len() >= 3 {
885 inferred_tetrahedral_centers.push(i);
886 }
887 }
888 }
889
890 Ok(GraphFeatureAnalysis {
891 aromaticity: AromaticityAnalysis {
892 aromatic_atoms,
893 aromatic_bonds,
894 },
895 stereocenters: StereocenterAnalysis {
896 tagged_tetrahedral_centers,
897 inferred_tetrahedral_centers,
898 },
899 })
900}
901
902pub fn compute_eht_or_uff_fallback(
907 smiles: &str,
908 elements: &[u8],
909 positions: &[[f64; 3]],
910 allow_experimental_eht: bool,
911) -> Result<ElectronicWorkflowResult, String> {
912 let support = get_eht_support(elements);
913 let should_fallback = match support.level {
914 eht::SupportLevel::Unsupported => true,
915 eht::SupportLevel::Experimental => !allow_experimental_eht,
916 eht::SupportLevel::Supported => false,
917 };
918
919 if should_fallback {
920 let coords_flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
921 let energy = compute_uff_energy(smiles, &coords_flat).map_err(|e| {
922 format!(
923 "EHT is not appropriate for this system (support: {:?}) and UFF fallback failed: {}",
924 support.level, e
925 )
926 })?;
927 return Ok(ElectronicWorkflowResult::UffFallback {
928 energy_kcal_mol: energy,
929 reason: if matches!(support.level, eht::SupportLevel::Unsupported) {
930 "EHT unsupported for one or more elements; routed to UFF-only workflow.".to_string()
931 } else {
932 "EHT confidence is experimental and experimental mode is disabled; routed to UFF-only workflow."
933 .to_string()
934 },
935 support,
936 });
937 }
938
939 let result = eht::solve_eht(elements, positions, None)?;
940 Ok(ElectronicWorkflowResult::Eht { result })
941}
942
943pub fn compare_methods(
948 smiles: &str,
949 elements: &[u8],
950 positions: &[[f64; 3]],
951 allow_experimental_eht: bool,
952) -> MethodComparisonResult {
953 let plan = get_system_method_plan(elements);
954 let mut comparisons = Vec::new();
955
956 let coords_flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
957
958 {
959 let meta = build_method_metadata(
960 ScientificMethod::Uff,
961 &plan.capabilities.uff,
962 &["Comparison uses UFF force-field energy as the UFF observable."],
963 );
964 if !meta.available {
965 comparisons.push(MethodComparisonEntry {
966 method: ScientificMethod::Uff,
967 status: MethodComparisonStatus::Unavailable,
968 available: false,
969 confidence: meta.confidence,
970 confidence_score: meta.confidence_score,
971 warnings: meta.warnings,
972 limitations: meta.limitations,
973 payload: None,
974 error: Some("UFF is unavailable for this element set.".to_string()),
975 });
976 } else {
977 match compute_uff_energy(smiles, &coords_flat) {
978 Ok(energy) => comparisons.push(MethodComparisonEntry {
979 method: ScientificMethod::Uff,
980 status: MethodComparisonStatus::Success,
981 available: true,
982 confidence: meta.confidence,
983 confidence_score: meta.confidence_score,
984 warnings: meta.warnings,
985 limitations: meta.limitations,
986 payload: Some(MethodComparisonPayload::Uff {
987 energy_kcal_mol: energy,
988 }),
989 error: None,
990 }),
991 Err(err) => comparisons.push(MethodComparisonEntry {
992 method: ScientificMethod::Uff,
993 status: MethodComparisonStatus::Error,
994 available: true,
995 confidence: meta.confidence,
996 confidence_score: meta.confidence_score,
997 warnings: meta.warnings,
998 limitations: meta.limitations,
999 payload: None,
1000 error: Some(err),
1001 }),
1002 }
1003 }
1004 }
1005
1006 {
1007 let meta = build_method_metadata(
1008 ScientificMethod::Eht,
1009 &plan.capabilities.eht,
1010 &["Comparison uses frontier orbital energies and gap as the EHT observable."],
1011 );
1012
1013 if !meta.available {
1014 comparisons.push(MethodComparisonEntry {
1015 method: ScientificMethod::Eht,
1016 status: MethodComparisonStatus::Unavailable,
1017 available: false,
1018 confidence: meta.confidence,
1019 confidence_score: meta.confidence_score,
1020 warnings: meta.warnings,
1021 limitations: meta.limitations,
1022 payload: None,
1023 error: Some("EHT is unavailable for this element set.".to_string()),
1024 });
1025 } else if matches!(meta.confidence, eht::SupportLevel::Experimental)
1026 && !allow_experimental_eht
1027 {
1028 comparisons.push(MethodComparisonEntry {
1029 method: ScientificMethod::Eht,
1030 status: MethodComparisonStatus::Unavailable,
1031 available: true,
1032 confidence: meta.confidence,
1033 confidence_score: meta.confidence_score,
1034 warnings: meta.warnings,
1035 limitations: meta.limitations,
1036 payload: None,
1037 error: Some(
1038 "EHT confidence is experimental and allow_experimental_eht=false.".to_string(),
1039 ),
1040 });
1041 } else {
1042 match eht::solve_eht(elements, positions, None) {
1043 Ok(result) => comparisons.push(MethodComparisonEntry {
1044 method: ScientificMethod::Eht,
1045 status: MethodComparisonStatus::Success,
1046 available: true,
1047 confidence: meta.confidence,
1048 confidence_score: meta.confidence_score,
1049 warnings: meta.warnings,
1050 limitations: meta.limitations,
1051 payload: Some(MethodComparisonPayload::Eht {
1052 homo_energy: result.homo_energy,
1053 lumo_energy: result.lumo_energy,
1054 gap: result.gap,
1055 support: result.support,
1056 }),
1057 error: None,
1058 }),
1059 Err(err) => comparisons.push(MethodComparisonEntry {
1060 method: ScientificMethod::Eht,
1061 status: MethodComparisonStatus::Error,
1062 available: true,
1063 confidence: meta.confidence,
1064 confidence_score: meta.confidence_score,
1065 warnings: meta.warnings,
1066 limitations: meta.limitations,
1067 payload: None,
1068 error: Some(err),
1069 }),
1070 }
1071 }
1072 }
1073
1074 MethodComparisonResult { plan, comparisons }
1075}
1076
1077pub fn compute_esp(
1079 elements: &[u8],
1080 positions: &[[f64; 3]],
1081 spacing: f64,
1082 padding: f64,
1083) -> Result<esp::EspGrid, String> {
1084 let pop = compute_population(elements, positions)?;
1085 #[cfg(feature = "parallel")]
1086 {
1087 Ok(esp::compute_esp_grid_parallel(
1088 elements,
1089 positions,
1090 &pop.mulliken_charges,
1091 spacing,
1092 padding,
1093 ))
1094 }
1095
1096 #[cfg(not(feature = "parallel"))]
1097 {
1098 Ok(esp::compute_esp_grid(
1099 elements,
1100 positions,
1101 &pop.mulliken_charges,
1102 spacing,
1103 padding,
1104 ))
1105 }
1106}
1107
1108pub fn compute_dos(
1110 elements: &[u8],
1111 positions: &[[f64; 3]],
1112 sigma: f64,
1113 e_min: f64,
1114 e_max: f64,
1115 n_points: usize,
1116) -> Result<dos::DosResult, String> {
1117 let eht_result = eht::solve_eht(elements, positions, None)?;
1118 let flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1119
1120 #[cfg(feature = "parallel")]
1121 {
1122 Ok(dos::compute_pdos_parallel(
1123 elements,
1124 &flat,
1125 &eht_result.energies,
1126 &eht_result.coefficients,
1127 eht_result.n_electrons,
1128 sigma,
1129 e_min,
1130 e_max,
1131 n_points,
1132 ))
1133 }
1134
1135 #[cfg(not(feature = "parallel"))]
1136 {
1137 Ok(dos::compute_pdos(
1138 elements,
1139 &flat,
1140 &eht_result.energies,
1141 &eht_result.coefficients,
1142 eht_result.n_electrons,
1143 sigma,
1144 e_min,
1145 e_max,
1146 n_points,
1147 ))
1148 }
1149}
1150
1151pub fn compute_rmsd(coords: &[f64], reference: &[f64]) -> f64 {
1153 alignment::compute_rmsd(coords, reference)
1154}
1155
1156pub fn compute_uff_energy(smiles: &str, coords: &[f64]) -> Result<f64, String> {
1158 let mol = graph::Molecule::from_smiles(smiles)?;
1159 let n = mol.graph.node_count();
1160 if coords.len() != n * 3 {
1161 return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1162 }
1163 let ff = forcefield::builder::build_uff_force_field(&mol);
1164 let mut gradient = vec![0.0f64; n * 3];
1165 let energy = ff.compute_system_energy_and_gradients(coords, &mut gradient);
1166 Ok(energy)
1167}
1168
1169pub fn compute_mmff94_energy(smiles: &str, coords: &[f64]) -> Result<f64, String> {
1176 let mol = graph::Molecule::from_smiles(smiles)?;
1177 let n = mol.graph.node_count();
1178 if coords.len() != n * 3 {
1179 return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1180 }
1181 let elements: Vec<u8> = (0..n)
1182 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
1183 .collect();
1184 let bonds: Vec<(usize, usize, u8)> = mol
1185 .graph
1186 .edge_indices()
1187 .map(|e| {
1188 let (a, b) = mol.graph.edge_endpoints(e).unwrap();
1189 let order = match mol.graph[e].order {
1190 graph::BondOrder::Single => 1u8,
1191 graph::BondOrder::Double => 2,
1192 graph::BondOrder::Triple => 3,
1193 graph::BondOrder::Aromatic => 2,
1194 graph::BondOrder::Unknown => 1,
1195 };
1196 (a.index(), b.index(), order)
1197 })
1198 .collect();
1199 let terms = forcefield::mmff94::Mmff94Builder::build(&elements, &bonds);
1200 let (energy, _grad) = forcefield::mmff94::Mmff94Builder::total_energy(&terms, coords);
1201 Ok(energy)
1202}
1203
1204pub fn compute_pm3(elements: &[u8], positions: &[[f64; 3]]) -> Result<pm3::Pm3Result, String> {
1211 pm3::solve_pm3(elements, positions)
1212}
1213
1214pub fn compute_xtb(elements: &[u8], positions: &[[f64; 3]]) -> Result<xtb::XtbResult, String> {
1221 xtb::solve_xtb(elements, positions)
1222}
1223
1224pub fn compute_ml_descriptors(
1231 elements: &[u8],
1232 bonds: &[(usize, usize, u8)],
1233 charges: &[f64],
1234 aromatic_atoms: &[bool],
1235) -> ml::MolecularDescriptors {
1236 ml::compute_descriptors(elements, bonds, charges, aromatic_atoms)
1237}
1238
1239pub fn predict_ml_properties(desc: &ml::MolecularDescriptors) -> ml::MlPropertyResult {
1244 ml::predict_properties(desc)
1245}
1246
1247pub fn compute_md_trajectory(
1249 smiles: &str,
1250 coords: &[f64],
1251 n_steps: usize,
1252 dt_fs: f64,
1253 seed: u64,
1254) -> Result<dynamics::MdTrajectory, String> {
1255 dynamics::simulate_velocity_verlet_uff(smiles, coords, n_steps, dt_fs, seed, None)
1256}
1257
1258pub fn compute_md_trajectory_nvt(
1260 smiles: &str,
1261 coords: &[f64],
1262 n_steps: usize,
1263 dt_fs: f64,
1264 seed: u64,
1265 target_temp_k: f64,
1266 thermostat_tau_fs: f64,
1267) -> Result<dynamics::MdTrajectory, String> {
1268 dynamics::simulate_velocity_verlet_uff(
1269 smiles,
1270 coords,
1271 n_steps,
1272 dt_fs,
1273 seed,
1274 Some((target_temp_k, thermostat_tau_fs)),
1275 )
1276}
1277
1278pub fn compute_simplified_neb_path(
1280 smiles: &str,
1281 start_coords: &[f64],
1282 end_coords: &[f64],
1283 n_images: usize,
1284 n_iter: usize,
1285 spring_k: f64,
1286 step_size: f64,
1287) -> Result<dynamics::NebPathResult, String> {
1288 dynamics::compute_simplified_neb_path(
1289 smiles,
1290 start_coords,
1291 end_coords,
1292 n_images,
1293 n_iter,
1294 spring_k,
1295 step_size,
1296 )
1297}
1298
1299fn coords_flat_to_matrix_f32(coords: &[f64], n_atoms: usize) -> nalgebra::DMatrix<f32> {
1300 let mut m = nalgebra::DMatrix::<f32>::zeros(n_atoms, 3);
1301 for i in 0..n_atoms {
1302 m[(i, 0)] = coords[3 * i] as f32;
1303 m[(i, 1)] = coords[3 * i + 1] as f32;
1304 m[(i, 2)] = coords[3 * i + 2] as f32;
1305 }
1306 m
1307}
1308
1309fn coords_matrix_f32_to_flat(m: &nalgebra::DMatrix<f32>) -> Vec<f64> {
1310 let mut out = Vec::with_capacity(m.nrows() * 3);
1311 for i in 0..m.nrows() {
1312 out.push(m[(i, 0)] as f64);
1313 out.push(m[(i, 1)] as f64);
1314 out.push(m[(i, 2)] as f64);
1315 }
1316 out
1317}
1318
1319pub fn search_conformers_with_uff(
1328 smiles: &str,
1329 n_samples: usize,
1330 seed: u64,
1331 rmsd_threshold: f64,
1332) -> Result<ConformerSearchResult, String> {
1333 if n_samples == 0 {
1334 return Err("n_samples must be > 0".to_string());
1335 }
1336 if rmsd_threshold <= 0.0 {
1337 return Err("rmsd_threshold must be > 0".to_string());
1338 }
1339
1340 let mol = graph::Molecule::from_smiles(smiles)?;
1341 let n_atoms = mol.graph.node_count();
1342 let bounds = distgeom::smooth_bounds_matrix(distgeom::calculate_bounds_matrix(&mol));
1343
1344 let mut generated = Vec::new();
1345 let mut notes = Vec::new();
1346 let mut rotatable_bonds = 0usize;
1347
1348 for i in 0..n_samples {
1349 let sample_seed = seed.wrapping_add(i as u64);
1350 let conf = embed(smiles, sample_seed);
1351
1352 if conf.error.is_some() || conf.coords.len() != n_atoms * 3 {
1353 continue;
1354 }
1355
1356 let mut coords = coords_flat_to_matrix_f32(&conf.coords, n_atoms);
1357 let rot_mc = forcefield::optimize_torsions_monte_carlo_bounds(
1358 &mut coords,
1359 &mol,
1360 &bounds,
1361 sample_seed ^ 0x9E37_79B9_7F4A_7C15,
1362 64,
1363 0.4,
1364 );
1365 let rot_greedy = forcefield::optimize_torsions_bounds(&mut coords, &mol, &bounds, 2);
1366 let rot = rot_mc.max(rot_greedy);
1367 rotatable_bonds = rot;
1368 let coords_flat = coords_matrix_f32_to_flat(&coords);
1369
1370 match compute_uff_energy(smiles, &coords_flat) {
1371 Ok(energy_kcal_mol) => generated.push(ConformerEnsembleMember {
1372 seed: sample_seed,
1373 cluster_id: None,
1374 coords: coords_flat,
1375 energy_kcal_mol,
1376 }),
1377 Err(_) => continue,
1378 }
1379 }
1380
1381 if generated.is_empty() {
1382 return Err("failed to generate any valid conformers".to_string());
1383 }
1384
1385 generated.sort_by(|a, b| {
1386 a.energy_kcal_mol
1387 .partial_cmp(&b.energy_kcal_mol)
1388 .unwrap_or(std::cmp::Ordering::Equal)
1389 });
1390
1391 let generated_count = generated.len();
1392
1393 let mut unique = Vec::new();
1394 let mut cluster_members: Vec<Vec<u64>> = Vec::new();
1395 for candidate in generated {
1396 let existing_cluster = unique.iter().position(|u: &ConformerEnsembleMember| {
1397 compute_rmsd(&candidate.coords, &u.coords) < rmsd_threshold
1398 });
1399
1400 if let Some(cluster_id) = existing_cluster {
1401 cluster_members[cluster_id].push(candidate.seed);
1402 } else {
1403 unique.push(candidate.clone());
1404 cluster_members.push(vec![candidate.seed]);
1405 }
1406 }
1407
1408 for (cluster_id, member) in unique.iter_mut().enumerate() {
1409 member.cluster_id = Some(cluster_id);
1410 }
1411
1412 let clusters: Vec<ConformerClusterSummary> = unique
1413 .iter()
1414 .enumerate()
1415 .map(|(cluster_id, representative)| ConformerClusterSummary {
1416 cluster_id,
1417 representative_seed: representative.seed,
1418 size: cluster_members[cluster_id].len(),
1419 member_seeds: cluster_members[cluster_id].clone(),
1420 })
1421 .collect();
1422
1423 notes.push(
1424 "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."
1425 .to_string(),
1426 );
1427
1428 Ok(ConformerSearchResult {
1429 generated: generated_count,
1430 unique: unique.len(),
1431 rotatable_bonds,
1432 conformers: unique,
1433 clusters,
1434 notes,
1435 })
1436}
1437
1438pub fn compute_uff_energy_with_aromatic_heuristics(
1440 smiles: &str,
1441 coords: &[f64],
1442) -> Result<reactivity::UffHeuristicEnergy, String> {
1443 let mol = graph::Molecule::from_smiles(smiles)?;
1444 let n = mol.graph.node_count();
1445 if coords.len() != n * 3 {
1446 return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1447 }
1448
1449 let ff = forcefield::builder::build_uff_force_field(&mol);
1450 let mut gradient = vec![0.0f64; n * 3];
1451 let raw = ff.compute_system_energy_and_gradients(coords, &mut gradient);
1452 Ok(reactivity::apply_aromatic_uff_correction(&mol, raw))
1453}
1454
1455pub fn compute_empirical_pka(smiles: &str) -> Result<reactivity::EmpiricalPkaResult, String> {
1457 let mol = graph::Molecule::from_smiles(smiles)?;
1458 let charges = compute_charges(smiles)?;
1459 Ok(reactivity::estimate_empirical_pka(&mol, &charges.charges))
1460}
1461
1462pub fn create_unit_cell(
1464 a: f64,
1465 b: f64,
1466 c: f64,
1467 alpha: f64,
1468 beta: f64,
1469 gamma: f64,
1470) -> materials::UnitCell {
1471 materials::UnitCell::from_parameters(&materials::CellParameters {
1472 a,
1473 b,
1474 c,
1475 alpha,
1476 beta,
1477 gamma,
1478 })
1479}
1480
1481pub fn assemble_framework(
1485 node: &materials::Sbu,
1486 linker: &materials::Sbu,
1487 topology: &materials::Topology,
1488 cell: &materials::UnitCell,
1489) -> materials::CrystalStructure {
1490 materials::assemble_framework(node, linker, topology, cell)
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 use super::*;
1496
1497 #[test]
1498 fn test_cisplatin_style_support_metadata() {
1499 let smiles = "[Pt](Cl)(Cl)([NH3])[NH3]";
1500 let mol = parse(smiles).expect("Cisplatin-style example should parse");
1501 let elements: Vec<u8> = (0..mol.graph.node_count())
1502 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
1503 .collect();
1504
1505 let caps = get_system_capabilities(&elements);
1506 assert!(caps.eht.available);
1507 assert!(matches!(
1508 caps.eht.confidence,
1509 eht::SupportLevel::Experimental
1510 ));
1511 }
1512
1513 #[test]
1514 fn test_pt_system_routes_to_uff_when_experimental_disabled() {
1515 let smiles = "[Pt](Cl)(Cl)([NH3])[NH3]";
1516 let mol = parse(smiles).expect("Pt example should parse");
1517 let n = mol.graph.node_count();
1518 let elements: Vec<u8> = (0..n)
1519 .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
1520 .collect();
1521 let positions = vec![[0.0, 0.0, 0.0]; n];
1522
1523 let result = compute_eht_or_uff_fallback(smiles, &elements, &positions, false)
1524 .expect("Fallback workflow should return a result");
1525
1526 assert!(matches!(
1527 result,
1528 ElectronicWorkflowResult::UffFallback { .. }
1529 ));
1530 }
1531
1532 #[test]
1533 fn test_method_plan_prefers_supported_workflows_for_organic_systems() {
1534 let plan = get_system_method_plan(&[6, 1, 1, 1, 1]);
1535
1536 assert_eq!(plan.geometry.recommended, Some(ScientificMethod::Embed));
1537 assert_eq!(
1538 plan.force_field_energy.recommended,
1539 Some(ScientificMethod::Uff)
1540 );
1541 assert_eq!(plan.orbitals.recommended, Some(ScientificMethod::Eht));
1542 assert_eq!(plan.population.recommended, Some(ScientificMethod::Eht));
1543 assert_eq!(plan.orbital_grid.recommended, Some(ScientificMethod::Eht));
1544 assert!(plan.orbitals.methods[0].confidence_score > 0.9);
1545 }
1546
1547 #[test]
1548 fn test_method_plan_marks_metal_orbital_workflow_experimental() {
1549 let plan = get_system_method_plan(&[78, 17, 17, 7, 7]);
1550
1551 assert_eq!(plan.orbitals.recommended, Some(ScientificMethod::Eht));
1552 assert_eq!(
1553 plan.force_field_energy.recommended,
1554 Some(ScientificMethod::Uff)
1555 );
1556 assert!(matches!(
1557 plan.orbitals.methods[0].confidence,
1558 eht::SupportLevel::Experimental
1559 ));
1560 assert!(plan.orbitals.methods[0].confidence_score < 0.9);
1561 assert!(!plan.orbitals.methods[0].warnings.is_empty());
1562 }
1563
1564 #[test]
1565 fn test_method_plan_reports_unavailable_workflows_for_unsupported_elements() {
1566 let plan = get_system_method_plan(&[92]);
1567
1568 assert_eq!(plan.force_field_energy.recommended, None);
1569 assert_eq!(plan.orbitals.recommended, None);
1570 assert_eq!(plan.population.recommended, None);
1571 assert_eq!(plan.orbital_grid.recommended, None);
1572 assert!(!plan.orbitals.methods[0].limitations.is_empty());
1573 }
1574
1575 #[test]
1576 fn test_compare_methods_supported_system_returns_success_rows() {
1577 let result = compare_methods("CC", &[6, 6], &[[0.0, 0.0, 0.0], [1.54, 0.0, 0.0]], true);
1578 assert_eq!(result.comparisons.len(), 2);
1579 assert!(result
1580 .comparisons
1581 .iter()
1582 .any(|entry| matches!(entry.method, ScientificMethod::Uff) && entry.available));
1583 assert!(result.comparisons.iter().any(|entry| matches!(
1584 entry.method,
1585 ScientificMethod::Eht
1586 ) && matches!(
1587 entry.status,
1588 MethodComparisonStatus::Success
1589 )));
1590 }
1591
1592 #[test]
1593 fn test_compare_methods_blocks_experimental_eht_when_disabled() {
1594 let result = compare_methods("[O]", &[78], &[[0.0, 0.0, 0.0]], false);
1595 let eht_row = result
1596 .comparisons
1597 .iter()
1598 .find(|entry| matches!(entry.method, ScientificMethod::Eht))
1599 .expect("EHT row must exist");
1600 assert!(matches!(
1601 eht_row.status,
1602 MethodComparisonStatus::Unavailable
1603 ));
1604 }
1605
1606 #[test]
1607 fn test_compare_methods_reports_unavailable_for_unsupported_elements() {
1608 let result = compare_methods("[O]", &[92], &[[0.0, 0.0, 0.0]], true);
1609 assert!(result
1610 .comparisons
1611 .iter()
1612 .all(|entry| matches!(entry.status, MethodComparisonStatus::Unavailable)));
1613 }
1614
1615 #[test]
1616 fn test_compute_fukui_descriptors_returns_atomwise_output() {
1617 let elements = [8u8, 1, 1];
1618 let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
1619 let result = compute_fukui_descriptors(&elements, &positions).unwrap();
1620 assert_eq!(result.num_atoms, 3);
1621 assert_eq!(result.condensed.len(), 3);
1622 }
1623
1624 #[test]
1625 fn test_compute_uv_vis_spectrum_returns_requested_grid_size() {
1626 let elements = [6u8, 6, 1, 1, 1, 1];
1627 let positions = [
1628 [0.0, 0.0, 0.0],
1629 [1.34, 0.0, 0.0],
1630 [-0.6, 0.92, 0.0],
1631 [-0.6, -0.92, 0.0],
1632 [1.94, 0.92, 0.0],
1633 [1.94, -0.92, 0.0],
1634 ];
1635 let spectrum = compute_uv_vis_spectrum(&elements, &positions, 0.2, 0.5, 8.0, 256).unwrap();
1636 assert_eq!(spectrum.energies_ev.len(), 256);
1637 assert_eq!(spectrum.intensities.len(), 256);
1638 }
1639
1640 #[test]
1641 fn test_analyze_graph_features_reports_benzene_aromaticity() {
1642 let analysis = analyze_graph_features("c1ccccc1").unwrap();
1643 assert_eq!(analysis.aromaticity.aromatic_atoms.len(), 12);
1644 assert_eq!(
1645 analysis
1646 .aromaticity
1647 .aromatic_atoms
1648 .iter()
1649 .filter(|v| **v)
1650 .count(),
1651 6
1652 );
1653 assert_eq!(analysis.aromaticity.aromatic_bonds.len(), 6);
1654 }
1655
1656 #[test]
1657 fn test_compute_empirical_pka_finds_acidic_site_for_acetic_acid() {
1658 let result = compute_empirical_pka("CC(=O)O").unwrap();
1659 assert!(!result.acidic_sites.is_empty());
1660 }
1661
1662 #[test]
1663 fn test_compute_uff_energy_with_aromatic_heuristics_applies_correction() {
1664 let conf = embed("c1ccccc1", 42);
1665 assert!(conf.error.is_none());
1666
1667 let result = compute_uff_energy_with_aromatic_heuristics("c1ccccc1", &conf.coords).unwrap();
1668 assert!(result.aromatic_bond_count >= 6);
1669 assert!(result.corrected_energy_kcal_mol <= result.raw_energy_kcal_mol);
1670 }
1671
1672 #[test]
1673 fn test_search_conformers_with_uff_returns_ranked_unique_ensemble() {
1674 let result = search_conformers_with_uff("CCCC", 10, 42, 0.2).unwrap();
1675 assert!(result.generated >= 1);
1676 assert!(result.unique >= 1);
1677 assert_eq!(result.unique, result.conformers.len());
1678 assert_eq!(result.unique, result.clusters.len());
1679 assert!(result.rotatable_bonds >= 1);
1680
1681 let mut total_members = 0usize;
1682 for (i, cluster) in result.clusters.iter().enumerate() {
1683 assert_eq!(cluster.cluster_id, i);
1684 assert!(cluster.size >= 1);
1685 total_members += cluster.size;
1686 assert_eq!(result.conformers[i].cluster_id, Some(i));
1687 assert_eq!(result.conformers[i].seed, cluster.representative_seed);
1688 }
1689 assert_eq!(total_members, result.generated);
1690
1691 for i in 1..result.conformers.len() {
1692 assert!(
1693 result.conformers[i - 1].energy_kcal_mol <= result.conformers[i].energy_kcal_mol
1694 );
1695 }
1696 }
1697
1698 #[test]
1699 fn test_search_conformers_with_uff_large_rmsd_threshold_collapses_duplicates() {
1700 let result = search_conformers_with_uff("CCCC", 8, 123, 10.0).unwrap();
1701 assert_eq!(result.unique, 1);
1702 assert_eq!(result.clusters.len(), 1);
1703 assert_eq!(result.clusters[0].size, result.generated);
1704 }
1705
1706 #[test]
1707 fn test_compute_md_trajectory_velocity_verlet_runs() {
1708 let conf = embed("CC", 42);
1709 assert!(conf.error.is_none());
1710
1711 let trj = compute_md_trajectory("CC", &conf.coords, 10, 0.25, 7).unwrap();
1712 assert_eq!(trj.frames.len(), 11);
1713 assert!(trj
1714 .frames
1715 .iter()
1716 .all(|f| f.coords.iter().all(|v| v.is_finite())));
1717 }
1718
1719 #[test]
1720 fn test_compute_md_trajectory_nvt_runs() {
1721 let conf = embed("CCO", 42);
1722 assert!(conf.error.is_none());
1723
1724 let trj =
1725 compute_md_trajectory_nvt("CCO", &conf.coords, 12, 0.25, 17, 300.0, 10.0).unwrap();
1726 assert_eq!(trj.frames.len(), 13);
1727 assert!(trj.frames.iter().all(|f| f.temperature_k.is_finite()));
1728 }
1729
1730 #[test]
1731 fn test_compute_simplified_neb_path_runs() {
1732 let c1 = embed("CC", 42);
1733 let c2 = embed("CC", 43);
1734 assert!(c1.error.is_none());
1735 assert!(c2.error.is_none());
1736
1737 let path =
1738 compute_simplified_neb_path("CC", &c1.coords, &c2.coords, 6, 20, 0.01, 1e-5).unwrap();
1739 assert_eq!(path.images.len(), 6);
1740 assert!(path
1741 .images
1742 .iter()
1743 .all(|img| img.potential_energy_kcal_mol.is_finite()));
1744 }
1745}