60 minReal data snapshotExecuted reading edition

How does a small drug fit into a protein pocket?

Turn a real 3D model, find the drug and its neighbors, and learn what a distance measurement can tell us.

Bioinformatics · Lesson 07🔋 Offline core analysis⏱ 60 minReal data snapshot📦 numpy · pandas · scipy · matplotlib · py3Dmol · IPython
Start with a teaching sketchGreen is the protein and orange is the small drug. Top: identify the two parts. Bottom: see the drug in the pocket. Purple rings pick out nearby parts; the dashed line shows where a distance is measured.
Green is the protein and orange is the small drug. Top: identify the two parts. Bottom: see the drug in the pocket. Purple rings pick out nearby parts; the dashed line shows where a distance is measured.1

Start here: a story, then pictures

Three words to know. A protein is a long chain of small building blocks folded into a 3D shape. Each building block is an amino acid; inside a protein, scientists also call it a residue. An atom is a much smaller part of a building block. Think of beads made from even tinier pieces. Real proteins can bend and move.

Two ways to learn. To follow the story, read the short explanations and look at the pictures; code and extra technical notes can wait. To do the experiment on your device, download the whole .ipynb, use + → Import from Files in VibeIt, and run every code cell from top to bottom.

The charts and interactive molecular models below are actual saved code outputs. The data and sketches are packed inside the notebook, with no extra data file to find. Save it in your working folder. You do not need an AI account.

Let the tools get ready

In [1] · Python
See the Python code for this step (optional reading)
from pathlib import Path
import base64, gzip, hashlib, json, platform
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display, HTML
import py3Dmol
from scipy.spatial.distance import cdist
from IPython.display import display as _display
def display(value):
    if isinstance(value,pd.DataFrame):
        table=value.to_html(max_rows=12,escape=True)
        style="<style>.vibeit-readable-table{max-width:100%;overflow-x:auto}.vibeit-readable-table table{width:max-content;max-width:none;border-collapse:collapse}.vibeit-readable-table td,.vibeit-readable-table th{white-space:nowrap!important;word-break:normal!important;overflow-wrap:normal!important}</style>"
        return _display(HTML(style+'<div class="vibeit-readable-table">'+table+'</div>'))
    return _display(value)
RECIPE_ID='07-imatinib-pocket'
OUTPUT_DIR=Path.cwd()/"results"/RECIPE_ID
plt.rcParams.update({"figure.dpi":120,"font.size":10,"axes.spines.top":False,"axes.spines.right":False,
                      "axes.prop_cycle":plt.cycler(color=["#18785f","#416aa6","#bc6541","#9974a5"])})
print("Python:",platform.python_version(),"; NumPy:",np.__version__,"; Pandas:",pd.__version__)
print("Working folder:",Path.cwd())
print("Core data mode: embedded snapshot; no network requests")
Python: 3.10.8 ; NumPy: 1.26.4 ; Pandas: 2.3.3
Working folder: /Users/zhiluo/Developer/vibeit-cookbook/cookbook/downloads
Core data mode: embedded snapshot; no network requests

Open the data packed inside this notebook

In [2] · Python
See the Python code for this step (optional reading)
def load_snapshot(recipe_id, expected_sha256, snapshot_key):
    """Read embedded data from this notebook, including a renamed copy."""
    for path in sorted(Path.cwd().glob("*.ipynb")):
        try:
            notebook = json.loads(path.read_text(encoding="utf-8"))
            metadata = notebook.get("metadata", {}).get("vibeit_cookbook", {})
            blob = metadata.get("snapshots", {}).get(snapshot_key)
            if metadata.get("recipe_id") != recipe_id or not blob:
                continue
            if blob.get("sha256") != expected_sha256:
                continue
            raw = gzip.decompress(base64.b64decode(blob["gzip_base64"], validate=True))
            if hashlib.sha256(raw).hexdigest() != expected_sha256:
                raise ValueError("Embedded snapshot checksum mismatch: " + snapshot_key)
            return json.loads(raw)
        except (OSError, json.JSONDecodeError):
            continue
    raise FileNotFoundError(
        "Save/open the complete .ipynb in the active working folder. "
        "The embedded snapshot could not be located for " + recipe_id)

