60 minReal data snapshotExecuted reading edition

How does an antibody meet a protein surface?

Follow three colors in a real 3D model and discover where an antibody piece meets its target protein.

Bioinformatics · Lesson 08🔋 Offline core analysis⏱ 60 minReal data snapshot📦 numpy · pandas · scipy · matplotlib · py3Dmol · IPython
Start with a teaching sketchGreen is the target protein. Orange and blue show the two Fab chains approaching a local surface patch. Dashed lines show distances from a Fab part to a green target part.
Green is the target protein. Orange and blue show the two Fab chains approaching a local surface patch. Dashed lines show distances from a Fab part to a green target part.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='08-trastuzumab-interface'
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={'trastuzumab': '147b0a3329c44a3ac92e5eda4c4e0e5b18511bf95c9938e3e0936567f9c252b5'}
def snapshot(key):
    return load_snapshot(RECIPE_ID,SNAPSHOT_HASHES[key],key)
print("Embedded snapshots:",list(SNAPSHOT_HASHES))
Embedded snapshots: ['trastuzumab']

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 the antibody’s Fab piece and part of HER2 already observed together in an experiment.2

Follow the story and try it

1. A shaped hand meets a protein surface

Last time a small drug fitted into a pocket. This time the drug is an antibody, which is itself a protein. Think of its binding part as a shaped hand that fits a particular place on another protein’s surface. The target protein here is called HER2.

We study the antibody’s gripping part, called Fab. Fab is the antibody’s binding piece. Our question is simple: where do the two proteins meet, and which little building blocks are close?

Turn the model to see the meeting place from different directions.2

2. Give the three chains simple name tags

The model’s file code is 1N8Z. Inside it, A is the Fab light chain, B is the Fab heavy chain, and C is HER2. A chain is a string of protein building blocks. “Light” and “heavy” are the names of the two chains.

Here the drug is made from protein chains, rather than one small molecule. The file also lists a sugar labelled NAG.

A label can contain an extra letter, like a house address with an A added after the number. Keep that letter so two different blocks are not mistaken for one.2

In [7] · Python
See the Python code for this step (optional reading)
KEY = "trastuzumab"
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()))
drug = heavy[(heavy.record == "ATOM") & heavy.chain.isin(["A","B"])].copy()
target = heavy[(heavy.record == "ATOM") & (heavy.chain == "C")].copy()
assert set(drug.chain) == {"A","B"} and not target.empty
print("Fab heavy atoms:", len(drug), "; HER2 heavy atoms:", len(target))
print("Residues carrying insertion codes:",
      len(drug.loc[drug.icode != "", ["chain","resnum","icode"]].drop_duplicates()))
chain molecule deposited_sequence_length observed_CA_residues
0 A Herceptin Fab (antibody) - light chain 214 214
1 B Herceptin Fab (antibody) - heavy chain 220 220
2 C Receptor protein-tyrosine kinase erbB-2 607 581
PDB: 1N8Z ; resolution (Å): [2.52]
Revision: 2024-10-30T00:00:00.000+00:00
Selected atom rows: 7890 ; usable heavy atoms: 7890 ; zero occupancy: 0
Non-polymer components: ['HOH', 'NAG', 'SO4']
Fab heavy atoms: 3287 ; HER2 heavy atoms: 4491
Residues carrying insertion codes: 0

3. Follow the green, orange and blue shapes

Green is HER2. Orange is Fab light chain A. Blue is Fab heavy chain B. Turn the model and find where orange and blue approach green. Do they sit all over it, or meet at a limited patch?

The dot picture below uses the same chain colors and helps show the overall arrangement. Its axes record positions in the saved model.

This view focuses on the protein chains.2

In [8] · Python
See the Python code for this step (optional reading)
viewer = py3Dmol.view(width="100%",height=480)
viewer.addModel(pdb_atom_text(pd.concat([target,drug])),"pdb")
for chain,color in [("C","#18785f"),("A","#bc6541"),("B","#416aa6")]:
    viewer.setStyle({"chain":chain},{"cartoon":{"color":color}})
viewer.setBackgroundColor("white");viewer.zoomTo();viewer.zoom(.7)
display(HTML(viewer._make_html()))
fig=plt.figure(figsize=(8,5));ax=fig.add_subplot(111,projection="3d")
for chain,color,label in [("C","#18785f","HER2 C"),("A","#bc6541","Fab light A"),("B","#416aa6","Fab heavy B")]:
    frame=ca[ca.chain==chain]
    ax.scatter(frame.x,frame.y,frame.z,s=10,c=color,alpha=.6,label=label)
ax.set(xlabel="x (Å)",ylabel="y (Å)",zlabel="z (Å)",title="1N8Z: an antibody meets a target surface")
ax.set_box_aspect(np.ptp(pd.concat([target,drug])[["x","y","z"]].to_numpy(),axis=0))
ax.legend(loc="upper left");plt.tight_layout();plt.show()

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

Computed figure from this code cell

4. Count the blocks and the pairs separately

Use the same at most 4 Å neighbor rule. Each pair is one Fab block and one HER2 block whose nearest atoms are close enough. We find 14 Fab blocks, 17 HER2 blocks and 32 close pairs.

