use chematic_core::{AtomIdx, Molecule};
use crate::coords::{Coords3D, Point3};
fn avg_mass(an: u8) -> f64 {
match an {
1 => 1.008,
6 => 12.011,
7 => 14.007,
8 => 15.999,
9 => 18.998,
15 => 30.974,
16 => 32.065,
17 => 35.453,
35 => 79.904,
53 => 126.904,
n => n as f64,
}
}
fn center_of_mass(mol: &Molecule, coords: &Coords3D) -> (Point3, f64) {
let mut cx = 0.0_f64;
let mut cy = 0.0_f64;
let mut cz = 0.0_f64;
let mut total = 0.0_f64;
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
let m = avg_mass(mol.atom(idx).element.atomic_number());
let p = coords.get(idx);
cx += m * p.x;
cy += m * p.y;
cz += m * p.z;
total += m;
}
if total > 1e-10 {
(Point3::new(cx / total, cy / total, cz / total), total)
} else {
(Point3::zero(), total)
}
}
fn inertia_tensor(mol: &Molecule, coords: &Coords3D) -> [[f64; 3]; 3] {
let (cm, _) = center_of_mass(mol, coords);
let mut t = [[0.0_f64; 3]; 3];
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
let m = avg_mass(mol.atom(idx).element.atomic_number());
let p = coords.get(idx);
let x = p.x - cm.x;
let y = p.y - cm.y;
let z = p.z - cm.z;
t[0][0] += m * (y * y + z * z);
t[1][1] += m * (x * x + z * z);
t[2][2] += m * (x * x + y * y);
t[0][1] -= m * x * y;
t[1][0] = t[0][1];
t[0][2] -= m * x * z;
t[2][0] = t[0][2];
t[1][2] -= m * y * z;
t[2][1] = t[1][2];
}
t
}
pub(crate) fn jacobi3(m: [[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
let mut a = m;
let mut v: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
for _ in 0..100 {
let (p, q) = {
let mut mx = 0.0_f64;
let mut pi = 0usize;
let mut qi = 1usize;
for i in 0..3 {
for j in (i + 1)..3 {
if a[i][j].abs() > mx {
mx = a[i][j].abs();
pi = i;
qi = j;
}
}
}
if mx < 1e-12 {
break;
}
(pi, qi)
};
let diff = a[q][q] - a[p][p];
let t = if diff.abs() < 1e-15 * a[p][q].abs() {
1.0_f64.copysign(a[p][q])
} else {
let phi = diff / (2.0 * a[p][q]);
let sign = if phi >= 0.0 { 1.0_f64 } else { -1.0_f64 };
sign / (phi.abs() + (1.0 + phi * phi).sqrt())
};
let c = 1.0 / (1.0 + t * t).sqrt();
let s = t * c;
let tau = s / (1.0 + c);
let a_pp = a[p][p] - t * a[p][q];
let a_qq = a[q][q] + t * a[p][q];
a[p][p] = a_pp;
a[q][q] = a_qq;
a[p][q] = 0.0;
a[q][p] = 0.0;
for r in 0..3 {
if r != p && r != q {
let old_pr = a[p][r];
let old_qr = a[q][r];
a[p][r] = old_pr - s * (old_qr + tau * old_pr);
a[r][p] = a[p][r];
a[q][r] = old_qr + s * (old_pr - tau * old_qr);
a[r][q] = a[q][r];
}
}
for r in 0..3 {
let old_rp = v[r][p];
let old_rq = v[r][q];
v[r][p] = old_rp - s * (old_rq + tau * old_rp);
v[r][q] = old_rq + s * (old_rp - tau * old_rq);
}
}
let mut pairs: [(f64, [f64; 3]); 3] = [
(a[0][0], [v[0][0], v[1][0], v[2][0]]),
(a[1][1], [v[0][1], v[1][1], v[2][1]]),
(a[2][2], [v[0][2], v[1][2], v[2][2]]),
];
pairs.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
let evals = [pairs[0].0, pairs[1].0, pairs[2].0];
let evecs = [
[pairs[0].1[0], pairs[1].1[0], pairs[2].1[0]],
[pairs[0].1[1], pairs[1].1[1], pairs[2].1[1]],
[pairs[0].1[2], pairs[1].1[2], pairs[2].1[2]],
];
(evals, evecs)
}
pub fn pmi(mol: &Molecule, coords: &Coords3D) -> (f64, f64, f64) {
let tensor = inertia_tensor(mol, coords);
let (evals, _) = jacobi3(tensor);
(evals[0].max(0.0), evals[1].max(0.0), evals[2].max(0.0))
}
pub fn pmi1(mol: &Molecule, coords: &Coords3D) -> f64 {
pmi(mol, coords).0
}
pub fn pmi2(mol: &Molecule, coords: &Coords3D) -> f64 {
pmi(mol, coords).1
}
pub fn pmi3(mol: &Molecule, coords: &Coords3D) -> f64 {
pmi(mol, coords).2
}
pub fn npr1(mol: &Molecule, coords: &Coords3D) -> f64 {
let (p1, _, p3) = pmi(mol, coords);
if p3 < 1e-10 { 0.0 } else { p1 / p3 }
}
pub fn npr2(mol: &Molecule, coords: &Coords3D) -> f64 {
let (_, p2, p3) = pmi(mol, coords);
if p3 < 1e-10 { 0.0 } else { p2 / p3 }
}
pub fn radius_of_gyration(mol: &Molecule, coords: &Coords3D) -> f64 {
let (cm, total_m) = center_of_mass(mol, coords);
if total_m < 1e-10 {
return 0.0;
}
let mut sum = 0.0_f64;
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
let m = avg_mass(mol.atom(idx).element.atomic_number());
let p = coords.get(idx);
let r2 = (p.x - cm.x).powi(2) + (p.y - cm.y).powi(2) + (p.z - cm.z).powi(2);
sum += m * r2;
}
(sum / total_m).sqrt()
}
pub fn asphericity(mol: &Molecule, coords: &Coords3D) -> f64 {
let (p1, p2, p3) = pmi(mol, coords);
p3 - (p1 + p2) / 2.0
}
pub fn eccentricity(mol: &Molecule, coords: &Coords3D) -> f64 {
let (p1, _, p3) = pmi(mol, coords);
if p3 < 1e-10 {
return 0.0;
}
(1.0 - p1 / p3).max(0.0).sqrt()
}
pub fn plane_of_best_fit(mol: &Molecule, coords: &Coords3D) -> f64 {
let n = mol.atom_count();
if n < 3 {
return 0.0;
}
let mut cx = 0.0_f64;
let mut cy = 0.0_f64;
let mut cz = 0.0_f64;
for i in 0..n {
let p = coords.get(AtomIdx(i as u32));
cx += p.x;
cy += p.y;
cz += p.z;
}
cx /= n as f64;
cy /= n as f64;
cz /= n as f64;
let mut cov = [[0.0_f64; 3]; 3];
for i in 0..n {
let p = coords.get(AtomIdx(i as u32));
let x = p.x - cx;
let y = p.y - cy;
let z = p.z - cz;
cov[0][0] += x * x;
cov[0][1] += x * y;
cov[0][2] += x * z;
cov[1][0] += x * y;
cov[1][1] += y * y;
cov[1][2] += y * z;
cov[2][0] += x * z;
cov[2][1] += y * z;
cov[2][2] += z * z;
}
let (_, evecs) = jacobi3(cov);
let nx = evecs[0][0];
let ny = evecs[1][0];
let nz = evecs[2][0];
let mut sum_sq = 0.0_f64;
for i in 0..n {
let p = coords.get(AtomIdx(i as u32));
let d = (p.x - cx) * nx + (p.y - cy) * ny + (p.z - cz) * nz;
sum_sq += d * d;
}
(sum_sq / n as f64).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
use crate::dg::generate_coords;
fn mol(s: &str) -> chematic_core::Molecule {
parse(s).unwrap_or_else(|e| panic!("parse '{s}': {e}"))
}
#[test]
fn jacobi3_diagonal_identity() {
let m = [[3.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 2.0]];
let (evals, _) = jacobi3(m);
let mut sorted = evals;
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!(
(sorted[0] - 1.0).abs() < 1e-9,
"expected 1, got {}",
sorted[0]
);
assert!(
(sorted[1] - 2.0).abs() < 1e-9,
"expected 2, got {}",
sorted[1]
);
assert!(
(sorted[2] - 3.0).abs() < 1e-9,
"expected 3, got {}",
sorted[2]
);
}
#[test]
fn jacobi3_symmetric_matrix() {
let m = [[5.0, 4.0, 0.0], [4.0, 5.0, 0.0], [0.0, 0.0, 1.0]];
let (evals, _) = jacobi3(m);
assert!((evals[0] - 1.0).abs() < 1e-8, "evals[0]={}", evals[0]);
assert!((evals[1] - 1.0).abs() < 1e-8, "evals[1]={}", evals[1]);
assert!((evals[2] - 9.0).abs() < 1e-8, "evals[2]={}", evals[2]);
}
#[test]
fn single_atom_pmi_zero() {
let m = mol("C");
let c = generate_coords(&m);
let (p1, p2, p3) = pmi(&m, &c);
assert!(
p1 < 1e-9 && p2 < 1e-9 && p3 < 1e-9,
"single atom PMI should be 0"
);
}
#[test]
fn ethane_pmi_sorted() {
let m = mol("CC");
let c = generate_coords(&m);
let (p1, p2, p3) = pmi(&m, &c);
assert!(p1 <= p2 + 1e-9, "PMI1 <= PMI2: {p1} vs {p2}");
assert!(p2 <= p3 + 1e-9, "PMI2 <= PMI3: {p2} vs {p3}");
assert!(p3 > 1e-9, "ethane PMI3 > 0");
}
#[test]
fn npr_in_range() {
let m = mol("c1ccccc1");
let c = generate_coords(&m);
let n1 = npr1(&m, &c);
let n2 = npr2(&m, &c);
assert!((0.0..=1.0 + 1e-9).contains(&n1), "NPR1 out of range: {n1}");
assert!((0.0..=1.0 + 1e-9).contains(&n2), "NPR2 out of range: {n2}");
assert!(n1 <= n2 + 1e-9, "NPR1 <= NPR2: {n1} vs {n2}");
}
#[test]
fn rog_positive_aspirin() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O");
let c = generate_coords(&m);
let rg = radius_of_gyration(&m, &c);
assert!(rg > 0.0, "aspirin Rg should be positive, got {rg}");
}
#[test]
fn rog_single_atom_zero() {
let m = mol("C");
let c = generate_coords(&m);
let rg = radius_of_gyration(&m, &c);
assert!(rg.abs() < 1e-9, "single atom Rg should be 0");
}
#[test]
fn asphericity_nonneg() {
let m = mol("c1ccccc1");
let c = generate_coords(&m);
let b = asphericity(&m, &c);
assert!(b >= -1e-9, "asphericity should be >= 0, got {b}");
}
#[test]
fn eccentricity_in_range() {
let m = mol("CCC");
let c = generate_coords(&m);
let e = eccentricity(&m, &c);
assert!(
(0.0..=1.0 + 1e-9).contains(&e),
"eccentricity out of range: {e}"
);
}
#[test]
fn pbf_benzene_near_zero() {
let m = mol("c1ccccc1");
let c = generate_coords(&m);
let pbf = plane_of_best_fit(&m, &c);
assert!(pbf < 0.1, "benzene PBF should be near 0, got {pbf:.4}");
}
#[test]
fn pbf_two_atoms_zero() {
let m = mol("CC");
let c = generate_coords(&m);
let pbf = plane_of_best_fit(&m, &c);
assert!(pbf.abs() < 1e-9, "two-atom PBF should be 0");
}
#[test]
fn pbf_manually_nonplanar() {
use crate::coords::Coords3D;
let m = mol("CC(C)C"); let n = m.atom_count();
assert_eq!(n, 4);
let mut c = Coords3D::new_zeroed(n);
c.set(chematic_core::AtomIdx(0), Point3::new(0.0, 0.0, 0.0));
c.set(chematic_core::AtomIdx(1), Point3::new(1.0, 0.0, 0.0));
c.set(chematic_core::AtomIdx(2), Point3::new(0.0, 1.0, 0.0));
c.set(chematic_core::AtomIdx(3), Point3::new(0.0, 0.0, 1.0));
let pbf = plane_of_best_fit(&m, &c);
assert!(
pbf > 0.1,
"non-coplanar atoms should have PBF > 0, got {pbf:.4}"
);
}
}