Skip to content

Commit d527a81

Browse files
committed
improvements to imro reading and channel selection
1 parent f200342 commit d527a81

File tree

1 file changed

+68
-34
lines changed

1 file changed

+68
-34
lines changed

src/probeinterface/neuropixels_tools.py

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -416,10 +416,8 @@ def build_neuropixels_probe(probe_part_number: str) -> Probe:
416416
positions = np.stack((x_pos, y_pos), axis=1)
417417

418418
# ===== 4. Calculate contact IDs =====
419-
if shank_ids is not None:
420-
contact_ids = [f"s{shank_id}e{elec_id}" for shank_id, elec_id in zip(shank_ids, elec_ids)]
421-
else:
422-
contact_ids = [f"e{elec_id}" for elec_id in elec_ids]
419+
shank_ids_iter = shank_ids if shank_ids is not None else [None] * len(elec_ids)
420+
contact_ids = [_build_canonical_contact_id(elec_id, shank_id) for shank_id, elec_id in zip(shank_ids_iter, elec_ids)]
423421

424422
# ===== 5. Create Probe object and set contacts =====
425423
probe = Probe(ndim=2, si_units="um", model_name=probe_part_number, manufacturer="imec")
@@ -693,6 +691,39 @@ def write_imro(file: str | Path, probe: Probe):
693691
##
694692

695693

694+
def _build_canonical_contact_id(electrode_id: int, shank_id: int | None = None) -> str:
695+
"""
696+
Build the canonical contact ID string for a Neuropixels electrode.
697+
698+
This establishes the standard naming convention used throughout probeinterface
699+
for Neuropixels contact identification.
700+
701+
Parameters
702+
----------
703+
electrode_id : int
704+
Physical electrode ID on the probe (e.g., 0-959 for NP1.0)
705+
shank_id : int or None, default: None
706+
Shank ID for multi-shank probes. If None, assumes single-shank probe.
707+
708+
Returns
709+
-------
710+
contact_id : str
711+
Canonical contact ID string, either "e{electrode_id}" for single-shank
712+
or "s{shank_id}e{electrode_id}" for multi-shank probes.
713+
714+
Examples
715+
--------
716+
>>> _build_canonical_contact_id(123)
717+
'e123'
718+
>>> _build_canonical_contact_id(123, shank_id=0)
719+
's0e123'
720+
"""
721+
if shank_id is not None:
722+
return f"s{shank_id}e{electrode_id}"
723+
else:
724+
return f"e{electrode_id}"
725+
726+
696727
def _parse_imro_string(imro_table_string: str, probe_part_number: str) -> dict:
697728
"""
698729
Parse IMRO (Imec ReadOut) table string into structured per-channel data.
@@ -717,7 +748,10 @@ def _parse_imro_string(imro_table_string: str, probe_part_number: str) -> dict:
717748
imro_per_channel : dict
718749
Dictionary where each key maps to a list of values (one per channel).
719750
Keys are IMRO fields like "channel", "bank", "electrode", "ap_gain", etc.
720-
Example: {"channel": [0,1,2,...], "bank": [1,0,0,...], "ap_gain": [500,500,...]}
751+
The "electrode" key always contains physical electrode IDs (0-959 for NP1.0, etc.).
752+
For NP2.0+: electrode IDs come directly from IMRO data.
753+
For NP1.0: electrode IDs are computed as bank * 384 + channel.
754+
Example: {"channel": [0,1,2,...], "bank": [1,0,0,...], "electrode": [384,1,2,...], "ap_gain": [500,500,...]}
721755
"""
722756
# Get IMRO field format from catalogue
723757
probe_features = _load_np_probe_features()
@@ -735,6 +769,15 @@ def _parse_imro_string(imro_table_string: str, probe_part_number: str) -> dict:
735769
for field, field_value in zip(imro_fields, values):
736770
imro_per_channel[field].append(field_value)
737771

