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