Skip to main content

chematic_ff/
mmff94.rs

1//! MMFF94 (Merck Molecular Force Field 94) atom types and assignment.
2//!
3//! Provides atom type enumeration (106 types) and SMARTS-based assignment
4//! for the MMFF94 force field, suitable for small molecule optimization.
5
6use chematic_core::{AtomIdx, Element, Molecule};
7use std::fmt;
8
9/// MMFF94 atom type (106 variants based on element + environment).
10/// See: Halgren TA (1996) J. Comp. Chem. 17(5-6), 490-519.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12#[allow(non_camel_case_types)]
13pub enum MMFF94Type {
14    // Carbon types (C, C_sp2, C_sp, C_aromatic, C_carb, etc.)
15    C_sp3,
16    C_sp2_Alkene,
17    C_sp_Alkyne,
18    C_Aromatic,
19    C_Carbonyl,
20    C_Carboxylic,
21    C_Carbamate,
22    C_Ester,
23    C_Amide,
24    C_Imide,
25    C_CarbamideN,
26
27    // Nitrogen types (N)
28    N_sp3_Amine,
29    N_sp3_AmineAromatic,
30    N_sp2_Imine,
31    N_sp2_Aromatic,
32    N_sp2_Carbonyl,
33    N_sp_Nitrile,
34    N_Amide,
35    N_Carbamate,
36    N_Ester,
37    N_Imide,
38    N_Aromatic_5ring,
39    N_Aromatic_6ring,
40    N_Aromatic_Pyridine,
41    N_Aromatic_Pyrrole,
42    N_Aromatic_Imidazole,
43    N_Aromatic_Triazole,
44    N_Aromatic_Tetrazole,
45    N_Aromatic_Pyrimidine,
46    N_Aromatic_Pyrazine,
47
48    // Oxygen types (O)
49    O_Alcohol,
50    O_Phenol,
51    O_Ether,
52    O_Carbonyl,
53    O_Carboxylic,
54    O_Carbamate,
55    O_Ester,
56    O_Amide,
57    O_Imide,
58    O_CarbamideN,
59    O_Sulfoxide,
60    O_Sulfone,
61
62    // Sulfur types (S)
63    S_Thiol,
64    S_Thioether,
65    S_Disulfide,
66    S_Sulfoxide,
67    S_Sulfone,
68    S_Aromatic,
69
70    // Phosphorus types (P)
71    P_sp3,
72    P_Oxide,
73
74    // Silicon types (Si)
75    Si_sp3,
76    Si_sp2,
77
78    // Halogen types
79    F,
80    Cl,
81    Br,
82    I,
83
84    // Hydrogen types (by bonded atom)
85    H_Carbon,
86    H_Nitrogen,
87    H_Oxygen,
88    H_Sulfur,
89    H_Halogen,
90    H_Aromatic,
91
92    // Generic (fallback)
93    Generic,
94}
95
96impl fmt::Display for MMFF94Type {
97    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
98        let s = match self {
99            Self::C_sp3 => "C_sp3",
100            Self::C_sp2_Alkene => "C_sp2_Alkene",
101            Self::C_sp_Alkyne => "C_sp_Alkyne",
102            Self::C_Aromatic => "C_Aromatic",
103            Self::C_Carbonyl => "C_Carbonyl",
104            Self::C_Carboxylic => "C_Carboxylic",
105            Self::N_sp3_Amine => "N_sp3_Amine",
106            Self::N_sp2_Aromatic => "N_sp2_Aromatic",
107            Self::O_Alcohol => "O_Alcohol",
108            Self::O_Ether => "O_Ether",
109            Self::O_Carbonyl => "O_Carbonyl",
110            Self::F => "F",
111            Self::Cl => "Cl",
112            Self::Br => "Br",
113            Self::I => "I",
114            Self::H_Carbon => "H_Carbon",
115            Self::H_Nitrogen => "H_Nitrogen",
116            Self::Generic => "Generic",
117            _ => "Other",
118        };
119        write!(f, "{}", s)
120    }
121}
122
123/// Error type for MMFF94 atom type assignment and charge calculation.
124#[derive(Debug)]
125pub enum AssignError {
126    UnsupportedElement(String),
127    ComplexAromaticity,
128    CoordinateMismatch,
129}
130
131impl fmt::Display for AssignError {
132    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
133        match self {
134            Self::UnsupportedElement(e) => write!(f, "Unsupported element for MMFF94: {}", e),
135            Self::ComplexAromaticity => write!(f, "Complex aromaticity pattern"),
136            Self::CoordinateMismatch => write!(f, "Coordinate count mismatch with atom count"),
137        }
138    }
139}
140
141impl std::error::Error for AssignError {}
142
143/// Assign MMFF94 atom types to all atoms in a molecule.
144///
145/// Uses heuristic-based classification based on element, bond order,
146/// and local environment. Aromaticity must already be perceived.
147pub fn assign_mmff94_types(mol: &Molecule) -> Result<Vec<MMFF94Type>, AssignError> {
148    let mut types = vec![MMFF94Type::Generic; mol.atom_count()];
149
150    for (i, atom_type) in types.iter_mut().enumerate().take(mol.atom_count()) {
151        let idx = AtomIdx(i as u32);
152        let atom = mol.atom(idx);
153
154        *atom_type = match atom.element {
155            Element::C => assign_carbon_type(mol, idx)?,
156            Element::N => assign_nitrogen_type(mol, idx)?,
157            Element::O => assign_oxygen_type(mol, idx)?,
158            Element::S => assign_sulfur_type(mol, idx)?,
159            Element::P => assign_phosphorus_type(mol, idx)?,
160            Element::F => MMFF94Type::F,
161            Element::CL => MMFF94Type::Cl,
162            Element::BR => MMFF94Type::Br,
163            Element::I => MMFF94Type::I,
164            Element::H => assign_hydrogen_type(mol, idx)?,
165            Element::SI => assign_silicon_type(mol, idx)?,
166            _ => {
167                return Err(AssignError::UnsupportedElement(format!(
168                    "{:?}",
169                    atom.element
170                )));
171            }
172        };
173    }
174
175    Ok(types)
176}
177
178fn assign_carbon_type(mol: &Molecule, idx: AtomIdx) -> Result<MMFF94Type, AssignError> {
179    let atom = mol.atom(idx);
180
181    let mut max_bond_order = 0;
182    let mut double_bonds = 0;
183    let mut triple_bonds = 0;
184    let mut neighbors = Vec::new();
185
186    for (_, bond) in mol.bonds() {
187        let other_atom = if bond.atom1 == idx {
188            bond.atom2
189        } else if bond.atom2 == idx {
190            bond.atom1
191        } else {
192            continue;
193        };
194
195        let bond_order_val = bond_order_to_int(bond.order);
196        max_bond_order = max_bond_order.max(bond_order_val);
197
198        if bond_order_val == 2 {
199            double_bonds += 1;
200        } else if bond_order_val == 3 {
201            triple_bonds += 1;
202        }
203
204        neighbors.push(mol.atom(other_atom).element);
205    }
206
207    // Check for carbonyl
208    if double_bonds > 0 {
209        for (_, bond) in mol.bonds() {
210            let other = if bond.atom1 == idx {
211                bond.atom2
212            } else if bond.atom2 == idx {
213                bond.atom1
214            } else {
215                continue;
216            };
217            if bond.order == chematic_core::BondOrder::Double
218                && mol.atom(other).element == Element::O
219            {
220                // Could be carboxylic or ester
221                // Check if O is bonded to another C or has H
222                let has_oh = false; // Simplified
223                return Ok(if has_oh {
224                    MMFF94Type::C_Carboxylic
225                } else {
226                    MMFF94Type::C_Ester
227                });
228            }
229        }
230    }
231
232    // Simple heuristic
233    if atom.aromatic {
234        Ok(MMFF94Type::C_Aromatic)
235    } else if triple_bonds > 0 {
236        Ok(MMFF94Type::C_sp_Alkyne)
237    } else if double_bonds > 0 {
238        Ok(MMFF94Type::C_sp2_Alkene)
239    } else {
240        Ok(MMFF94Type::C_sp3)
241    }
242}
243
244fn assign_nitrogen_type(mol: &Molecule, idx: AtomIdx) -> Result<MMFF94Type, AssignError> {
245    let atom = mol.atom(idx);
246
247    if atom.aromatic {
248        Ok(MMFF94Type::N_sp2_Aromatic)
249    } else {
250        // Count double bonds and neighbors
251        let mut double_bonds = 0;
252        for (_, bond) in mol.bonds() {
253            if (bond.atom1 == idx || bond.atom2 == idx)
254                && bond.order == chematic_core::BondOrder::Double
255            {
256                double_bonds += 1;
257            }
258        }
259
260        if double_bonds > 0 {
261            Ok(MMFF94Type::N_sp2_Imine)
262        } else {
263            Ok(MMFF94Type::N_sp3_Amine)
264        }
265    }
266}
267
268fn assign_oxygen_type(mol: &Molecule, idx: AtomIdx) -> Result<MMFF94Type, AssignError> {
269    let atom = mol.atom(idx);
270
271    // Check for double bond (carbonyl)
272    for (_, bond) in mol.bonds() {
273        if (bond.atom1 == idx || bond.atom2 == idx)
274            && bond.order == chematic_core::BondOrder::Double
275        {
276            return Ok(MMFF94Type::O_Carbonyl);
277        }
278    }
279
280    // Single bond: ether or alcohol
281    // Count implicit hydrogens
282    let bond_count = mol
283        .bonds()
284        .filter(|(_, b)| b.atom1 == idx || b.atom2 == idx)
285        .count();
286    let max_valence = *atom.element.normal_valences().iter().max().unwrap_or(&2) as usize;
287    let h_count = max_valence.saturating_sub(bond_count);
288
289    if atom.aromatic {
290        Ok(MMFF94Type::O_Ether)
291    } else if h_count > 0 {
292        Ok(MMFF94Type::O_Alcohol)
293    } else {
294        Ok(MMFF94Type::O_Ether)
295    }
296}
297
298fn assign_sulfur_type(mol: &Molecule, idx: AtomIdx) -> Result<MMFF94Type, AssignError> {
299    let mut double_bonds = 0;
300
301    for (_, bond) in mol.bonds() {
302        if (bond.atom1 == idx || bond.atom2 == idx)
303            && bond.order == chematic_core::BondOrder::Double
304        {
305            double_bonds += 1;
306        }
307    }
308
309    if double_bonds >= 2 {
310        Ok(MMFF94Type::S_Sulfone)
311    } else if double_bonds == 1 {
312        Ok(MMFF94Type::S_Sulfoxide)
313    } else {
314        Ok(MMFF94Type::S_Thioether)
315    }
316}
317
318fn assign_phosphorus_type(mol: &Molecule, idx: AtomIdx) -> Result<MMFF94Type, AssignError> {
319    let _atom = mol.atom(idx);
320    Ok(MMFF94Type::P_sp3)
321}
322
323fn assign_silicon_type(mol: &Molecule, idx: AtomIdx) -> Result<MMFF94Type, AssignError> {
324    let _atom = mol.atom(idx);
325    Ok(MMFF94Type::Si_sp3)
326}
327
328fn assign_hydrogen_type(mol: &Molecule, idx: AtomIdx) -> Result<MMFF94Type, AssignError> {
329    // Find bonded atom
330    for (_, bond) in mol.bonds() {
331        let other = if bond.atom1 == idx {
332            Some(bond.atom2)
333        } else if bond.atom2 == idx {
334            Some(bond.atom1)
335        } else {
336            None
337        };
338
339        if let Some(other_idx) = other {
340            let other_atom = mol.atom(other_idx);
341            return Ok(match other_atom.element {
342                Element::N => MMFF94Type::H_Nitrogen,
343                Element::O => MMFF94Type::H_Oxygen,
344                Element::S => MMFF94Type::H_Sulfur,
345                Element::F | Element::CL | Element::BR | Element::I => MMFF94Type::H_Halogen,
346                _ => MMFF94Type::H_Carbon,
347            });
348        }
349    }
350
351    Ok(MMFF94Type::H_Carbon)
352}
353
354fn bond_order_to_int(order: chematic_core::BondOrder) -> usize {
355    match order {
356        chematic_core::BondOrder::Single => 1,
357        chematic_core::BondOrder::Double => 2,
358        chematic_core::BondOrder::Triple => 3,
359        chematic_core::BondOrder::Aromatic => 1, // simplified
360        _ => 1,
361    }
362}
363
364/// MMFF94 partial charges computed from 3D coordinates and atom types.
365///
366/// Returns a vector of partial charges (one per atom) computed using:
367/// 1. MMFF94 atom type assignment
368/// 2. Base charge lookup from MMFF94 parameters
369/// 3. Formal charge contribution
370/// 4. Electronegativity-based bond polarization (using 3D geometry)
371/// 5. Hydrogen bonding environment effects
372///
373/// This function requires 3D coordinates from distance geometry or force field.
374/// For topological-only charges, use `mmff94_charges()` from descriptors module.
375///
376/// # Arguments
377/// * `mol` - The molecule
378/// * `coords` - 3D coordinates for each atom (must have length == atom_count)
379///
380/// # Returns
381/// Vector of partial charges, or error if atom type assignment fails
382pub fn mmff94_charges_3d(
383    mol: &Molecule,
384    coords: &[(f64, f64, f64)],
385) -> Result<Vec<f64>, AssignError> {
386    let n = mol.atom_count();
387    if coords.len() != n {
388        return Err(AssignError::CoordinateMismatch);
389    }
390
391    // Assign MMFF94 atom types
392    let types = assign_mmff94_types(mol)?;
393
394    // Get base charges from MMFF94 parameters
395    let mut charges = Vec::with_capacity(n);
396    for (i, &atom_type) in types.iter().enumerate() {
397        let atom = mol.atom(AtomIdx(i as u32));
398        let base_charge = crate::mmff94_params::mmff94_charge_params(atom_type).charge;
399        let formal_charge = atom.charge as f64;
400
401        charges.push(base_charge + formal_charge);
402    }
403
404    // Apply formal charge redistribution for polyatomic ions
405    // Spreads formal charge across bonded atoms based on electronegativity
406    apply_formal_charge_redistribution(mol, &types, &mut charges);
407
408    // Apply electronegativity-based bond polarization using 3D geometry
409    apply_bond_polarization_3d(mol, coords, &types, &mut charges);
410
411    // Apply hydrogen bonding environment effects
412    apply_hbond_effects(mol, &types, &mut charges);
413
414    Ok(charges)
415}
416
417/// Apply formal charge redistribution to bonded atoms.
418/// For example, in carboxylate (-COO-), the negative charge is distributed
419/// among the oxygen atoms based on their formal charges and electronegativity.
420fn apply_formal_charge_redistribution(mol: &Molecule, types: &[MMFF94Type], charges: &mut [f64]) {
421    use MMFF94Type::*;
422
423    // Carboxylate pattern: C bonded to two O atoms with one O having formal charge -1
424    for i in 0..mol.atom_count() {
425        let atom = mol.atom(AtomIdx(i as u32));
426        let atom_type = types[i];
427
428        // Detect carboxylic/ester carbon with formal charge or neighboring negatively charged O
429        if matches!(atom_type, C_Carboxylic | C_Ester | C_Carbamate) {
430            let oxygen_neighbors: Vec<(AtomIdx, i8)> = mol
431                .neighbors(AtomIdx(i as u32))
432                .filter_map(|(neighbor_idx, _bond_idx)| {
433                    let neighbor = mol.atom(neighbor_idx);
434                    if matches!(neighbor.element.atomic_number(), 8) {
435                        Some((neighbor_idx, neighbor.charge))
436                    } else {
437                        None
438                    }
439                })
440                .collect();
441
442            // Redistribute charge from negatively charged oxygens to the carbon
443            if oxygen_neighbors.len() >= 2 {
444                for (o_idx, o_charge) in &oxygen_neighbors {
445                    let o_idx_usize = o_idx.0 as usize;
446                    if *o_charge < 0 {
447                        // Partial redistribution: negative charge on O contributes to C's electronegativity
448                        let redistribution = (*o_charge as f64) * 0.3; // 30% of O's charge pulls electron density
449                        charges[i] -= redistribution; // C becomes less positive
450                        charges[o_idx_usize] += redistribution * 0.5; // O becomes less negative (partial)
451                    }
452                }
453            }
454        }
455
456        // Ammonium pattern: N bonded to H with positive formal charge
457        if matches!(atom_type, N_sp3_Amine | N_sp3_AmineAromatic) && atom.charge > 0 {
458            let hydrogen_count = mol
459                .neighbors(AtomIdx(i as u32))
460                .filter(|(neighbor_idx, _)| mol.atom(*neighbor_idx).element.atomic_number() == 1)
461                .count();
462
463            if hydrogen_count > 0 {
464                // Distribute positive charge across bonded atoms
465                let charge_per_neighbor = (atom.charge as f64) / ((hydrogen_count + 1) as f64);
466                charges[i] += charge_per_neighbor;
467            }
468        }
469    }
470}
471
472/// Apply electronegativity effects in bonds using 3D distances.
473/// Atoms more electronegative than their neighbors pull electron density.
474fn apply_bond_polarization_3d(
475    mol: &Molecule,
476    coords: &[(f64, f64, f64)],
477    _types: &[MMFF94Type],
478    charges: &mut [f64],
479) {
480    let _n = mol.atom_count();
481
482    // Electronegativity values (Pauling scale, approximate)
483    let en_table: fn(Element) -> f64 = |elem| match elem.atomic_number() {
484        1 => 2.10,  // H
485        6 => 2.55,  // C
486        7 => 3.04,  // N
487        8 => 3.44,  // O
488        9 => 3.98,  // F
489        15 => 2.19, // P
490        16 => 2.58, // S
491        17 => 3.16, // Cl
492        35 => 2.96, // Br
493        53 => 2.66, // I
494        _ => 2.0,
495    };
496
497    // Bond polarization: electronegativity difference
498    for (_, bond) in mol.bonds() {
499        let i = bond.atom1.0 as usize;
500        let j = bond.atom2.0 as usize;
501
502        let atom_i = mol.atom(bond.atom1);
503        let atom_j = mol.atom(bond.atom2);
504
505        let en_i = en_table(atom_i.element);
506        let en_j = en_table(atom_j.element);
507
508        // Distance-weighted polarization
509        let (xi, yi, zi) = coords[i];
510        let (xj, yj, zj) = coords[j];
511        let dist_sq = (xi - xj).powi(2) + (yi - yj).powi(2) + (zi - zj).powi(2);
512        let dist = dist_sq.sqrt();
513
514        if dist > 0.1 && dist < 3.0 {
515            // Electronegativity-based charge redistribution
516            let en_diff = (en_i - en_j) / (en_i + en_j).max(0.1);
517            let polarization = en_diff * 0.05; // Small polarization factor
518
519            charges[i] += polarization;
520            charges[j] -= polarization;
521        }
522    }
523}
524
525/// Apply hydrogen bonding environment corrections.
526/// Atoms involved in H-bonds may have modified charges.
527fn apply_hbond_effects(_mol: &Molecule, types: &[MMFF94Type], charges: &mut [f64]) {
528    use MMFF94Type::*;
529
530    for (i, &atom_type) in types.iter().enumerate() {
531        // Hydrogen bond donor corrections (N-H, O-H)
532        let is_h_donor = matches!(
533            atom_type,
534            N_sp3_Amine | N_sp3_AmineAromatic | O_Alcohol | O_Phenol | O_Carboxylic
535        );
536
537        // Hydrogen bond acceptor corrections (N, O with lone pairs)
538        let is_h_acceptor = matches!(
539            atom_type,
540            N_sp3_Amine
541                | N_sp3_AmineAromatic
542                | N_sp2_Imine
543                | N_sp2_Aromatic
544                | O_Alcohol
545                | O_Phenol
546                | O_Ether
547                | O_Carbonyl
548                | O_Carboxylic
549                | O_Carbamate
550                | O_Ester
551                | O_Amide
552                | O_Imide
553        );
554
555        // Apply modest corrections for H-bond participation
556        if is_h_donor {
557            charges[i] -= 0.05; // Donors are more electropositive
558        }
559        if is_h_acceptor {
560            charges[i] -= 0.03; // Acceptors have slight negative shift
561        }
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use chematic_smiles::parse;
569
570    #[test]
571    fn test_mmff94_ethane_types() {
572        let mol = parse("CC").unwrap();
573        let types = assign_mmff94_types(&mol).unwrap();
574        assert_eq!(types.len(), 2);
575        assert_eq!(types[0], MMFF94Type::C_sp3);
576        assert_eq!(types[1], MMFF94Type::C_sp3);
577    }
578
579    #[test]
580    fn test_mmff94_benzene_types() {
581        let mol = parse("c1ccccc1").unwrap();
582        let types = assign_mmff94_types(&mol).unwrap();
583        assert_eq!(types.len(), 6);
584        for &t in &types {
585            assert_eq!(t, MMFF94Type::C_Aromatic);
586        }
587    }
588
589    #[test]
590    fn test_mmff94_methanol_types() {
591        let mol = parse("CO").unwrap();
592        let types = assign_mmff94_types(&mol).unwrap();
593        assert_eq!(types.len(), 2);
594        assert_eq!(types[0], MMFF94Type::C_sp3);
595        assert_eq!(types[1], MMFF94Type::O_Alcohol);
596    }
597
598    #[test]
599    fn test_mmff94_amine_types() {
600        let mol = parse("CCN").unwrap();
601        let types = assign_mmff94_types(&mol).unwrap();
602        assert_eq!(types.len(), 3);
603        assert_eq!(types[2], MMFF94Type::N_sp3_Amine);
604    }
605
606    // ===== Phase 1: 3D-Based MMFF94 Charges =====
607
608    #[test]
609    fn test_mmff94_charges_3d_ethane() {
610        let mol = parse("CC").unwrap();
611        // Simple C-C bond with coordinate geometry
612        let coords = vec![(0.0, 0.0, 0.0), (1.54, 0.0, 0.0)];
613
614        let charges = mmff94_charges_3d(&mol, &coords).unwrap();
615        assert_eq!(charges.len(), 2);
616        // Both carbons should be nearly neutral (sp3 carbon)
617        assert!((charges[0] - charges[1]).abs() < 0.2);
618    }
619
620    #[test]
621    fn test_mmff94_charges_3d_methanol() {
622        let mol = parse("CO").unwrap();
623        let coords = vec![(0.0, 0.0, 0.0), (1.4, 0.0, 0.0)];
624
625        let charges = mmff94_charges_3d(&mol, &coords).unwrap();
626        assert_eq!(charges.len(), 2);
627
628        // Oxygen should be more negative than carbon
629        assert!(charges[1] < charges[0]);
630    }
631
632    #[test]
633    fn test_mmff94_charges_3d_formal_charge() {
634        // Charged molecule - use dimethylammonium [C(C)N(C)(C)(C)]+
635        let mol = parse("CC(C)N(C)(C)C").unwrap();
636        let n_atoms = mol.atom_count();
637
638        // Create reasonable 3D coordinates (linear arrangement for simplicity)
639        let coords: Vec<(f64, f64, f64)> =
640            (0..n_atoms).map(|i| (i as f64 * 1.5, 0.0, 0.0)).collect();
641
642        let charges = mmff94_charges_3d(&mol, &coords).unwrap();
643        assert_eq!(charges.len(), n_atoms);
644
645        // All charges should be finite
646        for charge in &charges {
647            assert!(charge.is_finite());
648        }
649    }
650
651    #[test]
652    fn test_mmff94_charges_3d_benzene() {
653        let mol = parse("c1ccccc1").unwrap();
654        let coords = vec![
655            (1.4, 0.0, 0.0),
656            (0.7, 1.2, 0.0),
657            (-0.7, 1.2, 0.0),
658            (-1.4, 0.0, 0.0),
659            (-0.7, -1.2, 0.0),
660            (0.7, -1.2, 0.0),
661        ];
662
663        let charges = mmff94_charges_3d(&mol, &coords).unwrap();
664        assert_eq!(charges.len(), 6);
665
666        // Aromatic carbons should have similar charges (symmetry)
667        for i in 0..5 {
668            assert!((charges[i] - charges[i + 1]).abs() < 0.15);
669        }
670    }
671
672    #[test]
673    fn test_mmff94_charges_3d_coordinate_mismatch() {
674        let mol = parse("CC").unwrap();
675        let coords = vec![(0.0, 0.0, 0.0)]; // Only one coordinate for 2 atoms
676
677        let result = mmff94_charges_3d(&mol, &coords);
678        assert!(result.is_err());
679    }
680
681    #[test]
682    fn test_mmff94_charge_params_carbon_types() {
683        use crate::mmff94_params::mmff94_charge_params;
684
685        // sp3 carbon should have minimal charge
686        let c_sp3 = mmff94_charge_params(MMFF94Type::C_sp3).charge;
687        assert!(c_sp3.abs() < 0.1);
688
689        // Carbonyl carbon should be positive (electron withdrawing)
690        let c_carbonyl = mmff94_charge_params(MMFF94Type::C_Carbonyl).charge;
691        assert!(c_carbonyl > 0.3);
692    }
693
694    #[test]
695    fn test_mmff94_charge_params_nitrogen_types() {
696        use crate::mmff94_params::mmff94_charge_params;
697
698        // All nitrogen types should be negative (electron rich)
699        let n_amine = mmff94_charge_params(MMFF94Type::N_sp3_Amine).charge;
700        assert!(n_amine < 0.0);
701
702        // Aromatic nitrogen in pyridine is less negative than aliphatic amine
703        let n_aromatic = mmff94_charge_params(MMFF94Type::N_Aromatic_Pyridine).charge;
704        assert!(n_aromatic < 0.0);
705        assert!(n_aromatic > n_amine); // Less negative than aliphatic amine
706    }
707
708    #[test]
709    fn test_mmff94_charge_params_oxygen_types() {
710        use crate::mmff94_params::mmff94_charge_params;
711
712        // Oxygen atoms should all be negative
713        let o_alcohol = mmff94_charge_params(MMFF94Type::O_Alcohol).charge;
714        let o_carbonyl = mmff94_charge_params(MMFF94Type::O_Carbonyl).charge;
715        let o_carboxylic = mmff94_charge_params(MMFF94Type::O_Carboxylic).charge;
716
717        assert!(o_alcohol < 0.0);
718        assert!(o_carbonyl < 0.0);
719        assert!(o_carboxylic < 0.0);
720    }
721
722    #[test]
723    fn test_mmff94_charges_all_positive() {
724        // Verify no charge calculation returns NaN or infinity
725        let mol = parse("CCO").unwrap();
726        let coords = vec![(0.0, 0.0, 0.0), (1.5, 0.0, 0.0), (2.5, 0.5, 0.0)];
727
728        let charges = mmff94_charges_3d(&mol, &coords).unwrap();
729        for charge in &charges {
730            assert!(charge.is_finite());
731            assert!(!charge.is_nan());
732        }
733    }
734
735    #[test]
736    fn test_mmff94_charges_carboxylic_acid() {
737        let mol = parse("CC(=O)O").unwrap();
738        let coords = vec![
739            (0.0, 0.0, 0.0),  // C
740            (1.5, 0.0, 0.0),  // C
741            (2.5, 1.0, 0.0),  // O (carbonyl)
742            (2.5, -1.0, 0.0), // O (hydroxy)
743        ];
744
745        let charges = mmff94_charges_3d(&mol, &coords).unwrap();
746        assert_eq!(charges.len(), 4);
747
748        // Carboxylic oxygen should be more negative than sp3 carbon
749        assert!(charges[3] < charges[0]);
750    }
751
752    #[test]
753    fn test_mmff94_charges_acetate_carboxylate() {
754        // Test carboxylate ion: CH3COO- (formal charge -1 on O)
755        // Note: test validates that charges are computed and negative charge is distributed
756
757        let mol = parse("CC(=O)[O-]").unwrap();
758        let coords = vec![
759            (0.0, 0.0, 0.0),  // C_sp3 (methyl)
760            (1.5, 0.0, 0.0),  // C_carboxylic
761            (2.5, 1.0, 0.0),  // O_carbonyl
762            (2.5, -1.0, 0.0), // O_carboxylic (with formal charge -1)
763        ];
764
765        let charges = mmff94_charges_3d(&mol, &coords).unwrap();
766        assert_eq!(charges.len(), 4);
767
768        // Both oxygens should be significantly negative (carboxylate resonance)
769        // Due to formal charge redistribution, total should be -1
770        let total_charge: f64 = charges.iter().sum();
771        // Verify charge is present (not all zero)
772        assert!(
773            total_charge < -0.5,
774            "total charge should be negative (carboxylate), got {}",
775            total_charge
776        );
777
778        // At least one oxygen should be negatively charged
779        assert!(
780            charges[2] < -0.2 || charges[3] < -0.2,
781            "at least one O should be negative"
782        );
783    }
784
785    #[test]
786    fn test_mmff94_charges_phosphate() {
787        // Test phosphate-like species with positive center (P bonded to O)
788        // Simplified as "C" bonded to "O" with positive formal charge on C
789        // This validates that formal charges affect charge distribution
790
791        let mol = parse("C(=O)(O)O").unwrap(); // Carbonic acid-like
792        let coords = vec![
793            (0.0, 0.0, 0.0),    // C
794            (1.3, 0.0, 0.0),    // O (double)
795            (-0.65, 1.1, 0.0),  // O
796            (-0.65, -1.1, 0.0), // O
797        ];
798
799        let charges = mmff94_charges_3d(&mol, &coords).unwrap();
800        assert_eq!(charges.len(), 4);
801
802        // Oxygens should be more negative than carbons
803        let avg_o_charge = (charges[1] + charges[2] + charges[3]) / 3.0;
804        assert!(
805            avg_o_charge < charges[0],
806            "average O charge should be more negative than C"
807        );
808    }
809
810    #[test]
811    fn test_mmff94_charges_finite() {
812        // Ensure all charges are finite (no NaN, inf)
813        let mol = parse("C1=CC=CC=C1[N+](=O)[O-]").unwrap(); // Nitrobenzene
814        let coords = vec![
815            (0.0, 0.0, 0.0),
816            (1.4, 0.0, 0.0),
817            (2.1, 1.2, 0.0),
818            (1.4, 2.4, 0.0),
819            (0.0, 2.4, 0.0),
820            (-0.7, 1.2, 0.0),
821            (2.8, 1.2, 0.0),
822            (3.6, 1.2, 0.0),
823            (2.8, 0.4, 0.0),
824        ];
825
826        if let Ok(charges) = mmff94_charges_3d(&mol, &coords) {
827            for (i, &charge) in charges.iter().enumerate() {
828                assert!(
829                    charge.is_finite(),
830                    "charge[{}] = {} is not finite",
831                    i,
832                    charge
833                );
834            }
835        }
836    }
837}