Code Organization
Coming from Development Setup? This page explains how the code you just installed is actually laid out, so you can find your way around it.
What this toolbox does
The VRLab Toolbox turns raw recordings — physiology signals, VR event logs, and questionnaire answers — into one clean, per-participant data file that's ready for analysis. It does this with a pipeline: a fixed sequence of steps (find files → import → process → combine → save) that every participant's data passes through, in the same order, every time.
The workspace, at a glance
"The workspace" just means the top-level project folder — the one you open in your editor, containing everything below:
| File / folder | What it's for |
|---|---|
pyproject.toml |
The project's main settings file: build info, the CLI commands ([project.scripts]), and config for tools like ruff and pytest. |
requirements.txt |
The exact package versions needed to run the toolbox. This is what you install first. |
requirements-dev.txt |
Extra tools only needed for developing the toolbox (not for just running it) — currently pyright, ruff, mkdocs, mkdocs-material. |
src/vrlab_toolbox/ |
All the toolbox's Python code. |
tests/ |
The automated test suite (see Testing). |
docs/ |
This documentation site. |
Going further
"Dev requirements" is a common convention: packages a user of the toolbox never needs (a linter, a type checker, a docs builder), but a contributor does. Splitting them out keeps the toolbox itself lightweight to install.
How pyproject.toml works
pyproject.toml is the standard, modern place Python projects put their
build/packaging config — one file instead of the older scattered
setup.py/setup.cfg/MANIFEST.in. The sections that matter here:
[project]— package metadata: name, anddynamic = ["version"](the version isn't written by hand — see next point).[tool.setuptools_scm]— its presence tellssetuptoolsto derive the package's version automatically from git tags, rather than a hand-maintained version string anywhere in the code. Push a tag likev1.2.0, and that becomes the installed package's version. More on why that matters in Building & Releasing.[tool.setuptools.dynamic]— pointsdependenciesatrequirements.txt, which is whypip install -e .alone is enough (see Getting Started).[project.scripts]— declares each CLI command ascommand_name = "python.module.path:function", e.g.vrlab_crane_process = "vrlab_toolbox.cli.vrlab_crane_process:main". This is what turns a plain Python function into a command you can type on its own.
Going further
When you pip install -e ., every name under [project.scripts] gets
written as an actual small executable/script file inside your
virtual environment — .venv\Scripts\ on Windows,
.venv/bin/ on macOS/Linux (see Development Setup
for what a venv is). That's genuinely why typing vrlab_crane_process
works once your venv is active: your shell finds that file on its
PATH. Worth browsing that folder once, next to site-packages/ — it
demystifies where CLI commands actually come from.
Building and previewing this documentation site
This site (docs/) is built with mkdocs. From the workspace root:
# 1. Install the dev tools (mkdocs + mkdocs-material), if you haven't already
pip install -r requirements-dev.txt
# 2. Start a live-reload preview server
mkdocs serve
Step 2 prints a local address (usually http://127.0.0.1:8000/) — open it
in a browser. The page reloads automatically as you edit files in docs/.
Ctrl+C stops the server.
For a static build instead of a live server, use mkdocs build — this
produces a site/ folder (already git-ignored; see Testing).
Going further
For now, this site is local-only — there's no public link to it yet.
The plan is to eventually host it with GitHub Pages: a free feature
of GitHub that takes a folder of built HTML (like mkdocs build's
site/ output) and serves it at its own web address, usually
https://<user-or-org>.github.io/<repo-name>/. We can't turn that on
yet because GitHub Pages only builds automatically from a private
repository on paid GitHub plans (Pro/Team/Enterprise) — on the free
plan, the repo has to be public first. Once this repo is public (or the
plan changes), the plan is to add a small GitHub Actions workflow that
runs mkdocs build and publishes the result automatically, so the site
stays in sync with docs/ without a manual step.
Inside src/vrlab_toolbox/
| Folder | What lives there |
|---|---|
cli/ |
Small command-line scripts users run directly. |
processing/ |
The actual pipeline logic: loading data, building trial intervals, computing physiology metrics, and wiring it all together. |
read_mobi_xdf/ |
Loading .xdf (LSL) recording files. |
Going further
Two folders are mid-cleanup, worth knowing about if you see them
referenced elsewhere: qc/ is dead code (unused, scheduled for
removal), and read_mobi_xdf/ is planned to move into processing/,
living next to biopac.py — the same place .mat loading lives —
since both are just physiology-file loaders for different formats.
Why pipeline.py, then crane_pipeline.py?
processing/pipeline.py holds the generic, reusable machinery —
PipelineTemplate and the Sequential*Steps containers — that don't know
anything about one specific experiment. processing/crane_pipeline.py is
where one experiment ("Crane") wires that generic machinery together with
its own concrete steps, via run_pipeline(). Other experiments (foh_pipeline.py,
long_walk_pipeline.py) follow the same pattern: reuse the shared
machinery, supply experiment-specific steps.
One call to run_pipeline() processes exactly one participant,
returning one row of output. Combining many participants into the final
combined CSV/SPSS file happens separately, at the CLI level — see
Design Patterns for exactly
where that split happens in the code.
Where the actual steps live
crane_pipeline.py itself is mostly wiring — it imports each step from
the module that actually implements it:
| Step | Implemented in |
|---|---|
| Find a participant's files | crane_pipeline.py (FindCraneParticipantFilesStrategyStep) |
| Import/process Crane behaviour data | crane_behaviour.py |
| Import/process debrief data | crane_debrief_behaviour.py |
| Import physiology data | biopac.py |
| Build trial intervals | crane_trial_intervals.py |
| Process EDA physiology | eda.py |
If you're trying to find where a specific piece of logic actually runs,
start from this table rather than crane_pipeline.py — that file mostly
just imports and assembles the pieces above.
(See Pipeline Concepts for why it's built this way.)
foh_pipeline.py is wired the same way, for FOH's own steps:
| Step | Implemented in |
|---|---|
| Find a participant's files | foh_pipeline.py (FindFohParticipantFilesStrategyStep) |
| Import/process FOH trial + target behaviour data | foh_behaviour.py, foh_target_behaviour.py |
| Import physiology data | lsl.py (FohLslPhysiologyDataImportStrategy) |
| Build trial intervals | foh_trial_intervals.py, using event definitions from foh_config.py |
| Process EDA physiology | eda.py — the same ProcessEdaPhysiologyDataStrategyStep Crane uses, unmodified |
See Lab Streaming (LSL/XDF) for what's different about
FOH's data (one .xdf file instead of separate .mat/CSV files) and why
that only changes the import/interval steps, not the processing steps.
How eda.py processes a signal
EDA & SCRs covers what EDA/SCRs are and how to read the QC plot, in plain terms; this is the code behind it. The processing chain, from raw signal to one row of output:
- Slice —
run_eda_intervalsusestrial_intervals.slice_data_frameto cut the full EDA recording into one chunk per trial interval. - Clean, decompose, find peaks — for each interval,
run_nk_eda_processingwraps three NeuroKit2 calls in order:nk.eda_clean(remove noise) →nk.eda_phasic(split into tonic/phasic components) →nk.eda_peaks(detect SCRs in the phasic component). - Count —
get_eda_data_outcounts detected SCR peaks for that interval and divides by the interval's length in minutes, producing one..._SCR_per_minvalue. - Tidy column names —
correct_ordercleans up the resulting column names so repeated interval labels (e.g. twoITIcolumns) don't collide. - QC plot — separately,
run_eda_qcruns the same cleaning/decompose steps over the whole, unsliced recording (not per interval) purely to draw the QC figure — it doesn't feed into the numeric output.
eda.py calls NeuroKit2 with this project's chosen methods
(clean_method="biosppy", peak_detect_method="vanhalem2020") and
reshapes the result — see EDA & SCRs for
what NeuroKit2 itself is.
ProcessEdaPhysiologyDataStrategyStep (eda.py) is the concrete strategy
that satisfies ProcessPhysiologyDataStrategyStep from pipeline.py — see
Design Patterns for what that means. Like the
interval-matching step, it has its own fallback_strategy:
ProcessEdaPhysiologyDataStrategyStep— the main path. Takes the already-matchedTrialIntervalsproduced by the interval step and processes each labelled trial.ProcessEdaPhysiologyFallbackStrategyStep— used when no matched intervals are available. It derives its own raw, unlabelled trigger intervals directly from the physiology data (trial_intervals.get_raw_biopac_trigger_intervals) and processes those instead — the same "partial data beats no data" idea as the interval step's fallback (see item 5 in Next Steps).
The command-line tools
CLI scripts live in src/vrlab_toolbox/cli/ (e.g. vrlab_crane_process.py).
They're deliberately kept thin: read input, loop over files, call into
processing/, save the result. All the real decision-making lives in
processing/, not in the CLI. Two reasons:
- Testing — code in
processing/can be tested directly withpytest, without running a full command-line program. - Reuse — the same processing logic can be called from a CLI, a script, or a notebook, without rewriting it.
How a CLI command receives input (click)
Each CLI file uses the click library
to turn a plain function into a command-line command. Take
vrlab_crane_process.py:
@click.command()
@click.argument("input_folder", type=click.Path(exists=True, dir_okay=True, path_type=Path))
@click.argument("output_folder", type=click.Path(exists=True, dir_okay=True, path_type=Path))
@click.option("--subject_id", required=False, default="", help="Process a single participant")
@click.option("--verbose", is_flag=True, help="Give verbose output")
def main(input_folder: Path, output_folder: Path, verbose: bool, subject_id: str):
...
@click.argument(...)— a required, positional value (order matters).type=click.Path(...)tells click to check the path exists and hand it back as apathlib.Path, instead of a plain string you'd have to convert yourself.@click.option(...)— an optional, named value (--subject_id ...), with a default if it's left out.is_flag=Truemakes--verbosea simple on/off switch.- Each decorator adds one parameter to
main(...), in the order they're declared as arguments (options can come in any order on the command line).
So running:
vrlab_crane_process C:\data\input C:\data\output --subject_id P00018
calls main(input_folder=..., output_folder=..., subject_id="P00018", verbose=False).
The command name (vrlab_crane_process) itself comes from [project.scripts]
in pyproject.toml, which points at this main function.
Code style
Two automatic tools help keep the code consistent — config for both lives in
pyproject.toml:
- ruff — checks for errors and style issues (e.g. unused imports) and can auto-format code. This project's ruff settings: 100-character line length, double-quote strings.
- pyright — checks type hints for mistakes before you run the code.
Turning on Pylance type checking in VS Code
Running pyright from the terminal checks the whole project at once, but
you don't have to wait for that to see a problem — Pylance, the
extension VS Code already uses for Python (autocomplete, go-to-definition,
etc.), is built on the same pyright engine. It can underline type
errors live, in the editor, as you type, before you ever run the file or
the terminal check.
This project's own .vscode/settings.json doesn't set a
python.analysis.typeCheckingMode — it only configures the interpreter
path, the integrated terminal, and ruff as the on-save formatter:
{
"python.defaultInterpreterPath": "${workspaceFolder}\\.venv\\Scripts\\python.exe",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true
}
}
So Pylance's live type checking currently runs at whichever level is the
extension's own built-in default on your machine — nothing in this repo
pins it. If you want it to check more (or less) as you type, that's a
setting you'd add to .vscode/settings.json yourself, e.g.:
{
"python.analysis.typeCheckingMode": "basic"
}
Pyright's three levels, from lightest to strictest: "off" (no live type
checking at all), "basic", and "strict". (Some pyright versions also
expose an in-between "standard" level — check what your installed
Pylance version offers via the setting's autocomplete in VS Code before
picking one.)
Why this is worth turning on, not just noise to silence: it catches
real mistakes before you run anything — passing the wrong concrete type
into a strategy step's run(), forgetting a required argument, or calling
a function marked @deprecated (see Golden Rules
for a real example of that last one). In a codebase built around typed
Protocol contracts like this one (Design Patterns),
that's exactly the class of bug type checking is designed to catch —
finding out at edit time, not after a participant's data has half-run
through the pipeline.
Going further
Our style follows PEP 8, Python's official style guide — Real Python's PEP 8 guide is a good, readable walkthrough of the reasoning behind it.
See the AI Style Guide for this same stack — click,
pandera, dataclasses/Protocol, pytest, rich — written up as an
explicit reference for AI coding assistants, so a new tool or pattern
doesn't get introduced alongside what's already here.
Next: Golden Rules — a handful of coding rules of thumb this codebase actually follows, with real examples.