Why are the numbers different? Imagine three children each greeting two teachers. There are three children and two teachers, but six child–teacher pairs. One person can appear in more than one pair. Protein blocks can do the same.

Read the bars: the orange bar counts 8 light-chain blocks; the blue bar counts 6 heavy-chain blocks. Both chains have neighbors across the meeting place.3

Five people can make six greeting pairs.
Five people can make six greeting pairs.1
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)
left_profile=left_residues.copy();left_profile["label"]=residue_labels(left_residues)
left_profile["min_distance_A"]=distances.min(axis=1)
target_profile=right_residues.copy();target_profile["label"]=residue_labels(right_residues)
target_profile["min_distance_A"]=distances.min(axis=0)
contact_mask=distances<=CUTOFF
contributions=pd.DataFrame([dict(Fab_chain=chain,
    unique_Fab_residues=int(((left_profile.chain==chain)&(left_profile.min_distance_A<=CUTOFF)).sum()),
    residue_pair_edges=int(contact_mask[left_profile.chain.to_numpy()==chain].sum())) for chain in ["A","B"]])
display(contributions)
print("Unique HER2 interface residues:",int((target_profile.min_distance_A<=CUTOFF).sum()),
      "; residue-pair edges:",int(contact_mask.sum()))
display(target_profile[target_profile.min_distance_A<=CUTOFF].sort_values("min_distance_A").head(12))
fig,ax=plt.subplots(figsize=(6,3))
ax.bar(["Fab light A","Fab heavy B"],contributions.unique_Fab_residues,color=["#bc6541","#416aa6"])
ax.set(ylabel="Unique Fab residues within cutoff",title="Geometric contribution of each Fab chain (≤4 Å)")
plt.tight_layout();plt.show()
Fab_chain unique_Fab_residues residue_pair_edges
0 A 8 16
1 B 6 16
Unique HER2 interface residues: 17 ; residue-pair edges: 32
chain resnum icode residue atom_count label min_distance_A
566 C 593 LYS 9 C:LYS593 2.416987
543 C 560 ASP 8 C:ASP560 2.600914
554 C 571 PRO 7 C:PRO571 2.887824
552 C 569 LYS 9 C:LYS569 2.957163
569 C 596 ASP 8 C:ASP596 2.985947
575 C 602 GLN 9 C:GLN602 3.047644
555 C 572 PRO 7 C:PRO572 3.109259
541 C 558 GLU 9 C:GLU558 3.133440
556 C 573 PHE 11 C:PHE573 3.416137
553 C 570 ASP 8 C:ASP570 3.495785
540 C 557 PRO 7 C:PRO557 3.511510
571 C 598 GLU 9 C:GLU598 3.520107
Computed figure from this code cell

5. Look for close pairs in the color table

Rows are Fab blocks; columns are HER2 blocks. A square shows the nearest atom distance for that pair. Yellow is closer; blue/purple is farther. Read the right-hand color ruler instead of guessing from the names.

Only blocks with at least one close partner are included in this picture. A block can be close to one partner and far from another, so the whole row need not be yellow. Distances above 8 Å share the far-end color.3

In [10] · Python
See the Python code for this step (optional reading)
left_indices=np.flatnonzero(contact_mask.any(axis=1))
right_indices=np.flatnonzero(contact_mask.any(axis=0))
fig,ax=plt.subplots(figsize=(10,7))
if len(left_indices) and len(right_indices):
    image=ax.imshow(distances[np.ix_(left_indices,right_indices)],aspect="auto",cmap="viridis_r",vmin=2,vmax=8)
    ax.set(xticks=range(len(right_indices)),xticklabels=target_profile.label.iloc[right_indices],
        yticks=range(len(left_indices)),yticklabels=left_profile.label.iloc[left_indices])
    plt.setp(ax.get_xticklabels(),rotation=70,ha="right")
    fig.colorbar(image,ax=ax,label="Minimum heavy-atom distance (Å; scale capped at 8)")
else:
    ax.text(.5,.5,"No interface at this cutoff",ha="center",transform=ax.transAxes)
ax.set(xlabel="HER2 author residue ID",ylabel="Fab author residue ID",title="Antibody–target geometry; not interaction energy")
plt.tight_layout();plt.show()
Computed figure from this code cell

6. Find the meeting place along the bead chain

Imagine placing the HER2 building blocks in order like beads on a numbered string. This plot lets us follow that order. Along the bottom is the HER2 block number; up the side is the distance to the nearest Fab atom.

Green points show observed HER2 blocks. Purple points are within our 4 Å rule. Look for where the purple points gather: the close neighbors are concentrated in a limited part of the chain, not spread everywhere.

A missing point means there was no usable position for that block.3