772+
# Ensure "electrode" key always exists with physical electrode IDs
773+
# Different probe types encode electrode selection differently
774+
if "electrode" not in imro_per_channel:
775+
# NP1.0: Bank-based addressing (physical_electrode_id = bank * 384 + channel)
776+
readout_channel_ids = np.array(imro_per_channel["channel"])
777+
bank_key = "bank" if "bank" in imro_per_channel else "bank_mask"
778+
bank_indices = np.array(imro_per_channel[bank_key])
779+
imro_per_channel["electrode"] = (bank_indices * 384 + readout_channel_ids).tolist()
780+
738781
return imro_per_channel
739782

740783

@@ -790,41 +833,32 @@ def read_spikeglx(file: str | Path) -> Probe:
790833
imro_table_string = meta["imroTbl"]
791834
imro_per_channel = _parse_imro_string(imro_table_string, imDatPrb_pn)
792835

793-
# ===== 4. Get active electrodes from IMRO data =====
794-
# Convert IMRO channel numbers to physical electrode IDs. Different probe types encode
795-
# electrode selection differently: NP1.0 uses banks, NP2.0+ uses direct electrode IDs.
796-
if "electrode" in imro_per_channel:
797-
# NP2.0+: Direct electrode addressing
798-
physical_electrode_ids = np.array(imro_per_channel["electrode"])
799-
else:
800-
# NP1.0: Bank-based addressing (physical_electrode_id = bank * 384 + channel)
801-
readout_channel_ids = np.array(imro_per_channel["channel"])
802-
bank_key = "bank" if "bank" in imro_per_channel else "bank_mask"
803-
bank_indices = np.array(imro_per_channel[bank_key])
804-
physical_electrode_ids = bank_indices * 384 + readout_channel_ids
836+
# ===== 4. Build contact IDs for active electrodes =====
837+
# Convert physical electrode IDs to probeinterface canonical contact ID strings
838+
imro_electrode = imro_per_channel["electrode"]
839+
imro_shank = imro_per_channel.get("shank", [None] * len(imro_electrode))
840+
active_contact_ids = [
841+
_build_canonical_contact_id(elec_id, shank_id)
842+
for shank_id, elec_id in zip(imro_shank, imro_electrode)
843+
]
805844

806845
# ===== 5. Slice full probe to active electrodes =====
807-
selected_contact_indices = []
808-
for idx, electrode_id in enumerate(physical_electrode_ids):
809-
if "shank" in imro_per_channel:
810-
shank_id = imro_per_channel["shank"][idx]
811-
contact_id_str = f"s{shank_id}e{electrode_id}"
812-
else:
813-
contact_id_str = f"e{electrode_id}"
814-
815-
full_probe_index = np.where(full_probe.contact_ids == contact_id_str)[0]
816-
if len(full_probe_index) > 0:
817-
selected_contact_indices.append(full_probe_index[0])
846+
# Find indices where full probe contact IDs match the active contact IDs
847+
full_probe_contact_ids = np.array(full_probe.contact_ids)
848+
active_contact_ids_array = np.array(active_contact_ids)
849+
mask = np.isin(full_probe_contact_ids, active_contact_ids_array)
850+
selected_contact_indices = np.where(mask)[0]
818851

819852
probe = full_probe.get_slice(np.array(selected_contact_indices, dtype=int))
820853

821854
# ===== 6. Store IMRO properties (acquisition settings) as annotations =====
822-
vector_properties = ("channel", "bank", "bank_mask", "ref_id", "ap_gain", "lf_gain", "ap_hipas_flt")
823-
vector_properties_available = {}
824-
for k, v in imro_per_channel.items():
825-
if (k in vector_properties) and (len(v) > 0):
826-
vector_properties_available[imro_field_to_pi_field.get(k)] = v
827-
probe.annotate_contacts(**vector_properties_available)
855+
# Map IMRO field names to probeinterface field names and add as contact annotations
856+
imro_properties_to_add = ("channel", "bank", "bank_mask", "ref_id", "ap_gain", "lf_gain", "ap_hipas_flt")
857+
probe.annotate_contacts(**{
858+
imro_field_to_pi_field.get(k): v
859+
for k, v in imro_per_channel.items()
860+
if k in imro_properties_to_add and len(v) > 0
861+
})
828862

829863
# ===== 7. Slice to saved channels (if subset was saved) =====
830864
# This is DIFFERENT from IMRO selection: IMRO selects which electrodes to acquire,

0 commit comments

Comments
 (0)