Skip to main content

sci_form/
lib.rs

1//! **sci-form** — computational chemistry library for conformer generation, quantum methods,
2//! spectroscopy, molecular properties, and materials science.
3//!
4//! # Architecture
5//!
6//! The crate is organized in layers from molecular construction up to derived properties:
7//!
8//! - **Parsing & topology** — [`smiles`], [`graph`], [`smarts`], [`smirks`], [`rings`], [`topology`]
9//! - **Geometry** — [`distgeom`], [`etkdg`], [`conformer`], [`alignment`], [`forcefield`], [`optimization`]
10//! - **Charges & surface** — [`charges`], [`charges_eeq`], [`dipole`], [`surface`], [`esp`], [`population`]
11//! - **Solvation** — [`solvation`], [`solvation_alpb`]
12//! - **Electronic structure** — [`scf`], [`eht`], [`pm3`], [`xtb`], [`hf`], [`dos`]
13//! - **Spectroscopy** — [`spectroscopy`], [`ir`], [`nmr`]
14//! - **Neural potentials** — [`ani`]
15//! - **ML & descriptors** — [`ml`], [`reactivity`]
16//! - **Materials** — [`materials`], [`periodic`]
17//! - **Transport & infra** — [`transport`], [`gpu`], [`mesh`]
18//!
19//! # Coordinate convention
20//!
21//! All coordinate arrays are **flat** `[x0, y0, z0, x1, y1, z1, ...]` in ångströms.
22//! Element arrays are atomic numbers (`u8`): 1 = H, 6 = C, 7 = N, 8 = O, etc.
23//!
24//! # Targets
25//!
26//! The public surface is mirrored across four targets: Rust (this crate), Python (`crates/python/`),
27//! WASM/TypeScript (`crates/wasm/`), and CLI (`crates/cli/`). Changes to public types or functions
28//! must be propagated to all targets.
29
30// Scientific/numerical code patterns that are idiomatic in this domain
31#![allow(clippy::too_many_arguments)]
32#![allow(clippy::needless_range_loop)]
33
34pub mod alignment;
35pub mod ani;
36pub mod canonical_smiles;
37pub mod charges;
38pub mod charges_eeq;
39pub mod clustering;
40pub mod conformer;
41pub mod dipole;
42pub mod dispersion;
43pub mod distgeom;
44pub mod dos;
45pub mod dynamics;
46pub mod eht;
47pub mod esp;
48pub mod etkdg;
49pub mod experimental_status;
50#[doc(hidden)]
51pub mod fixture_io;
52pub mod forcefield;
53pub mod gpu;
54pub mod graph;
55pub mod hf;
56pub mod ir;
57pub mod materials;
58pub mod mesh;
59pub mod ml;
60pub mod nmr;
61pub mod optimization;
62pub mod periodic;
63pub mod pm3;
64pub mod population;
65pub mod potentials;
66pub mod reactivity;
67pub mod rings;
68pub mod scf;
69pub mod smarts;
70pub mod smiles;
71pub mod smirks;
72pub mod solvation;
73pub mod solvation_alpb;
74pub mod spectroscopy;
75pub mod stereo;
76pub mod surface;
77pub mod topology;
78pub mod transport;
79pub mod xtb;
80
81#[cfg(any(
82    feature = "alpha-cga",
83    feature = "alpha-gsm",
84    feature = "alpha-sdr",
85    feature = "alpha-edl",
86    feature = "alpha-periodic-linear",
87    feature = "alpha-kinetics",
88    feature = "alpha-render-bridge",
89    feature = "alpha-reaction-dynamics"
90))]
91pub mod alpha;
92
93#[cfg(any(
94    feature = "beta-kpm",
95    feature = "beta-mbh",
96    feature = "beta-cpm",
97    feature = "beta-randnla",
98    feature = "beta-riemannian"
99))]
100pub mod beta;
101
102// ─── ALPHA modules (feature-gated) ──────────────────────────────────────────
103
104#[cfg(feature = "alpha-dynamics-live")]
105pub mod dynamics_live;
106
107#[cfg(feature = "alpha-dft")]
108pub mod dft;
109
110#[cfg(feature = "alpha-mlff")]
111pub mod mlff {
112    //! Re-export MLFF types from the ml module.
113    pub use crate::ml::inference::*;
114    pub use crate::ml::mlff::*;
115    pub use crate::ml::symmetry_functions::*;
116}
117
118use serde::{Deserialize, Serialize};
119
120// ─── Public API Types ────────────────────────────────────────────────────────
121
122/// Result of a 3D conformer generation for a single molecule.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ConformerResult {
125    /// Input SMILES string.
126    pub smiles: String,
127    /// Number of atoms in the molecule.
128    pub num_atoms: usize,
129    /// Flat xyz coordinates: [x0, y0, z0, x1, y1, z1, ...].
130    /// Empty on failure.
131    pub coords: Vec<f64>,
132    /// Atom elements (atomic numbers) in the same order as coords.
133    pub elements: Vec<u8>,
134    /// Bond list as (start_atom, end_atom, order_string).
135    pub bonds: Vec<(usize, usize, String)>,
136    /// Error message if generation failed.
137    pub error: Option<String>,
138    /// Generation time in milliseconds.
139    pub time_ms: f64,
140}
141
142/// Configuration for conformer generation.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ConformerConfig {
145    /// RNG seed (same seed = reproducible output).
146    pub seed: u64,
147    /// Number of threads for batch processing (0 = auto-detect).
148    pub num_threads: usize,
149}
150
151/// One conformer entry in a ranked conformer-search ensemble.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ConformerEnsembleMember {
154    /// RNG seed used for this embedding attempt.
155    pub seed: u64,
156    /// Cluster identifier assigned after RMSD clustering.
157    pub cluster_id: Option<usize>,
158    /// Flat xyz coordinates: [x0, y0, z0, x1, y1, z1, ...].
159    pub coords: Vec<f64>,
160    /// UFF energy in kcal/mol.
161    pub energy_kcal_mol: f64,
162}
163
164/// One RMSD-based cluster summary in a conformer-search ensemble.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ConformerClusterSummary {
167    /// Cluster identifier.
168    pub cluster_id: usize,
169    /// Seed of the representative (lowest-energy) conformer in this cluster.
170    pub representative_seed: u64,
171    /// Number of conformers assigned to this cluster.
172    pub size: usize,
173    /// Seeds of all conformers assigned to this cluster.
174    pub member_seeds: Vec<u64>,
175}
176
177/// Result of a conformer-search workflow with UFF ranking and RMSD filtering.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct ConformerSearchResult {
180    /// Number of successful embedded conformers before filtering.
181    pub generated: usize,
182    /// Number of conformers retained after RMSD duplicate filtering.
183    pub unique: usize,
184    /// Number of rotatable bonds detected in the molecule.
185    pub rotatable_bonds: usize,
186    /// Ranked conformers (lowest UFF energy first).
187    pub conformers: Vec<ConformerEnsembleMember>,
188    /// RMSD-based clusters with representative/member mapping.
189    pub clusters: Vec<ConformerClusterSummary>,
190    /// Non-fatal notes and warnings.
191    pub notes: Vec<String>,
192}
193
194/// Capability status for one operation on a given element set.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct MethodCapability {
197    /// Whether the operation is available for the provided element set.
198    pub available: bool,
199    /// Confidence level for the operation.
200    pub confidence: eht::SupportLevel,
201    /// Elements that block operation support.
202    pub unsupported_elements: Vec<u8>,
203    /// Human-readable warnings.
204    pub warnings: Vec<String>,
205}
206
207/// Explicit computational method exposed by top-level planning APIs.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum ScientificMethod {
211    Embed,
212    Uff,
213    Eht,
214    Pm3,
215    Xtb,
216    Mmff94,
217    Ani,
218    Hf3c,
219}
220
221/// Property domain used when choosing a recommended computational method.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum PropertyRequest {
225    Geometry,
226    ForceFieldEnergy,
227    Orbitals,
228    Population,
229    OrbitalGrid,
230}
231
232/// Structured metadata for one method on a specific element set.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct MethodMetadata {
235    pub method: ScientificMethod,
236    pub available: bool,
237    pub confidence: eht::SupportLevel,
238    pub confidence_score: f64,
239    pub limitations: Vec<String>,
240    pub warnings: Vec<String>,
241}
242
243/// Recommended method plan for one requested property domain.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct PropertyMethodPlan {
246    pub property: PropertyRequest,
247    pub recommended: Option<ScientificMethod>,
248    pub fallback: Option<ScientificMethod>,
249    pub rationale: Vec<String>,
250    pub methods: Vec<MethodMetadata>,
251}
252
253/// Full method-planning summary for a molecule element set.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct SystemMethodPlan {
256    pub capabilities: SystemCapabilities,
257    pub geometry: PropertyMethodPlan,
258    pub force_field_energy: PropertyMethodPlan,
259    pub orbitals: PropertyMethodPlan,
260    pub population: PropertyMethodPlan,
261    pub orbital_grid: PropertyMethodPlan,
262}
263
264/// Capability summary for core operations on a given element set.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct SystemCapabilities {
267    pub embed: MethodCapability,
268    pub uff: MethodCapability,
269    pub eht: MethodCapability,
270    pub population: MethodCapability,
271    pub orbital_grid: MethodCapability,
272}
273
274/// Result of an electronic workflow with optional UFF fallback.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(tag = "mode", rename_all = "snake_case")]
277pub enum ElectronicWorkflowResult {
278    Eht {
279        result: eht::EhtResult,
280    },
281    UffFallback {
282        energy_kcal_mol: f64,
283        reason: String,
284        support: eht::EhtSupport,
285    },
286}
287
288/// Execution status for one method inside a multi-method comparison run.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "snake_case")]
291pub enum MethodComparisonStatus {
292    Success,
293    Unavailable,
294    Error,
295}
296
297/// Compact per-method payload for multi-method comparison.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299#[serde(tag = "kind", rename_all = "snake_case")]
300pub enum MethodComparisonPayload {
301    Eht {
302        homo_energy: f64,
303        lumo_energy: f64,
304        gap: f64,
305        support: eht::EhtSupport,
306    },
307    Uff {
308        energy_kcal_mol: f64,
309    },
310}
311
312/// One method row in the multi-method comparison workflow.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct MethodComparisonEntry {
315    pub method: ScientificMethod,
316    pub status: MethodComparisonStatus,
317    pub available: bool,
318    pub confidence: eht::SupportLevel,
319    pub confidence_score: f64,
320    pub warnings: Vec<String>,
321    pub limitations: Vec<String>,
322    pub payload: Option<MethodComparisonPayload>,
323    pub error: Option<String>,
324}
325
326/// Structured comparison result for multiple methods on the same geometry/system.
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct MethodComparisonResult {
329    pub plan: SystemMethodPlan,
330    pub comparisons: Vec<MethodComparisonEntry>,
331}
332
333/// Aromaticity analysis summary from graph-level bond annotations.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct AromaticityAnalysis {
336    pub aromatic_atoms: Vec<bool>,
337    pub aromatic_bonds: Vec<(usize, usize)>,
338}
339
340/// Graph-level stereocenter analysis.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct StereocenterAnalysis {
343    pub tagged_tetrahedral_centers: Vec<usize>,
344    pub inferred_tetrahedral_centers: Vec<usize>,
345}
346
347/// Combined structural graph feature analysis for UI/API consumers.
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct GraphFeatureAnalysis {
350    pub aromaticity: AromaticityAnalysis,
351    pub stereocenters: StereocenterAnalysis,
352}
353
354/// Configuration for the high-level GIAO NMR route.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct GiaoNmrConfig {
357    /// Total molecular charge.
358    pub charge: i32,
359    /// Spin multiplicity. The current public SCF route supports singlet closed-shell systems only.
360    pub multiplicity: u32,
361    /// Maximum number of SCF iterations.
362    pub max_scf_iterations: usize,
363    /// Enable rayon-parallel ERI evaluation when the feature is available.
364    pub use_parallel_eri: bool,
365    /// Allow the internal hydrogen-like fallback basis for unsupported elements.
366    pub allow_basis_fallback: bool,
367}
368
369/// One isotope-specific shielding entry returned by the high-level GIAO API.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct GiaoNmrEntry {
372    pub atom_index: usize,
373    pub element: u8,
374    pub tensor: [[f64; 3]; 3],
375    pub isotropic: f64,
376    pub anisotropy: f64,
377    pub chemical_shift: f64,
378}
379
380/// Result of a high-level SCF-backed GIAO NMR calculation.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct GiaoNmrResult {
383    pub nucleus: String,
384    pub target_atomic_number: u8,
385    pub method: String,
386    pub basis_set: String,
387    pub charge: i32,
388    pub multiplicity: u32,
389    pub scf_converged: bool,
390    pub scf_iterations: usize,
391    pub total_energy_hartree: f64,
392    pub n_basis: usize,
393    pub n_target_atoms: usize,
394    pub chemical_shifts: Vec<f64>,
395    pub shieldings: Vec<GiaoNmrEntry>,
396    pub fallback_elements: Vec<u8>,
397    pub notes: Vec<String>,
398}
399
400impl Default for ConformerConfig {
401    fn default() -> Self {
402        Self {
403            seed: 42,
404            num_threads: 0,
405        }
406    }
407}
408
409impl Default for GiaoNmrConfig {
410    fn default() -> Self {
411        Self {
412            charge: 0,
413            multiplicity: 1,
414            max_scf_iterations: 100,
415            use_parallel_eri: false,
416            allow_basis_fallback: false,
417        }
418    }
419}
420
421// ─── Public API Functions ────────────────────────────────────────────────────
422
423/// Library version string.
424pub fn version() -> String {
425    format!("sci-form {}", env!("CARGO_PKG_VERSION"))
426}
427
428/// Report EHT capability metadata for a given element list.
429pub fn get_eht_support(elements: &[u8]) -> eht::EhtSupport {
430    eht::analyze_eht_support(elements)
431}
432
433fn is_uff_element_supported(z: u8) -> bool {
434    matches!(
435        z,
436        1 | 5
437            | 6
438            | 7
439            | 8
440            | 9
441            | 14
442            | 15
443            | 16
444            | 17
445            | 22
446            | 23
447            | 24
448            | 25
449            | 26
450            | 27
451            | 28
452            | 29
453            | 30
454            | 32
455            | 33
456            | 34
457            | 35
458            | 42
459            | 46
460            | 47
461            | 50
462            | 51
463            | 52
464            | 53
465            | 78
466            | 79
467    )
468}
469
470fn unique_sorted_unsupported(elements: &[u8], pred: impl Fn(u8) -> bool) -> Vec<u8> {
471    let mut out: Vec<u8> = elements.iter().copied().filter(|&z| !pred(z)).collect();
472    out.sort_unstable();
473    out.dedup();
474    out
475}
476
477/// Report operation capability metadata for a given element list.
478pub fn get_system_capabilities(elements: &[u8]) -> SystemCapabilities {
479    let eht_support = get_eht_support(elements);
480    let uff_unsupported = unique_sorted_unsupported(elements, is_uff_element_supported);
481
482    let embed = MethodCapability {
483        available: !elements.is_empty(),
484        confidence: eht::SupportLevel::Experimental,
485        unsupported_elements: Vec::new(),
486        warnings: vec![
487            "Embed capability is inferred from element presence only; final success still depends on full molecular graph and geometry constraints.".to_string(),
488        ],
489    };
490
491    let uff = if uff_unsupported.is_empty() {
492        MethodCapability {
493            available: true,
494            confidence: eht::SupportLevel::Supported,
495            unsupported_elements: Vec::new(),
496            warnings: Vec::new(),
497        }
498    } else {
499        MethodCapability {
500            available: false,
501            confidence: eht::SupportLevel::Unsupported,
502            unsupported_elements: uff_unsupported.clone(),
503            warnings: vec![format!(
504                "UFF atom typing is unavailable for elements {:?}.",
505                uff_unsupported
506            )],
507        }
508    };
509
510    let eht = MethodCapability {
511        available: eht_support.unsupported_elements.is_empty(),
512        confidence: eht_support.level,
513        unsupported_elements: eht_support.unsupported_elements.clone(),
514        warnings: eht_support.warnings.clone(),
515    };
516
517    let population = MethodCapability {
518        available: eht.available,
519        confidence: eht.confidence,
520        unsupported_elements: eht.unsupported_elements.clone(),
521        warnings: eht.warnings.clone(),
522    };
523
524    let orbital_grid = MethodCapability {
525        available: eht.available,
526        confidence: eht.confidence,
527        unsupported_elements: eht.unsupported_elements.clone(),
528        warnings: eht.warnings.clone(),
529    };
530
531    SystemCapabilities {
532        embed,
533        uff,
534        eht,
535        population,
536        orbital_grid,
537    }
538}
539
540fn confidence_score_for_method(method: ScientificMethod, capability: &MethodCapability) -> f64 {
541    if !capability.available {
542        return 0.0;
543    }
544
545    match method {
546        ScientificMethod::Embed => 0.8,
547        ScientificMethod::Uff | ScientificMethod::Mmff94 => match capability.confidence {
548            eht::SupportLevel::Supported => 0.95,
549            eht::SupportLevel::Experimental => 0.75,
550            eht::SupportLevel::Unsupported => 0.0,
551        },
552        ScientificMethod::Eht | ScientificMethod::Pm3 | ScientificMethod::Xtb => {
553            match capability.confidence {
554                eht::SupportLevel::Supported => 0.95,
555                eht::SupportLevel::Experimental => 0.6,
556                eht::SupportLevel::Unsupported => 0.0,
557            }
558        }
559        ScientificMethod::Ani => match capability.confidence {
560            eht::SupportLevel::Supported => 0.90,
561            eht::SupportLevel::Experimental => 0.7,
562            eht::SupportLevel::Unsupported => 0.0,
563        },
564        ScientificMethod::Hf3c => match capability.confidence {
565            eht::SupportLevel::Supported => 0.85,
566            eht::SupportLevel::Experimental => 0.65,
567            eht::SupportLevel::Unsupported => 0.0,
568        },
569    }
570}
571
572fn build_method_metadata(
573    method: ScientificMethod,
574    capability: &MethodCapability,
575    extra_limitations: &[&str],
576) -> MethodMetadata {
577    let mut limitations: Vec<String> = extra_limitations.iter().map(|s| s.to_string()).collect();
578
579    if !capability.unsupported_elements.is_empty() {
580        limitations.push(format!(
581            "Unsupported elements for this method: {:?}.",
582            capability.unsupported_elements
583        ));
584    }
585
586    if matches!(method, ScientificMethod::Eht)
587        && matches!(capability.confidence, eht::SupportLevel::Experimental)
588    {
589        limitations.push(
590            "Transition-metal EHT parameters remain provisional and should be treated as experimental."
591                .to_string(),
592        );
593    }
594
595    MethodMetadata {
596        method,
597        available: capability.available,
598        confidence: capability.confidence,
599        confidence_score: confidence_score_for_method(method, capability),
600        limitations,
601        warnings: capability.warnings.clone(),
602    }
603}
604
605fn build_property_plan(
606    property: PropertyRequest,
607    recommended: Option<ScientificMethod>,
608    fallback: Option<ScientificMethod>,
609    rationale: Vec<String>,
610    methods: Vec<MethodMetadata>,
611) -> PropertyMethodPlan {
612    PropertyMethodPlan {
613        property,
614        recommended,
615        fallback,
616        rationale,
617        methods,
618    }
619}
620
621/// Build a structured method plan with recommendations, fallback paths, and confidence scores.
622pub fn get_system_method_plan(elements: &[u8]) -> SystemMethodPlan {
623    let capabilities = get_system_capabilities(elements);
624
625    let geometry_method = build_method_metadata(
626        ScientificMethod::Embed,
627        &capabilities.embed,
628        &["Geometry generation still depends on graph topology, stereochemistry, and embedding constraints."],
629    );
630    let geometry = build_property_plan(
631        PropertyRequest::Geometry,
632        geometry_method.available.then_some(ScientificMethod::Embed),
633        None,
634        vec!["Embedding is the top-level geometry generation path in sci-form.".to_string()],
635        vec![geometry_method],
636    );
637
638    let uff_method = build_method_metadata(
639        ScientificMethod::Uff,
640        &capabilities.uff,
641        &["This recommendation applies to force-field energy evaluation, not molecular orbital analysis."],
642    );
643    let force_field_energy = build_property_plan(
644        PropertyRequest::ForceFieldEnergy,
645        uff_method.available.then_some(ScientificMethod::Uff),
646        None,
647        vec![
648            "UFF is the top-level force-field energy path when atom typing is available."
649                .to_string(),
650        ],
651        vec![uff_method],
652    );
653
654    let eht_method = build_method_metadata(
655        ScientificMethod::Eht,
656        &capabilities.eht,
657        &["EHT is the only current top-level orbital method in sci-form."],
658    );
659    let orbitals = build_property_plan(
660        PropertyRequest::Orbitals,
661        eht_method.available.then_some(ScientificMethod::Eht),
662        None,
663        vec!["Orbital energies and MO coefficients are produced by the EHT workflow.".to_string()],
664        vec![eht_method.clone()],
665    );
666
667    let population_method = build_method_metadata(
668        ScientificMethod::Eht,
669        &capabilities.population,
670        &["Population analysis is derived from the EHT density and overlap matrices."],
671    );
672    let population = build_property_plan(
673        PropertyRequest::Population,
674        population_method.available.then_some(ScientificMethod::Eht),
675        None,
676        vec!["Population analysis currently requires a successful EHT calculation.".to_string()],
677        vec![population_method],
678    );
679
680    let orbital_grid_method = build_method_metadata(
681        ScientificMethod::Eht,
682        &capabilities.orbital_grid,
683        &["Orbital-grid rendering currently depends on EHT molecular-orbital coefficients."],
684    );
685    let orbital_grid = build_property_plan(
686        PropertyRequest::OrbitalGrid,
687        orbital_grid_method
688            .available
689            .then_some(ScientificMethod::Eht),
690        None,
691        vec![
692            "Orbital-grid generation currently requires a successful EHT calculation.".to_string(),
693        ],
694        vec![orbital_grid_method],
695    );
696
697    SystemMethodPlan {
698        capabilities,
699        geometry,
700        force_field_energy,
701        orbitals,
702        population,
703        orbital_grid,
704    }
705}
706
707/// Generate a 3D conformer from a SMILES string.
708pub fn embed(smiles: &str, seed: u64) -> ConformerResult {
709    #[cfg(not(target_arch = "wasm32"))]
710    let start = std::time::Instant::now();
711
712    let mol = match graph::Molecule::from_smiles(smiles) {
713        Ok(m) => m,
714        Err(e) => {
715            return ConformerResult {
716                smiles: smiles.to_string(),
717                num_atoms: 0,
718                coords: vec![],
719                elements: vec![],
720                bonds: vec![],
721                error: Some(e),
722                #[cfg(not(target_arch = "wasm32"))]
723                time_ms: start.elapsed().as_secs_f64() * 1000.0,
724                #[cfg(target_arch = "wasm32")]
725                time_ms: 0.0,
726            };
727        }
728    };
729
730    let n = mol.graph.node_count();
731    let elements: Vec<u8> = (0..n)
732        .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
733        .collect();
734    let bonds: Vec<(usize, usize, String)> = mol
735        .graph
736        .edge_indices()
737        .map(|e| {
738            let (a, b) = mol.graph.edge_endpoints(e).unwrap();
739            let order = match mol.graph[e].order {
740                graph::BondOrder::Single => "SINGLE",
741                graph::BondOrder::Double => "DOUBLE",
742                graph::BondOrder::Triple => "TRIPLE",
743                graph::BondOrder::Aromatic => "AROMATIC",
744                graph::BondOrder::Unknown => "UNKNOWN",
745            };
746            (a.index(), b.index(), order.to_string())
747        })
748        .collect();
749
750    // Try multiple seeds for difficult molecules — if the first seed fails,
751    // retry with different seeds before giving up.
752    let max_seed_retries = 3u64;
753    let mut last_err = String::new();
754    for retry in 0..max_seed_retries {
755        let current_seed = seed.wrapping_add(retry.wrapping_mul(997));
756        match conformer::generate_3d_conformer(&mol, current_seed) {
757            Ok(coords) => {
758                let mut flat = Vec::with_capacity(n * 3);
759                for i in 0..n {
760                    flat.push(coords[(i, 0)] as f64);
761                    flat.push(coords[(i, 1)] as f64);
762                    flat.push(coords[(i, 2)] as f64);
763                }
764                return ConformerResult {
765                    smiles: smiles.to_string(),
766                    num_atoms: n,
767                    coords: flat,
768                    elements,
769                    bonds,
770                    error: None,
771                    #[cfg(not(target_arch = "wasm32"))]
772                    time_ms: start.elapsed().as_secs_f64() * 1000.0,
773                    #[cfg(target_arch = "wasm32")]
774                    time_ms: 0.0,
775                };
776            }
777            Err(e) => {
778                last_err = e;
779            }
780        }
781    }
782    ConformerResult {
783        smiles: smiles.to_string(),
784        num_atoms: n,
785        coords: vec![],
786        elements,
787        bonds,
788        error: Some(last_err),
789        #[cfg(not(target_arch = "wasm32"))]
790        time_ms: start.elapsed().as_secs_f64() * 1000.0,
791        #[cfg(target_arch = "wasm32")]
792        time_ms: 0.0,
793    }
794}
795
796/// Batch-embed multiple SMILES in parallel.
797///
798/// Uses rayon thread pool for parallel processing.
799/// `config.num_threads = 0` means auto-detect CPU count.
800#[cfg(feature = "parallel")]
801pub fn embed_batch(smiles_list: &[&str], config: &ConformerConfig) -> Vec<ConformerResult> {
802    use rayon::prelude::*;
803
804    if config.num_threads > 0 {
805        let pool = rayon::ThreadPoolBuilder::new()
806            .num_threads(config.num_threads)
807            .build()
808            .unwrap();
809        pool.install(|| {
810            smiles_list
811                .par_iter()
812                .map(|smi| embed(smi, config.seed))
813                .collect()
814        })
815    } else {
816        smiles_list
817            .par_iter()
818            .map(|smi| embed(smi, config.seed))
819            .collect()
820    }
821}
822
823/// Batch-embed multiple SMILES sequentially (no rayon dependency).
824#[cfg(not(feature = "parallel"))]
825pub fn embed_batch(smiles_list: &[&str], config: &ConformerConfig) -> Vec<ConformerResult> {
826    smiles_list
827        .iter()
828        .map(|smi| embed(smi, config.seed))
829        .collect()
830}
831
832/// Generate multiple conformers for a SMILES and filter by Butina RMSD clustering.
833///
834/// Generates `n_conformers` embeddings with different seeds, then clusters the
835/// successful ones by RMSD and returns only the cluster centroids (diverse set).
836///
837/// # Arguments
838/// - `smiles`: SMILES string
839/// - `n_conformers`: number of embedding attempts (different seeds)
840/// - `rmsd_cutoff`: RMSD threshold for clustering (Å), typically 1.0
841/// - `base_seed`: base seed for reproducibility (seeds = base_seed..base_seed+n_conformers)
842pub fn embed_diverse(
843    smiles: &str,
844    n_conformers: usize,
845    rmsd_cutoff: f64,
846    base_seed: u64,
847) -> Vec<ConformerResult> {
848    let all_results: Vec<ConformerResult> = (0..n_conformers as u64)
849        .map(|i| embed(smiles, base_seed.wrapping_add(i)))
850        .collect();
851
852    let successful: Vec<(usize, &ConformerResult)> = all_results
853        .iter()
854        .enumerate()
855        .filter(|(_, r)| r.error.is_none() && !r.coords.is_empty())
856        .collect();
857
858    if successful.len() <= 1 {
859        return all_results
860            .into_iter()
861            .filter(|r| r.error.is_none())
862            .collect();
863    }
864
865    let coords_vecs: Vec<Vec<f64>> = successful.iter().map(|(_, r)| r.coords.clone()).collect();
866    let cluster_result = clustering::butina_cluster(&coords_vecs, rmsd_cutoff);
867
868    cluster_result
869        .centroid_indices
870        .iter()
871        .map(|&ci| {
872            let (orig_idx, _) = successful[ci];
873            all_results[orig_idx].clone()
874        })
875        .collect()
876}
877
878/// Parse a SMILES string and return molecular structure (no 3D generation).
879pub fn parse(smiles: &str) -> Result<graph::Molecule, String> {
880    graph::Molecule::from_smiles(smiles)
881}
882
883/// Compute Gasteiger-Marsili partial charges from a SMILES string.
884///
885/// Parses the molecule, extracts bonds and elements, then runs 6 iterations
886/// of electronegativity equalization.
887pub fn compute_charges(smiles: &str) -> Result<charges::gasteiger::ChargeResult, String> {
888    let mol = graph::Molecule::from_smiles(smiles)?;
889    let n = mol.graph.node_count();
890    let elements: Vec<u8> = (0..n)
891        .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
892        .collect();
893    let formal_charges: Vec<i8> = (0..n)
894        .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].formal_charge)
895        .collect();
896    let bonds: Vec<(usize, usize)> = mol
897        .graph
898        .edge_indices()
899        .map(|e| {
900            let (a, b) = mol.graph.edge_endpoints(e).unwrap();
901            (a.index(), b.index())
902        })
903        .collect();
904    charges::gasteiger::gasteiger_marsili_charges(&elements, &bonds, &formal_charges, 6)
905}
906
907/// Compute solvent-accessible surface area from SMILES + 3D coordinates.
908///
909/// The `coords_flat` parameter is a flat [x0,y0,z0, x1,y1,z1,...] array.
910pub fn compute_sasa(
911    elements: &[u8],
912    coords_flat: &[f64],
913    probe_radius: Option<f64>,
914) -> Result<surface::sasa::SasaResult, String> {
915    if coords_flat.len() != elements.len() * 3 {
916        return Err(format!(
917            "coords length {} != 3 * elements {}",
918            coords_flat.len(),
919            elements.len()
920        ));
921    }
922    let positions: Vec<[f64; 3]> = coords_flat
923        .chunks_exact(3)
924        .map(|c| [c[0], c[1], c[2]])
925        .collect();
926
927    #[cfg(feature = "parallel")]
928    {
929        Ok(surface::sasa::compute_sasa_parallel(
930            elements,
931            &positions,
932            probe_radius,
933            None,
934        ))
935    }
936
937    #[cfg(not(feature = "parallel"))]
938    {
939        Ok(surface::sasa::compute_sasa(
940            elements,
941            &positions,
942            probe_radius,
943            None,
944        ))
945    }
946}
947
948/// Compute Mulliken & Löwdin population analysis from atomic elements and positions.
949pub fn compute_population(
950    elements: &[u8],
951    positions: &[[f64; 3]],
952) -> Result<population::PopulationResult, String> {
953    let eht_result = eht::solve_eht(elements, positions, None)?;
954    Ok(population::compute_population(
955        elements,
956        positions,
957        &eht_result.coefficients,
958        eht_result.n_electrons,
959    ))
960}
961
962/// Compute molecular dipole moment (Debye) from atomic elements and positions.
963pub fn compute_dipole(
964    elements: &[u8],
965    positions: &[[f64; 3]],
966) -> Result<dipole::DipoleResult, String> {
967    let eht_result = eht::solve_eht(elements, positions, None)?;
968    Ok(dipole::compute_dipole_from_eht(
969        elements,
970        positions,
971        &eht_result.coefficients,
972        eht_result.n_electrons,
973    ))
974}
975
976/// Compute atom-resolved HOMO/LUMO frontier descriptors from EHT.
977pub fn compute_frontier_descriptors(
978    elements: &[u8],
979    positions: &[[f64; 3]],
980) -> Result<reactivity::FrontierDescriptors, String> {
981    let eht_result = eht::solve_eht(elements, positions, None)?;
982    Ok(reactivity::compute_frontier_descriptors(
983        elements,
984        positions,
985        &eht_result,
986    ))
987}
988
989/// Compute Fukui-function workflows and condensed per-atom descriptors from EHT.
990pub fn compute_fukui_descriptors(
991    elements: &[u8],
992    positions: &[[f64; 3]],
993) -> Result<reactivity::FukuiDescriptors, String> {
994    let eht_result = eht::solve_eht(elements, positions, None)?;
995    Ok(reactivity::compute_fukui_descriptors(
996        elements,
997        positions,
998        &eht_result,
999    ))
1000}
1001
1002/// Build empirical reactivity rankings using condensed Fukui descriptors and Mulliken charges.
1003pub fn compute_reactivity_ranking(
1004    elements: &[u8],
1005    positions: &[[f64; 3]],
1006) -> Result<reactivity::ReactivityRanking, String> {
1007    let eht_result = eht::solve_eht(elements, positions, None)?;
1008    let fukui = reactivity::compute_fukui_descriptors(elements, positions, &eht_result);
1009    let pop = population::compute_population(
1010        elements,
1011        positions,
1012        &eht_result.coefficients,
1013        eht_result.n_electrons,
1014    );
1015    Ok(reactivity::rank_reactivity_sites(
1016        &fukui,
1017        &pop.mulliken_charges,
1018    ))
1019}
1020
1021/// Build an exploratory UV-Vis-like spectrum from low-cost EHT transitions.
1022pub fn compute_uv_vis_spectrum(
1023    elements: &[u8],
1024    positions: &[[f64; 3]],
1025    sigma: f64,
1026    e_min: f64,
1027    e_max: f64,
1028    n_points: usize,
1029) -> Result<reactivity::UvVisSpectrum, String> {
1030    let eht_result = eht::solve_eht(elements, positions, None)?;
1031    Ok(reactivity::compute_uv_vis_like_spectrum(
1032        &eht_result,
1033        sigma,
1034        e_min,
1035        e_max,
1036        n_points,
1037    ))
1038}
1039
1040/// Compute Wiberg-like and Mayer-like bond orders from EHT.
1041pub fn compute_bond_orders(
1042    elements: &[u8],
1043    positions: &[[f64; 3]],
1044) -> Result<population::BondOrderResult, String> {
1045    let eht_result = eht::solve_eht(elements, positions, None)?;
1046    Ok(population::compute_bond_orders(
1047        elements,
1048        positions,
1049        &eht_result.coefficients,
1050        eht_result.n_electrons,
1051    ))
1052}
1053
1054/// Compute structured topology analysis for transition-metal coordination environments.
1055pub fn compute_topology(
1056    elements: &[u8],
1057    positions: &[[f64; 3]],
1058) -> topology::TopologyAnalysisResult {
1059    topology::analyze_topology(elements, positions)
1060}
1061
1062/// Analyze aromaticity and graph-level stereocenters from a SMILES string.
1063pub fn analyze_graph_features(smiles: &str) -> Result<GraphFeatureAnalysis, String> {
1064    use petgraph::visit::EdgeRef;
1065
1066    let mol = parse(smiles)?;
1067    let n_atoms = mol.graph.node_count();
1068    let mut aromatic_atoms = vec![false; n_atoms];
1069    let mut aromatic_bonds = Vec::new();
1070
1071    for edge in mol.graph.edge_references() {
1072        if matches!(edge.weight().order, graph::BondOrder::Aromatic) {
1073            let i = edge.source().index();
1074            let j = edge.target().index();
1075            aromatic_atoms[i] = true;
1076            aromatic_atoms[j] = true;
1077            aromatic_bonds.push((i, j));
1078        }
1079    }
1080
1081    let mut tagged_tetrahedral_centers = Vec::new();
1082    let mut inferred_tetrahedral_centers = Vec::new();
1083    for i in 0..n_atoms {
1084        let idx = petgraph::graph::NodeIndex::new(i);
1085        let atom = &mol.graph[idx];
1086        if matches!(
1087            atom.chiral_tag,
1088            graph::ChiralType::TetrahedralCW | graph::ChiralType::TetrahedralCCW
1089        ) {
1090            tagged_tetrahedral_centers.push(i);
1091        }
1092
1093        let neighbors: Vec<_> = mol.graph.neighbors(idx).collect();
1094        if neighbors.len() == 4 && matches!(atom.hybridization, graph::Hybridization::SP3) {
1095            let mut signature: Vec<u8> = neighbors.iter().map(|n| mol.graph[*n].element).collect();
1096            signature.sort_unstable();
1097            signature.dedup();
1098            if signature.len() >= 3 {
1099                inferred_tetrahedral_centers.push(i);
1100            }
1101        }
1102    }
1103
1104    Ok(GraphFeatureAnalysis {
1105        aromaticity: AromaticityAnalysis {
1106            aromatic_atoms,
1107            aromatic_bonds,
1108        },
1109        stereocenters: StereocenterAnalysis {
1110            tagged_tetrahedral_centers,
1111            inferred_tetrahedral_centers,
1112        },
1113    })
1114}
1115
1116/// Compute electronic properties with automatic fallback to UFF energy.
1117///
1118/// If EHT is unsupported for the element set, this function routes directly to UFF.
1119/// If EHT is experimental and `allow_experimental_eht` is false, it also routes to UFF.
1120pub fn compute_eht_or_uff_fallback(
1121    smiles: &str,
1122    elements: &[u8],
1123    positions: &[[f64; 3]],
1124    allow_experimental_eht: bool,
1125) -> Result<ElectronicWorkflowResult, String> {
1126    let support = get_eht_support(elements);
1127    let should_fallback = match support.level {
1128        eht::SupportLevel::Unsupported => true,
1129        eht::SupportLevel::Experimental => !allow_experimental_eht,
1130        eht::SupportLevel::Supported => false,
1131    };
1132
1133    if should_fallback {
1134        let coords_flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1135        let energy = compute_uff_energy(smiles, &coords_flat).map_err(|e| {
1136            format!(
1137                "EHT is not appropriate for this system (support: {:?}) and UFF fallback failed: {}",
1138                support.level, e
1139            )
1140        })?;
1141        return Ok(ElectronicWorkflowResult::UffFallback {
1142            energy_kcal_mol: energy,
1143            reason: if matches!(support.level, eht::SupportLevel::Unsupported) {
1144                "EHT unsupported for one or more elements; routed to UFF-only workflow.".to_string()
1145            } else {
1146                "EHT confidence is experimental and experimental mode is disabled; routed to UFF-only workflow."
1147                    .to_string()
1148            },
1149            support,
1150        });
1151    }
1152
1153    let result = eht::solve_eht(elements, positions, None)?;
1154    Ok(ElectronicWorkflowResult::Eht { result })
1155}
1156
1157/// Compare multiple supported methods on the same geometry/system.
1158///
1159/// This workflow executes available methods independently and returns per-method
1160/// status, confidence metadata, warnings, limitations, and compact outputs.
1161pub fn compare_methods(
1162    smiles: &str,
1163    elements: &[u8],
1164    positions: &[[f64; 3]],
1165    allow_experimental_eht: bool,
1166) -> MethodComparisonResult {
1167    let plan = get_system_method_plan(elements);
1168    let mut comparisons = Vec::new();
1169
1170    let coords_flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1171
1172    {
1173        let meta = build_method_metadata(
1174            ScientificMethod::Uff,
1175            &plan.capabilities.uff,
1176            &["Comparison uses UFF force-field energy as the UFF observable."],
1177        );
1178        if !meta.available {
1179            comparisons.push(MethodComparisonEntry {
1180                method: ScientificMethod::Uff,
1181                status: MethodComparisonStatus::Unavailable,
1182                available: false,
1183                confidence: meta.confidence,
1184                confidence_score: meta.confidence_score,
1185                warnings: meta.warnings,
1186                limitations: meta.limitations,
1187                payload: None,
1188                error: Some("UFF is unavailable for this element set.".to_string()),
1189            });
1190        } else {
1191            match compute_uff_energy(smiles, &coords_flat) {
1192                Ok(energy) => comparisons.push(MethodComparisonEntry {
1193                    method: ScientificMethod::Uff,
1194                    status: MethodComparisonStatus::Success,
1195                    available: true,
1196                    confidence: meta.confidence,
1197                    confidence_score: meta.confidence_score,
1198                    warnings: meta.warnings,
1199                    limitations: meta.limitations,
1200                    payload: Some(MethodComparisonPayload::Uff {
1201                        energy_kcal_mol: energy,
1202                    }),
1203                    error: None,
1204                }),
1205                Err(err) => comparisons.push(MethodComparisonEntry {
1206                    method: ScientificMethod::Uff,
1207                    status: MethodComparisonStatus::Error,
1208                    available: true,
1209                    confidence: meta.confidence,
1210                    confidence_score: meta.confidence_score,
1211                    warnings: meta.warnings,
1212                    limitations: meta.limitations,
1213                    payload: None,
1214                    error: Some(err),
1215                }),
1216            }
1217        }
1218    }
1219
1220    {
1221        let meta = build_method_metadata(
1222            ScientificMethod::Eht,
1223            &plan.capabilities.eht,
1224            &["Comparison uses frontier orbital energies and gap as the EHT observable."],
1225        );
1226
1227        if !meta.available {
1228            comparisons.push(MethodComparisonEntry {
1229                method: ScientificMethod::Eht,
1230                status: MethodComparisonStatus::Unavailable,
1231                available: false,
1232                confidence: meta.confidence,
1233                confidence_score: meta.confidence_score,
1234                warnings: meta.warnings,
1235                limitations: meta.limitations,
1236                payload: None,
1237                error: Some("EHT is unavailable for this element set.".to_string()),
1238            });
1239        } else if matches!(meta.confidence, eht::SupportLevel::Experimental)
1240            && !allow_experimental_eht
1241        {
1242            comparisons.push(MethodComparisonEntry {
1243                method: ScientificMethod::Eht,
1244                status: MethodComparisonStatus::Unavailable,
1245                available: true,
1246                confidence: meta.confidence,
1247                confidence_score: meta.confidence_score,
1248                warnings: meta.warnings,
1249                limitations: meta.limitations,
1250                payload: None,
1251                error: Some(
1252                    "EHT confidence is experimental and allow_experimental_eht=false.".to_string(),
1253                ),
1254            });
1255        } else {
1256            match eht::solve_eht(elements, positions, None) {
1257                Ok(result) => comparisons.push(MethodComparisonEntry {
1258                    method: ScientificMethod::Eht,
1259                    status: MethodComparisonStatus::Success,
1260                    available: true,
1261                    confidence: meta.confidence,
1262                    confidence_score: meta.confidence_score,
1263                    warnings: meta.warnings,
1264                    limitations: meta.limitations,
1265                    payload: Some(MethodComparisonPayload::Eht {
1266                        homo_energy: result.homo_energy,
1267                        lumo_energy: result.lumo_energy,
1268                        gap: result.gap,
1269                        support: result.support,
1270                    }),
1271                    error: None,
1272                }),
1273                Err(err) => comparisons.push(MethodComparisonEntry {
1274                    method: ScientificMethod::Eht,
1275                    status: MethodComparisonStatus::Error,
1276                    available: true,
1277                    confidence: meta.confidence,
1278                    confidence_score: meta.confidence_score,
1279                    warnings: meta.warnings,
1280                    limitations: meta.limitations,
1281                    payload: None,
1282                    error: Some(err),
1283                }),
1284            }
1285        }
1286    }
1287
1288    MethodComparisonResult { plan, comparisons }
1289}
1290
1291/// Compute ESP grid from atomic elements, positions and Mulliken charges.
1292pub fn compute_esp(
1293    elements: &[u8],
1294    positions: &[[f64; 3]],
1295    spacing: f64,
1296    padding: f64,
1297) -> Result<esp::EspGrid, String> {
1298    let pop = compute_population(elements, positions)?;
1299    let (grid, _report) = gpu::esp_grid_gpu::compute_esp_grid_with_report(
1300        elements,
1301        positions,
1302        &pop.mulliken_charges,
1303        spacing,
1304        padding,
1305    );
1306    Ok(grid)
1307}
1308
1309/// Compute DOS/PDOS from EHT orbital energies.
1310pub fn compute_dos(
1311    elements: &[u8],
1312    positions: &[[f64; 3]],
1313    sigma: f64,
1314    e_min: f64,
1315    e_max: f64,
1316    n_points: usize,
1317) -> Result<dos::DosResult, String> {
1318    let eht_result = eht::solve_eht(elements, positions, None)?;
1319    let flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1320
1321    #[cfg(feature = "parallel")]
1322    {
1323        Ok(dos::compute_pdos_parallel(
1324            elements,
1325            &flat,
1326            &eht_result.energies,
1327            &eht_result.coefficients,
1328            eht_result.n_electrons,
1329            sigma,
1330            e_min,
1331            e_max,
1332            n_points,
1333        ))
1334    }
1335
1336    #[cfg(not(feature = "parallel"))]
1337    {
1338        Ok(dos::compute_pdos(
1339            elements,
1340            &flat,
1341            &eht_result.energies,
1342            &eht_result.coefficients,
1343            eht_result.n_electrons,
1344            sigma,
1345            e_min,
1346            e_max,
1347            n_points,
1348        ))
1349    }
1350}
1351
1352/// Compute RMSD between two coordinate sets after Kabsch alignment.
1353pub fn compute_rmsd(coords: &[f64], reference: &[f64]) -> f64 {
1354    alignment::compute_rmsd(coords, reference)
1355}
1356
1357/// Compute UFF force field energy for a molecule.
1358pub fn compute_uff_energy(smiles: &str, coords: &[f64]) -> Result<f64, String> {
1359    let mol = graph::Molecule::from_smiles(smiles)?;
1360    let n = mol.graph.node_count();
1361    if coords.len() != n * 3 {
1362        return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1363    }
1364    let ff = forcefield::builder::build_uff_force_field(&mol);
1365    let mut gradient = vec![0.0f64; n * 3];
1366    let energy = ff.compute_system_energy_and_gradients(coords, &mut gradient);
1367    Ok(energy)
1368}
1369
1370/// Compute MMFF94 force field energy for a molecule.
1371///
1372/// `smiles`: SMILES string for bond/topology information.
1373/// `coords`: flat xyz coordinates `[x0,y0,z0, x1,y1,z1, ...]`.
1374///
1375/// Returns total MMFF94 energy in kcal/mol.
1376pub fn compute_mmff94_energy(smiles: &str, coords: &[f64]) -> Result<f64, String> {
1377    let mol = graph::Molecule::from_smiles(smiles)?;
1378    let n = mol.graph.node_count();
1379    if coords.len() != n * 3 {
1380        return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
1381    }
1382    let elements: Vec<u8> = (0..n)
1383        .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
1384        .collect();
1385    let bonds: Vec<(usize, usize, u8)> = mol
1386        .graph
1387        .edge_indices()
1388        .map(|e| {
1389            let (a, b) = mol.graph.edge_endpoints(e).unwrap();
1390            let order = match mol.graph[e].order {
1391                graph::BondOrder::Single => 1u8,
1392                graph::BondOrder::Double => 2,
1393                graph::BondOrder::Triple => 3,
1394                graph::BondOrder::Aromatic => 2,
1395                graph::BondOrder::Unknown => 1,
1396            };
1397            (a.index(), b.index(), order)
1398        })
1399        .collect();
1400    let terms = forcefield::mmff94::Mmff94Builder::build(&elements, &bonds);
1401    let (energy, _grad) = forcefield::mmff94::Mmff94Builder::total_energy(&terms, coords);
1402    Ok(energy)
1403}
1404
1405/// Run a PM3 semi-empirical calculation.
1406///
1407/// `elements`: atomic numbers.
1408/// `positions`: Cartesian coordinates in Å, one `[x,y,z]` per atom.
1409///
1410/// Returns orbital energies, total energy, heat of formation, and Mulliken charges.
1411pub fn compute_pm3(elements: &[u8], positions: &[[f64; 3]]) -> Result<pm3::Pm3Result, String> {
1412    pm3::solve_pm3(elements, positions)
1413}
1414
1415/// Compute PM3 analytical energy gradients.
1416///
1417/// Returns dE/dR in eV/Å for each atom, computed from the converged
1418/// SCF density matrix using Hellmann-Feynman + Pulay corrections.
1419pub fn compute_pm3_gradient(
1420    elements: &[u8],
1421    positions: &[[f64; 3]],
1422) -> Result<pm3::Pm3GradientResult, String> {
1423    pm3::compute_pm3_gradient(elements, positions)
1424}
1425
1426/// Run an xTB tight-binding calculation.
1427///
1428/// `elements`: atomic numbers.
1429/// `positions`: Cartesian coordinates in Å, one `[x,y,z]` per atom.
1430///
1431/// Returns orbital energies, total energy, gap, and Mulliken charges.
1432/// Uses GFN2-xTB internally for reliable SCC convergence and accurate energetics.
1433pub fn compute_xtb(elements: &[u8], positions: &[[f64; 3]]) -> Result<xtb::XtbResult, String> {
1434    let gfn2 = xtb::gfn2::solve_gfn2(elements, positions)?;
1435    Ok(xtb::XtbResult {
1436        orbital_energies: gfn2.orbital_energies,
1437        electronic_energy: gfn2.electronic_energy,
1438        repulsive_energy: gfn2.repulsive_energy,
1439        total_energy: gfn2.total_energy,
1440        n_basis: gfn2.n_basis,
1441        n_electrons: gfn2.n_electrons,
1442        homo_energy: gfn2.homo_energy,
1443        lumo_energy: gfn2.lumo_energy,
1444        gap: gfn2.gap,
1445        mulliken_charges: gfn2.mulliken_charges,
1446        scc_iterations: gfn2.scc_iterations,
1447        converged: gfn2.converged,
1448    })
1449}
1450
1451/// Compute xTB analytical energy gradients.
1452///
1453/// Returns dE/dR in eV/Å for each atom, computed from the converged
1454/// SCC density using Hellmann-Feynman + Pulay + repulsive corrections.
1455pub fn compute_xtb_gradient(
1456    elements: &[u8],
1457    positions: &[[f64; 3]],
1458) -> Result<xtb::XtbGradientResult, String> {
1459    xtb::compute_xtb_gradient(elements, positions)
1460}
1461
1462// ─── Spectroscopy API (Track D) ─────────────────────────────────────────────
1463
1464/// Compute an sTDA UV-Vis spectrum with proper oscillator strengths.
1465///
1466/// Uses EHT/xTB molecular orbital transitions with transition dipole moment
1467/// evaluation and configurable broadening (Gaussian or Lorentzian).
1468pub fn compute_stda_uvvis(
1469    elements: &[u8],
1470    positions: &[[f64; 3]],
1471    sigma: f64,
1472    e_min: f64,
1473    e_max: f64,
1474    n_points: usize,
1475    broadening: reactivity::BroadeningType,
1476) -> Result<reactivity::StdaUvVisSpectrum, String> {
1477    reactivity::compute_stda_uvvis_spectrum(
1478        elements, positions, sigma, e_min, e_max, n_points, broadening,
1479    )
1480}
1481
1482/// Perform vibrational analysis via numerical Hessian.
1483///
1484/// Computes vibrational frequencies (cm⁻¹), normal modes, IR intensities,
1485/// and zero-point vibrational energy from a semiempirical Hessian.
1486///
1487/// `method`: "eht", "pm3", "xtb", or "uff"
1488pub fn compute_vibrational_analysis(
1489    elements: &[u8],
1490    positions: &[[f64; 3]],
1491    method: &str,
1492    step_size: Option<f64>,
1493) -> Result<ir::VibrationalAnalysis, String> {
1494    let hessian_method =
1495        match method {
1496            "eht" => ir::HessianMethod::Eht,
1497            "pm3" => ir::HessianMethod::Pm3,
1498            "xtb" => ir::HessianMethod::Xtb,
1499            "uff" => return Err(
1500                "UFF vibrational analysis requires SMILES; use compute_vibrational_analysis_uff"
1501                    .to_string(),
1502            ),
1503            _ => return Err(format!("Unknown method '{}', use eht/pm3/xtb/uff", method)),
1504        };
1505    ir::compute_vibrational_analysis(elements, positions, hessian_method, step_size)
1506}
1507
1508/// Perform vibrational analysis using UFF analytical Hessian.
1509///
1510/// Uses gradient-difference method (O(6N) gradient evaluations) instead of
1511/// the standard O(9N²) energy evaluations, leveraging UFF's analytical gradients.
1512pub fn compute_vibrational_analysis_uff(
1513    smiles: &str,
1514    elements: &[u8],
1515    positions: &[[f64; 3]],
1516    step_size: Option<f64>,
1517) -> Result<ir::VibrationalAnalysis, String> {
1518    ir::vibrations::compute_vibrational_analysis_uff(smiles, elements, positions, step_size)
1519}
1520
1521/// Generate a Lorentzian-broadened IR spectrum from vibrational analysis.
1522///
1523/// `gamma`: line width in cm⁻¹ (typically 10–30)
1524/// `wn_min`, `wn_max`: wavenumber range in cm⁻¹
1525/// `n_points`: grid resolution
1526pub fn compute_ir_spectrum(
1527    analysis: &ir::VibrationalAnalysis,
1528    gamma: f64,
1529    wn_min: f64,
1530    wn_max: f64,
1531    n_points: usize,
1532) -> ir::IrSpectrum {
1533    ir::compute_ir_spectrum(analysis, gamma, wn_min, wn_max, n_points)
1534}
1535
1536/// Predict representative NMR chemical shifts from SMILES.
1537pub fn predict_nmr_shifts(smiles: &str) -> Result<nmr::NmrShiftResult, String> {
1538    let mol = graph::Molecule::from_smiles(smiles)?;
1539    Ok(nmr::predict_chemical_shifts(&mol))
1540}
1541
1542/// Predict chemical shifts for a specific NMR nucleus from SMILES.
1543pub fn predict_nmr_shifts_for_nucleus(
1544    smiles: &str,
1545    nucleus: &str,
1546) -> Result<Vec<nmr::ChemicalShift>, String> {
1547    let mol = graph::Molecule::from_smiles(smiles)?;
1548    let nucleus = parse_nmr_nucleus(nucleus)?;
1549    Ok(nmr::predict_chemical_shifts_for_nucleus(&mol, nucleus))
1550}
1551
1552/// Predict J-coupling constants for a molecule.
1553///
1554/// If positions are provided, uses Karplus equation for 3D-dependent ³J values.
1555/// Otherwise uses topological estimates.
1556pub fn predict_nmr_couplings(
1557    smiles: &str,
1558    positions: &[[f64; 3]],
1559) -> Result<Vec<nmr::JCoupling>, String> {
1560    let mol = graph::Molecule::from_smiles(smiles)?;
1561    Ok(nmr::predict_j_couplings(&mol, positions))
1562}
1563
1564fn parse_nmr_nucleus(nucleus: &str) -> Result<nmr::NmrNucleus, String> {
1565    nmr::NmrNucleus::parse(nucleus)
1566}
1567
1568struct GiaoNmrPreflight {
1569    nucleus: nmr::NmrNucleus,
1570    system: scf::types::MolecularSystem,
1571    basis: scf::basis::BasisSet,
1572    fallback_elements: Vec<u8>,
1573}
1574
1575const EXPLICIT_GIAO_STO3G_ELEMENTS: &[u8] =
1576    &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 15, 16, 17, 35, 53];
1577
1578fn has_explicit_giao_sto3g_support(z: u8) -> bool {
1579    EXPLICIT_GIAO_STO3G_ELEMENTS.contains(&z)
1580}
1581
1582fn format_element_label(z: u8) -> String {
1583    nmr::NmrNucleus::default_for_element(z)
1584        .map(|nucleus| format!("{}({})", nucleus.element_symbol(), z))
1585        .unwrap_or_else(|| format!("Z{}", z))
1586}
1587
1588fn format_element_set(elements: &[u8]) -> String {
1589    let mut unique = elements.to_vec();
1590    unique.sort_unstable();
1591    unique.dedup();
1592    unique
1593        .into_iter()
1594        .map(format_element_label)
1595        .collect::<Vec<_>>()
1596        .join(", ")
1597}
1598
1599fn preflight_giao_nmr(
1600    elements: &[u8],
1601    positions: &[[f64; 3]],
1602    nucleus: &str,
1603    config: &GiaoNmrConfig,
1604) -> Result<GiaoNmrPreflight, String> {
1605    if elements.is_empty() {
1606        return Err("GIAO NMR requires at least one atom.".to_string());
1607    }
1608    if elements.len() != positions.len() {
1609        return Err(format!(
1610            "GIAO NMR requires one 3D coordinate per atom; got {} elements and {} positions.",
1611            elements.len(),
1612            positions.len()
1613        ));
1614    }
1615    if config.max_scf_iterations == 0 {
1616        return Err("GIAO NMR requires max_scf_iterations > 0.".to_string());
1617    }
1618    if config.multiplicity != 1 {
1619        return Err(format!(
1620            "The public GIAO NMR API currently supports singlet closed-shell systems only; got multiplicity {}.",
1621            config.multiplicity
1622        ));
1623    }
1624
1625    let parsed_nucleus = parse_nmr_nucleus(nucleus)?;
1626    if !elements
1627        .iter()
1628        .any(|&z| z == parsed_nucleus.atomic_number())
1629    {
1630        return Err(format!(
1631            "Requested nucleus {} is not present in the provided element list.",
1632            parsed_nucleus.canonical()
1633        ));
1634    }
1635
1636    let system = scf::types::MolecularSystem::from_angstrom(
1637        elements,
1638        positions,
1639        config.charge,
1640        config.multiplicity,
1641    );
1642    if !system.n_electrons().is_multiple_of(2) {
1643        return Err(format!(
1644            "The public GIAO NMR API currently supports even-electron closed-shell systems only; got {} electrons.",
1645            system.n_electrons()
1646        ));
1647    }
1648
1649    let fallback_elements: Vec<u8> = elements
1650        .iter()
1651        .copied()
1652        .filter(|&z| !has_explicit_giao_sto3g_support(z))
1653        .collect();
1654
1655    if !config.allow_basis_fallback && !fallback_elements.is_empty() {
1656        return Err(format!(
1657            "The public GIAO NMR SCF route only has explicit STO-3G basis data for {}. This system includes fallback-only elements: {}. Re-run with GiaoNmrConfig.allow_basis_fallback=true for a screening-only attempt.",
1658            format_element_set(EXPLICIT_GIAO_STO3G_ELEMENTS),
1659            format_element_set(&fallback_elements)
1660        ));
1661    }
1662
1663    let basis = scf::basis::BasisSet::sto3g(&system.atomic_numbers, &system.positions_bohr);
1664    let n_occupied = system.n_electrons() / 2;
1665    if n_occupied > basis.n_basis {
1666        return Err(format!(
1667            "GIAO NMR preflight failed for {}: STO-3G supplies {} basis functions for {} occupied orbitals. This usually means one or more elements only have the hydrogen-like fallback basis in the current SCF path.",
1668            parsed_nucleus.canonical(),
1669            basis.n_basis,
1670            n_occupied
1671        ));
1672    }
1673
1674    Ok(GiaoNmrPreflight {
1675        nucleus: parsed_nucleus,
1676        system,
1677        basis,
1678        fallback_elements,
1679    })
1680}
1681
1682/// Compute isotope-specific GIAO NMR shieldings using the public SCF route.
1683pub fn compute_giao_nmr(
1684    elements: &[u8],
1685    positions: &[[f64; 3]],
1686    nucleus: &str,
1687) -> Result<GiaoNmrResult, String> {
1688    compute_giao_nmr_configured(elements, positions, nucleus, &GiaoNmrConfig::default())
1689}
1690
1691/// Compute isotope-specific GIAO NMR shieldings using the public SCF route and explicit settings.
1692pub fn compute_giao_nmr_configured(
1693    elements: &[u8],
1694    positions: &[[f64; 3]],
1695    nucleus: &str,
1696    config: &GiaoNmrConfig,
1697) -> Result<GiaoNmrResult, String> {
1698    let request = preflight_giao_nmr(elements, positions, nucleus, config)?;
1699
1700    let scf_config = scf::scf_loop::ScfConfig {
1701        max_iterations: config.max_scf_iterations,
1702        use_parallel_eri: config.use_parallel_eri,
1703        parallel_threshold: if config.use_parallel_eri { 0 } else { 20 },
1704        ..scf::scf_loop::ScfConfig::default()
1705    };
1706
1707    let scf = scf::scf_loop::run_scf(&request.system, &scf_config);
1708    let shieldings = spectroscopy::compute_nmr_shieldings_for_nucleus(
1709        request.nucleus,
1710        &request.system.atomic_numbers,
1711        &request.system.positions_bohr,
1712        &spectroscopy::ScfInput::from(&scf),
1713        &request.basis.function_to_atom,
1714    );
1715    if shieldings.is_empty() {
1716        return Err(format!(
1717            "Requested nucleus {} is not present in the provided element list.",
1718            request.nucleus.canonical()
1719        ));
1720    }
1721
1722    let entries: Vec<GiaoNmrEntry> = shieldings
1723        .iter()
1724        .map(|entry| GiaoNmrEntry {
1725            atom_index: entry.atom_index,
1726            element: entry.element,
1727            tensor: entry.tensor,
1728            isotropic: entry.isotropic,
1729            anisotropy: entry.anisotropy,
1730            chemical_shift: entry.chemical_shift,
1731        })
1732        .collect();
1733
1734    let mut notes = vec![format!(
1735        "GIAO NMR for {} via the public SCF route (RHF/STO-3G).",
1736        request.nucleus.unicode_label()
1737    )];
1738    if scf.converged {
1739        notes.push(format!(
1740            "SCF converged in {} iterations with total energy {:.6} Hartree.",
1741            scf.scf_iterations, scf.total_energy
1742        ));
1743    } else {
1744        notes.push(format!(
1745            "SCF did not reach the requested threshold after {} iterations; treat the returned shieldings as screening-level only.",
1746            scf.scf_iterations
1747        ));
1748    }
1749    if request.fallback_elements.is_empty() {
1750        notes.push(
1751            "All elements in this system use explicit STO-3G basis data in the current SCF path."
1752                .to_string(),
1753        );
1754    } else {
1755        notes.push(format!(
1756            "Fallback basis enabled for {}. Heavy-element shieldings are qualitative in this mode.",
1757            format_element_set(&request.fallback_elements)
1758        ));
1759    }
1760    if request.nucleus.is_quadrupolar() {
1761        notes.push(
1762            "Quadrupolar nucleus selected: this API returns isotropic shieldings and chemical shifts only; relaxation-driven broadening still needs experimental lineshape modeling.".to_string(),
1763        );
1764    }
1765    if !request.nucleus.is_primary_target() {
1766        notes.push(
1767            "Non-1H/13C nuclei remain sensitive to the reference shielding and basis choice; benchmark the target family before quantitative use.".to_string(),
1768        );
1769    }
1770
1771    Ok(GiaoNmrResult {
1772        nucleus: request.nucleus.canonical().to_string(),
1773        target_atomic_number: request.nucleus.atomic_number(),
1774        method: "RHF/GIAO".to_string(),
1775        basis_set: "STO-3G".to_string(),
1776        charge: config.charge,
1777        multiplicity: config.multiplicity,
1778        scf_converged: scf.converged,
1779        scf_iterations: scf.scf_iterations,
1780        total_energy_hartree: scf.total_energy,
1781        n_basis: scf.n_basis,
1782        n_target_atoms: entries.len(),
1783        chemical_shifts: entries.iter().map(|entry| entry.chemical_shift).collect(),
1784        shieldings: entries,
1785        fallback_elements: request.fallback_elements,
1786        notes,
1787    })
1788}
1789
1790/// Generate a complete NMR spectrum from SMILES.
1791///
1792/// `nucleus`: any supported nucleus alias, for example "1H", "13C", "35Cl", or "195Pt"
1793/// `gamma`: Lorentzian line width in ppm
1794/// `ppm_min`, `ppm_max`: spectral window
1795/// `n_points`: grid resolution
1796pub fn compute_nmr_spectrum(
1797    smiles: &str,
1798    nucleus: &str,
1799    gamma: f64,
1800    ppm_min: f64,
1801    ppm_max: f64,
1802    n_points: usize,
1803) -> Result<nmr::NmrSpectrum, String> {
1804    compute_nmr_spectrum_with_coords(smiles, &[], nucleus, gamma, ppm_min, ppm_max, n_points)
1805}
1806
1807/// Generate a complete NMR spectrum from SMILES and optional 3D coordinates.
1808/// When coordinates are provided, vicinal ³J couplings use the Karplus equation.
1809pub fn compute_nmr_spectrum_with_coords(
1810    smiles: &str,
1811    positions: &[[f64; 3]],
1812    nucleus: &str,
1813    gamma: f64,
1814    ppm_min: f64,
1815    ppm_max: f64,
1816    n_points: usize,
1817) -> Result<nmr::NmrSpectrum, String> {
1818    let mol = graph::Molecule::from_smiles(smiles)?;
1819    let couplings = nmr::predict_j_couplings(&mol, positions);
1820    let nuc = parse_nmr_nucleus(nucleus)?;
1821    let shifts = nmr::predict_chemical_shifts_for_nucleus(&mol, nuc);
1822    Ok(nmr::spectrum::compute_nmr_spectrum_for_shifts(
1823        &shifts, &couplings, nuc, gamma, ppm_min, ppm_max, n_points,
1824    ))
1825}
1826
1827/// Generate HOSE codes for all atoms in a molecule.
1828pub fn compute_hose_codes(smiles: &str, max_radius: usize) -> Result<Vec<nmr::HoseCode>, String> {
1829    let mol = graph::Molecule::from_smiles(smiles)?;
1830    Ok(nmr::hose::generate_hose_codes(&mol, max_radius))
1831}
1832
1833/// Compute orbital isosurface mesh for visualization.
1834///
1835/// Works with all implemented electronic-structure methods: EHT, PM3, xTB, HF-3c.
1836///
1837/// - `elements`: atomic numbers
1838/// - `positions`: Cartesian coordinates `[x,y,z]` in Å
1839/// - `method`: "eht", "pm3", "xtb", or "hf3c"
1840/// - `mo_index`: which molecular orbital to visualize
1841/// - `spacing`: grid spacing in Å (0.2 typical)
1842/// - `padding`: padding around molecule in Å (3.0 typical)
1843/// - `isovalue`: isosurface threshold (0.02 typical)
1844pub fn compute_orbital_mesh(
1845    elements: &[u8],
1846    positions: &[[f64; 3]],
1847    method: &str,
1848    mo_index: usize,
1849    spacing: f64,
1850    padding: f64,
1851    isovalue: f32,
1852) -> Result<mesh::OrbitalMeshResult, String> {
1853    let m = match method.to_lowercase().as_str() {
1854        "eht" | "huckel" => mesh::MeshMethod::Eht,
1855        "pm3" => mesh::MeshMethod::Pm3,
1856        "xtb" | "gfn0" | "gfn-xtb" => mesh::MeshMethod::Xtb,
1857        "hf3c" | "hf-3c" | "hf" => mesh::MeshMethod::Hf3c,
1858        _ => {
1859            return Err(format!(
1860                "Unknown method '{}'. Supported: eht, pm3, xtb, hf3c",
1861                method
1862            ))
1863        }
1864    };
1865    mesh::compute_orbital_mesh(elements, positions, m, mo_index, spacing, padding, isovalue)
1866}
1867
1868/// Compute molecular descriptors for ML property prediction.
1869///
1870/// `elements`: atomic numbers.
1871/// `bonds`: (atom_i, atom_j, bond_order) list.
1872/// `charges`: partial charges (or empty slice for default).
1873/// `aromatic_atoms`: aromatic flags per atom (or empty slice).
1874pub fn compute_ml_descriptors(
1875    elements: &[u8],
1876    bonds: &[(usize, usize, u8)],
1877    charges: &[f64],
1878    aromatic_atoms: &[bool],
1879) -> ml::MolecularDescriptors {
1880    ml::compute_descriptors(elements, bonds, charges, aromatic_atoms)
1881}
1882
1883/// Predict molecular properties using ML proxy models.
1884///
1885/// Returns LogP, molar refractivity, solubility, Lipinski flags,
1886/// and druglikeness score from molecular descriptors.
1887pub fn predict_ml_properties(desc: &ml::MolecularDescriptors) -> ml::MlPropertyResult {
1888    ml::predict_properties(desc)
1889}
1890
1891/// Predict molecular properties using ensemble ML models.
1892///
1893/// Returns consensus LogP (with uncertainty), TPSA, pKa estimates,
1894/// Veber bioavailability rules, BBB permeability, and confidence score.
1895pub fn predict_ensemble(
1896    elements: &[u8],
1897    bonds: &[(usize, usize, u8)],
1898    charges: &[f64],
1899    aromatic_atoms: &[bool],
1900) -> ml::EnsembleResult {
1901    let desc = ml::compute_descriptors(elements, bonds, charges, aromatic_atoms);
1902    ml::predict_ensemble(&desc, elements, bonds)
1903}
1904
1905/// Compute Topological Polar Surface Area (TPSA) in Ų.
1906pub fn compute_tpsa(elements: &[u8], bonds: &[(usize, usize, u8)]) -> f64 {
1907    ml::compute_tpsa(elements, bonds)
1908}
1909
1910/// Run short exploratory molecular dynamics with Velocity Verlet (NVE-like).
1911pub fn compute_md_trajectory(
1912    smiles: &str,
1913    coords: &[f64],
1914    n_steps: usize,
1915    dt_fs: f64,
1916    seed: u64,
1917) -> Result<dynamics::MdTrajectory, String> {
1918    dynamics::simulate_velocity_verlet_uff(smiles, coords, n_steps, dt_fs, seed, None)
1919}
1920
1921/// Run short exploratory molecular dynamics with Velocity Verlet + Berendsen NVT thermostat.
1922pub fn compute_md_trajectory_nvt(
1923    smiles: &str,
1924    coords: &[f64],
1925    n_steps: usize,
1926    dt_fs: f64,
1927    seed: u64,
1928    target_temp_k: f64,
1929    thermostat_tau_fs: f64,
1930) -> Result<dynamics::MdTrajectory, String> {
1931    dynamics::simulate_velocity_verlet_uff(
1932        smiles,
1933        coords,
1934        n_steps,
1935        dt_fs,
1936        seed,
1937        Some((target_temp_k, thermostat_tau_fs)),
1938    )
1939}
1940
1941/// Build a simplified NEB path between two geometries.
1942pub fn compute_simplified_neb_path(
1943    smiles: &str,
1944    start_coords: &[f64],
1945    end_coords: &[f64],
1946    n_images: usize,
1947    n_iter: usize,
1948    spring_k: f64,
1949    step_size: f64,
1950) -> Result<dynamics::NebPathResult, String> {
1951    dynamics::compute_simplified_neb_path(
1952        smiles,
1953        start_coords,
1954        end_coords,
1955        n_images,
1956        n_iter,
1957        spring_k,
1958        step_size,
1959    )
1960}
1961
1962/// Build a simplified NEB path using a configurable energy backend.
1963///
1964/// `method`: one of `"uff"`, `"mmff94"`, `"pm3"`, `"xtb"`, `"gfn1"`, `"gfn2"`, `"hf3c"`.
1965pub fn compute_simplified_neb_path_configurable(
1966    smiles: &str,
1967    start_coords: &[f64],
1968    end_coords: &[f64],
1969    n_images: usize,
1970    n_iter: usize,
1971    spring_k: f64,
1972    step_size: f64,
1973    method: &str,
1974) -> Result<dynamics::NebPathResult, String> {
1975    dynamics::compute_simplified_neb_path_configurable(
1976        smiles,
1977        start_coords,
1978        end_coords,
1979        n_images,
1980        n_iter,
1981        spring_k,
1982        step_size,
1983        method,
1984    )
1985}
1986
1987/// Compute single-point energy (kcal/mol) with any NEB-capable backend.
1988///
1989/// `method`: one of `"uff"`, `"mmff94"`, `"pm3"`, `"xtb"`, `"gfn1"`, `"gfn2"`, `"hf3c"`.
1990pub fn neb_backend_energy_kcal(method: &str, smiles: &str, coords: &[f64]) -> Result<f64, String> {
1991    dynamics::neb_backend_energy_kcal(method, smiles, coords)
1992}
1993
1994/// Compute energy (kcal/mol) and gradient (kcal/mol/Å) with any NEB-capable backend.
1995///
1996/// Returns `(energy, gradient_flat)`.
1997pub fn neb_backend_energy_and_gradient(
1998    method: &str,
1999    smiles: &str,
2000    coords: &[f64],
2001) -> Result<(f64, Vec<f64>), String> {
2002    dynamics::neb_backend_energy_and_gradient(method, smiles, coords)
2003}
2004
2005/// Compute a full reaction dynamics path: embed reactant + product fragments,
2006/// build oriented complexes, run NEB for the reactive zone, and generate
2007/// approach/departure frames — all with energies computed in Rust.
2008///
2009/// See [`dynamics::ReactionDynamicsConfig`] for tuning parameters.
2010pub fn compute_reaction_dynamics(
2011    reactant_smiles: &[&str],
2012    product_smiles: &[&str],
2013    method: &str,
2014    config: &dynamics::ReactionDynamicsConfig,
2015) -> Result<dynamics::ReactionDynamicsResult, String> {
2016    dynamics::compute_reaction_dynamics(reactant_smiles, product_smiles, method, config)
2017}
2018
2019fn coords_flat_to_matrix_f32(coords: &[f64], n_atoms: usize) -> nalgebra::DMatrix<f32> {
2020    let mut m = nalgebra::DMatrix::<f32>::zeros(n_atoms, 3);
2021    for i in 0..n_atoms {
2022        m[(i, 0)] = coords[3 * i] as f32;
2023        m[(i, 1)] = coords[3 * i + 1] as f32;
2024        m[(i, 2)] = coords[3 * i + 2] as f32;
2025    }
2026    m
2027}
2028
2029fn coords_matrix_f32_to_flat(m: &nalgebra::DMatrix<f32>) -> Vec<f64> {
2030    let mut out = Vec::with_capacity(m.nrows() * 3);
2031    for i in 0..m.nrows() {
2032        out.push(m[(i, 0)] as f64);
2033        out.push(m[(i, 1)] as f64);
2034        out.push(m[(i, 2)] as f64);
2035    }
2036    out
2037}
2038
2039/// Search conformers by sampling multiple embeddings, optimizing torsions, and ranking with UFF.
2040///
2041/// This workflow:
2042/// 1. Generates up to `n_samples` conformers with different seeds.
2043/// 2. Applies Monte Carlo torsion sampling plus a greedy rotatable-bond refinement pass.
2044/// 3. Scores each conformer with UFF energy (kcal/mol).
2045/// 4. Filters near-duplicates using RMSD thresholding.
2046/// 5. Builds explicit RMSD clusters and returns representative/member mapping.
2047pub fn search_conformers_with_uff(
2048    smiles: &str,
2049    n_samples: usize,
2050    seed: u64,
2051    rmsd_threshold: f64,
2052) -> Result<ConformerSearchResult, String> {
2053    if n_samples == 0 {
2054        return Err("n_samples must be > 0".to_string());
2055    }
2056    if rmsd_threshold <= 0.0 {
2057        return Err("rmsd_threshold must be > 0".to_string());
2058    }
2059
2060    let mol = graph::Molecule::from_smiles(smiles)?;
2061    let n_atoms = mol.graph.node_count();
2062    let bounds = distgeom::smooth_bounds_matrix(distgeom::calculate_bounds_matrix(&mol));
2063
2064    let mut generated = Vec::new();
2065    let mut notes = Vec::new();
2066    let mut rotatable_bonds = 0usize;
2067
2068    for i in 0..n_samples {
2069        let sample_seed = seed.wrapping_add(i as u64);
2070        let conf = embed(smiles, sample_seed);
2071
2072        if conf.error.is_some() || conf.coords.len() != n_atoms * 3 {
2073            continue;
2074        }
2075
2076        let mut coords = coords_flat_to_matrix_f32(&conf.coords, n_atoms);
2077        let rot_mc = forcefield::optimize_torsions_monte_carlo_bounds(
2078            &mut coords,
2079            &mol,
2080            &bounds,
2081            sample_seed ^ 0x9E37_79B9_7F4A_7C15,
2082            64,
2083            0.4,
2084        );
2085        let rot_greedy = forcefield::optimize_torsions_bounds(&mut coords, &mol, &bounds, 2);
2086        let rot = rot_mc.max(rot_greedy);
2087        rotatable_bonds = rot;
2088        let coords_flat = coords_matrix_f32_to_flat(&coords);
2089
2090        match compute_uff_energy(smiles, &coords_flat) {
2091            Ok(energy_kcal_mol) => generated.push(ConformerEnsembleMember {
2092                seed: sample_seed,
2093                cluster_id: None,
2094                coords: coords_flat,
2095                energy_kcal_mol,
2096            }),
2097            Err(_) => continue,
2098        }
2099    }
2100
2101    if generated.is_empty() {
2102        return Err("failed to generate any valid conformers".to_string());
2103    }
2104
2105    generated.sort_by(|a, b| {
2106        a.energy_kcal_mol
2107            .partial_cmp(&b.energy_kcal_mol)
2108            .unwrap_or(std::cmp::Ordering::Equal)
2109    });
2110
2111    let generated_count = generated.len();
2112
2113    let mut unique = Vec::new();
2114    let mut cluster_members: Vec<Vec<u64>> = Vec::new();
2115    for candidate in generated {
2116        let existing_cluster = unique.iter().position(|u: &ConformerEnsembleMember| {
2117            compute_rmsd(&candidate.coords, &u.coords) < rmsd_threshold
2118        });
2119
2120        if let Some(cluster_id) = existing_cluster {
2121            cluster_members[cluster_id].push(candidate.seed);
2122        } else {
2123            unique.push(candidate.clone());
2124            cluster_members.push(vec![candidate.seed]);
2125        }
2126    }
2127
2128    for (cluster_id, member) in unique.iter_mut().enumerate() {
2129        member.cluster_id = Some(cluster_id);
2130    }
2131
2132    let clusters: Vec<ConformerClusterSummary> = unique
2133        .iter()
2134        .enumerate()
2135        .map(|(cluster_id, representative)| ConformerClusterSummary {
2136            cluster_id,
2137            representative_seed: representative.seed,
2138            size: cluster_members[cluster_id].len(),
2139            member_seeds: cluster_members[cluster_id].clone(),
2140        })
2141        .collect();
2142
2143    notes.push(
2144        "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."
2145            .to_string(),
2146    );
2147
2148    Ok(ConformerSearchResult {
2149        generated: generated_count,
2150        unique: unique.len(),
2151        rotatable_bonds,
2152        conformers: unique,
2153        clusters,
2154        notes,
2155    })
2156}
2157
2158/// Compute UFF energy and apply an aromaticity-informed heuristic correction.
2159pub fn compute_uff_energy_with_aromatic_heuristics(
2160    smiles: &str,
2161    coords: &[f64],
2162) -> Result<reactivity::UffHeuristicEnergy, String> {
2163    let mol = graph::Molecule::from_smiles(smiles)?;
2164    let n = mol.graph.node_count();
2165    if coords.len() != n * 3 {
2166        return Err(format!("coords length {} != 3 * atoms {}", coords.len(), n));
2167    }
2168
2169    let ff = forcefield::builder::build_uff_force_field(&mol);
2170    let mut gradient = vec![0.0f64; n * 3];
2171    let raw = ff.compute_system_energy_and_gradients(coords, &mut gradient);
2172    Ok(reactivity::apply_aromatic_uff_correction(&mol, raw))
2173}
2174
2175/// Estimate acidic/basic pKa sites from graph environments and Gasteiger-charge heuristics.
2176pub fn compute_empirical_pka(smiles: &str) -> Result<reactivity::EmpiricalPkaResult, String> {
2177    let mol = graph::Molecule::from_smiles(smiles)?;
2178    let charges = compute_charges(smiles)?;
2179    Ok(reactivity::estimate_empirical_pka(&mol, &charges.charges))
2180}
2181
2182/// Create a periodic unit cell from lattice parameters (a, b, c in Å; α, β, γ in degrees).
2183pub fn create_unit_cell(
2184    a: f64,
2185    b: f64,
2186    c: f64,
2187    alpha: f64,
2188    beta: f64,
2189    gamma: f64,
2190) -> materials::UnitCell {
2191    materials::UnitCell::from_parameters(&materials::CellParameters {
2192        a,
2193        b,
2194        c,
2195        alpha,
2196        beta,
2197        gamma,
2198    })
2199}
2200
2201/// Assemble a framework crystal structure from node/linker SBUs on a topology.
2202///
2203/// Returns the assembled crystal structure as a JSON-serializable `CrystalStructure`.
2204pub fn assemble_framework(
2205    node: &materials::Sbu,
2206    linker: &materials::Sbu,
2207    topology: &materials::Topology,
2208    cell: &materials::UnitCell,
2209) -> materials::CrystalStructure {
2210    materials::assemble_framework(node, linker, topology, cell)
2211}
2212
2213// ─── CIF Import/Export ────────────────────────────────────────────────────────
2214
2215/// Parse a CIF (Crystallographic Information File) string into a crystal structure.
2216///
2217/// Returns cell parameters, space group info, and atom site positions.
2218pub fn parse_cif(input: &str) -> Result<materials::CifStructure, String> {
2219    materials::cif::parse_cif(input)
2220}
2221
2222/// Write a crystal structure to CIF format string.
2223pub fn write_cif(structure: &materials::CifStructure) -> String {
2224    materials::cif::write_cif(structure)
2225}
2226
2227// ─── UHF / ROHF — Open-Shell SCF ──────────────────────────────────────────────
2228
2229/// Run an Unrestricted Hartree-Fock (UHF) calculation for open-shell systems.
2230///
2231/// `elements`: atomic numbers.
2232/// `positions`: Cartesian coordinates in Å, one `[x,y,z]` per atom.
2233/// `charge`: total molecular charge (0 = neutral).
2234/// `multiplicity`: spin multiplicity (1 = singlet, 2 = doublet, 3 = triplet, …).
2235///
2236/// Returns separate alpha/beta orbitals, <S²> expectation, spin contamination.
2237pub fn compute_uhf(
2238    elements: &[u8],
2239    positions: &[[f64; 3]],
2240    charge: i32,
2241    multiplicity: u32,
2242) -> Result<scf::uhf::UhfResult, String> {
2243    let system =
2244        scf::types::MolecularSystem::from_angstrom(elements, positions, charge, multiplicity);
2245    Ok(scf::uhf::run_uhf_parallel(&system))
2246}
2247
2248/// Run a Restricted Open-shell Hartree-Fock (ROHF) calculation.
2249///
2250/// Uses canonical ROHF projection: same spatial orbitals for both spins,
2251/// eliminating spin contamination. Suited for clean-spin-state calculations.
2252///
2253/// `elements`: atomic numbers.
2254/// `positions`: Cartesian coordinates in Å, one `[x,y,z]` per atom.
2255/// `charge`: total molecular charge.
2256/// `multiplicity`: spin multiplicity.
2257pub fn compute_rohf(
2258    elements: &[u8],
2259    positions: &[[f64; 3]],
2260    charge: i32,
2261    multiplicity: u32,
2262) -> Result<scf::uhf::UhfResult, String> {
2263    let system =
2264        scf::types::MolecularSystem::from_angstrom(elements, positions, charge, multiplicity);
2265    Ok(scf::uhf::run_rohf(&system))
2266}
2267
2268/// Run UHF/ROHF with full configuration control.
2269pub fn compute_uhf_configured(
2270    elements: &[u8],
2271    positions: &[[f64; 3]],
2272    charge: i32,
2273    multiplicity: u32,
2274    config: &scf::uhf::UhfConfig,
2275) -> Result<scf::uhf::UhfResult, String> {
2276    let system =
2277        scf::types::MolecularSystem::from_angstrom(elements, positions, charge, multiplicity);
2278    Ok(scf::uhf::run_uhf(&system, config))
2279}
2280
2281/// Run a complete HF-3c calculation (Hartree-Fock with D3, gCP, SRB corrections).
2282///
2283/// `elements`: atomic numbers.
2284/// `positions`: Cartesian coordinates in Å, one `[x,y,z]` per atom.
2285/// `config`: calculation parameters (or use `HfConfig::default()`).
2286///
2287/// Returns orbital energies, total energy, correction energies, and optional CIS states.
2288pub fn compute_hf3c(
2289    elements: &[u8],
2290    positions: &[[f64; 3]],
2291    config: &hf::HfConfig,
2292) -> Result<hf::Hf3cResult, String> {
2293    hf::solve_hf3c(elements, positions, config)
2294}
2295
2296/// Compute ANI neural-network potential energy (and optionally forces).
2297///
2298/// Uses internally-generated test weights — suitable for testing and demonstration.
2299/// For physically meaningful results, use `ani::compute_ani()` with trained weights.
2300///
2301/// `elements`: atomic numbers (supported: H, C, N, O, F, S, Cl).
2302/// `positions`: Cartesian coordinates in Å, one `[x,y,z]` per atom.
2303pub fn compute_ani(elements: &[u8], positions: &[[f64; 3]]) -> Result<ani::AniResult, String> {
2304    ani::api::compute_ani_test(elements, positions)
2305}
2306
2307/// Compute ANI neural-network potential energy with custom config and pre-loaded models.
2308///
2309/// `elements`: atomic numbers.
2310/// `positions`: Cartesian coordinates in Å.
2311/// `config`: ANI configuration (cutoff, force computation flag).
2312/// `models`: pre-loaded element→network map from `ani::weights::load_weights()`.
2313pub fn compute_ani_with_models(
2314    elements: &[u8],
2315    positions: &[[f64; 3]],
2316    config: &ani::AniConfig,
2317    models: &std::collections::HashMap<u8, ani::nn::FeedForwardNet>,
2318) -> Result<ani::AniResult, String> {
2319    ani::compute_ani(elements, positions, config, models)
2320}
2321
2322/// Compute ESP grid and return full result with values, origin, spacing and dimensions.
2323///
2324/// This is a convenience alias for `compute_esp()` returning the `EspGrid` struct.
2325pub fn compute_esp_grid(
2326    elements: &[u8],
2327    positions: &[[f64; 3]],
2328    spacing: f64,
2329    padding: f64,
2330) -> Result<esp::EspGrid, String> {
2331    compute_esp(elements, positions, spacing, padding)
2332}
2333
2334// ─── Canonical SMILES API ─────────────────────────────────────────────────────
2335
2336/// Generate a canonical SMILES string from an input SMILES.
2337///
2338/// Produces a deterministic SMILES representation regardless of
2339/// input atom ordering (e.g., "CCO" and "OCC" produce the same output).
2340pub fn to_canonical_smiles(smiles: &str) -> Result<String, String> {
2341    let mol = graph::Molecule::from_smiles(smiles)?;
2342    Ok(canonical_smiles::to_canonical_smiles(&mol))
2343}
2344
2345// ─── Stereochemistry API ─────────────────────────────────────────────────────
2346
2347/// Analyze stereochemistry: detect chiral centers (R/S) and E/Z double bonds.
2348pub fn analyze_stereo(smiles: &str, coords: &[f64]) -> Result<stereo::StereoAnalysis, String> {
2349    let mol = graph::Molecule::from_smiles(smiles)?;
2350    let positions: Vec<[f64; 3]> = coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2351    Ok(stereo::analyze_stereo(&mol, &positions))
2352}
2353
2354// ─── Solvation API ───────────────────────────────────────────────────────────
2355
2356/// Compute non-polar solvation energy from SASA and atomic solvation parameters.
2357pub fn compute_nonpolar_solvation(
2358    elements: &[u8],
2359    positions: &[[f64; 3]],
2360    probe_radius: Option<f64>,
2361) -> solvation::NonPolarSolvation {
2362    solvation::compute_nonpolar_solvation(elements, positions, probe_radius)
2363}
2364
2365/// Compute Generalized Born electrostatic + non-polar solvation energy.
2366pub fn compute_gb_solvation(
2367    elements: &[u8],
2368    positions: &[[f64; 3]],
2369    charges: &[f64],
2370    solvent_dielectric: Option<f64>,
2371    solute_dielectric: Option<f64>,
2372    probe_radius: Option<f64>,
2373) -> solvation::GbSolvation {
2374    solvation::compute_gb_solvation(
2375        elements,
2376        positions,
2377        charges,
2378        solvent_dielectric,
2379        solute_dielectric,
2380        probe_radius,
2381    )
2382}
2383
2384// ─── Clustering API ──────────────────────────────────────────────────────────
2385
2386/// Cluster conformers using Butina (Taylor-Butina) algorithm based on RMSD.
2387pub fn butina_cluster(conformers: &[Vec<f64>], rmsd_cutoff: f64) -> clustering::ClusterResult {
2388    clustering::butina_cluster(conformers, rmsd_cutoff)
2389}
2390
2391/// Compute all-pairs RMSD matrix for a set of conformers.
2392pub fn compute_rmsd_matrix(conformers: &[Vec<f64>]) -> Vec<Vec<f64>> {
2393    clustering::compute_rmsd_matrix(conformers)
2394}
2395
2396// ─── Rings & Fingerprints API ────────────────────────────────────────────────
2397
2398/// Compute the Smallest Set of Smallest Rings (SSSR).
2399pub fn compute_sssr(smiles: &str) -> Result<rings::sssr::SssrResult, String> {
2400    let mol = graph::Molecule::from_smiles(smiles)?;
2401    Ok(rings::sssr::compute_sssr(&mol))
2402}
2403
2404/// Compute Extended-Connectivity Fingerprint (ECFP/Morgan).
2405pub fn compute_ecfp(
2406    smiles: &str,
2407    radius: usize,
2408    n_bits: usize,
2409) -> Result<rings::ecfp::ECFPFingerprint, String> {
2410    let mol = graph::Molecule::from_smiles(smiles)?;
2411    Ok(rings::ecfp::compute_ecfp(&mol, radius, n_bits))
2412}
2413
2414/// Compute ECFP fingerprints for a batch of SMILES (parallelized with rayon when enabled).
2415pub fn compute_ecfp_batch(
2416    smiles_list: &[&str],
2417    radius: usize,
2418    n_bits: usize,
2419) -> Result<Vec<rings::ecfp::ECFPFingerprint>, String> {
2420    let mols: Result<Vec<_>, _> = smiles_list
2421        .iter()
2422        .map(|s| graph::Molecule::from_smiles(s))
2423        .collect();
2424    let mols = mols?;
2425    Ok(rings::ecfp::compute_ecfp_batch(&mols, radius, n_bits))
2426}
2427
2428/// Compute Tanimoto similarity between two ECFP fingerprints.
2429pub fn compute_tanimoto(
2430    fp1: &rings::ecfp::ECFPFingerprint,
2431    fp2: &rings::ecfp::ECFPFingerprint,
2432) -> f64 {
2433    rings::ecfp::compute_tanimoto(fp1, fp2)
2434}
2435
2436// ─── Charges configured API ─────────────────────────────────────────────────
2437
2438/// Compute Gasteiger-Marsili charges with configurable parameters.
2439pub fn compute_charges_configured(
2440    smiles: &str,
2441    config: &charges::gasteiger::GasteigerConfig,
2442) -> Result<charges::gasteiger::ChargeResult, String> {
2443    let mol = graph::Molecule::from_smiles(smiles)?;
2444    let n = mol.graph.node_count();
2445    let elements: Vec<u8> = (0..n)
2446        .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
2447        .collect();
2448    let bonds: Vec<(usize, usize)> = mol
2449        .graph
2450        .edge_indices()
2451        .map(|e| {
2452            let (a, b) = mol.graph.edge_endpoints(e).unwrap();
2453            (a.index(), b.index())
2454        })
2455        .collect();
2456    let formal_charges: Vec<i8> = (0..n)
2457        .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].formal_charge)
2458        .collect();
2459    charges::gasteiger::gasteiger_marsili_charges_configured(
2460        &elements,
2461        &bonds,
2462        &formal_charges,
2463        config,
2464    )
2465}
2466
2467// ─── NMR Enhanced API ────────────────────────────────────────────────────────
2468
2469/// Predict J-couplings averaged over a conformer ensemble using Boltzmann weighting.
2470pub fn compute_ensemble_j_couplings(
2471    smiles: &str,
2472    conformer_coords: &[Vec<f64>],
2473    energies_kcal: &[f64],
2474    temperature_k: f64,
2475) -> Result<Vec<nmr::JCoupling>, String> {
2476    let mol = graph::Molecule::from_smiles(smiles)?;
2477    let positions: Vec<Vec<[f64; 3]>> = conformer_coords
2478        .iter()
2479        .map(|c| c.chunks(3).map(|p| [p[0], p[1], p[2]]).collect())
2480        .collect();
2481    Ok(nmr::coupling::ensemble_averaged_j_couplings(
2482        &mol,
2483        &positions,
2484        energies_kcal,
2485        temperature_k,
2486    ))
2487}
2488
2489// ─── IR Enhanced API ─────────────────────────────────────────────────────────
2490
2491/// Compute IR spectrum with configurable broadening type.
2492pub fn compute_ir_spectrum_broadened(
2493    analysis: &ir::VibrationalAnalysis,
2494    gamma: f64,
2495    wn_min: f64,
2496    wn_max: f64,
2497    n_points: usize,
2498    broadening: &str,
2499) -> ir::IrSpectrum {
2500    let bt = match broadening {
2501        "gaussian" | "Gaussian" => ir::BroadeningType::Gaussian,
2502        _ => ir::BroadeningType::Lorentzian,
2503    };
2504    ir::vibrations::compute_ir_spectrum_with_broadening(
2505        analysis, gamma, wn_min, wn_max, n_points, bt,
2506    )
2507}
2508
2509#[cfg(test)]
2510mod tests {
2511    use super::*;
2512
2513    #[test]
2514    fn test_cisplatin_style_support_metadata() {
2515        let smiles = "[Pt](Cl)(Cl)([NH3])[NH3]";
2516        let mol = parse(smiles).expect("Cisplatin-style example should parse");
2517        let elements: Vec<u8> = (0..mol.graph.node_count())
2518            .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
2519            .collect();
2520
2521        let caps = get_system_capabilities(&elements);
2522        assert!(caps.eht.available);
2523        assert!(matches!(
2524            caps.eht.confidence,
2525            eht::SupportLevel::Experimental
2526        ));
2527    }
2528
2529    #[test]
2530    fn test_pt_system_routes_to_uff_when_experimental_disabled() {
2531        let smiles = "[Pt](Cl)(Cl)([NH3])[NH3]";
2532        let mol = parse(smiles).expect("Pt example should parse");
2533        let n = mol.graph.node_count();
2534        let elements: Vec<u8> = (0..n)
2535            .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
2536            .collect();
2537        let positions = vec![[0.0, 0.0, 0.0]; n];
2538
2539        let result = compute_eht_or_uff_fallback(smiles, &elements, &positions, false)
2540            .expect("Fallback workflow should return a result");
2541
2542        assert!(matches!(
2543            result,
2544            ElectronicWorkflowResult::UffFallback { .. }
2545        ));
2546    }
2547
2548    #[test]
2549    fn test_method_plan_prefers_supported_workflows_for_organic_systems() {
2550        let plan = get_system_method_plan(&[6, 1, 1, 1, 1]);
2551
2552        assert_eq!(plan.geometry.recommended, Some(ScientificMethod::Embed));
2553        assert_eq!(
2554            plan.force_field_energy.recommended,
2555            Some(ScientificMethod::Uff)
2556        );
2557        assert_eq!(plan.orbitals.recommended, Some(ScientificMethod::Eht));
2558        assert_eq!(plan.population.recommended, Some(ScientificMethod::Eht));
2559        assert_eq!(plan.orbital_grid.recommended, Some(ScientificMethod::Eht));
2560        assert!(plan.orbitals.methods[0].confidence_score > 0.9);
2561    }
2562
2563    #[test]
2564    fn test_method_plan_marks_metal_orbital_workflow_experimental() {
2565        let plan = get_system_method_plan(&[78, 17, 17, 7, 7]);
2566
2567        assert_eq!(plan.orbitals.recommended, Some(ScientificMethod::Eht));
2568        assert_eq!(
2569            plan.force_field_energy.recommended,
2570            Some(ScientificMethod::Uff)
2571        );
2572        assert!(matches!(
2573            plan.orbitals.methods[0].confidence,
2574            eht::SupportLevel::Experimental
2575        ));
2576        assert!(plan.orbitals.methods[0].confidence_score < 0.9);
2577        assert!(!plan.orbitals.methods[0].warnings.is_empty());
2578    }
2579
2580    #[test]
2581    fn test_method_plan_reports_unavailable_workflows_for_unsupported_elements() {
2582        let plan = get_system_method_plan(&[92]);
2583
2584        assert_eq!(plan.force_field_energy.recommended, None);
2585        assert_eq!(plan.orbitals.recommended, None);
2586        assert_eq!(plan.population.recommended, None);
2587        assert_eq!(plan.orbital_grid.recommended, None);
2588        assert!(!plan.orbitals.methods[0].limitations.is_empty());
2589    }
2590
2591    #[test]
2592    fn test_compare_methods_supported_system_returns_success_rows() {
2593        let result = compare_methods("CC", &[6, 6], &[[0.0, 0.0, 0.0], [1.54, 0.0, 0.0]], true);
2594        assert_eq!(result.comparisons.len(), 2);
2595        assert!(result
2596            .comparisons
2597            .iter()
2598            .any(|entry| matches!(entry.method, ScientificMethod::Uff) && entry.available));
2599        assert!(result.comparisons.iter().any(|entry| matches!(
2600            entry.method,
2601            ScientificMethod::Eht
2602        ) && matches!(
2603            entry.status,
2604            MethodComparisonStatus::Success
2605        )));
2606    }
2607
2608    #[test]
2609    fn test_compare_methods_blocks_experimental_eht_when_disabled() {
2610        let result = compare_methods("[O]", &[78], &[[0.0, 0.0, 0.0]], false);
2611        let eht_row = result
2612            .comparisons
2613            .iter()
2614            .find(|entry| matches!(entry.method, ScientificMethod::Eht))
2615            .expect("EHT row must exist");
2616        assert!(matches!(
2617            eht_row.status,
2618            MethodComparisonStatus::Unavailable
2619        ));
2620    }
2621
2622    #[test]
2623    fn test_compare_methods_reports_unavailable_for_unsupported_elements() {
2624        let result = compare_methods("[O]", &[92], &[[0.0, 0.0, 0.0]], true);
2625        assert!(result
2626            .comparisons
2627            .iter()
2628            .all(|entry| matches!(entry.status, MethodComparisonStatus::Unavailable)));
2629    }
2630
2631    #[test]
2632    fn test_compute_fukui_descriptors_returns_atomwise_output() {
2633        let elements = [8u8, 1, 1];
2634        let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
2635        let result = compute_fukui_descriptors(&elements, &positions).unwrap();
2636        assert_eq!(result.num_atoms, 3);
2637        assert_eq!(result.condensed.len(), 3);
2638    }
2639
2640    #[test]
2641    fn test_compute_uv_vis_spectrum_returns_requested_grid_size() {
2642        let elements = [6u8, 6, 1, 1, 1, 1];
2643        let positions = [
2644            [0.0, 0.0, 0.0],
2645            [1.34, 0.0, 0.0],
2646            [-0.6, 0.92, 0.0],
2647            [-0.6, -0.92, 0.0],
2648            [1.94, 0.92, 0.0],
2649            [1.94, -0.92, 0.0],
2650        ];
2651        let spectrum = compute_uv_vis_spectrum(&elements, &positions, 0.2, 0.5, 8.0, 256).unwrap();
2652        assert_eq!(spectrum.energies_ev.len(), 256);
2653        assert_eq!(spectrum.intensities.len(), 256);
2654    }
2655
2656    #[test]
2657    fn test_analyze_graph_features_reports_benzene_aromaticity() {
2658        let analysis = analyze_graph_features("c1ccccc1").unwrap();
2659        assert_eq!(analysis.aromaticity.aromatic_atoms.len(), 12);
2660        assert_eq!(
2661            analysis
2662                .aromaticity
2663                .aromatic_atoms
2664                .iter()
2665                .filter(|v| **v)
2666                .count(),
2667            6
2668        );
2669        assert_eq!(analysis.aromaticity.aromatic_bonds.len(), 6);
2670    }
2671
2672    #[test]
2673    fn test_compute_empirical_pka_finds_acidic_site_for_acetic_acid() {
2674        let result = compute_empirical_pka("CC(=O)O").unwrap();
2675        assert!(!result.acidic_sites.is_empty());
2676    }
2677
2678    #[test]
2679    fn test_compute_uff_energy_with_aromatic_heuristics_applies_correction() {
2680        let conf = embed("c1ccccc1", 42);
2681        assert!(conf.error.is_none());
2682
2683        let result = compute_uff_energy_with_aromatic_heuristics("c1ccccc1", &conf.coords).unwrap();
2684        assert!(result.aromatic_bond_count >= 6);
2685        assert!(result.corrected_energy_kcal_mol <= result.raw_energy_kcal_mol);
2686    }
2687
2688    #[test]
2689    fn test_search_conformers_with_uff_returns_ranked_unique_ensemble() {
2690        let result = search_conformers_with_uff("CCCC", 10, 42, 0.2).unwrap();
2691        assert!(result.generated >= 1);
2692        assert!(result.unique >= 1);
2693        assert_eq!(result.unique, result.conformers.len());
2694        assert_eq!(result.unique, result.clusters.len());
2695        assert!(result.rotatable_bonds >= 1);
2696
2697        let mut total_members = 0usize;
2698        for (i, cluster) in result.clusters.iter().enumerate() {
2699            assert_eq!(cluster.cluster_id, i);
2700            assert!(cluster.size >= 1);
2701            total_members += cluster.size;
2702            assert_eq!(result.conformers[i].cluster_id, Some(i));
2703            assert_eq!(result.conformers[i].seed, cluster.representative_seed);
2704        }
2705        assert_eq!(total_members, result.generated);
2706
2707        for i in 1..result.conformers.len() {
2708            assert!(
2709                result.conformers[i - 1].energy_kcal_mol <= result.conformers[i].energy_kcal_mol
2710            );
2711        }
2712    }
2713
2714    #[test]
2715    fn test_search_conformers_with_uff_large_rmsd_threshold_collapses_duplicates() {
2716        let result = search_conformers_with_uff("CCCC", 8, 123, 10.0).unwrap();
2717        assert_eq!(result.unique, 1);
2718        assert_eq!(result.clusters.len(), 1);
2719        assert_eq!(result.clusters[0].size, result.generated);
2720    }
2721
2722    #[test]
2723    fn test_compute_md_trajectory_velocity_verlet_runs() {
2724        let conf = embed("CC", 42);
2725        assert!(conf.error.is_none());
2726
2727        let trj = compute_md_trajectory("CC", &conf.coords, 10, 0.25, 7).unwrap();
2728        assert_eq!(trj.frames.len(), 11);
2729        assert!(trj
2730            .frames
2731            .iter()
2732            .all(|f| f.coords.iter().all(|v| v.is_finite())));
2733    }
2734
2735    #[test]
2736    fn test_compute_md_trajectory_nvt_runs() {
2737        let conf = embed("CCO", 42);
2738        assert!(conf.error.is_none());
2739
2740        let trj =
2741            compute_md_trajectory_nvt("CCO", &conf.coords, 12, 0.25, 17, 300.0, 10.0).unwrap();
2742        assert_eq!(trj.frames.len(), 13);
2743        assert!(trj.frames.iter().all(|f| f.temperature_k.is_finite()));
2744    }
2745
2746    #[test]
2747    fn test_compute_simplified_neb_path_runs() {
2748        let c1 = embed("CC", 42);
2749        let c2 = embed("CC", 43);
2750        assert!(c1.error.is_none());
2751        assert!(c2.error.is_none());
2752
2753        let path =
2754            compute_simplified_neb_path("CC", &c1.coords, &c2.coords, 6, 20, 0.01, 1e-5).unwrap();
2755        assert_eq!(path.images.len(), 6);
2756        assert!(path
2757            .images
2758            .iter()
2759            .all(|img| img.potential_energy_kcal_mol.is_finite()));
2760    }
2761
2762    #[test]
2763    fn test_compute_hf3c_water() {
2764        let conf = embed("O", 42);
2765        assert!(conf.error.is_none());
2766        let pos: Vec<[f64; 3]> = conf.coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2767
2768        let result = compute_hf3c(&conf.elements, &pos, &hf::HfConfig::default());
2769        assert!(result.is_ok(), "HF-3c should succeed for water");
2770        let r = result.unwrap();
2771        assert!(r.energy.is_finite());
2772        assert!(!r.orbital_energies.is_empty());
2773    }
2774
2775    #[test]
2776    fn test_compute_ani_water() {
2777        let conf = embed("O", 42);
2778        assert!(conf.error.is_none());
2779        let pos: Vec<[f64; 3]> = conf.coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2780
2781        let result = compute_ani(&conf.elements, &pos);
2782        assert!(result.is_ok(), "ANI should succeed for water");
2783        let r = result.unwrap();
2784        assert!(r.energy.is_finite());
2785        assert_eq!(r.forces.len(), 3); // 3 atoms in water
2786        assert_eq!(r.species.len(), 3);
2787    }
2788
2789    #[test]
2790    fn test_compute_esp_grid_water() {
2791        let conf = embed("O", 42);
2792        assert!(conf.error.is_none());
2793        let pos: Vec<[f64; 3]> = conf.coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
2794
2795        let result = compute_esp_grid(&conf.elements, &pos, 0.5, 3.0);
2796        assert!(result.is_ok(), "ESP grid should succeed for water");
2797        let g = result.unwrap();
2798        assert!(!g.values.is_empty());
2799        assert!(g.spacing > 0.0);
2800        assert!(g.dims[0] > 0 && g.dims[1] > 0 && g.dims[2] > 0);
2801    }
2802}