SNAPSHOT_HASHES={'imatinib': '5c917b32818fda4715c7887577a22d275c25e7a80f9ebf0e6b55e923f2c1f187'}
def snapshot(key):
    return load_snapshot(RECIPE_ID,SNAPSHOT_HASHES[key],key)
print("Embedded snapshots:",list(SNAPSHOT_HASHES))
Embedded snapshots: ['imatinib']

Our reusable tools

In [3] · Python
See the Python code for this step (optional reading)
def parse_pdb(text):
    """First model; choose highest occupancy altloc, then blank/A/lexical order."""
    rows, first_model, inside = {}, None, True
    for line in text.splitlines():
        record = line[:6].strip()
        if record == "MODEL":
            number = line[10:14].strip()
            if first_model is None:
                first_model = number
            inside = number == first_model
        if record == "ENDMDL" and inside:
            break
        if record not in ("ATOM","HETATM") or not inside:
            continue
        try:
            atom = dict(record=record,serial=int(line[6:11]),atom=line[12:16].strip(),altloc=line[16:17].strip(),
                residue=line[17:20].strip(),chain=line[21:22].strip(),resnum=int(line[22:26]),icode=line[26:27].strip(),
                x=float(line[30:38]),y=float(line[38:46]),z=float(line[46:54]),
                occupancy=float(line[54:60].strip() or 0),bfactor=float(line[60:66].strip() or 0),element=line[76:78].strip())
        except ValueError as exc:
            raise ValueError("Invalid PDB numeric fields") from exc
        key = (record,atom["chain"],atom["resnum"],atom["icode"],atom["residue"],atom["atom"])
        rank = (atom["occupancy"],atom["altloc"]=="",atom["altloc"]=="A")
        old = rows.get(key)
        if old is None or rank > old[0] or (rank == old[0] and atom["altloc"] < old[1]["altloc"]):
            rows[key] = (rank,atom)
    columns = ["record","serial","atom","altloc","residue","chain","resnum","icode","x","y","z","occupancy","bfactor","element"]
    atoms = pd.DataFrame([entry[1] for entry in rows.values()],columns=columns)
    if atoms.empty:
        raise ValueError("No atoms in the first model")
    if not np.isfinite(atoms[["x","y","z","occupancy"]].to_numpy()).all():
        raise ValueError("Nonfinite coordinates or occupancy")
    return atoms

print("Method ready: parse_pdb")
Method ready: parse_pdb
In [4] · Python
See the Python code for this step (optional reading)
def residue_minimum_distances(left, right):
    """Minimum Euclidean atom distance per residue pair, retaining chain/insertion IDs.

    Caller explicitly selects positive-occupancy heavy atoms. This measures
    geometric proximity, not a hydrogen bond, affinity, buried area or energy.
    At most one residue's atom-distance block is held in memory at a time.
    """
    keys = ['chain', 'resnum', 'icode', 'residue']
    columns = keys + ['atom_count']
    if left.empty or right.empty:
        raise ValueError('Both atom selections must be nonempty')
    if not np.isfinite(left[['x','y','z']].to_numpy()).all() or not np.isfinite(right[['x','y','z']].to_numpy()).all():
        raise ValueError('Coordinates must be finite')
    left = left.reset_index(drop=True)
    right = right.reset_index(drop=True)
    left_groups = list(left.groupby(keys, sort=False).indices.items())
    right_groups = list(right.groupby(keys, sort=False).indices.items())
    left_residues = pd.DataFrame([list(key)+[len(indices)] for key,indices in left_groups], columns=columns)
    right_residues = pd.DataFrame([list(key)+[len(indices)] for key,indices in right_groups], columns=columns)
    right_codes = np.empty(len(right), dtype=int)
    for j, (_, indices) in enumerate(right_groups):
        right_codes[indices] = j
    matrix = np.full((len(left_groups),len(right_groups)), np.inf)
    right_xyz = right[['x','y','z']].to_numpy(dtype=float)
    for i, (_, indices) in enumerate(left_groups):
        block = cdist(left.iloc[indices][['x','y','z']].to_numpy(dtype=float), right_xyz)
        np.minimum.at(matrix[i], right_codes, block.min(axis=0))
    return left_residues, right_residues, matrix

