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, a file of code blocks called cells that you run one at a time, 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, a new major version of the Python package for tables, and 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, a file listing every package and its exact version. 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 (the PI who leads your group, or 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 is free and at version 0.12.18. pandas 3.0 changed how some older code behaves, so pin your versions. The current pandas, 3.0.6, needs Python 3.11 or newer; the latest Python is 3.14.7. Details for experts at the end of this book.
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 (a short code computed from its contents, which changes if any byte changes), 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, the usual package for charts, 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, a free installer for the conda package manager, which the imaging group uses. It's the better choice when you need software that isn't Python, from the conda-forge collection. For R users, the R package renv does the same job: it records the project's package versions in a lockfile, renv.lock, and installs exactly those versions on another computer.
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 folder, moves into it, and adds the two packages, which also writes the lockfile:
uv init hplc-summary
cd hplc-summary
uv add pandas matplotlib
She sets the Python version to 3.13 and commits the three files uv made: pyproject.toml (the project's description), .python-version and uv.lock (the lockfile). 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.
She saves the sample in a sample folder as sample.csv, with its own settings file sample.toml and a decisions file, in the same form as the monthly ones below. The hand-worked answer goes into expected_summary.csv in the same folder, with the columns group, n, mean and sd.
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". Chained assignment, selecting part of a table and overwriting it in one line, is the habit that caused her June bug. TOML is a simple text format for settings. She reads the plan, removes an "automatic outlier removal" nobody asked for, and lets it build. After review, analyse.py does this, from top to bottom:
- It reads the settings file you name when you run it.
- It computes the input file's fingerprint and stops if it differs from the one in the settings.
- It reads the table and stops if a group appears that the settings don't list.
- It finds samples that appear more than once, the re-injections, and stops if any of them has no decision. Then it keeps only the injection each decision names, and stops if the decisions and the input don't fit together.
- It computes n, mean and SD per group, in the order and with the decimals the settings give.
- It writes the summary table and the bar chart into a folder for this run under output.
- It writes run_info.txt: the fingerprint, the Git commit of the code, the Python and package versions, and a copy of the settings.
The code itself:
"""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, the injection to keep, the reason, and who decided. To fill in the fingerprint for a new month, she first writes any placeholder, such as "new", and runs the script once. The STOP message prints the file's real fingerprint. She checks it's the right file, then pastes the fingerprint into the settings.
The data folder is listed in .gitignore, the file that tells Git what never to store: unpublished measurements stay in the group drive, and the fingerprint proves that a colleague copied the same file.
The check script runs analyse.py on the sample, reads the summary it made and compares it with the hand-worked answer, value by value. Any stop or difference prints FAIL and what went wrong; a match prints PASS:
"""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, in five parts:
| README part | What it says |
|---|---|
| Set up once | Install uv, clone the repository, 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. |
| When it stops | One line per STOP message. |
Maintainer: Clara. Deputy: Ben. The --locked option 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, downloading a full copy with its history, 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, the company's business software, every Monday. That means a service account (a login for a program rather than a person), 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 lab's sample-tracking system), the ERP (the company's business software), 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, and note it in CHANGES.md, a short change log in the repository.
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
What you hand in: a Git repository with:
analyse.pyandcheck.py;- the three files uv made, including the lockfile
uv.lock; - one settings file and one decisions file per month, with input fingerprints;
- a sample with a hand-checked expected summary;
- 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.
Details for experts
Checked on 23 September 2026. You don't need this to follow the book; it's for setting things up or answering a colleague.
| What | Details | What it means for you |
|---|---|---|
| uv | Version 0.12.18, released 22 September 2026. Free under the MIT or Apache 2.0 licence, both open-source licences that allow use at work. | No licence to buy or request. |
| Installing uv | Windows: winget install --id=astral-sh.uv -e (winget is Windows' built-in app installer). macOS: brew install uv (Homebrew, a common Mac installer). Or the standalone installer on docs.astral.sh/uv, which puts uv in your user's own folder for programs. | Use the standalone installer if you have no admin rights. |
uv init | Creates pyproject.toml, the project's description and package list, and .python-version, the file that tells uv which Python to use. | Both get committed to Git. |
uv add | uv add pandas adds a package and writes uv.lock. uv's documentation says to commit the lockfile to version control. | Commit uv.lock every time it changes, or your colleague gets other versions. |
uv run and --locked | uv run locks and syncs the environment (brings the installed packages in line with the lockfile) before it runs a command. With --locked it stops with an error instead of updating an out-of-date lockfile. | Put --locked in every README command, so nobody updates packages by accident. |
| Python itself | uv downloads the required Python version automatically when it's missing. The latest Python is 3.14.7. | Your colleague doesn't need to install Python first. |
| pandas | Version 3.0.6, released 17 September 2026, needs Python 3.11 or newer. | A project pinned to Python 3.13, as in the example, is fine. |
| pandas 3.0 changes | Released January 2026. A dedicated string type, a column type just for text, became the default. Copy-on-write became the rule: a selected part of a table behaves as a copy, so chained assignment (selecting and then overwriting in one line) no longer changes the original table. | Old notebooks can run without an error and give different numbers. That's Clara's June bug. |
Live sources and scheduled runs: more in T10-L04.
Further reading and tools
Next books
- T10-L02 · Clean a Messy Export Once, So Your Team Can Refresh It: the clean table this pipeline reads.
- T10-L04 · Connect Your Analysis to a Live System, and Stop Bad Data Before It Reaches Anyone: 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, So an AI Assistant Can Change Code Your Team Depends On: 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.