Skip to main content

sci_form/
lib.rs

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