In [11] · Python
See the Python code for this step (optional reading)
fig,ax=plt.subplots(figsize=(9,3))
ax.scatter(target_profile.resnum,target_profile.min_distance_A,s=9,c="#18785f",alpha=.6,label="Observed HER2 residues")
interface=target_profile[target_profile.min_distance_A<=CUTOFF]
ax.scatter(interface.resnum,interface.min_distance_A,s=28,c="#9974a5",label="Within cutoff")
ax.axhline(CUTOFF,ls="--",c="#bc6541")
ax.set(xlabel="HER2 deposited author residue number",ylabel="Minimum distance to Fab (Å)",title="A localized protein surface is recognized")
ax.legend();plt.tight_layout();plt.show()
Computed figure from this code cell

7. More neighbors does not mean a tighter grip

Make the boundary larger and you may count more neighbors. The model stays still; our counting rule changes. In the curve, separate lines count Fab blocks, HER2 blocks and pairs. They are different kinds of counts.

The small drug and the larger antibody have different sizes and arrangements. Compare their pictures as well as their counts.

Our result describes a meeting place. To test how tightly the parts bind or how they change a cell’s behavior, scientists do separate experiments.4

In [12] · Python
See the Python code for this step (optional reading)
threshold_table=pd.DataFrame([dict(cutoff_A=t,Fab_residues=int((distances.min(axis=1)<=t).sum()),
    HER2_residues=int((distances.min(axis=0)<=t).sum()),residue_pair_edges=int((distances<=t).sum()))
    for t in [0.01,3.,3.5,4.,4.5,5.,6.]])
assert np.all(np.diff(threshold_table[["Fab_residues","HER2_residues","residue_pair_edges"]],axis=0)>=0)
display(threshold_table)
fig,ax=plt.subplots(figsize=(7,3))
for column in ["Fab_residues","HER2_residues","residue_pair_edges"]:
    ax.plot(threshold_table.cutoff_A,threshold_table[column],"o-",label=column)
ax.axvline(CUTOFF,ls="--",c="#bc6541")
ax.set(xlabel="Geometric cutoff (Å)",ylabel="Count (denominator shown in legend)",title="Interface definition sensitivity")
ax.legend();plt.tight_layout();plt.show()
cutoff_A Fab_residues HER2_residues residue_pair_edges
0 0.01 0 0 0
1 3.00 6 5 6
2 3.50 10 10 16
3 4.00 14 17 32
4 4.50 20 19 42
5 5.00 21 19 47
6 6.00 26 22 76
Computed figure from this code cell

8. Check the answer, save it, and tell the story

Measure one closest pair a second way, and move the whole model together to check that distances stay unchanged. Save the tables and model in results/08-trastuzumab-interface/.

If a boundary is too small to include any pair, an empty table is an honest answer.

Tell someone: “The small drug sits in a pocket. The antibody piece meets a protein surface. Both lessons measure who is near whom.”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()))
left_profile.to_csv(OUTPUT_DIR / "Fab-residue-distances.csv",index=False)
rows,cols=np.nonzero(contact_mask)
contact_edges=pd.DataFrame(dict(Fab_residue=np.array(residue_labels(left_residues))[rows],
    HER2_residue=np.array(residue_labels(right_residues))[cols],distance_A=distances[rows,cols]))
contact_edges.to_csv(OUTPUT_DIR / "interface-residue-pairs.csv",index=False)
print("Exported Fab profile and",len(contact_edges),"geometric interface edges")
Empty selection: reported correctly
PASS: independent atom minimum, rigid translation, finite distances, empty selections and empty contacts
Exported: ['Fab-residue-distances.csv', 'analysis-summary.json', 'interface-residue-pairs.csv', 'residue-minimum-distances.csv', 'selected-complex.pdb', 'target-residue-distances.csv', 'threshold-sensitivity.csv']
Exported Fab profile and 32 geometric interface edges

Exercises and reference explanation

  1. Three children each greet two teachers. How many people and how many child–teacher pairs are there?
  2. What pairs remain if we use a very tiny 0.01 Å boundary?

Reference explanation. There are five people and six pairs; people and pairs are different counts. At that boundary the picture and table show zero close pairs.

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 listed packages, highlight HER2 interface residues in the 3D model at 3.5/4/5 Å. Preserve author insertion codes and compare with the exported target profile. Do not label the result a validated epitope.

Prompt 2

Add separate minimum-distance profiles from HER2 to Fab light chain A and heavy chain B. Retain positive-occupancy heavy atoms and a zero-contact case. Use only NumPy/Pandas/SciPy/Matplotlib/py3Dmol and standard library; do not predict affinity.

Human acceptance. Manually rerun code. At each HER2 residue the combined minimum must equal the minimum of the A-only and B-only values. Unique contact counts must not decrease as the cutoff rises; preserve insertion codes in CSV and empty-interface reporting.

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/1N8Z',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 1N8Z coordinates — CC0 1.0 (wwPDB data); credit depositors and primary publication; retrieved 2026-10-10. SHA-256 ed934d36503335c6764b08880f9e50933d99eb13b9bf145339cfcaee150eb21e. Preserve complete original PDB; first-model, occupancy and chain selections are visible student code. No docking or coordinate registration.

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

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

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

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

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 1N8Z contains human HER2 extracellular construct C and trastuzumab Fab light/heavy chains A/B, at 2.52 Å resolution. Trastuzumab is humanized; source-entity organism annotations describe the deposited construct.

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.