| name | bio-applied-structural-methods |
| description | Parse PDB CRYST1/header for unit cell, space group, resolution, R-factors; apply symmetry operators; pick X-ray vs cryo-EM vs NMR. Use when checking structure quality, parsing CRYST1, or choosing a method. |
| tool_type | python |
| primary_tool | NumPy |
Structural Determination Methods
When to Use
- Deciding whether X-ray crystallography, cryo-EM, or NMR fits a target (protein size, flexibility, membrane context, need for a ligand Kd)
- Parsing a PDB
CRYST1 record to get unit cell dimensions, space group, and crystal system
- Generating symmetry-related copies of an atom/domain from a crystallographic symmetry operator
- Evaluating whether a downloaded PDB structure is good enough to use for docking, homology modeling, or mutagenesis design (resolution, R-work/R-free)
- Interpreting a PDB header's
EXPDTA, REMARK 2, and REMARK 3 records
Version Compatibility
- Biopython >=1.81 (
Bio.PDB.PDBParser as an alternative to manual header parsing)
- NumPy >=1.24, Python >=3.10
- wwPDB legacy PDB format (fixed-width
CRYST1/REMARK records) — stable since PDB format v2; also present, differently formatted, in mmCIF (_cell.*, _refine.* tags)
Prerequisites
pip install biopython numpy
- Basic PDB file layout (ATOM/HETATM records) — see
bio-core-protein-structure
- Fractional vs. Cartesian coordinates helps but isn't required for the symmetry example below
Parsing the Unit Cell (CRYST1) and Crystal System
Goal: extract unit cell parameters and space group from a PDB file, and classify the crystal system.
Approach: CRYST1 is fixed-width, not whitespace-delimited — some space groups (e.g. P 21 21 21) contain internal spaces, so split on column positions per the wwPDB spec, not str.split().
def parse_cryst1(pdb_file: str) -> dict:
"""
Extract unit cell parameters from the CRYST1 record of a PDB file.
CRYST1 column layout (0-indexed, fixed-width):
cols 6-15 a (Angstrom)
cols 15-24 b (Angstrom)
cols 24-33 c (Angstrom)
cols 33-40 alpha (deg)
cols 40-47 beta (deg)
cols 47-54 gamma (deg)
cols 55-66 space group
cols 66-70 Z (copies of the asymmetric unit per cell)
"""
with open(pdb_file) as fh:
for line in fh:
if line.startswith("CRYST1"):
return {
"a": float(line[6:15]), "b": float(line[15:24]), "c": float(line[24:33]),
"alpha": float(line[33:40]), "beta": float(line[40:47]), "gamma": float(line[47:54]),
"spacegroup": line[55:66].strip(),
"Z": int(line[66:70].strip()) if len(line) > 66 line[:].strip() ,
}
() -> :
eq = x, y: (x - y) < tol
is90 = x: eq(x, )
eq(a, b) eq(b, c) is90(alpha) is90(beta) is90(gamma):
eq(a, b) is90(alpha) is90(beta) eq(gamma, ):
eq(a, b) is90(alpha) is90(beta) is90(gamma):
is90(alpha) is90(beta) is90(gamma):
is90(alpha) is90(gamma) is90(beta):
line =
tempfile, os
tempfile.NamedTemporaryFile(mode=, suffix=, delete=) tmp:
tmp.write(line)
tmp_path = tmp.name
params = parse_cryst1(tmp_path)
os.unlink(tmp_path)
(params[], classify_crystal_system(
params[], params[], params[], params[], params[], params[]))
Applying Crystallographic Symmetry Operations
Goal: generate the coordinates of a symmetry-related copy of an atom from a space-group operator.
Approach: every space-group symmetry operation is x' = R @ x + t in fractional coordinates; apply the rotation matrix and translation vector from the space-group tables (e.g. International Tables for Crystallography).
import numpy as np
def apply_symmetry_operation(coords, rotation, translation) -> np.ndarray:
"""
Apply one crystallographic symmetry operation to fractional coordinates.
coords : array-like, shape (3,) — fractional coordinates of an atom
rotation : array-like, shape (3, 3) — rotation matrix of the operator
translation : array-like, shape (3,) — translation vector of the operator
"""
return np.dot(rotation, coords) + translation
rotation_P21 = np.array([[-1, 0, 0], [0, 1, 0], [0, 0, -1]], dtype=float)
translation_P21 = np.array([0.0, 0.5, 0.0])
original = np.array([0.12, 0.34, 0.56])
sym_copy = apply_symmetry_operation(original, rotation_P21, translation_P21)
print(f"Original: {original} -> Symmetry mate: {sym_copy}")
Assessing Structure Quality from the PDB Header
Goal: decide whether a downloaded PDB entry is trustworthy enough for downstream analysis (docking, mutagenesis design, homology templates).
Approach: pull EXPDTA (method), resolution, and R-work/R-free from REMARK lines; a large R_free - R_work gap (>0.05-0.10) signals overfitting regardless of nominal resolution.
def parse_pdb_header(pdb_text: str) -> dict:
"""Extract experiment type, resolution, R-factors, unit cell, and space group."""
info = {}
for line in pdb_text.splitlines():
if line.startswith("EXPDTA"):
info["experiment"] = line[10:].strip()
elif "RESOLUTION." in line:
parts = line.split()
for i, p in enumerate(parts):
if p == "ANGSTROMS." and i > 0:
info["resolution_A"] = float(parts[i - 1])
elif "R VALUE" in line and "WORKING" in line:
info["R_work"] = float(line.split()[-1])
elif "FREE R VALUE" in line and "SET" not in line:
info["R_free"] = float(line.split()[-1])
elif line.startswith("CRYST1"):
info["space_group"] = line[55:66].strip()
info
() -> :
res = info.get()
band = ( res res <
res res <
res res <
res )
r_work, r_free = info.get(), info.get()
overfit =
r_work r_free :
gap = r_free - r_work
overfit =
header =
(evaluate_structure_quality(parse_pdb_header(header)))
Choosing a Structural Method
| Feature | X-ray crystallography | Cryo-EM (single particle) | NMR |
|---|
| Size range | Any (needs crystal) | ~100 kDa+ (best); smaller now feasible | <30-40 kDa (assignment-limited) |
| Sample state | Crystal (ordered lattice) | Vitrified, non-crystalline | Concentrated solution |
| Best for | Small/medium, well-ordered proteins | Large complexes, membrane proteins, multiple conformations | Dynamics, ligand Kd, disordered regions |
| Resolution | 1-3.5A typical | 2-4A typical (improving) | Ensemble, no single resolution number |
def recommend_method(mw_kda: float, dynamic: bool = False, membrane: bool = False,
need_ligand_kd: bool = False) -> str:
"""Rule-of-thumb structural method recommendation (not a substitute for expert judgment)."""
if need_ligand_kd and mw_kda < 30:
return "NMR (15N-HSQC titration gives per-residue CSP and Kd)"
if mw_kda > 150 or membrane or dynamic:
return "Cryo-EM (large/flexible/membrane targets; 3D classification separates states)"
if mw_kda < 40:
return "X-ray crystallography (small proteins crystallize readily; NMR also feasible)"
return "X-ray crystallography (default for well-behaved 40-150 kDa targets)"
print(recommend_method(450, dynamic=True))
print(recommend_method(15))
Pitfalls
CRYST1 is fixed-width, not whitespace-split: space groups like P 21 21 21 contain internal spaces; splitting on whitespace corrupts the space-group and Z fields.
- R-work always improves with more refinement parameters: only
R_free (computed from reflections withheld from refinement) is a fair quality check; trust the R_free - R_work gap, not R-work alone.
- Resolution number alone is not sufficient: check R-free, and for cryo-EM also check local resolution — global resolution can hide poorly resolved flexible regions.
- Low-resolution cryo-EM maps (>4A) cannot reliably place side chains — backbone tracing may still be trustworthy.
- NMR "structures" are ensembles (multiple
MODEL records in one PDB entry) representing conformational dynamics, not competing guesses of one static structure — do not just pick MODEL 1 and discard the rest.
- mmCIF vs legacy PDB: unit cell/resolution live in
_cell.*/_refine.* mmCIF tags, not CRYST1/REMARK, for structures only distributed in mmCIF.
See Also
bio-core-protein-structure — Bio.PDB SMCRA hierarchy, RMSD, DSSP
structural-bioinformatics — Ramachandran plots, PWM/PROSITE, broader Bio.PDB workflows
alphafold-structure-prediction — predicted-model alternative when no experimental structure exists
bio-applied-proteomics — mass-spectrometry side of protein characterization (not covered here)