#![forbid(unsafe_code)]
use std::collections::{HashMap, VecDeque};
use chematic_core::{AtomIdx, BondIdx, Element, Molecule, MoleculeBuilder, validate_valence};
use crate::{hash::mol_hash, hydrogen::remove_hydrogens, tautomer::canonical_tautomer};
use chematic_smarts::{MatchConfig, find_matches_with_config, parse_smarts};
#[derive(Clone, Debug)]
pub struct SaltCatalog {
patterns: Vec<(&'static str, &'static str)>,
}
impl Default for SaltCatalog {
fn default() -> Self {
Self::new()
}
}
impl SaltCatalog {
pub fn new() -> Self {
Self {
patterns: vec(-[#1])-[#6](=[#8])[O-]"),
("formate", "[#6](=[#8])[O-]"),
(
"propionate",
"[#6](-[#1])(-[#1])-[#6](-[#1])-[#6](=[#8])[O-]",
),
("benzoate", "c1ccccc1-[#6](=[#8])[O-]"),
(
"trifluoroacetate",
"[#9]-[#6](-[#9])(-[#9])-[#6](=[#8])[O-]",
),
(
"mesylate",
"[#16](=[#8])(=[#8])-[#8]-[#6](-[#1])(-[#1])-[#1]",
),
("tosylate", "c1ccc(cc1)-[#16](=[#8])(=[#8])-[#8]"),
(
"nosylate",
"[#8]-[#6](-[#1])(-[#1])-[#8]-[#16](=[#8])(=[#8])-c1ccc([N+](=O)[O-])cc1",
),
("sulfate", "[#16](=[#8])(=[#8])(-[#8])-[#8]"),
("phosphate", "[#15](=[#8])(-[#8])(-[#8])-[#8]"),
(
"citrate",
"[#6](-[#6](=[#8])[O-])(-[#6](=[#8])[O-])-[#6](-[#8])-[#6](=[#8])[O-]",
),
(
"tartrate",
"[#6](-[#8])(-[#6](-[#8])-[#6](=[#8])[O-])-[#6](=[#8])[O-]",
),
("sodium_cation", "[Na+]"),
("potassium_cation", "[K+]"),
("lithium_cation", "[Li+]"),
("calcium_cation", "[Ca+2]"),
("magnesium_cation", "[Mg+2]"),
("chloride_anion", "[Cl-]"),
("bromide_anion", "[Br-]"),
("iodide_anion", "[I-]"),
("fluoride_anion", "[F-]"),
("oxide_anion", "[O-2]"),
("sulfate_anion", "[#16](=[#8])(=[#8])(-[#8])-[#8-]"),
("phosphate_anion", "[#15](=[#8])(-[#8])(-[#8-])-[#8]"),
("water", "[#8](-[#1])-[#1]"),
(
"dmso",
"[#16](=[#8])(-[#6](-[#1])(-[#1])-[#1])-[#6](-[#1])(-[#1])-[#1]",
),
("methanol", "[#6](-[#1])(-[#1])-[#8]-[#1]"),
("ethanol", "[#6](-[#1])(-[#1])-[#6](-[#1])(-[#1])-[#8]-[#1]"),
(
"isopropanol",
"[#6](-[#1])(-[#1])-[#6](-[#8]-[#1])(-[#1])-[#6](-[#1])(-[#1])-[#1]",
),
("borate", "[#5](-[#8])(-[#8])-[#8]"),
("ammonium", "[#7+;H0,H1,H2,H3]"),
],
}
}
pub fn add(&mut self, name: &'static str, smarts: &'static str) {
self.patterns.push((name, smarts));
}
pub fn is_salt(&self, frag: &Molecule) -> bool {
let config = MatchConfig {
max_visit_budget: Some(1_000_000),
max_matches: Some(1),
uniquify: false,
..Default::default()
};
for (_, smarts_str) in &self.patterns {
if let Ok(query) = parse_smarts(smarts_str)
&& !find_matches_with_config(&query, frag, &config).is_empty()
{
return true;
}
}
false
}
}
fn connected_components(mol: &Molecule) -> Vec<Vec<AtomIdx>> {
let n = mol.atom_count();
let mut visited = vec![false; n];
let mut components: Vec<Vec<AtomIdx>> = Vec::new();
for start in 0..n {
if visited[start] {
continue;
}
visited[start] = true;
let mut component = Vec::new();
let mut queue: VecDeque<AtomIdx> = VecDeque::new();
queue.push_back(AtomIdx(start as u32));
while let Some(current) = queue.pop_front() {
component.push(current);
for (neighbor, _) in mol.neighbors(current) {
let ni = neighbor.0 as usize;
if !visited[ni] {
visited[ni] = true;
queue.push_back(neighbor);
}
}
}
components.push(component);
}
components.sort_by_key(|b| std::cmp::Reverse(b.len()));
components
}
fn copy_bonds(mol: &Molecule, builder: &mut MoleculeBuilder, remap: &HashMap<AtomIdx, AtomIdx>) {
for i in 0..mol.bond_count() {
let bond = mol.bond(BondIdx(i as u32));
if let (Some(&new_a), Some(&new_b)) = (remap.get(&bond.atom1), remap.get(&bond.atom2)) {
let _ = builder.add_bond(new_a, new_b, bond.order);
}
}
}
fn is_salt_fragment(frag: &Molecule) -> bool {
let n = frag.atom_count();
if n == 1 {
let atom = frag.atom(AtomIdx(0));
return matches!(
atom.element.atomic_number(),
11 | 19 | 37 | 55 | 17 | 35 | 53 | 8 );
}
if n == 2 {
let a0 = frag.atom(AtomIdx(0)).element.atomic_number();
let a1 = frag.atom(AtomIdx(1)).element.atomic_number();
let bond_count = frag.bond_count();
if bond_count == 0 {
let metals = [11, 19, 37, 55]; let nonmetals = [17, 35, 53, 8]; return (metals.contains(&a0) && nonmetals.contains(&a1))
|| (metals.contains(&a1) && nonmetals.contains(&a0));
}
}
if n <= 4 {
let has_organic = frag.atoms().any(|(_, a)| a.element.atomic_number() == 6);
if !has_organic {
return true;
}
}
false
}
pub fn remove_salts(mol: &Molecule) -> Molecule {
remove_salts_with_catalog(mol, &SaltCatalog::new())
}
pub fn remove_salts_with_catalog(mol: &Molecule, catalog: &SaltCatalog) -> Molecule {
if mol.atom_count() == 0 {
return MoleculeBuilder::new().build();
}
let components = connected_components(mol);
let mut largest_non_salt: Option<&Vec<AtomIdx>> = None;
let mut largest_non_salt_size = 0;
for component in &components {
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for &old_idx in component {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
remap.insert(old_idx, new_idx);
}
copy_bonds(mol, &mut builder, &remap);
let frag = builder.build();
let is_salt = catalog.is_salt(&frag) || is_salt_fragment(&frag);
if !is_salt && component.len() > largest_non_salt_size {
largest_non_salt = Some(component);
largest_non_salt_size = component.len();
}
}
let component = largest_non_salt.unwrap_or(&components[0]);
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
let mut builder = MoleculeBuilder::new();
for &old_idx in component {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
remap.insert(old_idx, new_idx);
}
copy_bonds(mol, &mut builder, &remap);
builder.build()
}
pub fn largest_fragment(mol: &Molecule) -> Molecule {
remove_salts(mol)
}
pub fn normalize_groups(mol: &Molecule) -> Molecule {
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
let mut nitro_atoms = std::collections::HashSet::new();
let mut oxide_atoms = std::collections::HashSet::new();
let mut azide_atoms = std::collections::HashSet::new();
let mut sulfoxide_atoms = std::collections::HashSet::new();
for (idx, atom) in mol.atoms() {
if atom.element.atomic_number() == 7 && atom.charge == 1 {
detect_nitro(mol, idx, atom, &mut nitro_atoms, &mut oxide_atoms);
detect_azide(mol, idx, &mut azide_atoms);
}
if atom.element.atomic_number() == 16 {
detect_sulfoxide(mol, idx, &mut sulfoxide_atoms);
}
}
for (idx, atom) in mol.atoms() {
let mut new_atom = atom.clone();
if nitro_atoms.contains(&idx) {
if atom.element.atomic_number() == 7 || atom.element.atomic_number() == 8 {
new_atom.charge = 0;
}
}
if azide_atoms.contains(&idx) {
if atom.element.atomic_number() == 7 {
new_atom.charge = 0;
}
}
let new_idx = builder.add_atom(new_atom);
remap.insert(idx, new_idx);
}
for i in 0..mol.bond_count() {
let bond = mol.bond(chematic_core::BondIdx(i as u32));
let mut new_order = bond.order;
if nitro_atoms.contains(&bond.atom1) && nitro_atoms.contains(&bond.atom2) {
let a1_is_n = mol.atom(bond.atom1).element.atomic_number() == 7;
let a2_is_o = mol.atom(bond.atom2).element.atomic_number() == 8;
let a1_is_o = mol.atom(bond.atom1).element.atomic_number() == 8;
let a2_is_n = mol.atom(bond.atom2).element.atomic_number() == 7;
if (a1_is_n
&& a2_is_o
&& bond.order == chematic_core::BondOrder::Single
&& mol.atom(bond.atom2).charge == -1)
|| (a1_is_o
&& a2_is_n
&& bond.order == chematic_core::BondOrder::Single
&& mol.atom(bond.atom1).charge == -1)
{
new_order = chematic_core::BondOrder::Double;
}
}
if azide_atoms.contains(&bond.atom1) && azide_atoms.contains(&bond.atom2) {
let a1_is_n = mol.atom(bond.atom1).element.atomic_number() == 7;
let a2_is_n = mol.atom(bond.atom2).element.atomic_number() == 7;
if a1_is_n && a2_is_n && bond.order == chematic_core::BondOrder::Single {
new_order = chematic_core::BondOrder::Double;
}
}
if oxide_atoms.contains(&bond.atom1) || oxide_atoms.contains(&bond.atom2) {
}
if let (Some(&new_a1), Some(&new_a2)) = (remap.get(&bond.atom1), remap.get(&bond.atom2)) {
let _ = builder.add_bond(new_a1, new_a2, new_order);
}
}
builder.build()
}
fn detect_nitro(
mol: &Molecule,
idx: AtomIdx,
atom: &chematic_core::Atom,
nitro_atoms: &mut std::collections::HashSet<AtomIdx>,
oxide_atoms: &mut std::collections::HashSet<AtomIdx>,
) {
let o_nbrs: Vec<_> = mol
.neighbors(idx)
.filter(|(n, _)| mol.atom(*n).element.atomic_number() == 8)
.collect();
if o_nbrs.len() == 2 {
let mut has_double_o = false;
let mut has_single_neg_o = false;
for (o_idx, bid) in &o_nbrs {
let o = mol.atom(*o_idx);
let b = mol.bond(*bid);
if b.order == chematic_core::BondOrder::Double && o.charge == 0 {
has_double_o = true;
}
if b.order == chematic_core::BondOrder::Single && o.charge == -1 {
has_single_neg_o = true;
nitro_atoms.insert(*o_idx);
}
}
if has_double_o && has_single_neg_o {
nitro_atoms.insert(idx);
}
} else if let Some((o_idx, bid)) = o_nbrs.first() {
let o = mol.atom(*o_idx);
let b = mol.bond(*bid);
if atom.aromatic && b.order == chematic_core::BondOrder::Single && o.charge == -1 {
nitro_atoms.insert(idx);
oxide_atoms.insert(*o_idx);
}
}
}
fn detect_azide(
mol: &Molecule,
idx: AtomIdx,
azide_atoms: &mut std::collections::HashSet<AtomIdx>,
) {
let n_nbrs: Vec<_> = mol
.neighbors(idx)
.filter(|(n, _)| mol.atom(*n).element.atomic_number() == 7)
.collect();
for (n_idx, bid) in &n_nbrs {
let n = mol.atom(*n_idx);
let b = mol.bond(*bid);
if b.order == chematic_core::BondOrder::Triple && n.charge == 0 {
for (other_idx, other_bid) in n_nbrs.iter() {
if other_idx == n_idx {
continue;
}
let other = mol.atom(*other_idx);
let other_b = mol.bond(*other_bid);
if other_b.order == chematic_core::BondOrder::Single && other.charge == -1 {
azide_atoms.insert(idx);
azide_atoms.insert(*n_idx);
azide_atoms.insert(*other_idx);
}
}
}
}
}
fn detect_sulfoxide(
mol: &Molecule,
idx: AtomIdx,
sulfoxide_atoms: &mut std::collections::HashSet<AtomIdx>,
) {
for (o_idx, bid) in mol.neighbors(idx) {
if mol.atom(o_idx).element.atomic_number() == 8
&& mol.bond(bid).order == chematic_core::BondOrder::Double
{
sulfoxide_atoms.insert(idx);
sulfoxide_atoms.insert(o_idx);
}
}
}
pub fn has_zwitterion(mol: &Molecule) -> bool {
let mut has_positive = false;
let mut has_negative = false;
for (_, atom) in mol.atoms() {
if atom.charge > 0 {
has_positive = true;
} else if atom.charge < 0 {
has_negative = true;
}
if has_positive && has_negative {
return true;
}
}
false
}
pub fn normalize_zwitterion(mol: &Molecule) -> Molecule {
if !has_zwitterion(mol) {
return clone_molecule(mol);
}
let mut modifications: HashMap<AtomIdx, (i8, Option<u8>)> = HashMap::new();
let mut positive_atoms: Vec<AtomIdx> = Vec::new();
let mut negative_atoms: Vec<AtomIdx> = Vec::new();
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
let atom = mol.atom(idx);
if atom.charge > 0 {
positive_atoms.push(idx);
} else if atom.charge < 0 {
negative_atoms.push(idx);
}
}
for &neg_idx in &negative_atoms {
if positive_atoms.is_empty() {
continue;
}
let mut closest_pos_idx = positive_atoms[0];
let mut closest_distance = i32::MAX;
for &pos_idx in &positive_atoms {
if let Some(dist) = bfs_distance(mol, neg_idx, pos_idx)
&& dist < closest_distance
{
closest_distance = dist;
closest_pos_idx = pos_idx;
}
}
let neg_atom = mol.atom(neg_idx);
let pos_atom = mol.atom(closest_pos_idx);
let new_neg_charge = neg_atom.charge + 1;
let neg_h = neg_atom.hydrogen_count.unwrap_or(0);
modifications.insert(neg_idx, (new_neg_charge, Some(neg_h + 1)));
let pos_h = pos_atom.hydrogen_count.unwrap_or(0);
if pos_h > 0 {
let new_pos_charge = pos_atom.charge - 1;
modifications.insert(closest_pos_idx, (new_pos_charge, Some(pos_h - 1)));
}
}
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for i in 0..mol.atom_count() {
let old_idx = AtomIdx(i as u32);
let mut atom = mol.atom(old_idx).clone();
if let Some(&(new_charge, new_h)) = modifications.get(&old_idx) {
atom.charge = new_charge;
atom.hydrogen_count = new_h;
}
let new_idx = builder.add_atom(atom);
remap.insert(old_idx, new_idx);
}
copy_bonds(mol, &mut builder, &remap);
builder.build()
}
fn bfs_distance(mol: &Molecule, start: AtomIdx, end: AtomIdx) -> Option<i32> {
if start == end {
return Some(0);
}
let n = mol.atom_count();
let mut visited = vec![false; n];
let mut queue = std::collections::VecDeque::new();
queue.push_back((start, 0));
visited[start.0 as usize] = true;
while let Some((current, dist)) = queue.pop_front() {
for (neighbor, _) in mol.neighbors(current) {
if neighbor == end {
return Some(dist + 1);
}
let ni = neighbor.0 as usize;
if !visited[ni] {
visited[ni] = true;
queue.push_back((neighbor, dist + 1));
}
}
}
None
}
pub fn neutralize_charges(mol: &Molecule) -> Molecule {
let mut modifications: HashMap<AtomIdx, (i8, Option<u8>)> = HashMap::new();
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
let atom = mol.atom(idx);
let h = atom.hydrogen_count.unwrap_or(0);
match (atom.element, atom.charge) {
(Element::O, -1) => {
let has_c_neighbor = mol
.neighbors(idx)
.any(|(nb, _)| mol.atom(nb).element == Element::C);
if has_c_neighbor {
modifications.insert(idx, (0, Some(h + 1)));
}
}
(Element::N, 1) | (Element::O, 1) if h > 0 => {
modifications.insert(idx, (0, Some(h - 1)));
}
_ => {}
}
}
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for i in 0..mol.atom_count() {
let old_idx = AtomIdx(i as u32);
let mut atom = mol.atom(old_idx).clone();
if let Some(&(new_charge, new_h)) = modifications.get(&old_idx) {
atom.charge = new_charge;
atom.hydrogen_count = new_h;
}
let new_idx = builder.add_atom(atom);
remap.insert(old_idx, new_idx);
}
copy_bonds(mol, &mut builder, &remap);
builder.build()
}
pub fn remove_isotopes(mol: &Molecule) -> Molecule {
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for i in 0..mol.atom_count() {
let old_idx = AtomIdx(i as u32);
let mut atom = mol.atom(old_idx).clone();
atom.isotope = None;
let new_idx = builder.add_atom(atom);
remap.insert(old_idx, new_idx);
}
copy_bonds(mol, &mut builder, &remap);
builder.build()
}
pub fn remove_stereo(mol: &Molecule) -> Molecule {
use chematic_core::{BondOrder, Chirality};
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for i in 0..mol.atom_count() {
let old_idx = AtomIdx(i as u32);
let mut atom = mol.atom(old_idx).clone();
atom.chirality = Chirality::None;
let new_idx = builder.add_atom(atom);
remap.insert(old_idx, new_idx);
}
for i in 0..mol.bond_count() {
let bond = mol.bond(BondIdx(i as u32));
if let (Some(&new_a), Some(&new_b)) = (remap.get(&bond.atom1), remap.get(&bond.atom2)) {
let order = match bond.order {
BondOrder::Up | BondOrder::Down => BondOrder::Single,
other => other,
};
let _ = builder.add_bond(new_a, new_b, order);
}
}
builder.build()
}
pub fn clean_stereo_groups(mol: &Molecule) -> Molecule {
use chematic_core::{Chirality, StereoGroup};
let cleaned: Vec<StereoGroup> = mol
.stereo_groups()
.iter()
.filter_map(|g| {
let chiral_atoms: Vec<AtomIdx> = g
.atom_indices
.iter()
.copied()
.filter(|&idx| mol.atom(idx).chirality != Chirality::None)
.collect();
if chiral_atoms.is_empty() {
None
} else {
Some(StereoGroup::new(g.kind.clone(), chiral_atoms))
}
})
.collect();
let mut out = MoleculeBuilder::from_molecule(mol).build();
out.set_stereo_groups(cleaned);
out
}
pub fn prefer_organic(mol: &Molecule) -> Molecule {
if mol.atom_count() == 0 {
return MoleculeBuilder::new().build();
}
let components = connected_components(mol);
let mut largest_organic: Option<&Vec<AtomIdx>> = None;
let mut largest_organic_size = 0;
for component in &components {
let has_carbon = component
.iter()
.any(|&idx| mol.atom(idx).element.atomic_number() == 6);
if has_carbon && component.len() > largest_organic_size {
largest_organic = Some(component);
largest_organic_size = component.len();
}
}
let target_component = largest_organic.or_else(|| components.first());
if let Some(component) = target_component {
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for &old_idx in component {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
remap.insert(old_idx, new_idx);
}
copy_bonds(mol, &mut builder, &remap);
builder.build()
} else {
MoleculeBuilder::new().build()
}
}
pub fn reionize(mol: &Molecule) -> Molecule {
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
let mut atom = mol.atom(idx).clone();
let an = atom.element.atomic_number();
if an == 8 {
if let Some((c_idx, _)) = mol.neighbors(idx).find(|(neighbor, bond_idx)| {
mol.bond(*bond_idx).order == chematic_core::BondOrder::Single
&& mol.atom(*neighbor).element.atomic_number() == 6
}) {
let is_aromatic = mol.atom(c_idx).aromatic;
let has_double_bonded_o = mol.neighbors(c_idx).any(|(other, bond_idx)| {
mol.bond(bond_idx).order == chematic_core::BondOrder::Double
&& mol.atom(other).element.atomic_number() == 8
&& other != idx
});
if (is_aromatic || has_double_bonded_o) && atom.charge >= 0 {
atom.charge -= 1; }
}
}
if an == 7 {
let is_amide = mol.neighbors(idx).any(|(neighbor, bond_idx)| {
mol.bond(bond_idx).order == chematic_core::BondOrder::Single
&& mol.atom(neighbor).element.atomic_number() == 6
&& mol.neighbors(neighbor).any(|(o_neighbor, o_bond)| {
mol.bond(o_bond).order == chematic_core::BondOrder::Double
&& (mol.atom(o_neighbor).element.atomic_number() == 8
|| mol.atom(o_neighbor).element.atomic_number() == 16)
})
});
if !is_amide {
let h_count = chematic_core::implicit_hcount(mol, idx);
if (h_count == 2 || h_count == 1) && atom.charge <= 0 {
atom.charge += 1; }
}
}
let new_idx = builder.add_atom(atom);
remap.insert(idx, new_idx);
}
copy_bonds(mol, &mut builder, &remap);
builder.build()
}
pub fn uncharge(mol: &Molecule) -> Molecule {
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
let mut atom = mol.atom(idx).clone();
atom.charge = 0; let new_idx = builder.add_atom(atom);
remap.insert(idx, new_idx);
}
copy_bonds(mol, &mut builder, &remap);
builder.build()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum StandardizationStep {
LargestFragment,
NeutralizeCharges,
NormalizeGroups,
ZwitterionNormalization,
RemoveExplicitHydrogens,
CanonicalTautomer,
FragmentParent,
ChargeParent,
IsotopeParent,
StereoParent,
}
impl StandardizationStep {
pub fn as_str(self) -> &'static str {
match self {
Self::LargestFragment => "largest_fragment",
Self::NeutralizeCharges => "neutralize_charges",
Self::NormalizeGroups => "normalize_groups",
Self::ZwitterionNormalization => "zwitterion_normalization",
Self::RemoveExplicitHydrogens => "remove_explicit_hydrogens",
Self::CanonicalTautomer => "canonical_tautomer",
Self::FragmentParent => "fragment_parent",
Self::ChargeParent => "charge_parent",
Self::IsotopeParent => "isotope_parent",
Self::StereoParent => "stereo_parent",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PipelineStatus {
Unchanged,
Modified,
CompletedWithWarnings,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StandardizationWarning {
pub code: String,
pub message: String,
}
impl StandardizationWarning {
fn new(code: &str, message: String) -> Self {
Self {
code: code.to_string(),
message,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MoleculeSnapshot {
pub atoms: usize,
pub bonds: usize,
pub hash: u64,
}
impl MoleculeSnapshot {
fn from_mol(mol: &Molecule) -> Self {
Self {
atoms: mol.atom_count(),
bonds: mol.bond_count(),
hash: mol_hash(mol),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StandardizationStepReport {
pub step: StandardizationStep,
pub enabled: bool,
pub changed: bool,
pub before: MoleculeSnapshot,
pub after: MoleculeSnapshot,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StandardizationReport {
pub status: PipelineStatus,
pub input: MoleculeSnapshot,
pub output: MoleculeSnapshot,
pub steps: Vec<StandardizationStepReport>,
pub warnings: Vec<StandardizationWarning>,
}
impl StandardizationReport {
pub fn changed(&self) -> bool {
self.input.hash != self.output.hash
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ZwitterionHandling {
Keep,
#[default]
Normalize,
}
#[derive(Clone, Debug)]
pub struct StandardizeOptions {
pub canonical_tautomer: bool,
pub neutralize_charges: bool,
pub remove_explicit_h: bool,
pub largest_fragment_only: bool,
pub zwitterion_handling: ZwitterionHandling,
}
impl Default for StandardizeOptions {
fn default() -> Self {
Self {
canonical_tautomer: true,
neutralize_charges: true,
remove_explicit_h: true,
largest_fragment_only: false,
zwitterion_handling: ZwitterionHandling::Normalize,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct StandardizationPipeline {
options: StandardizeOptions,
}
impl StandardizationPipeline {
pub fn new(options: StandardizeOptions) -> Self {
Self { options }
}
pub fn options(&self) -> &StandardizeOptions {
&self.options
}
pub fn run(&self, mol: &Molecule) -> (Molecule, StandardizationReport) {
let input = MoleculeSnapshot::from_mol(mol);
let mut current = clone_molecule(mol);
let mut steps = Vec::new();
let mut warnings = detect_initial_warnings(mol);
let has_metals = current.atoms().any(|(_, a)| is_metal(a.element));
if has_metals {
current = disconnect_metals(¤t);
}
current = self.apply_stage(
current,
StandardizationStep::NeutralizeCharges,
self.options.neutralize_charges,
neutralize_charges,
&mut steps,
&mut warnings,
);
current = self.apply_stage(
current,
StandardizationStep::LargestFragment,
self.options.largest_fragment_only,
largest_fragment,
&mut steps,
&mut warnings,
);
let zwitterion_enabled = self.options.zwitterion_handling == ZwitterionHandling::Normalize;
current = self.apply_stage(
current,
StandardizationStep::ZwitterionNormalization,
zwitterion_enabled,
normalize_zwitterion,
&mut steps,
&mut warnings,
);
current = self.apply_stage(
current,
StandardizationStep::RemoveExplicitHydrogens,
self.options.remove_explicit_h,
remove_hydrogens,
&mut steps,
&mut warnings,
);
current = self.apply_stage(
current,
StandardizationStep::CanonicalTautomer,
self.options.canonical_tautomer,
canonical_tautomer,
&mut steps,
&mut warnings,
);
let output = MoleculeSnapshot::from_mol(¤t);
let status = if input.hash == output.hash {
PipelineStatus::Unchanged
} else if !warnings.is_empty() {
PipelineStatus::CompletedWithWarnings
} else {
PipelineStatus::Modified
};
(
current,
StandardizationReport {
status,
input,
output,
steps,
warnings,
},
)
}
fn apply_stage(
&self,
current: Molecule,
step: StandardizationStep,
enabled: bool,
f: fn(&Molecule) -> Molecule,
steps: &mut Vec<StandardizationStepReport>,
warnings: &mut Vec<StandardizationWarning>,
) -> Molecule {
let before = MoleculeSnapshot::from_mol(¤t);
let next = if enabled {
f(¤t)
} else {
clone_molecule(¤t)
};
let after = MoleculeSnapshot::from_mol(&next);
steps.push(StandardizationStepReport {
step,
enabled,
changed: before.hash != after.hash,
before,
after,
});
if enabled {
append_valence_warnings(step, &next, warnings);
}
next
}
}
fn clone_molecule(mol: &Molecule) -> Molecule {
MoleculeBuilder::from_molecule(mol).build()
}
fn detect_initial_warnings(mol: &Molecule) -> Vec<StandardizationWarning> {
let mut warnings = Vec::new();
let valence_errors = validate_valence(mol);
if !valence_errors.is_empty() {
warnings.push(StandardizationWarning::new(
"input_valence_validation_failed",
format!(
"input molecule has {} valence validation issue(s)",
valence_errors.len()
),
));
}
warnings
}
fn append_valence_warnings(
step: StandardizationStep,
mol: &Molecule,
warnings: &mut Vec<StandardizationWarning>,
) {
let errors = validate_valence(mol);
if errors.is_empty() {
return;
}
warnings.push(StandardizationWarning::new(
"valence_validation_failed",
format!(
"{} produced {} valence validation issue(s)",
step.as_str(),
errors.len()
),
));
}
fn disconnect_metals(mol: &Molecule) -> Molecule {
let mut builder = MoleculeBuilder::new();
for i in 0..mol.atom_count() {
builder.add_atom(mol.atom(AtomIdx(i as u32)).clone());
}
for i in 0..mol.bond_count() {
let bond = mol.bond(BondIdx(i as u32));
let atom1_is_metal = is_metal(mol.atom(bond.atom1).element);
let atom2_is_metal = is_metal(mol.atom(bond.atom2).element);
if !atom1_is_metal && !atom2_is_metal {
builder.add_bond(bond.atom1, bond.atom2, bond.order).ok();
}
}
builder.build()
}
fn is_metal(element: Element) -> bool {
matches!(
element.atomic_number(),
3 | 4
| 11 | 12 | 13
| 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31
| 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50
| 55 | 56 | 57..=71
| 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83
| 87 | 88 | 89..=103
| 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116
)
}
pub fn standardize(mol: &Molecule, opts: &StandardizeOptions) -> Molecule {
StandardizationPipeline::new(opts.clone()).run(mol).0
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
#[test]
fn largest_fragment_two_fragments_picks_larger() {
let mol = parse("CC.CCC").unwrap();
let result = largest_fragment(&mol);
assert_eq!(result.atom_count(), 3, "should keep propane (3 C)");
}
#[test]
fn largest_fragment_single_fragment_unchanged() {
let mol = parse("CC").unwrap();
let result = largest_fragment(&mol);
assert_eq!(result.atom_count(), 2);
}
#[test]
fn largest_fragment_keeps_benzene_over_ethane() {
let mol = parse("CC.c1ccccc1").unwrap();
let result = largest_fragment(&mol);
assert_eq!(result.atom_count(), 6, "should keep benzene (6 atoms)");
}
#[test]
fn largest_fragment_ionic_pair_keeps_one_atom() {
let mol = parse("[Na+].[Cl-]").unwrap();
let result = largest_fragment(&mol);
assert_eq!(result.atom_count(), 1);
}
#[test]
fn neutralize_neutral_molecule_unchanged() {
let mol = parse("CC").unwrap();
let result = neutralize_charges(&mol);
for i in 0..result.atom_count() {
let atom = result.atom(AtomIdx(i as u32));
assert_eq!(atom.charge, 0, "all atoms should remain neutral");
}
}
#[test]
fn neutralize_acetate_oxygen() {
let mol = parse("CC(=O)[O-]").unwrap();
let result = neutralize_charges(&mol);
let neutralized_o = (0..result.atom_count())
.map(|i| result.atom(AtomIdx(i as u32)))
.find(|a| a.element == Element::O && a.hydrogen_count == Some(1));
assert!(
neutralized_o.is_some(),
"neutralized [O-] should have hydrogen_count == Some(1)"
);
assert_eq!(
neutralized_o.unwrap().charge,
0,
"neutralized [O-] should have charge == 0"
);
}
#[test]
fn standardize_with_defaults() {
let mol = parse("CC(=O)[O-]").unwrap();
let opts = StandardizeOptions::default();
let result = standardize(&mol, &opts);
let has_neutral_o = (0..result.atom_count())
.map(|i| result.atom(AtomIdx(i as u32)))
.any(|a| a.element == Element::O && a.charge == 0);
assert!(has_neutral_o, "acetate oxygen should be neutralized");
assert!(
result.atom_count() >= 3,
"should have at least 3 atoms after standardization"
);
}
#[test]
fn standardize_skip_largest_fragment() {
let mol = parse("CC.CCC").unwrap();
let opts = StandardizeOptions {
zwitterion_handling: ZwitterionHandling::Normalize,
largest_fragment_only: false,
..Default::default()
};
let result = standardize(&mol, &opts);
assert_eq!(
result.atom_count(),
5,
"should keep both fragments when largest_fragment_only=false"
);
}
#[test]
fn pipeline_report_tracks_enabled_stage_changes() {
let mol = parse("CC.CCC").unwrap();
let pipeline = StandardizationPipeline::new(StandardizeOptions {
largest_fragment_only: true,
neutralize_charges: false,
remove_explicit_h: false,
canonical_tautomer: false,
zwitterion_handling: ZwitterionHandling::Keep,
});
let (result, report) = pipeline.run(&mol);
assert_eq!(result.atom_count(), 3);
assert_eq!(report.status, PipelineStatus::Modified);
assert!(report.changed());
assert_eq!(report.steps.len(), 5);
assert_eq!(report.steps[0].step, StandardizationStep::NeutralizeCharges);
assert!(!report.steps[0].enabled);
assert_eq!(report.steps[1].step, StandardizationStep::LargestFragment);
assert!(report.steps[1].enabled);
assert!(report.steps[1].changed);
}
#[test]
fn pipeline_report_marks_unchanged_clean_molecule() {
let mol = parse("CC").unwrap();
let pipeline = StandardizationPipeline::new(StandardizeOptions {
canonical_tautomer: false,
neutralize_charges: false,
remove_explicit_h: false,
largest_fragment_only: false,
zwitterion_handling: ZwitterionHandling::Keep,
});
let (_result, report) = pipeline.run(&mol);
assert_eq!(report.status, PipelineStatus::Unchanged);
assert!(!report.changed());
assert!(report.warnings.is_empty());
assert!(report.steps.iter().all(|s| !s.enabled && !s.changed));
}
#[test]
fn pipeline_report_disconnects_metal_bonds() {
let mol = parse("[Na]OC").unwrap();
assert_eq!(mol.bond_count(), 2, "input has Na-O and O-C bonds");
let pipeline = StandardizationPipeline::new(StandardizeOptions {
canonical_tautomer: false,
neutralize_charges: false,
remove_explicit_h: false,
largest_fragment_only: false,
zwitterion_handling: ZwitterionHandling::Keep,
});
let (result, _report) = pipeline.run(&mol);
assert_eq!(result.bond_count(), 1, "Na-O bond should be disconnected");
assert!(
result.bond(BondIdx(0)).atom1.0 < 3 && result.bond(BondIdx(0)).atom2.0 < 3,
"remaining bond should connect organic atoms"
);
}
#[test]
fn bug3_ionic_pair_neutralize_before_largest_fragment() {
let mol = parse("[NH3+].[OH-]").unwrap();
let pipeline = StandardizationPipeline::new(StandardizeOptions {
largest_fragment_only: true,
neutralize_charges: true,
remove_explicit_h: false,
canonical_tautomer: false,
zwitterion_handling: ZwitterionHandling::Normalize,
});
let (_result, report) = pipeline.run(&mol);
assert_eq!(report.steps.len(), 5, "Should have 5 steps in pipeline");
assert_eq!(
report.steps[0].step,
StandardizationStep::NeutralizeCharges,
"NeutralizeCharges must be step 0"
);
assert_eq!(
report.steps[1].step,
StandardizationStep::LargestFragment,
"LargestFragment must be step 1"
);
assert!(report.changed(), "Pipeline should report changes");
assert_eq!(
report.status,
PipelineStatus::Modified,
"Should be marked as Modified"
);
}
#[test]
fn remove_isotopes_strips_isotope_labels() {
let mol = parse("[13C]CC").unwrap();
let result = remove_isotopes(&mol);
for i in 0..result.atom_count() {
assert_eq!(
result.atom(chematic_core::AtomIdx(i as u32)).isotope,
None,
"atom {} should have no isotope",
i
);
}
}
#[test]
fn remove_isotopes_preserves_structure() {
let mol = parse("[13C]CC").unwrap();
let result = remove_isotopes(&mol);
assert_eq!(result.atom_count(), 3, "atom count preserved");
assert_eq!(result.bond_count(), 2, "bond count preserved");
}
#[test]
fn remove_stereo_strips_chirality() {
let mol = parse("N[C@@H](C)C(=O)O").unwrap();
let result = remove_stereo(&mol);
for i in 0..result.atom_count() {
use chematic_core::Chirality;
assert_eq!(
result.atom(chematic_core::AtomIdx(i as u32)).chirality,
Chirality::None,
"atom {} should have no chirality",
i
);
}
}
#[test]
fn remove_stereo_converts_wedge_bonds_to_single() {
let mol = parse("C[C@H](O)C").unwrap();
let result = remove_stereo(&mol);
for i in 0..result.bond_count() {
use chematic_core::BondOrder;
let bond = result.bond(chematic_core::BondIdx(i as u32));
assert_ne!(
bond.order,
BondOrder::Up,
"bond {} should not be Up after stereo removal",
i
);
assert_ne!(
bond.order,
BondOrder::Down,
"bond {} should not be Down after stereo removal",
i
);
}
}
#[test]
fn remove_stereo_preserves_structure() {
let mol = parse("N[C@@H](C)C(=O)O").unwrap();
let result = remove_stereo(&mol);
assert_eq!(result.atom_count(), 6, "atom count preserved");
assert_eq!(result.bond_count(), 5, "bond count preserved");
}
#[test]
fn parent_variant_step_names_distinct() {
let frag_parent = StandardizationStep::FragmentParent;
let charge_parent = StandardizationStep::ChargeParent;
let isotope_parent = StandardizationStep::IsotopeParent;
let stereo_parent = StandardizationStep::StereoParent;
assert_eq!(frag_parent.as_str(), "fragment_parent");
assert_eq!(charge_parent.as_str(), "charge_parent");
assert_eq!(isotope_parent.as_str(), "isotope_parent");
assert_eq!(stereo_parent.as_str(), "stereo_parent");
}
#[test]
fn prefer_organic_removes_inorganic_salts() {
let mol = parse("CCO.[Na+].[Cl-]").unwrap();
assert_eq!(mol.atom_count(), 5, "input has CCO + Na + Cl");
let result = prefer_organic(&mol);
assert_eq!(result.atom_count(), 3, "should keep only ethanol (C, C, O)");
}
#[test]
fn prefer_organic_keeps_organic_if_no_inorganic() {
let mol = parse("CC").unwrap();
let result = prefer_organic(&mol);
assert_eq!(result.atom_count(), 2, "ethane unchanged");
}
#[test]
fn prefer_organic_falls_back_to_largest() {
let mol = parse("C.C.C").unwrap();
let result = prefer_organic(&mol);
assert_eq!(
result.atom_count(),
1,
"falls back to largest fragment (one C)"
);
}
#[test]
fn uncharge_neutralizes_all_charges() {
let mol = parse("[NH4+].[OH-]").unwrap();
assert!(
mol.atoms().any(|(_, a)| a.charge != 0),
"input has charged atoms"
);
let result = uncharge(&mol);
for (_, atom) in result.atoms() {
assert_eq!(atom.charge, 0, "all atoms should be neutral");
}
}
#[test]
fn reionize_deprotonates_carboxylic_acids() {
let mol = parse("CC(=O)O").unwrap();
let result = reionize(&mol);
let has_negative_oxygen = result
.atoms()
.any(|(_, a)| a.element.atomic_number() == 8 && a.charge < 0);
assert!(
has_negative_oxygen,
"reionize should deprotonate carboxylic acids"
);
}
#[test]
fn reionize_protonates_amines() {
let mol = parse("CC(N)C").unwrap();
let result = reionize(&mol);
let has_positive_nitrogen = result
.atoms()
.any(|(_, a)| a.element.atomic_number() == 7 && a.charge > 0);
assert!(has_positive_nitrogen, "reionize should protonate amines");
}
#[test]
fn reionize_protects_amide_nitrogen() {
let mol = parse("CC(=O)N").unwrap();
let result = reionize(&mol);
let has_positive_nitrogen = result
.atoms()
.any(|(_, a)| a.element.atomic_number() == 7 && a.charge > 0);
assert!(
!has_positive_nitrogen,
"reionize should NOT protonate amide nitrogen"
);
}
#[test]
fn reionize_protects_thioamide_nitrogen() {
let mol = parse("CC(=S)N").unwrap();
let result = reionize(&mol);
let has_positive_nitrogen = result
.atoms()
.any(|(_, a)| a.element.atomic_number() == 7 && a.charge > 0);
assert!(
!has_positive_nitrogen,
"reionize should NOT protonate thioamide nitrogen (C=S conjugation)"
);
}
#[test]
fn normalize_groups_nitro() {
let mol = parse("C[N+](=O)[O-]").unwrap();
let result = normalize_groups(&mol);
let all_neutral = result.atoms().all(|(_, a)| a.charge == 0);
assert!(all_neutral, "nitro group should be neutralized");
let mut has_double_bond = false;
for (_, bond) in result.bonds() {
let a1 = result.atom(bond.atom1);
let a2 = result.atom(bond.atom2);
if ((a1.element.atomic_number() == 7 && a2.element.atomic_number() == 8)
|| (a1.element.atomic_number() == 8 && a2.element.atomic_number() == 7))
&& bond.order == chematic_core::BondOrder::Double
{
has_double_bond = true;
}
}
assert!(has_double_bond, "nitro should have N=O double bond");
}
#[test]
fn normalize_groups_azide() {
let mol = parse("[N-][N+]#N").unwrap();
let result = normalize_groups(&mol);
let all_neutral = result.atoms().all(|(_, a)| a.charge == 0);
assert!(all_neutral, "azide should be neutralized");
let mut has_double_bond_count = 0;
for (_, bond) in result.bonds() {
let a1 = result.atom(bond.atom1);
let a2 = result.atom(bond.atom2);
if a1.element.atomic_number() == 7
&& a2.element.atomic_number() == 7
&& bond.order == chematic_core::BondOrder::Double
{
has_double_bond_count += 1;
}
}
assert!(
has_double_bond_count > 0,
"azide should have N=N double bonds after normalization"
);
}
#[test]
fn normalize_groups_sulfoxide() {
let mol = parse("C[S](=O)C").unwrap();
let result = normalize_groups(&mol);
let mut has_s_double_o = false;
for (_, bond) in result.bonds() {
let a1 = result.atom(bond.atom1);
let a2 = result.atom(bond.atom2);
if ((a1.element.atomic_number() == 16 && a2.element.atomic_number() == 8)
|| (a1.element.atomic_number() == 8 && a2.element.atomic_number() == 16))
&& bond.order == chematic_core::BondOrder::Double
{
has_s_double_o = true;
}
}
assert!(has_s_double_o, "sulfoxide should have S=O double bond");
}
#[test]
fn remove_stereo_clears_stereo_groups() {
use chematic_core::{AtomIdx, StereoGroup, StereoGroupKind};
let mut mol = parse("[C@@H](F)(Cl)Br").unwrap();
mol.add_stereo_group(StereoGroup::new(
StereoGroupKind::Absolute,
vec![AtomIdx(0)],
));
assert_eq!(
mol.stereo_groups().len(),
1,
"precondition: group was added"
);
let stripped = remove_stereo(&mol);
assert_eq!(
stripped.stereo_groups().len(),
0,
"remove_stereo must clear stereo groups"
);
}
#[test]
fn clean_stereo_groups_drops_non_chiral_atoms() {
use chematic_core::{AtomIdx, StereoGroup, StereoGroupKind};
let mut mol = parse("C[C@@H](F)Cl").unwrap();
mol.add_stereo_group(StereoGroup::new(
StereoGroupKind::Absolute,
vec![AtomIdx(0), AtomIdx(1)], ));
let cleaned = clean_stereo_groups(&mol);
assert_eq!(
cleaned.stereo_groups().len(),
1,
"group must survive with 1 atom"
);
assert_eq!(
cleaned.stereo_groups()[0].atom_indices,
vec![AtomIdx(1)],
"only chiral atom must remain in group"
);
}
#[test]
fn clean_stereo_groups_drops_empty_groups() {
use chematic_core::{AtomIdx, StereoGroup, StereoGroupKind};
let mut mol = parse("CC").unwrap(); mol.add_stereo_group(StereoGroup::new(
StereoGroupKind::Absolute,
vec![AtomIdx(0)],
));
let cleaned = clean_stereo_groups(&mol);
assert_eq!(
cleaned.stereo_groups().len(),
0,
"empty group must be removed"
);
}
#[test]
fn clean_stereo_groups_preserves_valid_groups() {
use chematic_core::{AtomIdx, StereoGroup, StereoGroupKind};
let mut mol = parse("[C@@H](F)(Cl)Br").unwrap();
mol.add_stereo_group(StereoGroup::new(
StereoGroupKind::Absolute,
vec![AtomIdx(0)],
));
let cleaned = clean_stereo_groups(&mol);
assert_eq!(
cleaned.stereo_groups().len(),
1,
"valid group must be preserved"
);
assert_eq!(cleaned.stereo_groups()[0].atom_indices, vec![AtomIdx(0)]);
}
#[test]
fn normalize_groups_mixed_nitro_and_azide() {
let mol = parse("C[N+](=O)[O-].N[N+](=O)[O-]").unwrap();
let result = normalize_groups(&mol);
let all_neutral = result.atoms().all(|(_, a)| a.charge == 0);
assert!(all_neutral, "both nitro and azide should be neutralized");
}
}