Skip to main content

sci_form/
lib.rs

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