#![forbid(unsafe_code)]
use std::sync::OnceLock;
use chematic_core::Molecule;
use chematic_smarts::{QueryMolecule, find_matches, parse_smarts};
use crate::descriptors::{
hba_count, hbd_count, heavy_atom_count, logp_crippen, molecular_weight,
num_aromatic_heterocycles, ring_bundle, tpsa,
};
use crate::esol::esol_solubility;
use crate::logd::logd_simple;
use crate::pka::{pka_acid, pka_base};
#[inline]
fn bbb_score_from(tpsa: f64, logp: f64) -> f64 {
-0.0148 * tpsa + 0.152 * logp + 0.139
}
#[inline]
fn caco2_from(tpsa: f64, logp: f64) -> f64 {
-0.1416 * tpsa + 0.6585 * logp - 0.5046
}
fn herg_risk_from(logp: f64, mw: f64, has_basic_n: bool) -> f64 {
let mut score = 0.0_f64;
if has_basic_n {
score += 0.40;
}
if logp > 4.0 {
score += 0.30;
} else if logp > 2.0 {
score += 0.15;
}
if mw > 400.0 {
score += 0.20;
} else if mw > 300.0 {
score += 0.10;
}
score.min(1.0)
}
fn cyp3a4_from(mw: f64, logp: f64, het_ar: usize, hba: usize) -> f64 {
let mut score = 0.0_f64;
if mw > 500.0 {
score += 0.25;
} else if mw > 400.0 {
score += 0.15;
}
if logp > 4.0 {
score += 0.25;
} else if logp > 3.0 {
score += 0.15;
}
if het_ar >= 2 {
score += 0.30;
} else if het_ar == 1 {
score += 0.15;
}
if hba >= 6 {
score += 0.20;
} else if hba >= 4 {
score += 0.10;
}
score.min(1.0)
}
#[inline]
fn ppb_from(logp: f64) -> f64 {
(100.0 / (1.0 + (-1.2 * (logp - 1.0)).exp())).clamp(1.0, 99.0)
}
fn clearance_score_from(logp: f64, mw: f64, hba: usize, hbd: usize, n_heavy: usize) -> f64 {
let het_density = (hba as f64 + hbd as f64) / (n_heavy as f64).max(1.0);
let x = -0.4 * logp + 0.004 * mw - 0.8 + 1.5 * het_density;
1.0 / (1.0 + (-x).exp())
}
fn clearance_class_from(
logp: f64,
mw: f64,
hba: usize,
hbd: usize,
n_heavy: usize,
) -> ClearanceClass {
match clearance_score_from(logp, mw, hba, hbd, n_heavy) {
s if s < 0.35 => ClearanceClass::Low,
s if s < 0.65 => ClearanceClass::Medium,
_ => ClearanceClass::High,
}
}
pub fn bbb_score(mol: &Molecule) -> f64 {
bbb_score_from(tpsa(mol), logp_crippen(mol))
}
pub fn bbb_passes(mol: &Molecule) -> bool {
tpsa(mol) < 90.0 && molecular_weight(mol) < 400.0 && hbd_count(mol) <= 3
}
pub fn caco2_permeability(mol: &Molecule) -> f64 {
caco2_from(tpsa(mol), logp_crippen(mol))
}
pub fn bbb_score_from_parts(tpsa: f64, logp: f64) -> f64 {
bbb_score_from(tpsa, logp)
}
pub fn caco2_precomputed(tpsa: f64, logp: f64) -> f64 {
caco2_from(tpsa, logp)
}
pub fn cyp3a4_precomputed(mw: f64, logp: f64, het_ar: usize, hba: usize) -> f64 {
cyp3a4_from(mw, logp, het_ar, hba)
}
pub fn herg_risk_score(mol: &Molecule) -> f64 {
let logp = logp_crippen(mol);
let mw = molecular_weight(mol);
let has_basic_n = pka_base(mol).map(|p| p > 7.0).unwrap_or(false);
herg_risk_from(logp, mw, has_basic_n)
}
pub fn herg_risk_precomputed(mol: &Molecule, logp: f64, mw: f64) -> f64 {
let has_basic_n = pka_base(mol).map(|p| p > 7.0).unwrap_or(false);
herg_risk_from(logp, mw, has_basic_n)
}
pub fn cyp3a4_inhibition_risk(mol: &Molecule) -> f64 {
cyp3a4_from(
molecular_weight(mol),
logp_crippen(mol),
num_aromatic_heterocycles(mol),
hba_count(mol),
)
}
static AMES_SMARTS: &[(&str, &str)] = &[
("aromatic_nitro", "[c,n][N+](=O)[O-]"),
("primary_aromatic_amine", "[NH2][c,n]"),
("epoxide", "[C;!a]1O[C;!a]1"),
("n_nitroso", "[#7]-N=O"),
("aromatic_azo", "c-N=N-c"),
("hydrazine", "[NH]-[NH2]"),
("aliphatic_azo", "[#6;!a]-[#7]=[#7]-[#6;!a]"),
("diazonium", "[#6][N+]#N"),
("nitrosamine", "[#7](-[#6])-N=O"),
("aromatic_amine_n_oxide", "[c,n][NH][OH]"),
("alpha_beta_unsaturated_aldehyde", "[CH]=[CH]-C=O"),
("alkyl_epoxide", "[C;!R;!a]-1-O-[C;!R;!a]-1"),
];
fn ames_patterns() -> &'static [(QueryMolecule, &'static str)] {
static CACHE: OnceLock<Vec<(QueryMolecule, &'static str)>> = OnceLock::new();
CACHE.get_or_init(|| {
AMES_SMARTS
.iter()
.filter_map(|(name, smarts)| parse_smarts(smarts).ok().map(|q| (q, *name)))
.collect()
})
}
pub fn ames_alerts(mol: &Molecule) -> Vec<&'static str> {
ames_patterns()
.iter()
.filter(|(q, _)| !find_matches(q, mol).is_empty())
.map(|(_, name)| *name)
.collect()
}
pub fn ames_risk_score(mol: &Molecule) -> f64 {
let hits = ames_alerts(mol).len();
(hits as f64 / 3.0).min(1.0)
}
pub fn ames_passes(mol: &Molecule) -> bool {
ames_alerts(mol).is_empty()
}
pub fn ppb_percent(mol: &Molecule) -> f64 {
ppb_from(logp_crippen(mol))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClearanceClass {
Low,
Medium,
High,
}
pub fn clearance_score(mol: &Molecule) -> f64 {
clearance_score_from(
logp_crippen(mol),
molecular_weight(mol),
hba_count(mol),
hbd_count(mol),
heavy_atom_count(mol),
)
}
pub fn clearance_class(mol: &Molecule) -> ClearanceClass {
clearance_class_from(
logp_crippen(mol),
molecular_weight(mol),
hba_count(mol),
hbd_count(mol),
heavy_atom_count(mol),
)
}
#[derive(Debug, Clone)]
pub struct AdmetProfile {
pub bbb_score: f64,
pub bbb_passes: bool,
pub caco2: f64,
pub herg_risk: f64,
pub cyp3a4_risk: f64,
pub pka_acid: Option<f64>,
pub pka_base: Option<f64>,
pub esol: f64,
pub logd74: f64,
pub mw: f64,
pub logp: f64,
pub tpsa: f64,
pub hbd: usize,
pub hba: usize,
pub rotatable_bonds: usize,
pub ames_risk: f64,
pub ppb: f64,
pub clearance: ClearanceClass,
}
pub fn admet_profile(mol: &Molecule) -> AdmetProfile {
let logp = logp_crippen(mol);
let tpsa_val = tpsa(mol);
let mw = molecular_weight(mol);
let hbd = hbd_count(mol);
let rb = ring_bundle(mol);
let base_pka = pka_base(mol);
let has_basic_n = base_pka.map(|p| p > 7.0).unwrap_or(false);
let n_heavy = heavy_atom_count(mol);
AdmetProfile {
bbb_score: bbb_score_from(tpsa_val, logp),
bbb_passes: tpsa_val < 90.0 && mw < 400.0 && hbd <= 3,
caco2: caco2_from(tpsa_val, logp),
herg_risk: herg_risk_from(logp, mw, has_basic_n),
cyp3a4_risk: cyp3a4_from(mw, logp, rb.num_aromatic_heterocycles, rb.hba_count),
pka_acid: pka_acid(mol),
pka_base: base_pka,
esol: esol_solubility(mol),
logd74: logd_simple(mol, 7.4),
mw,
logp,
tpsa: tpsa_val,
hbd,
hba: rb.hba_count,
rotatable_bonds: rb.rotatable_bond_count,
ames_risk: ames_risk_score(mol),
ppb: ppb_from(logp),
clearance: clearance_class_from(logp, mw, rb.hba_count, hbd, n_heavy),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BoiledEggProfile {
pub gi_absorbed: bool,
pub bbb_penetrant: bool,
pub logp: f64,
pub tpsa: f64,
}
pub fn boiled_egg(mol: &Molecule) -> BoiledEggProfile {
boiled_egg_from(logp_crippen(mol), tpsa(mol))
}
pub fn boiled_egg_from(logp: f64, tpsa: f64) -> BoiledEggProfile {
BoiledEggProfile {
gi_absorbed: logp <= 5.88 && tpsa <= 131.6,
bbb_penetrant: (-0.3..=6.1).contains(&logp) && tpsa <= 71.1,
logp,
tpsa,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
fn mol(s: &str) -> Molecule {
parse(s).unwrap()
}
#[test]
fn test_bbb_benzene_passes() {
let m = mol("c1ccccc1");
assert!(bbb_passes(&m), "benzene should pass BBB rules");
assert!(bbb_score(&m) > -1.0, "benzene should have positive logBB");
}
#[test]
fn test_bbb_aspirin_passes() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O");
assert!(
bbb_passes(&m),
"aspirin should pass BBB rules (MW=180, TPSA~63)"
);
}
#[test]
fn test_bbb_score_high_tpsa_fails() {
let m = mol("CN(C)C(=N)NC(=N)N"); let score = bbb_score(&m);
assert!(
score < 0.0,
"high-TPSA molecule should have logBB < 0, got {score:.3}"
);
}
#[test]
fn test_bbb_rule_metformin_fails() {
let m = mol("CN(C)C(=N)NC(=N)N");
let passes = bbb_passes(&m);
let _ = passes;
}
#[test]
fn test_caco2_nonpolar_high() {
let m = mol("CCCCCC"); let perm = caco2_permeability(&m);
assert!(
perm > -5.5,
"hexane should have high Caco-2 (logPCaco2 > -5.5), got {perm:.3}"
);
}
#[test]
fn test_caco2_polar_low() {
let m = mol("OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@@H]1O");
let perm = caco2_permeability(&m);
assert!(
perm < -5.5,
"glucose should have low Caco-2 (logPCaco2 < -5.5), got {perm:.3}"
);
}
#[test]
fn test_caco2_aspirin() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O");
let perm = caco2_permeability(&m);
assert!(
perm > -10.0 && perm < -5.0,
"aspirin Caco-2 in range, got {perm:.3}"
);
}
#[test]
fn test_herg_basic_lipophilic_high() {
let m = mol("c1cc(ccc1C(=O)CCCCN2CCC(CC2)c3ccc(cc3)Cl)F");
let risk = herg_risk_score(&m);
assert!(
risk > 0.5,
"basic + lipophilic molecule should have high hERG risk, got {risk:.3}"
);
}
#[test]
fn test_herg_benzene_low() {
let m = mol("c1ccccc1");
let risk = herg_risk_score(&m);
assert!(risk < 0.4, "benzene has low hERG risk, got {risk:.3}");
}
#[test]
fn test_herg_score_range() {
let m = mol("CN1CCCCC1"); let risk = herg_risk_score(&m);
assert!(
(0.0..=1.0).contains(&risk),
"hERG score must be in [0,1], got {risk}"
);
}
#[test]
fn test_cyp3a4_benzene_low() {
let m = mol("c1ccccc1");
let risk = cyp3a4_inhibition_risk(&m);
assert!(risk < 0.3, "benzene has low CYP3A4 risk, got {risk:.3}");
}
#[test]
fn test_cyp3a4_large_het_ar_high() {
let m = mol("c1cnc(nc1)-c1nc2ccccc2n1"); let risk = cyp3a4_inhibition_risk(&m);
assert!(risk > 0.0, "aromatic heterocycles have some CYP3A4 risk");
}
#[test]
fn test_cyp3a4_score_range() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O");
let risk = cyp3a4_inhibition_risk(&m);
assert!(
(0.0..=1.0).contains(&risk),
"CYP3A4 score in [0,1], got {risk}"
);
}
#[test]
fn test_admet_profile_aspirin() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O");
let profile = admet_profile(&m);
assert!(profile.mw > 170.0 && profile.mw < 185.0, "aspirin MW ~180");
assert!(profile.pka_acid.is_some(), "aspirin has acid site");
assert!(profile.bbb_passes, "aspirin passes BBB rules");
assert!(profile.herg_risk >= 0.0 && profile.herg_risk <= 1.0);
assert!(profile.cyp3a4_risk >= 0.0 && profile.cyp3a4_risk <= 1.0);
}
#[test]
fn test_admet_profile_benzene() {
let m = mol("c1ccccc1");
let profile = admet_profile(&m);
assert!(profile.pka_acid.is_none());
assert!(profile.pka_base.is_none());
assert!(profile.bbb_passes);
}
#[test]
fn test_admet_profile_glucose() {
let m = mol("OCC1OC(O)C(O)C(O)C1O");
let profile = admet_profile(&m);
assert!(!profile.bbb_passes, "glucose should not pass BBB rules");
assert!(profile.caco2 < -5.5, "glucose has low Caco-2 permeability");
}
#[test]
fn test_ames_clean_molecule() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O"); assert!(ames_passes(&m), "aspirin should have no Ames alerts");
assert_eq!(ames_risk_score(&m), 0.0);
}
#[test]
fn test_ames_nitro_aromatic() {
let m = mol("c1ccc([N+](=O)[O-])cc1"); assert!(
!ames_passes(&m),
"nitrobenzene should trigger aromatic_nitro alert"
);
assert!(ames_risk_score(&m) > 0.0);
}
#[test]
fn test_ames_primary_aromatic_amine() {
let m = mol("Nc1ccccc1"); assert!(
!ames_passes(&m),
"aniline should trigger primary_aromatic_amine alert"
);
}
#[test]
fn test_ames_n_nitroso() {
let m = mol("CN(C)N=O"); assert!(!ames_passes(&m), "N-nitroso compound should trigger alert");
}
#[test]
fn test_ppb_lipophilic_molecule() {
let m = mol("c1ccc2ccccc2c1"); let ppb = ppb_percent(&m);
assert!(
ppb > 80.0,
"naphthalene should have high PPB, got {ppb:.1}%"
);
}
#[test]
fn test_ppb_hydrophilic_molecule() {
let m = mol("OCC1OC(O)C(O)C(O)C1O"); let ppb = ppb_percent(&m);
assert!(ppb < 30.0, "glucose should have low PPB, got {ppb:.1}%");
}
#[test]
fn test_ppb_range() {
for smi in &["C", "CCO", "c1ccccc1", "CCCCCCCC"] {
let m = mol(smi);
let ppb = ppb_percent(&m);
assert!(
(1.0..=99.0).contains(&ppb),
"PPB out of range for {smi}: {ppb}"
);
}
}
#[test]
fn test_clearance_returns_valid_class() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O");
let cls = clearance_class(&m);
assert!(matches!(
cls,
ClearanceClass::Low | ClearanceClass::Medium | ClearanceClass::High
));
}
#[test]
fn test_clearance_score_range() {
for smi in &["C", "CCO", "c1ccccc1", "CC(=O)Oc1ccccc1C(=O)O"] {
let m = mol(smi);
let s = clearance_score(&m);
assert!(
(0.0..=1.0).contains(&s),
"clearance_score out of range for {smi}: {s}"
);
}
}
#[test]
fn test_boiled_egg_aspirin_gi_absorbed() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O");
let e = boiled_egg(&m);
assert!(e.gi_absorbed, "aspirin should be GI absorbed");
}
#[test]
fn test_boiled_egg_zone_keys() {
let m = mol("CCO");
let e = boiled_egg(&m);
assert!(e.gi_absorbed);
assert!(e.bbb_penetrant);
}
#[test]
fn test_admet_profile_has_new_fields() {
let m = mol("c1ccccc1");
let p = admet_profile(&m);
assert!((0.0..=1.0).contains(&p.ames_risk));
assert!((1.0..=99.0).contains(&p.ppb));
assert!(matches!(
p.clearance,
ClearanceClass::Low | ClearanceClass::Medium | ClearanceClass::High
));
}
}