Skip to main content
TSR Research Group

Your first 10 minutes with BioTSR

A recorded run. This page shows the notebook exactly as it ran on 5 November 2026 with BioTSR 1.0.0, from a clean environment, with every output it produced. It is a page to read: nothing here runs in your browser.

Download the notebook (.ipynb) to run it yourself, or go back to Learn BioTSR for how to open it.

This notebook computes, with BioTSR 1.0.0, the example used throughout Learn BioTSR and in the lesson Four triangles, one key. You will:

  1. install BioTSR 1.0.0 and get two public structures, 1UBQ (ubiquitin) and 1BRS (barnase–barstar);
  2. compute every Cα triangle of ubiquitin chain A and check the result;
  3. find the triangle Val 70 · Ile 44 · Leu 8 in BioTSR's output, and the three other triangles that share its key;
  4. compare three protein chains and count the keys they all share.

Each step checks its result against the expected numbers and stops if they differ.

You need: Python 3.9–3.13 on Linux or macOS (Windows is not supported), Jupyter, and an internet connection for the install and the structure downloads.

How to run it. In a terminal, in the folder where you saved this file:

python3 -m venv biotsr-env
source biotsr-env/bin/activate
python -m pip install jupyterlab
jupyter lab biotsr-first-10-minutes.ipynb

Then choose Run → Run All Cells. The notebook installs BioTSR itself. VS Code's notebook editor works as well.

What it writes. Everything goes into one folder beside the notebook, biotsr-first-10-minutes/, about 25 MB in all (most of it one 17 MB file listing every triangle). The notebook changes nothing outside that folder, and running it again is safe: finished calculations are checked and skipped.

0 · Two small helpers

run prints a BioTSR command and runs it inside the lesson folder, exactly as you would type it in a terminal there. table shows a few rows as a table. You can run this cell without reading it.

Show the helper code
In [1]:
import csv, hashlib, json, math, os, platform, shlex, shutil, subprocess, sys, urllib.request
from pathlib import Path
from IPython.display import Markdown, display

WORK = Path("biotsr-first-10-minutes")   # the lesson folder; nothing is written outside it
WORK.mkdir(exist_ok=True)

def run(command, check=True):
    """Run a documented `biotsr ...` command in the lesson folder and show what it prints.

    BioTSR prints its summary on stderr and the path of what it wrote on stdout.
    If the `biotsr` command is not on PATH, `python -m biotsr` runs the same program.
    With check=True a failure stops the notebook; with check=False the exit status is returned.
    """
    print("$ " + command)
    biotsr = ["biotsr"] if shutil.which("biotsr") else [sys.executable, "-m", "biotsr"]
    proc = subprocess.run(biotsr + shlex.split(command)[1:], cwd=WORK, capture_output=True, text=True)
    for text in (proc.stderr, proc.stdout):
        if text.strip():
            print(text.rstrip())
    if check and proc.returncode != 0:
        raise RuntimeError(f"command failed with exit status {proc.returncode}")
    return None if check else proc.returncode

def table(rows, columns):
    """Show rows (dicts) as a small Markdown table."""
    head = "| " + " | ".join(columns) + " |\n|" + "---|" * len(columns) + "\n"
    body = "".join("| " + " | ".join(str(r[c]) for c in columns) + " |\n" for r in rows)
    display(Markdown(head + body))

def read_tsv(path):
    with open(path, newline="") as fh:
        return list(csv.DictReader(fh, delimiter="\t"))

1 · Install BioTSR and check the version

The release is on PyPI. If BioTSR 1.0.0 is already installed, this cell finishes at once.

In [2]:
%pip install --quiet --disable-pip-version-check "biotsr==1.0.0"
Note: you may need to restart the kernel to use updated packages.
In [3]:
import biotsr
print("Python", platform.python_version(), "on", platform.system())
assert biotsr.__version__ == "1.0.0", "this notebook was written for BioTSR 1.0.0"
run("biotsr --version")
Python 3.13.2 on Darwin
$ biotsr --version
biotsr 1.0.0

2 · Get the structures, and check them

The expected numbers in this notebook belong to particular copies of the two structures: the ones BioTSR 1.0.0 was tested with. The RCSB revises entries from time to time, so before any calculation the notebook checks each file against the SHA-256 checksum recorded for it in the BioTSR 1.0.0 release (tests/data/structures/manifest.json at tag v1.0.0).

