T10-L03 · Data & analysis · Level 3 Builder · 17-minute read, plus 3 hours of doing
The figure nobody could rebuild
Clara's cleaning workbook from T10-L02 works. Every month a colleague refreshes it and the check block adds up. The analysis after it doesn't work that way. Clara opens a notebook, runs the cells, decides which of the re-injected samples to keep, and pastes the bar chart into the group meeting slides. In June one of those charts goes into a paper draft. In September a co-author asks her to add two samples and redraw it. She can't reproduce June's numbers. Her laptop now has pandas 3, which changed how a chained assignment behaves, so a line that used to overwrite values no longer changes the table. And she no longer remembers which injection she kept for two of the samples.
In a company it's the monthly cost review. Deniz's clean table goes into a spreadsheet where the controller compares actual costs with the budget per cost centre. In July the budget was reforecast. Half the team compares against the new budget, half against the old one, and the management summary mixes both.
Neither analysis was wrong on the day it was made. Nobody could make it again. You are at Level 3 Builder: colleagues and co-authors depend on these numbers. This book shows you how to package an analysis so that someone else can rerun it on their own computer and get the same result, or a clear message telling them why not.
After this you can
- Separate raw input, code, settings and generated output, so nobody fixes a result by hand.
- Pin the Python environment with uv and a lockfile, so a colleague's computer runs the same package versions.
- Fingerprint every input and record every analyst decision in a file, so the pipeline stops when either is missing or changed.
- Ship a small sample with hand-checked answers and a check that prints PASS or FAIL.
- Hand the pipeline to a colleague who reruns a real month on a fresh copy and gets identical numbers.
Before you start
- Books: T10-L02 for the clean table this pipeline reads. T08-L02 for working with OpenCode Desktop and a shared Git repository. The four-quadrant method is in T01-L01.
- uv, a free tool that installs Python and your packages at exactly the versions written in a lockfile. You and your colleague both need it. Its installer puts it in your own user folder, so it normally needs no admin rights.
- OpenCode Desktop for the first draft of the script, and Git with a place to share the repository.
- Two months of your clean table, and ten minutes with whoever decides the analysis choices (your PI, the controller).
- One colleague who will rerun it without you.
- About 3¼ hours: 17 minutes to read, about 3 hours to do, in two sessions.
Last verified 23 September 2026. uv 0.12.18 (22 September 2026), free under the MIT or Apache 2.0 licence. Install on Windows with
winget install --id=astral-sh.uv -e, on macOS withbrew install uv, or with the standalone installer on docs.astral.sh/uv, which puts uv in your user's own folder for programs.uv initcreatespyproject.tomland.python-version, the file that tells uv which Python to use;uv add pandasadds a package and writesuv.lock, which uv's documentation says to commit to version control.uv runlocks and syncs the environment before it runs a command; with--lockedit stops with an error instead of updating an out-of-date lockfile. uv downloads the required Python version automatically when it's missing. pandas 3.0.6 (17 September 2026) needs Python 3.11 or newer. pandas 3.0 (January 2026) made a dedicated string type the default and switched to copy-on-write, under which chained assignment stops working. The latest Python is 3.14.7.
The idea in one page
A result your group can rely on comes from six parts. Leave one out and the numbers depend on your laptop or your memory.
| Part | Why it's there |
|---|---|
| 1. A script | Every step from the clean table to the summary and figure, in order, the same way each time. |
| 2. A settings file per run | Which input, which groups in which order, which decimals. Nobody edits the script to change a setting. |
| 3. A pinned environment | The exact Python and package versions, in a lockfile, so a new computer gets the same software. |
| 4. Pinned inputs and decisions | A fingerprint of each input file, and a file recording every choice a person made. The script stops if either is missing or different. |
| 5. A sample with known answers | A few rows whose summary you worked out by hand. The check reruns it and prints PASS or FAIL. |
| 6. A README | Setup, rerun, check, and what to do when it stops, for someone who has never met you. |
Four facts shape how you build it.
- Hidden state is what breaks reruns. An absolute path, a notebook cell run out of order, a package updated last month, a choice made by eye. None of it is visible on your computer, because your computer has it. A colleague's rerun on a fresh copy is the only test that finds it.
- Package updates change results. Major versions change defaults, and code written for one version can run without error on the next and do something else. The lockfile fixes the versions until you decide to update them and rerun the checks.
- Analyst choices are data. "Keep the first injection for sample L2" is part of the result, as much as the measured values. If it lives in your head, the result does too. Write it into a small file with a reason and a name.
- Output is generated, never edited. A figure with a label fixed by hand can't be regenerated. Delete the output folder and rerun: if something is missing, it lived in the wrong place.
What stays yours: the analysis choices, the hand-checked answers and the judgement whether a difference matters. An AI coding assistant writes code; you decide what correct means.
Worked example: two analyses, four quadrants
Both situations use the four-quadrant method from T01-L01. The lab one is told in full. Names and numbers are illustrations; the code runs as shown.
Lab: the monthly HPLC summary
Structure it. "Every month our clean HPLC table becomes a summary per treatment group, n, mean and standard deviation, and a bar chart for the group meeting. Some of these charts go into papers. A good result: anyone in the group can rerun any month, on their own computer, and get the same numbers and figure; and a co-author can see how each one was made."
Understand it. Clara tries to rerun June herself before building anything, and writes down every place she has to guess. The notebook reads from C:\Users\clara\Downloads\. It runs only if cell 7 goes before cell 5. A line meant to replace the values of failed runs with empty values no longer changes the table under pandas 3, so the June mean now includes two failed runs. And for two re-injected samples she can't say which injection she kept. The T10-L02 workbook flags them correctly; the choice after that was hers, made by eye, every month.
That last point changes her problem. Back to Structure it: "The pipeline must never choose an injection. A person does, in a decisions file with a reason and a name, and the pipeline stops if any re-injected sample has no decision." She shows the PI the June case. He agrees, and asks that the reason column is filled in every time.
Choose tools. Python with pandas and matplotlib, because the group already uses them and the analysis is ordinary. uv for the environment: one command sets up the exact Python and packages from the lockfile, on Windows and macOS, and it normally installs without admin rights. The alternative is Miniforge with a conda environment file, which the imaging group uses; it's the better choice when you need non-Python software from conda-forge. For R users, renv does the same job. The group's Gitea holds the code; the data stay in the group drive. OpenCode Desktop writes the first draft of the script, as in T08-L02, and Clara reviews every line. She rejects KNIME here: its workflows can be versioned too, but everyone in the group already reads Python.
Build it. She creates the project and pins the environment:
uv init hplc-summary
cd hplc-summary
uv add pandas matplotlib
She sets .python-version to 3.13 and commits pyproject.toml, .python-version and uv.lock. Then she builds the sample before any analysis code. Nine samples in three groups, with one sample re-injected, chosen so she can work out the answer in her head:
sample_id,group,injection,result_mg_l
C1,control,1,10.0
C2,control,1,12.0
C3,control,1,14.0
L1,low dose,1,20.0
L2,low dose,1,22.0
L2,low dose,2,30.0
L3,low dose,1,24.0
H1,high dose,1,40.0
H2,high dose,1,44.0
H3,high dose,1,48.0
The decisions file for the sample keeps injection 1 of L2, with the reason pressure warning on injection 2. By hand: control 12.00 ± 2.00, low dose 22.00 ± 2.00, high dose 44.00 ± 4.00, n = 3 each. If the pipeline ignored the decision and kept both L2 rows, low dose would be 24.00 with n = 4. That's the point of the sample: a wrong pipeline gives a visibly wrong answer.
Her request to OpenCode in Plan mode names the files, the settings, the stops and the outputs, and says "pandas 3; no chained assignment; standard settings file in TOML; no other packages". She reads the plan, removes an "automatic outlier removal" nobody asked for, and lets it build. After review, analyse.py reads:
"""Monthly HPLC summary: n, mean and SD per group, plus a bar chart.
Run: uv run analyse.py <settings file> e.g. uv run analyse.py months/2026-09.toml"""
import hashlib, pathlib, subprocess, sys, tomllib
from importlib.metadata import version
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pandas as pd
HERE = pathlib.Path(__file__).parent
settings = pathlib.Path(sys.argv[1])
P = tomllib.loads(settings.read_text(encoding="utf-8"))
data = settings.parent / P["input"]
found = hashlib.blake2b(data.read_bytes(), digest_size=16).hexdigest()
if found != P["input_fingerprint"]:
sys.exit(f"STOP: {data.name} has fingerprint {found}, the settings expect "
f"{P['input_fingerprint']!r}. Is it the right file? See README.")
rows = pd.read_csv(data)
unknown = set(rows["group"]) - set(P["group_order"])
if unknown:
sys.exit(f"STOP: groups not in group_order: {sorted(unknown)}")
counts = rows["sample_id"].value_counts()
repeated = set(counts[counts > 1].index)
decided = pd.read_csv(settings.parent / P["decisions"])
if repeated - set(decided["sample_id"]):
sys.exit(f"STOP: no decision for {sorted(repeated - set(decided['sample_id']))}. "
"Add them to the decisions file (see README).")
rows = rows.merge(decided[["sample_id", "keep_injection"]], on="sample_id", how="left")
rows = rows[rows["keep_injection"].isna() | (rows["injection"] == rows["keep_injection"])]
if rows["sample_id"].duplicated().any() or len(rows) != len(counts):
sys.exit("STOP: the decisions file doesn't match the input (see README).")
summary = (rows.groupby("group")["result_mg_l"]
.agg(n="count", mean="mean", sd="std")
.reindex(P["group_order"]).round(P["decimals"]))
out = HERE / "output" / settings.stem
out.mkdir(parents=True, exist_ok=True)
summary.to_csv(out / "summary.csv")
ax = summary["mean"].plot.bar(yerr=summary["sd"], capsize=4, color="grey")
ax.set_ylabel(f"mean result ({P['unit']}), error bars: SD")
ax.set_xlabel(f"n per group: {', '.join(str(n) for n in summary['n'])}")
plt.tight_layout()
plt.savefig(out / "summary.png", dpi=200)
commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=HERE,
capture_output=True, text=True).stdout.strip()
(out / "run_info.txt").write_text(
f"input {data.name}, fingerprint {found}\ncode commit {commit or 'unknown'}\n"
f"python {sys.version.split()[0]}, pandas {version('pandas')}, "
f"matplotlib {version('matplotlib')}\n\n" + settings.read_text(encoding="utf-8"),
encoding="utf-8")
print(summary.to_string(), f"\nResults in {out}", sep="\n")
Three stops, each with a reason a colleague can act on: a different input file, a group nobody declared, a re-injection nobody decided. Everything the result depends on goes into run_info.txt next to it.
Each month gets its own small settings file, committed to Git:
# months/2026-09.toml. Copy the input from the group drive into data/ first.
input = "../data/hplc-2026-09-clean.csv"
input_fingerprint = "3f9c0e5a7d21b8c46e0f92ab15d7c3e8"
decisions = "2026-09-decisions.csv"
group_order = ["control", "low dose", "high dose"]
unit = "mg/L"
decimals = 2
The decisions file next to it has four columns: sample_id, keep_injection, reason, decided_by. The data folder is listed in .gitignore: unpublished measurements stay in the group drive, and the fingerprint proves that a colleague copied the same file.
The check script reruns the sample and compares it with the hand-worked answer:
"""Rerun the sample and compare with the hand-checked summary. Run: uv run check.py"""
import pathlib, subprocess, sys
import pandas as pd
HERE = pathlib.Path(__file__).parent
run = subprocess.run([sys.executable, str(HERE / "analyse.py"), str(HERE / "sample/sample.toml")])
if run.returncode != 0:
sys.exit("FAIL: the sample run stopped (see the message above)")
got = pd.read_csv(HERE / "output/sample/summary.csv", index_col="group")
want = pd.read_csv(HERE / "sample/expected_summary.csv", index_col="group")
try:
pd.testing.assert_frame_equal(got, want, check_dtype=False)
except AssertionError as difference:
sys.exit(f"FAIL: sample summary differs from the hand-checked answer\n{difference}")
print("PASS")
The first check fails. The numbers agree, but she typed the expected file with the groups in alphabetical order, while the pipeline follows group_order. She fixes the expected file, not the code: the order in the settings is the one the group wants on the chart. The second run prints PASS. Then she deletes the whole output folder, reruns September, and compares with the numbers she'd made in the notebook. Two groups match. Low dose differs, because the notebook had kept both injections of one sample. The pipeline is right, and the notebook had been wrong for at least three months.
Her README follows the pattern from T18-L03: set up once (install uv, clone, copy the month's file from the group drive into data/), check (uv run --locked check.py must print PASS), run a month (uv run --locked analyse.py months/2026-09.toml), add a month (new settings file, fingerprint, decisions, commit), and When it stops, with one line per STOP message. Maintainer: Clara. Deputy: Ben. The --locked flag makes uv stop instead of quietly updating packages when someone has edited pyproject.toml without updating the lockfile.
Hand it over. Ben clones the repository on his Windows laptop, where he has never used uv, and follows the README without asking her anything. uv downloads Python 3.13 and the pinned packages. The check prints PASS. He copies September's file from the group drive and runs it. His summary.csv is identical to hers, byte for byte. He makes one mistake on the way: he copies August's file by accident, and the fingerprint STOP catches it. That goes into the README as the most likely error. The figure looks the same; she doesn't ask for identical image files, because font rendering can differ between systems, and writes that into the README too: the table must match exactly; the figure is regenerated from it.
For the paper figure, the co-author now gets a commit, a settings file and a decisions file with reasons. She reruns June from the committed settings, with the decisions from Clara's lab notebook entered and signed. The June mean changes in the second decimal for one group, and the paper uses the rerun.
What it taught her: the script took an hour. Finding what lived only in her head, and giving it a file, took the rest of the day.
Company: the monthly cost review
Structure it. Deniz writes: "Each month: actual costs per cost centre from the clean table, compared with the budget, month and year to date. The controller presents it to management. A good result: anyone in finance can rerun any month and get the controller's numbers."
Understand it. The July mix-up comes from the budget file. It was reforecast mid-year and overwritten in place, so the same file name now holds a different budget. Deniz also finds that the year-to-date figures were summed by hand in a spreadsheet from each month's result. Back to Structure it: the budget is an input like any other, with its own fingerprint and a version name, and the report prints which budget it compared with. Year to date is computed from all months' inputs, not added up by hand.
Choose tools. The same pattern and tools. The inputs contain no personal data after T10-L02's cleaning, which the controller confirms before anything goes into Git.
Build it. Her settings file has two pinned inputs, the month's actuals and budget-2026-reforecast-july.xlsx with its fingerprint, and a line budget_version = "Reforecast July" that appears in the table heading. Her sample has two cost centres over three months with round numbers, including one month where the budget changes, so the known answer tests the switch. The first check fails: year to date compares every month with the new budget. The controller settles the rule: each month is compared with the budget valid in that month. It goes into the settings and the README, and the second run passes.
Where it stops. In week four the controller asks whether the pipeline could read the actuals straight from the ERP system every Monday. That means a service account, a live source that can change shape, and quality checks on data nobody has looked at. It's Level 4 work with IT, and T10-L04 starts there. Deniz writes it into the README as the next step.
In both cases the code was the small part. What made the numbers rerunnable was finding the hidden state: a path, a package version, a choice, a file overwritten in place.
Where your build stops
A pipeline with pinned inputs, a lockfile, a sample and a README is right when people run it by hand on files they copy. Stop and talk to IT, or move to T10-L04, when:
- the pipeline should read from a live system: a database, the LIMS, the ERP, an instrument server;
- it should run on a schedule with nobody watching;
- the data include personal or confidential fields (T12-L01 first);
- other systems consume the output directly.
What goes wrong
It only runs on your computer. Symptom: a colleague gets a missing-module error or a different number. Fix: uv with a committed lockfile, --locked in every README command, and a colleague's rerun on a fresh clone before anyone relies on it.
The input changed under the same name. Symptom: last month's rerun gives a new number. Fix: a fingerprint per input in the settings file, and a new file name for every new version of an input.
A decision lives in your head. Symptom: you can't say why a sample was excluded. Fix: a decisions file with reason and name, and a STOP when a decision is missing.
Fixing the output. Symptom: a label corrected in the figure file, lost on the next run. Fix: change the settings or the script, delete output, rerun.
Expected answers copied from the pipeline. Symptom: the check passes, but the expected file came from a run, not from your hand calculation. Fix: work out the sample by hand, and design it so that the likely mistake gives a visibly different answer.
Updating packages by accident. Symptom: someone runs uv add for a side project and the lockfile changes. Fix: package updates are a change like any other: update on purpose, rerun the check and one old month, note it in CHANGES.md.
Do it yourself (3 hours, two sessions)
- Structure it (15 min). Write who reruns the analysis, how often, and what "the same result" means: an identical table, a stated tolerance, a regenerated figure.
- Understand it (30 min). Try to rerun last month from scratch. Write down every guess you make: paths, cell order, packages, choices. Rewrite your problem with the choices that need a file.
- Choose tools (10 min). uv, or conda if you need non-Python software. Decide where code and data live, and what stays out of Git.
- Build it: sample and environment (40 min).
uv init,uv add, commit the lockfile. Make a sample of about ten rows with an answer worked out by hand and one decision that changes it. - Build it: script and check (50 min). Draft with OpenCode in Plan mode, review every line, add the stops. Write the check. Delete
output, rerun a real month and compare with your old result. Explain every difference. - Build it: README and rerun (35 min). Write the README and name a deputy. Watch a colleague clone, check and rerun one month without help. Fix the README wherever they got stuck.
Exit check
Delivered artifact: a Git repository with:
analyse.pyandcheck.py;pyproject.toml,.python-versionanduv.lock;- one settings file and one decisions file per month, with input fingerprints;
- a sample with a hand-checked
expected_summary.csv; - a README with setup, check, run, add a month, and when it stops, and a named maintainer and deputy.
It passes when a colleague clones it on their own computer, the check prints PASS, and their rerun of one real month gives a summary identical to yours.
Rule to remember
If it lives only on your computer or in your head, the result isn't reproducible yet.
Further reading and tools
Next books
- T10-L02 · Clean a Messy Export Once: the clean table this pipeline reads.
- T10-L04 · Pipelines over real data: live sources, quality checks that stop bad data, schedules.
- T18-L03 · Build a Cell-Counting Pipeline Your Group Can Rerun and Check: the same six parts for image analysis.
- T15-L02 · Record an AI Step So a Co-Author Can Rerun It: what to write in the methods section.
- T08-L03 · Write the Spec First: changing the pipeline later without breaking it.
Tools
- OpenCode: Taught. Drafts the script in Plan and Build mode.
- Git: Taught. Code, settings and decisions, with history.
- Gitea, GitHub: Catalogued. Where the repository lives.
- KNIME: Catalogued. A visual alternative when your group doesn't read Python.
- Claude: Catalogued. Code execution for exploring a new export before you write the pipeline.