nltools 0.6.0.dev0__py3-none-any.whl

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 (95) hide show
  1. nltools/__init__.py +55 -0
  2. nltools/algorithms/__init__.py +90 -0
  3. nltools/algorithms/alignment/__init__.py +21 -0
  4. nltools/algorithms/alignment/procrustes.py +565 -0
  5. nltools/algorithms/alignment/srm.py +758 -0
  6. nltools/algorithms/backends.py +1059 -0
  7. nltools/algorithms/corrections.py +177 -0
  8. nltools/algorithms/decoding.py +327 -0
  9. nltools/algorithms/inference/__init__.py +50 -0
  10. nltools/algorithms/inference/bootstrap.py +1386 -0
  11. nltools/algorithms/inference/correlation.py +373 -0
  12. nltools/algorithms/inference/intersubject.py +422 -0
  13. nltools/algorithms/inference/isc.py +1554 -0
  14. nltools/algorithms/inference/matrix.py +602 -0
  15. nltools/algorithms/inference/one_sample.py +288 -0
  16. nltools/algorithms/inference/random.py +122 -0
  17. nltools/algorithms/inference/timeseries.py +347 -0
  18. nltools/algorithms/inference/two_sample.py +212 -0
  19. nltools/algorithms/inference/utils.py +58 -0
  20. nltools/algorithms/inference/validation.py +282 -0
  21. nltools/algorithms/neighborhoods.py +207 -0
  22. nltools/algorithms/outliers.py +308 -0
  23. nltools/algorithms/regression.py +83 -0
  24. nltools/algorithms/signal.py +303 -0
  25. nltools/algorithms/similarity.py +234 -0
  26. nltools/algorithms/validation.py +151 -0
  27. nltools/cross_validation.py +72 -0
  28. nltools/data/__init__.py +30 -0
  29. nltools/data/adjacency/__init__.py +875 -0
  30. nltools/data/adjacency/io.py +111 -0
  31. nltools/data/adjacency/modeling.py +569 -0
  32. nltools/data/adjacency/plotting.py +174 -0
  33. nltools/data/adjacency/state.py +349 -0
  34. nltools/data/adjacency/stats.py +596 -0
  35. nltools/data/adjacency/utils.py +79 -0
  36. nltools/data/atlases/__init__.py +23 -0
  37. nltools/data/atlases/labeling.py +158 -0
  38. nltools/data/atlases/loading.py +76 -0
  39. nltools/data/atlases/registry.py +96 -0
  40. nltools/data/atlases/reporting.py +456 -0
  41. nltools/data/braindata/__init__.py +2170 -0
  42. nltools/data/braindata/analysis.py +1381 -0
  43. nltools/data/braindata/bootstrap.py +398 -0
  44. nltools/data/braindata/io.py +896 -0
  45. nltools/data/braindata/modeling.py +594 -0
  46. nltools/data/braindata/plotting.py +501 -0
  47. nltools/data/braindata/prediction.py +1250 -0
  48. nltools/data/braindata/utils.py +348 -0
  49. nltools/data/braindata/validation.py +197 -0
  50. nltools/data/braindata/viewer.js +266 -0
  51. nltools/data/braindata/viewer.py +770 -0
  52. nltools/data/combine.py +27 -0
  53. nltools/data/designmatrix/__init__.py +1032 -0
  54. nltools/data/designmatrix/append.py +518 -0
  55. nltools/data/designmatrix/diagnostics.py +248 -0
  56. nltools/data/designmatrix/io.py +356 -0
  57. nltools/data/designmatrix/plotting.py +291 -0
  58. nltools/data/designmatrix/regressors.py +463 -0
  59. nltools/data/designmatrix/transforms.py +200 -0
  60. nltools/data/designmatrix/utils.py +350 -0
  61. nltools/data/ownership.py +129 -0
  62. nltools/data/results.py +291 -0
  63. nltools/data/roc/__init__.py +398 -0
  64. nltools/data/simulator/__init__.py +927 -0
  65. nltools/data/simulator/haxby.py +124 -0
  66. nltools/data/validation.py +83 -0
  67. nltools/datasets.py +218 -0
  68. nltools/io/__init__.py +10 -0
  69. nltools/io/events.py +67 -0
  70. nltools/io/h5.py +246 -0
  71. nltools/mask.py +403 -0
  72. nltools/models/__init__.py +11 -0
  73. nltools/models/glm.py +543 -0
  74. nltools/models/results.py +49 -0
  75. nltools/models/ridge.py +1303 -0
  76. nltools/models/validation.py +26 -0
  77. nltools/plotting/__init__.py +32 -0
  78. nltools/plotting/adjacency.py +421 -0
  79. nltools/plotting/brain.py +669 -0
  80. nltools/plotting/decomposition.py +111 -0
  81. nltools/plotting/prediction.py +110 -0
  82. nltools/resources/covariates_example.csv +161 -0
  83. nltools/resources/onsets_example.csv +40 -0
  84. nltools/templates/__init__.py +51 -0
  85. nltools/templates/config.py +144 -0
  86. nltools/templates/fetch.py +260 -0
  87. nltools/templates/matching.py +183 -0
  88. nltools/templates/paths.py +106 -0
  89. nltools/templates/registry.py +25 -0
  90. nltools/utils.py +230 -0
  91. nltools/version.py +13 -0
  92. nltools-0.6.0.dev0.dist-info/METADATA +95 -0
  93. nltools-0.6.0.dev0.dist-info/RECORD +95 -0
  94. nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
  95. nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,348 @@