print("Method ready: residue_minimum_distances")
Method ready: residue_minimum_distances
In [5] · Python
See the Python code for this step (optional reading)
def residue_labels(frame):
    """Display author residue identifiers without discarding insertion codes."""
    return [f'{r.chain}:{r.residue}{int(r.resnum)}{r.icode}' for r in frame.itertuples()]

print("Method ready: residue_labels")
Method ready: residue_labels
In [6] · Python
See the Python code for this step (optional reading)
def pdb_atom_text(atoms):
    """Serialize an explicitly selected atom table for 3D display, without reorienting it."""
    lines = []
    for r in atoms.itertuples():
        lines.append(f'{r.record:<6}{r.serial:5d} {r.atom:>4} {r.residue:>3} {r.chain:1}{r.resnum:4d}{r.icode:1}   '
                     f'{r.x:8.3f}{r.y:8.3f}{r.z:8.3f}{r.occupancy:6.2f}{r.bfactor:6.2f}          {r.element:>2}  ')
    return '\n'.join(lines) + '\nEND\n'

print("Method ready: pdb_atom_text")
Method ready: pdb_atom_text

What is in our model?

Our model shows a piece of mouse ABL and a drug already observed together in an experiment. We study that picture rather than guessing a new position.2

Follow the story and try it

1. A small piece and a much bigger machine

Imagine a machine with a small groove where a working part fits. A protein called ABL has a place used by a small molecule called ATP. The drug imatinib fits in a related pocket and helps ABL stay in a shape that is not working. Today we are looking at where the drug sits and what is near it.

Think of hemoglobin’s heme as a normal working part of a machine. Here we look at a drug that gets in the way of the machine’s work. Real proteins move, so they are more flexible than toy blocks.

Try saying it: “Today we will find the drug and its nearby protein parts.”2

2. Check the label before opening the box

This model is a lab-made picture of part of a mouse ABL protein together with imatinib. Scientists call the file 1IEP. You do not need to remember that code. It keeps us from using the wrong picture.

The file contains two similar protein copies, labelled A and B. We look at A and its own drug. A/B are labels that help us choose a copy. The drug’s file label is STI. The experiment recorded usable positions for some parts of the protein.

Read the table: chain is the copy label. For A, the recorded sequence has 293 building blocks, while positions for 274 Cα reference points were observed. Those two counts answer different questions.2

In [7] · Python
See the Python code for this step (optional reading)
KEY = "imatinib"
data = snapshot(KEY)
atoms = parse_pdb(data["pdb"])
heavy = atoms[(atoms.occupancy > 0) & ~atoms.element.isin(["H", "D"])].copy()
assert heavy.serial.is_unique
ca = heavy[(heavy.record == "ATOM") & (heavy.atom == "CA")]
identity = []
for entity in data["entities"]:
    for chain in entity["entity_poly"]["pdbx_strand_id"].split(","):
        identity.append(dict(chain=chain, molecule=entity["rcsb_polymer_entity"]["pdbx_description"],
            deposited_sequence_length=entity["entity_poly"]["rcsb_sample_sequence_length"],
            observed_CA_residues=int((ca.chain == chain).sum())))
display(pd.DataFrame(identity))
print("PDB:", data["pdb_id"], "; resolution (Å):", data["entry"]["rcsb_entry_info"]["resolution_combined"])
print("Revision:", data["entry"]["rcsb_accession_info"]["revision_date"])
print("Selected atom rows:", len(atoms), "; usable heavy atoms:", len(heavy),
      "; zero occupancy:", int((atoms.occupancy == 0).sum()))
