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