use chematic_core::{AtomIdx, Element, Molecule};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PdbqtError {
InvalidAtomLine { line: usize, detail: String },
InvalidCharge { line: usize, raw: String },
UnknownElement { symbol: String, line: usize },
}
impl core::fmt::Display for PdbqtError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::InvalidAtomLine { line, detail } => {
write!(f, "PDBQT: invalid ATOM line {line}: {detail}")
}
Self::InvalidCharge { line, raw } => {
write!(f, "PDBQT: invalid charge '{raw}' at line {line}")
}
Self::UnknownElement { symbol, line } => {
write!(f, "PDBQT: unknown element '{symbol}' at line {line}")
}
}
}
}
impl std::error::Error for PdbqtError {}
pub fn autodock_atom_type(mol: &Molecule, idx: AtomIdx) -> &'static str {
let atom = mol.atom(idx);
match atom.element.atomic_number() {
6 => {
if atom.aromatic {
"A"
} else {
"C"
}
}
7 => {
let has_h = mol
.neighbors(idx)
.any(|(nb, _)| mol.atom(nb).element.atomic_number() == 1);
if !has_h && atom.charge <= 0 {
"NA"
} else {
"N"
}
}
8 => "OA", 16 => {
if atom.charge >= 0 { "SA" } else { "S" }
}
1 => {
let is_donor = mol.neighbors(idx).any(|(nb, _)| {
let an = mol.atom(nb).element.atomic_number();
an == 7 || an == 8
});
if is_donor { "HD" } else { "H" }
}
15 => "P",
9 => "F",
17 => "Cl",
35 => "Br",
53 => "I",
12 => "Mg",
25 => "Mn",
30 => "Zn",
20 => "Ca",
26 => "Fe",
_ => "X", }
}
pub fn write_pdbqt(
mol: &Molecule,
coords: &[(f64, f64, f64)],
charges: &[f64],
ligand_name: &str,
) -> String {
let mut out = String::new();
out.push_str("REMARK PDBQT written by chematic\n");
out.push_str(&format!("REMARK NAME = {ligand_name}\n"));
for (serial, (aidx, atom)) in mol.atoms().enumerate() {
let i = aidx.0 as usize;
let (x, y, z) = coords.get(i).copied().unwrap_or((0.0, 0.0, 0.0));
let q = charges.get(i).copied().unwrap_or(0.0);
let ad_type = autodock_atom_type(mol, aidx);
let sym = atom.element.symbol();
let atom_name = if sym.len() == 1 {
format!(" {sym} ")
} else {
format!("{sym} ")
};
out.push_str(&format!(
"ATOM {:>5} {:<4} {:<3} {:>1}{:>4} {:>8.3}{:>8.3}{:>8.3}{:>6.2}{:>6.2} {:>+7.4} {:<2}\n",
serial + 1, atom_name, "LIG", "A", 1, x, y, z,
1.00, 0.00, q, ad_type, ));
}
out.push_str("END\n");
out
}
#[allow(clippy::type_complexity)]
pub fn parse_pdbqt(s: &str) -> Result<(Molecule, Vec<(f64, f64, f64)>, Vec<f64>), PdbqtError> {
use chematic_core::MoleculeBuilder;
let mut builder = MoleculeBuilder::new();
let mut coords: Vec<(f64, f64, f64)> = Vec::new();
let mut charges: Vec<f64> = Vec::new();
for (lineno, line) in s.lines().enumerate() {
let record = &line[..line.len().min(6)];
if !matches!(record.trim(), "ATOM" | "HETATM") {
continue;
}
if line.len() < 54 {
return Err(PdbqtError::InvalidAtomLine {
line: lineno + 1,
detail: "line too short (need at least 54 chars for coordinates)".into(),
});
}
let ad_type = line.get(77..79).map(str::trim).unwrap_or("").trim();
let elem_sym = match ad_type {
"A" => "C",
"NA" => "N",
"OA" | "OS" => "O",
"SA" => "S",
"HD" => "H",
other if !other.is_empty() => other,
_ => {
line.get(76..78).map(str::trim).unwrap_or("C").trim()
}
};
let element = Element::from_symbol(elem_sym).ok_or_else(|| PdbqtError::UnknownElement {
symbol: elem_sym.to_string(),
line: lineno + 1,
})?;
use chematic_core::Atom;
builder.add_atom(Atom::new(element));
let parse_f = |s: &str, col: &str| -> Result<f64, PdbqtError> {
s.trim()
.parse::<f64>()
.map_err(|_| PdbqtError::InvalidAtomLine {
line: lineno + 1,
detail: format!("cannot parse {col} coordinate: '{}'", s.trim()),
})
};
let x = parse_f(&line[30..38], "x")?;
let y = parse_f(&line[38..46], "y")?;
let z = parse_f(&line[46..54], "z")?;
coords.push((x, y, z));
let q_raw = line
.get(66..76)
.or_else(|| line.get(71..76))
.map(str::trim)
.unwrap_or("0.0");
let q = q_raw
.parse::<f64>()
.map_err(|_| PdbqtError::InvalidCharge {
line: lineno + 1,
raw: q_raw.to_string(),
})?;
charges.push(q);
}
Ok((builder.build(), coords, charges))
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
#[test]
fn write_pdbqt_ethanol() {
let mol = parse("CCO").unwrap();
let coords = vec![(0.0, 0.0, 0.0), (1.54, 0.0, 0.0), (2.5, 1.2, 0.0)];
let charges = vec![-0.100, 0.050, -0.400];
let out = write_pdbqt(&mol, &coords, &charges, "ETH");
assert!(out.contains("REMARK"));
assert!(out.contains("ATOM"));
assert!(out.contains("LIG"));
assert!(out.contains(" C ") || out.contains("C\n") || out.contains("C "));
assert!(out.contains("OA"));
}
#[test]
fn write_pdbqt_aromatic_carbon_gets_type_a() {
let mol = parse("c1ccccc1").unwrap();
let out = write_pdbqt(&mol, &[], &[], "BNZ");
assert!(out.contains("A ") || out.contains(" A"));
}
#[test]
fn roundtrip_coordinates() {
let mol = parse("CCO").unwrap();
let coords = vec![(1.0, 2.0, 3.0), (4.0, 5.0, 6.0), (7.0, 8.0, 9.0)];
let charges = vec![-0.1, 0.2, -0.3];
let pdbqt = write_pdbqt(&mol, &coords, &charges, "TST");
let (mol2, coords2, charges2) = parse_pdbqt(&pdbqt).unwrap();
assert_eq!(mol2.atom_count(), 3);
assert!((coords2[0].0 - 1.0).abs() < 0.01);
assert!((coords2[1].1 - 5.0).abs() < 0.01);
assert!((charges2[2] - (-0.3)).abs() < 0.01);
}
}