1use std::collections::HashSet;
8
9use chematic_core::{AtomIdx, BondOrder, Molecule};
10use chematic_ff::{
11 assign_dreiding_types, assign_mmff94_types, dreiding_angle, dreiding_bond_len, dreiding_vdw,
12 mmff94_angle_params, mmff94_bond_params, mmff94_charges_3d, mmff94_vdw_params,
13};
14
15use crate::coords::{Coords3D, Point3};
16
17const BOND_SPRING_CONSTANT: f64 = 700.0;
24
25const ANGLE_SPRING_CONSTANT: f64 = 100.0;
28
29const VDW_CUTOFF: f64 = 8.0;
32
33#[derive(Debug, Clone, Copy, Default)]
39pub enum ForceField {
40 UFF,
42 #[default]
44 DREIDING,
45 MMFF94,
47}
48
49pub struct MinimizeConfig {
51 pub max_steps: usize,
53 pub step_size: f64,
55 pub convergence: f64,
57 pub force_field: ForceField,
59}
60
61impl Default for MinimizeConfig {
62 fn default() -> Self {
63 Self {
64 max_steps: 200,
65 step_size: 0.05,
66 convergence: 1e-4,
67 force_field: ForceField::DREIDING,
68 }
69 }
70}
71
72pub fn minimize(mol: &Molecule, coords: Coords3D) -> Coords3D {
74 minimize_with_config(mol, coords, &MinimizeConfig::default())
75}
76
77pub fn minimize_uff(mol: &Molecule, coords: Coords3D) -> Coords3D {
81 minimize(mol, coords)
82}
83
84pub fn minimize_dreiding(mol: &Molecule, coords: Coords3D) -> Coords3D {
96 minimize_dreiding_with_config(mol, coords, &MinimizeConfig::default())
97}
98
99pub fn minimize_mmff94(mol: &Molecule, coords: Coords3D) -> Coords3D {
111 let config = MinimizeConfig {
112 force_field: ForceField::MMFF94,
113 ..MinimizeConfig::default()
114 };
115 minimize_with_config(mol, coords, &config)
116}
117
118fn minimize_gradient_descent<F>(
130 mol: &Molecule,
131 coords: Coords3D,
132 config: &MinimizeConfig,
133 energy_fn: F,
134) -> Coords3D
135where
136 F: Fn(&Coords3D) -> f64,
137{
138 if mol.atom_count() <= 1 {
139 return coords;
140 }
141
142 let mut c = coords;
143 let delta = 1e-4;
144
145 for _ in 0..config.max_steps {
146 let mut grad = vec![Point3::zero(); mol.atom_count()];
147 let mut max_grad = 0.0f64;
148
149 for i in 0..mol.atom_count() {
150 let idx = AtomIdx(i as u32);
151
152 grad[i].x = {
154 let orig = c.get(idx);
155 let mut p = orig;
156 p.x += delta;
157 c.set(idx, p);
158 let ep = energy_fn(&c);
159 let mut p = orig;
160 p.x -= delta;
161 c.set(idx, p);
162 let em = energy_fn(&c);
163 c.set(idx, orig);
164 (ep - em) / (2.0 * delta)
165 };
166
167 grad[i].y = {
168 let orig = c.get(idx);
169 let mut p = orig;
170 p.y += delta;
171 c.set(idx, p);
172 let ep = energy_fn(&c);
173 let mut p = orig;
174 p.y -= delta;
175 c.set(idx, p);
176 let em = energy_fn(&c);
177 c.set(idx, orig);
178 (ep - em) / (2.0 * delta)
179 };
180
181 grad[i].z = {
182 let orig = c.get(idx);
183 let mut p = orig;
184 p.z += delta;
185 c.set(idx, p);
186 let ep = energy_fn(&c);
187 let mut p = orig;
188 p.z -= delta;
189 c.set(idx, p);
190 let em = energy_fn(&c);
191 c.set(idx, orig);
192 (ep - em) / (2.0 * delta)
193 };
194
195 let gmax = grad[i].x.abs().max(grad[i].y.abs()).max(grad[i].z.abs());
196 if gmax > max_grad {
197 max_grad = gmax;
198 }
199 }
200
201 if max_grad < config.convergence {
202 break;
203 }
204
205 let scale = config.step_size / max_grad.max(1e-8);
206 for i in 0..mol.atom_count() {
207 let idx = AtomIdx(i as u32);
208 let p = c.get(idx);
209 c.set(
210 idx,
211 Point3::new(
212 p.x - scale * grad[i].x,
213 p.y - scale * grad[i].y,
214 p.z - scale * grad[i].z,
215 ),
216 );
217 }
218 }
219
220 c
221}
222
223fn minimize_mmff94_with_config(
225 mol: &Molecule,
226 coords: Coords3D,
227 config: &MinimizeConfig,
228) -> Coords3D {
229 if mol.atom_count() <= 1 {
230 return coords;
231 }
232
233 let mmff94_types = match assign_mmff94_types(mol) {
235 Ok(types) => types,
236 Err(_) => return coords, };
238
239 minimize_gradient_descent(mol, coords, config, |c| {
240 total_energy_mmff94(mol, c, &mmff94_types)
241 })
242}
243
244pub fn minimize_dreiding_with_config(
246 mol: &Molecule,
247 coords: Coords3D,
248 config: &MinimizeConfig,
249) -> Coords3D {
250 if mol.atom_count() <= 1 {
251 return coords;
252 }
253
254 let dreiding_types = assign_dreiding_types(mol);
256
257 minimize_gradient_descent(mol, coords, config, |c| {
258 total_energy_dreiding(mol, c, &dreiding_types)
259 })
260}
261
262fn total_energy_dreiding(
263 mol: &Molecule,
264 coords: &Coords3D,
265 dreiding_types: &[chematic_ff::DREIDINGType],
266) -> f64 {
267 bond_energy_dreiding(mol, coords, dreiding_types)
268 + angle_energy_dreiding(mol, coords, dreiding_types)
269 + vdw_energy_dreiding(mol, coords, dreiding_types)
270}
271
272fn bond_energy_dreiding(
273 mol: &Molecule,
274 coords: &Coords3D,
275 dreiding_types: &[chematic_ff::DREIDINGType],
276) -> f64 {
277 let mut energy = 0.0;
278 let k = BOND_SPRING_CONSTANT;
279 for (_, bond) in mol.bonds() {
280 let a1 = bond.atom1;
281 let a2 = bond.atom2;
282 let r = coords.get(a1).distance(&coords.get(a2));
283 let t1 = dreiding_types[a1.0 as usize];
284 let t2 = dreiding_types[a2.0 as usize];
285 let r0 = dreiding_bond_len(t1, t2, bond.order);
286 let dr = r - r0;
287 energy += 0.5 * k * dr * dr;
288 }
289 energy
290}
291
292fn angle_energy_dreiding(
293 mol: &Molecule,
294 coords: &Coords3D,
295 dreiding_types: &[chematic_ff::DREIDINGType],
296) -> f64 {
297 let mut energy = 0.0;
298 let k = ANGLE_SPRING_CONSTANT;
299
300 for b_idx in 0..mol.atom_count() {
301 let b = AtomIdx(b_idx as u32);
302 let neighbors: Vec<AtomIdx> = mol.neighbors(b).map(|(nb, _)| nb).collect();
303
304 if neighbors.len() < 2 {
305 continue;
306 }
307
308 let theta0 = dreiding_angle(dreiding_types[b_idx]);
309
310 for (i, &a) in neighbors.iter().enumerate() {
311 for &c in &neighbors[i + 1..] {
312 let pb = coords.get(b);
313
314 let pa = coords.get(a);
315 let pc = coords.get(c);
316
317 let va = pa.sub(&pb);
318 let vc = pc.sub(&pb);
319
320 let na = va.norm();
321 let nc = vc.norm();
322
323 if na < 1e-10 || nc < 1e-10 {
324 continue;
325 }
326
327 let cos_theta = (va.dot(&vc) / (na * nc)).clamp(-1.0, 1.0);
328 let theta = cos_theta.acos();
329 let dtheta = theta - theta0;
330 energy += 0.5 * k * dtheta * dtheta;
331 }
332 }
333 }
334
335 energy
336}
337
338fn vdw_energy_dreiding(
339 mol: &Molecule,
340 coords: &Coords3D,
341 dreiding_types: &[chematic_ff::DREIDINGType],
342) -> f64 {
343 let n = mol.atom_count();
344 let cutoff = VDW_CUTOFF;
345
346 let mut excluded: HashSet<(usize, usize)> = HashSet::new();
347
348 for (_, bond) in mol.bonds() {
349 let i = bond.atom1.0 as usize;
350 let j = bond.atom2.0 as usize;
351 excluded.insert((i.min(j), i.max(j)));
352 }
353
354 for b_idx in 0..n {
355 let b = AtomIdx(b_idx as u32);
356 let neighbors: Vec<usize> = mol.neighbors(b).map(|(nb, _)| nb.0 as usize).collect();
357 for ii in 0..neighbors.len() {
358 for jj in (ii + 1)..neighbors.len() {
359 let i = neighbors[ii];
360 let j = neighbors[jj];
361 excluded.insert((i.min(j), i.max(j)));
362 }
363 }
364 }
365
366 let mut energy = 0.0;
367 for i in 0..n {
368 for j in (i + 1)..n {
369 if excluded.contains(&(i, j)) {
370 continue;
371 }
372 let r = coords
373 .get(AtomIdx(i as u32))
374 .distance(&coords.get(AtomIdx(j as u32)));
375
376 if r < 0.01 || r >= cutoff {
377 continue;
378 }
379
380 let t_i = dreiding_types[i];
381 let t_j = dreiding_types[j];
382 let (r0_i, well_i) = dreiding_vdw(t_i);
383 let (r0_j, well_j) = dreiding_vdw(t_j);
384
385 let r0 = (r0_i + r0_j) / 2.0;
387 let well = (well_i * well_j).sqrt();
388
389 let ratio = r0 / r;
390 let ratio6 = ratio * ratio * ratio * ratio * ratio * ratio;
391 let ratio12 = ratio6 * ratio6;
392 energy += well * (ratio12 - 2.0 * ratio6);
393 }
394 }
395
396 energy
397}
398
399pub fn minimize_with_config(mol: &Molecule, coords: Coords3D, config: &MinimizeConfig) -> Coords3D {
401 if mol.atom_count() <= 1 {
402 return coords;
403 }
404
405 match config.force_field {
407 ForceField::MMFF94 => minimize_mmff94_with_config(mol, coords, config),
408 _ => {
409 minimize_generic_with_config(mol, coords, config)
411 }
412 }
413}
414
415fn minimize_generic_with_config(
416 mol: &Molecule,
417 coords: Coords3D,
418 config: &MinimizeConfig,
419) -> Coords3D {
420 minimize_gradient_descent(mol, coords, config, |c| total_energy(mol, c))
421}
422
423fn total_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
428 bond_energy(mol, coords) + angle_energy(mol, coords) + vdw_energy(mol, coords)
429}
430
431fn ideal_bond_len(sym1: &str, sym2: &str, order: BondOrder) -> f64 {
438 let (a, b) = if sym1 <= sym2 {
439 (sym1, sym2)
440 } else {
441 (sym2, sym1)
442 };
443 match (a, b, order) {
444 ("C", "C", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.540,
446 ("C", "C", BondOrder::Double) => 1.340,
447 ("C", "C", BondOrder::Triple) => 1.204,
448 ("C", "C", BondOrder::Aromatic) => 1.395,
449 ("C", "H", _) => 1.090,
451 ("C", "N", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.469,
453 ("C", "N", BondOrder::Double) => 1.279,
454 ("C", "N", BondOrder::Triple) => 1.158,
455 ("C", "N", BondOrder::Aromatic) => 1.340,
456 ("C", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.427,
458 ("C", "O", BondOrder::Double) => 1.217,
459 ("C", "O", BondOrder::Aromatic) => 1.355,
460 ("C", "S", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.819,
462 ("C", "S", BondOrder::Double) => 1.610,
463 ("C", "S", BondOrder::Aromatic) => 1.750,
464 ("C", "F", _) => 1.350,
466 ("C", "Cl", _) => 1.770,
468 ("Br", "C", _) => 1.940,
470 ("C", "I", _) => 2.140,
472 ("C", "P", _) => 1.840,
474 ("C", "Si", _) => 1.870,
476 ("H", "H", _) => 0.741,
478 ("H", "N", _) => 1.010,
480 ("H", "O", _) => 0.960,
482 ("H", "S", _) => 1.340,
484 ("H", "P", _) => 1.420,
486 ("N", "N", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.450,
488 ("N", "N", BondOrder::Double) => 1.250,
489 ("N", "N", BondOrder::Triple) => 1.100,
490 ("N", "N", BondOrder::Aromatic) => 1.350,
491 ("N", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.400,
493 ("N", "O", BondOrder::Double) => 1.210,
494 ("N", "O", BondOrder::Aromatic) => 1.340,
495 ("O", "O", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 1.480,
497 ("O", "O", BondOrder::Double) => 1.210,
498 ("S", "S", BondOrder::Single | BondOrder::Up | BondOrder::Down) => 2.050,
500 ("S", "S", BondOrder::Double) => 1.890,
501 ("P", "P", _) => 2.280,
503 _ => match order {
505 BondOrder::Single | BondOrder::Up | BondOrder::Down => 1.54,
506 BondOrder::Double => 1.34,
507 BondOrder::Triple => 1.20,
508 BondOrder::Quadruple => 1.20,
509 BondOrder::Aromatic => 1.40,
510 BondOrder::Zero
511 | BondOrder::Dative
512 | BondOrder::QueryAny
513 | BondOrder::QuerySingleOrDouble
514 | BondOrder::QuerySingleOrAromatic => 1.54,
515 BondOrder::QueryDoubleOrAromatic => 1.40,
516 },
517 }
518}
519
520#[derive(Clone, Copy, PartialEq, Debug)]
522enum Hybridization {
523 SP, SP2, SP3, }
527
528fn atom_hybridization(mol: &Molecule, idx: AtomIdx) -> Hybridization {
529 if mol.atom(idx).aromatic {
530 return Hybridization::SP2;
531 }
532 let mut has_triple = false;
533 let mut has_double_or_aromatic = false;
534 for (_, bond_idx) in mol.neighbors(idx) {
535 match mol.bond(bond_idx).order {
536 BondOrder::Triple => has_triple = true,
537 BondOrder::Double | BondOrder::Aromatic => has_double_or_aromatic = true,
538 _ => {}
539 }
540 }
541 if has_triple {
542 Hybridization::SP
543 } else if has_double_or_aromatic {
544 Hybridization::SP2
545 } else {
546 Hybridization::SP3
547 }
548}
549
550fn ideal_angle_rad(sym: &str, hyb: Hybridization) -> f64 {
552 match hyb {
553 Hybridization::SP => 180.0_f64.to_radians(),
554 Hybridization::SP2 => 120.0_f64.to_radians(),
555 Hybridization::SP3 => match sym {
556 "O" | "Se" => 104.5_f64.to_radians(),
557 "N" => 107.0_f64.to_radians(),
558 "S" => 99.0_f64.to_radians(),
559 "P" => 93.0_f64.to_radians(),
560 _ => 109.47_f64.to_radians(),
561 },
562 }
563}
564
565fn uff_vdw_radius(sym: &str) -> f64 {
567 match sym {
568 "H" => 1.20,
569 "C" => 1.70,
570 "N" => 1.55,
571 "O" => 1.52,
572 "F" => 1.47,
573 "Si" => 2.10,
574 "P" => 1.80,
575 "S" => 1.80,
576 "Cl" => 1.75,
577 "Br" => 1.85,
578 "I" => 1.98,
579 "Se" => 1.90,
580 "Te" => 2.06,
581 _ => 1.70,
582 }
583}
584
585fn bond_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
590 let mut energy = 0.0;
591 for (_, bond) in mol.bonds() {
592 let a1 = bond.atom1;
593 let a2 = bond.atom2;
594 let r = coords.get(a1).distance(&coords.get(a2));
595 let sym1 = mol.atom(a1).element.symbol();
596 let sym2 = mol.atom(a2).element.symbol();
597 let r0 = ideal_bond_len(sym1, sym2, bond.order);
598 let dr = r - r0;
599 energy += 0.5 * BOND_SPRING_CONSTANT * dr * dr;
600 }
601 energy
602}
603
604fn angle_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
609 let mut energy = 0.0;
610
611 for b_idx in 0..mol.atom_count() {
612 let b = AtomIdx(b_idx as u32);
613 let neighbors: Vec<AtomIdx> = mol.neighbors(b).map(|(nb, _)| nb).collect();
614
615 if neighbors.len() < 2 {
616 continue;
617 }
618
619 let sym_b = mol.atom(b).element.symbol();
620 let hyb = atom_hybridization(mol, b);
621 let theta0 = ideal_angle_rad(sym_b, hyb);
622 let pb = coords.get(b);
623
624 for i in 0..neighbors.len() {
625 for j in (i + 1)..neighbors.len() {
626 let a = neighbors[i];
627 let c = neighbors[j];
628
629 let pa = coords.get(a);
630 let pc = coords.get(c);
631
632 let va = pa.sub(&pb);
633 let vc = pc.sub(&pb);
634
635 let na = va.norm();
636 let nc = vc.norm();
637
638 if na < 1e-10 || nc < 1e-10 {
639 continue;
640 }
641
642 let cos_theta = (va.dot(&vc) / (na * nc)).clamp(-1.0, 1.0);
643 let theta = cos_theta.acos();
644 let dtheta = theta - theta0;
645 energy += 0.5 * ANGLE_SPRING_CONSTANT * dtheta * dtheta;
646 }
647 }
648 }
649
650 energy
651}
652
653fn vdw_energy(mol: &Molecule, coords: &Coords3D) -> f64 {
658 let n = mol.atom_count();
659 let cutoff = VDW_CUTOFF;
660
661 let mut excluded: HashSet<(usize, usize)> = HashSet::new();
662
663 for (_, bond) in mol.bonds() {
664 let i = bond.atom1.0 as usize;
665 let j = bond.atom2.0 as usize;
666 excluded.insert((i.min(j), i.max(j)));
667 }
668
669 for b_idx in 0..n {
670 let b = AtomIdx(b_idx as u32);
671 let neighbors: Vec<usize> = mol.neighbors(b).map(|(nb, _)| nb.0 as usize).collect();
672 for ii in 0..neighbors.len() {
673 for jj in (ii + 1)..neighbors.len() {
674 let i = neighbors[ii];
675 let j = neighbors[jj];
676 excluded.insert((i.min(j), i.max(j)));
677 }
678 }
679 }
680
681 let mut energy = 0.0;
682 for i in 0..n {
683 for j in (i + 1)..n {
684 if excluded.contains(&(i, j)) {
685 continue;
686 }
687 let r = coords
688 .get(AtomIdx(i as u32))
689 .distance(&coords.get(AtomIdx(j as u32)));
690
691 if r < 0.01 || r >= cutoff {
692 continue;
693 }
694
695 let sym_i = mol.atom(AtomIdx(i as u32)).element.symbol();
696 let sym_j = mol.atom(AtomIdx(j as u32)).element.symbol();
697 let r0 = uff_vdw_radius(sym_i) + uff_vdw_radius(sym_j);
698
699 let ratio = r0 / r;
700 let ratio6 = ratio * ratio * ratio * ratio * ratio * ratio;
701 let ratio12 = ratio6 * ratio6;
702 energy += 0.05 * ratio12;
703 }
704 }
705
706 energy
707}
708
709fn total_energy_mmff94(
714 mol: &Molecule,
715 coords: &Coords3D,
716 mmff94_types: &[chematic_ff::MMFF94Type],
717) -> f64 {
718 let bond_e = bond_energy_mmff94(mol, coords, mmff94_types);
719 let angle_e = angle_energy_mmff94(mol, coords, mmff94_types);
720 let vdw_e = vdw_energy_mmff94(mol, coords, mmff94_types);
721
722 let elec_e = electrostatic_energy_mmff94(mol, coords, mmff94_types).unwrap_or(0.0);
724
725 bond_e + angle_e + vdw_e + elec_e
726}
727
728fn bond_energy_mmff94(
729 mol: &Molecule,
730 coords: &Coords3D,
731 mmff94_types: &[chematic_ff::MMFF94Type],
732) -> f64 {
733 let mut energy = 0.0;
734
735 for (_, bond) in mol.bonds() {
736 let a1 = bond.atom1;
737 let a2 = bond.atom2;
738 let r = coords.get(a1).distance(&coords.get(a2));
739 let t1 = mmff94_types[a1.0 as usize];
740 let t2 = mmff94_types[a2.0 as usize];
741
742 if let Some(params) = mmff94_bond_params(t1, t2, bond.order) {
743 let dr = r - params.r0;
744 energy += 0.5 * params.kb * dr * dr;
745 }
746 }
747
748 energy
749}
750
751fn angle_energy_mmff94(
752 mol: &Molecule,
753 coords: &Coords3D,
754 mmff94_types: &[chematic_ff::MMFF94Type],
755) -> f64 {
756 let mut energy = 0.0;
757
758 for b_idx in 0..mol.atom_count() {
759 let b = AtomIdx(b_idx as u32);
760 let neighbors: Vec<AtomIdx> = mol.neighbors(b).map(|(nb, _)| nb).collect();
761
762 if neighbors.len() < 2 {
763 continue;
764 }
765
766 for (i, &a) in neighbors.iter().enumerate() {
767 for &c in &neighbors[i + 1..] {
768 let t1 = mmff94_types[a.0 as usize];
769 let t2 = mmff94_types[b_idx];
770 let t3 = mmff94_types[c.0 as usize];
771
772 if let Some(params) = mmff94_angle_params(t1, t2, t3) {
773 let pb = coords.get(b);
774 let pa = coords.get(a);
775 let pc = coords.get(c);
776
777 let va = pa.sub(&pb);
778 let vc = pc.sub(&pb);
779
780 let na = va.norm();
781 let nc = vc.norm();
782
783 if na < 1e-10 || nc < 1e-10 {
784 continue;
785 }
786
787 let cos_theta = (va.dot(&vc) / (na * nc)).clamp(-1.0, 1.0);
788 let theta = cos_theta.acos();
789 let dtheta = theta - params.theta0;
790 energy += 0.5 * params.ka * dtheta * dtheta;
791 }
792 }
793 }
794 }
795
796 energy
797}
798
799fn vdw_energy_mmff94(
800 mol: &Molecule,
801 coords: &Coords3D,
802 mmff94_types: &[chematic_ff::MMFF94Type],
803) -> f64 {
804 let n = mol.atom_count();
805 let cutoff = VDW_CUTOFF;
806 let mut excluded: HashSet<(usize, usize)> = HashSet::new();
807
808 for (_, bond) in mol.bonds() {
809 let i = bond.atom1.0 as usize;
810 let j = bond.atom2.0 as usize;
811 excluded.insert((i.min(j), i.max(j)));
812 }
813
814 for b_idx in 0..n {
816 let b = AtomIdx(b_idx as u32);
817 let neighbors: Vec<usize> = mol.neighbors(b).map(|(nb, _)| nb.0 as usize).collect();
818 for &neighbor in &neighbors {
819 excluded.insert((b_idx.min(neighbor), b_idx.max(neighbor)));
820 }
821 }
822
823 let mut energy = 0.0;
824
825 for i in 0..n {
826 for j in (i + 1)..n {
827 if excluded.contains(&(i, j)) {
828 continue;
829 }
830
831 let ri = coords.get(AtomIdx(i as u32));
832 let rj = coords.get(AtomIdx(j as u32));
833 let d = ri.distance(&rj);
834
835 if d > cutoff {
836 continue;
837 }
838
839 let params_i = mmff94_vdw_params(mmff94_types[i]);
840 let params_j = mmff94_vdw_params(mmff94_types[j]);
841
842 let r_ij = (params_i.r_star * params_j.r_star).sqrt();
844 let eps_ij = (params_i.epsilon * params_j.epsilon).sqrt();
845
846 if d > 0.0 {
848 let r6 = (r_ij / d).powi(6);
849 energy += eps_ij * (r6 * r6 - 2.0 * r6);
850 }
851 }
852 }
853
854 energy
855}
856
857fn electrostatic_energy_mmff94(
860 mol: &Molecule,
861 coords: &Coords3D,
862 _mmff94_types: &[chematic_ff::MMFF94Type],
863) -> Result<f64, String> {
864 let coord_tuples: Vec<(f64, f64, f64)> = (0..mol.atom_count())
866 .map(|i| {
867 let p = coords.get(AtomIdx(i as u32));
868 (p.x, p.y, p.z)
869 })
870 .collect();
871
872 let charges = mmff94_charges_3d(mol, &coord_tuples)
874 .map_err(|e| format!("charge calculation failed: {}", e))?;
875
876 let n = mol.atom_count();
877 let mut energy = 0.0;
878
879 let mut excluded: HashSet<(usize, usize)> = HashSet::new();
881
882 for (_, bond) in mol.bonds() {
884 let i = bond.atom1.0 as usize;
885 let j = bond.atom2.0 as usize;
886 excluded.insert((i.min(j), i.max(j)));
887 }
888
889 for b_idx in 0..n {
891 let b = AtomIdx(b_idx as u32);
892 let neighbors: Vec<usize> = mol.neighbors(b).map(|(nb, _)| nb.0 as usize).collect();
893 for &neighbor in &neighbors {
894 excluded.insert((b_idx.min(neighbor), b_idx.max(neighbor)));
895 }
896 }
897
898 let dielectric = 4.0; let coulomb_const = 332.0; for i in 0..n {
902 for j in (i + 1)..n {
903 if excluded.contains(&(i, j)) {
905 continue;
906 }
907
908 let ri = coords.get(AtomIdx(i as u32));
909 let rj = coords.get(AtomIdx(j as u32));
910 let d = ri.distance(&rj);
911
912 if d > 0.01 {
913 let coulomb = coulomb_const * charges[i] * charges[j] / (d * dielectric);
915 energy += coulomb;
916 }
917 }
918 }
919
920 Ok(energy)
921}
922
923#[cfg(test)]
928mod tests {
929 use super::*;
930 use crate::dg::generate_coords;
931 use chematic_smiles::parse;
932
933 fn all_pairs_min_dist(coords: &Coords3D, n: usize) -> f64 {
934 let mut min_d = f64::MAX;
935 for i in 0..n {
936 for j in (i + 1)..n {
937 let d = coords
938 .get(AtomIdx(i as u32))
939 .distance(&coords.get(AtomIdx(j as u32)));
940 min_d = min_d.min(d);
941 }
942 }
943 min_d
944 }
945
946 #[test]
947 fn test_single_atom_unchanged() {
948 let mol = parse("O").unwrap();
949 let coords = generate_coords(&mol);
950 let orig = coords.get(AtomIdx(0));
951 let result = minimize(&mol, coords);
952 let after = result.get(AtomIdx(0));
953 assert!((orig.x - after.x).abs() < 1e-10);
954 }
955
956 #[test]
957 fn test_zero_steps_unchanged() {
958 let mol = parse("CC").unwrap();
959 let coords = generate_coords(&mol);
960 let config = MinimizeConfig {
961 max_steps: 0,
962 ..MinimizeConfig::default()
963 };
964 let before0 = coords.get(AtomIdx(0));
965 let result = minimize_with_config(&mol, coords, &config);
966 let after0 = result.get(AtomIdx(0));
967 assert!((before0.x - after0.x).abs() < 1e-10);
968 }
969
970 #[test]
971 fn test_ethane_bond_after_minimize() {
972 let mol = parse("CC").unwrap();
973 let coords = generate_coords(&mol);
974 let result = minimize(&mol, coords);
975 let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
976 assert!(
977 d > 1.2 && d < 1.8,
978 "C-C distance={d:.3}, expected 1.2-1.8 Å"
979 );
980 }
981
982 #[test]
983 fn test_ethane_converges_to_uff_length() {
984 let mol = parse("CC").unwrap();
985 let coords = generate_coords(&mol);
986 let result = minimize(&mol, coords);
987 let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
988 assert!(
990 (d - 1.540).abs() < 0.05,
991 "C-C distance={d:.4}, expected ~1.540"
992 );
993 }
994
995 #[test]
996 fn test_propane_no_clash() {
997 let mol = parse("CCC").unwrap();
998 let coords = generate_coords(&mol);
999 let result = minimize(&mol, coords);
1000 let min_d = all_pairs_min_dist(&result, mol.atom_count());
1001 assert!(min_d > 0.8, "atom clash: min distance={min_d:.3}");
1002 }
1003
1004 #[test]
1005 fn test_benzene_no_clash() {
1006 let mol = parse("c1ccccc1").unwrap();
1007 let coords = generate_coords(&mol);
1008 let result = minimize(&mol, coords);
1009 let min_d = all_pairs_min_dist(&result, mol.atom_count());
1010 assert!(
1011 min_d > 0.8,
1012 "atom clash in benzene: min distance={min_d:.3}"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_disconnected_no_clash() {
1018 let mol = parse("CC.CC").unwrap();
1019 let coords = generate_coords(&mol);
1020 let result = minimize(&mol, coords);
1021 let min_d = all_pairs_min_dist(&result, mol.atom_count());
1022 assert!(
1023 min_d > 0.8,
1024 "atom clash in disconnected: min distance={min_d:.3}"
1025 );
1026 }
1027
1028 #[test]
1029 fn test_default_config_no_panic() {
1030 let mol = parse("CC(=O)O").unwrap();
1031 let coords = generate_coords(&mol);
1032 let result = minimize(&mol, coords);
1033 assert_eq!(result.atom_count(), mol.atom_count());
1034 }
1035
1036 #[test]
1037 fn test_acetic_acid_no_clash() {
1038 let mol = parse("CC(=O)O").unwrap();
1039 let coords = generate_coords(&mol);
1040 let result = minimize(&mol, coords);
1041 let min_d = all_pairs_min_dist(&result, mol.atom_count());
1042 assert!(min_d > 0.8, "clash in acetic acid: {min_d:.3}");
1043 }
1044
1045 #[test]
1046 fn test_minimize_idempotent() {
1047 let mol = parse("CCC").unwrap();
1048 let coords = generate_coords(&mol);
1049 let result1 = minimize(&mol, coords);
1050 let e1 = total_energy(&mol, &result1);
1051 let result2 = minimize(&mol, result1);
1052 let e2 = total_energy(&mol, &result2);
1053 assert!(e2 <= e1 + 1.0, "energy increased: e1={e1:.4}, e2={e2:.4}");
1054 }
1055
1056 #[test]
1057 fn test_naphthalene_no_overlap() {
1058 let mol = parse("c1ccc2ccccc2c1").unwrap();
1059 let coords = generate_coords(&mol);
1060 let result = minimize(&mol, coords);
1061 let min_d = all_pairs_min_dist(&result, mol.atom_count());
1062 assert!(min_d > 0.8, "overlap in naphthalene: {min_d:.3}");
1063 }
1064
1065 #[test]
1066 fn test_co_bond_double_shorter_than_single() {
1067 let mol = parse("CC(=O)O").unwrap();
1069 let coords = generate_coords(&mol);
1070 let result = minimize(&mol, coords);
1071 assert_eq!(result.atom_count(), 4);
1074 let min_d = all_pairs_min_dist(&result, 4);
1075 assert!(min_d > 0.5, "clash in CO test: {min_d:.3}");
1076 }
1077
1078 #[test]
1079 fn test_heteroatom_c_n_bond() {
1080 let mol = parse("CN").unwrap(); let coords = generate_coords(&mol);
1082 let result = minimize(&mol, coords);
1083 let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1084 assert!(
1086 (d - 1.469).abs() < 0.1,
1087 "C-N distance={d:.4}, expected ~1.469"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_acetylene_sp_hybridization() {
1093 let mol = parse("C#C").unwrap(); let coords = generate_coords(&mol);
1095 let result = minimize(&mol, coords);
1096 let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1097 assert!(
1099 (d - 1.204).abs() < 0.05,
1100 "C≡C distance={d:.4}, expected ~1.204"
1101 );
1102 }
1103
1104 #[test]
1105 fn test_ideal_bond_len_cc_single() {
1106 assert!((ideal_bond_len("C", "C", BondOrder::Single) - 1.540).abs() < 1e-6);
1107 assert!((ideal_bond_len("C", "C", BondOrder::Double) - 1.340).abs() < 1e-6);
1108 assert!((ideal_bond_len("C", "C", BondOrder::Triple) - 1.204).abs() < 1e-6);
1109 assert!((ideal_bond_len("C", "C", BondOrder::Aromatic) - 1.395).abs() < 1e-6);
1110 }
1111
1112 #[test]
1113 fn test_ideal_bond_len_symmetry() {
1114 let bo = BondOrder::Single;
1116 assert_eq!(ideal_bond_len("C", "N", bo), ideal_bond_len("N", "C", bo));
1117 assert_eq!(ideal_bond_len("C", "O", bo), ideal_bond_len("O", "C", bo));
1118 assert_eq!(ideal_bond_len("Br", "C", bo), ideal_bond_len("C", "Br", bo));
1119 }
1120
1121 #[test]
1122 fn test_atom_hybridization_sp2_aromatic() {
1123 let mol = parse("c1ccccc1").unwrap();
1124 for i in 0..6 {
1125 assert_eq!(
1126 atom_hybridization(&mol, AtomIdx(i)),
1127 Hybridization::SP2,
1128 "benzene atom {i} should be SP2"
1129 );
1130 }
1131 }
1132
1133 #[test]
1134 fn test_atom_hybridization_sp_triple() {
1135 let mol = parse("C#C").unwrap();
1136 assert_eq!(atom_hybridization(&mol, AtomIdx(0)), Hybridization::SP);
1137 assert_eq!(atom_hybridization(&mol, AtomIdx(1)), Hybridization::SP);
1138 }
1139
1140 #[test]
1141 fn test_atom_hybridization_sp3_alkane() {
1142 let mol = parse("CCC").unwrap();
1143 for i in 0..3 {
1144 assert_eq!(
1145 atom_hybridization(&mol, AtomIdx(i)),
1146 Hybridization::SP3,
1147 "propane atom {i} should be SP3"
1148 );
1149 }
1150 }
1151
1152 #[test]
1153 fn test_minimize_dreiding_ethane_no_clash() {
1154 let mol = parse("CC").unwrap();
1155 let coords = generate_coords(&mol);
1156 let min_coords = minimize_dreiding(&mol, coords);
1157 let n = mol.atom_count();
1158 for i in 0..n {
1159 for j in (i + 1)..n {
1160 let d = min_coords
1161 .get(AtomIdx(i as u32))
1162 .distance(&min_coords.get(AtomIdx(j as u32)));
1163 assert!(
1164 d > 0.5,
1165 "atoms {i} and {j} clashed after DREIDING minimization (d={d:.3})"
1166 );
1167 }
1168 }
1169 }
1170
1171 #[test]
1172 fn test_minimize_dreiding_benzene_no_clash() {
1173 let mol = parse("c1ccccc1").unwrap();
1174 let coords = generate_coords(&mol);
1175 let min_coords = minimize_dreiding(&mol, coords);
1176 let n = mol.atom_count();
1177 for i in 0..n {
1178 for j in (i + 1)..n {
1179 let d = min_coords
1180 .get(AtomIdx(i as u32))
1181 .distance(&min_coords.get(AtomIdx(j as u32)));
1182 assert!(
1183 d > 0.5,
1184 "atoms {i} and {j} clashed after DREIDING minimization (d={d:.3})"
1185 );
1186 }
1187 }
1188 }
1189
1190 #[test]
1191 fn test_minimize_mmff94_ethane() {
1192 let mol = parse("CC").unwrap();
1193 let c = generate_coords(&mol);
1194 let result = minimize_mmff94(&mol, c);
1195 assert_eq!(result.atom_count(), 2);
1196 let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1197 assert!(d > 1.4 && d < 1.7, "C-C should be ~1.54 Å, got {:.3}", d);
1198 }
1199
1200 #[test]
1201 fn test_minimize_mmff94_benzene() {
1202 let mol = parse("c1ccccc1").unwrap();
1203 let c = generate_coords(&mol);
1204 let result = minimize_mmff94(&mol, c);
1205 assert_eq!(result.atom_count(), 6);
1206 let min_d = all_pairs_min_dist(&result, 6);
1207 assert!(min_d > 1.2, "benzene clash: {min_d:.3}");
1208 }
1209
1210 #[test]
1211 fn test_minimize_mmff94_aspirin() {
1212 let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap();
1213 let c = generate_coords(&mol);
1214 let result = minimize_mmff94(&mol, c);
1215 assert_eq!(result.atom_count(), mol.atom_count());
1217 for i in 0..mol.atom_count() {
1218 let p = result.get(chematic_core::AtomIdx(i as u32));
1219 assert!(
1220 p.x.is_finite() && p.y.is_finite() && p.z.is_finite(),
1221 "aspirin atom {i} has invalid coords"
1222 );
1223 }
1224 }
1225
1226 #[test]
1229 fn test_electrostatic_energy_methanol() {
1230 let mol = parse("CO").unwrap();
1232 let c = generate_coords(&mol);
1233 let mmff94_types = assign_mmff94_types(&mol).unwrap();
1234
1235 let elec_e = electrostatic_energy_mmff94(&mol, &c, &mmff94_types);
1237 assert!(elec_e.is_ok());
1238 assert!(elec_e.unwrap().is_finite());
1239 }
1240
1241 #[test]
1242 fn test_electrostatic_energy_carboxylic_acid() {
1243 let mol = parse("CC(=O)O").unwrap();
1245 let c = generate_coords(&mol);
1246 let mmff94_types = assign_mmff94_types(&mol).unwrap();
1247
1248 let elec_e = electrostatic_energy_mmff94(&mol, &c, &mmff94_types);
1249 assert!(elec_e.is_ok());
1250 let energy = elec_e.unwrap();
1251 assert!(energy.is_finite());
1252 }
1254
1255 #[test]
1256 fn test_mmff94_with_electrostatic_ethane() {
1257 let mol = parse("CC").unwrap();
1259 let c = generate_coords(&mol);
1260 let result = minimize_mmff94(&mol, c);
1261
1262 assert_eq!(result.atom_count(), 2);
1264 let d = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1265 assert!(
1266 d > 1.4 && d < 1.7,
1267 "ethane C-C should be ~1.54 Å with electrostatic, got {:.3}",
1268 d
1269 );
1270 }
1271
1272 #[test]
1273 fn test_mmff94_minimization_includes_charge_effects() {
1274 let mol = parse("CCO").unwrap();
1276 let c = generate_coords(&mol);
1277
1278 let result = minimize_mmff94(&mol, c);
1280
1281 assert_eq!(result.atom_count(), 3);
1283 for i in 0..3 {
1284 let p = result.get(AtomIdx(i as u32));
1285 assert!(
1286 p.x.is_finite() && p.y.is_finite() && p.z.is_finite(),
1287 "atom {i} has invalid coordinate after minimization"
1288 );
1289 }
1290
1291 let c_c = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
1293 let c_o = result.get(AtomIdx(1)).distance(&result.get(AtomIdx(2)));
1294 assert!(c_c > 1.0, "C-C bond too short: {c_c:.3}");
1295 assert!(c_o > 1.0, "C-O bond too short: {c_o:.3}");
1296 }
1297
1298 #[test]
1299 fn test_mmff94_charges_3d_integration() {
1300 let mol = parse("c1ccccc1O").unwrap(); let c = generate_coords(&mol);
1303
1304 let result = minimize_mmff94(&mol, c);
1306 assert_eq!(result.atom_count(), mol.atom_count());
1307
1308 for i in 0..mol.atom_count() {
1310 let p = result.get(AtomIdx(i as u32));
1311 assert!(p.x.is_finite() && p.y.is_finite() && p.z.is_finite());
1312 }
1313 }
1314
1315 #[test]
1316 fn test_total_energy_mmff94_includes_electrostatic() {
1317 let mol = parse("CCN").unwrap(); let c = generate_coords(&mol);
1320 let mmff94_types = assign_mmff94_types(&mol).unwrap();
1321
1322 let total_e = total_energy_mmff94(&mol, &c, &mmff94_types);
1323 let bond_e = bond_energy_mmff94(&mol, &c, &mmff94_types);
1324 let angle_e = angle_energy_mmff94(&mol, &c, &mmff94_types);
1325 let vdw_e = vdw_energy_mmff94(&mol, &c, &mmff94_types);
1326
1327 let electrostatic_e = electrostatic_energy_mmff94(&mol, &c, &mmff94_types).unwrap_or(0.0);
1329 let expected = bond_e + angle_e + vdw_e + electrostatic_e;
1330
1331 assert!(
1332 (total_e - expected).abs() < 1e-6,
1333 "total energy mismatch: got {}, expected {}",
1334 total_e,
1335 expected
1336 );
1337 }
1338}