diff --git a/src/probeinterface/probe.py b/src/probeinterface/probe.py index 5e205fa4..e264a4eb 100644 --- a/src/probeinterface/probe.py +++ b/src/probeinterface/probe.py @@ -110,7 +110,9 @@ def __init__( self.device_channel_indices = None # Handle ids with str so it can be displayed like names - # This must be unique at Probe AND ProbeGroup level + # This must be unique at Probe level. Across a ProbeGroup the key that is + # unique is the pair (probe_index, contact_id), so two copies of the same + # probe model keep their own contact_ids without clashing. self._contact_ids = None # Handle contact side for double face probes @@ -560,8 +562,13 @@ def wiring_to_device(self, pathway: str, channel_offset: int = 0): def set_contact_ids(self, contact_ids: np.ndarray | list): """ Set contact ids. Channel ids are converted to strings. - Contact ids must be **unique** for the **Probe** - and also for the **ProbeGroup** + Contact ids must be **unique** within the **Probe**. + + They are *not* required to be unique across a **ProbeGroup**: the key that is + unique there is the pair ``(probe_index, contact_id)``. Adding the same probe + model to a ProbeGroup twice therefore keeps both sets of contact_ids as they + are, and :meth:`ProbeGroup.select_contacts` takes ``probe_ids`` to disambiguate + a contact_id that appears on more than one probe. Parameters ---------- diff --git a/src/probeinterface/probegroup.py b/src/probeinterface/probegroup.py index 869e5d86..f56aaa6c 100644 --- a/src/probeinterface/probegroup.py +++ b/src/probeinterface/probegroup.py @@ -356,6 +356,10 @@ def get_global_contact_ids(self) -> np.ndarray: """ Gets all contact ids concatenated across probes + Contact ids are unique within a Probe but may repeat across probes, so the + returned array is not necessarily unique. Pair it with the ``probe_index`` + column of :meth:`to_numpy` to get a key that is unique across the ProbeGroup. + Returns ------- contact_ids: np.ndarray @@ -546,14 +550,29 @@ def select_contacts( return self.get_slice(indices) def _check_global_device_wiring_and_ids(self) -> None: + if self.get_contact_count() == 0: + return + arr = self.to_numpy(complete=True) + # check unique device_channel_indices for !=-1 - chans = self.get_global_device_channel_indices() - keep = chans["device_channel_indices"] >= 0 - valid_chans = chans[keep]["device_channel_indices"] + chans = arr["device_channel_indices"] + valid_chans = chans[chans >= 0] if valid_chans.size != np.unique(valid_chans).size: raise ValueError("channel device indices are not unique across probes") + # check unique contact ids. A contact_id is unique within a Probe, not across + # the whole ProbeGroup, so the key that has to be unique here is the pair + # (probe_index, contact_id). This lets the same probe model be added twice + # without renaming its contacts, while still identifying every contact. + pairs = list(zip(arr["probe_index"].tolist(), arr["contact_ids"].tolist())) + if len(set(pairs)) != len(pairs): + duplicated = sorted({pair for pair in pairs if pairs.count(pair) > 1}) + raise ValueError( + f"(probe_index, contact_id) pairs are not unique across probes: {duplicated}. " + "contact_ids only need to be unique within a single Probe." + ) + def auto_generate_contact_ids(self, *args, **kwargs) -> None: """ Annotate all contacts with unique contact_id values. diff --git a/tests/test_probegroup.py b/tests/test_probegroup.py index f0127926..15d9c1a6 100644 --- a/tests/test_probegroup.py +++ b/tests/test_probegroup.py @@ -102,6 +102,56 @@ def test_set_contact_ids_rejects_wrong_size(): probe.set_contact_ids(["a", "b", "c"]) +def test_duplicate_contact_ids_across_probes_are_allowed(): + """contact_ids are unique per Probe, not per ProbeGroup. + + Adding the same probe model twice must not rename anything and must not raise. + """ + probegroup = ProbeGroup() + for _ in range(2): + probe = generate_dummy_probe() + probe.set_contact_ids([str(i) for i in range(probe.get_contact_count())]) + probegroup.add_probe(probe) + + arr = probegroup.to_numpy(complete=True) + contact_ids = arr["contact_ids"].tolist() + + # the ids themselves repeat across the two probes + assert len(set(contact_ids)) < len(contact_ids) + # but (probe_index, contact_id) is unique, which is the ProbeGroup-level key + pairs = list(zip(arr["probe_index"].tolist(), contact_ids)) + assert len(set(pairs)) == len(pairs) + + +def test_generate_dummy_probe_group_repeats_contact_ids_across_probes(): + """Guards the documented semantics against a return to group-wide uniqueness.""" + from probeinterface import generate_dummy_probe_group + + arr = generate_dummy_probe_group().to_numpy(complete=True) + contact_ids = arr["contact_ids"].tolist() + + assert len(set(contact_ids)) < len(contact_ids) + pairs = list(zip(arr["probe_index"].tolist(), contact_ids)) + assert len(set(pairs)) == len(pairs) + + +def test_check_global_ids_rejects_within_probe_duplicates(): + """The ProbeGroup-level check validates ids, not just device_channel_indices. + + ``set_contact_ids`` already blocks within-probe duplicates, so this reaches the + group check the way a Probe built by other means (for example an older + serialized file) would. + """ + probegroup = _make_probegroup() + probe = probegroup.probes[0] + duplicated = probe.contact_ids.copy() + duplicated[1] = duplicated[0] + probe._contact_ids = duplicated + + with pytest.raises(ValueError, match=r"\(probe_index, contact_id\) pairs are not unique"): + probegroup._check_global_device_wiring_and_ids() + + # ── get_global_contact_positions() tests ────────────────────────────────────