@@ -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-
833800def _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-
16541576def 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