1
+ """Shared helpers for BrainData submodules.
2
+
3
+ These are internal utilities used by the facade and submodules — not part of the
4
+ public API.
5
+ """
6
+
7
+ import gc
8
+ import os
9
+ from contextlib import contextmanager
10
+
11
+ import numpy as np
12
+ from numpy.typing import ArrayLike
13
+
14
+ from ..ownership import _copy_graph
15
+
16
+
17
+ @contextmanager
18
+ def _coalesced_gc():
19
+ """Collapse nilearn's forced per-copy `gc.collect()` calls into one per operation.
20
+
21
+ nilearn runs a full `gc.collect()` after every masked-array copy it makes; a
22
+ masking-heavy operation — a GLM fit that re-validates the same mask and
23
+ builds several result maps — fires dozens. With torch/nilearn/sklearn
24
+ resident each sweep costs ~0.1s, so the storm dominates the wall-clock of
25
+ otherwise-trivial numerical work.
26
+
27
+ This no-ops the interim collects and runs a single real collect on exit,
28
+ so peak memory stays bounded to one operation's worth of cyclic garbage
29
+ (nilearn's collect is a peak-memory optimization, not a correctness
30
+ requirement — suppressing it only defers reclamation). Opt out with
31
+ `NLTOOLS_NO_GC_COALESCE=1`.
32
+
33
+ Because `@contextmanager` results double as decorators, this can also be
34
+ used as `@_coalesced_gc()` on an operation-boundary method.
35
+
36
+ Nesting is safe: each frame restores whatever it saved, so only the
37
+ outermost frame restores the real `gc.collect` and runs the final sweep;
38
+ inner frames' exit-time collect is a no-op.
39
+
40
+ Caveat: this swaps a process-global builtin. It is safe under the default
41
+ loky (process) worker backend — each worker has its own `gc`. Under a
42
+ *threading* backend there is a brief window where a concurrent thread sees
43
+ the no-op collect; `NLTOOLS_NO_GC_COALESCE=1` is the escape hatch there.
44
+ """
45
+ if os.environ.get("NLTOOLS_NO_GC_COALESCE"):
46
+ yield
47
+ return
48
+ saved = gc.collect # may already be the no-op if we're nested
49
+ gc.collect = lambda *a, **k: 0
50
+ try:
51
+ yield
52
+ finally:
53
+ gc.collect = saved # only the outermost frame restores the real collect
54
+ gc.collect() # no-op if still nested; one real sweep at the top
55
+
56
+
57
+ def _resolve_threshold(value: float | str | None, data: ArrayLike) -> float | None:
58
+ """Resolve a threshold spec — a number or a percentile string — to a float.
59
+
60
+ The single source of truth for what `"98%"` means across the library
61
+ (`BrainData.threshold` and `BrainData.iplot` both route through it).
62
+ Numbers and None pass through unchanged. A percentile string is resolved
63
+ against the **finite nonzero** values of `data`: on a masked stat map
64
+ most voxels are exactly zero (absence of data), and including them drags
65
+ every percentile toward zero.
66
+
67
+ Args:
68
+ value: A number (returned as-is), None (returned as-is), or a string
69
+ like `"98%"`.
70
+ data: Array-like the percentile is computed over. Callers choose the
71
+ frame of reference — e.g. `iplot` passes magnitudes
72
+ (`np.abs(data)`) because its window is a magnitude window, while
73
+ `threshold` passes signed values.
74
+
75
+ Returns:
76
+ float | None: The resolved threshold.
77
+
78
+ Raises:
79
+ ValueError: If `value` is a string without a trailing `%`.
80
+ """
81
+ if value is None or not isinstance(value, str):
82
+ return value
83
+ if not value.endswith("%"):
84
+ raise ValueError(
85
+ f"string threshold must be a percentile like '98%', got {value!r}"
86
+ )
87
+ pct = float(value[:-1])
88
+ vals = np.asarray(data, dtype=float).ravel()
89
+ vals = vals[np.isfinite(vals)]
90
+ vals = vals[vals != 0]
91
+ if vals.size == 0:
92
+ return 0.0
93
+ return float(np.percentile(vals, pct))
94
+
95
+
96
+ def _is_default(value, default):
97
+ """Report whether a `fit` or `predict` option still holds its signature default.
98
+
99
+ The check rejects non-default *values*, not the act of passing a keyword:
100
+ an option explicitly given its own default is indistinguishable from an
101
+ untouched one and is treated as untouched. Array-like options
102
+ (`ridge_alpha`, `ridge_dirichlet_concentration`) make a bare `!=` return an
103
+ array, so equality is compared elementwise, and a sequence given as a list
104
+ matches a tuple default.
105
+
106
+ Args:
107
+ value: The supplied option value.
108
+ default: The signature default.
109
+
110
+ Returns:
111
+ bool: True when the option still holds its default value.
112
+ """
113
+ if value is default:
114
+ return True
115
+ if isinstance(value, bool) != isinstance(default, bool):
116
+ # `0` is not `False`: a flag given an integer was supplied deliberately.
117
+ return False
118
+ if np.ndim(value) != np.ndim(default):
119
+ return False
120
+ return bool(np.array_equal(value, default))
121
+
122
+
123
+ def _check_brain_data(data, mask=None):
124
+ """Return *data* as a BrainData, coercing Niimg-like inputs if needed.
125
+
126
+ If *data* is already a BrainData, the optional *mask* is applied via
127
+ `BrainData.apply_mask`. Otherwise *data* is passed through
128
+ `BrainData`, which dispatches on type (file path, list of paths,
129
+ URL, h5, ``nib.Nifti1Image``, numpy array). Unsupported types raise
130
+ ``TypeError`` from
131
+ `_validate_data_type`.
132
+ """
133
+ from . import BrainData
134
+
135
+ if isinstance(data, BrainData):
136
+ if mask is not None:
137
+ data = data.apply_mask(mask)
138
+ return data
139
+ return BrainData(data, mask=mask)
140
+
141
+
142
+ def _check_brain_data_is_single(data):
143
+ """Logical test if BrainData instance is a single image.
144
+
145
+ Args:
146
+ data (BrainData | Nifti1Image | str): Data to test; non-BrainData
147
+ inputs are coerced first.
148
+
149
+ Returns:
150
+ bool: True if the data holds a single image.
151
+ """
152
+ data = _check_brain_data(data)
153
+ return len(data.shape) <= 1
154
+
155
+
156
+ #: Every attribute a fit may attach to a BrainData. The enumeration is
157
+ #: exhaustive: clearing fitted state deletes exactly these names, with no
158
+ #: predicates and no special cases. `predict` attaches nothing, so it
159
+ #: contributes no names.
160
+ _FIT_STATE_ATTRIBUTES = (
161
+ "model_",
162
+ "ridge_weights",
163
+ "ridge_fitted_values",
164
+ "ridge_r2",
165
+ "glm_betas",
166
+ "glm_residual",
167
+ "glm_predicted",
168
+ "glm_r2",
169
+ )
170
+
171
+
172
+ def _clear_fit_state(bd):
173
+ """Remove state invalidated by changing a BrainData object's data."""
174
+ for name in _FIT_STATE_ATTRIBUTES:
175
+ if hasattr(bd, name):
176
+ delattr(bd, name)
177
+
178
+
179
+ def _copy_for_fit(source):
180
+ """Copy retained state without traversing obsolete fitted attributes."""
181
+ return _copy_graph(source, exclude=_FIT_STATE_ATTRIBUTES)
182
+
183
+
184
+ def _row_values(data, X, Y):
185
+ """Validate the complete replacement row state before graph construction."""
186
+ from ..validation import _validate_frame
187
+
188
+ data = np.asarray(data)
189
+ count = 0 if data.size == 0 else (1 if data.ndim == 1 else data.shape[0])
190
+ X, Y = _validate_frame(X, frame_type="X"), _validate_frame(Y, frame_type="Y")
191
+ for name, frame in (("X", X), ("Y", Y)):
192
+ if not frame.is_empty() and frame.height != count:
193
+ raise ValueError(
194
+ f"{name} has {frame.height} rows but result data has {count} rows"
195
+ )
196
+ return {"data": data, "_X": X, "_Y": Y}
197
+
198
+
199
+ def _result_from_rows(source, data, *, X, Y):
200
+ """Construct independently owned data with complete replacement row metadata."""
201
+ return _copy_graph(
202
+ source, exclude=_FIT_STATE_ATTRIBUTES, replacements=_row_values(data, X, Y)
203
+ )
204
+
205
+
206
+ def _result_from_array(source, data, *, rows):
207
+ """Construct a result with an explicit observation-row policy."""
208
+ if rows not in ("preserve", "clear"):
209
+ raise ValueError("rows must be 'preserve' or 'clear'")
210
+ return _result_from_rows(
211
+ source,
212
+ data,
213
+ X=source.X if rows == "preserve" else None,
214
+ Y=source.Y if rows == "preserve" else None,
215
+ )
216
+
217
+
218
+ def _result_from_selection(source, index):
219
+ """Apply one observation selection to data and both metadata frames."""
220
+ if not isinstance(index, (int, np.integer, slice)):
221
+ index = np.asarray(index).flatten()
222
+ data = source.data[index, :]
223
+ if not isinstance(index, slice) and data.ndim == 2 and data.shape[0] == 1:
224
+ data = data[0]
225
+ return _result_from_rows(
226
+ source,
227
+ data,
228
+ X=_polars_row_select(source.X, index),
229
+ Y=_polars_row_select(source.Y, index),
230
+ )
231
+
232
+
233
+ def _result_with_mask(source, data, mask, *, rows):
234
+ """Install independent replacement spatial state for a changed voxel axis."""
235
+ from .io import _initialize_mask
236
+
237
+ data = np.asarray(data)
238
+ if data.size and data.shape[-1] != int(np.count_nonzero(mask.get_fdata() > 0)):
239
+ raise ValueError("Result voxel axis must match replacement mask support")
240
+ if rows not in ("preserve", "clear"):
241
+ raise ValueError("rows must be 'preserve' or 'clear'")
242
+ values = _row_values(
243
+ data,
244
+ source.X if rows == "preserve" else None,
245
+ source.Y if rows == "preserve" else None,
246
+ )
247
+ values.update({"mask": mask, "masker": None, "_labels": None})
248
+ result = _copy_graph(source, exclude=_FIT_STATE_ATTRIBUTES, replacements=values)
249
+ _initialize_mask(result, result.mask)
250
+ return result
251
+
252
+
253
+ def _perform_arithmetic(
254
+ bd, other, operation, operation_name, inplace=False, reverse=False
255
+ ):
256
+ """Perform an arithmetic operation with validation.
257
+
258
+ Args:
259
+ bd (BrainData): Left operand unless ``reverse`` is True.
260
+ other (float | BrainData | np.ndarray): The other operand.
261
+ operation (Callable): NumPy ufunc (e.g. ``np.add``, ``np.subtract``).
262
+ operation_name (str): Human-readable name for error messages.
263
+ inplace (bool): If True, mutate ``bd`` in place.
264
+ reverse (bool): If True, reverse operand order (for ``__rsub__`` etc.).
265
+
266
+ Returns:
267
+ BrainData: Result of the operation.
268
+ """
269
+ from .validation import _validate_arithmetic_operand, _validate_brain_data_shapes
270
+
271
+ operand_type = _validate_arithmetic_operand(other, operation_name)
272
+
273
+ if operand_type == "scalar":
274
+ if reverse:
275
+ result_data = operation(other, bd.data)
276
+ else:
277
+ result_data = operation(bd.data, other)
278
+ elif operand_type == "brain_data":
279
+ _validate_brain_data_shapes(bd, other, operation_name)
280
+ if reverse:
281
+ result_data = operation(other.data, bd.data)
282
+ else:
283
+ result_data = operation(bd.data, other.data)
284
+ elif operand_type == "array":
285
+ if len(other) != len(bd):
286
+ raise ValueError(
287
+ f"Vector {operation_name} requires that the length of the vector "
288
+ f"({len(other)}) match the number of images ({len(bd)})"
289
+ )
290
+ result_data = np.dot(bd.data.T, other).T
291
+
292
+ if inplace:
293
+ _clear_fit_state(bd)
294
+ bd.data = result_data
295
+ if operand_type == "array":
296
+ bd.X = None
297
+ bd.Y = None
298
+ return bd
299
+ return _result_from_array(
300
+ bd, result_data, rows="clear" if operand_type == "array" else "preserve"
301
+ )
302
+
303
+
304
+ def _apply_func(bd, stat_func, axis=0):
305
+ """Apply a statistical function to BrainData's ``.data`` attribute.
306
+
307
+ If *axis* is 0, returns a BrainData with the statistic computed across
308
+ samples (e.g. within a voxel over time). If *axis* is 1, returns a numpy
309
+ array with the statistic computed across features (e.g. across voxels
310
+ within a single time-point).
311
+
312
+ Args:
313
+ bd (BrainData): Data to reduce.
314
+ stat_func (Callable): Accepts an array and an ``axis`` kwarg.
315
+ axis (int): ``0`` = across images, ``1`` = within images.
316
+
317
+ Returns:
318
+ float | np.ndarray | BrainData: The reduced result; type depends on
319
+ whether the input is a single image and on ``axis``.
320
+ """
321
+ if _check_brain_data_is_single(bd):
322
+ return stat_func(bd.data)
323
+
324
+ if axis == 1:
325
+ return stat_func(bd.data, axis=1)
326
+ if axis == 0:
327
+ return _result_from_array(bd, stat_func(bd.data, axis=0), rows="clear")
328
+ raise ValueError("axis must be 0 or 1")
329
+
330
+
331
+ def _polars_row_select(df, index):
332
+ """Row-select a polars DataFrame by int / slice / int-array index.
333
+
334
+ Polars has no ``.iloc`` — this helper normalizes the three index
335
+ shapes BrainData's ``__getitem__`` hands it (pandas parity).
336
+ """
337
+ import polars as pl
338
+
339
+ if df.is_empty():
340
+ return df
341
+ if isinstance(index, (int, np.integer)):
342
+ return df.slice(int(index), 1)
343
+ if isinstance(index, slice):
344
+ return df[index]
345
+ idx = np.asarray(index).flatten()
346
+ if idx.dtype == bool:
347
+ return df.filter(pl.Series(idx))
348
+ return df[idx.tolist()]
@@ -0,0 +1,197 @@
1
+ """Input validation for `BrainData`.
2
+
3
+ Helpers that validate constructor inputs, array shapes, and operand
4
+ compatibility between `BrainData` objects and other data types.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import nibabel as nib
10
+ import numpy as np
11
+
12
+
13
+ def _validate_brain_data_shapes(brain1, brain2, operation="operation"):
14
+ """Validate shape compatibility between two BrainData objects.
15
+
16
+ Args:
17
+ brain1 (BrainData): First operand.
18
+ brain2 (BrainData): Second operand.
19
+ operation (str): Name of the operation for error messages.
20
+
21
+ Returns:
22
+ tuple[bool, bool]: ``(brain1_is_single, brain2_is_single)``.
23
+
24
+ Raises:
25
+ ValueError: If shapes are incompatible for the operation.
26
+ """
27
+ shape1, shape2 = brain1.shape, brain2.shape
28
+ brain1_is_single = len(shape1) == 1
29
+ brain2_is_single = len(shape2) == 1
30
+
31
+ if brain1_is_single and brain2_is_single:
32
+ if shape1[0] != shape2[0]:
33
+ raise ValueError(
34
+ f"Cannot {operation}: both images must have the same number of voxels. "
35
+ f"Image 1 has {shape1[0]} voxels, Image 2 has {shape2[0]} voxels."
36
+ )
37
+ elif brain1_is_single and not brain2_is_single:
38
+ raise ValueError(
39
+ f"Cannot {operation} multiple images to a single image. "
40
+ f"Image 1 is single, Image 2 has {shape2[0]} images."
41
+ )
42
+ elif not brain1_is_single and brain2_is_single:
43
+ if shape1[1] != shape2[0]:
44
+ raise ValueError(
45
+ f"Cannot {operation}: number of voxels must match. "
46
+ f"Image 1 has {shape1[1]} voxels, Image 2 has {shape2[0]} voxels."
47
+ )
48
+ elif not brain1_is_single and not brain2_is_single:
49
+ if shape1[0] != shape2[0] or shape1[1] != shape2[1]:
50
+ raise ValueError(
51
+ f"Cannot {operation} multiple images of different shapes. "
52
+ f"Image 1 shape: {shape1}, Image 2 shape: {shape2}"
53
+ )
54
+
55
+ return brain1_is_single, brain2_is_single
56
+
57
+
58
+ def _validate_arithmetic_operand(other, operation_name):
59
+ """Validate operand type for arithmetic operations.
60
+
61
+ Args:
62
+ other (object): The operand to validate.
63
+ operation_name (str): Name of the operation (e.g. ``'add'``,
64
+ ``'multiply'``).
65
+
66
+ Returns:
67
+ str: Type of operand ('scalar', 'brain_data', or 'array').
68
+
69
+ Raises:
70
+ ValueError: If operand type is not supported.
71
+ """
72
+ # Import here to avoid circular imports
73
+ from nltools.data import BrainData
74
+
75
+ if isinstance(other, (int, np.integer, float, np.floating)):
76
+ return "scalar"
77
+ if isinstance(other, BrainData):
78
+ return "brain_data"
79
+ if isinstance(other, (list, np.ndarray)) and operation_name == "multiply":
80
+ return "array"
81
+ valid_types = "int, float, or BrainData"
82
+ if operation_name == "multiply":
83
+ valid_types = "int, float, list, np.ndarray, or BrainData"
84
+ raise ValueError(
85
+ f"Cannot {operation_name} with type {type(other).__name__}. "
86
+ f"Operand must be {valid_types}."
87
+ )
88
+
89
+
90
+ def _validate_data_type(data):
91
+ """Validate input data type for BrainData initialization.
92
+
93
+ Args:
94
+ data (object): Constructor input to classify.
95
+
96
+ Returns:
97
+ str: One of ``'brain_data'``, ``'list'``, ``'h5'``, ``'url'``,
98
+ ``'file'``, ``'nibabel'``, ``'array'``, or ``'none'``.
99
+
100
+ Raises:
101
+ TypeError: If data type is not supported.
102
+ """
103
+ # Import here to avoid circular imports
104
+ from nltools.data import BrainData
105
+
106
+ if data is None:
107
+ return "none"
108
+ if isinstance(data, BrainData):
109
+ return "brain_data"
110
+ if isinstance(data, list):
111
+ return "list"
112
+ if isinstance(data, (str, Path)):
113
+ from nltools.io.h5 import _is_h5_path
114
+
115
+ data_str = str(data)
116
+ if _is_h5_path(data_str):
117
+ return "h5"
118
+ if "://" in data_str:
119
+ return "url"
120
+ return "file"
121
+ if isinstance(data, nib.Nifti1Image):
122
+ return "nibabel"
123
+ if isinstance(data, np.ndarray):
124
+ return "array"
125
+ raise TypeError(
126
+ f"Data must be a BrainData, filepath (str/Path), nibabel image, "
127
+ f"numpy array, or list of these types. Received {type(data).__name__}"
128
+ )
129
+
130
+
131
+ def _validate_list_data(data_list):
132
+ """Validate that all items in a list are the same type.
133
+
134
+ Args:
135
+ data_list (list): Items to validate.
136
+
137
+ Returns:
138
+ str: ``'brain_data'`` or ``'file'``.
139
+
140
+ Raises:
141
+ ValueError: If list contains mixed types or unsupported types.
142
+ """
143
+ if not data_list:
144
+ raise ValueError("List is empty")
145
+
146
+ # Import here to avoid circular imports
147
+ from nltools.data import BrainData
148
+
149
+ first_type = type(data_list[0])
150
+
151
+ # Check if all items are the same type
152
+ if not all(isinstance(x, first_type) for x in data_list):
153
+ raise ValueError(
154
+ "All items in the list must be the same type. "
155
+ "Found mixed types in the list."
156
+ )
157
+
158
+ # Determine what type we're dealing with
159
+ if isinstance(data_list[0], BrainData):
160
+ return "brain_data"
161
+ if isinstance(data_list[0], (str, Path, nib.Nifti1Image)):
162
+ return "file"
163
+ raise ValueError(
164
+ f"List items must be BrainData objects, file paths, or nibabel images. "
165
+ f"Found {first_type.__name__}"
166
+ )
167
+
168
+
169
+ def _validate_append_shapes(data1_shape, data2_shape):
170
+ """Validate shape compatibility for appending BrainData objects.
171
+
172
+ Args:
173
+ data1_shape (tuple[int, ...]): Shape of the first BrainData.
174
+ data2_shape (tuple[int, ...]): Shape of the BrainData being appended.
175
+
176
+ Raises:
177
+ ValueError: If shapes are incompatible for appending.
178
+ """
179
+ data1_is_single = len(data1_shape) == 1
180
+ data2_is_single = len(data2_shape) == 1
181
+
182
+ error_msg = (
183
+ f"Cannot append: incompatible number of voxels. "
184
+ f"Data 1 shape: {data1_shape}, Data 2 shape: {data2_shape}"
185
+ )
186
+
187
+ if data1_is_single and data2_is_single:
188
+ if data1_shape[0] != data2_shape[0]:
189
+ raise ValueError(error_msg)
190
+ elif data1_is_single and not data2_is_single:
191
+ if data1_shape[0] != data2_shape[1]:
192
+ raise ValueError(error_msg)
193
+ elif not data1_is_single and data2_is_single:
194
+ if data1_shape[1] != data2_shape[0]:
195
+ raise ValueError(error_msg)
196
+ elif data1_shape[1] != data2_shape[1]:
197
+ raise ValueError(error_msg)