Tools for making systems-neuroscience recordings readable by someone who cannot ask you anything — a collaborator, a future you, or an automated agent.
build_behavior_nwb_template.py |
single-session NWB builder: SpikeGLX/Kilosort spikes + behavior task + camera → one self-describing .nwb |
NWB_logic_explainer.md |
what each NWB block corresponds to in your data, and which two blocks actually encode your task |
ShijiaData_GLM_helpers/ |
dataset-specific: NWB -> jaxGLM design matrix, plus a machine-readable tool descriptor an agent can route to |
Install: pip install pynwb numpy pandas scipy. Everything is standalone — no lab-specific
dependencies, nothing to configure beyond the four seams described below.
MIT licensed. Issues and PRs welcome; if you adapt it to a different rig I would like to hear what you had to change.
Turn one recording session — Neuropixels spikes + a behavior task + a camera —
into a single .nwb file that anyone can read with pynwb (Python) or MatNWB (MATLAB),
that streams cleanly to DANDI later, and that describes itself well enough for an
automated reader (a proof-reader / QC agent, or a collaborator you never talk to).
This is a starter template, distilled from a working dual-Neuropixels licking-task builder. Adapt it to your task by editing four seams: two that make the file correct (events, trial table) and two that make it usable (the task in common language, and a description per trial column).
Why two of the four seams are just descriptions. A structurally perfect file whose 40 trial columns are named
isHit,blockNum,pauseThenStopand described as"trial column (bool): blockNum"is not self-describing — a reader still has to come and ask you. Real failures this caused, all silent: a column holding a space-separated string of times looked numeric until it crashed;blockNumtaggedboolis a block index, so it read asTruefor every block after the first; a session-constant repeated on every row got averaged as if it varied. None of that is visible from the data — only from a description.
This builder consumes an already-synced
sync.mat. It does not create one. That alignment step is the real work, and it is on you — this template only packages the result.Concretely, before you run anything here you must have a
sync.matcontaining, as vectors all in seconds on one master (NI/DAQ) clock:
timeNI— the master clocktimeImec0(andtimeImec1if dual) — each probe's per-sample times, on the master clocktimeCamera— each video frame's time, on the master clock- your behavioral TTL channels (lick, reward, grab, …) — same length as
timeNIIf your lab runs the shared MATLAB
...loadSessionDataBatch...flow, you already have this and can skip ahead. If you use a different sync pipeline (your own SpikeGLX alignment, TPrime, etc.), you must first produce async.matin this shape yourself — the builder has no way to align clocks for you, and everything below assumes the alignment is already done. See "The one idea that makes this simple" below for why: once the clocks are shared, building the NWB is just packaging, not alignment.
You already produce all of this if your pipeline looks like the shared MATLAB
...loadSessionDataBatch... flow:
| Input | What it is | Example |
|---|---|---|
sync.mat |
the sync output — all clocks in seconds on one master (NI) clock | timeNI, timeImec0, timeCamera, + your TTL channels |
| spike folder | Kilosort / AIND output, flat files | spike_times.npy (samples), spike_clusters.npy, optional cluster_info.tsv, cluster_group.tsv |
| trial table | one CSV, one row per trial | start_time, stop_time + your task columns |
camera .avi |
the behavior video (referenced, not embedded) | camera3.avi |
SpikeGLX .meta |
any .ap.meta/.nidq.meta from the run — only its fileCreateTime is read, to set the NWB session start time |
run_g0_t0.nidq.meta |
You do not need the raw
.ap.bin, LFP, or zarr. The processed tier (spikes + trials + behavior + video reference) is what collaborators actually load, and it's ~100 MB instead of tens of GB.
Nothing gets re-aligned here. Your sync step already put every clock into seconds on one master clock. The builder just reads those vectors and files them into NWB containers:
- spike sample
i→ seconds =timeImec0[i] - a TTL channel → event times = its
0→1transition times vstimeNI - camera frame
i→ seconds =timeCamera[i]← so no ffmpeg is needed for sync
If your sync is correct, the NWB is correct.
Open build_behavior_nwb_template.py and search for SEAM:
SEAM 1 — EVENT_CHANNELS — map your task's TTL channels to NWB event names:
EVENT_CHANNELS = {
"grab": "grabTTL", # nwb_name : sync.mat channel
"spout_arrival": "spoutArrivalTTL",
"reward": "leftSolenoid",
}Each value must be a binary vector in sync.mat the same length as timeNI;
the builder stores its onset times. This is the only place your events differ
from a cue/lick/water task.
SEAM 2 — the trial table CSV — must have two required columns of seconds on
the master clock: start_time and stop_time. Add any task columns
you want (grab_time, spout_arrival_time, outcome, block_id, …); each extra
column is auto-added to nwb.trials, typed as text / bool / numeric. No code
change — a grab/spout task vs a cue/lick/water task is just a different CSV.
SEAM 3 — TASK — your task in common language, as structured sentences.
No lab jargon and no bare internal codes: not "4W50" but "reward is delivered at
the 4th lick on 50% of trials". Five fields, each one thing a reader cannot infer:
TASK = dict(
name="head-fixed grab task", token="grabTask",
what_the_animal_does="A head-fixed mouse reaches for and grabs a pellet ...",
measured_behaviour="Reach onset and grab success, detected by ...",
trial_definition="A trial starts at go-cue onset and ends at the next cue.",
reward="Reward is a 20 mg pellet, constant throughout the session.",
manipulation="No block structure: contingencies are constant within a session.",
caveats="Trials aborted by an early reach have outcome='abort'; exclude them.",
)The builder concatenates these into one paragraph in experiment_description.
Generating it (rather than hand-writing prose) means it can't drift from the fields,
and every session in your dataset phrases the same facts the same way — which is what
lets an agent compare sessions instead of re-reading prose each time.
SEAM 4 — TRIAL_COLUMN_DOCS — one line per trial column: meaning, units, true
type. Patterns worth copying:
TRIAL_COLUMN_DOCS = {
"grab_time": "float, seconds on the session clock; NaN if the mouse never grabbed.",
"block_id": "INT (not bool). 1-based block index; 1 throughout if no blocks.",
"lick_times_s": "STRING holding a space-separated list of seconds. Parse it -- NOT "
"a numeric array.",
"outcome": "str, one of {'success','miss','abort'}.",
}The builder never invents a description. An undocumented column ships explicitly
flagged UNDOCUMENTED ... Do not guess, and the run prints a warning naming it — an
honest gap is safe, a confident wrong type is not.
The builder writes a compact JSON block to data_collection (a standard NWB field, so
nwb.data_collection just works — no extension). It answers, once and inside the file,
what every automated consumer asks of every session:
import json
summary = json.loads(nwb.data_collection)
summary["task"]["plain_language"] # the generated paragraph
summary["counts"] # units (sua/mua/noise), trials, events
summary["clock"] # 1 time base, t=0 meaning, valid window
summary["recording"]["probes"][0] # serial, target region, histology_confirmed
summary["qc_state"] # "NONE APPLIED" -- the gate is yours
summary["analysis_ready"], summary["known_gaps"]analysis_ready is not "no gaps". A gap is not a failure. The most common one —
histology not yet traced — leaves the session fully analysable: spikes, trials and
behaviour are untouched, and the region is simply the intended surgical target
instead of verified anatomy. Only claims about a specific atlas area have to wait. So
each gap carries blocks_analysis plus still_valid / not_valid sentences, and
analysis_ready is the AND of those flags. An agent that paused on any gap would idle
most of a normal dataset — set HISTOLOGY_CONFIRMED = False and keep going.
Two more things the file now carries that are easy to omit and painful to add later:
- Probe serial (
imDatPrb_sn, read from an.ap.meta).imec0is a rig fact — it says which port was used and gets reused by a different probe next month. The serial is the only field that means "the same piece of silicon", which is what makes cross-session unit tracking possible for chronic implants. PointMETA_FILEat an.ap.meta, not a.nidq.meta, or you get the session time but no probe identity. - The clock statement, verbatim in
experiment_descriptionand indata_collection.clock_note— including that negative timestamps are expected (t=0 is the first shared sync pulse, not the start of any one recording). A consumer that helpfully clips at 0 silently discards real data.
Then fill in the CONFIG block at the top (paths, subject, probe target, whether
histology is confirmed, chronic vs acute) and run:
pip install pynwb numpy pandas scipy
python build_behavior_nwb_template.pyPython:
from pynwb import NWBHDF5IO
with NWBHDF5IO("session.nwb", "r") as io:
nwb = io.read()
units = nwb.units.to_dataframe() # spike_times in seconds
trials = nwb.trials.to_dataframe() # start/stop + your columns
licks = nwb.processing["behavior"]["BehavioralEvents"]["grab"].timestamps[:]The behavior camera is referenced by filename — keep the .avi in the same
folder as the .nwb (NWB stores only the basename + per-frame timestamps).
The production builder also does per-unit histology/CCF (Allen region per unit), known-bad-probe overrides, mean-waveform shipping, and QC self-sufficiency. Those are project-specific — add them back only if you need them. The skeleton here is the part that's the same for everyone.
See NWB_logic_explainer.md for the conceptual one-pager.