Skip to main content

sci_form/
lib.rs

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