print("Non-polymer components:", sorted(atoms.loc[atoms.record == "HETATM", "residue"].unique()))
compound = data["compound"]["chem_comp"]
display(pd.DataFrame([dict(component=compound["id"], formula=compound["formula"],
    formula_weight_Da=compound["formula_weight"], formal_charge=compound["pdbx_formal_charge"])]))
drug = heavy[(heavy.chain == "A") & (heavy.residue == "STI")].copy()
target = heavy[(heavy.chain == "A") & (heavy.record == "ATOM")].copy()
assert len(drug) == 37 and not target.empty
print("Bound drug heavy atoms:", len(drug), "; protein heavy atoms:", len(target))
chain molecule deposited_sequence_length observed_CA_residues
0 A PROTO-ONCOGENE TYROSINE-PROTEIN KINASE ABL 293 274
1 B PROTO-ONCOGENE TYROSINE-PROTEIN KINASE ABL 293 274
PDB: 1IEP ; resolution (Å): [2.1]
Revision: 2023-08-09T00:00:00.000+00:00
Selected atom rows: 4710 ; usable heavy atoms: 4710 ; zero occupancy: 0
Non-polymer components: ['CL', 'HOH', 'STI']
component formula formula_weight_Da formal_charge
0 STI C29 H31 N7 O 493.603 0
Bound drug heavy atoms: 37 ; protein heavy atoms: 2229

3. Turn the model and find the orange drug

First picture: the drug alone, shown in its experimentally observed position and shape. It was taken out of the same model for easier viewing.

Second picture: green ribbons show the larger ABL protein, and orange sticks show the drug. The ribbons are a drawing style that helps us follow the protein’s shape.

Try it: turn and zoom the model. Find the orange piece, then see how much larger the green protein is. Stick connections help us follow the molecule’s shape.2

In [8] · Python
See the Python code for this step (optional reading)
drug_view = py3Dmol.view(width="100%", height=350)
drug_view.addModel(pdb_atom_text(drug), "pdb")
drug_view.setStyle({}, {"stick":{"colorscheme":"orangeCarbon"}})
drug_view.setHoverable({}, True, "function(a,v){if(!a.label)a.label=v.addLabel(a.atom,{position:a,backgroundColor:'white',fontColor:'black'});}",
    "function(a,v){if(a.label){v.removeLabel(a.label);delete a.label;}}")
drug_view.setBackgroundColor("white"); drug_view.zoomTo(); drug_view.zoom(.7)
display(HTML(drug_view._make_html()))
complex_view = py3Dmol.view(width="100%", height=440)
complex_view.addModel(pdb_atom_text(pd.concat([target, drug])), "pdb")
complex_view.setStyle({"chain":"A"}, {"cartoon":{"color":"#18785f"}})
complex_view.setStyle({"resn":"STI"}, {"stick":{"colorscheme":"orangeCarbon"}})
complex_view.setBackgroundColor("white"); complex_view.zoomTo(); complex_view.zoom(.7)
display(HTML(complex_view._make_html()))

3Dmol.js failed to load for some reason. Please check your browser console for error messages.

3Dmol.js failed to load for some reason. Please check your browser console for error messages.

4. Find the drug’s close neighbors

Imagine circling the neighbors who live close to your house. We do something similar for the drug: which protein building blocks have any small part close enough to it? We measure the shortest straight-line distance between their atoms and the drug’s atoms.

Our rule is at most 4 Å. Å is a very tiny unit of length. You do not need to memorize the conversion now; 4 Å is just the boundary we chose. We find 21 protein building blocks inside it.

Read the bars: one bar is one block. A shorter bar means a smaller nearest distance. The dashed line marks 4 Å. label is the block’s name and number; min_distance_A is its nearest distance.3

