bidsreader 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {bidsreader-0.2.0 → bidsreader-0.3.0}/PKG-INFO +3 -3
  2. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/__init__.py +1 -2
  3. bidsreader-0.3.0/bidsreader/_neurorad_algo.py +81 -0
  4. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/basereader.py +47 -0
  5. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/cmlbidsreader.py +167 -38
  6. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/helpers.py +42 -9
  7. bidsreader-0.3.0/bidsreader/region_enrichment.py +94 -0
  8. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/PKG-INFO +3 -3
  9. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/SOURCES.txt +5 -1
  10. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/requires.txt +2 -2
  11. {bidsreader-0.2.0 → bidsreader-0.3.0}/pyproject.toml +3 -3
  12. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_cmlbidsreader.py +17 -3
  13. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_helpers.py +69 -7
  14. bidsreader-0.3.0/tests/test_neurorad_algo.py +113 -0
  15. bidsreader-0.3.0/tests/test_pair_midpoint_parity.py +137 -0
  16. bidsreader-0.3.0/tests/test_region_enrichment.py +111 -0
  17. bidsreader-0.2.0/bidsreader/public_helpers.py +0 -17
  18. {bidsreader-0.2.0 → bidsreader-0.3.0}/.github/workflows/publish.yml +0 -0
  19. {bidsreader-0.2.0 → bidsreader-0.3.0}/.github/workflows/test.yml +0 -0
  20. {bidsreader-0.2.0 → bidsreader-0.3.0}/.gitignore +0 -0
  21. {bidsreader-0.2.0 → bidsreader-0.3.0}/LICENSE +0 -0
  22. {bidsreader-0.2.0 → bidsreader-0.3.0}/README.md +0 -0
  23. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/_errorwrap.py +0 -0
  24. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/convert.py +0 -0
  25. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/exc.py +0 -0
  26. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/filtering.py +0 -0
  27. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/units.py +0 -0
  28. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/dependency_links.txt +0 -0
  29. {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/top_level.txt +0 -0
  30. {bidsreader-0.2.0 → bidsreader-0.3.0}/setup.cfg +0 -0
  31. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/__init__.py +0 -0
  32. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/conftest.py +0 -0
  33. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_basereader.py +0 -0
  34. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_convert.py +0 -0
  35. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_errorwrap.py +0 -0
  36. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_exc.py +0 -0
  37. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_filtering.py +0 -0
  38. {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_units.py +0 -0
  39. {bidsreader-0.2.0 → bidsreader-0.3.0}/tutorials/bidsreader_tutorial.ipynb +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bidsreader
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Data loader and file reader for the OpenBIDS format
5
5
  Author: Computational Memory Lab
6
6
  License-Expression: MIT
@@ -19,8 +19,8 @@ Requires-Python: >=3.10
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: mne>=1.0
22
- Requires-Dist: mne-bids>=0.14
23
- Requires-Dist: numpy>=1.23
22
+ Requires-Dist: mne-bids>=0.12
23
+ Requires-Dist: numpy<2.0,>=1.23
24
24
  Requires-Dist: pandas>=1.5
25
25
  Provides-Extra: ptsa
26
26
  Requires-Dist: ptsa; extra == "ptsa"
@@ -8,9 +8,8 @@ from .filtering import (
8
8
  )
9
9
  from .convert import mne_epochs_to_ptsa, mne_raw_to_ptsa
10
10
  from .units import detect_unit, get_scale_factor, convert_unit
11
- from .public_helpers import get_data_index
12
11
  from collections import namedtuple
13
12
 
14
- __version__ = "0.2.0"
13
+ __version__ = "0.3.0"
15
14
  version_info = namedtuple("VersionInfo", "major,minor,patch")(
16
15
  *__version__.split('.'))
@@ -0,0 +1,81 @@
1
+ """Pair-location algorithms copied verbatim from the CML neurorad pipeline.
2
+
3
+ Source of truth:
4
+ github.com/pennmem/neurorad_pipeline
5
+ File: localization.py :: Localization.get_pair_coordinate
6
+ Pinned revision (on rhino2): cf563def3087126b1aedb1dea274172200f3fa75
7
+ Path checked: /home2/iped/neurorad_pipeline/localization.py
8
+
9
+ We copy rather than import to keep neurorad_pipeline off the runtime
10
+ dependency list. A test in tests/test_pair_midpoint_parity.py imports
11
+ neurorad_pipeline as a dev-only dependency and pins this copy against
12
+ the upstream implementation.
13
+
14
+ When the upstream algorithm changes, update this file AND the pinned
15
+ revision in the parity test's docstring in the same commit.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Dict, Tuple
21
+
22
+ import numpy as np
23
+
24
+
25
+ # Mirrors Localization.VALID_COORDINATE_SPACES (localization.py:13-19).
26
+ VALID_COORDINATE_SPACES: Tuple[str, ...] = (
27
+ "ct_voxel",
28
+ "fs",
29
+ "t1_mri",
30
+ "t2_mri",
31
+ "mni",
32
+ )
33
+
34
+ # Mirrors Localization.VALID_COORDINATE_TYPES (localization.py:21-24).
35
+ VALID_COORDINATE_TYPES: Tuple[str, ...] = (
36
+ "raw",
37
+ "corrected",
38
+ )
39
+
40
+ # Mirrors Localization.VALID_ATLASES (localization.py:35-39).
41
+ VALID_ATLASES: Tuple[str, ...] = (
42
+ "dk",
43
+ "whole_brain",
44
+ "mtl",
45
+ )
46
+
47
+ # BIDS space label -> (neurorad coordinate_space, coordinate_type).
48
+ # Synthesized from event_creation/submission/neurorad_tasks.py
49
+ # FIELD_NAMES_TABLE (lines 190-198) and the bids-convert CML_TO_BIDS_SPACE
50
+ # map. Spaces not present here (Talairach, t1MRI, fsnativeDural) are
51
+ # valid midpoint targets but have no neurorad atlas lookup.
52
+ BIDS_SPACE_TO_NEURORAD: Dict[str, Tuple[str, str]] = {
53
+ "fsnative": ("fs", "raw"),
54
+ "fsnativeBrainshift": ("fs", "corrected"),
55
+ "fsaverage": ("fsaverage", "raw"),
56
+ "fsaverageBrainshift": ("fsaverage", "corrected"),
57
+ "MNI152NLin6ASym": ("mni", "raw"),
58
+ "Pixels": ("ct_voxel", "raw"),
59
+ }
60
+
61
+
62
+ def pair_coordinate(coord1, coord2):
63
+ """Midpoint of two contact coordinates.
64
+
65
+ Verbatim behavior of
66
+ neurorad_pipeline.localization.Localization.get_pair_coordinate
67
+ (localization.py:274-289), stripped of its Localization-object
68
+ plumbing so callers can pass raw arrays.
69
+ """
70
+ if coord1 is None or coord2 is None:
71
+ return None
72
+ return (np.array(coord1) + np.array(coord2)) / 2
73
+
74
+
75
+ def pair_coordinate_axis(a, b):
76
+ """Vectorized midpoint for one coordinate axis across N pairs.
77
+
78
+ NaN on either side propagates, matching the None-returning behavior
79
+ of scalar pair_coordinate. Intended for pandas/numpy column math.
80
+ """
81
+ return (np.asarray(a, dtype=float) + np.asarray(b, dtype=float)) / 2.0
@@ -1,3 +1,5 @@
1
+ import re
2
+
1
3
  import pandas as pd
2
4
  from mne_bids import BIDSPath, get_entity_vals
3
5
  from pathlib import Path
@@ -206,3 +208,48 @@ class BaseReader:
206
208
  max_ses = si if max_ses is None else max(max_ses, si)
207
209
 
208
210
  return max_ses
211
+
212
+ # ---------- data index ----------
213
+ def get_data_index(self, root: Union[str, Path] = None, task: str = None) -> pd.DataFrame:
214
+ """Scan a BIDS root for sessions matching *task* and return a
215
+ DataFrame with ``subject``, ``task``, ``session`` columns.
216
+
217
+ Parameters default to ``self.root`` and ``self.task`` when called
218
+ on an instance, but can be overridden with explicit values::
219
+
220
+ # instance — uses reader's own root / task
221
+ reader = BaseReader(root="/data/BIDS", task="FR1")
222
+ df = reader.get_data_index()
223
+
224
+ # explicit — useful without a fully configured reader
225
+ df = reader.get_data_index(root="/other/root", task="catFR1")
226
+
227
+ Subclasses can override to add file-path columns for each
228
+ expected BIDS output (see ``CMLBIDSReader.get_data_index``).
229
+ """
230
+ root = Path(root) if root is not None else self.root
231
+ task = task if task is not None else self.task
232
+ if root is None:
233
+ raise ValueError("root must be provided either on the instance or as an argument")
234
+ if task is None:
235
+ raise ValueError("task must be provided either on the instance or as an argument")
236
+
237
+ pat = re.compile(
238
+ r"sub-(?P<sub>[^_]+)_ses-(?P<ses>[^_]+)_task-" + re.escape(task),
239
+ re.IGNORECASE,
240
+ )
241
+ rows = []
242
+ for f in root.rglob(f"*task-{task}*beh.tsv"):
243
+ m = pat.search(f.name)
244
+ if m:
245
+ rows.append({
246
+ "subject": m.group("sub"),
247
+ "task": task,
248
+ "session": m.group("ses"),
249
+ })
250
+ return (
251
+ pd.DataFrame(rows)
252
+ .drop_duplicates()
253
+ .sort_values(["subject", "session"])
254
+ .reset_index(drop=True)
255
+ )
@@ -54,14 +54,32 @@ class CMLBIDSReader(BaseReader):
54
54
  return "ieeg"
55
55
  return None
56
56
 
57
- def _determine_space(self) -> str:
57
+ DEFAULT_SPACE = "MNI152NLin6ASym"
58
+ SPACE_PREFERENCE = ("MNI152NLin6ASym", "Talairach", "fsaverage", "Pixels", "fsaverageBrainshift", "fsnative", "fsnativeBrainshift", "fsnativeDural", "t1MRI")
59
+
60
+ def _coordsystem_dir(self) -> Path:
58
61
  subject_root = self._subject_root()
59
- data_dir = subject_root / self._add_bids_prefix("session", self.session) / self.device
62
+ return subject_root / self._add_bids_prefix("session", self.session) / self.device
63
+
64
+ def list_available_spaces(self) -> list:
65
+ """Return the sorted list of BIDS space names present as
66
+ *_coordsystem.json files for this session."""
67
+ data_dir = self._coordsystem_dir()
68
+ if not data_dir.exists():
69
+ return []
70
+ spaces = []
71
+ for m in data_dir.glob("*_coordsystem.json"):
72
+ space = space_from_coordsystem_fname(m.name)
73
+ if space is not None:
74
+ spaces.append(space)
75
+ return sorted(set(spaces))
76
+
77
+ def _determine_space(self) -> str:
78
+ data_dir = self._coordsystem_dir()
60
79
 
61
80
  if not data_dir.exists():
62
81
  raise FileNotFoundBIDSError(
63
82
  f"determine_space: data directory does not exist.\n"
64
- f"subject_root={subject_root}\n"
65
83
  f"data_dir={data_dir}"
66
84
  )
67
85
 
@@ -72,22 +90,38 @@ class CMLBIDSReader(BaseReader):
72
90
  f"data_dir={data_dir}"
73
91
  )
74
92
 
75
- if len(matches) > 1:
76
- raise AmbiguousMatchError(
77
- f"determine_space: multiple coordsystem files found.\n"
78
- f"files={[m.name for m in matches]}"
79
- )
80
-
81
- fname = matches[0].name
82
- space = space_from_coordsystem_fname(fname)
83
-
84
- if space is None:
85
- raise DataParseError(
86
- f"determine_space: could not parse space from filename.\n"
87
- f"filename={fname}"
88
- )
89
-
90
- return space
93
+ spaces = []
94
+ for m in matches:
95
+ space = space_from_coordsystem_fname(m.name)
96
+ if space is None:
97
+ raise DataParseError(
98
+ f"determine_space: could not parse space from filename.\n"
99
+ f"filename={m.name}"
100
+ )
101
+ spaces.append(space)
102
+ spaces = sorted(set(spaces))
103
+
104
+ if len(spaces) == 1:
105
+ return spaces[0]
106
+
107
+ # Multiple spaces present: pick the highest-priority preferred space
108
+ # that is available.
109
+ for preferred in self.SPACE_PREFERENCE:
110
+ if preferred in spaces:
111
+ return preferred
112
+
113
+ # None of the preferred defaults is present. Rather than make the
114
+ # caller pass space= explicitly, fall back to a default: DEFAULT_SPACE
115
+ # if it happens to be available, otherwise the first space (sorted).
116
+ chosen = self.DEFAULT_SPACE if self.DEFAULT_SPACE in spaces else spaces[0]
117
+ warnings.warn(
118
+ f"determine_space: multiple spaces found and none of the "
119
+ f"preferred defaults {self.SPACE_PREFERENCE} present. "
120
+ f"Available spaces: {spaces}. Defaulting to {chosen!r}. "
121
+ f"Pass space=<one of these> to choose explicitly.",
122
+ RuntimeWarning,
123
+ )
124
+ return chosen
91
125
 
92
126
  def _validate_acq(self, acquisition: Optional[str]) -> Optional[str]:
93
127
  if not self.is_intracranial():
@@ -99,10 +133,10 @@ class CMLBIDSReader(BaseReader):
99
133
  def _get_needed_fields(self):
100
134
  return self.INTRACRANIAL_FIELDS if self.is_intracranial() else self.SCALP_FIELDS
101
135
 
102
- def _attach_bipolar_midpoint_montage(self, raw: mne.io.BaseRaw) -> None:
136
+ def _attach_bipolar_midpoint_montage(self, raw: mne.io.BaseRaw, space: Optional[str] = None) -> None:
103
137
  pairs_df = self.load_channels("bipolar")
104
- elec_df = self.load_electrodes()
105
- combo = combine_bipolar_electrodes(pairs_df, elec_df)
138
+ elec_df = self.load_electrodes(space=space)
139
+ combo = combine_bipolar_electrodes(pairs_df, elec_df, space=self.space)
106
140
 
107
141
  if not {"name", "x_mid", "y_mid", "z_mid"}.issubset(combo.columns):
108
142
  return
@@ -145,13 +179,25 @@ class CMLBIDSReader(BaseReader):
145
179
 
146
180
  return pd.read_csv(matches[0].fpath, sep="\t")
147
181
 
182
+ def _space_file(self, space: str, suffix: str, extension: str) -> Path:
183
+ """Build a space-<label>_<suffix>.<ext> path manually, bypassing
184
+ mne_bids.BIDSPath validation (which rejects non-standard space
185
+ labels like 'fsnative', 'fsnativeDural', 'fsaverageBrainshift')."""
186
+ data_dir = self._coordsystem_dir()
187
+ task_part = f"_task-{self.task}" if self.is_intracranial() and self.task else ""
188
+ fname = (
189
+ f"sub-{self.subject}_"
190
+ f"ses-{self.session}"
191
+ f"{task_part}"
192
+ f"_space-{space}_{suffix}{extension}"
193
+ )
194
+ return data_dir / fname
195
+
148
196
  @public_api
149
- def load_electrodes(self) -> pd.DataFrame:
197
+ def load_electrodes(self, space: Optional[str] = None) -> pd.DataFrame:
150
198
  self._require(self._get_needed_fields(), context="load_electrodes")
151
-
152
- _task = self.task if self.is_intracranial() else None
153
- bp = self._bp(datatype=self.device, suffix="electrodes", space=self.space, task=_task, extension=".tsv")
154
- return pd.read_csv(bp.fpath, sep="\t")
199
+ _space = space if space is not None else self.space
200
+ return pd.read_csv(self._space_file(_space, "electrodes", ".tsv"), sep="\t")
155
201
 
156
202
  @public_api
157
203
  def load_channels(self, acquisition: Optional[str] = None) -> pd.DataFrame:
@@ -162,28 +208,25 @@ class CMLBIDSReader(BaseReader):
162
208
  return pd.read_csv(bp.fpath, sep="\t")
163
209
 
164
210
  @public_api
165
- def load_combined_channels(self, acquisition: Optional[str] = None) -> pd.DataFrame:
211
+ def load_combined_channels(self, acquisition: Optional[str] = None, space: Optional[str] = None) -> pd.DataFrame:
166
212
  self._require(self._get_needed_fields(), context="load_combined_channels")
167
213
 
168
214
  channel_df = self.load_channels(acquisition)
169
- elec_df = self.load_electrodes()
215
+ elec_df = self.load_electrodes(space=space)
170
216
  if acquisition == "monopolar" or acquisition is None:
171
217
  return channel_df.merge(elec_df, on="name", how="left", suffixes=("", "_elec"))
172
218
  if acquisition == "bipolar":
173
- return combine_bipolar_electrodes(channel_df, elec_df)
219
+ return combine_bipolar_electrodes(channel_df, elec_df, space=self.space)
174
220
 
175
221
  @public_api
176
- def load_coordsystem_desc(self) -> Dict:
222
+ def load_coordsystem_desc(self, space: Optional[str] = None) -> Dict:
177
223
  self._require(self._get_needed_fields(), context="load_coordsystem")
178
-
179
- _task = self.task if self.is_intracranial() else None
180
- bp = self._bp(datatype=self.device, suffix="coordsystem", space=self.space, task=_task, extension=".json")
181
-
182
- with open(bp.fpath, "r") as f:
224
+ _space = space if space is not None else self.space
225
+ with open(self._space_file(_space, "coordsystem", ".json"), "r") as f:
183
226
  return json.load(f)
184
227
 
185
228
  @public_api
186
- def load_raw(self, acquisition: Optional[str] = None) -> mne.io.BaseRaw:
229
+ def load_raw(self, acquisition: Optional[str] = None, extension: Optional[str] = None) -> mne.io.BaseRaw:
187
230
  self._require(self._get_needed_fields(), context="load_raw")
188
231
 
189
232
  acq = self._validate_acq(acquisition)
@@ -193,6 +236,15 @@ class CMLBIDSReader(BaseReader):
193
236
  bp_kwargs["acquisition"] = acq
194
237
  bp = self._bp(**bp_kwargs)
195
238
 
239
+ if extension is None:
240
+ for ext in (".bdf", ".edf", ".vhdr", ".set", ".nwb", ".fif"):
241
+ candidate = bp.copy().update(suffix=self.device, extension=ext).fpath
242
+ if candidate.exists():
243
+ bp = bp.copy().update(suffix=self.device, extension=ext)
244
+ break
245
+ else :
246
+ bp = bp.copy().update(suffix=self.device, extension=extension)
247
+
196
248
  with warnings.catch_warnings():
197
249
  warnings.filterwarnings(
198
250
  "ignore",
@@ -204,6 +256,16 @@ class CMLBIDSReader(BaseReader):
204
256
  message=r".*is not an MNE-Python coordinate frame.*",
205
257
  category=RuntimeWarning,
206
258
  )
259
+ warnings.filterwarnings(
260
+ "ignore",
261
+ message=r"Expected to find a single (electrodes\.tsv|coordsystem\.json) file.*",
262
+ category=RuntimeWarning,
263
+ )
264
+ warnings.filterwarnings(
265
+ "ignore",
266
+ message=r"participants\.tsv file not found.*",
267
+ category=RuntimeWarning,
268
+ )
207
269
  raw = read_raw_bids(bp)
208
270
 
209
271
  if self.is_intracranial() and acq == "bipolar":
@@ -222,9 +284,10 @@ class CMLBIDSReader(BaseReader):
222
284
  event_repeated: str = "merge",
223
285
  channels: Optional[Iterable[str]] = None,
224
286
  preload: bool = False,
287
+ extension: Optional[str] = None
225
288
  ) -> mne.Epochs:
226
289
  self._require(self._get_needed_fields(), context="load_epochs")
227
- raw = self.load_raw(acquisition=acquisition)
290
+ raw = self.load_raw(acquisition=acquisition, extension=extension)
228
291
 
229
292
  all_events_raw, all_event_id = mne.events_from_annotations(raw)
230
293
 
@@ -267,3 +330,69 @@ class CMLBIDSReader(BaseReader):
267
330
  event_repeated=event_repeated,
268
331
  picks=picks,
269
332
  )
333
+
334
+ # ---------- data index ----------
335
+ def get_data_index(self, root: Union[str, Path] = None, task: str = None) -> pd.DataFrame:
336
+ """Scan a BIDS root and return a session-level DataFrame with
337
+ file-path columns for each major BIDS output.
338
+
339
+ Parameters default to ``self.root`` and ``self.task`` when called
340
+ on an instance, but can be overridden with explicit values.
341
+
342
+ Extends ``BaseReader.get_data_index`` (subject / task / session)
343
+ with columns whose values are the file path if the file exists,
344
+ or ``None`` if it doesn't:
345
+
346
+ - ``beh`` — behavioral events TSV
347
+ - ``eeg`` — scalp EEG recording (edf/bdf/set/vhdr)
348
+ - ``mono_ieeg`` — monopolar iEEG recording (edf/bdf)
349
+ - ``bi_ieeg`` — bipolar iEEG recording (edf/bdf)
350
+ - ``mono_channels`` — monopolar channels TSV
351
+ - ``bi_channels`` — bipolar channels TSV
352
+ - ``eeg_events`` / ``ieeg_events`` — device-level events TSV
353
+ - ``electrodes`` — electrodes TSV
354
+ - ``coordsystem`` — coordinate system JSON
355
+ """
356
+ root = root if root is not None else self.root
357
+ task = task if task is not None else self.task
358
+ df = super().get_data_index(root, task)
359
+ if df.empty:
360
+ return df
361
+
362
+ root = Path(root)
363
+ # File patterns to search for. Each entry is (column_name, subdir,
364
+ # glob_pattern). Patterns use {pfx} for the BIDS filename prefix.
365
+ _PATTERNS = [
366
+ ("beh", "beh", "{pfx}_beh.tsv"),
367
+ ("eeg", "eeg", "{pfx}_eeg.*"),
368
+ ("mono_ieeg", "ieeg", "{pfx}_acq-monopolar_ieeg.*"),
369
+ ("bi_ieeg", "ieeg", "{pfx}_acq-bipolar_ieeg.*"),
370
+ ("mono_channels", "ieeg", "{pfx}_acq-monopolar_channels.tsv"),
371
+ ("bi_channels", "ieeg", "{pfx}_acq-bipolar_channels.tsv"),
372
+ ("eeg_channels", "eeg", "{pfx}_channels.tsv"),
373
+ ("ieeg_events", "ieeg", "{pfx}_events.tsv"),
374
+ ("eeg_events", "eeg", "{pfx}_events.tsv"),
375
+ ("electrodes", "ieeg", "{pfx}_*electrodes.tsv"),
376
+ ("coordsystem", "ieeg", "{pfx}_*coordsystem.json"),
377
+ ]
378
+ # Data-file extensions to accept (skip sidecars).
379
+ _DATA_EXTS = {".edf", ".bdf", ".set", ".vhdr", ".fif", ".nwb", ".mff"}
380
+
381
+ new_cols = {col: [None] * len(df) for col, _, _ in _PATTERNS}
382
+ for idx, row in df.iterrows():
383
+ sub, ses = row["subject"], row["session"]
384
+ pfx = f"sub-{sub}_ses-{ses}_task-{task}"
385
+ sess_dir = root / f"sub-{sub}" / f"ses-{ses}"
386
+
387
+ for col, subdir, pat in _PATTERNS:
388
+ glob_pat = pat.format(pfx=pfx)
389
+ matches = list((sess_dir / subdir).glob(glob_pat))
390
+ # For recording files (eeg/ieeg), keep only data files.
391
+ if col in ("eeg", "mono_ieeg", "bi_ieeg"):
392
+ matches = [m for m in matches if m.suffix in _DATA_EXTS]
393
+ if matches:
394
+ new_cols[col][idx] = str(matches[0])
395
+
396
+ for col, _, _ in _PATTERNS:
397
+ df[col] = new_cols[col]
398
+ return df
@@ -1,8 +1,27 @@
1
+ """Utility helpers for bidsreader.
2
+
3
+ Pair-coordinate math (``combine_bipolar_electrodes``) uses
4
+ ``pair_coordinate_axis`` from :mod:`._neurorad_algo`, which is a
5
+ verbatim copy of the CML neurorad pipeline's
6
+ ``Localization.get_pair_coordinate``. The rule is the same in every
7
+ BIDS coordinate space we support — ``MNI152NLin6ASym``, ``Talairach``,
8
+ ``fsaverage``, ``fsaverageBrainshift``, ``fsnative``,
9
+ ``fsnativeBrainshift``, ``fsnativeDural``, ``t1MRI``, ``Pixels`` —
10
+ because those per-space contact coordinates have already been
11
+ produced by the pipeline's space-specific transforms before BIDS
12
+ export.
13
+
14
+ For the BIDS space ↔ CML ``coordinate_space`` / ``coordinate_type``
15
+ mapping see ``eeg_validation.preparers.montage.CML_TO_BIDS_SPACE`` or
16
+ :attr:`bidsreader._neurorad_algo.BIDS_SPACE_TO_NEURORAD`.
17
+ """
18
+
1
19
  import numpy as np
2
20
  import pandas as pd
3
21
  from typing import Iterable, Any, Tuple, Sequence, Optional, Dict
4
22
  import re
5
23
  from .exc import InvalidOptionError
24
+ from ._neurorad_algo import pair_coordinate_axis
6
25
 
7
26
  def validate_option(name: str, value: Any, allowed: Iterable[Any]) -> Any:
8
27
  if value is None:
@@ -95,7 +114,23 @@ def combine_bipolar_electrodes(
95
114
  pair_col: str = "name",
96
115
  elec_name_col: str = "name",
97
116
  region_cols: Sequence[str] = ("wb.region", "ind.region", "stein.region"),
117
+ space: Optional[str] = None,
98
118
  ) -> pd.DataFrame:
119
+ """Join bipolar pairs with electrode metadata and compute per-pair
120
+ coordinate midpoints, matching the neurorad pipeline's pair-location
121
+ algorithm (``Localization.get_pair_coordinate`` — see
122
+ :mod:`bidsreader._neurorad_algo`). Midpoint is taken in whatever BIDS
123
+ space ``elec_df`` carries; upstream brainshift / nonlinear-warp
124
+ corrections are expected to have already been applied per-contact.
125
+
126
+ Region columns from ``region_cols`` are carried through per-contact
127
+ (as ``{col}_ch1`` / ``{col}_ch2``) but no ``{col}_pair`` column is
128
+ synthesized. Neurorad assigns pair-region labels by independently
129
+ looking up the atlas at each pair's midpoint voxel, which cannot be
130
+ reproduced from contact-level agreement. Callers that need
131
+ pair-region labels should pull them from the upstream
132
+ ``pairs.json`` via ``enrich_pairs_with_cml_regions``.
133
+ """
99
134
  sep = "-"
100
135
  out = pairs_df.copy()
101
136
 
@@ -122,20 +157,18 @@ def combine_bipolar_electrodes(
122
157
  look2 = look.add_suffix("_ch2").rename(columns={f"{elec_name_col}_ch2": "ch2"})
123
158
  out = out.merge(look2, on="ch2", how="left")
124
159
 
125
- # Region agreement
126
- for rc in region_cols:
127
- if f"{rc}_ch1" in out.columns and f"{rc}_ch2" in out.columns:
128
- a = out[f"{rc}_ch1"]
129
- b = out[f"{rc}_ch2"]
130
- out[f"{rc}_pair"] = np.where(a.notna() & (a == b), a, np.nan)
131
-
132
- # Midpoints for every detected coordinate triplet
160
+ # Midpoints for every detected coordinate triplet. pair_coordinate_axis
161
+ # is a verbatim copy of neurorad_pipeline.localization.Localization
162
+ # .get_pair_coordinate reduced to a single axis.
133
163
  for prefix, (xcol, ycol, zcol) in coord_triplets.items():
134
164
  for col in (xcol, ycol, zcol):
135
165
  a = out[f"{col}_ch1"]
136
166
  b = out[f"{col}_ch2"]
137
167
  mid_name = f"{col}_mid" # e.g., "x_mid" or "tal.x_mid"
138
- out[mid_name] = np.where(a.notna() & b.notna(), (a + b) / 2.0, np.nan)
168
+ mid = pair_coordinate_axis(a, b)
169
+ if space == "Pixels":
170
+ mid = np.floor(mid)
171
+ out[mid_name] = np.where(a.notna() & b.notna(), mid, np.nan)
139
172
 
140
173
  return out
141
174
 
@@ -0,0 +1,94 @@
1
+ """Opt-in enrichment that attaches pair-level region labels from
2
+ the upstream CML ``pairs.json`` to a bidsreader pairs DataFrame.
3
+
4
+ Pure-BIDS users don't need this module. It exists only for the hybrid
5
+ case where a subject's BIDS export sits alongside the original
6
+ neurorad-pipeline output on rhino and the caller wants the pair-region
7
+ labels that neurorad computed (via independent atlas lookup at each
8
+ pair's midpoint voxel). The BIDS side can't reproduce those labels
9
+ from contact-level data, so we pull them from the upstream artifact.
10
+
11
+ See :mod:`._neurorad_algo` for why pair region labels differ from the
12
+ contact-level agreement heuristic.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Iterable, Optional
18
+
19
+ import pandas as pd
20
+
21
+
22
+ _DEFAULT_REGION_COLS: tuple[str, ...] = (
23
+ "ind.region",
24
+ "ind.corrected.region",
25
+ "avg.region",
26
+ "avg.corrected.region",
27
+ "mni.region",
28
+ "hcp.region",
29
+ "stein.region",
30
+ "das.region",
31
+ )
32
+
33
+
34
+ def enrich_pairs_with_cml_regions(
35
+ pairs_df: pd.DataFrame,
36
+ *,
37
+ subject: Optional[str] = None,
38
+ experiment: Optional[str] = None,
39
+ session: Optional[int] = None,
40
+ localization: Optional[int] = 0,
41
+ montage: Optional[int] = 0,
42
+ reader=None,
43
+ label_col: str = "name",
44
+ region_cols: Optional[Iterable[str]] = None,
45
+ ) -> pd.DataFrame:
46
+ """Return a copy of ``pairs_df`` with CML ``*.region`` columns joined on.
47
+
48
+ ``pairs_df`` is the output of
49
+ :func:`bidsreader.helpers.combine_bipolar_electrodes`, which uses
50
+ ``name`` as the pair-label column by default.
51
+ """
52
+
53
+ try:
54
+ from cmlreaders import CMLReader
55
+ except ImportError as exc:
56
+ raise ImportError(
57
+ "enrich_pairs_with_cml_regions requires cmlreaders. Install "
58
+ "it (pip install cmlreaders) or load pairs.json manually."
59
+ ) from exc
60
+
61
+ if reader is None:
62
+ if subject is None or experiment is None or session is None:
63
+ raise ValueError(
64
+ "Provide either a ready CMLReader or "
65
+ "(subject, experiment, session)."
66
+ )
67
+ reader = CMLReader(
68
+ subject=subject,
69
+ experiment=experiment,
70
+ session=session,
71
+ localization=localization,
72
+ montage=montage,
73
+ )
74
+
75
+ cml_pairs = reader.load("pairs")
76
+
77
+ wanted = tuple(region_cols) if region_cols is not None else _DEFAULT_REGION_COLS
78
+ available = [c for c in wanted if c in cml_pairs.columns]
79
+
80
+ if "label" not in cml_pairs.columns:
81
+ raise KeyError(
82
+ "cml_pairs is missing the 'label' column — can't join on pair "
83
+ "name. Got columns: %r" % (list(cml_pairs.columns),)
84
+ )
85
+
86
+ right = cml_pairs[["label", *available]].copy()
87
+ right["label"] = right["label"].astype("string").str.strip()
88
+ if label_col != "label":
89
+ right = right.rename(columns={"label": label_col})
90
+
91
+ out = pairs_df.copy()
92
+ out[label_col] = out[label_col].astype("string").str.strip()
93
+
94
+ return out.merge(right, how="left", on=label_col)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bidsreader
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Data loader and file reader for the OpenBIDS format
5
5
  Author: Computational Memory Lab
6
6
  License-Expression: MIT
@@ -19,8 +19,8 @@ Requires-Python: >=3.10
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: mne>=1.0
22
- Requires-Dist: mne-bids>=0.14
23
- Requires-Dist: numpy>=1.23
22
+ Requires-Dist: mne-bids>=0.12
23
+ Requires-Dist: numpy<2.0,>=1.23
24
24
  Requires-Dist: pandas>=1.5
25
25
  Provides-Extra: ptsa
26
26
  Requires-Dist: ptsa; extra == "ptsa"
@@ -6,13 +6,14 @@ pyproject.toml
6
6
  .github/workflows/test.yml
7
7
  bidsreader/__init__.py
8
8
  bidsreader/_errorwrap.py
9
+ bidsreader/_neurorad_algo.py
9
10
  bidsreader/basereader.py
10
11
  bidsreader/cmlbidsreader.py
11
12
  bidsreader/convert.py
12
13
  bidsreader/exc.py
13
14
  bidsreader/filtering.py
14
15
  bidsreader/helpers.py
15
- bidsreader/public_helpers.py
16
+ bidsreader/region_enrichment.py
16
17
  bidsreader/units.py
17
18
  bidsreader.egg-info/PKG-INFO
18
19
  bidsreader.egg-info/SOURCES.txt
@@ -28,5 +29,8 @@ tests/test_errorwrap.py
28
29
  tests/test_exc.py
29
30
  tests/test_filtering.py
30
31
  tests/test_helpers.py
32
+ tests/test_neurorad_algo.py
33
+ tests/test_pair_midpoint_parity.py
34
+ tests/test_region_enrichment.py
31
35
  tests/test_units.py
32
36
  tutorials/bidsreader_tutorial.ipynb
@@ -1,6 +1,6 @@
1
1
  mne>=1.0
2
- mne-bids>=0.14
3
- numpy>=1.23
2
+ mne-bids>=0.12
3
+ numpy<2.0,>=1.23
4
4
  pandas>=1.5
5
5
 
6
6
  [all]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bidsreader"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Data loader and file reader for the OpenBIDS format"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -24,8 +24,8 @@ classifiers = [
24
24
  ]
25
25
  dependencies = [
26
26
  "mne>=1.0",
27
- "mne-bids>=0.14",
28
- "numpy>=1.23",
27
+ "mne-bids>=0.12",
28
+ "numpy>=1.23,<2.0",
29
29
  "pandas>=1.5",
30
30
  ]
31
31
 
@@ -142,13 +142,27 @@ class TestDetermineSpace:
142
142
  with pytest.raises(FileNotFoundBIDSError, match="no.*coordsystem.json"):
143
143
  r._determine_space()
144
144
 
145
- def test_multiple_coordsystem_files_raises(self, tmp_root):
145
+ def test_multiple_spaces_prefers_preferred_default(self, tmp_root):
146
+ """When several spaces are present, the highest-priority space in
147
+ SPACE_PREFERENCE wins (here MNI152NLin6ASym over fsnative)."""
148
+ data_dir = self._make_data_dir(tmp_root)
149
+ (data_dir / "sub-R1001P_ses-0_space-fsnative_coordsystem.json").touch()
150
+ (data_dir / "sub-R1001P_ses-0_space-MNI152NLin6ASym_coordsystem.json").touch()
151
+ r = CMLBIDSReader(root=tmp_root, subject="R1001P", task="FR1", session="0", device="ieeg")
152
+ assert r._determine_space() == "MNI152NLin6ASym"
153
+
154
+ def test_multiple_spaces_none_preferred_falls_back_with_warning(self, tmp_root):
155
+ """When multiple spaces are present and none is in SPACE_PREFERENCE,
156
+ _determine_space falls back to a default (warning) instead of raising.
157
+ Neither MNI nor TAL is a canonical preferred label, so it picks the
158
+ first available (sorted)."""
146
159
  data_dir = self._make_data_dir(tmp_root)
147
160
  (data_dir / "sub-R1001P_ses-0_space-MNI_coordsystem.json").touch()
148
161
  (data_dir / "sub-R1001P_ses-0_space-TAL_coordsystem.json").touch()
149
162
  r = CMLBIDSReader(root=tmp_root, subject="R1001P", task="FR1", session="0", device="ieeg")
150
- with pytest.raises(AmbiguousMatchError, match="multiple coordsystem"):
151
- r._determine_space()
163
+ with pytest.warns(RuntimeWarning, match="Defaulting to"):
164
+ chosen = r._determine_space()
165
+ assert chosen == "MNI" # sorted(['MNI', 'TAL'])[0]
152
166
 
153
167
  def test_single_valid_match(self, tmp_root):
154
168
  data_dir = self._make_data_dir(tmp_root)
@@ -7,7 +7,7 @@ What is tested:
7
7
  - add_prefix: None input, already-prefixed value, value needing prefix
8
8
  - merge_duplicate_sample_events: no duplicates, duplicate trial_type merge, all-NaN
9
9
  - find_coord_triplets: bare xyz, prefixed xyz, mixed columns, no triplets
10
- - combine_bipolar_electrodes: pair splitting, midpoint computation, region agreement
10
+ - combine_bipolar_electrodes: pair splitting, midpoint computation, no synthetic pair region
11
11
  - normalize_trial_types: converts iterable of strings to set
12
12
  - match_event_label: exact match, slash-separated label, no match
13
13
  """
@@ -197,7 +197,12 @@ class TestCombineBipolarElectrodes:
197
197
  assert result.iloc[0]["y_mid"] == pytest.approx(5.5)
198
198
  assert result.iloc[0]["z_mid"] == pytest.approx(9.5)
199
199
 
200
- def test_region_agreement(self):
200
+ def test_no_synthetic_pair_region_column(self):
201
+ """Neurorad assigns pair-region labels via atlas lookup at the
202
+ midpoint voxel, not by contact-level agreement. The combiner
203
+ must therefore NOT synthesize a ``{col}_pair`` column. Per-contact
204
+ region columns are still present as ``{col}_ch1`` / ``{col}_ch2``.
205
+ """
201
206
  pairs = pd.DataFrame({"name": ["A1-A2"]})
202
207
  elecs = pd.DataFrame({
203
208
  "name": ["A1", "A2"],
@@ -205,17 +210,74 @@ class TestCombineBipolarElectrodes:
205
210
  "wb.region": ["hippocampus", "hippocampus"],
206
211
  })
207
212
  result = combine_bipolar_electrodes(pairs, elecs)
208
- assert result.iloc[0]["wb.region_pair"] == "hippocampus"
213
+ assert "wb.region_pair" not in result.columns
214
+ assert result.iloc[0]["wb.region_ch1"] == "hippocampus"
215
+ assert result.iloc[0]["wb.region_ch2"] == "hippocampus"
209
216
 
210
- def test_region_disagreement(self):
217
+ def test_pixels_space_floors_midpoint(self):
218
+ """In Pixels (CT voxel) space the midpoint is floored to an integer
219
+ voxel index, matching the neurorad pipeline's voxel handling."""
211
220
  pairs = pd.DataFrame({"name": ["A1-A2"]})
212
221
  elecs = pd.DataFrame({
213
222
  "name": ["A1", "A2"],
214
- "x": [1.0, 2.0], "y": [3.0, 4.0], "z": [5.0, 6.0],
215
- "wb.region": ["hippocampus", "amygdala"],
223
+ "x": [1.0, 2.0], "y": [5.0, 8.0], "z": [9.0, 10.0],
224
+ })
225
+ # raw midpoint = (1.5, 6.5, 9.5) -> floored = (1.0, 6.0, 9.0)
226
+ result = combine_bipolar_electrodes(pairs, elecs, space="Pixels")
227
+ assert result.iloc[0]["x_mid"] == pytest.approx(1.0)
228
+ assert result.iloc[0]["y_mid"] == pytest.approx(6.0)
229
+ assert result.iloc[0]["z_mid"] == pytest.approx(9.0)
230
+
231
+ def test_pixels_space_floors_multiple_pairs(self):
232
+ """Regression: flooring must work across many pairs at once. A scalar
233
+ math.floor here raised 'only length-1 arrays can be converted to
234
+ Python scalars' for any subject with >1 bipolar pair."""
235
+ pairs = pd.DataFrame({"name": ["A1-A2", "B1-B2"]})
236
+ elecs = pd.DataFrame({
237
+ "name": ["A1", "A2", "B1", "B2"],
238
+ "x": [1.2, 2.9, 3.1, 4.4],
239
+ "y": [5.0, 6.0, 7.0, 8.0],
240
+ "z": [9.0, 10.0, 11.0, 12.0],
241
+ })
242
+ result = combine_bipolar_electrodes(pairs, elecs, space="Pixels")
243
+ # A1-A2 raw mid x = 2.05 -> 2.0 ; B1-B2 raw mid x = 3.75 -> 3.0
244
+ assert list(result["x_mid"]) == pytest.approx([2.0, 3.0])
245
+
246
+ def test_non_pixels_space_does_not_floor(self):
247
+ """Default / non-Pixels spaces keep the fractional midpoint."""
248
+ pairs = pd.DataFrame({"name": ["A1-A2"]})
249
+ elecs = pd.DataFrame({
250
+ "name": ["A1", "A2"],
251
+ "x": [1.0, 2.0], "y": [5.0, 8.0], "z": [9.0, 10.0],
252
+ })
253
+ result = combine_bipolar_electrodes(pairs, elecs, space="MNI152NLin6ASym")
254
+ assert result.iloc[0]["x_mid"] == pytest.approx(1.5)
255
+ assert result.iloc[0]["y_mid"] == pytest.approx(6.5)
256
+
257
+ def test_missing_contact_coords_yield_nan_midpoint(self):
258
+ """If a pair references a contact missing from elec_df, its midpoint
259
+ columns are NaN rather than raising."""
260
+ pairs = pd.DataFrame({"name": ["A1-A2", "A1-GHOST"]})
261
+ elecs = pd.DataFrame({
262
+ "name": ["A1", "A2"],
263
+ "x": [1.0, 3.0], "y": [5.0, 7.0], "z": [9.0, 11.0],
264
+ })
265
+ result = combine_bipolar_electrodes(pairs, elecs)
266
+ assert result.iloc[0]["x_mid"] == pytest.approx(2.0)
267
+ assert pd.isna(result.iloc[1]["x_mid"])
268
+
269
+ def test_prefixed_coord_triplet_midpoint(self):
270
+ """Prefixed coordinate triplets (e.g. tal.x/tal.y/tal.z) also get a
271
+ {prefix}.{axis}_mid column."""
272
+ pairs = pd.DataFrame({"name": ["A1-A2"]})
273
+ elecs = pd.DataFrame({
274
+ "name": ["A1", "A2"],
275
+ "tal.x": [0.0, 4.0], "tal.y": [0.0, 2.0], "tal.z": [0.0, 6.0],
216
276
  })
217
277
  result = combine_bipolar_electrodes(pairs, elecs)
218
- assert pd.isna(result.iloc[0]["wb.region_pair"])
278
+ assert result.iloc[0]["tal.x_mid"] == pytest.approx(2.0)
279
+ assert result.iloc[0]["tal.y_mid"] == pytest.approx(1.0)
280
+ assert result.iloc[0]["tal.z_mid"] == pytest.approx(3.0)
219
281
 
220
282
 
221
283
  # ---------------------------------------------------------------------------
@@ -0,0 +1,113 @@
1
+ """
2
+ Tests for bidsreader._neurorad_algo
3
+
4
+ These are upstream-free unit tests for the copied neurorad pair-location
5
+ algorithm. The rhino-only parity test in test_pair_midpoint_parity.py pins
6
+ this implementation against the real neurorad_pipeline; here we exercise the
7
+ copy's behavior directly so it is covered even off rhino.
8
+
9
+ What is tested:
10
+ - pair_coordinate: midpoint, None-in/None-out short-circuit, return type
11
+ - pair_coordinate_axis: vectorized midpoint, NaN propagation, scalar input
12
+ - constant maps: shape/content sanity for the mirrored neurorad constants
13
+ """
14
+ import numpy as np
15
+ import pytest
16
+
17
+ from bidsreader._neurorad_algo import (
18
+ pair_coordinate,
19
+ pair_coordinate_axis,
20
+ VALID_COORDINATE_SPACES,
21
+ VALID_COORDINATE_TYPES,
22
+ VALID_ATLASES,
23
+ BIDS_SPACE_TO_NEURORAD,
24
+ )
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # pair_coordinate
29
+ # ---------------------------------------------------------------------------
30
+ class TestPairCoordinate:
31
+ """Tests for pair_coordinate (scalar / single-triplet midpoint)."""
32
+
33
+ def test_midpoint(self):
34
+ result = pair_coordinate([0.0, 0.0, 0.0], [2.0, 4.0, 6.0])
35
+ np.testing.assert_allclose(result, [1.0, 2.0, 3.0])
36
+
37
+ def test_negative_and_fractional(self):
38
+ result = pair_coordinate([-1.5, 0.0, 10.25], [1.5, 0.0, -10.25])
39
+ np.testing.assert_allclose(result, [0.0, 0.0, 0.0])
40
+
41
+ def test_none_first_returns_none(self):
42
+ assert pair_coordinate(None, [1.0, 2.0, 3.0]) is None
43
+
44
+ def test_none_second_returns_none(self):
45
+ assert pair_coordinate([1.0, 2.0, 3.0], None) is None
46
+
47
+ def test_returns_ndarray(self):
48
+ result = pair_coordinate([1.0, 2.0, 3.0], [3.0, 4.0, 5.0])
49
+ assert isinstance(result, np.ndarray)
50
+
51
+ def test_accepts_ndarray_input(self):
52
+ result = pair_coordinate(np.array([0.0, 0.0, 0.0]), np.array([4.0, 8.0, 2.0]))
53
+ np.testing.assert_allclose(result, [2.0, 4.0, 1.0])
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # pair_coordinate_axis
58
+ # ---------------------------------------------------------------------------
59
+ class TestPairCoordinateAxis:
60
+ """Tests for pair_coordinate_axis (vectorized single-axis midpoint)."""
61
+
62
+ def test_vectorized_midpoint(self):
63
+ a = [0.0, 10.0, -4.0]
64
+ b = [2.0, 20.0, 4.0]
65
+ np.testing.assert_allclose(pair_coordinate_axis(a, b), [1.0, 15.0, 0.0])
66
+
67
+ def test_nan_propagates(self):
68
+ a = [1.0, np.nan, 3.0]
69
+ b = [3.0, 5.0, np.nan]
70
+ result = pair_coordinate_axis(a, b)
71
+ assert result[0] == pytest.approx(2.0)
72
+ assert np.isnan(result[1])
73
+ assert np.isnan(result[2])
74
+
75
+ def test_scalar_input(self):
76
+ # Single-pair case: must not crash and must return the midpoint.
77
+ result = pair_coordinate_axis([2.0], [5.0])
78
+ np.testing.assert_allclose(result, [3.5])
79
+
80
+ def test_returns_float_dtype(self):
81
+ # Integer input must be promoted so /2 does not truncate.
82
+ result = pair_coordinate_axis([1, 2], [2, 3])
83
+ assert result.dtype == np.float64
84
+ np.testing.assert_allclose(result, [1.5, 2.5])
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Mirrored neurorad constants
89
+ # ---------------------------------------------------------------------------
90
+ class TestNeuroradConstants:
91
+ """Light sanity checks on the constants copied from localization.py."""
92
+
93
+ def test_valid_spaces(self):
94
+ assert VALID_COORDINATE_SPACES == ("ct_voxel", "fs", "t1_mri", "t2_mri", "mni")
95
+
96
+ def test_valid_types(self):
97
+ assert VALID_COORDINATE_TYPES == ("raw", "corrected")
98
+
99
+ def test_valid_atlases(self):
100
+ assert VALID_ATLASES == ("dk", "whole_brain", "mtl")
101
+
102
+ def test_bids_space_map_values_are_pairs(self):
103
+ # Every value is a (coordinate_space, coordinate_type) tuple, and the
104
+ # coordinate_type is one of the valid neurorad types.
105
+ for space, mapped in BIDS_SPACE_TO_NEURORAD.items():
106
+ assert len(mapped) == 2
107
+ assert mapped[1] in VALID_COORDINATE_TYPES
108
+
109
+ def test_pixels_maps_to_ct_voxel(self):
110
+ assert BIDS_SPACE_TO_NEURORAD["Pixels"] == ("ct_voxel", "raw")
111
+
112
+ def test_brainshift_maps_to_corrected(self):
113
+ assert BIDS_SPACE_TO_NEURORAD["fsnativeBrainshift"][1] == "corrected"
@@ -0,0 +1,137 @@
1
+ """Parity test: bidsreader._neurorad_algo.pair_coordinate vs. the
2
+ upstream neurorad pipeline's Localization.get_pair_coordinate.
3
+
4
+ neurorad_pipeline is NOT a runtime dependency of bidsreader. It is
5
+ loaded here dynamically from its rhino install (default:
6
+ /home2/iped/neurorad_pipeline/) for test purposes only. If the
7
+ directory isn't present (e.g. we're off rhino), the whole module is
8
+ skipped — the copied algorithm in _neurorad_algo.py is still
9
+ exercised by unit tests that don't require upstream.
10
+
11
+ When the upstream get_pair_coordinate changes, this test fails. Update
12
+ _neurorad_algo.pair_coordinate AND the pinned revision recorded in
13
+ _neurorad_algo.py's top-of-file docstring in the same commit.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib.util
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+ import pytest
25
+
26
+
27
+ NEURORAD_DIR_CANDIDATES = (
28
+ Path("/home2/iped/neurorad_pipeline"),
29
+ Path("/home2/iped/event_creation/neurorad"),
30
+ )
31
+
32
+
33
+ def _load_upstream_localization():
34
+ for d in NEURORAD_DIR_CANDIDATES:
35
+ loc_py = d / "localization.py"
36
+ if not loc_py.exists():
37
+ continue
38
+ # localization.py does `from json_cleaner import ...` so the dir
39
+ # must be on sys.path before import.
40
+ sys.path.insert(0, str(d))
41
+ try:
42
+ spec = importlib.util.spec_from_file_location(
43
+ f"_upstream_localization_{d.name}", str(loc_py)
44
+ )
45
+ mod = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(mod)
47
+ return mod.Localization
48
+ except Exception:
49
+ sys.path.pop(0)
50
+ continue
51
+ return None
52
+
53
+
54
+ UpstreamLocalization = _load_upstream_localization()
55
+ pytestmark = pytest.mark.skipif(
56
+ UpstreamLocalization is None,
57
+ reason="upstream neurorad_pipeline not available (off rhino?)",
58
+ )
59
+
60
+
61
+ def _make_loc_with_two_contacts(space, c1_xyz, c2_xyz, ctype="raw"):
62
+ loc = UpstreamLocalization()
63
+ loc._contact_dict = {
64
+ "leads": {
65
+ "LA": {
66
+ "type": "D",
67
+ "n_groups": 1,
68
+ "contacts": [
69
+ {
70
+ "name": "LA1",
71
+ "lead_group": 0,
72
+ "lead_loc": 0,
73
+ "grid_group": 0,
74
+ "grid_loc": [0, 0],
75
+ "atlases": {},
76
+ "info": {},
77
+ "coordinate_spaces": {space: {ctype: list(c1_xyz)}},
78
+ },
79
+ {
80
+ "name": "LA2",
81
+ "lead_group": 0,
82
+ "lead_loc": 1,
83
+ "grid_group": 0,
84
+ "grid_loc": [1, 0],
85
+ "atlases": {},
86
+ "info": {},
87
+ "coordinate_spaces": {space: {ctype: list(c2_xyz)}},
88
+ },
89
+ ],
90
+ "pairs": [],
91
+ }
92
+ }
93
+ }
94
+ return loc
95
+
96
+
97
+ SPACE_TYPE_CASES = [
98
+ ("ct_voxel", "raw"),
99
+ ("fs", "raw"),
100
+ ("fs", "corrected"),
101
+ ("t1_mri", "raw"),
102
+ ("t2_mri", "raw"),
103
+ ("mni", "raw"),
104
+ ("mni", "corrected"),
105
+ ]
106
+
107
+ COORD_CASES = [
108
+ ([0.0, 0.0, 0.0], [2.0, 4.0, 6.0]),
109
+ ([-1.5, 0.0, 10.25], [1.5, 0.0, -10.25]),
110
+ ([100.0, 200.0, -50.0], [101.0, 201.0, -49.0]),
111
+ ]
112
+
113
+
114
+ @pytest.mark.parametrize("space,ctype", SPACE_TYPE_CASES)
115
+ @pytest.mark.parametrize("c1,c2", COORD_CASES)
116
+ def test_pair_coordinate_matches_upstream(space, ctype, c1, c2):
117
+ """pair_coordinate(c1, c2) == Localization.get_pair_coordinate
118
+ across every (space, coordinate_type) combination."""
119
+ from bidsreader._neurorad_algo import pair_coordinate
120
+
121
+ loc = _make_loc_with_two_contacts(space, c1, c2, ctype=ctype)
122
+ upstream = loc.get_pair_coordinate(space, ["LA1", "LA2"], ctype)
123
+
124
+ ours = pair_coordinate(c1, c2)
125
+
126
+ # Upstream returns shape (1, 3); ours returns shape (3,). Flatten.
127
+ np.testing.assert_allclose(
128
+ np.asarray(upstream).ravel(), np.asarray(ours).ravel()
129
+ )
130
+
131
+
132
+ def test_pair_coordinate_none_in_none_out():
133
+ """Matches upstream short-circuit behavior (localization.py:286-287)."""
134
+ from bidsreader._neurorad_algo import pair_coordinate
135
+
136
+ assert pair_coordinate(None, [1.0, 2.0, 3.0]) is None
137
+ assert pair_coordinate([1.0, 2.0, 3.0], None) is None
@@ -0,0 +1,111 @@
1
+ """
2
+ Tests for bidsreader.region_enrichment.enrich_pairs_with_cml_regions
3
+
4
+ enrich_pairs_with_cml_regions joins authoritative pair-level region labels
5
+ from the upstream CML pairs.json onto a bidsreader pairs DataFrame. It loads
6
+ those labels via a cmlreaders.CMLReader, but accepts a ready reader object so
7
+ the join logic can be tested without cmlreaders installed or rhino access.
8
+
9
+ What is tested:
10
+ - region columns from pairs.json are joined onto matching pair names
11
+ - whitespace on both join keys is stripped before merging
12
+ - only the requested / available region columns are pulled
13
+ - left-join semantics: unmatched pairs survive with NaN region labels
14
+ - missing 'label' column in cml_pairs raises KeyError
15
+ - reader=None with incomplete (subject, experiment, session) raises ValueError
16
+ """
17
+ import importlib.util
18
+
19
+ import pandas as pd
20
+ import pytest
21
+
22
+ from bidsreader.region_enrichment import enrich_pairs_with_cml_regions
23
+
24
+ # enrich_pairs_with_cml_regions imports cmlreaders unconditionally (even when a
25
+ # reader is injected), so skip the whole module where cmlreaders is unavailable.
26
+ pytestmark = pytest.mark.skipif(
27
+ importlib.util.find_spec("cmlreaders") is None,
28
+ reason="cmlreaders not installed",
29
+ )
30
+
31
+
32
+ class FakeReader:
33
+ """Stand-in for cmlreaders.CMLReader.load('pairs')."""
34
+
35
+ def __init__(self, pairs_df):
36
+ self._pairs_df = pairs_df
37
+
38
+ def load(self, what):
39
+ assert what == "pairs"
40
+ return self._pairs_df
41
+
42
+
43
+ def _cml_pairs():
44
+ return pd.DataFrame({
45
+ "label": ["A1-A2", "B1-B2"],
46
+ "ind.region": ["hippocampus", "amygdala"],
47
+ "stein.region": ["CA1", "BLA"],
48
+ # a column not in the default wanted-set, to confirm filtering
49
+ "contact_1": [1, 3],
50
+ })
51
+
52
+
53
+ class TestEnrichPairsWithCmlRegions:
54
+ """Tests for enrich_pairs_with_cml_regions."""
55
+
56
+ def test_joins_region_columns(self):
57
+ pairs = pd.DataFrame({"name": ["A1-A2", "B1-B2"]})
58
+ result = enrich_pairs_with_cml_regions(pairs, reader=FakeReader(_cml_pairs()))
59
+ assert list(result["ind.region"]) == ["hippocampus", "amygdala"]
60
+ assert list(result["stein.region"]) == ["CA1", "BLA"]
61
+
62
+ def test_only_requested_region_cols_pulled(self):
63
+ pairs = pd.DataFrame({"name": ["A1-A2"]})
64
+ result = enrich_pairs_with_cml_regions(
65
+ pairs, reader=FakeReader(_cml_pairs()), region_cols=["ind.region"]
66
+ )
67
+ assert "ind.region" in result.columns
68
+ assert "stein.region" not in result.columns
69
+
70
+ def test_non_region_columns_not_pulled(self):
71
+ """contact_1 exists in cml_pairs but is not a region col -> dropped."""
72
+ pairs = pd.DataFrame({"name": ["A1-A2"]})
73
+ result = enrich_pairs_with_cml_regions(pairs, reader=FakeReader(_cml_pairs()))
74
+ assert "contact_1" not in result.columns
75
+
76
+ def test_strips_whitespace_on_join_keys(self):
77
+ pairs = pd.DataFrame({"name": [" A1-A2 "]})
78
+ cml = _cml_pairs()
79
+ cml.loc[0, "label"] = " A1-A2 "
80
+ result = enrich_pairs_with_cml_regions(pairs, reader=FakeReader(cml))
81
+ assert result.iloc[0]["ind.region"] == "hippocampus"
82
+
83
+ def test_left_join_keeps_unmatched_pairs(self):
84
+ pairs = pd.DataFrame({"name": ["A1-A2", "Z9-Z10"]})
85
+ result = enrich_pairs_with_cml_regions(pairs, reader=FakeReader(_cml_pairs()))
86
+ assert len(result) == 2
87
+ assert result.iloc[0]["ind.region"] == "hippocampus"
88
+ assert pd.isna(result.iloc[1]["ind.region"])
89
+
90
+ def test_does_not_mutate_input(self):
91
+ pairs = pd.DataFrame({"name": ["A1-A2"]})
92
+ enrich_pairs_with_cml_regions(pairs, reader=FakeReader(_cml_pairs()))
93
+ assert "ind.region" not in pairs.columns
94
+
95
+ def test_custom_label_col(self):
96
+ pairs = pd.DataFrame({"pair": ["A1-A2"]})
97
+ result = enrich_pairs_with_cml_regions(
98
+ pairs, reader=FakeReader(_cml_pairs()), label_col="pair"
99
+ )
100
+ assert result.iloc[0]["ind.region"] == "hippocampus"
101
+
102
+ def test_missing_label_column_raises(self):
103
+ bad = _cml_pairs().drop(columns=["label"])
104
+ pairs = pd.DataFrame({"name": ["A1-A2"]})
105
+ with pytest.raises(KeyError):
106
+ enrich_pairs_with_cml_regions(pairs, reader=FakeReader(bad))
107
+
108
+ def test_no_reader_and_incomplete_args_raises(self):
109
+ pairs = pd.DataFrame({"name": ["A1-A2"]})
110
+ with pytest.raises(ValueError):
111
+ enrich_pairs_with_cml_regions(pairs, subject="R1001P") # no exp/session
@@ -1,17 +0,0 @@
1
- # imports
2
- import pandas as pd
3
- from pathlib import Path
4
- import re
5
-
6
- def get_data_index(root, task):
7
- root = Path(root)
8
- rows = []
9
-
10
- pattern = re.compile(r"sub-(?P<sub>[^_]+)_ses-(?P<ses>[^_]+)_task-" + re.escape(task), re.IGNORECASE)
11
-
12
- for f in root.rglob(f"*task-{task}*beh.tsv"):
13
- m = pattern.search(f.name)
14
- if m:
15
- rows.append({"subject": m.group("sub"), "task": task, "session": m.group("ses")})
16
-
17
- return pd.DataFrame(rows).drop_duplicates().sort_values(["subject", "session"]).reset_index(drop=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes