diff --git a/examples/Python/session_info.py b/pycbsdk/examples/session_info.py similarity index 100% rename from examples/Python/session_info.py rename to pycbsdk/examples/session_info.py diff --git a/examples/Python/test_recording.py b/pycbsdk/examples/test_recording.py similarity index 100% rename from examples/Python/test_recording.py rename to pycbsdk/examples/test_recording.py diff --git a/pycbsdk/src/pycbsdk/_cdef.py b/pycbsdk/src/pycbsdk/_cdef.py index 27f0c9f8..c13bcfd5 100644 --- a/pycbsdk/src/pycbsdk/_cdef.py +++ b/pycbsdk/src/pycbsdk/_cdef.py @@ -316,7 +316,7 @@ uint32_t runlevel); // CCF configuration files -cbsdk_result_t cbsdk_session_load_channel_map(cbsdk_session_t session, const char* filepath, uint32_t bank_offset); +cbsdk_result_t cbsdk_session_load_channel_map(cbsdk_session_t session, const char* filepath, uint32_t start_chan, uint32_t hs_id); cbsdk_result_t cbsdk_session_save_ccf(cbsdk_session_t session, const char* filename); cbsdk_result_t cbsdk_session_load_ccf(cbsdk_session_t session, const char* filename); cbsdk_result_t cbsdk_session_load_ccf_sync(cbsdk_session_t session, const char* filename, uint32_t timeout_ms); diff --git a/pycbsdk/src/pycbsdk/cmp.py b/pycbsdk/src/pycbsdk/cmp.py new file mode 100644 index 00000000..7d272f18 --- /dev/null +++ b/pycbsdk/src/pycbsdk/cmp.py @@ -0,0 +1,244 @@ +"""Parse and apply Blackrock ``.cmp`` channel-map files. + +A ``.cmp`` file describes one headstage's electrode layout. Each data row +has the form:: + + col row bank electrode [label] + +where ``bank`` is a letter (``A``-``H``) and ``electrode`` is 1-based within +the bank. Rows are not guaranteed to be in channel order; this module sorts +by ``(bank, electrode)`` and assigns each sorted row an absolute 1-based +channel ID starting at a caller-supplied ``start_chan``. + +Labels commonly collide across headstages (every file may use +``elec1-1`` … ``elec1-128``), so each label is prefixed with ``"hs{hs_id}-"``. +Pass ``hs_id=0`` (the default) to skip prefixing — appropriate for +single-headstage rigs where the original labels are already unique. + +Typical use:: + + entries = parse_cmp("/path/to/headstage1.cmp", start_chan=1, hs_id=1) + for chan_id, entry in sorted(entries.items()): + print(chan_id, entry.label, entry.position) + +To apply CMPs to a live session, prefer :meth:`pycbsdk.Session.load_channel_map`. + +Command line:: + + python -m pycbsdk.cmp head1.cmp:1:1 head2.cmp:129:2 --device NSP +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class CmpEntry: + """One parsed CMP row, ready to apply to an absolute channel.""" + + chan_id: int # 1-based absolute channel + position: tuple[int, int, int, int] # (col, row, bank_idx, electrode) + label: str # prefixed (e.g. "hs1-chan3") + + +def parse_cmp( + filepath: str | Path, + start_chan: int = 1, + hs_id: int = 0, +) -> dict[int, CmpEntry]: + """Parse a single CMP file and assign absolute channel IDs. + + Args: + filepath: Path to the ``.cmp`` file. + start_chan: 1-based channel assigned to the first sorted row. + hs_id: Headstage identifier; labels are prefixed ``"hs{hs_id}-"``. + Pass ``0`` (the default) to leave labels un-prefixed. + + Returns: + Dict mapping absolute 1-based ``chan_id`` → :class:`CmpEntry`. + + Raises: + ValueError: If the file is malformed or contains no valid rows. + FileNotFoundError: If the file does not exist. + """ + raw: list[tuple[int, int, int, int, str]] = [] # (col, row, bank_idx, elec, label) + description: str | None = None + + with open(filepath) as fh: + for line in fh: + stripped = line.strip() + if not stripped or stripped.startswith("//"): + continue + if description is None: + description = stripped + continue + parts = stripped.split() + if len(parts) < 4: + raise ValueError( + f"{filepath}: malformed row (need 'col row bank elec [label]'): " + f"{line.rstrip()!r}" + ) + bank = parts[2] + if len(bank) != 1 or not bank[0].isalpha(): + raise ValueError(f"{filepath}: invalid bank letter {bank!r}") + bank_idx = ord(bank[0].upper()) - ord("A") + 1 + raw.append( + ( + int(parts[0]), + int(parts[1]), + bank_idx, + int(parts[3]), + parts[4] if len(parts) > 4 else "", + ) + ) + + if not raw: + raise ValueError(f"{filepath}: no valid entries") + + raw.sort(key=lambda r: (r[2], r[3])) + + # hs_id == 0 means "single headstage, no prefix needed". + prefix = "" if hs_id == 0 else f"hs{hs_id}-" + entries: dict[int, CmpEntry] = {} + for i, (col, row, bank_idx, elec, label) in enumerate(raw): + chan_id = start_chan + i + entries[chan_id] = CmpEntry( + chan_id=chan_id, + position=(col, row, bank_idx, elec), + label=f"{prefix}{label}", + ) + return entries + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _parse_spec(spec: str) -> tuple[Path, int, int]: + """Parse a ``FILE:START_CHAN:HS_ID`` or ``FILE:START_CHAN`` spec. + + ``HS_ID`` defaults to ``0`` (no label prefix) when omitted; ``START_CHAN`` + defaults to ``1``. The CLI does not auto-stack headstages — callers must + pass each spec explicitly. + """ + parts = spec.split(":") + if not parts or not parts[0]: + raise argparse.ArgumentTypeError(f"empty spec: {spec!r}") + path = Path(parts[0]) + if not path.exists(): + raise argparse.ArgumentTypeError(f"{path}: not found") + start_chan = int(parts[1]) if len(parts) > 1 and parts[1] else 1 + hs_id = int(parts[2]) if len(parts) > 2 and parts[2] else 0 + if len(parts) > 3: + raise argparse.ArgumentTypeError( + f"too many fields in spec {spec!r}; expected FILE[:START_CHAN[:HS_ID]]" + ) + return path, start_chan, hs_id + + +def _dump(specs: list[tuple[Path, int, int]]) -> None: + """Parse each spec and print its entries, checking for chan_id collisions.""" + seen: dict[int, tuple[Path, str]] = {} + for path, start_chan, hs_id in specs: + entries = parse_cmp(path, start_chan=start_chan, hs_id=hs_id) + print( + f"# {path} start_chan={start_chan} hs_id={hs_id} ({len(entries)} chans)" + ) + for chan_id in sorted(entries): + e = entries[chan_id] + col, row, bank_idx, elec = e.position + if chan_id in seen: + prev_path, prev_label = seen[chan_id] + print( + f" # WARNING: chan {chan_id} already claimed by " + f"{prev_path.name} as {prev_label!r}", + file=sys.stderr, + ) + seen[chan_id] = (path, e.label) + print( + f" chan {chan_id:>3} {e.label:<16s} col={col} row={row} " + f"bank={bank_idx} elec={elec}" + ) + + +def _apply(specs: list[tuple[Path, int, int]], device: str, timeout: float) -> None: + """Connect to a device and call ``session.load_channel_map`` for each spec.""" + # Import lazily so `python -m pycbsdk.cmp --dump` works without a device lib. + import time + + from pycbsdk import DeviceType, Session + from pycbsdk.session import _coerce_enum + + device_type = _coerce_enum(DeviceType, device) + with Session(device_type=device_type) as session: + deadline = time.monotonic() + timeout + while not session.running: + if time.monotonic() > deadline: + raise TimeoutError( + f"Session for {device_type.name} did not start within {timeout}s" + ) + time.sleep(0.1) + time.sleep(0.5) # let initial config settle + + for path, start_chan, hs_id in specs: + print(f"Loading {path.name} start_chan={start_chan} hs_id={hs_id}") + session.load_channel_map(str(path), start_chan=start_chan, hs_id=hs_id) + print("Done.") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python -m pycbsdk.cmp", + description=( + "Parse or apply Blackrock .cmp channel-map files. " + "Each SPEC is FILE[:START_CHAN[:HS_ID]] " + "(defaults: START_CHAN=1, HS_ID=0 → no label prefix)." + ), + ) + parser.add_argument( + "specs", + nargs="+", + type=_parse_spec, + metavar="SPEC", + help=( + "one or more FILE:START_CHAN:HS_ID specs " + "(START_CHAN default 1, HS_ID default 0 → no prefix)" + ), + ) + parser.add_argument( + "--dump", + action="store_true", + help="print parsed entries without connecting to a device", + ) + parser.add_argument( + "--device", + default="NPLAY", + help="device type when applying (default: NPLAY)", + ) + parser.add_argument( + "--timeout", + type=float, + default=10.0, + help="session connection timeout in seconds (default: 10)", + ) + args = parser.parse_args(argv) + + if args.dump: + _dump(args.specs) + return 0 + + try: + _apply(args.specs, device=args.device, timeout=args.timeout) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pycbsdk/src/pycbsdk/session.py b/pycbsdk/src/pycbsdk/session.py index e83a1ad0..011bf16e 100644 --- a/pycbsdk/src/pycbsdk/session.py +++ b/pycbsdk/src/pycbsdk/session.py @@ -1121,25 +1121,29 @@ def configure_channel(self, chan_id: int, **kwargs): # --- Channel Mapping (CMP) Files --- - def load_channel_map(self, filepath: str, bank_offset: int = 0): - """Load a channel mapping file (.cmp) and apply electrode positions. + def load_channel_map(self, filepath: str, start_chan: int = 1, hs_id: int = 0): + """Load a channel mapping file (.cmp) for one headstage. - CMP files define physical electrode positions on arrays. Because the device - does not persist position data, positions are stored locally and overlaid - onto channel info whenever updated config data arrives from the device. + CMP files describe one headstage's electrode layout. The file's rows + are sorted by (bank, electrode) and assigned absolute channel IDs + starting at ``start_chan``. Positions are stored locally and overlaid + onto chaninfo; labels are prefixed ``"hs{hs_id}-"`` and pushed to the + device so they persist in chaninfo. - Can be called multiple times for different front-end ports on a Hub device, - each with a different array and CMP file. + Call once per headstage — subsequent calls merge into the overlay, + so multiple headstages can coexist on one device. Args: filepath: Path to the .cmp file. - bank_offset: Offset added to CMP bank indices for multi-port Hubs. - CMP bank letter A becomes absolute bank (1 + bank_offset). - Port 1: offset 0 (A=bank 1). Port 2: offset 4 (A=bank 5), etc. + start_chan: 1-based channel to assign the first sorted row. + Typical: 1 for the first headstage, 129 for the second of a + 128-channel headstage, etc. + hs_id: Headstage identifier used to prefix labels. Pass ``0`` + (the default) to leave labels un-prefixed. """ _check( _get_lib().cbsdk_session_load_channel_map( - self._session, filepath.encode(), bank_offset + self._session, filepath.encode(), start_chan, hs_id ), "Failed to load channel map", ) diff --git a/pycbsdk/tests/conftest.py b/pycbsdk/tests/conftest.py index ddaade3f..5f03ba37 100644 --- a/pycbsdk/tests/conftest.py +++ b/pycbsdk/tests/conftest.py @@ -125,6 +125,20 @@ def cmp_path() -> Path: return cmp +@pytest.fixture(scope="session") +def manufacturer_cmp_path() -> Path: + """Path to the sanitized 128-channel manufacturer CMP fixture. + + The default 96-channel file has rows that are already in (bank, electrode) + order — useful for parser tests but not for exercising the sort. The + manufacturer sample has out-of-order rows like a real .cmp file. + """ + repo_root = Path(__file__).parent.parent.parent + cmp = repo_root / "tests" / "128ChannelManufacturerMapping.cmp" + assert cmp.exists(), f"CMP file not found at {cmp}" + return cmp + + @pytest.fixture(scope="session") def nplayserver_binary() -> Path | None: """Locate or download the nPlayServer binary for this platform.""" diff --git a/pycbsdk/tests/test_cmp.py b/pycbsdk/tests/test_cmp.py new file mode 100644 index 00000000..40057e4a --- /dev/null +++ b/pycbsdk/tests/test_cmp.py @@ -0,0 +1,110 @@ +"""Unit tests for pycbsdk.cmp (channel-map parser and CLI helpers).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from pycbsdk import cmp as cmp_mod +from pycbsdk.cmp import CmpEntry, main, parse_cmp + + +def test_parse_single_file_assigns_chans_from_one(cmp_path: Path): + entries = parse_cmp(cmp_path) + assert len(entries) == 96 + assert set(entries) == set(range(1, 97)) + assert all(isinstance(e, CmpEntry) for e in entries.values()) + + +def test_parse_labels_are_prefixed_with_hs_id(cmp_path: Path): + entries = parse_cmp(cmp_path, hs_id=3) + for entry in entries.values(): + assert entry.label.startswith("hs3-") + + +def test_parse_sorts_by_bank_then_electrode(cmp_path: Path): + entries = parse_cmp(cmp_path) + # 96-channel file: banks A, B, C each with 32 electrodes. + # chan 1 → (bank_idx=1, elec=1), chan 33 → (bank_idx=2, elec=1), … + assert entries[1].position[2:] == (1, 1) + assert entries[32].position[2:] == (1, 32) + assert entries[33].position[2:] == (2, 1) + assert entries[64].position[2:] == (2, 32) + assert entries[65].position[2:] == (3, 1) + assert entries[96].position[2:] == (3, 32) + + +def test_parse_start_chan_offsets_chan_ids(cmp_path: Path): + entries = parse_cmp(cmp_path, start_chan=129, hs_id=2) + assert set(entries) == set(range(129, 129 + 96)) + assert entries[129].position[2:] == (1, 1) # bank A elec 1 + assert all(e.label.startswith("hs2-") for e in entries.values()) + + +def test_parse_unsorted_rows(tmp_path: Path): + f = tmp_path / "unsorted.cmp" + f.write_text( + "// scratch\n" + "unsorted test\n" + "0 0 B 1 label_b1\n" + "0 0 A 3 label_a3\n" + "0 0 A 1 label_a1\n" + "0 0 A 2 label_a2\n" + ) + entries = parse_cmp(f, hs_id=1) + # (A,1), (A,2), (A,3), (B,1) → chans 1..4 + assert entries[1].label == "hs1-label_a1" + assert entries[2].label == "hs1-label_a2" + assert entries[3].label == "hs1-label_a3" + assert entries[4].label == "hs1-label_b1" + + +def test_parse_default_hs_id_omits_prefix(cmp_path: Path): + """Default hs_id=0 leaves labels unmodified.""" + bare = parse_cmp(cmp_path) + prefixed = parse_cmp(cmp_path, hs_id=3) + for chan_id, entry in bare.items(): + assert not entry.label.startswith("hs") + assert prefixed[chan_id].label == f"hs3-{entry.label}" + + +def test_parse_rejects_missing_file(tmp_path: Path): + with pytest.raises(FileNotFoundError): + parse_cmp(tmp_path / "nope.cmp") + + +def test_parse_rejects_empty_file(tmp_path: Path): + f = tmp_path / "empty.cmp" + f.write_text("// just comments\n// still just comments\n") + with pytest.raises(ValueError, match="no valid entries"): + parse_cmp(f) + + +def test_parse_spec_defaults(cmp_path: Path): + path, start_chan, hs_id = cmp_mod._parse_spec(str(cmp_path)) + assert path == cmp_path + assert start_chan == 1 + assert hs_id == 0 + + +def test_parse_spec_full(cmp_path: Path): + path, start_chan, hs_id = cmp_mod._parse_spec(f"{cmp_path}:129:2") + assert path == cmp_path + assert start_chan == 129 + assert hs_id == 2 + + +def test_parse_spec_rejects_nonexistent_file(): + with pytest.raises(Exception): # argparse.ArgumentTypeError + cmp_mod._parse_spec("/no/such/file.cmp:1:1") + + +def test_main_dump_prints_entries(cmp_path: Path, capsys): + rc = main([f"{cmp_path}:1:1", "--dump"]) + assert rc == 0 + stdout = capsys.readouterr().out + assert "start_chan=1" in stdout + assert "hs_id=1" in stdout + assert "chan 1" in stdout # formatted with width + assert "hs1-" in stdout diff --git a/pycbsdk/tests/test_configuration.py b/pycbsdk/tests/test_configuration.py index f7431d1b..279fd0fd 100644 --- a/pycbsdk/tests/test_configuration.py +++ b/pycbsdk/tests/test_configuration.py @@ -713,32 +713,165 @@ def test_load_ccf_sync(self, nplay_session, client_session): class TestCMP: - """Tests for channel mapping (CMP) file loading.""" + """Tests for channel mapping (CMP) file loading. + + ``load_channel_map`` writes positions into shmem synchronously, but pushes + labels to the device asynchronously (one ``CHANSETLABEL`` packet per + channel; the device echoes a ``CHANREP`` for each). ``session.sync()`` + sends a no-op runlevel SET and waits for the matching ``SYSREPRUNLEV``, + which guarantees every preceding ``CHANREP`` has been mirrored into shmem. + + The shared ``nplay_session`` plays back the 4-channel ``dnss`` recording, + so these tests intersect the parsed CMP entries with the frontend channels + the device actually reports. End-to-end coverage of larger channel counts + lives in the C++ integration tests against ``dnss256``. + """ - def test_load_channel_map(self, nplay_session, cmp_path): + @staticmethod + def _frontend_view(session): + """Return ``{chan_id: (position, label)}`` for present FRONTEND chans.""" + chan_ids = session.get_matching_channel_ids(ChannelType.FRONTEND) + positions = session.get_channels_positions(ChannelType.FRONTEND) + labels = session.get_channels_labels(ChannelType.FRONTEND) + return {c: (p, l) for c, p, l in zip(chan_ids, positions, labels)} + + def test_load_channel_map_smoke(self, nplay_session, cmp_path): + """Loading a CMP and syncing succeeds without raising.""" nplay_session.load_channel_map(str(cmp_path)) - time.sleep(0.5) + nplay_session.sync() - def test_load_channel_map_with_bank_offset(self, nplay_session, cmp_path): - nplay_session.load_channel_map(str(cmp_path), bank_offset=4) - time.sleep(0.5) + def test_load_nonexistent_file_raises(self, nplay_session, tmp_path): + bogus = tmp_path / "does_not_exist.cmp" + with pytest.raises(RuntimeError): + nplay_session.load_channel_map(str(bogus)) + + def test_positions_match_parsed(self, nplay_session, cmp_path): + """For each FE chan present on the device, position == parser output.""" + from pycbsdk.cmp import parse_cmp - def test_positions_after_cmp_load(self, nplay_session, cmp_path): nplay_session.load_channel_map(str(cmp_path)) - time.sleep(1) - # Check all front-end channels (not just 4) — the CMP maps 96 channels - # and matching depends on device-reported bank/term values. - positions = nplay_session.get_channels_positions(ChannelType.FRONTEND) - non_zero = [p for p in positions if any(v != 0 for v in p)] - assert len(non_zero) > 0, ( - f"No non-zero positions found in {len(positions)} channels after CMP load. " - f"First 4 channel bank/term: " - + ", ".join( - f"({nplay_session.get_channel_field(i+1, ChanInfoField.BANK)}, " - f"{nplay_session.get_channel_field(i+1, ChanInfoField.TERM)})" - for i in range(min(4, len(positions))) + nplay_session.sync() + + expected = parse_cmp(str(cmp_path)) + view = self._frontend_view(nplay_session) + intersect = sorted(set(expected) & set(view)) + assert intersect, "no FRONTEND chans overlap parsed CMP entries" + + for chan_id in intersect: + actual_pos, _ = view[chan_id] + assert actual_pos == expected[chan_id].position, ( + f"chan {chan_id}: expected {expected[chan_id].position}, got {actual_pos}" ) + + def test_labels_round_trip_through_device(self, nplay_session, cmp_path): + """After load + sync, labels read back through the device match the prefix.""" + from pycbsdk.cmp import parse_cmp + + nplay_session.load_channel_map(str(cmp_path), hs_id=7) + nplay_session.sync() + + expected = parse_cmp(str(cmp_path), hs_id=7) + view = self._frontend_view(nplay_session) + intersect = sorted(set(expected) & set(view)) + assert intersect + + for chan_id in intersect: + _, actual_label = view[chan_id] + assert actual_label == expected[chan_id].label, ( + f"chan {chan_id}: expected {expected[chan_id].label!r}, " + f"got {actual_label!r}" + ) + # All labels carry the hs7 prefix we requested. + assert actual_label.startswith("hs7-") + + def test_hs_id_prefix_changes_label(self, nplay_session, cmp_path): + """Loading with a different hs_id rewrites labels on the device.""" + from pycbsdk.cmp import parse_cmp + + cmp_chans = set(parse_cmp(str(cmp_path))) + + nplay_session.load_channel_map(str(cmp_path), hs_id=4) + nplay_session.sync() + before = self._frontend_view(nplay_session) + + nplay_session.load_channel_map(str(cmp_path), hs_id=5) + nplay_session.sync() + after = self._frontend_view(nplay_session) + + # Only chans the CMP covers carry the hs prefix; the rest keep their + # device-default labels. Restrict the check to covered chans that are + # also visible on the device. + relabeled = sorted(set(before) & set(after) & cmp_chans) + assert relabeled + for chan_id in relabeled: + assert before[chan_id][1].startswith("hs4-") + assert after[chan_id][1].startswith("hs5-") + # The original (un-prefixed) label is unchanged across the two loads. + assert before[chan_id][1].split("-", 1)[1] == after[chan_id][1].split("-", 1)[1] + + def test_start_chan_assignment( + self, nplay_session, manufacturer_cmp_path + ): + """Loading the same file at start_chan=1 vs 129 produces matching layouts. + + Parser output for ``(start_chan=1, hs_id=1)`` and ``(start_chan=129, + hs_id=2)`` must agree on (col, row, bank, electrode) at offset chans — + e.g. chan 1 under hs1 has the same position as chan 129 under hs2 — and + whichever of those chans the device actually reports must reflect the + right layout. + """ + from pycbsdk.cmp import parse_cmp + + # Load both headstages so chans 1.. and 129.. are populated. + nplay_session.load_channel_map( + str(manufacturer_cmp_path), start_chan=1, hs_id=1 + ) + nplay_session.load_channel_map( + str(manufacturer_cmp_path), start_chan=129, hs_id=2 ) + nplay_session.sync() + + hs1 = parse_cmp(str(manufacturer_cmp_path), start_chan=1, hs_id=1) + hs2 = parse_cmp(str(manufacturer_cmp_path), start_chan=129, hs_id=2) + + # Shared invariant: corresponding (chan, chan+128) entries describe the + # same electrode and differ only in the hs prefix. + for sorted_idx in range(min(len(hs1), len(hs2))): + assert hs1[1 + sorted_idx].position == hs2[129 + sorted_idx].position + assert hs1[1 + sorted_idx].label == hs2[129 + sorted_idx].label.replace( + "hs2-", "hs1-", 1 + ) + + # And on the device, every present FE chan that falls in either range + # matches its parsed entry. + view = self._frontend_view(nplay_session) + for chan_id, (pos, label) in view.items(): + if chan_id in hs1: + assert pos == hs1[chan_id].position + assert label == hs1[chan_id].label + elif chan_id in hs2: + assert pos == hs2[chan_id].position + assert label == hs2[chan_id].label + + def test_overlay_survives_chanrep_refresh(self, nplay_session, cmp_path): + """A CHANREP echoed back from the device should not erase our overlay. + + ``setChannelConfig`` triggers the device to send a CHANREP for the + channel we just labeled. The receive path re-applies the CMP overlay + before mirroring the CHANREP into shmem; otherwise, the position would + get clobbered by whatever the device thinks (which is not persisted). + """ + from pycbsdk.cmp import parse_cmp + + nplay_session.load_channel_map(str(cmp_path), hs_id=9) + nplay_session.sync() + + expected = parse_cmp(str(cmp_path), hs_id=9) + view = self._frontend_view(nplay_session) + for chan_id in sorted(set(expected) & set(view)): + pos, label = view[chan_id] + assert pos == expected[chan_id].position + assert label == expected[chan_id].label # --------------------------------------------------------------------------- diff --git a/src/cbsdk/include/cbsdk/cbsdk.h b/src/cbsdk/include/cbsdk/cbsdk.h index 37bb0cde..e3e24cc0 100644 --- a/src/cbsdk/include/cbsdk/cbsdk.h +++ b/src/cbsdk/include/cbsdk/cbsdk.h @@ -824,23 +824,27 @@ CBSDK_API cbsdk_result_t cbsdk_session_set_runlevel( // Channel Mapping (CMP) Files /////////////////////////////////////////////////////////////////////////////////////////////////// -/// Load a channel mapping file (.cmp) and apply electrode positions +/// Load a channel mapping file (.cmp) for one headstage /// -/// CMP files define physical electrode positions on arrays. Because the device does not -/// persist the position field in chaninfo, positions are stored locally and overlaid -/// onto channel info whenever config data arrives from the device. +/// The CMP file's rows are sorted by (bank, electrode) and assigned absolute +/// channel IDs starting at @p start_chan. Positions are stored locally and +/// overlaid on CHANREP packets; labels are prefixed "hs{hs_id}-" and pushed +/// to the device so they persist in chaninfo. /// -/// Can be called multiple times for different ports on a Hub device. +/// Call once per headstage — subsequent calls merge into the overlay. /// -/// @param session Session handle (must not be NULL) -/// @param filepath Path to the .cmp file (must not be NULL) -/// @param bank_offset Offset added to CMP bank indices. A=1+offset, B=2+offset, etc. -/// Use 0 for port 1, 4 for port 2, 8 for port 3, etc. +/// @param session Session handle (must not be NULL) +/// @param filepath Path to the .cmp file (must not be NULL) +/// @param start_chan 1-based channel to assign the first sorted row +/// (typical: 1 for first headstage, 129 for second) +/// @param hs_id Headstage identifier used to prefix labels +/// ("hs{hs_id}-{label}"). Pass 0 to leave labels un-prefixed. /// @return CBSDK_RESULT_SUCCESS on success, error code on failure CBSDK_API cbsdk_result_t cbsdk_session_load_channel_map( cbsdk_session_t session, const char* filepath, - uint32_t bank_offset); + uint32_t start_chan, + uint32_t hs_id); /////////////////////////////////////////////////////////////////////////////////////////////////// // CCF Configuration Files diff --git a/src/cbsdk/include/cbsdk/sdk_session.h b/src/cbsdk/include/cbsdk/sdk_session.h index 7d8f09d3..55d538a9 100644 --- a/src/cbsdk/include/cbsdk/sdk_session.h +++ b/src/cbsdk/include/cbsdk/sdk_session.h @@ -638,20 +638,26 @@ class SdkSession { /// Load a channel mapping file and apply electrode positions /// - /// CMP files define physical electrode positions on arrays. Because the device does not - /// persist the position field in chaninfo, this method stores positions locally and - /// overlays them onto channel info whenever updated config data arrives from the device. + /// CMP files describe one headstage. Rows are sorted by (bank, electrode) + /// and assigned absolute channel IDs starting at @c start_chan. Positions + /// are stored locally and overlaid on CHANREP packets; labels are prefixed + /// "hs{hs_id}-" and pushed to the device so they persist in chaninfo. /// - /// Can be called multiple times for different ports on a Hub device (each port may - /// have a different array with its own CMP file). Subsequent calls merge positions - /// into the existing map. + /// Call this once per headstage. Subsequent calls merge their entries + /// into the overlay, so multiple headstages can coexist on one device. /// - /// @param filepath Path to the .cmp file - /// @param bank_offset Offset added to CMP bank indices to produce absolute bank numbers. - /// CMP bank letter A becomes absolute bank (1 + bank_offset). - /// Port 1: offset 0 (A=bank 1). Port 2: offset 4 (A=bank 5), etc. + /// @param filepath Path to the .cmp file + /// @param start_chan 1-based channel to assign the first sorted row. Use + /// 1 for the first headstage, 129 for the second of a + /// 128-channel headstage, etc. + /// @param hs_id Headstage identifier used to prefix labels + /// ("hs{hs_id}-{label}"). Pass 0 (the default) to leave + /// labels un-prefixed. /// @return Result indicating success or error - Result loadChannelMap(const std::string& filepath, uint32_t bank_offset = 0); + Result loadChannelMap( + const std::string& filepath, + uint32_t start_chan = 1, + uint32_t hs_id = 0); ///-------------------------------------------------------------------------------------------- /// CCF Configuration Files diff --git a/src/cbsdk/src/cbsdk.cpp b/src/cbsdk/src/cbsdk.cpp index d2365779..3ef78926 100644 --- a/src/cbsdk/src/cbsdk.cpp +++ b/src/cbsdk/src/cbsdk.cpp @@ -1297,11 +1297,12 @@ cbsdk_result_t cbsdk_session_set_runlevel( // Channel Mapping (CMP) Files /////////////////////////////////////////////////////////////////////////////////////////////////// -cbsdk_result_t cbsdk_session_load_channel_map(cbsdk_session_t session, const char* filepath, uint32_t bank_offset) { +cbsdk_result_t cbsdk_session_load_channel_map( + cbsdk_session_t session, const char* filepath, uint32_t start_chan, uint32_t hs_id) { if (!session || !session->cpp_session || !filepath) { return CBSDK_RESULT_INVALID_PARAMETER; } - auto result = session->cpp_session->loadChannelMap(filepath, bank_offset); + auto result = session->cpp_session->loadChannelMap(filepath, start_chan, hs_id); if (result.isOk()) { return CBSDK_RESULT_SUCCESS; } else { diff --git a/src/cbsdk/src/cmp_parser.cpp b/src/cbsdk/src/cmp_parser.cpp index 06f30670..e0287a00 100644 --- a/src/cbsdk/src/cmp_parser.cpp +++ b/src/cbsdk/src/cmp_parser.cpp @@ -4,32 +4,46 @@ /////////////////////////////////////////////////////////////////////////////////////////////////// #include "cmp_parser.h" + +#include +#include #include #include -#include +#include namespace cbsdk { -cbutil::Result parseCmpFile(const std::string& filepath, uint32_t bank_offset) { +namespace { + +struct RawEntry { + int32_t col; + int32_t row; + int32_t bank_idx; ///< 1-based (A=1, B=2, …) + int32_t electrode; ///< 1-based within bank + std::string label; +}; + +} // namespace + +cbutil::Result parseCmpFile( + const std::string& filepath, + uint32_t start_chan, + uint32_t hs_id) { std::ifstream file(filepath); if (!file.is_open()) { - return cbutil::Result::error("Failed to open CMP file: " + filepath); + return cbutil::Result::error("Failed to open CMP file: " + filepath); } - CmpPositionMap positions; + std::vector raw; bool found_description = false; std::string line; while (std::getline(file, line)) { - // Strip trailing whitespace/tabs + // Strip trailing whitespace (including \r on Windows line endings) while (!line.empty() && (line.back() == ' ' || line.back() == '\t' || line.back() == '\r')) { line.pop_back(); } - - // Skip empty lines if (line.empty()) continue; - - // Skip comment lines if (line.size() >= 2 && line[0] == '/' && line[1] == '/') continue; // First non-comment line is the description — skip it @@ -38,41 +52,49 @@ cbutil::Result parseCmpFile(const std::string& filepath, uint32_ continue; } - // Parse data line: col row bank electrode [label] + // Data line: col row bank electrode [label] std::istringstream iss(line); - int32_t col, row; + int32_t col = 0, row = 0, electrode = 0; std::string bank_str; - int32_t electrode; - - if (!(iss >> col >> row >> bank_str >> electrode)) { - continue; // Skip malformed lines - } + if (!(iss >> col >> row >> bank_str >> electrode)) continue; + if (bank_str.empty() || !std::isalpha(static_cast(bank_str[0]))) continue; - // Bank letter to 1-based index: A=1, B=2, ..., H=8 - if (bank_str.empty() || !std::isalpha(static_cast(bank_str[0]))) { - continue; // Skip lines with invalid bank - } - uint32_t bank_from_letter = static_cast( - std::toupper(static_cast(bank_str[0])) - 'A' + 1); + int32_t bank_idx = std::toupper(static_cast(bank_str[0])) - 'A' + 1; - // Apply bank offset for multi-port Hub configurations - uint32_t absolute_bank = bank_from_letter + bank_offset; + std::string label; + iss >> label; // optional - // Electrode is 1-based in CMP, term is 0-based in chaninfo - uint32_t term = static_cast(electrode - 1); + raw.push_back({col, row, bank_idx, electrode, std::move(label)}); + } - // Store position: {col, row, bank_from_letter, electrode} - // We store the original CMP values (not offset bank) in position[2] - // so the position data reflects the physical array geometry - uint64_t key = cmpKey(absolute_bank, term); - positions[key] = {col, row, static_cast(bank_from_letter), electrode}; + if (raw.empty()) { + return cbutil::Result::error("No valid entries found in CMP file: " + filepath); } - if (positions.empty()) { - return cbutil::Result::error("No valid entries found in CMP file: " + filepath); + // Rows are not guaranteed to be in channel order. Sort so the Nth + // sorted entry maps to (start_chan + N). + std::sort(raw.begin(), raw.end(), [](const RawEntry& a, const RawEntry& b) { + if (a.bank_idx != b.bank_idx) return a.bank_idx < b.bank_idx; + return a.electrode < b.electrode; + }); + + // hs_id == 0 means "single headstage, no prefix needed". + std::string prefix = (hs_id == 0) + ? std::string{} + : "hs" + std::to_string(hs_id) + "-"; + + CmpEntries entries; + entries.reserve(raw.size()); + for (size_t i = 0; i < raw.size(); ++i) { + const auto& r = raw[i]; + uint32_t chan_id = start_chan + static_cast(i); + CmpEntry entry; + entry.position = {r.col, r.row, r.bank_idx, r.electrode}; + entry.label = prefix + r.label; + entries.emplace(chan_id, std::move(entry)); } - return cbutil::Result::ok(std::move(positions)); + return cbutil::Result::ok(std::move(entries)); } } // namespace cbsdk diff --git a/src/cbsdk/src/cmp_parser.h b/src/cbsdk/src/cmp_parser.h index 67151b26..68c297e9 100644 --- a/src/cbsdk/src/cmp_parser.h +++ b/src/cbsdk/src/cmp_parser.h @@ -2,14 +2,27 @@ /// @file cmp_parser.h /// @brief Parser for Blackrock .cmp (channel mapping) files /// -/// CMP files define electrode positions on NSP arrays. Format: +/// CMP files define electrode positions for a single headstage. Format: /// - Lines starting with // are comments /// - First non-comment line is a description string /// - Subsequent lines: col row bank electrode [label] /// - col: 0-based column (left to right) /// - row: 0-based row (bottom to top) -/// - bank: letter A-H (maps to bank index with offset) +/// - bank: letter A-H (32 electrodes per bank within the headstage) /// - electrode: 1-based electrode within bank (1-32) +/// - label: free-form label (optional) +/// +/// Rows in a CMP file are not guaranteed to be in channel order. The parser +/// sorts by (bank_letter, electrode) and assigns each sorted entry a 1-based +/// absolute channel ID starting at @c start_chan. This is how a CMP gets +/// applied to a contiguous range of channels — callers control the starting +/// channel so multiple CMPs (one per headstage) can be loaded on one device. +/// +/// Labels are reused across CMP files (e.g. "elec1-1"..."elec1-128" in every +/// file), so the parser prefixes each label with "hs{hs_id}-" to keep them +/// unique on the device. Pass @c hs_id == 0 to skip prefixing entirely +/// (sensible for single-headstage rigs where the original labels are already +/// unique). /// /////////////////////////////////////////////////////////////////////////////////////////////////// @@ -17,31 +30,40 @@ #define CBSDK_CMP_PARSER_H #include +#include #include #include #include -#include namespace cbsdk { -/// Key for CMP position lookup: (bank, term) packed into uint64_t -/// bank is the absolute bank index (1-based, matching chaninfo.bank) -/// term is the terminal index (0-based, matching chaninfo.term) -inline uint64_t cmpKey(uint32_t bank, uint32_t term) { - return (static_cast(bank) << 32) | static_cast(term); -} +/// One parsed CMP entry, ready to apply to an absolute channel. +struct CmpEntry { + std::array position; ///< {col, row, bank_letter_idx, electrode} + std::string label; ///< prefixed label (e.g. "hs1-chan12") +}; -/// Map from (bank, term) key to position[4] = {col, row, bank_idx, electrode} -using CmpPositionMap = std::unordered_map>; +/// Map from 1-based absolute channel ID to parsed entry. +using CmpEntries = std::unordered_map; -/// Parse a CMP file and return position data keyed by (absolute_bank, term). +/// Parse a CMP file and assign each row an absolute channel ID. +/// +/// Rows are sorted by (bank_letter, electrode) and the Nth sorted row is +/// assigned @c chan_id = @c start_chan + N. Labels get an "hs{hs_id}-" +/// prefix. /// -/// @param filepath Path to the .cmp file -/// @param bank_offset Offset added to bank indices from the file. -/// CMP bank letter A becomes absolute bank (1 + bank_offset). -/// Use 0 for port 1, 4 for port 2 (if 4 banks per port), etc. -/// @return Map of (bank, term) -> position[4], or error message -cbutil::Result parseCmpFile(const std::string& filepath, uint32_t bank_offset = 0); +/// @param filepath Path to the .cmp file. +/// @param start_chan 1-based channel to assign the first sorted entry. +/// Typical values: 1 (single headstage), 129 (second +/// headstage of 128 channels), etc. +/// @param hs_id Headstage identifier used to prefix labels. The final +/// label is "hs{hs_id}-{original_label}". Pass 0 (the +/// default) to leave labels un-prefixed. +/// @return Map of chan_id → CmpEntry on success, or error message. +cbutil::Result parseCmpFile( + const std::string& filepath, + uint32_t start_chan = 1, + uint32_t hs_id = 0); } // namespace cbsdk diff --git a/src/cbsdk/src/sdk_session.cpp b/src/cbsdk/src/sdk_session.cpp index df3c8fc7..5938ef78 100644 --- a/src/cbsdk/src/sdk_session.cpp +++ b/src/cbsdk/src/sdk_session.cpp @@ -257,10 +257,10 @@ struct SdkSession::Impl { std::array channel_type_cache; bool channel_cache_valid = false; - // CMP (channel mapping) position overlay - // Keyed by cmpKey(bank, term) → position[4] = {col, row, bank_letter, electrode} - CmpPositionMap cmp_positions; - std::mutex cmp_mutex; // protects cmp_positions (loaded rarely, read on every CHANREP) + // CMP (channel mapping) overlay. Keyed by 1-based absolute chan_id → + // {position[4], prefixed label}. Loaded rarely, read on every CHANREP. + CmpEntries cmp_entries; + std::mutex cmp_mutex; void rebuildChannelTypeCache() { for (uint32_t ch = 0; ch < cbMAXCHANS; ++ch) { @@ -345,36 +345,34 @@ struct SdkSession::Impl { // (e.g., during the handshake phase in start()). std::atomic shutting_down{false}; - /// Apply CMP position overlay to a CHANREP packet's channel in device_config. - /// Called from the receive path when a CHANREP is received. - /// Apply CMP positions to all existing chaninfo entries. - /// Called after loading a CMP file. Writes only to shmem. + /// Apply CMP overlay (position + label) to all existing chaninfo entries. + /// Called after loading a CMP file. Writes the updated chaninfo to shmem. + /// Label push to the device is done separately by SdkSession::loadChannelMap. void applyCmpToAllChannels() { std::lock_guard lock(cmp_mutex); - if (cmp_positions.empty()) return; + if (cmp_entries.empty()) return; + + for (const auto& [chan_id, entry] : cmp_entries) { + if (chan_id < 1 || chan_id > cbMAXCHANS) continue; - for (uint32_t ch = 0; ch < cbMAXCHANS; ++ch) { // Snapshot chaninfo to avoid racing with the receive thread cbPKT_CHANINFO ci{}; bool valid = false; if (device_session) { - const auto* p = device_session->getChanInfo(ch + 1); + const auto* p = device_session->getChanInfo(chan_id); if (p && p->chan > 0) { ci = *p; valid = true; } } else if (shmem_session) { - auto r = shmem_session->getChanInfo(ch); + auto r = shmem_session->getChanInfo(chan_id - 1); if (r.isOk() && r.value().chan > 0) { ci = r.value(); valid = true; } } if (!valid) continue; - // Apply position - uint64_t key = cmpKey(ci.bank, ci.term); - auto it = cmp_positions.find(key); - if (it == cmp_positions.end()) continue; - std::memcpy(ci.position, it->second.data(), sizeof(int32_t) * 4); + std::memcpy(ci.position, entry.position.data(), sizeof(int32_t) * 4); + std::strncpy(ci.label, entry.label.c_str(), sizeof(ci.label) - 1); + ci.label[sizeof(ci.label) - 1] = '\0'; - // Write to shmem (thread-safe) - if (shmem_session && ci.chan >= 1 && ci.chan <= cbMAXCHANS) { - shmem_session->setChanInfo(ci.chan - 1, ci); + if (shmem_session) { + shmem_session->setChanInfo(chan_id - 1, ci); } } } @@ -904,15 +902,21 @@ Result SdkSession::start() { } if ((pkt.cbpkt_header.type & 0xF0) == cbPKTTYPE_CHANREP) { auto chaninfo_copy = *reinterpret_cast(&pkt); - // Apply CMP position overlay before writing to shmem - // so positions are never lost to a race. + // Apply CMP overlay (position + label) before writing to + // shmem so locally-supplied geometry and labels survive + // even when the device sends a fresh CHANREP. { std::lock_guard lock(impl->cmp_mutex); - if (!impl->cmp_positions.empty()) { - uint64_t key = cmpKey(chaninfo_copy.bank, chaninfo_copy.term); - auto it = impl->cmp_positions.find(key); - if (it != impl->cmp_positions.end()) { - std::memcpy(chaninfo_copy.position, it->second.data(), sizeof(int32_t) * 4); + if (!impl->cmp_entries.empty()) { + auto it = impl->cmp_entries.find(chaninfo_copy.chan); + if (it != impl->cmp_entries.end()) { + std::memcpy(chaninfo_copy.position, + it->second.position.data(), + sizeof(int32_t) * 4); + std::strncpy(chaninfo_copy.label, + it->second.label.c_str(), + sizeof(chaninfo_copy.label) - 1); + chaninfo_copy.label[sizeof(chaninfo_copy.label) - 1] = '\0'; } } } @@ -2067,23 +2071,43 @@ static void extractFromNativeConfig(const cbshm::NativeConfigBuffer& native, cbC // Channel Mapping (CMP) Files /////////////////////////////////////////////////////////////////////////////////////////////////// -Result SdkSession::loadChannelMap(const std::string& filepath, uint32_t bank_offset) { - auto parse_result = parseCmpFile(filepath, bank_offset); +Result SdkSession::loadChannelMap( + const std::string& filepath, uint32_t start_chan, uint32_t hs_id) { + auto parse_result = parseCmpFile(filepath, start_chan, hs_id); if (parse_result.isError()) { return Result::error(parse_result.error()); } - // Merge parsed positions into our map (allows multiple loadChannelMap calls for multi-port) + // Snapshot labels before moving entries into the overlay map — we + // need a stable list to push to the device outside the cmp_mutex. + std::vector> labels_to_push; { std::lock_guard lock(m_impl->cmp_mutex); - for (auto& [key, pos] : parse_result.value()) { - m_impl->cmp_positions[key] = pos; + for (auto& [chan_id, entry] : parse_result.value()) { + labels_to_push.emplace_back(chan_id, entry.label); + m_impl->cmp_entries[chan_id] = std::move(entry); } } - // Apply positions to all existing chaninfo entries + // Apply positions + labels to shmem for any chaninfo already present. m_impl->applyCmpToAllChannels(); + // Push labels to the device so they persist in chaninfo and are echoed + // back in future CHANREP packets. Positions aren't persisted by the + // device, so no analogous push for position. + if (m_impl->device_session || m_impl->shmem_session) { + for (const auto& [chan_id, label] : labels_to_push) { + const cbPKT_CHANINFO* info = getChanInfo(chan_id); + if (!info) continue; + cbPKT_CHANINFO ci = *info; + ci.chan = chan_id; + ci.cbpkt_header.type = cbPKTTYPE_CHANSETLABEL; + std::strncpy(ci.label, label.c_str(), sizeof(ci.label) - 1); + ci.label[sizeof(ci.label) - 1] = '\0'; + (void)setChannelConfig(ci); // best-effort; per-channel failures shouldn't abort + } + } + return Result::ok(); } diff --git a/tests/128ChannelManufacturerMapping.cmp b/tests/128ChannelManufacturerMapping.cmp new file mode 100644 index 00000000..901a525a --- /dev/null +++ b/tests/128ChannelManufacturerMapping.cmp @@ -0,0 +1,142 @@ +// Auto generated mapfile +// +// legend +// col - 0 based column from left to right +// row - 0 based row from bottom to top +// bank - bank name - values can be A B C or D +// elec - 1 based electrode number within the bank - values can be 1-32 +// label - label used to rename channels in Central (optional) +// +// Comments begin with // +// First non-comment line is the Mapfile description +// +128-channel manufacturer mapping (sanitized sample) +//col row bank elec label +0 18 C 1 elec2-128 +0 16 C 3 elec2-127 +0 14 C 5 elec2-126 +0 12 C 7 elec2-125 +1 19 C 9 elec2-124 +1 18 C 11 elec2-123 +1 17 C 13 elec2-122 +1 16 C 15 elec2-121 +1 15 C 2 elec2-120 +1 14 C 4 elec2-119 +1 13 C 6 elec2-118 +2 20 C 8 elec2-117 +2 19 C 10 elec2-116 +2 18 C 12 elec2-115 +2 17 C 14 elec2-114 +2 16 C 16 elec2-113 +2 15 C 17 elec2-112 +2 14 C 18 elec2-111 +2 13 C 20 elec2-110 +2 12 C 19 elec2-109 +3 19 C 22 elec2-108 +3 18 C 21 elec2-107 +3 17 C 23 elec2-106 +3 16 C 24 elec2-105 +3 15 C 25 elec2-104 +3 14 C 26 elec2-103 +3 13 C 27 elec2-102 +4 20 C 28 elec2-101 +4 19 C 30 elec2-100 +4 18 C 29 elec2-99 +4 17 C 32 elec2-98 +4 16 C 31 elec2-97 +4 15 B 1 elec2-96 +4 14 B 3 elec2-95 +4 13 B 5 elec2-94 +4 12 B 7 elec2-93 +5 19 B 9 elec2-92 +5 18 B 13 elec2-91 +5 17 B 2 elec2-90 +5 16 B 4 elec2-89 +5 15 B 6 elec2-88 +5 14 B 8 elec2-87 +5 13 B 11 elec2-86 +6 20 B 15 elec2-85 +6 19 B 17 elec2-84 +6 18 B 16 elec2-83 +6 17 B 10 elec2-82 +6 16 B 12 elec2-81 +6 15 B 19 elec2-80 +6 14 B 21 elec2-79 +6 13 B 18 elec2-78 +6 12 B 14 elec2-77 +7 19 B 24 elec2-76 +7 18 B 23 elec2-75 +7 17 B 22 elec2-74 +7 16 B 20 elec2-73 +7 15 B 26 elec2-72 +7 14 B 25 elec2-71 +7 13 B 30 elec2-70 +8 20 B 28 elec2-69 +8 18 B 27 elec2-68 +8 16 B 32 elec2-67 +8 14 B 29 elec2-66 +8 12 B 31 elec2-65 +0 6 A 2 elec1-64 +0 4 A 1 elec1-63 +0 2 A 3 elec1-62 +0 0 A 4 elec1-61 +1 7 A 7 elec1-60 +1 6 A 5 elec1-59 +1 5 A 6 elec1-58 +1 4 A 9 elec1-57 +1 3 A 17 elec1-56 +1 2 A 15 elec1-55 +1 1 A 8 elec1-54 +2 8 A 11 elec1-53 +2 7 A 13 elec1-52 +2 6 A 19 elec1-51 +2 5 A 10 elec1-50 +2 4 A 12 elec1-49 +2 3 A 23 elec1-48 +2 2 A 25 elec1-47 +2 1 A 21 elec1-46 +2 0 A 14 elec1-45 +3 7 A 16 elec1-44 +3 6 A 20 elec1-43 +3 5 A 27 elec1-42 +3 4 A 29 elec1-41 +3 3 A 31 elec1-40 +3 2 A 18 elec1-39 +3 1 A 22 elec1-38 +4 8 A 24 elec1-37 +4 7 A 26 elec1-36 +4 6 A 28 elec1-35 +4 5 A 30 elec1-34 +4 4 A 32 elec1-33 +4 3 D 1 elec1-32 +4 2 D 3 elec1-31 +4 1 D 2 elec1-30 +4 0 D 4 elec1-29 +5 7 D 5 elec1-28 +5 6 D 6 elec1-27 +5 5 D 7 elec1-26 +5 4 D 8 elec1-25 +5 3 D 9 elec1-24 +5 2 D 10 elec1-23 +5 1 D 11 elec1-22 +6 8 D 12 elec1-21 +6 7 D 13 elec1-20 +6 6 D 14 elec1-19 +6 5 D 15 elec1-18 +6 4 D 17 elec1-17 +6 3 D 16 elec1-16 +6 2 D 19 elec1-15 +6 1 D 21 elec1-14 +6 0 D 23 elec1-13 +7 7 D 25 elec1-12 +7 6 D 27 elec1-11 +7 5 D 29 elec1-10 +7 4 D 31 elec1-9 +7 3 D 32 elec1-8 +7 2 D 18 elec1-7 +7 1 D 20 elec1-6 +8 8 D 22 elec1-5 +8 6 D 24 elec1-4 +8 4 D 26 elec1-3 +8 2 D 28 elec1-2 +8 0 D 30 elec1-1 diff --git a/tests/integration/CMakeLists.txt b/tests/integration/CMakeLists.txt index e4db5552..b09a5ead 100644 --- a/tests/integration/CMakeLists.txt +++ b/tests/integration/CMakeLists.txt @@ -130,8 +130,10 @@ if(NPLAY_AVAILABLE) endif() endif() -# CMP file from the repo's test fixtures -set(NPLAY_CMP_FILE "${PROJECT_SOURCE_DIR}/tests/96ChannelDefaultMapping.cmp") +# CMP file from the repo's test fixtures. Use the sanitized 128-ch +# manufacturer sample so integration tests exercise out-of-order rows +# and the real manufacturer's bank ordering (not the sorted defaults). +set(NPLAY_CMP_FILE "${PROJECT_SOURCE_DIR}/tests/128ChannelManufacturerMapping.cmp") ############################################################################### # Integration test executables diff --git a/tests/integration/test_capi_configuration.cpp b/tests/integration/test_capi_configuration.cpp index 1cc121db..6201b406 100644 --- a/tests/integration/test_capi_configuration.cpp +++ b/tests/integration/test_capi_configuration.cpp @@ -464,11 +464,11 @@ TEST_F(CApiCMPTest, LoadChannelMap) { #endif if (cmp.empty()) GTEST_SKIP() << "No CMP test file available"; - EXPECT_EQ(cbsdk_session_load_channel_map(sg.session, cmp.c_str(), 0), + EXPECT_EQ(cbsdk_session_load_channel_map(sg.session, cmp.c_str(), 1, 1), CBSDK_RESULT_SUCCESS); } -TEST_F(CApiCMPTest, LoadChannelMapWithBankOffset) { +TEST_F(CApiCMPTest, LoadChannelMapSecondHeadstage) { SessionGuard sg; ASSERT_TRUE(sg.create()); @@ -478,7 +478,8 @@ TEST_F(CApiCMPTest, LoadChannelMapWithBankOffset) { #endif if (cmp.empty()) GTEST_SKIP() << "No CMP test file available"; - EXPECT_EQ(cbsdk_session_load_channel_map(sg.session, cmp.c_str(), 4), + // Apply the same CMP to channels 129.. with a different headstage id. + EXPECT_EQ(cbsdk_session_load_channel_map(sg.session, cmp.c_str(), 129, 2), CBSDK_RESULT_SUCCESS); } diff --git a/tests/integration/test_sdk_configuration.cpp b/tests/integration/test_sdk_configuration.cpp index 299c0500..d80d1860 100644 --- a/tests/integration/test_sdk_configuration.cpp +++ b/tests/integration/test_sdk_configuration.cpp @@ -411,14 +411,15 @@ TEST_F(CMPTest, LoadChannelMap) { EXPECT_TRUE(load_result.isOk()) << load_result.error(); } -TEST_F(CMPTest, LoadChannelMapWithBankOffset) { +TEST_F(CMPTest, LoadChannelMapSecondHeadstage) { std::string cmp = getCmpPath(); if (cmp.empty()) GTEST_SKIP() << "No CMP test file available"; auto result = createNPlaySession(); ASSERT_TRUE(result.isOk()) << result.error(); - auto load_result = result.value().loadChannelMap(cmp, 4); + // Map the same CMP to channels starting at 129 with hs_id=2. + auto load_result = result.value().loadChannelMap(cmp, 129, 2); EXPECT_TRUE(load_result.isOk()) << load_result.error(); } diff --git a/tests/unit/test_cmp_parser.cpp b/tests/unit/test_cmp_parser.cpp index 34635704..c38cfc46 100644 --- a/tests/unit/test_cmp_parser.cpp +++ b/tests/unit/test_cmp_parser.cpp @@ -4,6 +4,12 @@ /////////////////////////////////////////////////////////////////////////////////////////////////// #include + +#include +#include +#include +#include + #include "cmp_parser.h" #ifndef CMP_TEST_DATA_DIR @@ -18,102 +24,106 @@ TEST(CmpParser, Parse8Channel) { auto result = cbsdk::parseCmpFile(testFile("8ChannelDefaultMapping.cmp")); ASSERT_TRUE(result.isOk()) << result.error(); - const auto& positions = result.value(); - // 8 channels: bank A, electrodes 1-8 - EXPECT_EQ(positions.size(), 8u); - - // First entry: 0 1 A 1 chan1 → bank=1(A), term=0(electrode 1-1) - auto it = positions.find(cbsdk::cmpKey(1, 0)); - ASSERT_NE(it, positions.end()); - EXPECT_EQ(it->second[0], 0); // col - EXPECT_EQ(it->second[1], 1); // row - EXPECT_EQ(it->second[2], 1); // bank_letter (A=1) - EXPECT_EQ(it->second[3], 1); // electrode - - // Last entry: 3 0 A 8 chan8 → bank=1(A), term=7(electrode 8-1) - it = positions.find(cbsdk::cmpKey(1, 7)); - ASSERT_NE(it, positions.end()); - EXPECT_EQ(it->second[0], 3); // col - EXPECT_EQ(it->second[1], 0); // row - EXPECT_EQ(it->second[2], 1); // bank_letter (A=1) - EXPECT_EQ(it->second[3], 8); // electrode + const auto& entries = result.value(); + EXPECT_EQ(entries.size(), 8u); + + // Defaults: start_chan=1, hs_id=0 → chans 1..8, no label prefix. + std::set chans; + for (const auto& [chan_id, _] : entries) chans.insert(chan_id); + EXPECT_EQ(chans, std::set({1, 2, 3, 4, 5, 6, 7, 8})); + + // hs_id=0 leaves labels un-prefixed. + for (const auto& [_, entry] : entries) { + EXPECT_NE(entry.label.substr(0, 2), "hs"); + } +} + +TEST(CmpParser, HsIdZeroLeavesLabelsUnprefixed) { + // Same file, default hs_id=0 vs explicit hs_id=3, comparing stripped labels. + auto bare = cbsdk::parseCmpFile(testFile("8ChannelDefaultMapping.cmp")); + auto with_hs = cbsdk::parseCmpFile(testFile("8ChannelDefaultMapping.cmp"), 1, 3); + ASSERT_TRUE(bare.isOk()); + ASSERT_TRUE(with_hs.isOk()); + + for (const auto& [chan_id, prefixed] : with_hs.value()) { + const auto it = bare.value().find(chan_id); + ASSERT_NE(it, bare.value().end()); + EXPECT_EQ(prefixed.label, "hs3-" + it->second.label); + } } TEST(CmpParser, Parse128Channel) { auto result = cbsdk::parseCmpFile(testFile("128ChannelDefaultMapping.cmp")); ASSERT_TRUE(result.isOk()) << result.error(); - const auto& positions = result.value(); - EXPECT_EQ(positions.size(), 128u); - - // First entry: 0 7 A 1 → bank=1, term=0 - auto it = positions.find(cbsdk::cmpKey(1, 0)); - ASSERT_NE(it, positions.end()); - EXPECT_EQ(it->second[0], 0); // col - EXPECT_EQ(it->second[1], 7); // row - - // Bank B entry: 0 5 B 1 chan33 → bank=2, term=0 - it = positions.find(cbsdk::cmpKey(2, 0)); - ASSERT_NE(it, positions.end()); - EXPECT_EQ(it->second[0], 0); // col - EXPECT_EQ(it->second[1], 5); // row - EXPECT_EQ(it->second[2], 2); // bank_letter (B=2) - - // Bank D, last electrode: 15 0 D 32 chan128 → bank=4, term=31 - it = positions.find(cbsdk::cmpKey(4, 31)); - ASSERT_NE(it, positions.end()); - EXPECT_EQ(it->second[0], 15); // col - EXPECT_EQ(it->second[1], 0); // row - EXPECT_EQ(it->second[2], 4); // bank_letter (D=4) - EXPECT_EQ(it->second[3], 32); // electrode + const auto& entries = result.value(); + EXPECT_EQ(entries.size(), 128u); + + // 128 contiguous channel IDs starting at 1. + for (uint32_t ch = 1; ch <= 128; ++ch) { + ASSERT_TRUE(entries.count(ch)) << "missing chan " << ch; + } + + // Bank layout after sort: chan 1 → (bank 1, elec 1), chan 33 → (bank 2, elec 1), + // chan 65 → (bank 3, elec 1), chan 128 → (bank 4, elec 32). + EXPECT_EQ(entries.at(1).position[2], 1); + EXPECT_EQ(entries.at(1).position[3], 1); + EXPECT_EQ(entries.at(33).position[2], 2); + EXPECT_EQ(entries.at(33).position[3], 1); + EXPECT_EQ(entries.at(128).position[2], 4); + EXPECT_EQ(entries.at(128).position[3], 32); } -TEST(CmpParser, Parse96Channel) { - auto result = cbsdk::parseCmpFile(testFile("96ChannelDefaultMapping.cmp")); +TEST(CmpParser, StartChanOffsetsChanIds) { + // Second headstage: same 96-channel CMP mapped to chans 129..224. + auto result = cbsdk::parseCmpFile( + testFile("96ChannelDefaultMapping.cmp"), /*start_chan=*/129, /*hs_id=*/2); ASSERT_TRUE(result.isOk()) << result.error(); - const auto& positions = result.value(); - EXPECT_EQ(positions.size(), 96u); + const auto& entries = result.value(); + EXPECT_EQ(entries.size(), 96u); - // 96-channel has banks A, B, C (3 banks × 32 electrodes) - // Verify bank C exists - auto it = positions.find(cbsdk::cmpKey(3, 0)); - ASSERT_NE(it, positions.end()); + std::set chans; + for (const auto& [chan_id, _] : entries) chans.insert(chan_id); + EXPECT_EQ(*chans.begin(), 129u); + EXPECT_EQ(*chans.rbegin(), 129u + 95u); - // Verify bank D does NOT exist (only 96 channels) - it = positions.find(cbsdk::cmpKey(4, 0)); - EXPECT_EQ(it, positions.end()); + for (const auto& [_, entry] : entries) { + EXPECT_EQ(entry.label.substr(0, 4), "hs2-"); + } } -TEST(CmpParser, BankOffset) { - // Load 8-channel CMP with bank_offset=4 (simulating port 2) - auto result = cbsdk::parseCmpFile(testFile("8ChannelDefaultMapping.cmp"), 4); +TEST(CmpParser, SortsOutOfOrderRows) { + // Synthesize a tiny CMP whose rows are deliberately out of (bank, elec) order. + auto path = (std::filesystem::temp_directory_path() / "cmp_unsorted_tmp.cmp").string(); + { + std::ofstream out(path); + out << "// scratch file\n" + << "unsorted test map\n" + // Rows given in reverse electrode order within bank A, then bank B first. + << "0 0 B 1 label_b1\n" + << "0 0 A 3 label_a3\n" + << "0 0 A 1 label_a1\n" + << "0 0 A 2 label_a2\n"; + } + + // Use an explicit hs_id so we exercise the prefix path here. + auto result = cbsdk::parseCmpFile(path, /*start_chan=*/1, /*hs_id=*/1); ASSERT_TRUE(result.isOk()) << result.error(); - const auto& positions = result.value(); - EXPECT_EQ(positions.size(), 8u); + const auto& entries = result.value(); + ASSERT_EQ(entries.size(), 4u); - // Bank A with offset 4 → absolute bank 5 - auto it = positions.find(cbsdk::cmpKey(5, 0)); - ASSERT_NE(it, positions.end()); - EXPECT_EQ(it->second[0], 0); // col - EXPECT_EQ(it->second[1], 1); // row - // position[2] stores the original bank letter index (A=1), not the offset bank - EXPECT_EQ(it->second[2], 1); + // Sort puts (A,1), (A,2), (A,3), (B,1) at chans 1..4. + EXPECT_EQ(entries.at(1).label, "hs1-label_a1"); + EXPECT_EQ(entries.at(2).label, "hs1-label_a2"); + EXPECT_EQ(entries.at(3).label, "hs1-label_a3"); + EXPECT_EQ(entries.at(4).label, "hs1-label_b1"); - // Original bank 1 should NOT have entries - it = positions.find(cbsdk::cmpKey(1, 0)); - EXPECT_EQ(it, positions.end()); + std::filesystem::remove(path); } TEST(CmpParser, NonexistentFile) { auto result = cbsdk::parseCmpFile("/nonexistent/path.cmp"); EXPECT_TRUE(result.isError()); } - -TEST(CmpParser, CmpKeyUniqueness) { - // Verify different (bank, term) pairs produce different keys - EXPECT_NE(cbsdk::cmpKey(1, 0), cbsdk::cmpKey(2, 0)); - EXPECT_NE(cbsdk::cmpKey(1, 0), cbsdk::cmpKey(1, 1)); - EXPECT_EQ(cbsdk::cmpKey(3, 15), cbsdk::cmpKey(3, 15)); -}