In [9] · Python
See the Python code for this step (optional reading)
CUTOFF = 4.0
left_residues, right_residues, distances = residue_minimum_distances(drug, target)
target_profile = right_residues.copy()
target_profile["label"] = residue_labels(right_residues)
target_profile["min_distance_A"] = distances.min(axis=0)
pocket = target_profile[target_profile.min_distance_A <= CUTOFF].sort_values("min_distance_A", kind="stable")
print("Pocket residues:", len(pocket))
display(pocket.head(12))
fig, ax = plt.subplots(figsize=(8,5))
if len(pocket):
    ax.barh(pocket.label.iloc[::-1], pocket.min_distance_A.iloc[::-1], color="#18785f")
else:
    ax.text(.5,.5,"No residues within cutoff",ha="center",transform=ax.transAxes)
ax.axvline(CUTOFF, color="#bc6541", linestyle="--", label=f"Cutoff {CUTOFF:g} Å")
ax.set(xlabel="Minimum heavy-atom distance (Å)", title="1IEP: ABL residues near imatinib")
ax.legend(); plt.tight_layout(); plt.show()
Pocket residues: 21
chain resnum icode residue atom_count label min_distance_A
135 A 360 ILE 8 A:ILE360 2.668500
90 A 315 THR 7 A:THR315 2.883189
93 A 318 MET 8 A:MET318 2.899270
156 A 381 ASP 8 A:ASP381 2.900704
61 A 286 GLU 9 A:GLU286 2.995906
136 A 361 HIS 10 A:HIS361 3.161738
74 A 299 VAL 7 A:VAL299 3.250268
65 A 290 MET 8 A:MET290 3.308218
92 A 317 PHE 11 A:PHE317 3.326891
155 A 380 ALA 5 A:ALA380 3.329238
46 A 271 LYS 9 A:LYS271 3.350683
157 A 382 PHE 11 A:PHE382 3.404990
Computed figure from this code cell

5. Use colors as a distance ruler

This picture is a table made of colored squares. A row is one drug atom; a column is one nearby protein block. Each square answers: “How close is this drug atom to the nearest atom in that block?”

Yellow means closer; blue/purple means farther. Use the color bar at the right as a ruler. Values above 8 Å share the far-end color, so they cannot be distinguished by color alone.3

In [10] · Python
See the Python code for this step (optional reading)
atom_to_target = cdist(drug[["x","y","z"]].to_numpy(), target[["x","y","z"]].to_numpy())
groups = list(target.reset_index(drop=True).groupby(["chain","resnum","icode","residue"], sort=False).indices.values())
atom_residue = np.column_stack([atom_to_target[:,indices].min(axis=1) for indices in groups])
assert np.allclose(atom_residue.min(axis=0), target_profile.min_distance_A)
selected = np.flatnonzero(target_profile.min_distance_A.to_numpy() <= CUTOFF)
fig, ax = plt.subplots(figsize=(10,7))
if len(selected):
    image = ax.imshow(atom_residue[:,selected], aspect="auto", cmap="viridis_r", vmin=2, vmax=8)
    ax.set(xticks=range(len(selected)), xticklabels=target_profile.label.iloc[selected],
           yticks=range(len(drug)), yticklabels=drug.atom)
    plt.setp(ax.get_xticklabels(), rotation=70, ha="right")
    fig.colorbar(image, ax=ax, label="Minimum atom-to-residue distance (Å; scale capped at 8)")
else:
    ax.text(.5,.5,"Empty pocket at this cutoff",ha="center",transform=ax.transAxes)
ax.set(xlabel="ABL author residue ID", ylabel="STI drug atom name", title="Observed drug–pocket geometry")
plt.tight_layout(); plt.show()
Computed figure from this code cell

6. A long bead chain can fold around a small piece

In the dot picture: green dots mark reference positions in the protein, orange dots mark drug atoms, and purple dots mark the selected nearby blocks. The protein chain folds in space, so blocks far apart along the chain can end up near the same drug.

In the close-up: purple sticks show the nearby protein atoms and orange sticks show the drug. A label such as A:THR315 is an address: copy A, block type THR, number 315. You do not have to memorize the letters.

Try rotating the model. The view changes, but the distances do not. Turning a map does not move the houses on it.3

