use std::collections::HashMap;
use molrs::{AtomId, Atomistic};
use crate::ff::forcefield::{ForceField, Params, StyleDefs};
use super::meta::OplsTypingMeta;
fn end_score(pattern: &str, atom_type: &str, atom_class: Option<&str>) -> Option<i64> {
if is_wildcard(pattern) {
return Some(0);
}
if pattern == atom_type {
return Some(3);
}
if let Some(cls) = atom_class
&& pattern == cls
{
return Some(1);
}
None
}
fn is_wildcard(pattern: &str) -> bool {
pattern.is_empty() || pattern == "*" || pattern == "X"
}
fn sequence_score(pattern: &[&str], atoms: &[(&str, Option<&str>)]) -> Option<i64> {
debug_assert_eq!(pattern.len(), atoms.len());
let score_in = |order: &mut dyn Iterator<Item = &(&str, Option<&str>)>| -> Option<i64> {
let mut total = 0;
for (pat, (at_type, at_class)) in pattern.iter().zip(order) {
total += end_score(pat, at_type, *at_class)?;
}
Some(total)
};
let forward = score_in(&mut atoms.iter());
let reversed = score_in(&mut atoms.iter().rev());
match (forward, reversed) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) | (None, Some(a)) => Some(a),
(None, None) => None,
}
}
struct Match<'a> {
name: &'a str,
params: &'a Params,
}
struct Candidate {
name: String,
pattern: Vec<String>,
layer: u32,
params: Params,
}
pub struct CandidateTables {
bonds: Vec<Candidate>,
angles: Vec<Candidate>,
dihedrals: Vec<Candidate>,
type_to_class: HashMap<String, String>,
}
impl CandidateTables {
pub fn build(ff: &ForceField, meta: &OplsTypingMeta) -> Self {
let (type_to_class, class_to_layer) = build_type_class_layer(meta);
let layer_of = |classes: &[&str]| -> u32 {
classes
.iter()
.map(|c| class_to_layer.get(*c).copied().unwrap_or(0))
.max()
.unwrap_or(0)
};
let bonds = match ff.get_style("bond", "harmonic").map(|s| &s.defs) {
Some(StyleDefs::Bond(types)) => types
.iter()
.map(|t| Candidate {
name: t.name.clone(),
pattern: vec![t.itom.clone(), t.jtom.clone()],
layer: layer_of(&[&t.itom, &t.jtom]),
params: t.params.clone(),
})
.collect(),
_ => Vec::new(),
};
let angles = match ff.get_style("angle", "harmonic").map(|s| &s.defs) {
Some(StyleDefs::Angle(types)) => types
.iter()
.map(|t| Candidate {
name: t.name.clone(),
pattern: vec![t.itom.clone(), t.jtom.clone(), t.ktom.clone()],
layer: layer_of(&[&t.itom, &t.jtom, &t.ktom]),
params: t.params.clone(),
})
.collect(),
_ => Vec::new(),
};
let dihedrals = match ff.get_style("dihedral", "opls").map(|s| &s.defs) {
Some(StyleDefs::Dihedral(types)) => types
.iter()
.map(|t| Candidate {
name: t.name.clone(),
pattern: vec![
t.itom.clone(),
t.jtom.clone(),
t.ktom.clone(),
t.ltom.clone(),
],
layer: layer_of(&[&t.itom, &t.jtom, &t.ktom, &t.ltom]),
params: t.params.clone(),
})
.collect(),
_ => Vec::new(),
};
Self {
bonds,
angles,
dihedrals,
type_to_class,
}
}
fn atom_of<'a>(&'a self, atom_type: &'a str) -> (&'a str, Option<&'a str>) {
(
atom_type,
self.type_to_class.get(atom_type).map(String::as_str),
)
}
fn best<'a>(table: &'a [Candidate], atoms: &[(&str, Option<&str>)]) -> Option<Match<'a>> {
let mut best_key: Option<(i64, u32)> = None;
let mut best: Option<Match<'a>> = None;
for cand in table {
let pat: Vec<&str> = cand.pattern.iter().map(String::as_str).collect();
let Some(score) = sequence_score(&pat, atoms) else {
continue;
};
let key = (score, cand.layer);
if best_key.is_none_or(|cur| key > cur) {
best_key = Some(key);
best = Some(Match {
name: &cand.name,
params: &cand.params,
});
}
}
best
}
}
fn build_type_class_layer(
meta: &OplsTypingMeta,
) -> (HashMap<String, String>, HashMap<String, u32>) {
let mut type_to_class = HashMap::new();
let mut class_to_layer: HashMap<String, u32> = HashMap::new();
for (name, row) in meta.iter() {
type_to_class.insert(name.clone(), row.class.clone());
if !row.class.is_empty() && row.class != "*" {
let e = class_to_layer.entry(row.class.clone()).or_insert(0);
*e = (*e).max(row.layer);
}
}
(type_to_class, class_to_layer)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoMatch {
Error,
Skip,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BondedTerm {
Bond([String; 2]),
Angle([String; 3]),
Dihedral([String; 4]),
}
pub trait Estimator {
fn estimate(&self, term: &BondedTerm) -> Result<Option<Params>, String>;
}
pub fn typify_bonded(
mol_typed: &Atomistic,
tables: &CandidateTables,
policy: NoMatch,
) -> Result<Atomistic, String> {
typify_bonded_with(mol_typed, tables, policy, None)
}
pub fn typify_bonded_with(
mol_typed: &Atomistic,
tables: &CandidateTables,
policy: NoMatch,
estimator: Option<&dyn Estimator>,
) -> Result<Atomistic, String> {
let mut out = mol_typed.clone();
let type_of: HashMap<AtomId, String> = out
.atoms()
.filter_map(|(id, a)| a.get_str("type").map(|t| (id, t.to_string())))
.collect();
let bond_rows: Vec<_> = out
.bonds()
.map(|(id, b)| (id, b.nodes[0], b.nodes[1]))
.collect();
for (id, i, j) in bond_rows {
let (Some(ti), Some(tj)) = (type_of.get(&i), type_of.get(&j)) else {
continue; };
let atoms = [tables.atom_of(ti), tables.atom_of(tj)];
match CandidateTables::best(&tables.bonds, &atoms) {
Some(m) => write_match(&mut out, BondedKind::Bond(id), &m)?,
None => {
let term = BondedTerm::Bond([ti.clone(), tj.clone()]);
resolve_no_match(&mut out, BondedKind::Bond(id), &term, policy, estimator)?;
}
}
}
crate::ff::typifier::topology::typify_bonded_topology(&mut out)?;
let angle_rows: Vec<_> = out
.angles()
.map(|(id, a)| (id, a.nodes[0], a.nodes[1], a.nodes[2]))
.collect();
for (id, i, j, k) in angle_rows {
let (Some(ti), Some(tj), Some(tk)) = (type_of.get(&i), type_of.get(&j), type_of.get(&k))
else {
continue;
};
let atoms = [tables.atom_of(ti), tables.atom_of(tj), tables.atom_of(tk)];
match CandidateTables::best(&tables.angles, &atoms) {
Some(m) => write_match(&mut out, BondedKind::Angle(id), &m)?,
None => {
let term = BondedTerm::Angle([ti.clone(), tj.clone(), tk.clone()]);
resolve_no_match(&mut out, BondedKind::Angle(id), &term, policy, estimator)?;
}
}
}
let dih_rows: Vec<_> = out
.dihedrals()
.map(|(id, d)| (id, d.nodes[0], d.nodes[1], d.nodes[2], d.nodes[3]))
.collect();
for (id, i, j, k, l) in dih_rows {
let (Some(ti), Some(tj), Some(tk), Some(tl)) = (
type_of.get(&i),
type_of.get(&j),
type_of.get(&k),
type_of.get(&l),
) else {
continue;
};
let atoms = [
tables.atom_of(ti),
tables.atom_of(tj),
tables.atom_of(tk),
tables.atom_of(tl),
];
match CandidateTables::best(&tables.dihedrals, &atoms) {
Some(m) => write_match(&mut out, BondedKind::Dihedral(id), &m)?,
None => {
let term = BondedTerm::Dihedral([ti.clone(), tj.clone(), tk.clone(), tl.clone()]);
resolve_no_match(&mut out, BondedKind::Dihedral(id), &term, policy, estimator)?;
}
}
}
Ok(out)
}
enum BondedKind {
Bond(molrs::BondId),
Angle(molrs::AngleId),
Dihedral(molrs::DihedralId),
}
fn write_match(out: &mut Atomistic, kind: BondedKind, m: &Match<'_>) -> Result<(), String> {
set_term_prop(out, &kind, "type", m.name.to_string())?;
write_params(out, kind, m.params)
}
fn write_params(out: &mut Atomistic, kind: BondedKind, params: &Params) -> Result<(), String> {
for (key, val) in params.iter() {
set_term_prop(out, &kind, key, val)?;
}
for (key, val) in params.iter_strings() {
set_term_prop(out, &kind, key, val)?;
}
Ok(())
}
fn set_term_prop(
out: &mut Atomistic,
kind: &BondedKind,
key: &str,
val: impl Into<molrs::system::molgraph::PropValue>,
) -> Result<(), String> {
match kind {
BondedKind::Bond(id) => out.set_bond_prop(*id, key, val),
BondedKind::Angle(id) => out.set_angle_prop(*id, key, val),
BondedKind::Dihedral(id) => out.set_dihedral_prop(*id, key, val),
}
.map_err(|e| e.to_string())
}
fn resolve_no_match(
out: &mut Atomistic,
kind: BondedKind,
term: &BondedTerm,
policy: NoMatch,
estimator: Option<&dyn Estimator>,
) -> Result<(), String> {
if let Some(est) = estimator
&& let Some(params) = est.estimate(term)?
{
return write_params(out, kind, ¶ms);
}
match policy {
NoMatch::Error => Err(format!("OPLS: no bonded type for {term:?}")),
NoMatch::Skip => Ok(()), }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn end_score_exact_type_is_3() {
assert_eq!(end_score("opls_135", "opls_135", Some("CT")), Some(3));
}
#[test]
fn end_score_class_is_1() {
assert_eq!(end_score("CT", "opls_135", Some("CT")), Some(1));
}
#[test]
fn end_score_wildcard_is_0() {
assert_eq!(end_score("", "opls_135", Some("CT")), Some(0));
assert_eq!(end_score("*", "opls_135", Some("CT")), Some(0));
assert_eq!(end_score("X", "opls_135", Some("CT")), Some(0));
}
#[test]
fn end_score_no_match_is_none() {
assert_eq!(end_score("OH", "opls_135", Some("CT")), None);
assert_eq!(end_score("HC", "opls_140", Some("CT")), None);
}
#[test]
fn end_score_no_class_falls_through_to_none() {
assert_eq!(end_score("CT", "opls_135", None), None);
}
#[test]
fn sequence_score_sums_per_end() {
let atoms = [("opls_135", Some("CT")), ("opls_136", Some("CT"))];
assert_eq!(sequence_score(&["CT", "CT"], &atoms), Some(2));
}
#[test]
fn sequence_score_is_reversal_symmetric() {
let atoms = [("opls_154", Some("OH")), ("opls_135", Some("CT"))];
assert_eq!(sequence_score(&["CT", "OH"], &atoms), Some(2));
}
#[test]
fn sequence_score_returns_best_orientation() {
let atoms = [("opls_135", Some("CT")), ("opls_140", Some("HC"))];
assert_eq!(sequence_score(&["opls_135", ""], &atoms), Some(3));
}
#[test]
fn sequence_score_none_when_no_orientation_matches() {
let atoms = [("opls_135", Some("CT")), ("opls_140", Some("HC"))];
assert_eq!(sequence_score(&["OH", "OH"], &atoms), None);
}
fn cand(pattern: &[&str], layer: u32, k0: f64) -> Candidate {
Candidate {
name: pattern.join("-"),
pattern: pattern.iter().map(|s| s.to_string()).collect(),
layer,
params: Params::from_pairs(&[("k0", k0)]),
}
}
#[test]
fn ranking_fully_resolved_beats_wildcard() {
let table = vec![cand(&["", "HC"], 0, 1.0), cand(&["CT", "HC"], 0, 2.0)];
let atoms = [("opls_135", Some("CT")), ("opls_140", Some("HC"))];
let best = CandidateTables::best(&table, &atoms).expect("a match");
assert_eq!(
best.params.get("k0"),
Some(2.0),
"fully-resolved candidate wins"
);
}
#[test]
fn ranking_equal_score_higher_layer_wins() {
let table = vec![cand(&["CT", "HC"], 0, 1.0), cand(&["CT", "HC"], 2, 9.0)];
let atoms = [("opls_135", Some("CT")), ("opls_140", Some("HC"))];
let best = CandidateTables::best(&table, &atoms).expect("a match");
assert_eq!(
best.params.get("k0"),
Some(9.0),
"higher-layer candidate wins"
);
}
#[test]
fn ranking_no_candidate_matches_is_none() {
let table = vec![cand(&["OH", "OH"], 0, 1.0)];
let atoms = [("opls_135", Some("CT")), ("opls_140", Some("HC"))];
assert!(CandidateTables::best(&table, &atoms).is_none());
}
#[test]
fn class_to_layer_takes_the_max() {
use crate::ff::typifier::opls::OplsTypeRow;
let mut meta = OplsTypingMeta::new();
let row = |class: &str, layer: u32| OplsTypeRow {
class: class.to_string(),
def: Some("[C]".into()),
overrides: Vec::new(),
priority: None,
layer,
};
meta.insert("opls_a", row("CT", 0));
meta.insert("opls_b", row("CT", 2)); let (t2c, c2l) = build_type_class_layer(&meta);
assert_eq!(t2c.get("opls_a").map(String::as_str), Some("CT"));
assert_eq!(c2l.get("CT"), Some(&2), "class layer is the max over types");
}
}