Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/probeinterface/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
----------
Expand Down
25 changes: 22 additions & 3 deletions src/probeinterface/probegroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions tests/test_probegroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────


Expand Down