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.
- {bidsreader-0.2.0 → bidsreader-0.3.0}/PKG-INFO +3 -3
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/__init__.py +1 -2
- bidsreader-0.3.0/bidsreader/_neurorad_algo.py +81 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/basereader.py +47 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/cmlbidsreader.py +167 -38
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/helpers.py +42 -9
- bidsreader-0.3.0/bidsreader/region_enrichment.py +94 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/PKG-INFO +3 -3
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/SOURCES.txt +5 -1
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/requires.txt +2 -2
- {bidsreader-0.2.0 → bidsreader-0.3.0}/pyproject.toml +3 -3
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_cmlbidsreader.py +17 -3
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_helpers.py +69 -7
- bidsreader-0.3.0/tests/test_neurorad_algo.py +113 -0
- bidsreader-0.3.0/tests/test_pair_midpoint_parity.py +137 -0
- bidsreader-0.3.0/tests/test_region_enrichment.py +111 -0
- bidsreader-0.2.0/bidsreader/public_helpers.py +0 -17
- {bidsreader-0.2.0 → bidsreader-0.3.0}/.github/workflows/publish.yml +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/.github/workflows/test.yml +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/.gitignore +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/LICENSE +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/README.md +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/_errorwrap.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/convert.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/exc.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/filtering.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader/units.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/dependency_links.txt +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/bidsreader.egg-info/top_level.txt +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/setup.cfg +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/__init__.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/conftest.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_basereader.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_convert.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_errorwrap.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_exc.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_filtering.py +0 -0
- {bidsreader-0.2.0 → bidsreader-0.3.0}/tests/test_units.py +0 -0
- {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.
|
|
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.
|
|
23
|
-
Requires-Dist: numpy
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
23
|
-
Requires-Dist: numpy
|
|
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/
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bidsreader"
|
|
7
|
-
version = "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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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": [
|
|
215
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|