Skip to content

Commit b85af37

Browse files
committed
add back adc annotation for openephys, centralize adc annotation, add adc check in tests, plugin_channe_keys -> settings_channel_keys
1 parent a55ba4e commit b85af37

2 files changed

Lines changed: 179 additions & 81 deletions

File tree

src/probeinterface/neuropixels_tools.py

Lines changed: 87 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ def make_mux_table_array(mux_information) -> np.array:
125125
126126
Returns
127127
-------
128+
num_adcs: int
129+
Number of ADCs used in the probe's readout system.
130+
num_channels_per_adc: int
131+
Number of readout channels assigned to each ADC.
128132
adc_groups_array : np.array
129133
Array of which channels are in each adc group, shaped (number of `adc`s, number of channels in each `adc`).
130134
"""
@@ -334,20 +338,10 @@ def _make_npx_probe_from_description(probe_description, model_name, elec_ids, sh
334338
# annotate with MUX table
335339
if mux_info is not None:
336340
# annotate each contact with its mux channel
337-
num_adcs, num_channels_per_adc, mux_table = make_mux_table_array(mux_info)
338-
num_contacts = positions.shape[0]
339-
# ADC group: which adc is used for each contact
340-
adc_groups = np.zeros(num_contacts, dtype="int64")
341-
# ADC sample order: order of sampling of the contact in the adc group
342-
adc_sample_order = np.zeros(num_contacts, dtype="int64")
343-
for adc_idx, adc_groups_per_adc in enumerate(mux_table):
344-
adc_groups_per_adc = adc_groups_per_adc[adc_groups_per_adc < num_contacts]
345-
adc_groups[adc_groups_per_adc] = adc_idx
346-
adc_sample_order[adc_groups_per_adc] = np.arange(len(adc_groups_per_adc))
341+
num_adcs, num_channels_per_adc, adc_groups_array = make_mux_table_array(mux_info)
347342
probe.annotate(num_adcs=num_adcs)
348343
probe.annotate(num_channels_per_adc=num_channels_per_adc)
349-
probe.annotate_contacts(adc_group=adc_groups)
350-
probe.annotate_contacts(adc_sample_order=adc_sample_order)
344+
_annotate_contacts_from_mux_table(probe, adc_groups_array)
351345

352346
return probe
353347

@@ -488,6 +482,74 @@ def build_neuropixels_probe(probe_part_number: str) -> Probe:
488482
return probe
489483

490484

485+
def _annotate_contacts_from_mux_table(probe: Probe, adc_groups_array: np.array):
486+
"""
487+
Annotate a Probe object with ADC group and sample order information based on the MUX table.
488+
489+
This function is used when building a complete Neuropixels probe from the ProbeTable specifications
490+
(via build_neuropixels_probe) to assign per-contact annotations for adc_group and adc_sample_order,
491+
which describe how each contact maps to the ADCs during recording.
492+
The function annotates the probe in place.
493+
494+
Parameters
495+
----------
496+
probe : Probe
497+
The Probe object to annotate. Must have device_channel_indices set.
498+
adc_groups_array : np.array
499+
The ADC groups array from the probe features, which describes how readout channels map to ADCs.
500+
"""
501+
# Map readout channels to ADC groups and sample order
502+
num_readout_channels = probe.get_contact_count()
503+
adc_groups = np.zeros(num_readout_channels, dtype="int64")
504+
adc_sample_order = np.zeros(num_readout_channels, dtype="int64")
505+
for adc_index, channels_per_adc in enumerate(adc_groups_array):
506+
# Filter out placeholder values (e.g., 128 in mux_np1200 for unused slots)
507+
valid_channels = channels_per_adc[channels_per_adc < num_readout_channels]
508+
adc_groups[valid_channels] = adc_index
509+
adc_sample_order[valid_channels] = np.arange(len(valid_channels))
510+
511+
probe.annotate_contacts(adc_group=adc_groups)
512+
probe.annotate_contacts(adc_sample_order=adc_sample_order)
513+
514+
515+
def annotate_probe_with_adc_sampling_info(probe: Probe, adc_sampling_table: str | None):
516+
"""
517+
Annotate a Probe object with ADC group and sample order information based on the ADC sampling table.
518+
519+
This function is used when reading a recording with a known channel map (via read_spikeglx, read_openephys)
520+
to assign per-contact annotations for adc_group and adc_sample_order, which describe how
521+
each contact maps to the ADCs during recording, and global annotations for num_adcs and num_channels_per_adc.
522+
523+
Parameters
524+
----------
525+
probe : Probe
526+
The Probe object to annotate. Must have device_channel_indices set.
527+
adc_sampling_table : str
528+
The ADC sampling table string from the probe features, which describes how readout channels map to ADCs.
529+
530+
Returns
531+
-------
532+
None
533+
The function modifies the Probe object in place.
534+
"""
535+
# Parse table string: (num_adcs,num_channels_per_adc)(ch ch ...)(ch ch ...)...
536+
if adc_sampling_table is None:
537+
return
538+
adc_info = adc_sampling_table.split(")(")[0]
539+
split_mux = adc_sampling_table.split(")(")[1:]
540+
num_adcs, num_channels_per_adc = map(int, adc_info[1:].split(","))
541+
probe.annotate(num_adcs=num_adcs)
542+
probe.annotate(num_channels_per_adc=num_channels_per_adc)
543+
544+
adc_groups_list = [
545+
np.array(each_mux.replace("(", "").replace(")", "").split(" ")).astype("int") for each_mux in split_mux
546+
]
547+
adc_groups_array = np.transpose(np.array(adc_groups_list))
548+
549+
# Map readout channels to ADC groups and sample order
550+
_annotate_contacts_from_mux_table(probe, adc_groups_array)
551+
552+
491553
def _read_imro_string(imro_str: str, imDatPrb_pn: Optional[str] = None) -> Probe:
492554
"""
493555
Parse the IMRO table when presented as a string and create a Probe object.
@@ -883,30 +945,7 @@ def read_spikeglx(file: str | Path) -> Probe:
883945
# apply the mapping. This must be done here (not in build_neuropixels_probe)
884946
# because the table indices are readout channel indices, not electrode indices.
885947
adc_sampling_table = probe.annotations.get("adc_sampling_table")
886-
if adc_sampling_table is not None:
887-
# Parse table string: (num_adcs,num_channels_per_adc)(ch ch ...)(ch ch ...)...
888-
adc_info = adc_sampling_table.split(")(")[0]
889-
split_mux = adc_sampling_table.split(")(")[1:]
890-
num_adcs, num_channels_per_adc = map(int, adc_info[1:].split(","))
891-
adc_groups_list = [
892-
np.array(each_mux.replace("(", "").replace(")", "").split(" ")).astype("int") for each_mux in split_mux
893-
]
894-
mux_table = np.transpose(np.array(adc_groups_list))
895-
896-
# Map readout channels to ADC groups and sample order
897-
num_readout_channels = probe.get_contact_count()
898-
adc_groups = np.zeros(num_readout_channels, dtype="int64")
899-
adc_sample_order = np.zeros(num_readout_channels, dtype="int64")
900-
for adc_index, channels_per_adc in enumerate(mux_table):
901-
# Filter out placeholder values (e.g., 128 in mux_np1200 for unused slots)
902-
valid_channels = channels_per_adc[channels_per_adc < num_readout_channels]
903-
adc_groups[valid_channels] = adc_index
904-
adc_sample_order[valid_channels] = np.arange(len(valid_channels))
905-
906-
probe.annotate(num_adcs=num_adcs)
907-
probe.annotate(num_channels_per_adc=num_channels_per_adc)
908-
probe.annotate_contacts(adc_group=adc_groups)
909-
probe.annotate_contacts(adc_sample_order=adc_sample_order)
948+
annotate_probe_with_adc_sampling_info(probe, adc_sampling_table)
910949

