Skip to content

Commit 32e54b7

Browse files
Add catalogue-based probe construction for OpenEphys and channel map ordering (#409)
Co-authored-by: Heberto Mayorquin <h.mayorquin@gmail.com>
1 parent 06f1200 commit 32e54b7

File tree

2 files changed

+120
-262
lines changed

2 files changed

+120
-262
lines changed

src/probeinterface/neuropixels_tools.py

Lines changed: 75 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -797,39 +797,6 @@ def _build_canonical_contact_id(electrode_id: int, shank_id: int | None = None)
797797
return f"e{electrode_id}"
798798

799799

800-
def _contact_id_to_global_electrode_index(contact_id: str, electrodes_per_shank: int) -> int:
801-
"""
802-
Convert a canonical contact ID back to a global electrode index.
803-
804-
This is the inverse of `_build_canonical_contact_id`. For multi-shank probes,
805-
the global index is ``shank_id * electrodes_per_shank + local_electrode_id``.
806-
807-
This formula works because the neuropixels-pxi plugin assigns electrode indices
808-
in left-to-right, bottom-to-top, left-shank-to-right-shank order (confirmed
809-
by Josh Siegle). Shank 0 owns indices 0 to electrodes_per_shank-1, shank 1
810-
owns the next block, and so on.
811-
812-
Parameters
813-
----------
814-
contact_id : str
815-
Canonical contact ID, e.g. "e123" or "s2e45".
816-
electrodes_per_shank : int
817-
Number of electrodes per shank (cols_per_shank * rows_per_shank).
818-
819-
Returns
820-
-------
821-
int
822-
Global electrode index.
823-
"""
824-
if contact_id.startswith("s"):
825-
shank_str, elec_str = contact_id.split("e")
826-
shank_id = int(shank_str[1:])
827-
local_id = int(elec_str)
828-
return shank_id * electrodes_per_shank + local_id
829-
else:
830-
return int(contact_id[1:])
831-
832-
833800
def _parse_imro_string(imro_table_string: str, probe_part_number: str) -> dict:
834801
"""
835802
Parse IMRO (Imec ReadOut) table string into structured per-channel data.
@@ -1136,6 +1103,12 @@ def _parse_openephys_settings(
11361103
neuropix_pxi_processor = None
11371104
onebox_processor = None
11381105
onix_processor = None
1106+
channel_map = None
1107+
record_node = None
1108+
channel_map_position = None
1109+
record_node_position = None
1110+
proc_counter = 0
1111+
11391112
for signal_chain in root.findall("SIGNALCHAIN"):
11401113
for processor in signal_chain:
11411114
if "PROCESSOR" == processor.tag:
@@ -1146,6 +1119,20 @@ def _parse_openephys_settings(
11461119
onebox_processor = processor
11471120
if "ONIX" in name:
11481121
onix_processor = processor
1122+
if "Channel Map" in name:
1123+
channel_map = processor
1124+
channel_map_position = proc_counter
1125+
if "Record Node" in name:
1126+
record_node = processor
1127+
record_node_position = proc_counter
1128+
proc_counter += 1
1129+
1130+
# Check if Channel Map comes before Record Node
1131+
channel_map_before_record_node = (
1132+
channel_map_position is not None
1133+
and record_node_position is not None
1134+
and channel_map_position < record_node_position
1135+
)
11491136

11501137
if neuropix_pxi_processor is None and onebox_processor is None and onix_processor is None:
11511138
if raise_error:
@@ -1173,18 +1160,16 @@ def _parse_openephys_settings(
11731160
else:
11741161
node_id = None
11751162

1176-
# read STREAM fields if present (>=0.6.x)
1163+
# Read STREAM fields if present (>=0.6.x)
11771164
stream_fields = processor.findall("STREAM")
11781165
if len(stream_fields) > 0:
11791166
has_streams = True
1180-
streams = []
11811167
probe_names_used = []
11821168
for stream_field in stream_fields:
11831169
stream = stream_field.attrib["name"]
11841170
# exclude ADC streams
11851171
if "ADC" in stream:
11861172
continue
1187-
streams.append(stream)
11881173
# find probe name (exclude "-AP"/"-LFP" from stream name)
11891174
stream = stream.replace("-AP", "").replace("-LFP", "")
11901175
if stream not in probe_names_used:
@@ -1196,8 +1181,27 @@ def _parse_openephys_settings(
11961181
if onix_processor is not None:
11971182
probe_names_used = [pn for pn in probe_names_used if "Probe" in pn]
11981183

1199-
# for Open Ephys version < 1.0 np_probes is in the EDITOR field.
1200-
# for Open Ephys version >= 1.0 np_probes is in the CUSTOM_PARAMETERS field.
1184+
# Load custom channel maps, if channel map is present and comes before record node
1185+
# (if not, it won't be applied to the recording)
1186+
probe_custom_channel_maps = None
1187+
if channel_map is not None and channel_map_before_record_node:
1188+
stream_fields = channel_map.findall("STREAM")
1189+
custom_parameters = channel_map.findall("CUSTOM_PARAMETERS")
1190+
if custom_parameters is not None:
1191+
custom_parameters = custom_parameters[0]
1192+
custom_maps_all = custom_parameters.findall("STREAM")
1193+
probe_custom_channel_maps = []
1194+
# filter ADC streams and keep custom maps for probe streams
1195+
for i, stream_field in enumerate(stream_fields):
1196+
stream = stream_field.attrib["name"]
1197+
# exclude ADC streams
1198+
if "ADC" in stream:
1199+
continue
1200+
custom_indices = [int(ch.attrib["index"]) for ch in custom_maps_all[i].findall("CH")]
1201+
probe_custom_channel_maps.append(custom_indices)
1202+
1203+
# For Open Ephys version < 1.0 np_probes is in the EDITOR field.
1204+
# For Open Ephys version >= 1.0 np_probes is in the CUSTOM_PARAMETERS field.
12011205
editor = processor.find("EDITOR")
12021206
if oe_version < parse("0.9.0"):
12031207
np_probes = editor.findall("NP_PROBE")
@@ -1234,7 +1238,7 @@ def _parse_openephys_settings(
12341238
if not has_streams:
12351239
probe_names_used = [f"{node_id}.{stream_index}" for stream_index in range(len(np_probes))]
12361240

1237-
# check consistency with stream names and other fields
1241+
# Check consistency with stream names and other fields
12381242
if has_streams:
12391243
if len(np_probes) < len(probe_names_used):
12401244
if raise_error:
@@ -1275,11 +1279,17 @@ def _parse_openephys_settings(
12751279
"settings_channel_keys": None,
12761280
"elec_ids": None,
12771281
"shank_ids": None,
1282+
"custom_channel_map": None,
12781283
}
12791284

12801285
if selected_electrodes is not None:
12811286
# Newer plugin versions provide electrode indices directly
12821287
info["selected_electrode_indices"] = [int(ei) for ei in selected_electrodes.attrib.values()]
1288+
if probe_custom_channel_maps is not None:
1289+
# Slice custom channel maps to match the number of selected electrodes
1290+
# (required when SYNC channel is present)
1291+
custom_indices = probe_custom_channel_maps[probe_idx][: len(info["selected_electrode_indices"])]
1292+
info["custom_channel_map"] = custom_indices
12831293
else:
12841294
# Older plugin versions: reverse-engineer electrode IDs from positions
12851295
channel_names = np.array(list(channels.attrib.keys()))
@@ -1361,6 +1371,11 @@ def _parse_openephys_settings(
13611371
info["contact_ids"] = [
13621372
_build_canonical_contact_id(eid, sid) for sid, eid in zip(shank_ids_iter, elec_ids)
13631373
]
1374+
if probe_custom_channel_maps is not None:
1375+
# Slice custom channel maps to match the number of selected electrodes
1376+
# (required when SYNC channel is present)
1377+
custom_indices = probe_custom_channel_maps[probe_idx][: len(info["contact_ids"])]
1378+
info["custom_channel_map"] = custom_indices
13641379

13651380
probes_info.append(info)
13661381

@@ -1489,6 +1504,9 @@ def _slice_openephys_catalogue_probe(full_probe: Probe, probe_info: dict) -> Pro
14891504
For SELECTED_ELECTRODES (newer plugin), uses the indices directly.
14901505
For CHANNELS (older plugin), matches reverse-engineered contact_ids to the catalogue.
14911506
1507+
If the `custom_channel_map` field is present in probe_info, due to a "Channel Map" processor in the signal
1508+
chain that comes before the "Record Node", it is applied as a further slice after electrode selection.
1509+
14921510
Parameters
14931511
----------
14941512
full_probe : Probe
@@ -1500,15 +1518,21 @@ def _slice_openephys_catalogue_probe(full_probe: Probe, probe_info: dict) -> Pro
15001518
-------
15011519
probe : Probe
15021520
"""
1521+
custom_channel_map = probe_info.get("custom_channel_map")
15031522
if probe_info["selected_electrode_indices"] is not None:
1504-
return full_probe.get_slice(selection=probe_info["selected_electrode_indices"])
1523+
selected_electrode_indices = np.array(probe_info["selected_electrode_indices"])
1524+
if custom_channel_map is not None:
1525+
selected_electrode_indices = selected_electrode_indices[custom_channel_map]
1526+
return full_probe.get_slice(selection=selected_electrode_indices)
15051527

15061528
contact_ids = probe_info["contact_ids"]
15071529
if contact_ids is not None:
15081530
catalogue_ids = set(full_probe.contact_ids)
15091531
if all(cid in catalogue_ids for cid in contact_ids):
15101532
contact_id_to_index = {cid: i for i, cid in enumerate(full_probe.contact_ids)}
15111533
selected_indices = np.array([contact_id_to_index[cid] for cid in contact_ids])
1534+
if custom_channel_map is not None:
1535+
selected_indices = selected_indices[custom_channel_map]
15121536
return full_probe.get_slice(selection=selected_indices)
15131537
else:
15141538
raise ValueError(
@@ -1540,123 +1564,20 @@ def _annotate_openephys_probe(probe: Probe, probe_info: dict) -> None:
15401564
if probe_info["dock"] is not None:
15411565
probe.annotate(dock=probe_info["dock"])
15421566
if probe_info.get("settings_channel_keys") is not None:
1543-
probe.annotate_contacts(settings_channel_key=probe_info["settings_channel_keys"])
1567+
settings_channel_keys = probe_info["settings_channel_keys"]
1568+
if probe_info.get("custom_channel_map") is not None:
1569+
settings_channel_keys = np.array(settings_channel_keys)[probe_info["custom_channel_map"]]
1570+
probe.annotate_contacts(settings_channel_key=settings_channel_keys)
15441571

15451572
adc_sampling_table = probe.annotations.get("adc_sampling_table")
15461573
_annotate_probe_with_adc_sampling_info(probe, adc_sampling_table)
15471574

15481575

1549-
def _compute_wiring_from_oebin(
1550-
probe: Probe,
1551-
oebin_file: str | Path,
1552-
stream_name: str,
1553-
settings_file: str | Path,
1554-
) -> np.ndarray:
1555-
"""
1556-
Compute device_channel_indices from an oebin file's electrode_index metadata.
1557-
1558-
Each oebin channel (for neuropixels-pxi >= 0.5.0) carries an ``electrode_index``
1559-
metadata field giving the global electrode index written to that binary column.
1560-
This function maps each probe contact to its binary column by matching the
1561-
contact's global electrode index to the oebin's electrode_index values.
1562-
1563-
Parameters
1564-
----------
1565-
probe : Probe
1566-
Probe with contact_ids set (e.g. from ``_slice_openephys_catalogue_probe``).
1567-
oebin_file : str or Path
1568-
Path to the structure.oebin JSON file.
1569-
stream_name : str
1570-
Stream name to select from the oebin's continuous streams. It needs to correspond to the folder_name field
1571-
in the oebin file.
1572-
settings_file : str or Path
1573-
Path to settings.xml (used only in error messages).
1574-
1575-
Returns
1576-
-------
1577-
device_channel_indices : np.ndarray
1578-
Array of length ``probe.get_contact_count()`` mapping each contact
1579-
to its column in the binary file.
1580-
"""
1581-
oebin_file = Path(oebin_file)
1582-
with open(oebin_file, "r") as f:
1583-
oebin = json.load(f)
1584-
1585-
continuous_streams = oebin.get("continuous", [])
1586-
matched_stream = None
1587-
for cs in continuous_streams:
1588-
folder_name = cs.get("folder_name", "").rstrip("/")
1589-
if folder_name == stream_name:
1590-
matched_stream = cs
1591-
break
1592-
1593-
if matched_stream is None:
1594-
available = [cs.get("folder_name", "unknown") for cs in continuous_streams]
1595-
raise ValueError(
1596-
f"Could not find stream matching '{stream_name}' in oebin file '{oebin_file}'. "
1597-
f"Available streams: {available}"
1598-
)
1599-
1600-
oebin_channels = matched_stream.get("channels", [])
1601-
num_contacts = probe.get_contact_count()
1602-
1603-
# Extract electrode_index metadata from oebin channels.
1604-
# This was added in neuropixels-pxi plugin v0.5.0 (January 2023).
1605-
oebin_electrode_indices = []
1606-
for ch in oebin_channels:
1607-
for m in ch.get("channel_metadata", []):
1608-
if m.get("name") == "electrode_index":
1609-
electrode_index = m["value"][0]
1610-
oebin_electrode_indices.append(electrode_index)
1611-
break
1612-
1613-
# If electrode_index metadata is not found, fall back to identity mapping (assume binary file is in channel-number order).
1614-
if len(oebin_electrode_indices) == 0:
1615-
device_channel_indices = np.arange(num_contacts)
1616-
else:
1617-
if len(oebin_electrode_indices) != num_contacts:
1618-
raise ValueError(
1619-
f"Channel count mismatch: oebin '{oebin_file}' has {len(oebin_electrode_indices)} electrode channels "
1620-
f"but probe from settings '{settings_file}' has {num_contacts} contacts."
1621-
)
1622-
1623-
# Check if electrode_index values are valid (not all zeros).
1624-
# All-zero values occur in recordings from neuropixels-pxi < 0.5.0.
1625-
has_valid_electrode_indices = not all(ei == 0 for ei in oebin_electrode_indices)
1626-
1627-
if has_valid_electrode_indices:
1628-
# Get electrodes_per_shank from probe metadata
1629-
part_number = probe.annotations["part_number"]
1630-
probe_features = _load_np_probe_features()
1631-
pt_metadata, _, _ = get_probe_metadata_from_probe_features(probe_features, part_number)
1632-
electrodes_per_shank = pt_metadata["cols_per_shank"] * pt_metadata["rows_per_shank"]
1633-
1634-
# Map each probe contact to its binary file column using electrode_index
1635-
electrode_index_to_column = {ei: col for col, ei in enumerate(oebin_electrode_indices)}
1636-
device_channel_indices = np.zeros(num_contacts, dtype=int)
1637-
for i, contact_id in enumerate(probe.contact_ids):
1638-
electrode_index = _contact_id_to_global_electrode_index(contact_id, electrodes_per_shank)
1639-
if electrode_index not in electrode_index_to_column:
1640-
raise ValueError(
1641-
f"Contact {contact_id} has global electrode_index {electrode_index} "
1642-
f"not found in oebin '{oebin_file}'. This indicates a mismatch between "
1643-
f"the probe configuration in settings and the recorded binary data."
1644-
)
1645-
device_channel_indices[i] = electrode_index_to_column[electrode_index]
1646-
else:
1647-
# Fallback: identity wiring. The binary .dat file is written in channel-number order
1648-
# (confirmed in https://github.com/open-ephys-plugins/neuropixels-pxi/issues/39).
1649-
device_channel_indices = np.arange(num_contacts)
1650-
1651-
return device_channel_indices
1652-
1653-
16541576
def read_openephys(
16551577
settings_file: str | Path,
16561578
stream_name: Optional[str] = None,
16571579
probe_name: Optional[str] = None,
16581580
serial_number: Optional[str] = None,
1659-
oebin_file: Optional[str | Path] = None,
16601581
fix_x_position_for_oe_5: bool = True,
16611582
raise_error: bool = True,
16621583
) -> Probe:
@@ -1668,10 +1589,9 @@ def read_openephys(
16681589
mutually exclusive selectors (``stream_name``, ``probe_name``, or
16691590
``serial_number``) to choose which probe to return.
16701591
1671-
By default the function returns a probe without ``device_channel_indices``
1672-
set. When ``oebin_file`` is also provided, the function reads the
1673-
structure.oebin to compute ``device_channel_indices`` that map each probe
1674-
contact to its column in the binary data file.
1592+
In case of a "Channel Map" processor in the signal chain that comes before the "Record Node",
1593+
the probe geometry and settings channel names will be sliced to the order of channels specified
1594+
in the channel map. Therefore, the probe is always wired from 0 to N-1.
16751595
16761596
Open Ephys versions 0.5.x, 0.6.x, and 1.0 are supported. For version
16771597
0.6.x+, probe names are inferred from ``<STREAM>`` elements. For version
@@ -1703,12 +1623,6 @@ def read_openephys(
17031623
Select a probe by exact match against its serial number. Useful for
17041624
automated pipelines that track probes by hardware serial. Mutually
17051625
exclusive with ``stream_name`` and ``probe_name``.
1706-
oebin_file : Path, str, or None
1707-
Path to the structure.oebin JSON file for a specific recording. When
1708-
provided, ``stream_name`` is required. The oebin's ``electrode_index``
1709-
metadata is used to compute ``device_channel_indices`` mapping each
1710-
probe contact to its binary file column. When None, identity wiring
1711-
is used.
17121626
fix_x_position_for_oe_5 : bool
17131627
Correct a y-position bug in the Neuropix-PXI plugin for Open Ephys
17141628
< 0.6.0, where multi-shank probe y-coordinates included an erroneous
@@ -1721,18 +1635,13 @@ def read_openephys(
17211635
Returns
17221636
-------
17231637
probe : Probe or None
1724-
The probe geometry. When ``oebin_file`` is provided,
1725-
``device_channel_indices`` are set. Returns None if
1726-
``raise_error`` is False and an error occurs.
1638+
The wired probe object. Returns None if ``raise_error`` is False and an error occurs.
17271639
17281640
Notes
17291641
-----
17301642
Electrode positions are only available when recording with the
17311643
Neuropix-PXI plugin version >= 0.3.3.
17321644
"""
1733-
if oebin_file is not None and stream_name is None:
1734-
raise ValueError("stream_name is required when oebin_file is provided.")
1735-
17361645
probes_info = _parse_openephys_settings(settings_file, fix_x_position_for_oe_5, raise_error)
17371646
if probes_info is None:
17381647
return None
@@ -1749,17 +1658,9 @@ def read_openephys(
17491658
if chans_saved is not None:
17501659
probe = probe.get_slice(chans_saved)
17511660

1752-
if oebin_file is not None:
1753-
device_channel_indices = _compute_wiring_from_oebin(probe, oebin_file, stream_name, settings_file)
1754-
probe.set_device_channel_indices(device_channel_indices)
1755-
1756-
# re-order contact annotations to match device channel order
1757-
ordered_adc_groups = [probe.contact_annotations["adc_group"][i] for i in device_channel_indices]
1758-
probe.annotate_contacts(adc_group=ordered_adc_groups)
1759-
ordered_adc_sample_orders = [probe.contact_annotations["adc_sample_order"][i] for i in device_channel_indices]
1760-
probe.annotate_contacts(adc_sample_order=ordered_adc_sample_orders)
1761-
else:
1762-
probe.set_device_channel_indices(np.arange(probe.get_contact_count()))
1661+
# Wire the probe: in case of a channel map preceding the record node, the probe is already sliced to the custom
1662+
# channel selection, so we can use identity mapping.
1663+
probe.set_device_channel_indices(np.arange(probe.get_contact_count()))
17631664
return probe
17641665

17651666

0 commit comments

Comments
 (0)