Skip to main content

sci_form/
reactivity.rs

1//! Frontier-orbital reactivity descriptors derived from EHT molecular orbitals.
2
3use serde::{Deserialize, Serialize};
4
5/// One empirical pKa estimate for a specific atom-centered acid/base site.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct EmpiricalPkaSite {
8    /// Atom index in the input graph.
9    pub atom_index: usize,
10    /// Site classification: `acidic` or `basic`.
11    pub site_type: String,
12    /// Textual environment label used by the heuristic rule.
13    pub environment: String,
14    /// Estimated pKa value from simple charge/environment heuristics.
15    pub estimated_pka: f64,
16    /// Rule confidence in [0,1].
17    pub confidence: f64,
18}
19
20/// Empirical pKa summary for acidic and basic candidate sites.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct EmpiricalPkaResult {
23    /// Ranked acidic sites (lowest pKa first).
24    pub acidic_sites: Vec<EmpiricalPkaSite>,
25    /// Ranked basic sites (highest pKa first).
26    pub basic_sites: Vec<EmpiricalPkaSite>,
27    /// Human-readable caveats and guidance.
28    pub notes: Vec<String>,
29}
30
31/// UFF energy enriched with aromaticity-aware correction metadata.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct UffHeuristicEnergy {
34    /// Raw UFF energy in kcal/mol.
35    pub raw_energy_kcal_mol: f64,
36    /// Aromatic stabilization correction in kcal/mol (negative lowers energy).
37    pub aromatic_stabilization_kcal_mol: f64,
38    /// Corrected heuristic energy in kcal/mol.
39    pub corrected_energy_kcal_mol: f64,
40    /// Number of aromatic bonds found in the parsed molecular graph.
41    pub aromatic_bond_count: usize,
42    /// Interpretation notes for downstream consumers.
43    pub notes: Vec<String>,
44}
45
46/// Atom-resolved frontier-orbital descriptor summary.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct FrontierDescriptors {
49    /// Number of atoms in the system.
50    pub num_atoms: usize,
51    /// HOMO atom contributions, normalized to sum to ~1.
52    pub homo_atom_contributions: Vec<f64>,
53    /// LUMO atom contributions, normalized to sum to ~1.
54    pub lumo_atom_contributions: Vec<f64>,
55    /// Simple dual-descriptor proxy: LUMO contribution minus HOMO contribution.
56    pub dual_descriptor: Vec<f64>,
57    /// HOMO energy in eV.
58    pub homo_energy: f64,
59    /// LUMO energy in eV.
60    pub lumo_energy: f64,
61    /// HOMO-LUMO gap in eV.
62    pub gap: f64,
63}
64
65/// One condensed Fukui descriptor row for an atom.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CondensedFukuiAtom {
68    /// Atom index in the input order.
69    pub atom_index: usize,
70    /// Nucleophilic-attack susceptibility proxy (f+).
71    pub f_plus: f64,
72    /// Electrophilic-attack susceptibility proxy (f-).
73    pub f_minus: f64,
74    /// Radical-attack susceptibility proxy (f0).
75    pub f_radical: f64,
76    /// Dual descriptor (f+ - f-).
77    pub dual_descriptor: f64,
78}
79
80/// Fukui workflow output, including atom-wise condensed descriptors.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct FukuiDescriptors {
83    /// Number of atoms in the system.
84    pub num_atoms: usize,
85    /// Nucleophilic-attack susceptibility proxy per atom.
86    pub f_plus: Vec<f64>,
87    /// Electrophilic-attack susceptibility proxy per atom.
88    pub f_minus: Vec<f64>,
89    /// Radical-attack susceptibility proxy per atom.
90    pub f_radical: Vec<f64>,
91    /// Dual descriptor (f+ - f-) per atom.
92    pub dual_descriptor: Vec<f64>,
93    /// Condensed atom-wise summary.
94    pub condensed: Vec<CondensedFukuiAtom>,
95    /// HOMO energy in eV.
96    pub homo_energy: f64,
97    /// LUMO energy in eV.
98    pub lumo_energy: f64,
99    /// HOMO-LUMO gap in eV.
100    pub gap: f64,
101    /// Domain and confidence caveats for interpretation.
102    pub validity_notes: Vec<String>,
103}
104
105/// One ranked site entry for empirical local-reactivity helpers.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ReactivitySiteScore {
108    /// Atom index in the input order.
109    pub atom_index: usize,
110    /// Composite empirical score.
111    pub score: f64,
112}
113
114/// Empirical local-reactivity ranking summary.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ReactivityRanking {
117    /// Ranked candidate sites for nucleophilic attack on the molecule.
118    pub nucleophilic_attack_sites: Vec<ReactivitySiteScore>,
119    /// Ranked candidate sites for electrophilic attack on the molecule.
120    pub electrophilic_attack_sites: Vec<ReactivitySiteScore>,
121    /// Ranked candidate sites for radical attack on the molecule.
122    pub radical_attack_sites: Vec<ReactivitySiteScore>,
123    /// Notes about how scores are constructed.
124    pub notes: Vec<String>,
125}
126
127/// A single broad-band transition peak in an exploratory UV-Vis-like spectrum.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct UvVisPeak {
130    /// Transition energy in eV.
131    pub energy_ev: f64,
132    /// Wavelength in nm.
133    pub wavelength_nm: f64,
134    /// Relative intensity (arbitrary units).
135    pub intensity: f64,
136    /// Occupied MO index.
137    pub from_mo: usize,
138    /// Virtual MO index.
139    pub to_mo: usize,
140}
141
142/// Exploratory UV-Vis-like spectrum built from low-cost EHT transitions.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct UvVisSpectrum {
145    /// Energy axis in eV.
146    pub energies_ev: Vec<f64>,
147    /// Intensity axis in arbitrary units.
148    pub intensities: Vec<f64>,
149    /// Dominant transitions used for interpretation.
150    pub peaks: Vec<UvVisPeak>,
151    /// Broadening sigma in eV.
152    pub sigma: f64,
153    /// Method caveats for use in UI/API consumers.
154    pub notes: Vec<String>,
155}
156
157fn orbital_atom_contributions(
158    basis: &[crate::eht::basis::AtomicOrbital],
159    overlap: &nalgebra::DMatrix<f64>,
160    coefficients: &[Vec<f64>],
161    mo_index: usize,
162    n_atoms: usize,
163) -> Vec<f64> {
164    let ao_to_atom = crate::population::population::ao_to_atom_map(basis);
165    let mut contributions = vec![0.0; n_atoms];
166
167    for mu in 0..basis.len() {
168        let mut gross = 0.0;
169        for nu in 0..basis.len() {
170            gross += coefficients[mu][mo_index] * overlap[(mu, nu)] * coefficients[nu][mo_index];
171        }
172        contributions[ao_to_atom[mu]] += gross;
173    }
174
175    let total: f64 = contributions.iter().sum();
176    if total.abs() > 1e-12 {
177        for value in &mut contributions {
178            *value /= total;
179        }
180    }
181
182    contributions
183}
184
185/// Compute atom-resolved HOMO/LUMO descriptors from an EHT result.
186pub fn compute_frontier_descriptors(
187    elements: &[u8],
188    positions: &[[f64; 3]],
189    eht_result: &crate::eht::EhtResult,
190) -> FrontierDescriptors {
191    let basis = crate::eht::basis::build_basis(elements, positions);
192    let overlap = crate::eht::build_overlap_matrix(&basis);
193
194    let homo_atom_contributions = orbital_atom_contributions(
195        &basis,
196        &overlap,
197        &eht_result.coefficients,
198        eht_result.homo_index,
199        elements.len(),
200    );
201    let lumo_atom_contributions = orbital_atom_contributions(
202        &basis,
203        &overlap,
204        &eht_result.coefficients,
205        eht_result.lumo_index,
206        elements.len(),
207    );
208    let dual_descriptor = lumo_atom_contributions
209        .iter()
210        .zip(homo_atom_contributions.iter())
211        .map(|(lumo, homo)| lumo - homo)
212        .collect();
213
214    FrontierDescriptors {
215        num_atoms: elements.len(),
216        homo_atom_contributions,
217        lumo_atom_contributions,
218        dual_descriptor,
219        homo_energy: eht_result.homo_energy,
220        lumo_energy: eht_result.lumo_energy,
221        gap: eht_result.gap,
222    }
223}
224
225fn validity_notes(elements: &[u8]) -> Vec<String> {
226    let support = crate::eht::analyze_eht_support(elements);
227    let mut notes = vec![
228        "Fukui descriptors are computed from frontier-orbital atom contributions (EHT proxy), not from full finite-difference electron-addition/removal calculations.".to_string(),
229        "Interpret values comparatively within related structures; absolute values are semi-quantitative.".to_string(),
230    ];
231
232    match support.level {
233        crate::eht::SupportLevel::Supported => {
234            notes.push(
235                "Element set is in supported EHT domain; qualitative organic trend interpretation is usually reliable."
236                    .to_string(),
237            );
238        }
239        crate::eht::SupportLevel::Experimental => {
240            notes.push(
241                "Element set is in experimental EHT domain (typically transition metals); use rankings as exploratory guidance only."
242                    .to_string(),
243            );
244        }
245        crate::eht::SupportLevel::Unsupported => {
246            notes.push(
247                "Element set is outside supported EHT parameterization; descriptor reliability is low or undefined."
248                    .to_string(),
249            );
250        }
251    }
252
253    notes.extend(support.warnings);
254    notes
255}
256
257/// Compute Fukui-style local reactivity descriptors from EHT frontier orbitals.
258pub fn compute_fukui_descriptors(
259    elements: &[u8],
260    positions: &[[f64; 3]],
261    eht_result: &crate::eht::EhtResult,
262) -> FukuiDescriptors {
263    let frontier = compute_frontier_descriptors(elements, positions, eht_result);
264    let f_plus = frontier.lumo_atom_contributions.clone();
265    let f_minus = frontier.homo_atom_contributions.clone();
266    let f_radical: Vec<f64> = f_plus
267        .iter()
268        .zip(f_minus.iter())
269        .map(|(fp, fm)| 0.5 * (fp + fm))
270        .collect();
271    let dual_descriptor: Vec<f64> = f_plus
272        .iter()
273        .zip(f_minus.iter())
274        .map(|(fp, fm)| fp - fm)
275        .collect();
276
277    let condensed: Vec<CondensedFukuiAtom> = (0..elements.len())
278        .map(|idx| CondensedFukuiAtom {
279            atom_index: idx,
280            f_plus: f_plus[idx],
281            f_minus: f_minus[idx],
282            f_radical: f_radical[idx],
283            dual_descriptor: dual_descriptor[idx],
284        })
285        .collect();
286
287    FukuiDescriptors {
288        num_atoms: elements.len(),
289        f_plus,
290        f_minus,
291        f_radical,
292        dual_descriptor,
293        condensed,
294        homo_energy: frontier.homo_energy,
295        lumo_energy: frontier.lumo_energy,
296        gap: frontier.gap,
297        validity_notes: validity_notes(elements),
298    }
299}
300
301fn sorted_scores(mut scores: Vec<ReactivitySiteScore>) -> Vec<ReactivitySiteScore> {
302    scores.sort_by(|a, b| {
303        b.score
304            .partial_cmp(&a.score)
305            .unwrap_or(std::cmp::Ordering::Equal)
306    });
307    scores
308}
309
310/// Build empirical local-reactivity rankings from Fukui descriptors and Mulliken charges.
311pub fn rank_reactivity_sites(
312    fukui: &FukuiDescriptors,
313    mulliken_charges: &[f64],
314) -> ReactivityRanking {
315    let n = fukui.num_atoms.min(mulliken_charges.len());
316
317    let mut nucleophilic_attack_sites = Vec::with_capacity(n);
318    let mut electrophilic_attack_sites = Vec::with_capacity(n);
319    let mut radical_attack_sites = Vec::with_capacity(n);
320
321    for i in 0..n {
322        let q = mulliken_charges[i];
323        let fp = fukui.f_plus[i];
324        let fm = fukui.f_minus[i];
325        let f0 = fukui.f_radical[i];
326
327        // Positive charge reinforces nucleophilic attack susceptibility.
328        let nuc_score = fp + 0.25 * q.max(0.0);
329        // Negative charge reinforces electrophilic attack susceptibility.
330        let elec_score = fm + 0.25 * (-q).max(0.0);
331        let rad_score = f0 + 0.1 * q.abs();
332
333        nucleophilic_attack_sites.push(ReactivitySiteScore {
334            atom_index: i,
335            score: nuc_score,
336        });
337        electrophilic_attack_sites.push(ReactivitySiteScore {
338            atom_index: i,
339            score: elec_score,
340        });
341        radical_attack_sites.push(ReactivitySiteScore {
342            atom_index: i,
343            score: rad_score,
344        });
345    }
346
347    ReactivityRanking {
348        nucleophilic_attack_sites: sorted_scores(nucleophilic_attack_sites),
349        electrophilic_attack_sites: sorted_scores(electrophilic_attack_sites),
350        radical_attack_sites: sorted_scores(radical_attack_sites),
351        notes: vec![
352            "Scores are empirical composites of condensed Fukui terms and Mulliken charge bias.".to_string(),
353            "Use ranking order for exploratory prioritization; values are not calibrated kinetic barriers.".to_string(),
354        ],
355    }
356}
357
358fn gaussian(x: f64, mu: f64, sigma: f64) -> f64 {
359    let s = sigma.max(1e-6);
360    let norm = 1.0 / (s * (2.0 * std::f64::consts::PI).sqrt());
361    let dx = x - mu;
362    norm * (-0.5 * dx * dx / (s * s)).exp()
363}
364
365/// Build an exploratory UV-Vis-like spectrum from low-cost EHT occupied→virtual transitions.
366pub fn compute_uv_vis_like_spectrum(
367    eht_result: &crate::eht::EhtResult,
368    sigma: f64,
369    e_min: f64,
370    e_max: f64,
371    n_points: usize,
372) -> UvVisSpectrum {
373    let n_points = n_points.max(2);
374    let span = (e_max - e_min).max(1e-6);
375    let step = span / (n_points as f64 - 1.0);
376    let energies_ev: Vec<f64> = (0..n_points).map(|i| e_min + step * i as f64).collect();
377    let mut intensities = vec![0.0; n_points];
378
379    let mut peaks = Vec::new();
380    for occ in 0..=eht_result.homo_index {
381        for virt in eht_result.lumo_index..eht_result.energies.len() {
382            let delta_e = eht_result.energies[virt] - eht_result.energies[occ];
383            if delta_e <= 1e-6 {
384                continue;
385            }
386
387            let mut overlap_strength = 0.0;
388            for ao in 0..eht_result.coefficients.len() {
389                overlap_strength +=
390                    (eht_result.coefficients[ao][occ] * eht_result.coefficients[ao][virt]).abs();
391            }
392
393            let intensity = overlap_strength * overlap_strength;
394            if intensity <= 1e-12 {
395                continue;
396            }
397
398            if peaks.len() < 24 {
399                peaks.push(UvVisPeak {
400                    energy_ev: delta_e,
401                    wavelength_nm: 1239.841984 / delta_e,
402                    intensity,
403                    from_mo: occ,
404                    to_mo: virt,
405                });
406            }
407
408            for (idx, e) in energies_ev.iter().enumerate() {
409                intensities[idx] += intensity * gaussian(*e, delta_e, sigma);
410            }
411        }
412    }
413
414    peaks.sort_by(|a, b| {
415        b.intensity
416            .partial_cmp(&a.intensity)
417            .unwrap_or(std::cmp::Ordering::Equal)
418    });
419
420    UvVisSpectrum {
421        energies_ev,
422        intensities,
423        peaks,
424        sigma,
425        notes: vec![
426            "Exploratory UV-Vis-like spectrum from EHT MO energy differences and coefficient-overlap intensity proxy.".to_string(),
427            "This is a qualitative visualization aid, not a calibrated excited-state method (no CI/TDDFT).".to_string(),
428        ],
429    }
430}
431
432fn has_bond_order(
433    mol: &crate::graph::Molecule,
434    a: usize,
435    b: usize,
436    order: crate::graph::BondOrder,
437) -> bool {
438    let ia = petgraph::graph::NodeIndex::new(a);
439    let ib = petgraph::graph::NodeIndex::new(b);
440    if let Some(edge_idx) = mol.graph.find_edge(ia, ib) {
441        return mol.graph[edge_idx].order == order;
442    }
443    false
444}
445
446fn is_carboxylic_acid_oxygen(mol: &crate::graph::Molecule, atom_idx: usize) -> bool {
447    let idx = petgraph::graph::NodeIndex::new(atom_idx);
448    let atom = &mol.graph[idx];
449    if atom.element != 8 {
450        return false;
451    }
452
453    let neighbors: Vec<_> = mol.graph.neighbors(idx).collect();
454    let has_h = neighbors.iter().any(|n| mol.graph[*n].element == 1);
455    if !has_h {
456        return false;
457    }
458
459    let carbon_neighbor = neighbors.iter().find(|n| mol.graph[**n].element == 6);
460    if let Some(c_idx) = carbon_neighbor {
461        let c_neighbors: Vec<_> = mol.graph.neighbors(*c_idx).collect();
462        let carbonyl_o_count = c_neighbors
463            .iter()
464            .filter(|n| mol.graph[**n].element == 8)
465            .filter(|n| {
466                has_bond_order(
467                    mol,
468                    c_idx.index(),
469                    n.index(),
470                    crate::graph::BondOrder::Double,
471                )
472            })
473            .count();
474        return carbonyl_o_count >= 1;
475    }
476
477    false
478}
479
480fn is_phenol_oxygen(mol: &crate::graph::Molecule, atom_idx: usize) -> bool {
481    let idx = petgraph::graph::NodeIndex::new(atom_idx);
482    let atom = &mol.graph[idx];
483    if atom.element != 8 {
484        return false;
485    }
486    let neighbors: Vec<_> = mol.graph.neighbors(idx).collect();
487    let has_h = neighbors.iter().any(|n| mol.graph[*n].element == 1);
488    if !has_h {
489        return false;
490    }
491    neighbors.iter().any(|n| {
492        if mol.graph[*n].element != 6 {
493            return false;
494        }
495        mol.graph
496            .edges(*n)
497            .any(|e| matches!(e.weight().order, crate::graph::BondOrder::Aromatic))
498    })
499}
500
501fn is_thiol_sulfur(mol: &crate::graph::Molecule, atom_idx: usize) -> bool {
502    let idx = petgraph::graph::NodeIndex::new(atom_idx);
503    let atom = &mol.graph[idx];
504    if atom.element != 16 {
505        return false;
506    }
507    mol.graph.neighbors(idx).any(|n| mol.graph[n].element == 1)
508}
509
510fn is_neutral_amine_nitrogen(mol: &crate::graph::Molecule, atom_idx: usize) -> bool {
511    let idx = petgraph::graph::NodeIndex::new(atom_idx);
512    let atom = &mol.graph[idx];
513    if atom.element != 7 || atom.formal_charge != 0 {
514        return false;
515    }
516    if !matches!(atom.hybridization, crate::graph::Hybridization::SP3) {
517        return false;
518    }
519    !mol.graph
520        .edges(idx)
521        .any(|e| matches!(e.weight().order, crate::graph::BondOrder::Aromatic))
522}
523
524fn is_aromatic_nitrogen(mol: &crate::graph::Molecule, atom_idx: usize) -> bool {
525    let idx = petgraph::graph::NodeIndex::new(atom_idx);
526    let atom = &mol.graph[idx];
527    if atom.element != 7 {
528        return false;
529    }
530    mol.graph
531        .edges(idx)
532        .any(|e| matches!(e.weight().order, crate::graph::BondOrder::Aromatic))
533}
534
535/// Estimate acidic/basic pKa sites from graph environment and Gasteiger charges.
536pub fn estimate_empirical_pka(mol: &crate::graph::Molecule, charges: &[f64]) -> EmpiricalPkaResult {
537    let n = mol.graph.node_count().min(charges.len());
538    let mut acidic_sites = Vec::new();
539    let mut basic_sites = Vec::new();
540
541    for atom_idx in 0..n {
542        let idx = petgraph::graph::NodeIndex::new(atom_idx);
543        let atom = &mol.graph[idx];
544        let q = charges[atom_idx];
545
546        if is_carboxylic_acid_oxygen(mol, atom_idx) {
547            acidic_sites.push(EmpiricalPkaSite {
548                atom_index: atom_idx,
549                site_type: "acidic".to_string(),
550                environment: "carboxylic_acid_oxygen".to_string(),
551                estimated_pka: (4.5 - 2.0 * q).clamp(-1.0, 14.0),
552                confidence: 0.82,
553            });
554        } else if is_phenol_oxygen(mol, atom_idx) {
555            acidic_sites.push(EmpiricalPkaSite {
556                atom_index: atom_idx,
557                site_type: "acidic".to_string(),
558                environment: "phenol_oxygen".to_string(),
559                estimated_pka: (10.0 - 1.5 * q).clamp(2.0, 16.0),
560                confidence: 0.68,
561            });
562        } else if is_thiol_sulfur(mol, atom_idx) {
563            acidic_sites.push(EmpiricalPkaSite {
564                atom_index: atom_idx,
565                site_type: "acidic".to_string(),
566                environment: "thiol_sulfur".to_string(),
567                estimated_pka: (10.5 - 1.2 * q).clamp(2.0, 16.0),
568                confidence: 0.64,
569            });
570        } else if atom.element == 7 && atom.formal_charge > 0 {
571            acidic_sites.push(EmpiricalPkaSite {
572                atom_index: atom_idx,
573                site_type: "acidic".to_string(),
574                environment: "ammonium_like".to_string(),
575                estimated_pka: (9.7 - 1.0 * q).clamp(4.0, 14.0),
576                confidence: 0.62,
577            });
578        }
579
580        if is_neutral_amine_nitrogen(mol, atom_idx) {
581            let h_count = mol
582                .graph
583                .neighbors(idx)
584                .filter(|n| mol.graph[*n].element == 1)
585                .count();
586            let base_pka = if h_count >= 2 {
587                10.8
588            } else if h_count == 1 {
589                10.4
590            } else {
591                9.8
592            };
593            basic_sites.push(EmpiricalPkaSite {
594                atom_index: atom_idx,
595                site_type: "basic".to_string(),
596                environment: "aliphatic_amine".to_string(),
597                estimated_pka: (base_pka - 2.5 * q).clamp(2.0, 14.5),
598                confidence: 0.75,
599            });
600        } else if is_aromatic_nitrogen(mol, atom_idx) && atom.formal_charge <= 0 {
601            basic_sites.push(EmpiricalPkaSite {
602                atom_index: atom_idx,
603                site_type: "basic".to_string(),
604                environment: "aromatic_nitrogen".to_string(),
605                estimated_pka: (5.2 - 1.8 * q).clamp(-1.0, 10.0),
606                confidence: 0.6,
607            });
608        }
609    }
610
611    acidic_sites.sort_by(|a, b| {
612        a.estimated_pka
613            .partial_cmp(&b.estimated_pka)
614            .unwrap_or(std::cmp::Ordering::Equal)
615    });
616    basic_sites.sort_by(|a, b| {
617        b.estimated_pka
618            .partial_cmp(&a.estimated_pka)
619            .unwrap_or(std::cmp::Ordering::Equal)
620    });
621
622    EmpiricalPkaResult {
623        acidic_sites,
624        basic_sites,
625        notes: vec![
626            "Empirical pKa estimates use simple graph environments plus Gasteiger-charge adjustments; values are coarse screening hints, not publication-grade predictions.".to_string(),
627            "Best use is relative ranking within related congeneric series under similar conditions.".to_string(),
628        ],
629    }
630}
631
632/// Apply a lightweight aromatic stabilization correction on top of raw UFF energy.
633pub fn apply_aromatic_uff_correction(
634    mol: &crate::graph::Molecule,
635    raw_energy_kcal_mol: f64,
636) -> UffHeuristicEnergy {
637    let aromatic_bond_count = mol
638        .graph
639        .edge_references()
640        .filter(|e| matches!(e.weight().order, crate::graph::BondOrder::Aromatic))
641        .count();
642    let aromatic_stabilization_kcal_mol = -0.08 * aromatic_bond_count as f64;
643
644    UffHeuristicEnergy {
645        raw_energy_kcal_mol,
646        aromatic_stabilization_kcal_mol,
647        corrected_energy_kcal_mol: raw_energy_kcal_mol + aromatic_stabilization_kcal_mol,
648        aromatic_bond_count,
649        notes: vec![
650            "Aromatic correction is an empirical post-UFF heuristic tied to aromatic bond count and should be used for ranking guidance only.".to_string(),
651            "Raw UFF and corrected values are both reported so downstream workflows can choose their own policy.".to_string(),
652        ],
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn test_h2_frontier_contributions_are_symmetric() {
662        let elements = [1u8, 1];
663        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
664        let eht = crate::eht::solve_eht(&elements, &positions, None).unwrap();
665        let descriptors = compute_frontier_descriptors(&elements, &positions, &eht);
666
667        assert!(
668            (descriptors.homo_atom_contributions[0] - descriptors.homo_atom_contributions[1]).abs()
669                < 1e-6
670        );
671        assert!(
672            (descriptors.lumo_atom_contributions[0] - descriptors.lumo_atom_contributions[1]).abs()
673                < 1e-6
674        );
675    }
676
677    #[test]
678    fn test_frontier_contributions_are_normalized() {
679        let elements = [8u8, 1, 1];
680        let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
681        let eht = crate::eht::solve_eht(&elements, &positions, None).unwrap();
682        let descriptors = compute_frontier_descriptors(&elements, &positions, &eht);
683
684        let homo_sum: f64 = descriptors.homo_atom_contributions.iter().sum();
685        let lumo_sum: f64 = descriptors.lumo_atom_contributions.iter().sum();
686        assert!((homo_sum - 1.0).abs() < 1e-6);
687        assert!((lumo_sum - 1.0).abs() < 1e-6);
688    }
689
690    #[test]
691    fn test_fukui_descriptors_are_consistent_and_normalized() {
692        let elements = [8u8, 1, 1];
693        let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
694        let eht = crate::eht::solve_eht(&elements, &positions, None).unwrap();
695        let fukui = compute_fukui_descriptors(&elements, &positions, &eht);
696
697        let f_plus_sum: f64 = fukui.f_plus.iter().sum();
698        let f_minus_sum: f64 = fukui.f_minus.iter().sum();
699        let f0_sum: f64 = fukui.f_radical.iter().sum();
700        assert!((f_plus_sum - 1.0).abs() < 1e-6);
701        assert!((f_minus_sum - 1.0).abs() < 1e-6);
702        assert!((f0_sum - 1.0).abs() < 1e-6);
703        assert_eq!(fukui.condensed.len(), elements.len());
704    }
705
706    #[test]
707    fn test_uv_vis_like_spectrum_has_requested_grid() {
708        let elements = [6u8, 6, 1, 1, 1, 1];
709        let positions = [
710            [0.0, 0.0, 0.0],
711            [1.34, 0.0, 0.0],
712            [-0.6, 0.92, 0.0],
713            [-0.6, -0.92, 0.0],
714            [1.94, 0.92, 0.0],
715            [1.94, -0.92, 0.0],
716        ];
717        let eht = crate::eht::solve_eht(&elements, &positions, None).unwrap();
718        let spec = compute_uv_vis_like_spectrum(&eht, 0.2, 0.5, 8.0, 300);
719        assert_eq!(spec.energies_ev.len(), 300);
720        assert_eq!(spec.intensities.len(), 300);
721    }
722
723    #[test]
724    fn test_empirical_pka_detects_carboxylic_acid_site() {
725        let mol = crate::graph::Molecule::from_smiles("CC(=O)O").unwrap();
726        let charges = crate::compute_charges("CC(=O)O").unwrap().charges;
727        let result = estimate_empirical_pka(&mol, &charges);
728
729        assert!(!result.acidic_sites.is_empty());
730        assert!(result
731            .acidic_sites
732            .iter()
733            .any(|site| site.environment == "carboxylic_acid_oxygen"));
734    }
735
736    #[test]
737    fn test_aromatic_uff_correction_is_negative_for_benzene() {
738        let mol = crate::graph::Molecule::from_smiles("c1ccccc1").unwrap();
739        let result = apply_aromatic_uff_correction(&mol, 10.0);
740        assert!(result.aromatic_bond_count >= 6);
741        assert!(result.aromatic_stabilization_kcal_mol < 0.0);
742        assert!(result.corrected_energy_kcal_mol < result.raw_energy_kcal_mol);
743    }
744}