In [11] · Python
See the Python code for this step (optional reading)
target_ca = target[target.atom == "CA"].copy()
target_ca["label"] = residue_labels(target_ca)
pocket_ca = target_ca[target_ca.label.isin(pocket.label)]
fig = plt.figure(figsize=(8,5)); ax = fig.add_subplot(111, projection="3d")
ax.scatter(target_ca.x,target_ca.y,target_ca.z,s=8,c="#18785f",alpha=.25,label="ABL Cα")
ax.scatter(drug.x,drug.y,drug.z,s=25,c="#bc6541",label="Imatinib heavy atoms")
ax.scatter(pocket_ca.x,pocket_ca.y,pocket_ca.z,s=30,c="#9974a5",label="Pocket residue Cα")
ax.set(xlabel="x (Å)",ylabel="y (Å)",zlabel="z (Å)",title="Sequence-distant residues form a drug pocket")
xyz=target[["x","y","z"]].to_numpy(); ax.set_box_aspect(np.ptp(xyz,axis=0))
ax.legend(loc="upper left"); plt.tight_layout(); plt.show()
target_labels = pd.Series(residue_labels(target), index=target.index)
pocket_atoms = target[target_labels.isin(pocket.label)]
pocket_view = py3Dmol.view(width="100%", height=440)
pocket_view.addModel(pdb_atom_text(pd.concat([pocket_atoms, drug])), "pdb")
pocket_view.setStyle({}, {"stick":{"color":"#9974a5","radius":.14}})
pocket_view.setStyle({"resn":"STI"}, {"stick":{"colorscheme":"orangeCarbon","radius":.2}})
for row in pocket.iloc[:4].itertuples():
    position = pocket_ca[pocket_ca.label == row.label]
    if len(position):
        r = position.iloc[0]
        pocket_view.addLabel(row.label, {"position":{"x":float(r.x),"y":float(r.y),"z":float(r.z)},
            "backgroundColor":"white","fontColor":"black","fontSize":12,"backgroundOpacity":.8})
pocket_view.setBackgroundColor("white"); pocket_view.zoomTo(); pocket_view.zoom(.7)
display(HTML(pocket_view._make_html()))
print("Close-up atoms:", len(pocket_atoms)+len(drug))
Computed figure from this code cell

3Dmol.js failed to load for some reason. Please check your browser console for error messages.

Close-up atoms: 208

7. A bigger circle includes more neighbors

Change the neighbor rule from 4 Å to 5 Å. A wider boundary may include more blocks, even though the drug and protein did not move. The curve shows this change in our counting rule.

Read the curve: along the bottom is the distance boundary; up the side is the number of neighbors. Making the boundary larger cannot remove a neighbor.

What did we learn? We located the drug and described its neighborhood. A possible next step is to measure binding and protein activity in an experiment.4

In [12] · Python
See the Python code for this step (optional reading)
threshold_table = pd.DataFrame([dict(cutoff_A=t,
    target_residues=int((target_profile.min_distance_A <= t).sum())) for t in [0.01,3.,3.5,4.,4.5,5.,6.]])
assert np.all(np.diff(threshold_table.target_residues) >= 0)
display(threshold_table)
fig,ax=plt.subplots(figsize=(7,3))
ax.plot(threshold_table.cutoff_A,threshold_table.target_residues,"o-",color="#416aa6")
ax.axvline(CUTOFF,ls="--",color="#bc6541")
ax.set(xlabel="Geometric cutoff (Å)",ylabel="Unique ABL residues",title="Definition sensitivity; not dose–response")
plt.tight_layout(); plt.show()
cutoff_A target_residues
0 0.01 0
1 3.00 5
2 3.50 13
3 4.00 21
4 4.50 25
5 5.00 28
6 6.00 32
Computed figure from this code cell

8. Check the ruler and save your work

We do two simple checks. First, measure one nearest pair again another way. Second, move the whole model together: distances must stay the same, just as moving an entire toy house does not change the gaps between its blocks.