The cell below:

  1. keeps a file in biotsr-first-10-minutes/pdb/ that already matches its checksum, for example from an earlier run;
  2. downloads any missing file from the RCSB with biotsr fetch, the documented route (the same command as on Getting started);
  3. if a file is still missing, or differs from the tested copy, replaces it with the copy tagged in the BioTSR repository at v1.0.0, after checking that copy's checksum, and says why;
  4. stops with an explanation if neither source can be reached.

Only the two lesson files pdb/1UBQ.pdb and pdb/1BRS.pdb are ever replaced.

In [4]:
# SHA-256 of the copies BioTSR 1.0.0 was tested with (tests/data/structures/manifest.json, tag v1.0.0).
PINNED = {
    "1UBQ": "d4a6812d8951cf6594e6a0763f089e35f5a80b62acb3c117b2c5565228a7b161",
    "1BRS": "0076aae54252706a8c8c02f7b1e71ce2394b0e928b413590b4ac12aade9e8f6c",
}
TAGGED = "https://raw.githubusercontent.com/TSRMethod/BioTSR/v1.0.0/tests/data/structures/{}.pdb"
PDB = WORK / "pdb"
PDB.mkdir(exist_ok=True)

def sha256(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()

def verified(pdb_id):
    path = PDB / f"{pdb_id}.pdb"
    return path.exists() and sha256(path) == PINNED[pdb_id]

source = {pdb_id: "already in pdb/, checksum matches" for pdb_id in PINNED if verified(pdb_id)}

missing = [pdb_id for pdb_id in PINNED if not (PDB / f"{pdb_id}.pdb").exists()]
if missing:
    run("biotsr fetch " + " ".join(missing) + " --pdb-dir pdb/", check=False)
    for pdb_id in missing:
        if verified(pdb_id):
            source[pdb_id] = "downloaded from the RCSB, checksum matches"

for pdb_id in PINNED:
    if pdb_id in source:
        continue
    path = PDB / f"{pdb_id}.pdb"
    changed = path.exists()
    reason = ("differs from the copy BioTSR 1.0.0 was tested with" if changed
              else "could not be downloaded from the RCSB")
    try:
        data = urllib.request.urlopen(TAGGED.format(pdb_id), timeout=60).read()
    except OSError as error:
        raise RuntimeError(
            f"{pdb_id}: the file {reason}, and the tested copy could not be downloaded either ({error}).\n"
            f"Check your internet connection or proxy and run this cell again, or save\n"
            f"  {TAGGED.format(pdb_id)}\n"
            f"as {path} yourself; the notebook accepts it only if its SHA-256 is {PINNED[pdb_id]}."
        ) from None
    if hashlib.sha256(data).hexdigest() != PINNED[pdb_id]:
        raise RuntimeError(f"{pdb_id}: the tagged copy does not match its recorded checksum; nothing was replaced.")
    path.write_bytes(data)
    source[pdb_id] = ("replaced with the tested copy from the BioTSR v1.0.0 tag, because the file " + reason if changed
                      else "the tested copy from the BioTSR v1.0.0 tag, because the file " + reason)

assert all(verified(pdb_id) for pdb_id in PINNED)
table([{"entry": pdb_id, "file": f"pdb/{pdb_id}.pdb", "checksum": PINNED[pdb_id][:12] + "…", "source": source[pdb_id]}
       for pdb_id in PINNED], ["entry", "file", "checksum", "source"])
$ biotsr fetch 1UBQ 1BRS --pdb-dir pdb/
ids        2 requested, 2 distinct
result     2 downloaded
report     pdb/fetch_report.csv
pdb/
entry file checksum source
1UBQ pdb/1UBQ.pdb d4a6812d8951… downloaded from the RCSB, checksum matches
1BRS pdb/1BRS.pdb 0076aae54252… downloaded from the RCSB, checksum matches

3 · Compute ubiquitin's triangles

This is the protein (Cα) method on chain A, the same command as on Getting started. --if-exists skip makes the cell safe to rerun: if the same calculation is already in results/, BioTSR checks it and skips it.

In [5]:
run("biotsr run --method protein --pdb pdb/1UBQ.pdb --chain A --output results/ --if-exists skip")
$ biotsr run --method protein --pdb pdb/1UBQ.pdb --chain A --output results/ --if-exists skip
method     protein
profile    standard_protein_calpha_v1
triangles  70300
keys       45349 distinct
output     results/1UBQ_A_protein (4 files, completion record 1UBQ_A_protein.run.json)
results/1UBQ_A_protein.run.json

Where the results are. The calculation writes four files that share one prefix, 1UBQ_A_protein (structure, chain, method):

  • .keys: every distinct key and its frequency, how many triangles have it;
  • .triplets: one row per triangle (optional, and by far the largest file);
  • .meta.json: what the keys mean and how the triangles were drawn;
  • .run.json: the completion record, written last. It lists the other three files and their sizes, so an interrupted run never looks complete.
In [6]:
PREFIX = WORK / "results" / "1UBQ_A_protein"
record = json.loads(PREFIX.with_suffix(".run.json").read_text())
listed = record["results"][0]["file_bytes"]
rows = [{"file": "results/" + name, "bytes": f"{size:,}", "role": "listed in the record"} for name, size in listed.items()]
rows.append({"file": "results/" + PREFIX.name + ".run.json",
             "bytes": f"{PREFIX.with_suffix('.run.json').stat().st_size:,}", "role": "the completion record"})
table(rows, ["file", "bytes", "role"])
assert len(rows) == 4
file bytes role
results/1UBQ_A_protein.keys 857,253 listed in the record
results/1UBQ_A_protein.meta.json 4,236 listed in the record
results/1UBQ_A_protein.triplets 17,353,232 listed in the record
results/1UBQ_A_protein.run.json 2,303 the completion record

Check the counts from the files themselves. The .keys file has one row per distinct key, with its frequency. The .triplets file has one row per triangle.

In [7]:
keys = read_tsv(PREFIX.with_suffix(".keys"))
triplets = read_tsv(PREFIX.with_suffix(".triplets"))
n_ca = sum(1 for line in open(PDB / "1UBQ.pdb")
           if line.startswith("ATOM") and line[12:16].strip() == "CA" and line[21] == "A")

print(f"Cα atoms in chain A:        {n_ca}")
print(f"ways to choose 3 of {n_ca}:     {math.comb(n_ca, 3):,}")
print(f"rows in .triplets:          {len(triplets):,}")
print(f"frequencies in .keys sum to {sum(int(k['frequency']) for k in keys):,}")
print(f"distinct keys (.keys rows): {len(keys):,}")

assert n_ca == 76
assert math.comb(76, 3) == len(triplets) == sum(int(k["frequency"]) for k in keys) == 70_300
assert len(keys) == len({k["key"] for k in keys}) == 45_349
Cα atoms in chain A:        76
ways to choose 3 of 76:     70,300
rows in .triplets:          70,300
frequencies in .keys sum to 70,300
distinct keys (.keys rows): 45,349

Why 70,300? A triangle is any three different Cα atoms, and the order does not matter. There are 76 × 75 × 74 ordered ways to pick three atoms, and each set of three is counted 3 × 2 × 1 = 6 times, so 76 × 75 × 74 / 6 = 70,300 triangles. They produce 45,349 distinct keys, because some triangles share a key.

The first rows of each file:

In [8]:
table(keys[:3], ["key", "category", "frequency"])
shown = [{**r, "theta": f"{float(r['theta']):.2f}", "max_distance": f"{float(r['max_distance']):.2f}"} for r in triplets[:3]]
table(shown, ["key", "label_name1", "seq1", "label_name2", "seq2", "label_name3", "seq3",
              "theta", "theta_class", "max_distance", "distance_class"])
key category frequency
4662039 regular 1
4662056 regular 1
4662058 regular 1
key label_name1 seq1 label_name2 seq2 label_name3 seq3 theta theta_class max_distance distance_class
11241182 MET 1 ILE 3 GLN 2 89.85 29 6.58 2
11117386 MET 1 GLN 2 PHE 4 27.70 5 10.25 4
17727096 VAL 5 MET 1 GLN 2 31.08 6 13.11 5

4 · Find the triangle Val 70 · Ile 44 · Leu 8

Each .triplets row lists its three residues (label_name, seq) in BioTSR's order: by vertex label, highest first. theta is the TSR angle θ, max_distance the longest edge (maxDist, in Å), and theta_class and distance_class the bins they fall in.

In [9]:
def triangle(*residue_numbers):
    """The .triplets row of the triangle made of these residue numbers."""
    want = sorted(str(n) for n in residue_numbers)
    rows = [r for r in triplets if sorted([r["seq1"], r["seq2"], r["seq3"]]) == want]
    assert len(rows) == 1
    return rows[0]

def residues(row):
    return " · ".join(f"{row[f'label_name{i}'].title()} {row[f'seq{i}']} (label {row[f'label{i}']})" for i in (1, 2, 3))

first = triangle(8, 44, 70)
print("BioTSR's order: ", residues(first))
print(f"angle θ:         {float(first['theta']):.2f}°   → class {first['theta_class']}")
print(f"longest edge:    {float(first['max_distance']):.2f} Å  → class {first['distance_class']}")
print("key:            ", first["key"])

assert [first["seq1"], first["seq2"], first["seq3"]] == ["70", "44", "8"]
assert first["key"] == "17637781" and first["theta_class"] == "11" and first["distance_class"] == "5"
assert round(float(first["theta"]), 2) == 43.25 and round(float(first["max_distance"]), 2) == 11.96
BioTSR's order:  Val 70 (label 20) · Ile 44 (label 10) · Leu 8 (label 8)
angle θ:         43.25°   → class 11
longest edge:    11.96 Å  → class 5
key:             17637781

Every triangle with this key, read from .triplets. They are numbered as in the lesson Four triangles, one key: Val 70 · Ile 44 · Leu 8 first, then the others in the order the file lists them. Angle class 11 covers 42.64° ≤ θ < 45.17°, and distance class 5 covers 11.00 Å ≤ maxDist < 14.00 Å.

In [10]:
KEY = first["key"]

def triangles_with_key(key):
    return [r for r in triplets if r["key"] == str(key)]

same_key = [first] + [r for r in triangles_with_key(KEY) if r is not first]
table([{"#": i, "residues (BioTSR's order)": " · ".join(f"{r[f'label_name{j}'].title()} {r[f'seq{j}']}" for j in (1, 2, 3)),
        "θ": f"{float(r['theta']):.2f}°", "angle class": r["theta_class"],
        "longest edge": f"{float(r['max_distance']):.2f} Å", "distance class": r["distance_class"]}
       for i, r in enumerate(same_key, 1)],
      ["#", "residues (BioTSR's order)", "θ", "angle class", "longest edge", "distance class"])
# residues (BioTSR's order) θ angle class longest edge distance class
1 Val 70 · Ile 44 · Leu 8 43.25° 11 11.96 Å 5
2 Val 5 · Ile 61 · Leu 56 43.67° 11 13.22 Å 5
3 Val 17 · Ile 61 · Leu 15 44.38° 11 13.11 Å 5
4 Val 70 · Ile 36 · Leu 71 44.03° 11 11.02 Å 5

And the same key in .keys: its frequency is the number of rows above.

In [11]:
key_row = [k for k in keys if k["key"] == KEY]
print("\t".join(key_row[0].values()), " ← .keys row")
assert len(key_row) == 1 and int(key_row[0]["frequency"]) == len(same_key) == 4
print("frequency", key_row[0]["frequency"], "= the", len(same_key), "matching .triplets rows")
17637781	regular	4  ← .keys row
frequency 4 = the 4 matching .triplets rows

Why do four different triangles share a key? A key records only the three vertex labels and the bins of the angle and the longest edge, not where the residues are or the exact values. Any Val–Ile–Leu triangle whose angle falls in class 11 and whose longest edge falls in class 5 gets key 17637781.

5 · Compare three protein chains

A manifest is a CSV file in which each row is one calculation. This one has three samples: ubiquitin (1UBQ chain A), barnase (1BRS chain A) and barstar (1BRS chain D). As on Getting started, the method is given once on the command line with --method protein. A manifest can instead name each row's method in a method column, as the BioTSR README does; use one or the other, not both.

In [12]:
samples = [
    {"sample_id": "ubiquitin", "pdb_id": "1UBQ", "chain": "A", "label": "ubiquitin"},
    {"sample_id": "barnase",   "pdb_id": "1BRS", "chain": "A", "label": "barnase"},
    {"sample_id": "barstar",   "pdb_id": "1BRS", "chain": "D", "label": "barstar"},
]
with open(WORK / "samples.csv", "w", newline="") as fh:
    writer = csv.DictWriter(fh, fieldnames=list(samples[0]))
    writer.writeheader()
    writer.writerows(samples)
print((WORK / "samples.csv").read_text())
sample_id,pdb_id,chain,label
ubiquitin,1UBQ,A,ubiquitin
barnase,1BRS,A,barnase
barstar,1BRS,D,barstar

biotsr batch runs every row; the files are named by sample_id. --no-triplets keeps only the keys, which is all the comparison needs, and --if-exists skip again makes the cell safe to rerun.

In [13]:
run("biotsr batch --manifest samples.csv --method protein --pdb-dir pdb/ --output batch/ --no-triplets --if-exists skip")
summary = list(csv.DictReader(open(WORK / "batch" / "batch_summary.csv")))
table(summary, ["sample_id", "structure_id", "chain", "n_triangles", "n_unique_keys"])
expected = {"ubiquitin": ("70300", "45349"), "barnase": ("204156", "125263"), "barstar": ("105995", "62559")}
assert {r["sample_id"]: (r["n_triangles"], r["n_unique_keys"]) for r in summary} == expected
$ biotsr batch --manifest samples.csv --method protein --pdb-dir pdb/ --output batch/ --no-triplets --if-exists skip
manifest   samples.csv (3 source rows, 3 requests)

requests   3 ok, 0 skipped, 0 failed
summary    batch/batch_summary.csv
batch/
sample_id structure_id chain n_triangles n_unique_keys
ubiquitin 1UBQ A 70300 45349
barnase 1BRS A 204156 125263
barstar 1BRS D 105995 62559

biotsr analyze common-keys finds the keys that occur in every sample. It reads the finished results, so rerunning it only rewrites its report.

In [14]:
run("biotsr analyze common-keys --from-results batch/ --output analysis_out/")
report = list(csv.DictReader(open(WORK / "analysis_out" / "analysis" / "common_keys" / "all_samples.csv")))
table(report, ["sample_id", "total_keys", "common_keys"])
assert {r["sample_id"] for r in report} == {"ubiquitin", "barnase", "barstar"}
assert all(int(r["common_keys"]) == 9_523 for r in report)
$ biotsr analyze common-keys --from-results batch/ --output analysis_out/
loaded     3 saved result(s) from 3 calculation(s) in batch/
common keys 9523 over 3 samples
analysis   analysis_out/analysis/common_keys
analysis_out/
sample_id total_keys common_keys
barnase 125263 9523
barstar 62559 9523
ubiquitin 45349 9523

Check it yourself. Read the three .keys files, intersect their sets of keys, and compare the result with the keys BioTSR lists in its report:

In [15]:
key_sets = {s["sample_id"]: {r["key"] for r in read_tsv(WORK / "batch" / f"{s['sample_id']}.keys")} for s in samples}
for name, ks in key_sets.items():
    print(f"{name:10s} {len(ks):>7,} distinct keys")
in_all_three = key_sets["ubiquitin"] & key_sets["barnase"] & key_sets["barstar"]
report_json = json.loads((WORK / "analysis_out" / "analysis" / "common_keys" / "all_samples.json").read_text())
biotsr_common = {str(k) for k in report_json["keys"]}
print(f"\nin all three (our intersection): {len(in_all_three):,}")
print(f"in all three (BioTSR's report):  {len(biotsr_common):,}")
assert in_all_three == biotsr_common and len(in_all_three) == 9_523
print("Same keys, same count.")
ubiquitin   45,349 distinct keys
barnase    125,263 distinct keys
barstar     62,559 distinct keys
in all three (our intersection): 9,523
in all three (BioTSR's report):  9,523
Same keys, same count.

9,523 distinct keys occur in all three samples. Each is a combination of three vertex labels and two shape bins that appears at least once in ubiquitin, in barnase and in barstar.

6 · Your turn

Ubiquitin has many other keys that more than one triangle shares. The cell below lists a few: the three keys shared by the most triangles, and three shared by exactly three. Pick one (a small one is easiest), set MY_KEY to it and run the cell again: it shows the triangles behind that key and checks the count against .keys. What do the triangles have in common, and what differs?

In [16]:
by_frequency = sorted((k for k in keys if k["key"] != KEY), key=lambda k: (-int(k["frequency"]), int(k["key"])))
repeated = by_frequency[:3] + [k for k in by_frequency if k["frequency"] == "3"][:3]
table(repeated, ["key", "frequency"])

MY_KEY = None   # ← put one of the keys above here, for example MY_KEY = 4722992

if MY_KEY is None:
    print("Set MY_KEY to one of the keys above and run this cell again.")
else:
    mine = triangles_with_key(MY_KEY)
    table([{"residues": " · ".join(f"{r[f'label_name{j}'].title()} {r[f'seq{j}']}" for j in (1, 2, 3)),
            "θ": f"{float(r['theta']):.2f}°", "longest edge": f"{float(r['max_distance']):.2f} Å"} for r in mine],
          ["residues", "θ", "longest edge"])
    frequency = int(next(k["frequency"] for k in keys if k["key"] == str(MY_KEY)))
    assert len(mine) == frequency
    print(f"{len(mine)} triangles, matching the .keys frequency of {frequency}.")
key frequency
10266893 26
14834393 26
14958223 26
4722992 3
5636465 3
5636468 3
Set MY_KEY to one of the keys above and run this cell again.

Where next

BioTSR is developed by the TSR Research Group, https://tsrmethod.github.io/.