911950
# ===== 7. Slice to saved channels (if subset was saved) =====
912951
# This is DIFFERENT from IMRO selection: IMRO selects which electrodes to acquire,
@@ -1059,7 +1098,7 @@ def _parse_openephys_settings(
10591098
- probe_part_number, serial_number, name, slot, port, dock
10601099
- selected_electrode_indices: list of int (from SELECTED_ELECTRODES), or None
10611100
- contact_ids: list of str (reverse-engineered from CHANNELS), or None
1062-
- channel_names: np.array of str, or None
1101+
- settings_channel_keys: np.array of str, or None
10631102
- elec_ids, shank_ids, pt_metadata, mux_info: for legacy fallback
10641103
"""
10651104
ET = import_safely("xml.etree.ElementTree")
@@ -1207,8 +1246,7 @@ def _parse_openephys_settings(
12071246
"mux_info": mux_info,
12081247
"selected_electrode_indices": None,
12091248
"contact_ids": None,
1210-
"channel_names": None,
1211-
"plugin_channel_keys": None,
1249+
"settings_channel_keys": None,
12121250
"elec_ids": None,
12131251
"shank_ids": None,
12141252
}
@@ -1287,8 +1325,7 @@ def _parse_openephys_settings(
12871325
)
12881326
elec_ids.append(elec_id)
12891327

1290-
info["channel_names"] = channel_names
1291-
info["plugin_channel_keys"] = channel_names
1328+
info["settings_channel_keys"] = channel_names
12921329
info["shank_ids"] = shank_ids
12931330
info["elec_ids"] = elec_ids
12941331

@@ -1419,13 +1456,12 @@ def _select_openephys_probe_info(
14191456
return probes_info[0]
14201457

14211458

1422-
def _slice_catalogue_probe(full_probe: Probe, probe_info: dict) -> Probe:
1459+
def _slice_openephys_catalogue_probe(full_probe: Probe, probe_info: dict) -> Probe:
14231460
"""
14241461
Slice a full catalogue probe using the electrode selection from probe_info.
14251462
14261463
For SELECTED_ELECTRODES (newer plugin), uses the indices directly.
14271464
For CHANNELS (older plugin), matches reverse-engineered contact_ids to the catalogue.
1428-
Falls back to legacy `_make_npx_probe_from_description` if matching fails.
14291465
14301466
Parameters
14311467
----------
@@ -1467,9 +1503,6 @@ def _annotate_openephys_probe(probe: Probe, probe_info: dict) -> None:
14671503
probe_info : dict
14681504
Probe info dict from `_parse_openephys_settings`.
14691505
"""
1470-
if probe_info["channel_names"] is not None:
1471-
probe.annotate_contacts(channel_name=probe_info["channel_names"])
1472-
14731506
probe.serial_number = probe_info["serial_number"]
14741507
probe.name = probe_info["name"]
14751508
probe.annotate(part_number=probe_info["probe_part_number"])
@@ -1480,8 +1513,11 @@ def _annotate_openephys_probe(probe: Probe, probe_info: dict) -> None:
14801513
probe.annotate(port=probe_info["port"])
14811514
if probe_info["dock"] is not None:
14821515
probe.annotate(dock=probe_info["dock"])
1483-
if probe_info.get("plugin_channel_keys") is not None:
1484-
probe.annotate_contacts(plugin_channel_key=probe_info["plugin_channel_keys"])
1516+
if probe_info.get("settings_channel_keys") is not None:
1517+
probe.annotate_contacts(settings_channel_key=probe_info["settings_channel_keys"])
1518+
1519+
adc_sampling_table = probe.annotations.get("adc_sampling_table")
1520+
annotate_probe_with_adc_sampling_info(probe, adc_sampling_table)
14851521

14861522

14871523
def _compute_device_channel_indices_from_oebin(
@@ -1501,7 +1537,7 @@ def _compute_device_channel_indices_from_oebin(
15011537
Parameters
15021538
----------
15031539
probe : Probe
1504-
Probe with contact_ids set (e.g. from ``_slice_catalogue_probe``).
1540+
Probe with contact_ids set (e.g. from ``_slice_openephys_catalogue_probe``).
15051541
oebin_file : str or Path
15061542
Path to the structure.oebin JSON file.
15071543
stream_name : str
@@ -1541,16 +1577,11 @@ def _compute_device_channel_indices_from_oebin(
15411577
# This was added in neuropixels-pxi plugin v0.5.0 (January 2023).
15421578
oebin_electrode_indices = []
15431579
for ch in oebin_channels:
1544-
electrode_index = None
15451580
for m in ch.get("channel_metadata", []):
15461581
if m.get("name") == "electrode_index":
15471582
electrode_index = m["value"][0]
1583+
oebin_electrode_indices.append(electrode_index)
15481584
break
1549-
oebin_electrode_indices.append(electrode_index)
1550-
1551-
# Filter out non-electrode channels (e.g. the sync channel) that lack electrode_index
1552-
electrode_channel_indices = [i for i, ei in enumerate(oebin_electrode_indices) if ei is not None]
1553-
oebin_electrode_indices = [oebin_electrode_indices[i] for i in electrode_channel_indices]
15541585

15551586
if len(oebin_electrode_indices) != num_contacts:
15561587
raise ValueError(
@@ -1680,7 +1711,7 @@ def read_openephys(
16801711
return None
16811712

16821713
full_probe = build_neuropixels_probe(probe_part_number=probe_info["probe_part_number"])
1683-
probe = _slice_catalogue_probe(full_probe, probe_info)
1714+
probe = _slice_openephys_catalogue_probe(full_probe, probe_info)
16841715
_annotate_openephys_probe(probe, probe_info)
16851716

16861717
chans_saved = get_saved_channel_indices_from_openephys_settings(settings_file, stream_name=stream_name)

0 commit comments

Comments
 (0)