Skip to main content

sci_form/
lib.rs

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