#2 Practicalities — best practices for research software
2026-07-24
0.1 + 0.2, “why is this slow?”)Nearly all of it stands on one shared abstraction: the NumPy array.
Remember why arrays are fast — one contiguous block. NumPy brings that to Python:
One line of Python = one compiled loop over the whole block
Iterate one very simple map:
\[z_{n+1} = z_n^2 + c, \qquad z_0 = 0\]
for a complex number \(c\) — i.e. a point in the plane.
max_iter times and colour it by when it escaped
As a project grows, one script stops being enough. Python gives you a few levels of structure, each one built out of the last.
scipy.stats.linregress).py file of related functions and classes, which can be imported with import my_module (e.g., scipy.stats)scipy)Four words for four different shapes of software. Knowing which one you are looking at (or writing) tells you what to expect from it.
plot_experiment.py from earlier. It is not usually imported by other codeimport matplotlib.pyplot as plt
import numpy as np
expt_number = 7
file = "data/experiments/sample4.csv"
data_file = []
with open(file, "r") as f:
for line in f.readlines():
if (
line.startswith("nmr-sample4")
and line.split(",")[-1] == expt_number
):
data_file.append(",".join(line.split(",")[1:]))
with open("output.txt", "w") as f:
f.writelines(data_file)
data = np.loadtxt(
data_file, delimiter=",", skiprows=1, comments="%"
)
plt.scatter(data[:, 0], data[:, 1])
plt.title("Sample 4")
plt.savefig("/home/mevans/figures/sample4_expt7.png")Paths, sample names, and “constants” that should be arguments, all baked into the script.
What happens with sample 5?
def plot_experiment(
data_dir, sample_name, expt_number, output_dir
):
"""Scatter-plot one experiment's full spectrum
from a sample's CSV.
"""
file = data_dir / f"{sample_name}.csv"
data_file = []
with open(file) as f:
for line in f:
tag, *values = line.strip().split(",")
if (tag == f"nmr-{sample_name}"
and int(values[-1]) == expt_number):
data_file.append(",".join(values[:-1]))
data = np.loadtxt(
data_file, delimiter=",", skiprows=1, comments="%"
)
plt.scatter(data[:, 0], data[:, 1])
plt.title(f"{sample_name}, experiment {expt_number}")
plt.savefig(output_dir / f"{sample_name}_expt{expt_number}.png")if __name__ == "__main__":
import argparse
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument("sample_name")
parser.add_argument("expt_number", type=int)
parser.add_argument("--data-dir",
type=Path, default=Path("data/experiments"))
parser.add_argument("--output-dir",
type=Path, default=Path("figures"))
args = parser.parse_args()
plot_experiment(args.data_dir, args.sample_name, args.expt_number, args.output_dir)The “good” function from before, but the lab now runs two instruments, and the second one’s output looks nothing like the first:
def plot_experiment(data_dir, sample_name, expt_number, output_dir):
"""Scatter-plot one experiment's full spectrum from a sample's CSV."""
file = data_dir / f"{sample_name}.csv"
data_file = []
with open(file) as f:
for line in f:
tag, *values = line.strip().split(",")
if tag == f"nmr-{sample_name}" and int(values[-1]) == expt_number:
data_file.append(",".join(values[:-1]))
data = np.loadtxt(data_file, delimiter=",", skiprows=1, comments="%")
plt.scatter(data[:, 0], data[:, 1])
plt.title(f"{sample_name}, experiment {expt_number}")
plt.savefig(output_dir / f"{sample_name}_expt{expt_number}.png")nmr- tag, the CSV filtering) are now welded into a function whose name promises nothing instrument-specificif fmt == ...: branch inside, and a third means another, and a fourthdef parse_nmr(file, sample_name):
"""Parse every NMR row for one sample, all experiments together."""
data_file = []
with open(file) as f:
for line in f:
tag, *values = line.strip().split(",")
if tag == f"nmr-{sample_name}":
data_file.append(",".join(values))
return np.loadtxt(data_file, delimiter=",", skiprows=1, comments="%")
def parse_xrd(file):
"""Parse the XRD instrument's whitespace-delimited export."""
return np.loadtxt(file, skiprows=3)
def parse(file, sample_name, fmt):
"""Parse one experiment file, whatever instrument it came from."""
if fmt == "nmr":
return parse_nmr(file, sample_name)
elif fmt == "xrd":
return parse_xrd(file)
else:
raise ValueError(f"unknown format {fmt!r}")
def plot_experiment(data, sample_name, expt_number, output_dir):
"""Scatter-plot one experiment's spectrum, picked out of parsed data."""
spectrum = data[data[:, -1] == expt_number]
plt.scatter(spectrum[:, 0], spectrum[:, 1])
plt.title(f"{sample_name}, experiment {expt_number}")
plt.savefig(output_dir / f"{sample_name}_expt{expt_number}.png")parse_* function and adding one elif, nothing else changesplot_experiment picks its own rows out with a mask, the same arr[arr > 1.2] trick from the NumPy slide, data[:, -1] is the expt_number column, kept alongside x and yBecause parsing happens once and plotting is separate, looping is trivial, and the file is only read once, not once per experiment:
def plot_experiments(data_dir, sample_name, expt_numbers, fmt, output_dir):
"""Plot several experiments from the same sample in one go."""
file = data_dir / f"{sample_name}.{fmt}"
data = parse(file, sample_name, fmt)
for expt_number in expt_numbers:
plot_experiment(data, sample_name, expt_number, output_dir)def compare_samples(data_dir, sample_names, expt_number, fmt, output_dir):
"""Overlay one experiment from several different samples on one plot."""
for sample_name in sample_names:
file = data_dir / f"{sample_name}.{fmt}"
data = parse(file, sample_name, fmt)
spectrum = data[data[:, -1] == expt_number]
plt.scatter(spectrum[:, 0], spectrum[:, 1], label=sample_name)
plt.legend()
plt.savefig(output_dir / f"comparison_expt{expt_number}.png")plot_experiments didn’t exist before, and cost almost nothing to write, it’s just a loop over a function we already had and trustedcompare_samples loops over files, not experiment numbers, and adds each one to the same plot instead of saving a new figure every time. plot_experiment doesn’t fit here, it saves and closes a figure per call, so this is a genuinely new, small function, built directly on parse() againplot_experiment isn’t the only thing that can consume parsed data. Remember JSON, from last time?
import json
def export_json(data, sample_name, expt_number, output_dir):
"""Save one experiment's spectrum as JSON, for any tool to read."""
spectrum = data[data[:, -1] == expt_number]
record = {
"sample": sample_name,
"experiment": expt_number,
"xy": spectrum[:, :2].tolist(), # ndarray isn't JSON-serialisable
}
with open(output_dir / f"{sample_name}_expt{expt_number}.json", "w") as f:
json.dump(record, f)parse(), same data, a completely different destination. A PNG is for you; JSON is for a colleague’s script, a website, a database.tolist() matters: json.dump doesn’t know what a NumPy array is, convert to plain Python first, at the boundary

