use std::collections::{BTreeMap, BTreeSet};
use omgkit_chem::sssr::Ring;
use omgkit_core::MolBuilder;
use crate::geom::{regular_polygon, Point2, BOND_LEN};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Degradation {
BridgedRingSystem {
atoms: Vec<u32>,
template: crate::templates::Status,
},
HaptoCoordination {
metal: u32,
ring: Vec<u32>,
},
}
pub(crate) struct System<'a> {
pub atoms: Vec<u32>,
pub rings: Vec<&'a Ring>,
}
pub(crate) fn group<'a>(systems: &[Vec<u32>], rings: &'a [Ring]) -> Vec<System<'a>> {
systems
.iter()
.map(|atoms| {
let set: BTreeSet<u32> = atoms.iter().copied().collect();
System {
atoms: atoms.clone(),
rings: rings
.iter()
.filter(|r| r.atoms.iter().all(|a| set.contains(a)))
.collect(),
}
})
.collect()
}
pub(crate) fn layout_local(
mol: &MolBuilder,
sys: &System<'_>,
ranks: &[u32],
over: crate::templates::Override<'_>,
) -> (BTreeMap<u32, Point2>, Option<Degradation>) {
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
if sys.rings.is_empty() {
let (pos, st) = relax(mol, &sys.atoms, ranks, &sys.rings, over);
return (pos, Some(bridged(&sys.atoms, st)));
}
let mut order: Vec<&Ring> = sys.rings.clone();
order.sort_by_key(|r| (std::cmp::Reverse(r.atoms.len()), ring_key(r, ranks)));
let first = canonical_cycle(&order[0].atoms, ranks);
for (a, p) in first.iter().zip(regular_polygon(first.len(), 0.0)) {
pos.insert(*a, p);
}
let mut placed: BTreeSet<usize> = BTreeSet::from([0]);
let mut degraded = None;
while placed.len() < order.len() {
let mut best: Option<(usize, u32, u32)> = None;
for (i, r) in order.iter().enumerate() {
if placed.contains(&i) {
continue;
}
let shared: Vec<u32> = r
.atoms
.iter()
.copied()
.filter(|a| pos.contains_key(a))
.collect();
if shared.len() != 2 {
continue; }
let (u, v) = if (ranks[shared[0] as usize], shared[0])
<= (ranks[shared[1] as usize], shared[1])
{
(shared[0], shared[1])
} else {
(shared[1], shared[0])
};
if !adjacent_in_ring(r, u, v) {
continue; }
let key = (ring_key(r, ranks), ranks[u as usize].min(ranks[v as usize]));
let better = match best {
None => true,
Some((bi, bu, bv)) => {
key < (
ring_key(order[bi], ranks),
ranks[bu as usize].min(ranks[bv as usize]),
)
}
};
if better {
best = Some((i, u, v));
}
}
let Some((i, u, v)) = best else {
let (pos, st) = relax(mol, &sys.atoms, ranks, &sys.rings, over);
degraded = Some(bridged(&sys.atoms, st));
return (pos, degraded);
};
fuse_on_bond(order[i], u, v, ranks, &mut pos);
placed.insert(i);
}
(pos, degraded)
}
fn canonical_cycle(atoms: &[u32], ranks: &[u32]) -> Vec<u32> {
let n = atoms.len();
let start = (0..n)
.min_by_key(|i| (ranks[atoms[*i] as usize], atoms[*i]))
.expect("环非空");
let fwd: Vec<u32> = (0..n).map(|k| atoms[(start + k) % n]).collect();
let bwd: Vec<u32> = (0..n).map(|k| atoms[(start + n - k) % n]).collect();
let key = |v: &[u32]| -> Vec<u32> { v.iter().map(|a| ranks[*a as usize]).collect() };
if key(&bwd) < key(&fwd) {
bwd
} else {
fwd
}
}
fn bridged(atoms: &[u32], template: crate::templates::Status) -> Degradation {
let mut a = atoms.to_vec();
a.sort_unstable();
Degradation::BridgedRingSystem { atoms: a, template }
}
fn ring_key(r: &Ring, ranks: &[u32]) -> Vec<u32> {
let mut k: Vec<u32> = r.atoms.iter().map(|a| ranks[*a as usize]).collect();
k.sort_unstable();
k
}
fn adjacent_in_ring(r: &Ring, u: u32, v: u32) -> bool {
let n = r.atoms.len();
(0..n).any(|i| {
let (a, b) = (r.atoms[i], r.atoms[(i + 1) % n]);
(a == u && b == v) || (a == v && b == u)
})
}
fn fuse_on_bond(r: &Ring, u: u32, v: u32, ranks: &[u32], pos: &mut BTreeMap<u32, Point2>) {
let n = r.atoms.len();
let start = r.atoms.iter().position(|a| *a == u).expect("u 在环上");
let forward = r.atoms[(start + 1) % n] == v;
let seq: Vec<u32> = (0..n)
.map(|k| {
let i = if forward { start + k } else { start + n - k };
r.atoms[i % n]
})
.collect();
debug_assert_eq!(seq[0], u);
debug_assert_eq!(seq[1], v);
let (pu, pv) = (pos[&u], pos[&v]);
let mid = (pu + pv) * 0.5;
let along = (pv - pu).normalized();
let normal = Point2::new(-along.y, along.x);
let apothem = BOND_LEN / (2.0 * (std::f64::consts::PI / n as f64).tan());
let mut order: Vec<u32> = pos.keys().copied().collect();
order.sort_by_key(|a| (ranks[*a as usize], *a));
let anchor = centroid(order.iter().map(|a| pos[a]));
let c1 = mid + normal * apothem;
let c2 = mid - normal * apothem;
const TIE: f64 = 1e-9;
let s = (anchor - mid).dot(normal);
let center = if s < -TIE {
c1
} else if s > TIE {
c2
} else {
c1
};
let step = std::f64::consts::TAU / n as f64;
let sign = if pu.rotated_about(center, step).dist(pv) < 1e-6 {
1.0
} else {
debug_assert!(
pu.rotated_about(center, -step).dist(pv) < 1e-6,
"拼环的几何不自洽:两个转向都到不了对面那个原子"
);
-1.0
};
for (k, a) in seq.iter().enumerate().skip(2) {
pos.insert(*a, pu.rotated_about(center, sign * step * k as f64));
}
}
fn centroid(pts: impl Iterator<Item = Point2>) -> Point2 {
let mut sum = Point2::ORIGIN;
let mut n = 0.0;
for p in pts {
sum = sum + p;
n += 1.0;
}
if n == 0.0 {
Point2::ORIGIN
} else {
sum * (1.0 / n)
}
}
pub(crate) fn relax(
mol: &MolBuilder,
atoms: &[u32],
ranks: &[u32],
rings: &[&Ring],
over: crate::templates::Override<'_>,
) -> (BTreeMap<u32, Point2>, crate::templates::Status) {
let (hit, status) = crate::templates::lookup_with(mol, atoms, ranks, over);
if let Some(p) = hit {
return (p, status);
}
if let Some(p) = crate::arcs::place(rings, ranks) {
return (p, status);
}
let mut sorted: Vec<u32> = atoms.to_vec();
sorted.sort_by_key(|a| (ranks[*a as usize], *a));
let mut best: Option<(Quality, BTreeMap<u32, Point2>)> = None;
for seed in 0..SEEDS {
let out = relax_from(mol, &sorted, seed, rings, ranks);
let key = quality(mol, &out, ranks);
let take = match &best {
None => true,
Some((b, _)) => key < *b,
};
if take {
best = Some((key, out));
}
}
(best.expect("SEEDS 至少为 1").1, status)
}
const SEEDS: usize = 5;
type Quality = (usize, i64, Vec<(i64, i64)>);
fn quality(mol: &MolBuilder, pos: &BTreeMap<u32, Point2>, ranks: &[u32]) -> Quality {
let live: Vec<(u32, Point2, Point2)> = mol
.bonds()
.iter()
.enumerate()
.filter_map(|(i, b)| {
Some((
u32::try_from(i).ok()?,
*pos.get(&b.begin)?,
*pos.get(&b.end)?,
))
})
.collect();
let mut cross = 0usize;
for (k, (_, u1, v1)) in live.iter().enumerate() {
for (_, u2, v2) in &live[k + 1..] {
if crate::geom::segments_cross(*u1, *v1, *u2, *v2) {
cross += 1;
}
}
}
#[allow(clippy::cast_possible_truncation)]
let dev = live
.iter()
.map(|(_, u, v)| ((u.dist(*v) - BOND_LEN).abs() * 1e6).round() as i64)
.max()
.unwrap_or(0);
let mut by_rank: Vec<(u32, Point2)> =
pos.iter().map(|(a, p)| (ranks[*a as usize], *p)).collect();
by_rank.sort_by_key(|x| x.0);
#[allow(clippy::cast_possible_truncation)]
let seq: Vec<(i64, i64)> = by_rank
.iter()
.map(|(_, p)| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64))
.collect();
(cross, dev, seq)
}
fn relax_from(
mol: &MolBuilder,
atoms: &[u32],
seed: usize,
rings: &[&Ring],
ranks: &[u32],
) -> BTreeMap<u32, Point2> {
let idx: BTreeMap<u32, usize> = atoms.iter().enumerate().map(|(i, a)| (*a, i)).collect();
let n = atoms.len();
let mut bonded: Vec<(usize, usize)> = mol
.bonds()
.iter()
.filter_map(|b| Some((*idx.get(&b.begin)?, *idx.get(&b.end)?)))
.map(|(u, v)| if u <= v { (u, v) } else { (v, u) })
.collect();
bonded.sort_unstable();
if seed >= 4 {
if let Some(p) = polygon_seed(mol, atoms, rings, ranks, &idx) {
return settle(p, n, &bonded, atoms);
}
}
let order: Vec<usize> = match seed {
1 => (0..n).rev().collect(),
2 => bfs_order(n, &bonded),
3 => (0..n).step_by(2).chain((1..n).step_by(2)).collect(),
_ => (0..n).collect(),
};
let r = BOND_LEN * n as f64 / std::f64::consts::TAU.max(1.0);
let mut p: Vec<Point2> = vec![Point2::ORIGIN; n];
for (slot, &i) in order.iter().enumerate() {
p[i] = Point2::new(r, 0.0).rotated(std::f64::consts::TAU * slot as f64 / n as f64);
}
settle(p, n, &bonded, atoms)
}
fn settle(
mut p: Vec<Point2>,
n: usize,
bonded: &[(usize, usize)],
atoms: &[u32],
) -> BTreeMap<u32, Point2> {
for _ in 0..400 {
let mut force = vec![Point2::ORIGIN; n];
for &(i, j) in bonded {
let d = p[j] - p[i];
let len = d.norm().max(1e-6);
let f = d.normalized() * ((len - BOND_LEN) * 0.35);
force[i] = force[i] + f;
force[j] = force[j] - f;
}
for i in 0..n {
for j in (i + 1)..n {
let d = p[j] - p[i];
let len = d.norm().max(1e-6);
if len < BOND_LEN * 1.2 {
let f = d.normalized() * ((BOND_LEN * 1.2 - len) * 0.25);
force[i] = force[i] - f;
force[j] = force[j] + f;
}
}
}
for i in 0..n {
p[i] = p[i] + force[i];
}
}
atoms.iter().copied().zip(p).collect()
}
fn polygon_seed(
mol: &MolBuilder,
atoms: &[u32],
rings: &[&Ring],
ranks: &[u32],
idx: &BTreeMap<u32, usize>,
) -> Option<Vec<Point2>> {
let first = rings
.iter()
.filter(|r| r.atoms.iter().all(|a| idx.contains_key(a)))
.min_by_key(|r| (std::cmp::Reverse(r.atoms.len()), ring_key(r, ranks)))?;
let cyc = canonical_cycle(&first.atoms, ranks);
let n = atoms.len();
let mut p = vec![Point2::ORIGIN; n];
let mut placed = vec![false; n];
for (a, q) in cyc.iter().zip(regular_polygon(cyc.len(), 0.0)) {
let i = *idx.get(a)?;
p[i] = q;
placed[i] = true;
}
loop {
let next = atoms.iter().enumerate().find(|(i, a)| {
!placed[*i]
&& mol
.neighbors(**a)
.any(|(nb, _)| idx.get(&nb).is_some_and(|j| placed[*j]))
});
let Some((i, a)) = next else { break };
let anchor = mol
.neighbors(*a)
.filter_map(|(nb, _)| Some((ranks[nb as usize], nb, *idx.get(&nb)?)))
.filter(|(_, _, j)| placed[*j])
.min()
.map(|(_, _, j)| j)?;
let dir = {
let (mut c, mut k) = (Point2::ORIGIN, 0.0_f64);
for (j, on) in placed.iter().enumerate() {
if *on {
c = c + p[j];
k += 1.0;
}
}
let away = (p[anchor] - c * (1.0 / k.max(1.0))).normalized();
if away.norm() < 1e-9 {
0.0
} else {
away.angle()
}
};
p[i] = p[anchor] + Point2::new(BOND_LEN, 0.0).rotated(dir);
placed[i] = true;
}
for (i, on) in placed.iter().enumerate() {
if !on {
p[i] = Point2::new(BOND_LEN * n as f64, 0.0)
.rotated(std::f64::consts::TAU * i as f64 / n as f64);
}
}
Some(p)
}
fn bfs_order(n: usize, bonded: &[(usize, usize)]) -> Vec<usize> {
let mut adj = vec![Vec::new(); n];
for &(i, j) in bonded {
adj[i].push(j);
adj[j].push(i);
}
for a in &mut adj {
a.sort_unstable();
}
let mut seen = vec![false; n];
let mut out = Vec::with_capacity(n);
for start in 0..n {
if seen[start] {
continue;
}
seen[start] = true;
let mut q = std::collections::VecDeque::from([start]);
while let Some(x) = q.pop_front() {
out.push(x);
for &y in &adj[x] {
if !seen[y] {
seen[y] = true;
q.push_back(y);
}
}
}
}
out
}
pub(crate) fn place_candidates(
mol: &MolBuilder,
local: &BTreeMap<u32, Point2>,
anchor: u32,
at: Point2,
dir: Point2,
) -> [BTreeMap<u32, Point2>; 2] {
let a = local[&anchor];
let by_xy = |u: &Point2, v: &Point2| u.x.total_cmp(&v.x).then(u.y.total_cmp(&v.y));
let mut us: Vec<Point2> = mol
.neighbors(anchor)
.filter_map(|(n, _)| local.get(&n))
.map(|p| (*p - a).normalized())
.collect();
us.sort_by(by_xy);
let from = if us.len() == 2 {
let mut bisect = Point2::ORIGIN;
for u in &us {
bisect = bisect + *u;
}
bisect
} else {
let mut pts: Vec<Point2> = local.values().copied().collect();
pts.sort_by(by_xy);
centroid(pts.into_iter()) - a
};
let to = dir.normalized();
let theta = if from.norm() < 1e-9 {
0.0
} else {
to.angle() - from.angle()
};
let straight: BTreeMap<u32, Point2> = local
.iter()
.map(|(k, p)| (*k, (*p - a).rotated(theta) + at))
.collect();
let flipped = straight
.iter()
.map(|(k, p)| (*k, p.mirrored(at, to)))
.collect();
[straight, flipped]
}
#[cfg(test)]
mod tests {
#[test]
fn the_bond_that_carries_a_ring_system_lands_on_the_symmetry_axis_of_its_two_ring_bonds() {
use super::*;
use omgkit_chem::{rings::fused_ring_systems, sssr::ring_set};
let smi = "Cl[Cu](Cl)([N+]1=C2C=CC=CC2=CC=C1)[N+]3=C4C=CC=CC4=CC=C3";
let mut m = omgkit_io::smiles::parse(smi).expect("SMILES 该能解析");
omgkit_chem::pipeline::sanitize(&mut m).expect("该能 sanitize");
let ranks = crate::ranks_of(&m);
let rings_all = ring_set(&m);
let systems = group(&fused_ring_systems(&m), &rings_all);
let at = Point2::new(3.0, -1.0);
let dir = Point2::new(0.6, 0.8);
let mut checked = 0usize;
for s in &systems {
let (local, _) = layout_local(&m, s, &ranks, None);
for &anchor in &s.atoms {
let inside: Vec<u32> = m
.neighbors(anchor)
.map(|(n, _)| n)
.filter(|n| local.contains_key(n))
.collect();
if inside.len() != 2 || m.neighbors(anchor).all(|(n, _)| local.contains_key(&n)) {
continue;
}
for cand in place_candidates(&m, &local, anchor, at, dir) {
let exo = (dir * -1.0).normalized();
let angs: Vec<f64> = inside
.iter()
.map(|n| {
let v = (cand[n] - cand[&anchor]).normalized();
v.dot(exo).clamp(-1.0, 1.0).acos().to_degrees()
})
.collect();
assert!(
(angs[0] - angs[1]).abs() < 1e-9,
"环外键没落在对称轴上:原子 {anchor} 处 {:.4}° / {:.4}°",
angs[0],
angs[1]
);
assert!(
(angs[0] - 120.0).abs() < 1e-9,
"六元环上该是 120°,实得 {:.4}°",
angs[0]
);
checked += 1;
}
}
}
assert!(checked >= 2, "一个接口都没查到,这条判据空过了");
}
#[test]
fn a_tie_between_the_two_ring_centres_is_decided_by_a_rule_not_by_rounding() {
use super::*;
let r = Ring {
atoms: vec![0, 1, 2, 3],
bonds: vec![0, 1, 2, 3],
};
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
pos.insert(0, Point2::new(0.0, 0.0));
pos.insert(1, Point2::new(1.0, 0.0));
let ranks = [0u32, 1, 2, 3];
let anchor = centroid(pos.values().copied());
assert!(
anchor.x == 0.5 && anchor.y == 0.0,
"这个摆法下质心该精确等于 mid,实得 ({}, {})",
anchor.x,
anchor.y
);
fuse_on_bond(&r, 0, 1, &ranks, &mut pos);
let c2 = pos[&2];
assert!(
c2.y > 0.0,
"平局时该取 `c1`(法线正向那个),实得原子 2 落在 y={:.4}",
c2.y
);
}
#[test]
fn the_ring_layout_does_not_care_how_sssr_wrote_the_cycles() {
use super::*;
let smi = "O=C1C[N+]23CC[N+]45CC(=O)O[Ni]24(O1)(OC(=O)C3)OC(=O)C5";
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
let ranks = crate::ranks_of(&m);
let rings_all = omgkit_chem::sssr::ring_set(&m);
let systems = group(&omgkit_chem::rings::fused_ring_systems(&m), &rings_all);
let sys = systems
.iter()
.max_by_key(|s| s.rings.len())
.expect("该有一个环系统");
assert!(sys.rings.len() >= 5, "这个分子该有五个环共用一个金属");
let base = layout_local(&m, sys, &ranks, None).0;
assert!(!base.is_empty(), "布局该给出坐标");
for k in 0..6usize {
for rev in [false, true] {
let rotated: Vec<Ring> = sys
.rings
.iter()
.map(|r| {
let n = r.atoms.len();
let idx: Vec<usize> = (0..n)
.map(|i| if rev { (k + n - i) % n } else { (k + i) % n })
.collect();
Ring {
atoms: idx.iter().map(|i| r.atoms[*i]).collect(),
bonds: (0..n)
.map(|i| {
let (x, y) = (idx[i], idx[(i + 1) % n]);
r.bonds[if rev { y } else { x }]
})
.collect(),
}
})
.collect();
let shuffled = System {
atoms: sys.atoms.clone(),
rings: rotated.iter().collect(),
};
let got = layout_local(&m, &shuffled, &ranks, None).0;
assert_eq!(
base.len(),
got.len(),
"环的序列转 {k} 步 rev={rev} 之后原子数变了"
);
for (a, p) in &base {
let q = got[a];
assert!(
(p.x - q.x).abs() < 1e-9 && (p.y - q.y).abs() < 1e-9,
"环的原子序列转 {k} 步 rev={rev} 之后布局就变了:\
原子 {a} 从 ({:.4},{:.4}) 挪到 ({:.4},{:.4})",
p.x,
p.y,
q.x,
q.y
);
}
}
}
}
#[test]
fn a_bridged_system_is_relaxed_from_several_starts() {
let mut won = 0;
for smi in [
"CC1(C)[C@@H]2CC[C@@]1(C)C(=O)C2", "CN1CC[C@]23c4c5ccc(O)c4O[C@H]2[C@@H](O)C=C[C@H]3[C@H]1C5", "CN1[C@H]2CC[C@@H]1C[C@@H](C2)OC(=O)C(CO)c1ccccc1", ] {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
let ranks = omgkit_io::canon::canonical_ranks(&m);
let systems = omgkit_chem::rings::fused_ring_systems(&m);
let rs = omgkit_chem::sssr::ring_set(&m);
let mut checked = 0;
for sys in group(&systems, &rs) {
if sys.rings.is_empty() || sys.atoms.len() < 6 {
continue;
}
let (pos, deg) = layout_local(&m, &sys, &ranks, None);
if deg.is_none() {
continue;
}
let single = relax_from(
&m,
&{
let mut a = sys.atoms.clone();
a.sort_by_key(|x| (ranks[*x as usize], *x));
a
},
0,
&sys.rings,
&ranks,
);
let (best, _, _) = quality(&m, &pos, &ranks);
let (one, _, _) = quality(&m, &single, &ranks);
assert!(
best <= one,
"{smi}:多起点挑出来的解({best} 处自交)还不如单起点({one} 处)"
);
if best < one {
won += 1;
}
checked += 1;
}
assert!(checked >= 1, "{smi}:一个环系统都没查到");
}
assert!(
won >= 1,
"多起点在这三个桥环分子上一次都没赢过单起点 —— 那它就是白跑的"
);
}
use super::*;
use omgkit_chem::{rings::fused_ring_systems, sssr::ring_set};
fn prep(smi: &str) -> MolBuilder {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
m
}
fn layout(smi: &str) -> (BTreeMap<u32, Point2>, Option<Degradation>) {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let rings = ring_set(&m);
let sys = group(&fused_ring_systems(&m), &rings);
layout_local(&m, &sys[0], &ranks, None)
}
fn bond_lengths(m: &MolBuilder, pos: &BTreeMap<u32, Point2>) -> Vec<f64> {
m.bonds()
.iter()
.filter_map(|b| Some(pos.get(&b.begin)?.dist(*pos.get(&b.end)?)))
.collect()
}
#[test]
fn a_single_ring_is_a_regular_polygon() {
for smi in ["C1CC1", "C1CCC1", "c1ccccc1", "C1CCCCCC1"] {
let m = prep(smi);
let (pos, deg) = layout(smi);
assert_eq!(deg, None, "{smi} 不该退化");
assert_eq!(pos.len(), m.num_atoms(), "{smi} 有原子没放上");
for d in bond_lengths(&m, &pos) {
assert!((d - BOND_LEN).abs() < 1e-9, "{smi} 键长 {d}");
}
}
}
#[test]
fn ortho_fused_rings_share_exactly_one_bond_and_keep_unit_bonds() {
for smi in [
"c1ccc2ccccc2c1",
"c1ccc2[nH]ccc2c1",
"c1ccc2c(c1)Cc1ccccc1-2",
] {
let m = prep(smi);
let (pos, deg) = layout(smi);
assert_eq!(deg, None, "{smi} 不该退化");
let ring_atoms: BTreeSet<u32> = pos.keys().copied().collect();
for b in m.bonds() {
if ring_atoms.contains(&b.begin) && ring_atoms.contains(&b.end) {
let d = pos[&b.begin].dist(pos[&b.end]);
assert!((d - BOND_LEN).abs() < 1e-9, "{smi} 环内键长 {d}");
}
}
let pts: Vec<Point2> = pos.values().copied().collect();
for i in 0..pts.len() {
for j in (i + 1)..pts.len() {
assert!(pts[i].dist(pts[j]) > 0.5, "{smi} 有两个原子挤在一起");
}
}
}
}
#[test]
fn not_in_the_table_and_no_fingerprint_at_all_are_reported_apart() {
use crate::templates::Status;
let m = prep("C1CC2CCC3CCC4CCC5CCC1C1C2C3C4C51");
let ranks = omgkit_io::canon::canonical_ranks(&m);
let rs = omgkit_chem::sssr::ring_set(&m);
let syss = group(&omgkit_chem::rings::fused_ring_systems(&m), &rs);
let sys = syss.iter().max_by_key(|s| s.atoms.len()).expect("有环系统");
assert_eq!(
crate::templates::lookup(&m, &sys.atoms, &ranks).1,
Status::NotInTable,
"指纹算得出来、表里没有,该报 NotInTable"
);
let mut found = 0usize;
for smi in [
"C12C3=C4C5=C1[Fe]23456789C%10C6=C7C8=C9%10",
"CN(C)C[C-]12C3=C4C5=C1[Fe++]23456789[C-]%10C6=C7C8=C9%10",
] {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let rs = omgkit_chem::sssr::ring_set(&m);
for sys in group(&omgkit_chem::rings::fused_ring_systems(&m), &rs) {
if crate::templates::lookup(&m, &sys.atoms, &ranks).1 == Status::NoFingerprint {
found += 1;
}
}
}
assert!(
found > 0,
"这两个分子该有环系统报 NoFingerprint,实际一个都没有 —— 判据验不到东西了"
);
}
#[test]
fn a_bridged_skeleton_says_whether_its_coordinates_came_from_the_table() {
for (smi, want) in [
("C1CC2CCC1CC2", true),
("C1C2CC3CC1CC(C2)C3", true),
(
"CN1CC[C@]23c4c5ccc(O)c4O[C@H]2[C@@H](O)C=C[C@H]3[C@H]1C5",
true,
),
("C1CC2CCC3CCC4CCC5CCC1C1C2C3C4C51", false),
] {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let rs = omgkit_chem::sssr::ring_set(&m);
let syss = group(&omgkit_chem::rings::fused_ring_systems(&m), &rs);
let sys = syss.iter().max_by_key(|s| s.atoms.len()).expect("有环系统");
let (_, deg) = layout_local(&m, sys, &ranks, None);
let Some(Degradation::BridgedRingSystem { atoms, template }) = deg else {
panic!("{smi} 该报桥环退化,得到 {deg:?}");
};
assert_eq!(
template == crate::templates::Status::Hit,
want,
"{smi} 的查表状态报成了 {template:?}"
);
if want {
let (pos, _) = layout_local(&m, sys, &ranks, None);
let (tpl, _) = crate::templates::lookup(&m, &atoms, &ranks);
let tpl = tpl.expect("报了 Hit 就该查得到");
for (a, p) in &tpl {
let got = pos.get(a).expect("每个原子都该有坐标");
assert!(
got.dist(*p) < 1e-9,
"{smi} 原子 {a}:画出来的 {got:?} 不是表里的 {p:?}"
);
}
}
}
}
#[test]
fn a_bridged_system_says_so_instead_of_pretending() {
let (pos, deg) = layout("C1CC2CCC1CC2");
assert!(
matches!(deg, Some(Degradation::BridgedRingSystem { .. })),
"桥环必须报退化,得到 {deg:?}"
);
assert_eq!(pos.len(), 8, "退化也要把每个原子都放上");
for p in pos.values() {
assert!(p.x.is_finite() && p.y.is_finite(), "退化解不能给出 NaN");
}
}
#[test]
fn the_layout_does_not_depend_on_how_the_ring_was_written() {
let shapes: Vec<Vec<i64>> = ["C1Cc2cccc3cccc1c23", "c1cc2CCc3cccc(c1)c23"]
.iter()
.map(|smi| shape_key(smi))
.collect();
assert_eq!(shapes[0], shapes[1], "同一分子的两种写法给出了不同形状");
}
fn shape_key(smi: &str) -> Vec<i64> {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let rings = ring_set(&m);
let sys = group(&fused_ring_systems(&m), &rings);
let s = sys.iter().max_by_key(|s| s.atoms.len()).expect("有环系统");
let (pos, _) = layout_local(&m, s, &ranks, None);
let pts: Vec<Point2> = pos.values().copied().collect();
let mut ds: Vec<i64> = (0..pts.len())
.flat_map(|i| ((i + 1)..pts.len()).map(move |j| (i, j)))
.map(|(i, j)| (pts[i].dist(pts[j]) * 1e4).round() as i64)
.collect();
ds.sort_unstable();
ds
}
}
#[cfg(test)]
mod generator {
use super::*;
use crate::geom::Point2;
const TOP: usize = 50;
const SHORTLIST: usize = 16;
const TRIES: usize = 20_000;
const ESCALATE: usize = 400_000;
fn splitmix(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
type MolScore = (usize, usize, usize, usize, usize, usize);
fn score_on_molecules(mols: &[String], skel: &str, coords: &[(f64, f64)]) -> MolScore {
let mut out: MolScore = (0, 0, 0, 0, 0, 0);
for smi in mols {
let Ok(mut m) = omgkit_io::smiles::parse(smi) else {
continue;
};
if omgkit_chem::pipeline::sanitize(&mut m).is_err() {
continue;
}
omgkit_io::stereo::perceive_bond_stereo(&mut m);
for style in &crate::style::Style::ALL {
let d = crate::generate_with(&m, style, Some((skel, coords)));
let grown = d.drawn(&m);
let mol = &*grown;
out.0 += usize::from(!d.crossings.is_empty());
const CRAMPED: f64 = 15.0;
let mut cramped = 0usize;
for at in 0..u32::try_from(mol.num_atoms()).expect("原子数超出 u32") {
let here = d.coords[at as usize];
let mut angs: Vec<f64> = mol
.neighbors(at)
.map(|(nb, _)| {
(d.coords[nb as usize] - here)
.angle()
.to_degrees()
.rem_euclid(360.0)
})
.collect();
if angs.len() < 3 {
continue;
}
angs.sort_by(|x, y| x.partial_cmp(y).expect("角度不会是 NaN"));
for k in 0..angs.len() {
if (angs[(k + 1) % angs.len()] - angs[k]).rem_euclid(360.0) < CRAMPED {
cramped += 1;
}
}
}
out.5 += usize::from(cramped > 0);
out.1 += usize::from(!d.unresolved.is_empty());
const OVERLAP: f64 = 0.05;
for i in 0..d.coords.len() {
for j in (i + 1)..d.coords.len() {
if d.coords[i].dist(d.coords[j]) < OVERLAP {
out.2 += 1;
}
}
}
let labels: Vec<Option<crate::label::Label>> = (0..mol.num_atoms())
.map(|a| {
crate::render::label_at(
mol,
u32::try_from(a).expect("原子数超出 u32"),
style,
&d.coords,
)
})
.collect();
for b in mol.bonds() {
if crate::render::is_squeezed(
d.coords[b.begin as usize],
d.coords[b.end as usize],
labels[b.begin as usize].as_ref(),
labels[b.end as usize].as_ref(),
style,
) {
out.3 += 1;
}
}
for a in 0..u32::try_from(mol.num_atoms()).expect("原子数超出 u32") {
if !crate::render::is_collinear(mol, a, &d.coords) {
continue;
}
let mut doubles = 0usize;
let mut triple = false;
for (_, bi) in mol.neighbors(a) {
match mol.bonds()[bi as usize].order {
omgkit_core::BondOrder::Triple => triple = true,
omgkit_core::BondOrder::Double => doubles += 1,
_ => {}
}
}
if !(triple || doubles >= 2) {
out.4 += 1;
}
}
}
}
out
}
fn offer(
pool: &mut Vec<(Quality, BTreeMap<u32, Point2>)>,
q: Quality,
p: BTreeMap<u32, Point2>,
) {
if pool.iter().any(|(b, _)| b.2 == q.2) {
return; }
pool.push((q, p));
pool.sort_by(|x, y| x.0.cmp(&y.0));
pool.truncate(SHORTLIST);
}
fn flatten(p: &BTreeMap<u32, Point2>, ranks: &[u32]) -> Vec<(f64, f64)> {
let mut v: Vec<(u32, Point2)> = p.iter().map(|(a, q)| (ranks[*a as usize], *q)).collect();
v.sort_by_key(|x| x.0);
v.iter().map(|(_, q)| (q.x, q.y)).collect()
}
fn pick_best(
pool: Vec<(Quality, BTreeMap<u32, Point2>)>,
mols: &[String],
skel: &str,
ranks: &[u32],
) -> Option<(Quality, BTreeMap<u32, Point2>)> {
pool.into_iter()
.map(|(q, p)| {
let flat = flatten(&p, ranks);
(score_on_molecules(mols, skel, &flat), q, p)
})
.min_by(|x, y| {
let key =
|v: &(MolScore, Quality, BTreeMap<u32, Point2>)| (mol_key(&v.0), v.1.clone());
key(x).cmp(&key(y))
})
.map(|(_, q, p)| (q, p))
}
fn mol_key(s: &MolScore) -> (usize, usize, usize, usize, usize, usize) {
(s.5, s.0, s.1, s.2, s.3, s.4)
}
fn one_skeleton(skel: &str, n: usize, mols: &[String]) -> Option<String> {
let mut m = omgkit_io::smiles::parse(skel).ok()?;
omgkit_chem::pipeline::sanitize(&mut m).ok()?;
let ranks = omgkit_io::canon::canonical_ranks(&m);
let atoms: Vec<u32> = (0..u32::try_from(m.num_atoms()).ok()?).collect();
let mut sorted = atoms.clone();
sorted.sort_by_key(|a| (ranks[*a as usize], *a));
let cnt = sorted.len();
let idx: BTreeMap<u32, usize> = sorted.iter().enumerate().map(|(i, a)| (*a, i)).collect();
let bonded: Vec<(usize, usize)> = m
.bonds()
.iter()
.filter_map(|b| Some((*idx.get(&b.begin)?, *idx.get(&b.end)?)))
.collect();
let rs = omgkit_chem::sssr::ring_set(&m);
let sys = group(&omgkit_chem::rings::fused_ring_systems(&m), &rs);
let s = sys.iter().max_by_key(|s| s.atoms.len())?;
let mut pool: Vec<(Quality, BTreeMap<u32, Point2>)> = Vec::new();
for seed in 0..SEEDS {
let out = relax_from(&m, &sorted, seed, &s.rings, &ranks);
let q = quality(&m, &out, &ranks);
offer(&mut pool, q, out);
}
let mut st = 0x51ED_270B_D5AB_C0DEu64 ^ (cnt as u64);
let r = BOND_LEN * cnt as f64 / std::f64::consts::TAU;
for k in 0..ESCALATE.max(TRIES) {
if k == TRIES && pool.first().is_some_and(|(q, _)| q.0 == 0) {
break;
}
let mut p = vec![Point2::ORIGIN; cnt];
for (i, q) in p.iter_mut().enumerate() {
let j = (splitmix(&mut st) % 1000) as f64 / 1000.0 - 0.5;
let t = std::f64::consts::TAU * (i as f64 + j * 3.0) / cnt as f64;
let rad = r * (1.0 + ((splitmix(&mut st) % 1000) as f64 / 1000.0 - 0.5) * 0.6);
*q = Point2::new(rad, 0.0).rotated(t);
}
let out = settle(p, cnt, &bonded, &sorted);
let q = quality(&m, &out, &ranks);
offer(&mut pool, q, out);
}
if let Some(p) = crate::arcs::place(&s.rings, &ranks) {
let q = quality(&m, &p, &ranks);
offer(&mut pool, q, p);
}
let best = pick_best(pool, mols, skel, &ranks)?;
let mut by_rank: Vec<(u32, Point2)> = best
.1
.iter()
.map(|(a, p)| (ranks[*a as usize], *p))
.collect();
by_rank.sort_by_key(|x| x.0);
let mut line = format!(" (\"{skel}\", &[");
for (_, p) in &by_rank {
line.push_str(&format!("({:.6}, {:.6}), ", p.x, p.y));
}
#[allow(clippy::cast_precision_loss)]
let dev = best.0 .1 as f64 / 1e6;
line.push_str(&format!(
"]), // 出现 {n} 次,自交 {},偏差 {dev:.3}",
best.0 .0
));
Some(line)
}
#[test]
fn a_cramped_substituent_outranks_a_bond_crossing() {
let skel = "C1CC2CCC1CC2";
let mols: Vec<String> = [
r"C1CC2CCN1C(=C/c1cnccc1)\C2=O",
"C1C[S+]2CC[S+]1CC2",
"CCCCC12CCC(CC1=O)(CC2)O",
"CCOC(C1C(C2CCC1CC2)C(=O)OCC)=O",
"COC(C1=C[C@H]2[C@@H](C[C@@H]1OC2=O)C#N)=O",
"COc1c(c(ccc1/C=C1/C(C2CCN1CC2)=O)OC)OC",
"COc1cc2c(ccnc2cc1)[C@H](C1CC2CCN1CC2CC)O",
"COc1ccccc1/C=C1/C(C2CCN1CC2)=O",
]
.iter()
.map(|s| (*s).to_string())
.collect();
let (m, ranks, pool) = candidate_pool(skel);
let flat = |p: &BTreeMap<u32, Point2>| flatten(p, &ranks);
let scores: Vec<MolScore> = pool
.iter()
.map(|(_, p)| score_on_molecules(&mols, skel, &flat(p)))
.collect();
let _ = &m;
let opposed = scores
.iter()
.any(|a| scores.iter().any(|b| a.5 < b.5 && a.0 > b.0));
assert!(opposed, "候选里没有「挤压更少但交叉更多」的对立,判据空过");
let lo_cramp = scores.iter().map(|s| s.5).min().expect("非空");
let lo_cross = scores.iter().map(|s| s.0).min().expect("非空");
let picked = pick_best(pool, &mols, skel, &ranks).expect("名单非空");
let got = score_on_molecules(&mols, skel, &flat(&picked.1));
assert_eq!(
got.5, lo_cramp,
"挑出来的挤压 {} 不是最小的 {lo_cramp}",
got.5
);
assert!(
got.0 > lo_cross,
"挑出来的交叉 {} 已经是最小的 {lo_cross} —— 那说明这条骨架上两个键\
指向同一个候选,验不出次序,该换骨架",
got.0
);
}
type Pool = (MolBuilder, Vec<u32>, Vec<(Quality, BTreeMap<u32, Point2>)>);
fn candidate_pool(skel: &str) -> Pool {
let mut m = omgkit_io::smiles::parse(skel).expect("该能解析");
omgkit_chem::pipeline::sanitize(&mut m).expect("该能 sanitize");
let ranks = omgkit_io::canon::canonical_ranks(&m);
let rs = omgkit_chem::sssr::ring_set(&m);
let sys = group(&omgkit_chem::rings::fused_ring_systems(&m), &rs);
let s = sys.iter().max_by_key(|s| s.atoms.len()).expect("有环系");
let mut sorted: Vec<u32> = (0..u32::try_from(m.num_atoms()).unwrap()).collect();
sorted.sort_by_key(|a| (ranks[*a as usize], *a));
let cnt = sorted.len();
let idx: BTreeMap<u32, usize> = sorted.iter().enumerate().map(|(i, a)| (*a, i)).collect();
let mut bonded: Vec<(usize, usize)> = m
.bonds()
.iter()
.filter_map(|b| Some((*idx.get(&b.begin)?, *idx.get(&b.end)?)))
.map(|(u, v)| if u <= v { (u, v) } else { (v, u) })
.collect();
bonded.sort_unstable();
let mut pool: Vec<(Quality, BTreeMap<u32, Point2>)> = Vec::new();
for seed in 0..SEEDS {
let out = relax_from(&m, &sorted, seed, &s.rings, &ranks);
let q = quality(&m, &out, &ranks);
offer(&mut pool, q, out);
}
let mut st = 0x51ED_270B_D5AB_C0DEu64 ^ (cnt as u64);
let r = BOND_LEN * cnt as f64 / std::f64::consts::TAU;
for _ in 0..4000 {
let mut p = vec![Point2::ORIGIN; cnt];
for (i, q) in p.iter_mut().enumerate() {
let j = (splitmix(&mut st) % 1000) as f64 / 1000.0 - 0.5;
let t = std::f64::consts::TAU * (i as f64 + j * 3.0) / cnt as f64;
let rad = r * (1.0 + ((splitmix(&mut st) % 1000) as f64 / 1000.0 - 0.5) * 0.6);
*q = Point2::new(rad, 0.0).rotated(t);
}
let out = settle(p, cnt, &bonded, &sorted);
let q = quality(&m, &out, &ranks);
offer(&mut pool, q, out);
}
assert!(pool.len() >= 4, "只攒到 {} 个候选,验不出东西", pool.len());
(m, ranks, pool)
}
#[test]
fn the_whole_molecule_score_is_what_decides() {
let skel = "C1C2CC3CC1CC(C2)C3";
let mols: Vec<String> = [
"N#CC1(c2ccccc2N)C2CC3CC(C2)CC1C3",
"O=C(O)CC12CC3CC(C1)CC(C(=O)O)(C3)C2",
"O=C(O)C12CC3CC(CC(CCBr)(C3)C1)C2",
"NCCC12CC3CC(C1)CC(CCN)(C3)C2",
"NCC12CC3CC(C1)CC(CN)(C3)C2",
"NC12CC3CC(C1)CC(N)(C3)C2",
"CNC12CC3CC(CC(S)(C3)C1)C2",
"O=C(O)CSC12CC3CC(CC(C3)C1)C2",
]
.iter()
.map(|s| (*s).to_string())
.collect();
let (m, ranks, pool) = candidate_pool(skel);
let _ = &m;
let flat = |p: &BTreeMap<u32, Point2>| flatten(p, &ranks);
let scores: Vec<MolScore> = pool
.iter()
.map(|(_, p)| score_on_molecules(&mols, skel, &flat(p)))
.collect();
let keys: Vec<_> = scores.iter().map(mol_key).collect();
let lo = keys.iter().min().expect("非空");
assert!(
keys.iter().max() > Some(lo),
"候选的整分子分数全一样,分不出高下,判据空过"
);
assert!(
&keys[0] > lo,
"`Quality` 排第一的就已经是整分子最好的 —— 这条判据在这个骨架上\
验不出东西了,该换骨架"
);
let picked = pick_best(pool, &mols, skel, &ranks).expect("名单非空");
let got = mol_key(&score_on_molecules(&mols, skel, &flat(&picked.1)));
assert_eq!(&got, lo, "实现挑出来的整分子分数不是名单里最好的");
}
#[test]
#[ignore]
fn regenerate_templates() {
let text = std::fs::read_to_string("../../harness/corpus/large.smi").unwrap();
let extra = std::fs::read_to_string("../../harness/corpus/bridged.smi").unwrap();
let mut freq: BTreeMap<String, usize> = BTreeMap::new();
let mut must: BTreeSet<String> = BTreeSet::new();
let mut by_skel: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (line, is_extra) in text
.lines()
.map(|l| (l, false))
.chain(extra.lines().map(|l| (l, true)))
{
let smi = line.split_whitespace().next().unwrap_or("");
if smi.is_empty() || smi.starts_with('#') {
continue;
}
let Ok(mut m) = omgkit_io::smiles::parse(smi) else {
continue;
};
if omgkit_chem::pipeline::sanitize(&mut m).is_err() {
continue;
}
if m.num_atoms() < 2 {
continue;
}
let ranks = omgkit_io::canon::canonical_ranks(&m);
let rs = omgkit_chem::sssr::ring_set(&m);
let mut degraded_here: Vec<String> = Vec::new();
for sys in group(&omgkit_chem::rings::fused_ring_systems(&m), &rs) {
let (_, deg) = layout_local(&m, &sys, &ranks, None);
if deg.is_none() {
continue;
}
if let Some(k) = crate::templates::skeleton_of(&m, &sys.atoms, &ranks) {
degraded_here.push(k.clone());
if is_extra {
must.insert(k);
} else {
*freq.entry(k).or_default() += 1;
}
}
}
if degraded_here.len() == 1 {
by_skel
.entry(degraded_here.remove(0))
.or_default()
.push(omgkit_io::canon::canonical_smiles(&m).smiles);
}
}
let mut v: Vec<(String, usize)> = freq.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let mut keep: Vec<(String, usize)> = v
.iter()
.enumerate()
.filter(|(i, (k, _))| *i < TOP || must.contains(k))
.map(|(_, kv)| kv.clone())
.collect();
for k in &must {
if !keep.iter().any(|(s, _)| s == k) {
keep.push((k.clone(), 0));
}
}
keep.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for v in by_skel.values_mut() {
v.sort_unstable();
v.dedup();
}
let lines: Vec<Option<String>> = std::thread::scope(|sc| {
let handles: Vec<_> = keep
.iter()
.map(|(skel, n)| {
let ms = by_skel.get(skel).cloned().unwrap_or_default();
sc.spawn(move || one_skeleton(skel, *n, &ms))
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
println!("// 本表由 rings.rs 的 `regenerate_templates` 生成,勿手改。");
println!("pub(crate) const TABLE: &[(&str, &[(f64, f64)])] = &[");
let mut kept = 0usize;
for l in lines.into_iter().flatten() {
println!("{l}");
kept += 1;
}
println!("];");
println!("// 共 {kept} 条");
}
}