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