use omgkit_core::{element, BondOrder};
use std::collections::HashMap;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Table,
RingRelaxed,
Model,
}
#[derive(Debug, Clone, Copy)]
pub struct Param {
pub value: f64,
pub lo: f64,
pub hi: f64,
pub source: Source,
}
fn order_tag(o: BondOrder) -> Option<&'static str> {
match o {
BondOrder::Single => Some("1"),
BondOrder::Double => Some("2"),
BondOrder::Triple => Some("3"),
BondOrder::Aromatic => Some("ar"),
_ => None,
}
}
fn order_factor(o: BondOrder) -> f64 {
match o {
BondOrder::Aromatic => 0.920_6,
BondOrder::Double => 0.869_9,
BondOrder::Triple | BondOrder::Quadruple => 0.783_3,
_ => 1.0,
}
}
#[must_use]
pub fn covalent_radius(z: u8) -> f64 {
element::by_atomic_num(z).map_or(0.76, |e| f64::from(e.rcov))
}
#[must_use]
pub fn vdw_radius(z: u8) -> f64 {
element::by_atomic_num(z).map_or(1.70, |e| f64::from(e.rvdw))
}
type BondKey = (String, String, String, usize);
type AngleKey = (String, usize, u8, usize, usize);
type Row = (f64, f64, f64, f64);
fn bonds() -> &'static HashMap<BondKey, Row> {
static T: OnceLock<HashMap<BondKey, Row>> = OnceLock::new();
T.get_or_init(|| {
let mut m = HashMap::new();
for line in include_str!("../data/mmff.bonds.tsv").lines() {
if line.starts_with('#') {
continue;
}
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 9 {
continue;
}
let (Ok(ring), Ok(med), Ok(p05), Ok(p95), Ok(mean)) = (
f[3].parse::<usize>(),
f[5].parse::<f64>(),
f[6].parse::<f64>(),
f[7].parse::<f64>(),
f[8].parse::<f64>(),
) else {
continue;
};
m.insert(
(f[0].to_string(), f[1].to_string(), f[2].to_string(), ring),
(med, p05, p95, mean),
);
}
m
})
}
fn angles() -> &'static HashMap<AngleKey, Row> {
static T: OnceLock<HashMap<AngleKey, Row>> = OnceLock::new();
T.get_or_init(|| {
let mut m = HashMap::new();
for line in include_str!("../data/mmff.angles.tsv").lines() {
if line.starts_with('#') {
continue;
}
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 10 {
continue;
}
let (Ok(deg), Ok(ar), Ok(rs), Ok(rg), Ok(med), Ok(p05), Ok(p95), Ok(mean)) = (
f[1].parse::<usize>(),
f[2].parse::<u8>(),
f[3].parse::<usize>(),
f[4].parse::<usize>(),
f[6].parse::<f64>(),
f[7].parse::<f64>(),
f[8].parse::<f64>(),
f[9].parse::<f64>(),
) else {
continue;
};
m.insert((f[0].to_string(), deg, ar, rs, rg), (med, p05, p95, mean));
}
m
})
}
#[must_use]
pub fn bond_length(a: u8, b: u8, order: BondOrder, min_ring: usize) -> Param {
let (Some(ea), Some(eb)) = (element::by_atomic_num(a), element::by_atomic_num(b)) else {
return covalent_model(a, b, order);
};
let (sa, sb) = (ea.symbol, eb.symbol);
let (lo_s, hi_s) = if sa <= sb { (sa, sb) } else { (sb, sa) };
if let Some(tag) = order_tag(order) {
let t = bonds();
for (ring, src) in [(min_ring, Source::Table), (0, Source::RingRelaxed)] {
if ring == 0 && src == Source::RingRelaxed && min_ring == 0 {
break; }
if let Some(&(med, _, _, _)) =
t.get(&(lo_s.to_string(), hi_s.to_string(), tag.to_string(), ring))
{
return Param {
value: med,
lo: med * 0.97,
hi: med * 1.03,
source: src,
};
}
}
}
covalent_model(a, b, order)
}
fn covalent_model(a: u8, b: u8, order: BondOrder) -> Param {
let v = (covalent_radius(a) + covalent_radius(b)) * order_factor(order);
Param {
value: v,
lo: v * 0.97,
hi: v * 1.03,
source: Source::Model,
}
}
#[must_use]
pub fn angle(
center: u8,
degree: usize,
aromatic: bool,
ring_self: usize,
ring_shared: usize,
) -> Param {
let Some(ec) = element::by_atomic_num(center) else {
return angle_model(center, degree);
};
let sym = ec.symbol;
let ar = u8::from(aromatic);
let t = angles();
let mut tries: Vec<(AngleKey, Source)> = vec![(
(sym.to_string(), degree, ar, ring_self, ring_shared),
Source::Table,
)];
if ring_shared != 0 {
tries.push((
(sym.to_string(), degree, ar, ring_shared, ring_shared),
Source::RingRelaxed,
));
}
if ring_shared == 0 && ring_self != 0 {
tries.push((
(sym.to_string(), degree, ar, ring_self, 0),
Source::RingRelaxed,
));
tries.push(((sym.to_string(), degree, ar, 0, 0), Source::RingRelaxed));
}
for (k, src) in tries {
if let Some(&(med, p05, p95, _)) = t.get(&k) {
return Param {
value: med.to_radians(),
lo: p05.to_radians(),
hi: p95.to_radians(),
source: src,
};
}
}
if ring_shared != 0 {
return ring_angle_model(ring_shared, degree);
}
angle_model(center, degree)
}
fn ring_angle_model(ring_size: usize, degree: usize) -> Param {
#[allow(clippy::cast_precision_loss)]
let polygon = 180.0 - 360.0 / ring_size.max(3) as f64;
let ideal: f64 = match degree {
0..=2 => 180.0,
3 => 120.0,
_ => 109.471,
};
let v = polygon.min(ideal);
Param {
value: v.to_radians(),
lo: (v - 10.0).to_radians(),
hi: (v + 10.0).to_radians(),
source: Source::Model,
}
}
fn angle_model(_center: u8, degree: usize) -> Param {
let ideal: f64 = match degree {
0..=2 => 180.0,
3 => 120.0,
_ => 109.471,
};
Param {
value: ideal.to_radians(),
lo: (ideal - 15.0).to_radians(),
hi: (ideal + 15.0).to_radians(),
source: Source::Model,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_embedded_tables_match_the_ones_in_harness() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("workspace 根");
for name in ["mmff.bonds.tsv", "mmff.angles.tsv"] {
let mine = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("data")
.join(name),
)
.expect("crate 里那份");
let theirs = std::fs::read_to_string(root.join("harness/params").join(name))
.expect("harness 里那份");
assert_eq!(mine, theirs, "{name} 两份不一致 —— 生产与判据会用不同的数");
}
}
#[test]
fn the_tables_are_actually_parsed_and_hit() {
assert!(bonds().len() > 100, "键长表只读到 {} 行", bonds().len());
assert!(angles().len() > 150, "键角表只读到 {} 行", angles().len());
let p = bond_length(6, 1, BondOrder::Single, 0);
assert_eq!(p.source, Source::Table, "C–H 该查得到");
assert!((p.value - 1.094).abs() < 1e-9, "C–H 得到 {}", p.value);
let p = bond_length(6, 6, BondOrder::Aromatic, 6);
assert_eq!(p.source, Source::Table);
assert!((p.value - 1.397).abs() < 1e-9, "芳香 C–C 得到 {}", p.value);
let a = angle(6, 4, false, 0, 0);
assert_eq!(a.source, Source::Table);
assert!(
(a.value.to_degrees() - 109.4).abs() < 1e-6,
"sp³ 碳得到 {}",
a.value.to_degrees()
);
assert!(a.lo < a.value && a.value < a.hi, "p05 < 中位 < p95");
}
#[test]
fn a_miss_says_so_and_still_returns_something_usable() {
let p = bond_length(6, 8, BondOrder::Dative, 0);
assert_eq!(p.source, Source::Model);
assert!(p.value.is_finite() && p.value > 0.5, "{}", p.value);
let p = bond_length(74, 74, BondOrder::Single, 0);
assert_eq!(p.source, Source::Model);
assert!(p.value.is_finite() && p.value > 0.5);
let a = angle(6, 7, false, 0, 0);
assert_eq!(a.source, Source::Model);
assert!(a.value.is_finite() && a.value > 0.0);
}
#[test]
fn bond_lookup_does_not_care_which_atom_comes_first() {
for (a, b) in [(6u8, 1u8), (7, 6), (8, 6), (16, 6), (17, 6)] {
let x = bond_length(a, b, BondOrder::Single, 0);
let y = bond_length(b, a, BondOrder::Single, 0);
assert!(
(x.value - y.value).abs() < 1e-15 && x.source == y.source,
"{a}-{b} 换个次序就变了:{} vs {}",
x.value,
y.value
);
}
}
}