Skip to content

Build the ch axis from the electrodes table, matching the live source - #25

Merged
cboulay merged 1 commit into
devfrom
feat/structured-ch-axis
Aug 29, 2026
Merged

Build the ch axis from the electrodes table, matching the live source#25
cboulay merged 1 commit into
devfrom
feat/structured-ch-axis

Conversation

@cboulay

@cboulay cboulay commented Aug 29, 2026

Copy link
Copy Markdown
Member

A live acquisition source hands downstream stages a structured ch axis — per-channel geometry, labels, headstage, array identity. Replaying the same recording gave a plain array of strings, so every stage that rereferences per array, plots by position, or selects by headstage had to know which of the two it was being fed.

NWBIteratorSettings(filepath=..., structured_ch_axis=True)

On a real 384-electrode recording:

[0] x=0 y=1600 size=400 label='elec1-m1-63' bank='A' elec=1 hs=1 array='hs1-elec1'
arrays: ['hs1-elec1', 'hs1-elec2', 'hs2-elec1', 'hs2-elec2']

Available on NWBSlicer, NWBIteratorSettings, and NWBClockDrivenSettings.

Matching the live source

CHANNEL_DTYPE is field-for-field identical to ezmsg.blackrock.channel_map.CHANNEL_DTYPE — same fields, same order, same 216-byte itemsize. That is the whole point: weights fitted offline and applied live must cluster channels identically, and a downstream stage reading ch['array'] or ch['x'] must not need to know its source.

A test parses that dtype out of the blackrock source and compares, rather than importing it. ezmsg-blackrock requires pycbsdk and should not become a dependency here just to read a file; parsing keeps the check honest instead of letting it skip when the import fails. array_identity is transcribed for the same reason and pinned by a table of cases.

CHANNEL_DTYPE field electrodes column
x, y rel_x, rel_y
size size
label label
bank bank (1-based int → letter)
elec term
headstage headstage
array derived from label + headstage

Off by default, and loud when it can't work

These columns are one acquisition stack's convention, not NWB schema; most files carry none of them. A file with none raises, naming what was looked for, rather than returning a record of zeros — the caller asked for this explicitly, and answering with fabricated geometry is the failure this codebase keeps designing out.

rel_x/rel_y only, never NWB's x/y

I had x/y as a fallback initially and it was wrong twice over. They are different quantities: rel_* is position within the electrode group, which is what a channel map describes and what the live source puts in these fields, while x/y/z is location in the brain, nominally in metres. The fallback would have filled a micrometre field with brain coordinates — and because almost every electrodes table carries x/y, it would also have made has_channel_metadata answer True for files with no channel map at all.

The synthetic test fixture is exactly such a file, which is how it got caught.

Coordinates are otherwise taken verbatim. The files this reads store micrometres, matching the device's channel map and the int32 micrometres the live source emits. A file that followed NWB's metres reading would land 1e6 away — nothing here rescales on a guess, for the same reason ezmsg.nwb.scaling doesn't.

Array-identity collisions are detected

array derives from connector and headstage, which repeat across devices: two hubs each with an elec1 on headstage 1 both give hs1-elec1. Per series that is correct and matches live, since a series is one device — verified that neither hub's own stream collides internally on the real recording. Across devices it would merge physically separate arrays, so group_name is used to warn when one identity covers more than one group. Verified the warning fires on a hand-built mixture of both hubs' rows.

Tests

A fixture builds an electrodes table shaped like an acquisition stack's — two connectors on one headstage plus one on a second, so the identity rule has something to separate — plus a second series over a strict subset in a different order, so region handling is exercised rather than assumed.

270 tests pass; ruff clean. Verified against a real 5.7 GB recording.

🤖 Generated with Claude Code

A live acquisition source hands downstream stages a structured ch axis --
per-channel geometry, labels, headstage, array identity. Replaying the
same recording gave a plain array of strings, so every stage that
rereferences per array, plots by position, or selects by headstage had to
know which of the two it was fed.

structured_ch_axis=True builds the live layout from the electrodes table.
CHANNEL_DTYPE is field-for-field identical to
ezmsg.blackrock.channel_map.CHANNEL_DTYPE -- a test parses it out of that
file and compares, rather than importing it, since ezmsg-blackrock needs
pycbsdk and should not become a dependency here just to read a file.
array_identity is transcribed for the same reason, and pinned by a table
of cases.

Off by default. The columns beyond label -- rel_x, rel_y, size, bank,
term, headstage -- are one acquisition stack's convention, not NWB schema.
A file with none of them raises rather than returning a record of zeros:
the caller asked for this explicitly, and answering with fabricated
geometry is the failure this codebase keeps designing out.

Positions come from rel_x/rel_y only, never NWB's x/y. They are different
quantities -- rel_* is position within the electrode group, which is what
a channel map describes, while x/y/z is location in the brain, nominally
in metres. Falling back to x/y would fill a micrometre field with
brain coordinates, and since almost every electrodes table carries x/y it
would also make has_channel_metadata answer True for files with no channel
map at all. The synthetic test fixture is exactly such a file, which is
how the fallback got caught.

Coordinates are otherwise taken verbatim: the files this reads store
micrometres, matching the device's channel map and the int32 micrometres
the live source emits, and rescaling on a guess is how a factor becomes
silently wrong.

array identity derives from connector and headstage, which repeat across
devices -- two hubs each with an elec1 on hs1 both give 'hs1-elec1'. Per
series that is right and matches live, since a series is one device;
across devices it would merge physically separate arrays. group_name
distinguishes them, so a warning fires when one identity covers more than
one group. Verified against a real recording: no collision within either
hub's own stream, and the warning does fire on a hand-built mixture.
@cboulay
cboulay merged commit 90dad66 into dev Aug 29, 2026
14 checks passed
@cboulay
cboulay deleted the feat/structured-ch-axis branch August 29, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant