Skip to content

Repository files navigation

neuroagent-toolkit

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.


Behavior-task NWB builder (generic template)

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, pauseThenStop and 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; blockNum tagged bool is a block index, so it read as True for 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.


⚠️ Prerequisite: a sync.mat with every clock on one master clock

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.mat containing, as vectors all in seconds on one master (NI/DAQ) clock:

  • timeNI — the master clock
  • timeImec0 (and timeImec1 if dual) — each probe's per-sample times, on the master clock
  • timeCamera — each video frame's time, on the master clock
  • your behavioral TTL channels (lick, reward, grab, …) — same length as timeNI

If 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 a sync.mat in 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.


What you need (inputs)

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.

The one idea that makes this simple

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→1 transition times vs timeNI
  • camera frame i → seconds = timeCamera[i]so no ffmpeg is needed for sync

If your sync is correct, the NWB is correct.

The four seams you edit

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.

What the file tells a downstream agent

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). imec0 is 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. Point META_FILE at an .ap.meta, not a .nidq.meta, or you get the session time but no probe identity.
  • The clock statement, verbatim in experiment_description and in data_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.py

Reading the result

Python:

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).

What this template intentionally leaves out

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.

About

Make systems-neuroscience recordings readable by someone who cannot ask you anything: a generic SpikeGLX/Kilosort + behavior + camera to self-describing NWB builder.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages