use chematic_core::{AtomIdx, Molecule};
use chematic_perception::{FeatureType, detect_features};
use crate::coords::Coords3D;
const PHI: f64 = 1.618_033_988_749_895; const NORM: f64 = 1.902_113_032_590_307;
const ICOSAHEDRON_VERTICES: [[f64; 3]; 12] = [
[0.0, 1.0 / NORM, PHI / NORM],
[0.0, 1.0 / NORM, -PHI / NORM],
[0.0, -1.0 / NORM, PHI / NORM],
[0.0, -1.0 / NORM, -PHI / NORM],
[1.0 / NORM, PHI / NORM, 0.0],
[1.0 / NORM, -PHI / NORM, 0.0],
[-1.0 / NORM, PHI / NORM, 0.0],
[-1.0 / NORM, -PHI / NORM, 0.0],
[PHI / NORM, 0.0, 1.0 / NORM],
[PHI / NORM, 0.0, -1.0 / NORM],
[-PHI / NORM, 0.0, 1.0 / NORM],
[-PHI / NORM, 0.0, -1.0 / NORM],
];
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum SpectrophoresNorm {
#[default]
None,
ZScore,
L2,
}
#[derive(Debug, Clone)]
pub struct SpectrophoresConfig {
pub normalize: SpectrophoresNorm,
pub cage_buffer: f64,
pub exponent: u32,
}
impl Default for SpectrophoresConfig {
fn default() -> Self {
Self {
normalize: SpectrophoresNorm::None,
cage_buffer: 3.0,
exponent: 2,
}
}
}
pub fn spectrophores(mol: &Molecule, coords: &Coords3D, config: &SpectrophoresConfig) -> Vec<f64> {
let heavy: Vec<AtomIdx> = mol
.atoms()
.filter(|(_, a)| a.element.atomic_number() != 1)
.map(|(idx, _)| idx)
.collect();
if heavy.is_empty() {
return vec![0.0; 48];
}
let positions: Vec<[f64; 3]> = heavy
.iter()
.map(|&idx| {
let p = coords.get(idx);
[p.x, p.y, p.z]
})
.collect();
let elec = electrostatic_props(mol, &heavy);
let lipo = lipophilic_props(mol, &heavy);
let arom = aromatic_props(mol, &heavy);
let hbond = hbond_props(mol, &heavy);
let props: [&[f64]; 4] = [&elec, &lipo, &arom, &hbond];
let cx = positions.iter().map(|p| p[0]).sum::<f64>() / positions.len() as f64;
let cy = positions.iter().map(|p| p[1]).sum::<f64>() / positions.len() as f64;
let cz = positions.iter().map(|p| p[2]).sum::<f64>() / positions.len() as f64;
let centered: Vec<[f64; 3]> = positions
.iter()
.map(|p| [p[0] - cx, p[1] - cy, p[2] - cz])
.collect();
let max_dist = centered
.iter()
.map(|p| (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt())
.fold(0.0_f64, f64::max);
let cage_r = max_dist + config.cage_buffer;
let mut result = Vec::with_capacity(48);
for prop_vals in props {
for vertex in &ICOSAHEDRON_VERTICES {
let probe = [vertex[0] * cage_r, vertex[1] * cage_r, vertex[2] * cage_r];
let s: f64 = centered
.iter()
.zip(prop_vals.iter())
.map(|(atom_pos, &q)| {
let dx = probe[0] - atom_pos[0];
let dy = probe[1] - atom_pos[1];
let dz = probe[2] - atom_pos[2];
let d2 = dx * dx + dy * dy + dz * dz;
if d2 < 1e-12 {
return 0.0; }
let d = d2.sqrt();
q / d.powi(config.exponent as i32)
})
.sum();
result.push(s);
}
}
apply_normalization(&mut result, config.normalize);
result
}
pub fn tanimoto_spectrophores(a: &[f64], b: &[f64]) -> f64 {
assert_eq!(
a.len(),
b.len(),
"Spectrophores vectors must have equal length"
);
let n = a.len() as f64;
if n == 0.0 {
return 1.0;
}
let mean_abs_diff = a
.iter()
.zip(b.iter())
.map(|(x, y)| (x - y).abs())
.sum::<f64>()
/ n;
1.0 / (1.0 + mean_abs_diff)
}
fn electrostatic_props(mol: &Molecule, heavy: &[AtomIdx]) -> Vec<f64> {
let all_charges = chematic_chem::gasteiger_charges(mol);
heavy
.iter()
.map(|&idx| *all_charges.get(idx.0 as usize).unwrap_or(&0.0))
.collect()
}
fn lipophilic_props(mol: &Molecule, heavy: &[AtomIdx]) -> Vec<f64> {
let all_logp = chematic_chem::logp_crippen_per_atom(mol);
heavy
.iter()
.map(|&idx| *all_logp.get(idx.0 as usize).unwrap_or(&0.0))
.collect()
}
fn aromatic_props(mol: &Molecule, heavy: &[AtomIdx]) -> Vec<f64> {
heavy
.iter()
.map(|&idx| if mol.atom(idx).aromatic { 1.0 } else { 0.0 })
.collect()
}
fn hbond_props(mol: &Molecule, heavy: &[AtomIdx]) -> Vec<f64> {
let n = mol.atom_count();
let mut scores = vec![0.0f64; n];
for feat in detect_features(mol) {
let contribution = match feat.ftype {
FeatureType::Donor => 1.0,
FeatureType::Acceptor => -1.0,
_ => continue,
};
let i = feat.atom.0 as usize;
if i < n {
scores[i] += contribution;
}
}
heavy.iter().map(|&idx| scores[idx.0 as usize]).collect()
}
fn apply_normalization(v: &mut [f64], mode: SpectrophoresNorm) {
match mode {
SpectrophoresNorm::None => {}
SpectrophoresNorm::ZScore => {
for chunk in v.chunks_mut(12) {
let n = chunk.len() as f64;
let mean = chunk.iter().sum::<f64>() / n;
let var = chunk.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
let std = var.sqrt();
if std > 1e-12 {
for x in chunk.iter_mut() {
*x = (*x - mean) / std;
}
} else {
for x in chunk.iter_mut() {
*x -= mean;
}
}
}
}
SpectrophoresNorm::L2 => {
let norm = v.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm > 1e-12 {
for x in v.iter_mut() {
*x /= norm;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
fn make_coords(mol: &Molecule) -> Coords3D {
crate::dg::generate_coords(mol)
}
#[test]
fn benzene_48_elements_finite() {
let mol = parse("c1ccccc1").unwrap();
let coords = make_coords(&mol);
let fp = spectrophores(&mol, &coords, &Default::default());
assert_eq!(fp.len(), 48, "must return 48 elements");
assert!(
fp.iter().all(|v| v.is_finite()),
"all values must be finite"
);
}
#[test]
fn empty_molecule_returns_zeros() {
let mol = parse("[H][H]").unwrap();
let coords = make_coords(&mol);
let fp = spectrophores(&mol, &coords, &Default::default());
assert_eq!(fp.len(), 48);
assert!(fp.iter().all(|v| *v == 0.0));
}
#[test]
fn same_molecule_tanimoto_is_one() {
let mol = parse("CC(=O)O").unwrap();
let coords = make_coords(&mol);
let fp = spectrophores(&mol, &coords, &Default::default());
let sim = tanimoto_spectrophores(&fp, &fp);
assert!((sim - 1.0).abs() < 1e-10, "tanimoto of self must be 1.0");
}
#[test]
fn zscore_normalization_finite() {
let mol = parse("CN1C=NC2=C1C(=O)N(C)C(=O)N2C").unwrap(); let coords = make_coords(&mol);
let config = SpectrophoresConfig {
normalize: SpectrophoresNorm::ZScore,
..Default::default()
};
let fp = spectrophores(&mol, &coords, &config);
assert_eq!(fp.len(), 48);
assert!(fp.iter().all(|v| v.is_finite()));
}
#[test]
fn different_molecules_tanimoto_less_than_one() {
let benzene = parse("c1ccccc1").unwrap();
let water = parse("O").unwrap();
let c1 = make_coords(&benzene);
let c2 = make_coords(&water);
let fp1 = spectrophores(&benzene, &c1, &Default::default());
let fp2 = spectrophores(&water, &c2, &Default::default());
let sim = tanimoto_spectrophores(&fp1, &fp2);
assert!(sim < 1.0, "benzene and water should differ: {sim}");
}
}