The code saves tables, a model file and a short summary in results/07-imatinib-pocket/. CSV files are tables you can open later; PDB holds the selected 3D positions. An empty result is allowed when the chosen boundary is too small.

Tell someone: “I found 21 neighboring protein blocks using the 4 Å rule. I can use the picture and table to show where they are.”4

In [13] · Python
See the Python code for this step (optional reading)
# Independent atom-pair calculation for the closest residue pair.
i, j = np.unravel_index(np.argmin(distances), distances.shape)
def residue_atoms(frame, row):
    return frame[(frame.chain == row.chain) & (frame.resnum == row.resnum) &
                 (frame.icode == row.icode) & (frame.residue == row.residue)]
left_test = residue_atoms(drug, left_residues.iloc[i])
right_test = residue_atoms(target, right_residues.iloc[j])
manual_minimum = min(float(np.linalg.norm(a-b))
    for a in left_test[["x","y","z"]].to_numpy() for b in right_test[["x","y","z"]].to_numpy())
assert np.isclose(manual_minimum, distances[i,j], atol=1e-12)
# A common rigid translation cannot change any distance.
shifted_drug = drug.copy(); shifted_target = target.copy()
shifted_drug[["x","y","z"]] += np.array([10., -7., 3.])
shifted_target[["x","y","z"]] += np.array([10., -7., 3.])
_, _, shifted_distances = residue_minimum_distances(shifted_drug, shifted_target)
assert np.allclose(distances, shifted_distances, atol=1e-12)
# Empty selections fail explicitly; a zero-contact threshold is a valid result.
try:
    residue_minimum_distances(drug.iloc[:0], target)
except ValueError:
    print("Empty selection: reported correctly")
else:
    raise AssertionError("Empty selection was accepted")
assert not (distances <= 0.01).any()
assert np.isfinite(distances).all() and (distances >= 0).all()
print("PASS: independent atom minimum, rigid translation, finite distances, empty selections and empty contacts")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
target_profile.to_csv(OUTPUT_DIR / "target-residue-distances.csv", index=False)
threshold_table.to_csv(OUTPUT_DIR / "threshold-sensitivity.csv", index=False)
pd.DataFrame(distances, index=residue_labels(left_residues), columns=residue_labels(right_residues)).to_csv(
    OUTPUT_DIR / "residue-minimum-distances.csv")
(OUTPUT_DIR / "selected-complex.pdb").write_text(pdb_atom_text(pd.concat([target, drug])), encoding="ascii")
summary = dict(pdb_id=data["pdb_id"], cutoff_angstrom=CUTOFF,
    atom_selection="positive occupancy heavy atoms; parser selects one altloc per atom",
    target_chains=sorted(target.chain.unique()), drug_chains=sorted(drug.chain.unique()),
    target_residues_within_cutoff=int((target_profile.min_distance_A <= CUTOFF).sum()),
    contacting_residue_pairs=int((distances <= CUTOFF).sum()),
    method="minimum atom distance, not affinity, interaction energy or buried surface area")
(OUTPUT_DIR / "analysis-summary.json").write_text(json.dumps(summary, indent=2)+"\n")
print("Exported:", sorted(p.name for p in OUTPUT_DIR.iterdir()))
Empty selection: reported correctly
PASS: independent atom minimum, rigid translation, finite distances, empty selections and empty contacts
Exported: ['analysis-summary.json', 'residue-minimum-distances.csv', 'selected-complex.pdb', 'target-residue-distances.csv', 'threshold-sensitivity.csv']

Exercises and reference explanation

  1. If we change the neighbor boundary from 4 to 5 Å, did we move the drug?
  2. A person can reach out with a hand. Why would measuring from the body center miss a close fingertip?

Reference explanation. We changed the counting rule, not the drug position. A protein side chain is like the reaching hand: an outer atom may be close even when its Cα reference atom is farther away. The neighbor count follows the chosen distance boundary.

Keep a copy before changing parameters. Compare old and new figures and record which inputs, calculations and conclusions changed.

