#![forbid(unsafe_code)]
use std::sync::OnceLock;
use chematic_core::{AtomIdx, Molecule};
use chematic_perception::find_sssr;
use chematic_smarts::{
MatchConfig, QueryMolecule, find_matches_with_rings_and_config, parse_smarts,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PkaSiteType {
Acid,
Base,
}
#[derive(Debug, Clone)]
pub struct PkaSite {
pub atom_idx: AtomIdx,
pub pka: f64,
pub site_type: PkaSiteType,
pub group_name: &'static str,
}
static PKA_RULES: &[(&str, &str, f64, PkaSiteType)] = &[
(
"carboxylic_acid",
"[OX2H1][CX3](=O)",
4.0,
PkaSiteType::Acid,
),
("tetrazole_nh", "[nH;r5;$(n1nnnc1)]", 4.9, PkaSiteType::Acid),
("thiol", "[SX2H1]", 8.3, PkaSiteType::Acid),
(
"hydroxamic_acid",
"[OX2H1][NX3][CX3](=O)",
8.7,
PkaSiteType::Acid,
),
(
"sulfonamide_nh",
"[NX3H1][SX4](=O)(=O)",
10.1,
PkaSiteType::Acid,
),
("phenol", "[OX2H1][cX3]", 10.0, PkaSiteType::Acid),
(
"guanidine",
"[NH;!R;$(N/C(=[NH])/N)]",
12.5,
PkaSiteType::Base,
),
(
"cyclic_amine_5",
"[NX3H1;r5;!$(N-c);!$(NC=O)]",
11.3,
PkaSiteType::Base,
),
(
"piperazine_nh",
"[NX3H1;r6;$(N1CCNCC1);!$(N-c);!$(NC=O)]",
9.8,
PkaSiteType::Base,
),
(
"morpholine_nh",
"[NX3H1;r6;$(N1CCOCC1);!$(NC=O)]",
8.3,
PkaSiteType::Base,
),
(
"thiomorpholine_nh",
"[NX3H1;r6;$(N1CCSCC1);!$(NC=O)]",
7.0,
PkaSiteType::Base,
),
(
"cyclic_amine_6",
"[NX3H1;r6;!$(N-c);!$(NC=O)]",
11.0,
PkaSiteType::Base,
),
(
"amine_secondary",
"[NX3H1;!R;!$(N-c);!$(NC=O);!$(NS(=O))]",
10.8,
PkaSiteType::Base,
),
(
"amine_primary",
"[NX3H2;!R;!$(N-c);!$(NC=O)]",
10.6,
PkaSiteType::Base,
),
(
"piperazine_tertiary",
"[NX3H0;r6;$(N1CCNCC1);!$(N-c);!$(NC=O)]",
8.7,
PkaSiteType::Base,
),
(
"morpholine_tertiary",
"[NX3H0;r6;$(N1CCOCC1);!$(NC=O)]",
7.4,
PkaSiteType::Base,
),
(
"thiomorpholine_tertiary",
"[NX3H0;r6;$(N1CCSCC1);!$(NC=O)]",
6.5,
PkaSiteType::Base,
),
(
"cyclic_amine_tertiary_6",
"[NX3H0;r6;!$(N-c);!$(NC=O)]",
9.5,
PkaSiteType::Base,
),
(
"cyclic_amine_tertiary_5",
"[NX3H0;r5;!$(N-c);!$(NC=O)]",
10.2,
PkaSiteType::Base,
),
(
"amine_tertiary",
"[NX3H0;!R;!$(N-c);!$(NC=O)]",
9.8,
PkaSiteType::Base,
),
(
"imidazole",
"[nX2H0;r5;$(n1c[nH]cc1)]",
6.9,
PkaSiteType::Base,
),
(
"aromatic_amine",
"[NX3;!$(NC=O);$(N-c)]",
4.6,
PkaSiteType::Base,
),
("pyridine", "[nX2H0;r6]", 5.2, PkaSiteType::Base),
];
struct CompiledRule {
name: &'static str,
query: QueryMolecule,
pka: f64,
site_type: PkaSiteType,
}
static COMPILED: OnceLock<Vec<CompiledRule>> = OnceLock::new();
fn compiled_rules() -> &'static [CompiledRule] {
COMPILED.get_or_init(|| {
PKA_RULES
.iter()
.filter_map(|&(name, smarts, pka, site_type)| {
parse_smarts(smarts).ok().map(|q| CompiledRule {
name,
query: q,
pka,
site_type,
})
})
.collect()
})
}
pub fn predict_pka(mol: &Molecule) -> Vec<PkaSite> {
let rules = compiled_rules();
let rings = find_sssr(mol);
let config = MatchConfig::default();
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut sites: Vec<PkaSite> = Vec::new();
for rule in rules {
let matches = find_matches_with_rings_and_config(&rule.query, mol, &rings, &config);
for atom_map in &matches {
if let Some(&atom_idx) = atom_map.get(&0)
&& seen.insert(atom_idx.0)
{
sites.push(PkaSite {
atom_idx,
pka: rule.pka,
site_type: rule.site_type,
group_name: rule.name,
});
}
}
}
sites
}
pub fn pka_acid(mol: &Molecule) -> Option<f64> {
predict_pka(mol)
.into_iter()
.filter(|s| s.site_type == PkaSiteType::Acid)
.map(|s| s.pka)
.reduce(f64::min)
}
pub fn pka_base(mol: &Molecule) -> Option<f64> {
predict_pka(mol)
.into_iter()
.filter(|s| s.site_type == PkaSiteType::Base)
.map(|s| s.pka)
.reduce(f64::max)
}
pub fn pka_both(mol: &Molecule) -> (Option<f64>, Option<f64>) {
let sites = predict_pka(mol);
let acid = sites
.iter()
.filter(|s| s.site_type == PkaSiteType::Acid)
.map(|s| s.pka)
.reduce(f64::min);
let base = sites
.iter()
.filter(|s| s.site_type == PkaSiteType::Base)
.map(|s| s.pka)
.reduce(f64::max);
(acid, base)
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
fn mol(s: &str) -> Molecule {
parse(s).unwrap()
}
#[test]
fn test_acetic_acid_pka() {
let m = mol("CC(=O)O");
let acid = pka_acid(&m);
assert!(acid.is_some(), "acetic acid should have acidic site");
assert!(
(acid.unwrap() - 4.0).abs() < 0.1,
"acetic acid pKa ~4.0, got {:.2}",
acid.unwrap()
);
}
#[test]
fn test_phenol_pka() {
let m = mol("Oc1ccccc1");
let sites = predict_pka(&m);
let phenol_site = sites.iter().find(|s| s.group_name == "phenol");
assert!(phenol_site.is_some(), "phenol should be detected");
assert!(
(phenol_site.unwrap().pka - 10.0).abs() < 0.1,
"phenol pKa ~10.0"
);
}
#[test]
fn test_thiol_pka() {
let m = mol("CCS");
let sites = predict_pka(&m);
let thiol_site = sites.iter().find(|s| s.group_name == "thiol");
assert!(thiol_site.is_some(), "thiol should be detected");
assert_eq!(thiol_site.unwrap().site_type, PkaSiteType::Acid);
}
#[test]
fn test_aniline_pka() {
let m = mol("Nc1ccccc1"); let base = pka_base(&m);
assert!(base.is_some(), "aniline should have basic site");
assert!(
(base.unwrap() - 4.6).abs() < 0.1,
"aniline pKa ~4.6, got {:.2}",
base.unwrap()
);
}
#[test]
fn test_pyridine_pka() {
let m = mol("c1ccncc1"); let base = pka_base(&m);
assert!(base.is_some(), "pyridine should have basic N");
let pka = base.unwrap();
assert!((pka - 5.2).abs() < 0.5, "pyridine pKa ~5.2, got {pka:.2}");
}
#[test]
fn test_primary_amine_pka() {
let m = mol("CCN"); let base = pka_base(&m);
assert!(base.is_some(), "ethylamine should have basic site");
let pka = base.unwrap();
assert!(
pka > 9.0 && pka < 12.0,
"aliphatic amine pKa 9–12, got {pka:.2}"
);
}
#[test]
fn test_piperidine_pka() {
let m = mol("C1CCNCC1"); let sites = predict_pka(&m);
let pip = sites.iter().find(|s| s.group_name == "piperidine");
if let Some(p) = pip {
assert!((p.pka - 11.2).abs() < 0.5, "piperidine pKa ~11.2");
} else {
let base = pka_base(&m);
assert!(
base.is_some(),
"piperidine should have at least one basic site"
);
}
}
#[test]
fn test_benzene_no_sites() {
let m = mol("c1ccccc1");
let sites = predict_pka(&m);
assert!(sites.is_empty(), "benzene has no ionizable sites");
assert!(pka_acid(&m).is_none());
assert!(pka_base(&m).is_none());
}
#[test]
fn test_aspirin_has_acid() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O"); let acid = pka_acid(&m);
assert!(acid.is_some(), "aspirin has a carboxylic acid");
assert!(acid.unwrap() < 5.0, "carboxylic acid pKa < 5");
}
#[test]
fn test_amphoteric_glycine() {
let m = mol("NCC(=O)O"); let acid = pka_acid(&m);
let base = pka_base(&m);
assert!(acid.is_some(), "glycine has acid site (COOH)");
assert!(base.is_some(), "glycine has base site (NH2)");
assert!(
acid.unwrap() < base.unwrap(),
"pKa_acid < pKa_base for amino acid"
);
}
#[test]
fn test_site_type_classification() {
let m = mol("CC(=O)O"); let sites = predict_pka(&m);
assert!(!sites.is_empty());
assert_eq!(sites[0].site_type, PkaSiteType::Acid);
}
#[test]
fn test_morpholine_lower_than_piperidine() {
let morpholine = mol("C1CNCCO1");
let piperidine = mol("C1CCNCC1");
let mp = pka_base(&morpholine).unwrap();
let pp = pka_base(&piperidine).unwrap();
assert!(
mp < pp,
"morpholine ({mp:.1}) should be less basic than piperidine ({pp:.1})"
);
assert!((mp - 8.3).abs() < 0.5, "morpholine pKa ~8.3, got {mp:.1}");
}
#[test]
fn test_piperazine_nh_pka() {
let m = mol("C1CNCCN1"); let pka = pka_base(&m).unwrap();
assert!((pka - 9.8).abs() < 0.5, "piperazine pKa ~9.8, got {pka:.1}");
}
#[test]
fn test_tetrazole_is_acidic() {
let m = mol("c1nnn[nH]1"); let acid = pka_acid(&m);
assert!(acid.is_some(), "tetrazole should have acidic N-H");
let pka = acid.unwrap();
assert!(pka < 6.0, "tetrazole pKa < 6, got {pka:.1}");
}
#[test]
fn test_hydroxamic_acid_pka() {
let m = mol("CC(=O)NO"); let acid = pka_acid(&m);
assert!(acid.is_some(), "hydroxamic acid should have acid site");
let pka = acid.unwrap();
assert!(
(pka - 8.7).abs() < 0.5,
"hydroxamic acid pKa ~8.7, got {pka:.1}"
);
}
#[test]
fn test_carboxylic_vs_phenol_priority() {
let m = mol("OC(=O)c1ccc(O)cc1");
let sites = predict_pka(&m);
let acid_sites: Vec<_> = sites
.iter()
.filter(|s| s.site_type == PkaSiteType::Acid)
.collect();
assert!(
acid_sites.len() >= 2,
"p-hydroxybenzoic acid has carboxylic acid + phenol"
);
let pkas: Vec<f64> = acid_sites.iter().map(|s| s.pka).collect();
assert!(pkas.iter().any(|&p| p < 5.0), "carboxylic acid pKa < 5");
assert!(pkas.iter().any(|&p| p > 8.0), "phenol pKa > 8");
}
}