brkraw 0.3.11__py3-none-any.whl → 0.5.0__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 (113) hide show
  1. brkraw/__init__.py +9 -3
  2. brkraw/apps/__init__.py +12 -0
  3. brkraw/apps/addon/__init__.py +30 -0
  4. brkraw/apps/addon/core.py +35 -0
  5. brkraw/apps/addon/dependencies.py +402 -0
  6. brkraw/apps/addon/installation.py +500 -0
  7. brkraw/apps/addon/io.py +21 -0
  8. brkraw/apps/hook/__init__.py +25 -0
  9. brkraw/apps/hook/core.py +636 -0
  10. brkraw/apps/loader/__init__.py +10 -0
  11. brkraw/apps/loader/core.py +622 -0
  12. brkraw/apps/loader/formatter.py +288 -0
  13. brkraw/apps/loader/helper.py +797 -0
  14. brkraw/apps/loader/info/__init__.py +11 -0
  15. brkraw/apps/loader/info/scan.py +85 -0
  16. brkraw/apps/loader/info/scan.yaml +90 -0
  17. brkraw/apps/loader/info/study.py +69 -0
  18. brkraw/apps/loader/info/study.yaml +156 -0
  19. brkraw/apps/loader/info/transform.py +92 -0
  20. brkraw/apps/loader/types.py +220 -0
  21. brkraw/cli/__init__.py +5 -0
  22. brkraw/cli/commands/__init__.py +2 -0
  23. brkraw/cli/commands/addon.py +327 -0
  24. brkraw/cli/commands/config.py +205 -0
  25. brkraw/cli/commands/convert.py +903 -0
  26. brkraw/cli/commands/hook.py +348 -0
  27. brkraw/cli/commands/info.py +74 -0
  28. brkraw/cli/commands/init.py +214 -0
  29. brkraw/cli/commands/params.py +106 -0
  30. brkraw/cli/commands/prune.py +288 -0
  31. brkraw/cli/commands/session.py +371 -0
  32. brkraw/cli/hook_args.py +80 -0
  33. brkraw/cli/main.py +83 -0
  34. brkraw/cli/utils.py +60 -0
  35. brkraw/core/__init__.py +13 -0
  36. brkraw/core/config.py +380 -0
  37. brkraw/core/entrypoints.py +25 -0
  38. brkraw/core/formatter.py +367 -0
  39. brkraw/core/fs.py +495 -0
  40. brkraw/core/jcamp.py +600 -0
  41. brkraw/core/layout.py +451 -0
  42. brkraw/core/parameters.py +781 -0
  43. brkraw/core/zip.py +1121 -0
  44. brkraw/dataclasses/__init__.py +14 -0
  45. brkraw/dataclasses/node.py +139 -0
  46. brkraw/dataclasses/reco.py +33 -0
  47. brkraw/dataclasses/scan.py +61 -0
  48. brkraw/dataclasses/study.py +131 -0
  49. brkraw/default/__init__.py +3 -0
  50. brkraw/default/pruner_specs/deid4share.yaml +42 -0
  51. brkraw/default/rules/00_default.yaml +4 -0
  52. brkraw/default/specs/metadata_dicom.yaml +236 -0
  53. brkraw/default/specs/metadata_transforms.py +92 -0
  54. brkraw/resolver/__init__.py +7 -0
  55. brkraw/resolver/affine.py +539 -0
  56. brkraw/resolver/datatype.py +69 -0
  57. brkraw/resolver/fid.py +90 -0
  58. brkraw/resolver/helpers.py +36 -0
  59. brkraw/resolver/image.py +188 -0
  60. brkraw/resolver/nifti.py +370 -0
  61. brkraw/resolver/shape.py +235 -0
  62. brkraw/schema/__init__.py +3 -0
  63. brkraw/schema/context_map.yaml +62 -0
  64. brkraw/schema/meta.yaml +57 -0
  65. brkraw/schema/niftiheader.yaml +95 -0
  66. brkraw/schema/pruner.yaml +55 -0
  67. brkraw/schema/remapper.yaml +128 -0
  68. brkraw/schema/rules.yaml +154 -0
  69. brkraw/specs/__init__.py +10 -0
  70. brkraw/specs/hook/__init__.py +12 -0
  71. brkraw/specs/hook/logic.py +31 -0
  72. brkraw/specs/hook/validator.py +22 -0
  73. brkraw/specs/meta/__init__.py +5 -0
  74. brkraw/specs/meta/validator.py +156 -0
  75. brkraw/specs/pruner/__init__.py +15 -0
  76. brkraw/specs/pruner/logic.py +361 -0
  77. brkraw/specs/pruner/validator.py +119 -0
  78. brkraw/specs/remapper/__init__.py +27 -0
  79. brkraw/specs/remapper/logic.py +924 -0
  80. brkraw/specs/remapper/validator.py +314 -0
  81. brkraw/specs/rules/__init__.py +6 -0
  82. brkraw/specs/rules/logic.py +263 -0
  83. brkraw/specs/rules/validator.py +103 -0
  84. brkraw-0.5.0.dist-info/METADATA +81 -0
  85. brkraw-0.5.0.dist-info/RECORD +88 -0
  86. {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info}/WHEEL +1 -2
  87. brkraw-0.5.0.dist-info/entry_points.txt +13 -0
  88. brkraw/lib/__init__.py +0 -4
  89. brkraw/lib/backup.py +0 -641
  90. brkraw/lib/bids.py +0 -0
  91. brkraw/lib/errors.py +0 -125
  92. brkraw/lib/loader.py +0 -1220
  93. brkraw/lib/orient.py +0 -194
  94. brkraw/lib/parser.py +0 -48
  95. brkraw/lib/pvobj.py +0 -301
  96. brkraw/lib/reference.py +0 -245
  97. brkraw/lib/utils.py +0 -471
  98. brkraw/scripts/__init__.py +0 -0
  99. brkraw/scripts/brk_backup.py +0 -106
  100. brkraw/scripts/brkraw.py +0 -744
  101. brkraw/ui/__init__.py +0 -0
  102. brkraw/ui/config.py +0 -17
  103. brkraw/ui/main_win.py +0 -214
  104. brkraw/ui/previewer.py +0 -225
  105. brkraw/ui/scan_info.py +0 -72
  106. brkraw/ui/scan_list.py +0 -73
  107. brkraw/ui/subj_info.py +0 -128
  108. brkraw-0.3.11.dist-info/METADATA +0 -25
  109. brkraw-0.3.11.dist-info/RECORD +0 -28
  110. brkraw-0.3.11.dist-info/entry_points.txt +0 -3
  111. brkraw-0.3.11.dist-info/top_level.txt +0 -2
  112. tests/__init__.py +0 -0
  113. {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,622 @@
1
+ """Loader utilities that attach conversion helpers to Bruker scans.
2
+
3
+ This module binds helper methods onto Scan/Reco objects so callers can fetch
4
+ reconstructed data, affines, NIfTI images, metadata, and parameter search
5
+ results with a simple API.
6
+
7
+ Last updated: 2025-12-30
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from types import MethodType
13
+ import re
14
+ import logging
15
+ import sys
16
+ from functools import partial
17
+ from typing import TYPE_CHECKING, Optional, Tuple, Union, Any, Mapping, Callable, cast, List, Dict, Iterable, Literal
18
+ from pathlib import Path
19
+
20
+ from ...core import config as config_core
21
+ from ...core.config import resolve_root
22
+ from ...specs import hook as converter_core
23
+ from ...specs.pruner import prune_dataset_to_zip
24
+ from ...specs.rules import load_rules, select_rule_use
25
+ from ...dataclasses import Scan, Study
26
+ from .types import StudyLoader, ScanLoader, RecoLoader
27
+ from .formatter import format_info_tables
28
+
29
+ logger = logging.getLogger("brkraw")
30
+ from . import info as info_resolver
31
+ from .helper import (
32
+ make_dir,
33
+ convert as _convert,
34
+ get_affine as _get_affine,
35
+ get_dataobj as _get_dataobj,
36
+ get_metadata as _get_metadata,
37
+ get_nifti1image as _get_nifti1image,
38
+ search_parameters as _search_parameters,
39
+ apply_converter_hook as _apply_converter_hook,
40
+ resolve_data_and_affine as _resolve_data_and_affine,
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ import numpy as np
45
+ from pathlib import Path
46
+ from ...resolver.nifti import Nifti1HeaderContents
47
+ from .types import XYZUNIT, TUNIT, SubjectType, SubjectPose, InfoScope, AffineSpace
48
+
49
+
50
+
51
+ class BrukerLoader:
52
+ """High-level entrypoint that resolves scans and exposes handy accessors."""
53
+
54
+ def __init__(self,
55
+ path: Union[str, Path],
56
+ affine_decimals: Optional[int] = None):
57
+ """
58
+ Create a loader for a Bruker study rooted at `path`.
59
+
60
+ This resolves image/affine metadata for each available scan and binds
61
+ convenience methods (`get_dataobj`, `get_affine`) directly onto scan
62
+ instances for downstream use.
63
+
64
+ Args:
65
+ path: Path to the study root.
66
+ affine_decimals: Decimal rounding applied to resolved affines.
67
+ """
68
+ self._study: Union["Study", "StudyLoader"] = Study.from_path(path)
69
+ if affine_decimals is None:
70
+ affine_decimals = config_core.float_decimals(root=resolve_root(None))
71
+ self._affine_decimals = affine_decimals
72
+ self._sw_version: Optional[str] = self._parse_sw_version()
73
+ self._attach_helpers()
74
+
75
+ def _parse_sw_version(self) -> Optional[str]:
76
+ """Resolve Paravision version from subject header or visu_pars."""
77
+ def _clean(value: object) -> Optional[str]:
78
+ if value is None:
79
+ return None
80
+ text = str(value).strip()
81
+ if text.startswith("<") and text.endswith(">"):
82
+ text = text[1:-1]
83
+ return text.strip()
84
+
85
+ def _parse_title(text: str) -> Optional[str]:
86
+ if not text:
87
+ return None
88
+ if text == "Parameter List":
89
+ return "5.1"
90
+ match = re.search(r"ParaVision\s+(\d+\.\d+\.\d+)", text)
91
+ if match:
92
+ return match.group(1)
93
+ match = re.search(r"ParaVision\s+360\s+V(\d+)\.(\d+)", text)
94
+ if match:
95
+ return f"360.{match.group(1)}.{match.group(2)}"
96
+ return None
97
+
98
+ def _parse_visu(text: str) -> Optional[str]:
99
+ if not text:
100
+ return None
101
+ match = re.search(r"(\d+\.\d+\.\d+)", text)
102
+ if match:
103
+ return match.group(1)
104
+ return None
105
+
106
+ study = self._study
107
+ try:
108
+ if getattr(study, "has_subject", False):
109
+ subject = getattr(study, "subject", None)
110
+ title = getattr(subject, "header", {}).get("TITLE")
111
+ cleaned = _clean(title)
112
+ parsed = _parse_title(cleaned or "")
113
+ if parsed:
114
+ return parsed
115
+ except Exception:
116
+ pass
117
+
118
+ try:
119
+ scan = next(iter(study.avail.values()))
120
+ reco = next(iter(scan.avail.values()))
121
+ from ...core.parameters import Parameters
122
+
123
+ visu_pars = cast(Parameters, reco.visu_pars)
124
+ value = None
125
+ try:
126
+ value = visu_pars["VisuCoreVersion"]
127
+ except Exception:
128
+ value = None
129
+ if value is None:
130
+ try:
131
+ value = visu_pars["VisuCreatorVersion"]
132
+ except Exception:
133
+ value = None
134
+ cleaned = _clean(value)
135
+ parsed = _parse_visu(cleaned or "")
136
+ return parsed
137
+ except Exception:
138
+ return None
139
+
140
+ def _attach_helpers(self):
141
+ """Resolve per-scan metadata and bind helper methods."""
142
+ self._study = cast(StudyLoader, self._study)
143
+ self._study.search_params = MethodType(_search_parameters, self._study)
144
+ base = resolve_root(None)
145
+ try:
146
+ rules = load_rules(root=base, validate=False)
147
+ except Exception:
148
+ rules = {}
149
+ for scan in self._study.avail.values():
150
+ _resolve_data_and_affine(
151
+ scan,
152
+ affine_decimals=self._affine_decimals
153
+ )
154
+ scan = cast(ScanLoader, scan)
155
+
156
+ # bind helper functions as methods on the scan instance
157
+ scan.get_dataobj = MethodType(_get_dataobj, scan)
158
+ scan.get_affine = MethodType(
159
+ partial(_get_affine, decimals=self._affine_decimals),
160
+ scan,
161
+ )
162
+ scan.get_nifti1image = MethodType(_get_nifti1image, scan)
163
+ scan.convert = MethodType(_convert, scan)
164
+ scan._converter_hook = None
165
+ scan._converter_hook_name = None
166
+ if rules:
167
+ try:
168
+ hook_name = select_rule_use(
169
+ scan,
170
+ rules.get("converter_hook", []),
171
+ base=base,
172
+ resolve_paths=False,
173
+ )
174
+ except Exception as exc:
175
+ logger.debug(
176
+ "Converter hook rule selection failed for scan %s: %s",
177
+ getattr(scan, "scan_id", "?"),
178
+ exc,
179
+ exc_info=True,
180
+ )
181
+ hook_name = None
182
+ if isinstance(hook_name, str):
183
+ try:
184
+ entry = converter_core.resolve_hook(hook_name)
185
+ except Exception as exc:
186
+ logger.warning(
187
+ "Converter hook %r not available: %s",
188
+ hook_name,
189
+ exc,
190
+ )
191
+ entry = None
192
+ if entry:
193
+ logger.debug("Applying converter hook: %s", hook_name)
194
+ scan._converter_hook_name = hook_name
195
+ _apply_converter_hook(
196
+ scan,
197
+ entry,
198
+ affine_decimals=self._affine_decimals,
199
+ )
200
+ else:
201
+ logger.debug("Converter hook %r resolved to no entry.", hook_name)
202
+ else:
203
+ logger.debug("No converter hook selected for scan %s.", getattr(scan, "scan_id", "?"))
204
+ scan.get_metadata = MethodType(_get_metadata, scan)
205
+ scan.search_params = MethodType(_search_parameters, scan)
206
+ for reco in scan.avail.values():
207
+ reco = cast(RecoLoader, reco)
208
+ reco.search_params = MethodType(_search_parameters, reco)
209
+
210
+ def search_params(self, key: str,
211
+ *,
212
+ file: Optional[Union[str, List[str]]] = None,
213
+ scan_id: Optional[int] = None,
214
+ reco_id: Optional[int] = None):
215
+ """Search parameter files for keys on study/scan/reco objects.
216
+
217
+ Args:
218
+ key: Parameter key to search for.
219
+ file: Filename or list of filenames to search.
220
+ scan_id: Scan id (required when searching from Study).
221
+ reco_id: Reco id (optional; flattens results for that reco).
222
+
223
+ Returns:
224
+ Mapping of filename to found values, or None if no hits.
225
+ """
226
+ self._study = cast(StudyLoader, self._study)
227
+ return self._study.search_params(key, file=file, scan_id=scan_id, reco_id=reco_id)
228
+
229
+ def override_converter(
230
+ self,
231
+ scan_id: int,
232
+ converter_hook: Mapping[str, Callable[..., Any]],
233
+ ) -> None:
234
+ """Override scan conversion methods with a converter hook.
235
+
236
+ Args:
237
+ scan_id: Scan identifier.
238
+ converter_hook: Mapping of method names to callables. Only
239
+ provided keys are overridden.
240
+ """
241
+ scan = self.avail[scan_id]
242
+ scan = cast(ScanLoader, scan)
243
+ _apply_converter_hook(
244
+ scan,
245
+ converter_hook,
246
+ affine_decimals=self._affine_decimals,
247
+ )
248
+
249
+ def restore_converter(self, scan_id: int) -> None:
250
+ """Restore default conversion methods for a scan.
251
+
252
+ Args:
253
+ scan_id: Scan identifier.
254
+ """
255
+ scan = self.avail[scan_id]
256
+ scan = cast(ScanLoader, scan)
257
+ scan.get_dataobj = MethodType(_get_dataobj, scan)
258
+ scan.get_affine = MethodType(
259
+ partial(_get_affine, decimals=self._affine_decimals),
260
+ scan,
261
+ )
262
+ scan.get_nifti1image = MethodType(_get_nifti1image, scan)
263
+ scan.convert = MethodType(_convert, scan)
264
+ scan._converter_hook = None
265
+
266
+ def get_scan(self, scan_id: int) -> "ScanLoader":
267
+ """Return scan by id.
268
+
269
+ Args:
270
+ scan_id: Scan identifier.
271
+
272
+ Returns:
273
+ Scan loader instance.
274
+ """
275
+ scan = self._study.get_scan(scan_id)
276
+ scan = cast(ScanLoader, scan)
277
+ return scan
278
+
279
+ def get_fid(
280
+ self,
281
+ scan_id: int,
282
+ buffer_start: Optional[int] = None,
283
+ buffer_size: Optional[int] = None,
284
+ *,
285
+ as_complex: bool = True,
286
+ ) -> Optional["np.ndarray"]:
287
+ """Return FID/rawdata for a scan.
288
+
289
+ Args:
290
+ scan_id: Scan identifier.
291
+ buffer_start: Optional byte offset to start reading.
292
+ buffer_size: Optional number of bytes to read.
293
+ as_complex: If True, return complex samples (default: True).
294
+
295
+ Returns:
296
+ NumPy array of samples.
297
+ """
298
+ scan = self.get_scan(scan_id)
299
+ if not hasattr(scan, 'get_fid'):
300
+ return None
301
+ return scan.get_fid(buffer_start=buffer_start, buffer_size=buffer_size, as_complex=as_complex)
302
+
303
+ def get_dataobj(self, scan_id: int, reco_id: Optional[int] = None) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
304
+ """Return reconstructed data for a scan/reco via attached helper.
305
+
306
+ Args:
307
+ scan_id: Scan identifier.
308
+ reco_id: Reco identifier (defaults to the first available).
309
+
310
+ Returns:
311
+ Single ndarray when one slice pack exists; otherwise a tuple.
312
+ """
313
+ scan = self.get_scan(scan_id)
314
+ return scan.get_dataobj(reco_id)
315
+
316
+ def get_affine(self,
317
+ scan_id: int,
318
+ reco_id: Optional[int] = None,
319
+ *,
320
+ space: AffineSpace = 'subject_ras',
321
+ override_subject_type: Optional[SubjectType] = None,
322
+ override_subject_pose: Optional[SubjectPose] = None,
323
+ decimals: Optional[int] = None
324
+ ) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
325
+ """Return affine(s) for a scan/reco via attached helper.
326
+
327
+ Args:
328
+ scan_id: Scan identifier.
329
+ reco_id: Reco identifier (defaults to the first available).
330
+ space: Output affine space ("raw", "scanner", "subject_ras").
331
+ override_subject_type: Subject type override for subject view.
332
+ override_subject_pose: Subject pose override for subject view.
333
+ decimals: Optional decimal rounding applied to returned affines.
334
+
335
+ Returns:
336
+ Single affine matrix when one slice pack exists; otherwise a tuple.
337
+ """
338
+ scan = self.get_scan(scan_id)
339
+ decimals = decimals or self._affine_decimals
340
+ return scan.get_affine(reco_id,
341
+ space=space,
342
+ override_subject_pose=override_subject_pose,
343
+ override_subject_type=override_subject_type,
344
+ decimals=decimals)
345
+
346
+ def get_nifti1image(self, scan_id: int, reco_id: Optional[int] = None,
347
+ *,
348
+ space: AffineSpace = 'subject_ras',
349
+ override_header: Optional[Nifti1HeaderContents] = None,
350
+ override_subject_type: Optional[SubjectType] = None,
351
+ override_subject_pose: Optional[SubjectPose] = None,
352
+ flip_x: bool = False,
353
+ flatten_fg: bool = False,
354
+ xyz_units: XYZUNIT = 'mm',
355
+ t_units: TUNIT = 'sec'):
356
+ """Return NIfTI image(s) for a scan/reco via attached helper.
357
+
358
+ Args:
359
+ scan_id: Scan identifier.
360
+ reco_id: Reco identifier (defaults to the first available).
361
+ space: Output affine space ("raw", "scanner", "subject_ras").
362
+ override_header: Optional header values to apply.
363
+ override_subject_type: Subject type override for subject view.
364
+ override_subject_pose: Subject pose override for subject view.
365
+ flip_x: If True, set NIfTI header x-flip flag.
366
+ xyz_units: Spatial units for NIfTI header.
367
+ t_units: Temporal units for NIfTI header.
368
+
369
+ Returns:
370
+ Single NIfTI image when one slice pack exists; otherwise a tuple.
371
+ """
372
+ scan = self.get_scan(scan_id)
373
+ return scan.convert(
374
+ reco_id,
375
+ format="nifti",
376
+ space=space,
377
+ override_header=override_header,
378
+ override_subject_type=override_subject_type,
379
+ override_subject_pose=override_subject_pose,
380
+ flip_x=flip_x,
381
+ flatten_fg=flatten_fg,
382
+ xyz_units=xyz_units,
383
+ t_units=t_units,
384
+ )
385
+
386
+ def convert(
387
+ self,
388
+ scan_id: int,
389
+ reco_id: Optional[int] = None,
390
+ *,
391
+ format: Literal["nifti", "nifti1"] = "nifti",
392
+ space: AffineSpace = 'subject_ras',
393
+ override_header: Optional[Nifti1HeaderContents] = None,
394
+ override_subject_type: Optional[SubjectType] = None,
395
+ override_subject_pose: Optional[SubjectPose] = None,
396
+ flip_x: bool = False,
397
+ flatten_fg: bool = False,
398
+ xyz_units: XYZUNIT = "mm",
399
+ t_units: TUNIT = "sec",
400
+ hook_args_by_name: Optional[Mapping[str, Mapping[str, Any]]] = None,
401
+ ):
402
+ """Convert a scan/reco to the requested output format."""
403
+ scan = self.get_scan(scan_id)
404
+ return scan.convert(
405
+ reco_id,
406
+ format=format,
407
+ space=space,
408
+ override_header=override_header,
409
+ override_subject_type=override_subject_type,
410
+ override_subject_pose=override_subject_pose,
411
+ flip_x=flip_x,
412
+ flatten_fg=flatten_fg,
413
+ xyz_units=xyz_units,
414
+ t_units=t_units,
415
+ hook_args_by_name=hook_args_by_name,
416
+ )
417
+
418
+ def get_metadata(
419
+ self,
420
+ scan_id: int,
421
+ reco_id: Optional[int] = None,
422
+ spec: Optional[Union[Mapping[str, Any], str, Path]] = None,
423
+ context_map: Optional[Union[str, Path]] = None,
424
+ return_spec: bool = False,
425
+ ):
426
+ """Return metadata for a scan/reco.
427
+
428
+ Args:
429
+ scan_id: Scan identifier.
430
+ reco_id: Reco identifier (defaults to the first available).
431
+ spec: Optional spec mapping or spec file path.
432
+ context_map: Optional context map override.
433
+ return_spec: If True, return spec info alongside metadata.
434
+
435
+ Returns:
436
+ Mapping of metadata fields, or None when no spec matches. When
437
+ return_spec is True, returns (metadata, spec_info).
438
+ """
439
+ scan = self.get_scan(scan_id)
440
+ return scan.get_metadata(
441
+ reco_id=reco_id,
442
+ spec=spec,
443
+ context_map=context_map,
444
+ return_spec=return_spec,
445
+ )
446
+
447
+ def prune_to_zip(
448
+ self,
449
+ dest: Union[str, Path],
450
+ files: Iterable[str],
451
+ *,
452
+ mode: Literal["keep", "drop"] = "keep",
453
+ update_params: Optional[Mapping[str, Mapping[str, Optional[str]]]] = None,
454
+ add_root: bool = True,
455
+ root_name: Optional[str] = None,
456
+ ) -> "BrukerLoader":
457
+ """Create a pruned dataset zip and return a loader for it.
458
+
459
+ Args:
460
+ dest: Destination zip path.
461
+ files: Filenames or relative paths used by the selection mode.
462
+ mode: "keep" to include only matching files, "drop" to exclude them.
463
+ update_params: Mapping or YAML path of {filename: {key: value}} JCAMP edits.
464
+ add_root: Whether to include a top-level root directory in the zip.
465
+ root_name: Override the root directory name when add_root is True.
466
+
467
+ Returns:
468
+ Loader bound to the newly created pruned zip.
469
+ """
470
+ source = self._study.fs.root
471
+ out_path = prune_dataset_to_zip(
472
+ source,
473
+ dest,
474
+ files=files,
475
+ mode=mode,
476
+ update_params=update_params,
477
+ add_root=add_root,
478
+ root_name=root_name,
479
+ )
480
+ return BrukerLoader(out_path, affine_decimals=self._affine_decimals)
481
+
482
+ @property
483
+ def avail(self) -> Mapping[int, Union["Scan", "ScanLoader"]]:
484
+ """Available scans keyed by scan id."""
485
+ return self._study.avail
486
+
487
+ @property
488
+ def subject(self) -> Optional[Dict[str, Any]]:
489
+ """Parsed study/subject info resolved from subject metadata.
490
+
491
+ Returns:
492
+ Mapping with Study/Subject entries, or None if resolution fails.
493
+ """
494
+ try:
495
+ return info_resolver.study(self)
496
+ except Exception:
497
+ return None
498
+
499
+ @property
500
+ def sw_version(self) -> Optional[str]:
501
+ """Resolved Paravision version string, if available."""
502
+ return self._sw_version
503
+
504
+ def info(
505
+ self,
506
+ scope: InfoScope = 'full',
507
+ *,
508
+ scan_id: Optional[Union[int, List[int]]] = None,
509
+ as_dict: bool = False,
510
+ scan_transpose: bool = True,
511
+ float_decimals: Optional[int] = None,
512
+ width: Optional[int] = None,
513
+ show_reco: bool = True,
514
+ ):
515
+ """Return study/scan summaries as a dict or formatted table.
516
+
517
+ Args:
518
+ scope: "full", "study", or "scan".
519
+ scan_id: Optional scan id or list of scan ids to include.
520
+ as_dict: If True, return a mapping; otherwise print a table and return None.
521
+ scan_transpose: If True, render scan fields in a transposed layout.
522
+ float_decimals: Decimal precision for floats (defaults to config).
523
+ width: Output table width (defaults to config).
524
+ show_reco: If False, omit reco entries from scan info.
525
+
526
+ Returns:
527
+ Mapping of info data, or None when formatted output is printed.
528
+ """
529
+ rules = {}
530
+ base = resolve_root(None)
531
+ scan_info: Dict[int, Any] = {}
532
+ if width is None:
533
+ width = config_core.output_width(root=base)
534
+ if float_decimals is None:
535
+ float_decimals = config_core.float_decimals(root=base)
536
+ try:
537
+ rules = load_rules(root=base, validate=False)
538
+ except Exception:
539
+ rules = {}
540
+
541
+ if scope in ['full', 'scan']:
542
+ if scan_id is None:
543
+ scan_ids = list(self.avail.keys())
544
+ elif isinstance(scan_id, list):
545
+ scan_ids = scan_id
546
+ else:
547
+ scan_ids = [scan_id]
548
+ for sid in scan_ids:
549
+ scan = cast(ScanLoader, self.avail[sid])
550
+ spec_path = None
551
+ if rules:
552
+ try:
553
+ spec_path = select_rule_use(
554
+ scan,
555
+ rules.get("info_spec", []),
556
+ base=base,
557
+ resolve_paths=True,
558
+ )
559
+ except Exception:
560
+ spec_path = None
561
+
562
+ if isinstance(spec_path, Path) and spec_path.exists():
563
+ scan_info[sid] = info_resolver.scan(scan, spec_source=spec_path)
564
+ else:
565
+ scan_info[sid] = info_resolver.scan(scan)
566
+
567
+ if not show_reco and isinstance(scan_info[sid], dict):
568
+ scan_info[sid].pop("Reco(s)", None)
569
+
570
+ if scope == 'scan':
571
+ if not as_dict:
572
+ config_core.configure_logging(root=base, stream=sys.stdout)
573
+ text = format_info_tables(
574
+ {"Scan(s)": scan_info},
575
+ width=width,
576
+ scan_indent=1,
577
+ reco_indent=1,
578
+ scan_transpose=scan_transpose,
579
+ float_decimals=float_decimals,
580
+ )
581
+ logger.info("%s", text)
582
+ return None
583
+ return scan_info
584
+
585
+ study_info = dict(self.subject) if self.subject else {}
586
+ if self.sw_version:
587
+ study_block = dict(study_info.get("Study", {}))
588
+ study_block = {"Software": f"Paravision v{self.sw_version}", **study_block}
589
+ study_info["Study"] = study_block
590
+
591
+ if scope == 'study':
592
+ if not as_dict:
593
+ config_core.configure_logging(root=base, stream=sys.stdout)
594
+ text = format_info_tables(
595
+ study_info,
596
+ width=width,
597
+ float_decimals=float_decimals,
598
+ )
599
+ logger.info("%s", text)
600
+ return None
601
+ return study_info
602
+
603
+ study_info['Scan(s)'] = scan_info
604
+ if not as_dict:
605
+ config_core.configure_logging(root=base, stream=sys.stdout)
606
+ text = format_info_tables(
607
+ study_info,
608
+ width=width,
609
+ scan_indent=1,
610
+ reco_indent=1,
611
+ scan_transpose=scan_transpose,
612
+ float_decimals=float_decimals,
613
+ )
614
+ logger.info("%s", text)
615
+ return None
616
+ return study_info
617
+
618
+ __all__ = [
619
+ "BrukerLoader",
620
+ ]
621
+
622
+ __dir__ = make_dir(__all__)