Lesson 9 of 13 · 12 min
Contacts, Interfaces, and Hydrogen Bonds
Most structural analyses reduce to a single geometric question: which atoms sit within a cutoff of each other. A brute-force all-versus-all scan scales as O(N^2), so Bio.PDB.NeighborSearch builds a KD-tree over the atomic coordinates -- an O(N log N) preprocessing step -- and, because atoms have a bounded local density, answers each fixed-radius query in close to O(log N) on average. Starting from those raw contacts you layer chemical and geometric filters to recover hydrogen bonds, salt bridges, hydrophobic packing, and the residues lining a protein-protein or protein-ligand interface.
Building the KD-tree
from Bio.PDB import PDBParser, NeighborSearch
parser = PDBParser(QUIET=True)
structure = parser.get_structure("3hfm", "3hfm.pdb") # HyHEL-10 Fab + lysozyme
model = structure[0]
# Protein heavy atoms only (drop hydrogens, water, and hetero groups)
atoms = [a for a in model.get_atoms()
if a.element != "H" and a.get_parent().id[0] == " "]
ns = NeighborSearch(atoms) # builds a KD-tree over the coordinates
# Every heavy-atom pair within 4.0 A, answered by radius queries on the tree
pairs = ns.search_all(4.0, level="A")
contacts = [(a, b) for a, b in pairs if a.get_parent() is not b.get_parent()]
print(f"{len(atoms)} protein heavy atoms")
print(f"{len(contacts)} inter-residue contacts <= 4.0 A")4296 protein heavy atoms
13312 inter-residue contacts <= 4.0 A
Hydrogen bond geometry
import numpy as np
from Bio.PDB.vectors import calc_angle
DONORS = {"N", "NE", "NH1", "NH2", "ND1", "ND2", "NE1", "NE2", "NZ",
"OG", "OG1", "OH", "SG"}
ACCEPTORS = {"O", "OD1", "OD2", "OE1", "OE2", "ND1", "NE2",
"OG", "OG1", "OH", "SD"}
hbonds = []
for a, b in ns.search_all(3.5, level="A"): # D...A heavy-atom cutoff 3.5 A
if a.element not in ("N", "O", "S") or b.element not in ("N", "O", "S"):
continue
for donor, acc in ((a, b), (b, a)):
dres, ares = donor.get_parent(), acc.get_parent()
if dres is ares:
continue
# Peptide-bond geometry puts N(i+1) ~2.3 A from O(i) without any H-bond,
# so skip sequentially adjacent residues within the same chain
if dres.get_parent().id == ares.get_parent().id and \
abs(dres.id[1] - ares.id[1]) <= 1:
continue
if donor.get_name() not in DONORS or acc.get_name() not in ACCEPTORS:
continue
dist = donor - acc # Atom.__sub__ returns distance (A)
# Bonded hydrogens live in the donor residue; the KD-tree holds heavy
# atoms only, so query the residue directly. On a protonated model this
# enables the D-H...A angle >= 120 deg test; on 3hfm it stays n/a.
hs = [h for h in dres.get_atoms()
if h.element == "H" and h - donor < 1.2]
angle = max((np.degrees(calc_angle(donor.get_vector(),
h.get_vector(),
acc.get_vector())) for h in hs),
default=None)
if angle is not None and angle < 120:
continue
hbonds.append((donor, acc, dist, angle))
print(f"{len(hbonds)} candidate hydrogen bonds")
for donor, acc, dist, angle in hbonds[:4]:
dr, ar = donor.get_parent(), acc.get_parent()
ang = f"{angle:5.1f}" if angle is not None else " n/a"
print(f"{dr.resname} {dr.id[1]:>3} {donor.get_name():<4} -> "
f"{ar.resname} {ar.id[1]:>3} {acc.get_name():<4} d={dist:.2f} angle={ang}")669 candidate hydrogen bonds
ASN 27 ND2 -> SER 24 O d=2.81 angle= n/a
ARG 125 NH1 -> GLN 121 OE1 d=2.45 angle= n/a
LYS 116 NZ -> ASN 106 OD1 d=2.49 angle= n/a
GLY 22 N -> TYR 20 O d=3.22 angle= n/a
Salt bridges and interfaces
CATIONIC = {"ARG": {"NH1", "NH2", "NE"}, "LYS": {"NZ"}, "HIS": {"ND1", "NE2"}}
ANIONIC = {"ASP": {"OD1", "OD2"}, "GLU": {"OE1", "OE2"}}
NONPOLAR = {"ALA", "VAL", "LEU", "ILE", "MET", "PHE", "TRP", "PRO"}
salt_bridges, hydrophobic, interface = set(), set(), set()
for a, b in contacts:
ra, rb = a.get_parent(), b.get_parent()
# Salt bridge (Barlow & Thornton): N...O <= 4.0 A across opposite charges
if (a - b) <= 4.0:
if a.get_name() in CATIONIC.get(ra.resname, ()) and \
b.get_name() in ANIONIC.get(rb.resname, ()):
salt_bridges.add((ra.get_full_id()[2:4], rb.get_full_id()[2:4]))
if b.get_name() in CATIONIC.get(rb.resname, ()) and \
a.get_name() in ANIONIC.get(ra.resname, ()):
salt_bridges.add((rb.get_full_id()[2:4], ra.get_full_id()[2:4]))
# Hydrophobic packing: side-chain C...C between nonpolar residues
# (exclude backbone C and CA so sequential main-chain carbons don't count)
if a.element == "C" and b.element == "C" and \
a.get_name() not in ("C", "CA") and b.get_name() not in ("C", "CA") and \
ra.resname in NONPOLAR and rb.resname in NONPOLAR:
hydrophobic.add((ra.get_full_id()[2:4], rb.get_full_id()[2:4]))
# Interface residues: the contact spans two different chains
if ra.get_parent().id != rb.get_parent().id:
interface.add((ra.get_parent().id, ra.resname, ra.id[1]))
interface.add((rb.get_parent().id, rb.resname, rb.id[1]))
print(f"{len(salt_bridges)} salt bridges, {len(hydrophobic)} hydrophobic pairs")
print(f"{len(interface)} interface residues across "
f"{len({c for c, _, _ in interface})} chains")
for chain, resn, resi in sorted(interface)[:5]:
print(f" chain {chain} {resn}{resi}")18 salt bridges, 167 hydrophobic pairs
109 interface residues across 3 chains
chain H ALA125
chain H ALA129
chain H ALA130
chain H ARG213
chain H ASN43
# Cross-check the same contacts visually in PyMOL.
fetch 3hfm, async=0
remove solvent
# Polar contacts (potential h-bonds) between lysozyme (Y) and Fab (H, L)
distance epitope_hb, chain Y, (chain H or chain L), mode=2
# Interface residues within 4.0 A across the antigen-antibody boundary
select epitope, byres (chain Y within 4.0 of (chain H or chain L))
select paratope, byres ((chain H or chain L) within 4.0 of chain Y)
show sticks, epitope or paratope
iterate epitope and name CA, print(f"{chain}/{resn}{resi}")Try it yourself: For the 3hfm Fab-lysozyme complex, list every hydrogen bond and salt bridge that crosses the antigen-antibody boundary by keeping only pairs where one residue is in chain Y (lysozyme) and the other in chain H or L (antibody) -- filtering to inter-chain pairs alone is not enough, since that also sweeps in the H-L Fab interface. Load the same structure in PyMOL, run distance epitope_hb, chain Y, (chain H or chain L), mode=2, and confirm each donor-acceptor pair you found shows up as a PyMOL polar contact. Then protonate the model (PyMOL h_add, or reduce / PDB2PQR), re-parse it so each residue carries its hydrogens, raise the D-H...A angle threshold from 120 to 135 degrees, and note which borderline heavy-atom contacts now fail the geometry test.