Optional: ask AI for a small code change

Prompt 1

Using only the listed NumPy/Pandas/SciPy/Matplotlib/py3Dmol packages and standard library, add a CUTOFF control at 3.5/4/5 Å and recolor pocket atoms in the 3D complex. Preserve the data and author IDs; do not perform docking or report binding energies.

Prompt 2

Add a table of the nearest protein atom to each drug N/O atom using the selected experimental coordinates. Call entries candidate polar proximities, not proven hydrogen bonds. Keep atom names, residue IDs and Å units; use only the listed packages.

Human acceptance. Run all affected cells manually. Pocket counts must be nondecreasing; 3D atom selections must agree with the table. Recompute one N/O distance with np.linalg.norm and retain the existing snapshot and translation checks.

Review the edited source, then manually run affected cells and subsequent checks. Ask the assistant to explain the purpose, packages and data provenance of its changes, and reconcile that explanation with actual output.

Optional: check the source online

In [14] · Python
See the Python code for this step (optional reading)
RUN_ONLINE_UPDATE=False
if RUN_ONLINE_UPDATE:
    import requests
    try:
        response=requests.get('https://data.rcsb.org/rest/v1/core/entry/1IEP',timeout=20)
        response.raise_for_status()
        print("Source:",response.url)
        print(response.text[:700])
    except requests.RequestException as error:
        print("Online extension unavailable:",type(error).__name__,str(error)[:160])
else:
    print("Online extension skipped; embedded core data unchanged")
Online extension skipped; embedded core data unchanged

Sources, snapshots and next steps

  • Drug complex 1IEP coordinates — CC0 1.0 (wwPDB data); credit depositors and primary publication; retrieved 2026-10-10. SHA-256 577386b4b8bc58a570facba0c8a843386842caef5ed7b60106c685d0f68d4218. Preserve complete original PDB; first-model, occupancy and chain selections are visible student code. No docking or coordinate registration.

  • Drug complex 1IEP entry metadata — CC0 1.0 (wwPDB data); credit depositors and primary publication; retrieved 2026-10-10. SHA-256 09499268eccba586c0443425240eccc171748fd24bc964c448e792c2ad96e4c2. Preserve experiment, revision and primary citation metadata.

  • Drug complex 1IEP polymer entity 1 — CC0 1.0 (wwPDB data); credit depositors and primary publication; retrieved 2026-10-10. SHA-256 010c30720f818eb5cd6d32507bacff456173ce04b1f5653a5e4dcbda9b998f8a. Preserve entity identity, source species, sequences and author chain IDs; do not infer a chain role from color.

  • Drug complex STI chemical component — CC0 1.0 (wwPDB data); credit depositors and primary publication; retrieved 2026-10-10. SHA-256 071661c3f0296df1a405b9157d5190d7cc0d50297c13b97a3ce2f7056007cc59. Preserve chemical identity, formula, descriptors and reported atom counts. Atom names and bound coordinates come from 1IEP, not ideal CCD coordinates.

What next? Explain the pictures to someone else in your own words. Then try one small change and compare the old and new results. If you use another real structure, first check what it contains and whether the components share a meaningful position in space.

Notes

1 The teaching sketches use simplified shapes and bead counts. The molecular coordinates come from the cited PDB experimental record; the charts and interactive models retain their actual saved execution outputs.

2 1IEP contains a mouse c-Abl kinase-domain construct and STI (imatinib), at 2.10 Å resolution. This lesson selects author chain A and its own ligand.

3 Distances are minimum Euclidean distances across positive-occupancy heavy atoms. The parser selects the first model and one alternate atom position; author chain, residue number and insertion code remain part of the identifier. The default neighbor boundary is 4 Å. The heatmap uses 2–8 Å and caps larger values at the far-end color.

4 Binding strength, interaction chemistry and biological function are assessed with appropriate experimental evidence. Here, counts depend on the coordinate model and chosen distance boundary. Separate structure files require a reliable common spatial placement before cross-component distances are meaningful.