An interface is the surface where two different things meet, a person, or another program, and your code.
plot_experiment.py from earlier todaydatalab, mentioned on the very first slide of this course, is one underlying platform with several different interfaces built on top.
A plain web request, the interface for any program at all:
The web interface, for a person:

pytest: ordinary unit tests for the logic itself, and tests that send real requests to the API and check what comes backLive: a recent Cypress run, played from the cloud dashboard.
uv, git, pytest, are run from the command line, and it is often the fastest way to work.py files in a proper editor are usually better for anything meant to be kept, reused, or shared. Worth saying if there is time.Live in this lecture: a short click-through tour of VS Code, and a look at its built-in debugger.
A code editor should, before anything else, be an excellent plain text editor. Everything else it offers is only worth having on top of that foundation, not instead of it.


Your computer probably already has a Python installed, maybe several. Installing every package you ever need into that one, shared Python is how projects end up fighting each other.
uvuv is a single, fast tool that replaces most of the separate tools that used to be needed to manage a Python project: pip, conda, venv, virtualenv, pip-tools, and pyenv, among others.
uv init my-project creates a new project, with its own isolated environment and its own list of dependenciesuv add numpy adds a dependency, installs it into the project’s environment, and records the exact version installeduv run script.py always runs your code inside the correct, up-to-date environment, with no separate “activation” step to rememberuv.lock is a complete, exact record of every installed package and its version, so the environment can be recreated identically on another machine, or in a year’s timeuv sync.Writing tests can feel like it slows you down, since it is extra code that produces no new results by itself. In practice, it saves time overall, because it catches mistakes early, while they are still cheap to fix.
pytestpytest is the standard tool for writing and running tests in Python. A test is a function whose name starts with test_, that checks something is true using a plain assert.
test_*.py, with functions named test_*. pytest finds and runs them automatically, no registration neededuv run pytestassert failed, and the values involved, so you can see straight away what went wrongVersion control is a system for recording the history of changes to a set of files over time.
For a proper hands-on treatment, see the dedicated git-tutorial.
git tracks history on your own computer. GitHub, and similar sites like GitLab, host a copy online, so it can be shared, backed up, and collaborated on.
navaninavani started, like a lot of research software does, as a set of scripts Ben Smith wrote during his PhD to process battery cycler data.
uv add navani, instead of emailing someone for a scriptWe also have a Grey Group GitHub!
Put together, everything in this lecture, structuring code, managing environments, version control, and testing, is what the growing field of research software engineering is about.
You can join the UK’s RSE society https://society-rse.org/ who host events, training and job boards
You could even join the RS-Triple-E https://east-england.society-rse.org/), the East of England RSE group, who meet monthly in Cambridge and online (spoilers: I run this)
Supposedly there are careers doing this work specifically in academia, with 40 groups in the UK
Most people’s first experience of AI and code is a chat window: paste in a function, ask a question, copy the answer back into the editor by hand.
git or uv, and edit files directlypytest itself, see a failure, and try again is doing something a copy-paste chat window structurally cannotAn AI coding tool is most helpful when you already know what you want to write, and can tell whether what it produces is actually correct (via tool calls to e.g., run tests).
do concurrent loopResearch Computing with Python