augmentrum 0.0.1__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 (42) hide show
  1. augmentrum/__init__.py +23 -0
  2. augmentrum/augmentation/__init__.py +32 -0
  3. augmentrum/augmentation/amplitude_scaling.py +170 -0
  4. augmentrum/augmentation/apodization.py +275 -0
  5. augmentrum/augmentation/artificial_peaks.py +334 -0
  6. augmentrum/augmentation/baseline_augmentation.py +544 -0
  7. augmentrum/augmentation/eddy_current.py +278 -0
  8. augmentrum/augmentation/gaussian_noise.py +211 -0
  9. augmentrum/augmentation/line_broadening.py +179 -0
  10. augmentrum/augmentation/phase_frequency.py +259 -0
  11. augmentrum/augmentation/residual_water.py +264 -0
  12. augmentrum/augmentation/spatial_augmentations.py +859 -0
  13. augmentrum/augmentation/spurious_echoes.py +378 -0
  14. augmentrum/augmentation/zero_fill.py +163 -0
  15. augmentrum/core/__init__.py +10 -0
  16. augmentrum/core/augmentrum.py +1172 -0
  17. augmentrum/core/base_module.py +423 -0
  18. augmentrum/core/dataset_utils.py +417 -0
  19. augmentrum/core/nifti_mrs_plus.py +1079 -0
  20. augmentrum/core/pipeline.py +358 -0
  21. augmentrum/dataset/brainbeats.py +537 -0
  22. augmentrum/dataset/cows.py +302 -0
  23. augmentrum/dataset/fmrsinpain.py +208 -0
  24. augmentrum/processing/bicubic_interpolator.py +296 -0
  25. augmentrum/processing/nifti_raw_processor.py +389 -0
  26. augmentrum/processing/tricubic_interpolator.py +293 -0
  27. augmentrum/processing/utils.py +414 -0
  28. augmentrum/resources/__init__.py +14 -0
  29. augmentrum/resources/scans/__init__.py +0 -0
  30. augmentrum/sampling/coil_average_sampler.py +274 -0
  31. augmentrum/sampling/kspace_reconstructor.py +275 -0
  32. augmentrum/sampling/kspace_sampling.py +2562 -0
  33. augmentrum/sampling/subject_splitter.py +131 -0
  34. augmentrum/utils/__init__.py +10 -0
  35. augmentrum/utils/philips.py +90 -0
  36. augmentrum/utils/plotting.py +651 -0
  37. augmentrum/utils/tensor_ops.py +203 -0
  38. augmentrum-0.0.1.dist-info/METADATA +189 -0
  39. augmentrum-0.0.1.dist-info/RECORD +42 -0
  40. augmentrum-0.0.1.dist-info/WHEEL +5 -0
  41. augmentrum-0.0.1.dist-info/licenses/LICENSE +202 -0
  42. augmentrum-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2562 @@
1
+ """
2
+ kspace_sampling.py
3
+ ==================
4
+ A backend-agnostic Python module for generating k-space sampling trajectories
5
+ and undersampling masks for MRI/MRS simulation pipelines.
6
+
7
+ Design Choices
8
+ --------------
9
+ Non-Cartesian trajectories default to center-out readout ordering, which places
10
+ the most signal-rich k-space center at the beginning of each shot and improves
11
+ compatibility with variable-density density-compensation functions. Golden-angle
12
+ and phyllotaxis-based orderings are used wherever angular distribution matters,
13
+ because any prefix of N shots retains near-uniform angular coverage — a critical
14
+ property for retrospective undersampling studies and for incrementally acquiring
15
+ datasets. Density estimates are recomputed analytically for every subset so that
16
+ downstream DCF pipelines receive a geometry-consistent starting point. No
17
+ gridding, reconstruction, or DCF computation is performed here; this module
18
+ exclusively returns coordinate arrays and boolean masks.
19
+
20
+ Interface Summary (plain text)
21
+ ------------------------------
22
+ read_header_geometry(header: dict) -> dict
23
+ generate_trajectory(name: str, header: dict, params: dict, backend) -> (list[array], dict)
24
+ shots_from_continuous(coords: array, segmentation: dict, backend) -> list[array]
25
+ undersample_shots(coords_per_shot: list, method: str, AF: float, params: dict, backend)
26
+ -> (array[bool], list[array[bool]] | None)
27
+ get_kspace_shots_and_mask(header, trajectory, undersampling, AF, traj_params, us_params, backend)
28
+ -> (list[array], array[bool], dict)
29
+
30
+ Units: all coordinates in cycles per metre (1/m), k-space centre at 0.
31
+ Conversion: k [1/m] = k_index / FOV_m where FOV_m = FOV_mm / 1000.
32
+
33
+ Backend must provide (see BackendInterface docstring below):
34
+ array, zeros, ones, linspace, stack, concat, sin, cos, sqrt, pi,
35
+ asarray, shape, to_cpu, floor, ceil, arange, randperm, meshgrid,
36
+ cast_bool, cast_int, cast_float, full.
37
+
38
+ Dependencies: numpy (always available); torch (optional, only if TorchBackend used).
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import math
44
+ import warnings
45
+ from typing import Any, Dict, List, Optional, Tuple, Union
46
+
47
+ import numpy as np
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Type aliases
51
+ # ---------------------------------------------------------------------------
52
+ Array = Any # numpy ndarray or torch.Tensor
53
+ Backend = Any # NumpyBackend or TorchBackend instance
54
+ HeaderDict = Dict[str, Any]
55
+ MetaDict = Dict[str, Any]
56
+ ParamsDict = Dict[str, Any]
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Constants
60
+ # ---------------------------------------------------------------------------
61
+ GOLDEN_ANGLE_RAD: float = math.pi * (3.0 - math.sqrt(5.0)) # ≈ 2.39996 rad
62
+ GOLDEN_RATIO: float = (1.0 + math.sqrt(5.0)) / 2.0 # ≈ 1.61803
63
+
64
+ # Trajectory → compatible undersampling strategies
65
+ _TRAJ_US_COMPAT: Dict[str, List[str]] = {
66
+ "cartesian_2d": ["keep_acs", "poisson_disc_cartesian", "random_vd",
67
+ "drop_every", "prefix", "combined"],
68
+ "cartesian_3d": ["keep_acs", "poisson_disc_cartesian", "random_vd",
69
+ "drop_every", "prefix", "combined"],
70
+ "radial_2d": ["prefix", "golden_prefix", "drop_every",
71
+ "random_vd", "keep_acs", "combined"],
72
+ "golden_radial_2d": ["prefix", "golden_prefix", "drop_every",
73
+ "random_vd", "keep_acs", "combined"],
74
+ "spiral_2d": ["prefix", "drop_every", "variable_density_spiral",
75
+ "combined"],
76
+ "rosette_2d": ["prefix", "drop_every", "combined"],
77
+ "rosette_2d_petals": ["prefix", "drop_every", "combined"],
78
+ "concentric_rings_2d": ["prefix", "drop_every", "combined"],
79
+ "stack_of_stars": ["prefix", "drop_every", "random_vd", "keep_acs",
80
+ "combined"],
81
+ "stack_of_spirals": ["prefix", "drop_every", "combined"],
82
+ "stack_of_cones": ["prefix", "drop_every", "combined"],
83
+ "stack_of_rosettes": ["prefix", "drop_every", "combined"],
84
+ "stack_of_ECCENTRIC":["prefix", "drop_every", "combined"],
85
+ "3d_phyllotaxis": ["prefix", "golden_prefix", "drop_every",
86
+ "random_vd", "shell_based", "combined"],
87
+ "cones_3d": ["prefix", "drop_every", "shell_based", "combined"],
88
+ "cones_3d_rosette": ["prefix", "drop_every", "shell_based", "combined"],
89
+ "floret_3d": ["prefix", "drop_every", "shell_based", "combined"],
90
+ "3d_egg_rosette": ["prefix", "golden_prefix", "drop_every",
91
+ "shell_based", "combined"],
92
+ }
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Backend Abstraction
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ class BackendInterface:
100
+ """
101
+ Required interface for any backend.
102
+
103
+ Methods
104
+ -------
105
+ array(data, dtype=None)
106
+ Create a backend array from a Python list or numpy array.
107
+ zeros(shape, dtype=None)
108
+ Array of zeros.
109
+ ones(shape, dtype=None)
110
+ Array of ones.
111
+ full(shape, fill_value, dtype=None)
112
+ Array filled with fill_value.
113
+ linspace(start, stop, num, endpoint)
114
+ Evenly-spaced values (like numpy.linspace).
115
+ arange(start, stop, step=1)
116
+ Evenly-spaced integers/floats (like numpy.arange).
117
+ stack(arrays, axis=0)
118
+ Stack sequence of arrays along a new axis.
119
+ concat(arrays, axis=0)
120
+ Concatenate arrays along an existing axis.
121
+ sin(x) / cos(x) / sqrt(x)
122
+ Element-wise trig / sqrt.
123
+ pi()
124
+ Return float pi.
125
+ asarray(x)
126
+ Convert to backend array (no-op if already correct type).
127
+ shape(x) -> tuple
128
+ Return shape as a plain Python tuple.
129
+ to_cpu(x) -> numpy.ndarray
130
+ Return numpy view/copy (for I/O and assertions).
131
+ floor(x) / ceil(x)
132
+ Element-wise floor / ceil.
133
+ cast_bool(x) -> array
134
+ Cast to boolean.
135
+ cast_int(x) -> array
136
+ Cast to int64.
137
+ cast_float(x) -> array
138
+ Cast to float32 or float64.
139
+ randperm(n) -> 1-D int array
140
+ Random permutation of range(n) — for random undersampling.
141
+ meshgrid(*arrays, indexing='ij')
142
+ N-D coordinate grids.
143
+ """
144
+
145
+
146
+ class NumpyBackend:
147
+ """Concrete backend using NumPy."""
148
+
149
+ def array(self, data, dtype=None):
150
+ return np.array(data, dtype=dtype)
151
+
152
+ def zeros(self, shape, dtype=None):
153
+ return np.zeros(shape, dtype=dtype or np.float32)
154
+
155
+ def ones(self, shape, dtype=None):
156
+ return np.ones(shape, dtype=dtype or np.float32)
157
+
158
+ def full(self, shape, fill_value, dtype=None):
159
+ return np.full(shape, fill_value, dtype=dtype or np.float32)
160
+
161
+ def linspace(self, start, stop, num, endpoint=True):
162
+ return np.linspace(start, stop, int(num), dtype=np.float32, endpoint=endpoint)
163
+
164
+ def arange(self, start, stop=None, step=1):
165
+ if stop is None:
166
+ return np.arange(start, dtype=np.float32)
167
+ return np.arange(start, stop, step, dtype=np.float32)
168
+
169
+ def stack(self, arrays, axis=0):
170
+ return np.stack(arrays, axis=axis)
171
+
172
+ def concat(self, arrays, axis=0):
173
+ return np.concatenate(arrays, axis=axis)
174
+
175
+ def sin(self, x):
176
+ return np.sin(x)
177
+
178
+ def cos(self, x):
179
+ return np.cos(x)
180
+
181
+ def sqrt(self, x):
182
+ return np.sqrt(x)
183
+
184
+ def pi(self):
185
+ return math.pi
186
+
187
+ def asarray(self, x):
188
+ return np.asarray(x, dtype=np.float32)
189
+
190
+ def shape(self, x):
191
+ return tuple(x.shape)
192
+
193
+ def to_cpu(self, x):
194
+ if isinstance(x, np.ndarray):
195
+ return x
196
+ return np.array(x)
197
+
198
+ def floor(self, x):
199
+ return np.floor(x)
200
+
201
+ def ceil(self, x):
202
+ return np.ceil(x)
203
+
204
+ def cast_bool(self, x):
205
+ return x.astype(bool)
206
+
207
+ def cast_int(self, x):
208
+ return x.astype(np.int64)
209
+
210
+ def cast_float(self, x):
211
+ return x.astype(np.float32)
212
+
213
+ def randperm(self, n):
214
+ return np.random.permutation(n).astype(np.int64)
215
+
216
+ def meshgrid(self, *arrays, indexing="ij"):
217
+ return np.meshgrid(*arrays, indexing=indexing)
218
+
219
+ def __repr__(self):
220
+ return "NumpyBackend()"
221
+
222
+
223
+ class TorchBackend:
224
+ """
225
+ Concrete backend using PyTorch.
226
+
227
+ Requires: import torch
228
+ device keyword can be passed at construction, e.g. TorchBackend(device='cuda').
229
+ """
230
+
231
+ def __init__(self, device: str = "cpu", dtype=None):
232
+ import torch # noqa: F401 – deferred import so numpy-only usage works
233
+ self._torch = torch
234
+ self.device = torch.device(device)
235
+ self._dtype = dtype or torch.float32
236
+
237
+ def array(self, data, dtype=None):
238
+ dt = dtype or self._dtype
239
+ return self._torch.tensor(data, dtype=dt, device=self.device)
240
+
241
+ def zeros(self, shape, dtype=None):
242
+ return self._torch.zeros(shape, dtype=dtype or self._dtype, device=self.device)
243
+
244
+ def ones(self, shape, dtype=None):
245
+ return self._torch.ones(shape, dtype=dtype or self._dtype, device=self.device)
246
+
247
+ def full(self, shape, fill_value, dtype=None):
248
+ return self._torch.full(shape, fill_value, dtype=dtype or self._dtype,
249
+ device=self.device)
250
+
251
+ def linspace(self, start, stop, num):
252
+ return self._torch.linspace(start, stop, int(num), dtype=self._dtype,
253
+ device=self.device)
254
+
255
+ def arange(self, start, stop=None, step=1):
256
+ if stop is None:
257
+ return self._torch.arange(start, dtype=self._dtype, device=self.device)
258
+ return self._torch.arange(start, stop, step, dtype=self._dtype,
259
+ device=self.device)
260
+
261
+ def stack(self, arrays, axis=0):
262
+ return self._torch.stack(arrays, dim=axis)
263
+
264
+ def concat(self, arrays, axis=0):
265
+ return self._torch.cat(arrays, dim=axis)
266
+
267
+ def sin(self, x):
268
+ return self._torch.sin(x)
269
+
270
+ def cos(self, x):
271
+ return self._torch.cos(x)
272
+
273
+ def sqrt(self, x):
274
+ return self._torch.sqrt(x)
275
+
276
+ def pi(self):
277
+ return math.pi
278
+
279
+ def asarray(self, x):
280
+ if isinstance(x, self._torch.Tensor):
281
+ return x.to(dtype=self._dtype, device=self.device)
282
+ return self._torch.tensor(x, dtype=self._dtype, device=self.device)
283
+
284
+ def shape(self, x):
285
+ return tuple(x.shape)
286
+
287
+ def to_cpu(self, x):
288
+ if isinstance(x, self._torch.Tensor):
289
+ return x.detach().cpu().numpy()
290
+ return np.asarray(x)
291
+
292
+ def floor(self, x):
293
+ return self._torch.floor(x)
294
+
295
+ def ceil(self, x):
296
+ return self._torch.ceil(x)
297
+
298
+ def cast_bool(self, x):
299
+ return x.bool()
300
+
301
+ def cast_int(self, x):
302
+ return x.long()
303
+
304
+ def cast_float(self, x):
305
+ return x.to(self._dtype)
306
+
307
+ def randperm(self, n):
308
+ return self._torch.randperm(n, device=self.device)
309
+
310
+ def meshgrid(self, *arrays, indexing="ij"):
311
+ return self._torch.meshgrid(*arrays, indexing=indexing)
312
+
313
+ def __repr__(self):
314
+ return f"TorchBackend(device={self.device}, dtype={self._dtype})"
315
+
316
+
317
+ def _validate_backend(backend: Backend) -> None:
318
+ """Raise if backend is missing any required method."""
319
+ required = [
320
+ "array", "zeros", "ones", "full", "linspace", "arange",
321
+ "stack", "concat", "sin", "cos", "sqrt", "pi",
322
+ "asarray", "shape", "to_cpu", "floor", "ceil",
323
+ "cast_bool", "cast_int", "cast_float", "randperm", "meshgrid",
324
+ ]
325
+ missing = [m for m in required if not hasattr(backend, m)]
326
+ if missing:
327
+ raise TypeError(
328
+ f"Backend {backend!r} is missing required methods: {missing}. "
329
+ "Implement a class with all methods listed in BackendInterface."
330
+ )
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Header Geometry
335
+ # ---------------------------------------------------------------------------
336
+
337
+ # Standard NIfTI-MRS header field names (for documentation & parsing)
338
+ _NIFTI_MRS_FIELDS = {
339
+ "pixdim": "voxel sizes (mm) stored at pixdim[1:4]",
340
+ "dim": "matrix dimensions stored at dim[1:4]",
341
+ "qform_code": "qform code (non-zero → use qform)",
342
+ "sform_code": "sform code (non-zero → use sform)",
343
+ "SpectrometerFrequency":"transmitter centre frequency [Hz] list",
344
+ "DwellTime": "ADC dwell time [s] per spectral point",
345
+ "ResonantNucleus": "nucleus label e.g. '1H'",
346
+ }
347
+
348
+
349
+ def read_header_geometry(header: HeaderDict) -> dict:
350
+ """
351
+ Parse a NIfTI-MRS header dictionary and return imaging geometry fields.
352
+
353
+ Parameters
354
+ ----------
355
+ header : dict
356
+ Parsed NIfTI-MRS header. Recognised standard fields (see _NIFTI_MRS_FIELDS):
357
+ - ``dim`` : array-like, dim[1..3] → matrix [nx, ny, nz]
358
+ - ``pixdim`` : array-like, pixdim[1..3] → voxel size [vx, vy, vz] in mm
359
+ - ``SpectrometerFrequency`` : list[float] → centre frequency in Hz
360
+ - ``DwellTime`` : float → ADC dwell time in seconds
361
+ Fallback keys also accepted: ``matrix``, ``voxel_size_mm``,
362
+ ``fov_mm``, ``dwell_time``.
363
+
364
+ Returns
365
+ -------
366
+ dict with keys:
367
+ fov_mm : tuple (fx, fy, fz) — field of view in mm
368
+ matrix : tuple (nx, ny, nz) — matrix size
369
+ voxel_mm : tuple (vx, vy, vz) — voxel size in mm
370
+ dwell_time : float or None
371
+ spectrometer_frequency : float or None
372
+ ndim : 2 or 3 (spatial)
373
+ inferred : list[str] — list of fields that were inferred rather than explicit
374
+ """
375
+ inferred: List[str] = []
376
+
377
+ # ---- Matrix ----
378
+ if "dim" in header:
379
+ dim = header["dim"]
380
+ nx, ny, nz = int(dim[1]), int(dim[2]), int(dim[3])
381
+ elif "matrix" in header:
382
+ m = header["matrix"]
383
+ nx, ny, nz = int(m[0]), int(m[1]), int(m[2]) if len(m) > 2 else 1
384
+ inferred.append("matrix from 'matrix' fallback key")
385
+ else:
386
+ raise KeyError(
387
+ "Header must contain 'dim' (NIfTI-MRS standard, dim[1:4] = [nx,ny,nz]) "
388
+ "or fallback key 'matrix'. Neither was found."
389
+ )
390
+
391
+ # ---- Voxel size ----
392
+ if "pixdim" in header:
393
+ pd = header["pixdim"]
394
+ vx, vy, vz = float(pd[1]), float(pd[2]), float(pd[3])
395
+ elif "voxel_size_mm" in header:
396
+ vs = header["voxel_size_mm"]
397
+ vx, vy, vz = (float(vs[0]), float(vs[1]),
398
+ float(vs[2]) if len(vs) > 2 else float(vs[0]))
399
+ inferred.append("voxel_mm from 'voxel_size_mm' fallback key")
400
+ elif "fov_mm" in header:
401
+ # derive voxel from FOV / matrix
402
+ fov = header["fov_mm"]
403
+ vx = float(fov[0]) / nx
404
+ vy = float(fov[1]) / ny
405
+ vz = float(fov[2]) / nz if len(fov) > 2 else 1.0
406
+ inferred.append("voxel_mm derived from fov_mm / matrix")
407
+ else:
408
+ raise KeyError(
409
+ "Header must contain 'pixdim' (NIfTI-MRS standard, pixdim[1:3] = voxel mm) "
410
+ "or fallback keys 'voxel_size_mm' or 'fov_mm'. None found."
411
+ )
412
+
413
+ fov_mm = (nx * vx, ny * vy, nz * vz)
414
+
415
+ # ---- Dwell time ----
416
+ dwell_time: Optional[float] = None
417
+ if "DwellTime" in header:
418
+ dwell_time = float(header["DwellTime"])
419
+ elif "dwell_time" in header:
420
+ dwell_time = float(header["dwell_time"])
421
+ inferred.append("dwell_time from 'dwell_time' fallback key")
422
+
423
+ # ---- Spectrometer frequency ----
424
+ spec_freq: Optional[float] = None
425
+ if "SpectrometerFrequency" in header:
426
+ sf = header["SpectrometerFrequency"]
427
+ spec_freq = float(sf[0]) if hasattr(sf, "__len__") else float(sf)
428
+
429
+ ndim = 2 if nz == 1 else 3
430
+
431
+ return {
432
+ "fov_mm": fov_mm,
433
+ "matrix": (nx, ny, nz),
434
+ "voxel_mm": (vx, vy, vz),
435
+ "dwell_time": dwell_time,
436
+ "spectrometer_frequency": spec_freq,
437
+ "ndim": ndim,
438
+ "inferred": inferred,
439
+ }
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # Trajectory Helpers
444
+ # ---------------------------------------------------------------------------
445
+
446
+ def _kmax_from_geometry(geom: dict) -> Tuple[float, float, float]:
447
+ """
448
+ Compute kmax per axis in cycles/m.
449
+
450
+ Formula: kmax_axis = (N_axis / 2) / (FOV_axis_mm / 1000)
451
+ equivalently kmax = N / (2 * FOV_m)
452
+ For Nyquist sampling Δk = 1 / FOV_m, so kmax = (N/2) * Δk.
453
+ """
454
+ nx, ny, nz = geom["matrix"]
455
+ fx_m, fy_m, fz_m = (v / 1000.0 for v in geom["fov_mm"])
456
+ kmax_x = (nx / 2.0) / fx_m
457
+ kmax_y = (ny / 2.0) / fy_m
458
+ kmax_z = (nz / 2.0) / fz_m if nz > 1 else 0.0
459
+ return kmax_x, kmax_y, kmax_z
460
+
461
+
462
+ def _density_estimate_placeholder(trajectory_name: str, n_shots: int,
463
+ samples_per_shot: int, geom: dict) -> dict:
464
+ """
465
+ Return a placeholder density estimate dictionary.
466
+
467
+ A proper DCF would require Voronoi cells or iterative pipe methods.
468
+ This placeholder records the parameters so downstream code can
469
+ recompute or override it.
470
+
471
+ Returns
472
+ -------
473
+ dict with keys:
474
+ type : str — 'analytic_placeholder'
475
+ description : str
476
+ n_shots : int
477
+ samples_per_shot : int
478
+ kmax : tuple[float]
479
+ note : str
480
+ """
481
+ kmax = _kmax_from_geometry(geom)
482
+ return {
483
+ "type": "analytic_placeholder",
484
+ "description": (
485
+ f"Density placeholder for '{trajectory_name}'. "
486
+ "Compute DCF externally (e.g. iterative pipe, Voronoi, or analytic)."
487
+ ),
488
+ "trajectory": trajectory_name,
489
+ "n_shots": n_shots,
490
+ "samples_per_shot": samples_per_shot,
491
+ "kmax": kmax,
492
+ "note": (
493
+ "For Cartesian: density = 1 everywhere by definition. "
494
+ "For radial: density ∝ |k| (centre-heavy). "
495
+ "For spirals: use gradient-delay-corrected NUFFT pipe density."
496
+ ),
497
+ }
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # Trajectory Generators
502
+ # ---------------------------------------------------------------------------
503
+
504
+ def _traj_cartesian_2d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
505
+ """
506
+ 2D Cartesian trajectory.
507
+
508
+ Each shot = one phase-encode line (ky) with full kx readout.
509
+
510
+ Parameters (params)
511
+ -------------------
512
+ ordering : str
513
+ 'linear' (default) — ky from -N/2 to N/2-1
514
+ 'centric' — ky ordered from centre outwards
515
+ n_shots : int, optional
516
+ Number of phase encodes to generate (default: ny).
517
+
518
+ Output shot count: n_shots (= ny unless overridden).
519
+ Sample count per shot: nx.
520
+ """
521
+ nx, ny, _ = geom["matrix"]
522
+ fov_x_m = geom["fov_mm"][0] / 1000.0
523
+ fov_y_m = geom["fov_mm"][1] / 1000.0
524
+
525
+ ordering = params.get("ordering", "linear")
526
+ n_shots = int(params.get("n_shots", ny))
527
+
528
+ kx_line = backend.linspace(-nx / 2, nx / 2 - 1, nx, True) / fov_x_m # (nx,) in 1/m
529
+
530
+ if ordering == "centric":
531
+ half = ny // 2
532
+ ky_idx = []
533
+ for i in range(max(half, ny - half)):
534
+ if i < half:
535
+ ky_idx.extend([half - 1 - i, half + i] if half + i < ny else [half - 1 - i])
536
+ elif half + i < ny:
537
+ ky_idx.append(half + i)
538
+ ky_indices = np.array(ky_idx[:n_shots], dtype=np.float32)
539
+ else:
540
+ ky_indices = np.arange(n_shots, dtype=np.float32)
541
+
542
+ # Map indices [0..ny-1] → k-space positions
543
+ # ky = (index - ny/2) / fov_y_m
544
+ ky_positions = (ky_indices - ny / 2.0) / fov_y_m
545
+
546
+ shots = []
547
+ for ky_val in ky_positions:
548
+ ky_line = backend.full((nx,), float(ky_val))
549
+ shot = backend.stack([kx_line, ky_line], axis=1) # (nx, 2)
550
+ shots.append(shot)
551
+
552
+ kmax_x, kmax_y, _ = _kmax_from_geometry(geom)
553
+ meta = {
554
+ "trajectory_type": "cartesian_2d",
555
+ "ordering": ordering,
556
+ "kmax": (kmax_x, kmax_y),
557
+ "fov_mm": geom["fov_mm"][:2],
558
+ "matrix": (nx, ny),
559
+ "n_shots": n_shots,
560
+ "samples_per_shot": nx,
561
+ "density_estimate": _density_estimate_placeholder("cartesian_2d", n_shots, nx, geom),
562
+ "params_used": params,
563
+ }
564
+ return shots, meta
565
+
566
+
567
+ def _traj_cartesian_3d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
568
+ """
569
+ 3D Cartesian trajectory.
570
+
571
+ Each shot = one (ky, kz) phase-encode pair with full kx readout.
572
+
573
+ Parameters (params)
574
+ -------------------
575
+ ordering : str
576
+ 'linear' (default), 'centric_ky', 'centric_kz', 'centric_both'
577
+ n_shots : int, optional
578
+ Total phase encodes (default: ny*nz).
579
+
580
+ Output shot count: ny * nz (or n_shots).
581
+ Sample count per shot: nx.
582
+ """
583
+ if geom["ndim"] < 3:
584
+ raise ValueError(
585
+ "cartesian_3d requires a 3D geometry (nz > 1). "
586
+ "Header has nz=1. Use cartesian_2d for 2D acquisitions."
587
+ )
588
+ nx, ny, nz = geom["matrix"]
589
+ fov_x_m = geom["fov_mm"][0] / 1000.0
590
+ fov_y_m = geom["fov_mm"][1] / 1000.0
591
+ fov_z_m = geom["fov_mm"][2] / 1000.0
592
+
593
+ ordering = params.get("ordering", "linear")
594
+ kx_line = backend.linspace(-nx / 2, nx / 2 - 1, nx) / fov_x_m
595
+
596
+ ky_arr = (np.arange(ny, dtype=np.float32) - ny / 2.0) / fov_y_m
597
+ kz_arr = (np.arange(nz, dtype=np.float32) - nz / 2.0) / fov_z_m
598
+
599
+ if "centric" in ordering:
600
+ def _centric_order(n):
601
+ half = n // 2
602
+ idx = []
603
+ for i in range(max(half, n - half)):
604
+ if i < half:
605
+ idx.extend([half - 1 - i, half + i] if half + i < n else [half - 1 - i])
606
+ elif half + i < n:
607
+ idx.append(half + i)
608
+ return idx
609
+
610
+ if ordering in ("centric_ky", "centric_both"):
611
+ ky_arr = ky_arr[_centric_order(ny)]
612
+ if ordering in ("centric_kz", "centric_both"):
613
+ kz_arr = kz_arr[_centric_order(nz)]
614
+
615
+ n_max = int(params.get("n_shots", ny * nz))
616
+ shots = []
617
+ count = 0
618
+ for kz_val in kz_arr:
619
+ if count >= n_max:
620
+ break
621
+ for ky_val in ky_arr:
622
+ if count >= n_max:
623
+ break
624
+ ky_line = backend.full((nx,), float(ky_val))
625
+ kz_line = backend.full((nx,), float(kz_val))
626
+ shot = backend.stack([kx_line, ky_line, kz_line], axis=1) # (nx, 3)
627
+ shots.append(shot)
628
+ count += 1
629
+
630
+ kmax_x, kmax_y, kmax_z = _kmax_from_geometry(geom)
631
+ n_shots = len(shots)
632
+ meta = {
633
+ "trajectory_type": "cartesian_3d",
634
+ "ordering": ordering,
635
+ "kmax": (kmax_x, kmax_y, kmax_z),
636
+ "fov_mm": geom["fov_mm"],
637
+ "matrix": (nx, ny, nz),
638
+ "n_shots": n_shots,
639
+ "samples_per_shot": nx,
640
+ "density_estimate": _density_estimate_placeholder("cartesian_3d", n_shots, nx, geom),
641
+ "params_used": params,
642
+ }
643
+ return shots, meta
644
+
645
+
646
+ def _traj_radial_2d(geom: dict, params: dict, backend: Backend,
647
+ use_golden: bool = False) -> Tuple[List[Array], MetaDict]:
648
+ """
649
+ 2D radial trajectory (uniform or golden-angle angular increment).
650
+
651
+ Center-out by default: samples go from k=0 outward to kmax.
652
+
653
+ Parameters (params)
654
+ -------------------
655
+ n_shots : int
656
+ Number of spokes (default: π/2 * max(nx,ny) ≈ Nyquist angular rate).
657
+ samples_per_shot : int
658
+ Samples per spoke (default: max(nx, ny)).
659
+ center_out : bool
660
+ If True (default), spoke goes from centre outward.
661
+ If False, spoke is symmetric (–kmax → +kmax, centre at midpoint).
662
+ golden_angle_rad : float
663
+ Golden angle override (default: GOLDEN_ANGLE_RAD).
664
+
665
+ Output shot count: n_shots.
666
+ Sample count per shot: samples_per_shot (constant).
667
+ """
668
+ nx, ny, _ = geom["matrix"]
669
+ fov_x_m = geom["fov_mm"][0] / 1000.0
670
+ fov_y_m = geom["fov_mm"][1] / 1000.0
671
+ kmax_x = (nx / 2.0) / fov_x_m
672
+ kmax_y = (ny / 2.0) / fov_y_m
673
+
674
+ n_shots = int(params.get("n_shots", max(1, int(math.pi / 2 * max(nx, ny)))))
675
+ sps = int(params.get("samples_per_shot", max(nx, ny)))
676
+ center_out = bool(params.get("center_out", True))
677
+ ga = float(params.get("golden_angle_rad", GOLDEN_ANGLE_RAD))
678
+
679
+ name = "golden_radial_2d" if use_golden else "radial_2d"
680
+ shots = []
681
+
682
+ for i in range(n_shots):
683
+ if use_golden:
684
+ angle = i * ga
685
+ else:
686
+ angle = i * math.pi / n_shots
687
+
688
+ if center_out:
689
+ r = backend.linspace(0.0, 1.0, sps)
690
+ else:
691
+ r = backend.linspace(-1.0, 1.0, sps)
692
+
693
+ kx = r * backend.cos(backend.array([angle]))[0] * kmax_x
694
+ ky = r * backend.sin(backend.array([angle]))[0] * kmax_y
695
+ shot = backend.stack([kx, ky], axis=1)
696
+ shots.append(shot)
697
+
698
+ kmax = (kmax_x, kmax_y)
699
+ meta = {
700
+ "trajectory_type": name,
701
+ "golden_angle_rad": ga if use_golden else None,
702
+ "kmax": kmax,
703
+ "fov_mm": geom["fov_mm"][:2],
704
+ "matrix": (nx, ny),
705
+ "n_shots": n_shots,
706
+ "samples_per_shot": sps,
707
+ "center_out": center_out,
708
+ "density_estimate": _density_estimate_placeholder(name, n_shots, sps, geom),
709
+ "params_used": params,
710
+ }
711
+ return shots, meta
712
+
713
+
714
+ def _traj_spiral_2d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
715
+ """
716
+ 2D multi-shot Archimedean or variable-density spiral.
717
+
718
+ Each shot = one spiral arm (interleaved rotation by 2π/n_shots).
719
+ Center-out (starts at k=0, ends at kmax).
720
+
721
+ Parameters (params)
722
+ -------------------
723
+ n_shots : int
724
+ Number of interleaved arms (default: 1).
725
+ samples_per_shot : int
726
+ Samples per arm (default: nx * ny // n_shots).
727
+ spiral_turns : float
728
+ Number of full turns per arm (default: n_shots * max(nx,ny)/4).
729
+ vd_alpha : float
730
+ Variable-density exponent ≥ 1 (1 = Archimedean, >1 = denser centre).
731
+ r(t) ∝ t^(1/vd_alpha).
732
+ rotate_first : bool
733
+ If True (default), shot i is rotated by i * 2π / n_shots.
734
+ """
735
+ nx, ny, _ = geom["matrix"]
736
+ fov_x_m = geom["fov_mm"][0] / 1000.0
737
+ fov_y_m = geom["fov_mm"][1] / 1000.0
738
+ kmax_x = (nx / 2.0) / fov_x_m
739
+ kmax_y = (ny / 2.0) / fov_y_m
740
+
741
+ n_shots = int(params.get("n_shots", 1))
742
+ sps = int(params.get("samples_per_shot", max(nx * ny // max(n_shots, 1), 64)))
743
+ turns = float(params.get("spiral_turns", n_shots * max(nx, ny) / 4.0))
744
+ vd_alpha = float(params.get("vd_alpha", 1.0))
745
+ rotate_first = bool(params.get("rotate_first", True))
746
+
747
+ t = backend.linspace(0.0, 1.0, sps) # normalised parameter
748
+ # r(t) ∝ t^(1/vd_alpha), ensures r ∈ [0, 1]
749
+ r = t ** (1.0 / vd_alpha)
750
+ theta_base = r * (2.0 * math.pi * turns)
751
+
752
+ shots = []
753
+ for i in range(n_shots):
754
+ rot = (2.0 * math.pi * i / n_shots) if rotate_first else 0.0
755
+ theta = theta_base + rot
756
+ kx = r * backend.cos(theta) * kmax_x
757
+ ky = r * backend.sin(theta) * kmax_y
758
+ shot = backend.stack([kx, ky], axis=1)
759
+ shots.append(shot)
760
+
761
+ meta = {
762
+ "trajectory_type": "spiral_2d",
763
+ "spiral_turns": turns,
764
+ "vd_alpha": vd_alpha,
765
+ "kmax": (kmax_x, kmax_y),
766
+ "fov_mm": geom["fov_mm"][:2],
767
+ "matrix": (nx, ny),
768
+ "n_shots": n_shots,
769
+ "samples_per_shot": sps,
770
+ "density_estimate": _density_estimate_placeholder("spiral_2d", n_shots, sps, geom),
771
+ "params_used": params,
772
+ }
773
+ return shots, meta
774
+
775
+
776
+ def _traj_rosette_2d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
777
+ """
778
+ 2D Rosette (looped petal) trajectory.
779
+
780
+ Parametric: kx = kmax * sin(ω1*t) * cos(ω2*t)
781
+ ky = kmax * sin(ω1*t) * sin(ω2*t)
782
+
783
+ Parameters (params)
784
+ -------------------
785
+ n_shots : int
786
+ Number of shots (each is one full rosette pass, default: 1).
787
+ samples_per_shot : int
788
+ Samples per shot (default: 512).
789
+ omega1, omega2 : float
790
+ Petal frequencies (default: 5, 4) — ratio determines petal count.
791
+ rotation_per_shot : float
792
+ Angular increment (rad) between shots (default: golden angle).
793
+ """
794
+ nx, ny, _ = geom["matrix"]
795
+ fov_x_m = geom["fov_mm"][0] / 1000.0
796
+ fov_y_m = geom["fov_mm"][1] / 1000.0
797
+ kmax_x = (nx / 2.0) / fov_x_m
798
+ kmax_y = (ny / 2.0) / fov_y_m
799
+
800
+ n_shots = int(params.get("n_shots", 1))
801
+ sps = int(params.get("samples_per_shot", 512))
802
+ omega1 = float(params.get("omega1", 5.0))
803
+ omega2 = float(params.get("omega2", 4.0))
804
+ rot_step = float(params.get("rotation_per_shot", GOLDEN_ANGLE_RAD))
805
+
806
+ t = backend.linspace(0.0, 2.0 * math.pi, sps)
807
+ shots = []
808
+ for i in range(n_shots):
809
+ rot = i * rot_step
810
+ kx = backend.sin(t * omega1) * backend.cos(t * omega2 + rot) * kmax_x
811
+ ky = backend.sin(t * omega1) * backend.sin(t * omega2 + rot) * kmax_y
812
+ shot = backend.stack([kx, ky], axis=1)
813
+ shots.append(shot)
814
+
815
+ meta = {
816
+ "trajectory_type": "rosette_2d",
817
+ "omega1": omega1,
818
+ "omega2": omega2,
819
+ "kmax": (kmax_x, kmax_y),
820
+ "fov_mm": geom["fov_mm"][:2],
821
+ "matrix": (nx, ny),
822
+ "n_shots": n_shots,
823
+ "samples_per_shot": sps,
824
+ "density_estimate": _density_estimate_placeholder("rosette_2d", n_shots, sps, geom),
825
+ "params_used": params,
826
+ }
827
+ return shots, meta
828
+
829
+
830
+ def _traj_rosette_2d_petals(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
831
+ """
832
+ 2D Rosette where each individual petal is returned as a separate shot.
833
+
834
+ Each petal traces a closed lemniscate-like path from the k-space origin
835
+ to the tip and back, giving a center-out-center readout per petal:
836
+
837
+ kx(t) = kmax_x · sin(t) · cos(t + φₖ)
838
+ ky(t) = kmax_y · sin(t) · sin(t + φₖ)
839
+ t ∈ [0, π], φₖ = -π/2 + 2π·k/n_petals + start_angle_rad
840
+
841
+ At t=0 and t=π: sin(t)=0, so both endpoints are at the origin.
842
+ The petal tip lies at t=π/2, pointing in direction φₖ + π/2.
843
+ With the default start_angle = -π/2, petal 0 points along +kx.
844
+
845
+ Parameters (params)
846
+ -------------------
847
+ n_petals : int
848
+ Number of petals / shots (default: 7).
849
+ samples_per_shot : int
850
+ Samples per petal (default: 512). Includes origin at both ends.
851
+ start_angle_rad : float
852
+ Extra azimuthal offset applied to all petals (default: 0).
853
+ The built-in -π/2 offset already aligns petal 0 with +kx.
854
+
855
+ Output shot count: n_petals.
856
+ Sample count per shot: samples_per_shot (constant).
857
+ """
858
+ nx, ny = geom["matrix"][0], geom["matrix"][1]
859
+ fov_x_m = geom["fov_mm"][0] / 1000.0
860
+ fov_y_m = geom["fov_mm"][1] / 1000.0
861
+ kmax_x = (nx / 2.0) / fov_x_m
862
+ kmax_y = (ny / 2.0) / fov_y_m
863
+
864
+ n_petals = int(params.get("n_petals", 7))
865
+ sps = int(params.get("samples_per_shot", 512))
866
+ start_angle = float(params.get("start_angle_rad", 0.0))
867
+
868
+ t = backend.linspace(0.0, math.pi, sps) # one petal: origin → tip → origin
869
+ shots = []
870
+ for k in range(n_petals):
871
+ # -π/2 aligns petal 0 with +kx; 2π*k/n_petals rotates CCW
872
+ phi = -math.pi / 2.0 + 2.0 * math.pi * k / n_petals + start_angle
873
+ kx = backend.sin(t) * backend.cos(t + phi) * kmax_x
874
+ ky = backend.sin(t) * backend.sin(t + phi) * kmax_y
875
+ shot = backend.stack([kx, ky], axis=1)
876
+ shots.append(shot)
877
+
878
+ kmax_xv, kmax_yv, _ = _kmax_from_geometry(geom)
879
+ meta = {
880
+ "trajectory_type": "rosette_2d_petals",
881
+ "n_petals": n_petals,
882
+ "kmax": (kmax_xv, kmax_yv),
883
+ "fov_mm": geom["fov_mm"][:2],
884
+ "matrix": (nx, ny),
885
+ "n_shots": n_petals,
886
+ "samples_per_shot": sps,
887
+ "center_out_center": True,
888
+ "density_estimate": _density_estimate_placeholder(
889
+ "rosette_2d_petals", n_petals, sps, geom),
890
+ "params_used": params,
891
+ }
892
+ return shots, meta
893
+
894
+
895
+ def _traj_stack_of_x(geom: dict, params: dict, backend: Backend,
896
+ inplane_name: str) -> Tuple[List[Array], MetaDict]:
897
+ """
898
+ Generic stack-of-2D hybrid: generate in-plane trajectory per kz slice.
899
+
900
+ In-plane options: 'radial', 'golden_radial', 'spiral', 'cones', 'ECCENTRIC'.
901
+ Shots are grouped: [kz0_shot0, kz0_shot1, ..., kz1_shot0, ...].
902
+
903
+ Parameters (params)
904
+ -------------------
905
+ kz_ordering : str
906
+ 'linear' or 'centric' (default: 'linear').
907
+ kz_range : tuple (kz_min_idx, kz_max_idx) optional
908
+ Slice index range to include (default: all nz slices).
909
+ plus all params for the in-plane trajectory generator.
910
+
911
+ Output shot count: n_kz_slices * n_inplane_shots.
912
+ """
913
+ if geom["ndim"] < 3:
914
+ raise ValueError(
915
+ f"stack_of_{inplane_name} requires a 3D geometry (nz > 1). "
916
+ f"Header has nz=1."
917
+ )
918
+ _, _, nz = geom["matrix"]
919
+ fov_z_m = geom["fov_mm"][2] / 1000.0
920
+ kmax_z = (nz / 2.0) / fov_z_m
921
+
922
+ kz_ordering = params.get("kz_ordering", "linear")
923
+ kz_range = params.get("kz_range", (0, nz))
924
+ kz_min, kz_max = int(kz_range[0]), int(kz_range[1])
925
+ n_kz = kz_max - kz_min
926
+
927
+ kz_indices = np.arange(kz_min, kz_max, dtype=np.float32)
928
+ kz_positions = (kz_indices - nz / 2.0) / fov_z_m
929
+
930
+ if kz_ordering == "centric":
931
+ half = n_kz // 2
932
+ order = []
933
+ for i in range(max(half, n_kz - half)):
934
+ if i < half:
935
+ order.extend([half - 1 - i, half + i] if half + i < n_kz else [half - 1 - i])
936
+ elif half + i < n_kz:
937
+ order.append(half + i)
938
+ kz_positions = kz_positions[order[:n_kz]]
939
+
940
+ # Generate in-plane geometry (treat as 2D)
941
+ geom2d = {**geom, "ndim": 2, "matrix": (geom["matrix"][0], geom["matrix"][1], 1),
942
+ "fov_mm": (geom["fov_mm"][0], geom["fov_mm"][1], 1.0)}
943
+
944
+ ip_map = {
945
+ "radial": lambda: _traj_radial_2d(geom2d, params, backend, use_golden=False),
946
+ "golden_radial": lambda: _traj_radial_2d(geom2d, params, backend, use_golden=True),
947
+ "spiral": lambda: _traj_spiral_2d(geom2d, params, backend),
948
+ "cones": lambda: _traj_spiral_2d(geom2d, params, backend),
949
+ "ECCENTRIC": lambda: _traj_eccentric(geom2d, params, backend),
950
+ "rosette": lambda: _traj_rosette_2d(geom2d, params, backend),
951
+ }
952
+ if inplane_name not in ip_map:
953
+ raise ValueError(f"Unknown in-plane trajectory '{inplane_name}'. "
954
+ f"Valid: {list(ip_map.keys())}")
955
+ inplane_shots, inplane_meta = ip_map[inplane_name]()
956
+
957
+ all_shots = []
958
+ for kz_val in kz_positions:
959
+ kz_col = float(kz_val)
960
+ for shot2d in inplane_shots:
961
+ nrows = backend.shape(shot2d)[0]
962
+ kz_col_arr = backend.full((nrows,), kz_col)
963
+ shot3d = backend.concat([shot2d, backend.stack([kz_col_arr], axis=1)], axis=1)
964
+ all_shots.append(shot3d)
965
+
966
+ n_shots_total = len(all_shots)
967
+ sps = backend.shape(all_shots[0])[0] if all_shots else 0
968
+ traj_name = f"stack_of_{inplane_name}"
969
+ kmax_x, kmax_y, _ = _kmax_from_geometry(geom)
970
+ meta = {
971
+ "trajectory_type": traj_name,
972
+ "inplane_trajectory": inplane_name,
973
+ "inplane_meta": inplane_meta,
974
+ "kz_ordering": kz_ordering,
975
+ "n_kz_slices": n_kz,
976
+ "n_inplane_shots": len(inplane_shots),
977
+ "kmax": (kmax_x, kmax_y, kmax_z),
978
+ "fov_mm": geom["fov_mm"],
979
+ "matrix": geom["matrix"],
980
+ "n_shots": n_shots_total,
981
+ "samples_per_shot": sps,
982
+ "density_estimate": _density_estimate_placeholder(traj_name, n_shots_total, sps, geom),
983
+ "params_used": params,
984
+ }
985
+ return all_shots, meta
986
+
987
+
988
+ def _traj_eccentric(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
989
+ """
990
+ ECCENTRIC MRSI trajectory (Echo-planar Concentric Rings Trajectory).
991
+
992
+ Concentric rings in k-space sampled with alternating directions,
993
+ commonly used in MRSI. Each shot is one ring.
994
+
995
+ Parameters (params)
996
+ -------------------
997
+ n_rings : int
998
+ Number of concentric rings (default: ny//2).
999
+ samples_per_ring : int
1000
+ Samples per ring (default: 2*π*ring_radius → proportional to circumference).
1001
+ min_ring_radius : float
1002
+ Minimum ring radius in normalised units (default: 1e-3).
1003
+ """
1004
+ nx, ny, _ = geom["matrix"]
1005
+ fov_x_m = geom["fov_mm"][0] / 1000.0
1006
+ fov_y_m = geom["fov_mm"][1] / 1000.0
1007
+ kmax_x = (nx / 2.0) / fov_x_m
1008
+ kmax_y = (ny / 2.0) / fov_y_m
1009
+
1010
+ n_rings = int(params.get("n_rings", ny // 2))
1011
+ min_r = float(params.get("min_ring_radius", 1e-3))
1012
+ radii = np.linspace(min_r, 1.0, n_rings, True)
1013
+
1014
+ shots = []
1015
+ for idx, r_norm in enumerate(radii):
1016
+ # Samples proportional to circumference
1017
+ n_samp = int(params.get("samples_per_ring",
1018
+ max(8, int(2 * math.pi * r_norm * max(nx, ny) / 2))))
1019
+ theta = np.linspace(0, 2 * math.pi, n_samp, endpoint=True)
1020
+ if idx % 2 == 1: # alternating CW/CCW for echo-planar style
1021
+ theta = theta[::-1]
1022
+ theta_b = backend.asarray(theta)
1023
+ kx = backend.cos(theta_b) * r_norm * kmax_x
1024
+ ky = backend.sin(theta_b) * r_norm * kmax_y
1025
+ shot = backend.stack([kx, ky], axis=1)
1026
+ shots.append(shot)
1027
+
1028
+ meta = {
1029
+ "trajectory_type": "ECCENTRIC",
1030
+ "n_rings": n_rings,
1031
+ "kmax": (kmax_x, kmax_y),
1032
+ "fov_mm": geom["fov_mm"][:2],
1033
+ "matrix": (nx, ny),
1034
+ "n_shots": n_rings,
1035
+ "samples_per_shot": "variable (∝ ring circumference)",
1036
+ "density_estimate": _density_estimate_placeholder("ECCENTRIC", n_rings,
1037
+ ny // 2, geom),
1038
+ "params_used": params,
1039
+ }
1040
+ return shots, meta
1041
+
1042
+
1043
+ def _traj_concentric_rings_2d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
1044
+ """
1045
+ 2D Concentric Rings trajectory.
1046
+
1047
+ A set of concentric circles in the kx–ky plane. Each ring is one shot.
1048
+ Rings are uniformly spaced from a minimum radius to kmax. Alternating
1049
+ rings are traversed in opposite directions (CW / CCW) to mimic an
1050
+ echo-planar acquisition pattern.
1051
+
1052
+ Equivalent to the ECCENTRIC / concentric-ring MRSI acquisition but
1053
+ exposed directly for 2D use without a kz stack.
1054
+
1055
+ Parameters (params)
1056
+ -------------------
1057
+ n_rings : int
1058
+ Number of concentric rings (default: ny // 2).
1059
+ samples_per_ring : int
1060
+ Samples per ring (default: proportional to circumference).
1061
+ Overrides the auto-computed circumference-proportional value.
1062
+ min_ring_radius : float
1063
+ Minimum ring radius as fraction of kmax (default: 1 / n_rings).
1064
+
1065
+ Output shot count: n_rings.
1066
+ Sample count per shot: variable unless samples_per_ring is set.
1067
+ """
1068
+ shots, meta_eccentric = _traj_eccentric(geom, params, backend)
1069
+ # Re-label trajectory type for clarity
1070
+ meta_eccentric["trajectory_type"] = "concentric_rings_2d"
1071
+ meta_eccentric["n_shots"] = len(shots)
1072
+ meta_eccentric["density_estimate"]["trajectory"] = "concentric_rings_2d"
1073
+ return shots, meta_eccentric
1074
+
1075
+
1076
+ def _traj_3d_phyllotaxis(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
1077
+ """
1078
+ Fermat phyllotaxis 3D radial trajectory.
1079
+
1080
+ Angular positions on unit sphere use sunflower / Fibonacci lattice,
1081
+ so any prefix of N shots provides near-uniform angular coverage.
1082
+ Center-out readout: each spoke starts at k=0 and extends to kmax.
1083
+
1084
+ Parameters (params)
1085
+ -------------------
1086
+ n_shots : int
1087
+ Number of spokes (default: π/4 * max(nx,ny,nz)^2).
1088
+ samples_per_shot : int
1089
+ Samples per spoke (default: max(nx,ny,nz)).
1090
+ golden_angle_2d : bool
1091
+ Use 2D golden angle for azimuth increment (default: True).
1092
+
1093
+ Output shot count: n_shots.
1094
+ Sample count per shot: samples_per_shot (constant).
1095
+ """
1096
+ if geom["ndim"] < 3:
1097
+ raise ValueError(
1098
+ "3d_phyllotaxis requires a 3D geometry (nz > 1). "
1099
+ "Header has nz=1. Use radial_2d or golden_radial_2d for 2D."
1100
+ )
1101
+ nx, ny, nz = geom["matrix"]
1102
+ fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
1103
+ kmax_x = (nx / 2.0) / fov_x_m
1104
+ kmax_y = (ny / 2.0) / fov_y_m
1105
+ kmax_z = (nz / 2.0) / fov_z_m
1106
+
1107
+ max_n = max(nx, ny, nz)
1108
+ n_shots = int(params.get("n_shots", max(1, int(math.pi / 4 * max_n ** 2))))
1109
+ sps = int(params.get("samples_per_shot", max_n))
1110
+
1111
+ # Fermat phyllotaxis: spoke i at
1112
+ # cos(elevation) = 1 - (2*i+1)/n_shots
1113
+ # azimuth = i * golden_angle (3D variant: 2.399963...)
1114
+ shots = []
1115
+ r = backend.linspace(0.0, 1.0, sps) # center-out
1116
+ for i in range(n_shots):
1117
+ cos_el = 1.0 - (2 * i + 1.0) / n_shots
1118
+ cos_el = float(np.clip(cos_el, -1.0, 1.0))
1119
+ sin_el = math.sqrt(max(0.0, 1.0 - cos_el ** 2))
1120
+ azimuth = i * GOLDEN_ANGLE_RAD # 2D golden angle for azimuth
1121
+
1122
+ dx = sin_el * math.cos(azimuth)
1123
+ dy = sin_el * math.sin(azimuth)
1124
+ dz = cos_el
1125
+
1126
+ kx = r * dx * kmax_x
1127
+ ky = r * dy * kmax_y
1128
+ kz = r * dz * kmax_z
1129
+ shot = backend.stack([kx, ky, kz], axis=1)
1130
+ shots.append(shot)
1131
+
1132
+ meta = {
1133
+ "trajectory_type": "3d_phyllotaxis",
1134
+ "golden_angle_rad": GOLDEN_ANGLE_RAD,
1135
+ "kmax": (kmax_x, kmax_y, kmax_z),
1136
+ "fov_mm": geom["fov_mm"],
1137
+ "matrix": (nx, ny, nz),
1138
+ "n_shots": n_shots,
1139
+ "samples_per_shot": sps,
1140
+ "center_out": True,
1141
+ "density_estimate": _density_estimate_placeholder("3d_phyllotaxis", n_shots, sps, geom),
1142
+ "params_used": params,
1143
+ }
1144
+ return shots, meta
1145
+
1146
+
1147
+ def _traj_cones_3d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
1148
+ """
1149
+ 3D cones trajectory: spiral arms arranged on cones at discrete polar angles.
1150
+
1151
+ Each shot is a spiral arm on a cone surface. Cones are indexed by their
1152
+ polar angle θ measured from the +kz axis.
1153
+
1154
+ By default the polar range is [0, π], covering both the upper (+kz) and
1155
+ lower (−kz) hemispheres symmetrically. Set ``polar_max=π/2`` to restrict
1156
+ to the upper hemisphere only (legacy behaviour).
1157
+
1158
+ Parameters (params)
1159
+ -------------------
1160
+ n_cones : int
1161
+ Number of cone polar angles (default: 7).
1162
+ arms_per_cone : int
1163
+ Azimuthal arms per cone (default: 8).
1164
+ samples_per_shot : int
1165
+ Samples per arm (default: max(nx, ny, nz)).
1166
+ spiral_turns_per_arm : float
1167
+ Spiral turns per arm (default: 1.0).
1168
+ polar_min : float
1169
+ Minimum polar angle in radians (default: 0 = +kz axis).
1170
+ polar_max : float
1171
+ Maximum polar angle in radians (default: π = −kz axis).
1172
+ Set to π/2 to generate upper-hemisphere only.
1173
+
1174
+ Output shot count: n_cones * arms_per_cone.
1175
+
1176
+ Notes
1177
+ -----
1178
+ Polar angle θ is the standard physics convention (angle from +kz):
1179
+ θ = 0 → spoke along +kz
1180
+ θ = π/2 → spoke in the kxy plane (kz = 0)
1181
+ θ = π → spoke along −kz
1182
+ kz = r · cos(θ) · kmax_z, so negative-kz cones arise naturally for θ > π/2.
1183
+ """
1184
+ if geom["ndim"] < 3:
1185
+ raise ValueError(
1186
+ "cones_3d requires a 3D geometry (nz > 1). Header has nz=1."
1187
+ )
1188
+ nx, ny, nz = geom["matrix"]
1189
+ fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
1190
+ kmax_x = (nx / 2.0) / fov_x_m
1191
+ kmax_y = (ny / 2.0) / fov_y_m
1192
+ kmax_z = (nz / 2.0) / fov_z_m
1193
+
1194
+ n_cones = int(params.get("n_cones", 14))
1195
+ arms_per_cone = int(params.get("arms_per_cone", 8))
1196
+ sps = int(params.get("samples_per_shot", max(nx, ny, nz)))
1197
+ turns = float(params.get("spiral_turns_per_arm", 1.0))
1198
+ polar_min = float(params.get("polar_min", 0.15*math.pi))#0.0))
1199
+ polar_max = float(params.get("polar_max", 0.85*math.pi))
1200
+
1201
+ cone_angles = np.linspace(polar_min, polar_max, n_cones, True)
1202
+ t = backend.linspace(0.0, 1.0, sps)
1203
+
1204
+ shots = []
1205
+ for ci, cone_ang in enumerate(cone_angles):
1206
+ sin_ca = math.sin(float(cone_ang))
1207
+ cos_ca = math.cos(float(cone_ang)) # negative for θ > π/2 → −kz cones
1208
+ for ai in range(arms_per_cone):
1209
+ az0 = ai * 2.0 * math.pi / arms_per_cone + ci * GOLDEN_ANGLE_RAD
1210
+ theta_spiral = t * (2.0 * math.pi * turns) + az0
1211
+ r = t # centre-out
1212
+
1213
+ kx = r * backend.cos(theta_spiral) * sin_ca * kmax_x
1214
+ ky = r * backend.sin(theta_spiral) * sin_ca * kmax_y
1215
+ kz = r * cos_ca * kmax_z # naturally negative when θ > π/2
1216
+ shot = backend.stack([kx, ky, kz], axis=1)
1217
+ shots.append(shot)
1218
+
1219
+ n_shots = len(shots)
1220
+ meta = {
1221
+ "trajectory_type": "cones_3d",
1222
+ "n_cones": n_cones,
1223
+ "arms_per_cone": arms_per_cone,
1224
+ "spiral_turns": turns,
1225
+ "polar_min_deg": math.degrees(polar_min),
1226
+ "polar_max_deg": math.degrees(polar_max),
1227
+ "kmax": (kmax_x, kmax_y, kmax_z),
1228
+ "fov_mm": geom["fov_mm"],
1229
+ "matrix": (nx, ny, nz),
1230
+ "n_shots": n_shots,
1231
+ "samples_per_shot": sps,
1232
+ "density_estimate": _density_estimate_placeholder("cones_3d", n_shots, sps, geom),
1233
+ "params_used": params,
1234
+ }
1235
+ return shots, meta
1236
+
1237
+
1238
+ def _traj_cones_3d_rosette(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
1239
+ """
1240
+ 3D Cones Rosette trajectory.
1241
+
1242
+ Replaces the spiral readout arms of ``cones_3d`` with rosette petals.
1243
+ Like a 2D petal, each 3D petal is a *closed* trajectory that starts and
1244
+ ends at the k-space origin (center-out-center), tracing a lemniscate-like
1245
+ loop on the surface of the cone:
1246
+
1247
+ t ∈ [0, π] (both endpoints at k = 0)
1248
+ φⱼ = j · 2π / petals_per_cone + cone_idx · golden_angle_rad
1249
+
1250
+ kx(t) = sin(t) · sin(θ_cone) · cos(t + φⱼ) · kmax_x
1251
+ ky(t) = sin(t) · sin(θ_cone) · sin(t + φⱼ) · kmax_y
1252
+ kz(t) = sin(t) · cos(θ_cone) · kmax_z
1253
+
1254
+ Because kx, ky, kz all share the sin(t) envelope, the trajectory lies
1255
+ on the cone surface for all t and returns to the origin at t=0 and t=π.
1256
+ The azimuthal sweep (t + φⱼ) creates the petal lobe in the transverse
1257
+ plane. The golden-angle inter-cone offset spreads petals uniformly in
1258
+ azimuth across cone levels.
1259
+
1260
+ By default the polar range is [0, π], covering both the upper (+kz) and
1261
+ lower (−kz) hemispheres. cos(θ) is negative for θ > π/2, which makes kz
1262
+ naturally negative — no special mirroring step is required.
1263
+
1264
+ Parameters (params)
1265
+ -------------------
1266
+ n_cones : int
1267
+ Number of cone polar angles, spaced across [polar_min, polar_max]
1268
+ (default: 7). Includes cones in both hemispheres when polar_max > π/2.
1269
+ petals_per_cone : int
1270
+ Number of rosette petals per cone (default: 6).
1271
+ samples_per_shot : int
1272
+ Samples per petal (default: 512). Includes origin at both ends.
1273
+ polar_min : float
1274
+ Minimum polar angle in radians (default: 0 = +kz axis).
1275
+ polar_max : float
1276
+ Maximum polar angle in radians (default: π = −kz axis).
1277
+ Set to π/2 to generate upper-hemisphere only.
1278
+
1279
+ Output shot count: n_cones * petals_per_cone.
1280
+ Sample count per shot: samples_per_shot (constant).
1281
+
1282
+ Notes
1283
+ -----
1284
+ Same polar-angle convention as cones_3d:
1285
+ θ = 0 → cone along +kz (kz > 0)
1286
+ θ = π/2 → equatorial cone (kz = 0)
1287
+ θ = π → cone along −kz (kz < 0)
1288
+ """
1289
+ if geom["ndim"] < 3:
1290
+ raise ValueError(
1291
+ "cones_3d_rosette requires a 3D geometry (nz > 1). "
1292
+ "Header has nz=1."
1293
+ )
1294
+ nx, ny, nz = geom["matrix"]
1295
+ fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
1296
+ kmax_x = (nx / 2.0) / fov_x_m
1297
+ kmax_y = (ny / 2.0) / fov_y_m
1298
+ kmax_z = (nz / 2.0) / fov_z_m
1299
+
1300
+ n_cones = int(params.get("n_cones", 14))
1301
+ ppc = int(params.get("petals_per_cone", 9))
1302
+ sps = int(params.get("samples_per_shot", 512))
1303
+ polar_min = float(params.get("polar_min", 0.15*math.pi))#0.0))
1304
+ polar_max = float(params.get("polar_max", 0.85*math.pi))
1305
+
1306
+ cone_angles = np.linspace(polar_min, polar_max, n_cones, True)
1307
+ t = backend.linspace(0.0, math.pi, sps) # petal: 0 → π
1308
+ sin_t = backend.sin(t) # envelope — 0 at both ends
1309
+
1310
+ shots = []
1311
+ for ci, theta_cone in enumerate(cone_angles):
1312
+ sin_ca = math.sin(float(theta_cone))
1313
+ cos_ca = math.cos(float(theta_cone)) # negative for θ > π/2 → −kz petals
1314
+ for pi_ in range(ppc):
1315
+ phi = pi_ * 2.0 * math.pi / ppc + ci * GOLDEN_ANGLE_RAD
1316
+ kx = sin_t * sin_ca * backend.cos(t + phi) * kmax_x
1317
+ ky = sin_t * sin_ca * backend.sin(t + phi) * kmax_y
1318
+ kz = sin_t * cos_ca * kmax_z # sign follows cos(θ) naturally
1319
+ shot = backend.stack([kx, ky, kz], axis=1)
1320
+ shots.append(shot)
1321
+
1322
+ n_shots = len(shots)
1323
+ kmax_xv, kmax_yv, kmax_zv = _kmax_from_geometry(geom)
1324
+ meta = {
1325
+ "trajectory_type": "cones_3d_rosette",
1326
+ "n_cones": n_cones,
1327
+ "petals_per_cone": ppc,
1328
+ "polar_min_deg": math.degrees(polar_min),
1329
+ "polar_max_deg": math.degrees(polar_max),
1330
+ "kmax": (kmax_xv, kmax_yv, kmax_zv),
1331
+ "fov_mm": geom["fov_mm"],
1332
+ "matrix": (nx, ny, nz),
1333
+ "n_shots": n_shots,
1334
+ "samples_per_shot": sps,
1335
+ "center_out_center": True,
1336
+ "density_estimate": _density_estimate_placeholder(
1337
+ "cones_3d_rosette", n_shots, sps, geom),
1338
+ "params_used": params,
1339
+ }
1340
+ return shots, meta
1341
+
1342
+
1343
+ def _traj_floret_3d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
1344
+ """
1345
+ FLORET (Fermat-Loop-Over-Radial Echo Trajectory) 3D trajectory.
1346
+
1347
+ Distributed spiral shells (hubs) arranged uniformly on the sphere via
1348
+ phyllotaxis ordering. Each hub is a set of interleaved spiral arms.
1349
+
1350
+ Parameters (params)
1351
+ -------------------
1352
+ n_hubs : int
1353
+ Number of shell hubs (default: 5).
1354
+ arms_per_hub : int
1355
+ Spiral arms per hub (default: 15).
1356
+ samples_per_shot : int
1357
+ Samples per arm (default: max(nx, ny, nz)).
1358
+ spiral_turns : float
1359
+ Turns per arm (default: arms_per_hub / 2).
1360
+ hub_cone_angle_deg : float
1361
+ Half-angle of each hub cone in degrees (default: 35).
1362
+
1363
+ Output shot count: n_hubs * arms_per_hub.
1364
+ """
1365
+ if geom["ndim"] < 3:
1366
+ raise ValueError("floret_3d requires a 3D geometry (nz > 1).")
1367
+ nx, ny, nz = geom["matrix"]
1368
+ fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
1369
+ kmax_x = (nx / 2.0) / fov_x_m
1370
+ kmax_y = (ny / 2.0) / fov_y_m
1371
+ kmax_z = (nz / 2.0) / fov_z_m
1372
+
1373
+ n_hubs = int(params.get("n_hubs", 5))
1374
+ aph = int(params.get("arms_per_hub", 15))
1375
+ sps = int(params.get("samples_per_shot", max(nx, ny, nz)))
1376
+ turns = float(params.get("spiral_turns", aph / 2.0))
1377
+ hub_ang_deg = float(params.get("hub_cone_angle_deg", 35.0))
1378
+ hub_ang_rad = math.radians(hub_ang_deg)
1379
+
1380
+ t = backend.linspace(0.0, 1.0, sps)
1381
+
1382
+ # Hub axis directions via phyllotaxis
1383
+ shots = []
1384
+ for h in range(n_hubs):
1385
+ cos_el_hub = 1.0 - (2 * h + 1.0) / n_hubs
1386
+ cos_el_hub = float(np.clip(cos_el_hub, -1.0, 1.0))
1387
+ sin_el_hub = math.sqrt(max(0.0, 1.0 - cos_el_hub ** 2))
1388
+ az_hub = h * GOLDEN_ANGLE_RAD
1389
+
1390
+ # Hub rotation matrix columns (local x, y axes on hub cone)
1391
+ hub_axis = np.array([sin_el_hub * math.cos(az_hub),
1392
+ sin_el_hub * math.sin(az_hub),
1393
+ cos_el_hub])
1394
+ # local x = cross(z_global, hub_axis) or fallback
1395
+ z_global = np.array([0.0, 0.0, 1.0])
1396
+ local_x = np.cross(z_global, hub_axis)
1397
+ norm = np.linalg.norm(local_x)
1398
+ if norm < 1e-8:
1399
+ local_x = np.array([1.0, 0.0, 0.0])
1400
+ else:
1401
+ local_x = local_x / norm
1402
+ local_y = np.cross(hub_axis, local_x)
1403
+
1404
+ for a in range(aph):
1405
+ az0 = a * 2.0 * math.pi / aph
1406
+ theta = t * (2.0 * math.pi * turns) + az0
1407
+ r = t
1408
+ # Arm direction sweeps within the hub cone
1409
+ arm_vec_x = r * backend.cos(theta) * math.sin(hub_ang_rad)
1410
+ arm_vec_y = r * backend.sin(theta) * math.sin(hub_ang_rad)
1411
+ arm_vec_z = r * math.cos(hub_ang_rad)
1412
+
1413
+ # Rotate from hub local frame to global
1414
+ kx = (arm_vec_x * local_x[0] + arm_vec_y * local_y[0]
1415
+ + arm_vec_z * hub_axis[0]) * kmax_x
1416
+ ky = (arm_vec_x * local_x[1] + arm_vec_y * local_y[1]
1417
+ + arm_vec_z * hub_axis[1]) * kmax_y
1418
+ kz = (arm_vec_x * local_x[2] + arm_vec_y * local_y[2]
1419
+ + arm_vec_z * hub_axis[2]) * kmax_z
1420
+ shot = backend.stack([kx, ky, kz], axis=1)
1421
+ shots.append(shot)
1422
+
1423
+ n_shots = len(shots)
1424
+ meta = {
1425
+ "trajectory_type": "floret_3d",
1426
+ "n_hubs": n_hubs,
1427
+ "arms_per_hub": aph,
1428
+ "hub_cone_angle_deg": hub_ang_deg,
1429
+ "spiral_turns": turns,
1430
+ "kmax": (kmax_x, kmax_y, kmax_z),
1431
+ "fov_mm": geom["fov_mm"],
1432
+ "matrix": (nx, ny, nz),
1433
+ "n_shots": n_shots,
1434
+ "samples_per_shot": sps,
1435
+ "density_estimate": _density_estimate_placeholder("floret_3d", n_shots, sps, geom),
1436
+ "params_used": params,
1437
+ }
1438
+ return shots, meta
1439
+
1440
+
1441
+ def _traj_3d_egg_rosette(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
1442
+ """
1443
+ 3D Egg-Shaped Rosette trajectory (Strasser, Medical University of Vienna).
1444
+
1445
+ Reference: Strasser B et al., "Ultra-high resolution whole-brain MR spectroscopic
1446
+ imaging using a 3D egg-shaped rosette k-space trajectory", MRM 2017 / ISMRM abstracts.
1447
+
1448
+ Each shot traces a closed egg-shaped loop on the surface of the k-space sphere:
1449
+
1450
+ kx(t) = kmax_x · sin(ω₁·t) · cos(ω₂·t + φₙ)
1451
+ ky(t) = kmax_y · sin(ω₁·t) · sin(ω₂·t + φₙ)
1452
+ kz(t) = kmax_z · cos(ω₁·t)
1453
+
1454
+ where t ∈ [0, 2π] traces one complete egg-shaped loop:
1455
+ - ω₁ = 1 (polar oscillation; kz sweeps north → south → north once per shot)
1456
+ - ω₂ = azimuthal wrap frequency (number of azimuthal lobes per shot)
1457
+ - φₙ = n · golden_angle_rad — per-shot azimuthal phase offset ensuring that
1458
+ any prefix of N shots gives near-uniform sphere coverage.
1459
+
1460
+ The "egg" geometry arises from the interplay of sin(ω₁t) (which vanishes at both
1461
+ poles) and cos(ω₁t) (which drives kz); the result is a lemniscate-like curve on
1462
+ the sphere whose half-width at the equator equals kmax and whose lobes are
1463
+ distributed uniformly in azimuth via the golden-angle increment between shots.
1464
+
1465
+ Parameters (params)
1466
+ -------------------
1467
+ n_shots : int
1468
+ Number of egg-rosette shots (default: 20).
1469
+ Any prefix gives uniform angular coverage thanks to golden-angle ordering.
1470
+ samples_per_shot : int
1471
+ Number of samples per shot (default: 4 * max(nx, ny, nz)).
1472
+ omega1 : float
1473
+ Polar frequency (default 1 → one polar oscillation per shot).
1474
+ Increase to add multiple north–south loops per trajectory.
1475
+ omega2 : int
1476
+ Azimuthal frequency — number of lobes per egg (default 5).
1477
+ Best chosen as an odd integer coprime with omega1 for closed trajectories.
1478
+ rotation_per_shot : float
1479
+ Azimuthal phase increment between shots in radians
1480
+ (default: GOLDEN_ANGLE_RAD ≈ 2.3999 rad).
1481
+
1482
+ Output shot count: n_shots.
1483
+ Sample count per shot: samples_per_shot (constant).
1484
+ """
1485
+ if geom["ndim"] < 3:
1486
+ raise ValueError(
1487
+ "3d_egg_rosette requires a 3D geometry (nz > 1). "
1488
+ "Header has nz=1."
1489
+ )
1490
+ nx, ny, nz = geom["matrix"]
1491
+ fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
1492
+ kmax_x = (nx / 2.0) / fov_x_m
1493
+ kmax_y = (ny / 2.0) / fov_y_m
1494
+ kmax_z = (nz / 2.0) / fov_z_m
1495
+
1496
+ n_shots = int(params.get("n_shots", 20))
1497
+ sps = int(params.get("samples_per_shot", 4 * max(nx, ny, nz)))
1498
+ omega1 = float(params.get("omega1", 1.0))
1499
+ omega2 = float(params.get("omega2", 5.0))
1500
+ rot_per_shot = float(params.get("rotation_per_shot", GOLDEN_ANGLE_RAD))
1501
+
1502
+ # t ∈ [0, 2π] → one complete polar oscillation per shot
1503
+ t = backend.linspace(0.0, 2.0 * math.pi, sps)
1504
+
1505
+ shots = []
1506
+ for n in range(n_shots):
1507
+ phi_n = n * rot_per_shot # golden-angle azimuth offset
1508
+ kx = backend.sin(t * omega1) * backend.cos(t * omega2 + phi_n) * kmax_x
1509
+ ky = backend.sin(t * omega1) * backend.sin(t * omega2 + phi_n) * kmax_y
1510
+ kz = backend.cos(t * omega1) * kmax_z # kz independent of phi_n
1511
+ shot = backend.stack([kx, ky, kz], axis=1) # (sps, 3)
1512
+ shots.append(shot)
1513
+
1514
+ meta = {
1515
+ "trajectory_type": "3d_egg_rosette",
1516
+ "omega1": omega1,
1517
+ "omega2": omega2,
1518
+ "rotation_per_shot": rot_per_shot,
1519
+ "kmax": (kmax_x, kmax_y, kmax_z),
1520
+ "fov_mm": geom["fov_mm"],
1521
+ "matrix": (nx, ny, nz),
1522
+ "n_shots": n_shots,
1523
+ "samples_per_shot": sps,
1524
+ "reference": "Strasser et al., MedUni Vienna",
1525
+ "density_estimate": _density_estimate_placeholder(
1526
+ "3d_egg_rosette", n_shots, sps, geom),
1527
+ "params_used": params,
1528
+ }
1529
+ return shots, meta
1530
+
1531
+ _TRAJECTORY_GENERATORS = {
1532
+ "cartesian_2d": lambda g, p, b: _traj_cartesian_2d(g, p, b),
1533
+ "cartesian_3d": lambda g, p, b: _traj_cartesian_3d(g, p, b),
1534
+ "radial_2d": lambda g, p, b: _traj_radial_2d(g, p, b, use_golden=False),
1535
+ "golden_radial_2d": lambda g, p, b: _traj_radial_2d(g, p, b, use_golden=True),
1536
+ "spiral_2d": lambda g, p, b: _traj_spiral_2d(g, p, b),
1537
+ "rosette_2d": lambda g, p, b: _traj_rosette_2d(g, p, b),
1538
+ "rosette_2d_petals": lambda g, p, b: _traj_rosette_2d_petals(g, p, b),
1539
+ "concentric_rings_2d": lambda g, p, b: _traj_concentric_rings_2d(g, p, b),
1540
+ "stack_of_stars": lambda g, p, b: _traj_stack_of_x(g, p, b, "golden_radial"),
1541
+ "stack_of_spirals": lambda g, p, b: _traj_stack_of_x(g, p, b, "spiral"),
1542
+ "stack_of_cones": lambda g, p, b: _traj_stack_of_x(g, p, b, "cones"),
1543
+ "stack_of_rosettes": lambda g, p, b: _traj_stack_of_x(g, p, b, "rosette"),
1544
+ "stack_of_ECCENTRIC": lambda g, p, b: _traj_stack_of_x(g, p, b, "ECCENTRIC"),
1545
+ "3d_phyllotaxis": lambda g, p, b: _traj_3d_phyllotaxis(g, p, b),
1546
+ "cones_3d": lambda g, p, b: _traj_cones_3d(g, p, b),
1547
+ "cones_3d_rosette": lambda g, p, b: _traj_cones_3d_rosette(g, p, b),
1548
+ "floret_3d": lambda g, p, b: _traj_floret_3d(g, p, b),
1549
+ "3d_egg_rosette": lambda g, p, b: _traj_3d_egg_rosette(g, p, b),
1550
+ }
1551
+
1552
+
1553
+ # ---------------------------------------------------------------------------
1554
+ # Public API: generate_trajectory
1555
+ # ---------------------------------------------------------------------------
1556
+
1557
+ def generate_trajectory(
1558
+ name: str,
1559
+ header: HeaderDict,
1560
+ params: ParamsDict,
1561
+ backend: Backend,
1562
+ ) -> Tuple[List[Array], MetaDict]:
1563
+ """
1564
+ Generate a k-space trajectory and return shot-organised coordinates.
1565
+
1566
+ Parameters
1567
+ ----------
1568
+ name : str
1569
+ Trajectory name. One of:
1570
+ 'cartesian_2d', 'cartesian_3d', 'radial_2d', 'golden_radial_2d',
1571
+ 'spiral_2d', 'rosette_2d', 'stack_of_stars', 'stack_of_spirals',
1572
+ 'stack_of_cones', 'stack_of_ECCENTRIC',
1573
+ '3d_phyllotaxis', 'cones_3d', 'floret_3d'.
1574
+ header : dict
1575
+ Parsed NIfTI-MRS header (passed to read_header_geometry internally).
1576
+ params : dict
1577
+ Trajectory-specific parameters (see individual trajectory docstrings).
1578
+ backend : Backend
1579
+ NumpyBackend() or TorchBackend() instance.
1580
+
1581
+ Returns
1582
+ -------
1583
+ coords_per_shot : list of arrays, each shape (Nsamples, Ndims)
1584
+ Ndims = 2 for 2D trajectories, 3 for 3D. All values in cycles/m.
1585
+ meta : dict
1586
+ trajectory_type, kmax (per-axis tuple), fov_mm, matrix, n_shots,
1587
+ samples_per_shot, density_estimate placeholder, params_used.
1588
+
1589
+ Raises
1590
+ ------
1591
+ KeyError
1592
+ If required header fields are missing.
1593
+ ValueError
1594
+ If trajectory name is unknown or geometry is incompatible.
1595
+ TypeError
1596
+ If backend is missing required methods.
1597
+ """
1598
+ _validate_backend(backend)
1599
+ if name not in _TRAJECTORY_GENERATORS:
1600
+ raise ValueError(
1601
+ f"Unknown trajectory '{name}'. Valid trajectories: "
1602
+ f"{sorted(_TRAJECTORY_GENERATORS.keys())}."
1603
+ )
1604
+ geom = read_header_geometry(header)
1605
+ if geom["inferred"]:
1606
+ warnings.warn(
1607
+ f"Some geometry fields were inferred: {geom['inferred']}. "
1608
+ "Verify header completeness for accurate k-space scaling.",
1609
+ UserWarning,
1610
+ stacklevel=2,
1611
+ )
1612
+ return _TRAJECTORY_GENERATORS[name](geom, params, backend)
1613
+
1614
+
1615
+ # ---------------------------------------------------------------------------
1616
+ # Public API: shots_from_continuous
1617
+ # ---------------------------------------------------------------------------
1618
+
1619
+ def shots_from_continuous(
1620
+ coords: Array,
1621
+ segmentation: Dict[str, Any],
1622
+ backend: Backend,
1623
+ ) -> List[Array]:
1624
+ """
1625
+ Split a continuous k-space readout array into shots.
1626
+
1627
+ Parameters
1628
+ ----------
1629
+ coords : array, shape (N_total_samples, Ndims)
1630
+ Continuous coordinate array in cycles/m.
1631
+ segmentation : dict
1632
+ Must contain one of:
1633
+ 'n_shots' : int — equal-length split into n_shots chunks.
1634
+ 'shot_lengths' : list[int] — variable lengths per shot.
1635
+ 'shot_boundaries' : list[int] — start indices for each shot
1636
+ (last boundary implicitly at N_total_samples).
1637
+ backend : Backend
1638
+
1639
+ Returns
1640
+ -------
1641
+ list of arrays, each shape (Nsamples_i, Ndims)
1642
+
1643
+ Raises
1644
+ ------
1645
+ ValueError
1646
+ If segmentation dict lacks required keys or lengths are inconsistent.
1647
+ """
1648
+ _validate_backend(backend)
1649
+ total = backend.shape(coords)[0]
1650
+ cpu_coords = backend.to_cpu(coords)
1651
+
1652
+ if "n_shots" in segmentation:
1653
+ n = int(segmentation["n_shots"])
1654
+ if total % n != 0:
1655
+ raise ValueError(
1656
+ f"'n_shots'={n} does not evenly divide total samples={total}. "
1657
+ "Use 'shot_lengths' or 'shot_boundaries' for unequal shots."
1658
+ )
1659
+ sps = total // n
1660
+ shots = [backend.asarray(cpu_coords[i * sps: (i + 1) * sps]) for i in range(n)]
1661
+
1662
+ elif "shot_lengths" in segmentation:
1663
+ lengths = list(segmentation["shot_lengths"])
1664
+ if sum(lengths) != total:
1665
+ raise ValueError(
1666
+ f"Sum of shot_lengths ({sum(lengths)}) != total samples ({total})."
1667
+ )
1668
+ shots = []
1669
+ start = 0
1670
+ for length in lengths:
1671
+ shots.append(backend.asarray(cpu_coords[start: start + length]))
1672
+ start += length
1673
+
1674
+ elif "shot_boundaries" in segmentation:
1675
+ bounds = list(segmentation["shot_boundaries"]) + [total]
1676
+ shots = []
1677
+ for s, e in zip(bounds[:-1], bounds[1:]):
1678
+ shots.append(backend.asarray(cpu_coords[s:e]))
1679
+
1680
+ else:
1681
+ raise ValueError(
1682
+ "segmentation must contain 'n_shots', 'shot_lengths', or 'shot_boundaries'. "
1683
+ f"Got keys: {list(segmentation.keys())}."
1684
+ )
1685
+ return shots
1686
+
1687
+
1688
+ # ---------------------------------------------------------------------------
1689
+ # Undersampling Strategies (internal helpers)
1690
+ # ---------------------------------------------------------------------------
1691
+
1692
+ def _us_prefix(n_shots: int, af: float, params: dict) -> np.ndarray:
1693
+ """Keep first ceil(n_shots / AF) shots."""
1694
+ n_keep = math.ceil(n_shots / af)
1695
+ mask = np.zeros(n_shots, dtype=bool)
1696
+ mask[:n_keep] = True
1697
+ return mask
1698
+
1699
+
1700
+ def _us_golden_prefix(n_shots: int, af: float, params: dict) -> np.ndarray:
1701
+ """
1702
+ Equivalent to prefix for golden-angle sorted trajectories.
1703
+ Alias of prefix; golden-angle ordering guarantees spatial uniformity.
1704
+ """
1705
+ return _us_prefix(n_shots, af, params)
1706
+
1707
+
1708
+ def _us_drop_every(n_shots: int, af: float, params: dict) -> np.ndarray:
1709
+ """Drop every M-th shot. M = round(AF) by default or params['drop_every_m']."""
1710
+ m = int(params.get("drop_every_m", max(1, round(af))))
1711
+ mask = np.ones(n_shots, dtype=bool)
1712
+ mask[::m] = False
1713
+ return mask
1714
+
1715
+
1716
+ def _us_random_vd(n_shots: int, af: float, params: dict,
1717
+ coords_per_shot: List[Array], backend: Backend) -> np.ndarray:
1718
+ """
1719
+ Variable-density random undersampling.
1720
+ Centre shots (lower norm) are kept with higher probability.
1721
+
1722
+ params
1723
+ ------
1724
+ vd_beta : float
1725
+ Density decay exponent. 0 = uniform random, >0 = centre-weighted (default 2).
1726
+ acs_fraction : float
1727
+ Fraction of shots nearest to k-centre always kept (default 0.05).
1728
+ """
1729
+ n_keep = math.ceil(n_shots / af)
1730
+ vd_beta = float(params.get("vd_beta", 2.0))
1731
+ acs_frac = float(params.get("acs_fraction", 0.05))
1732
+
1733
+ # Compute shot centre norms (use numpy via to_cpu)
1734
+ norms = []
1735
+ for shot in coords_per_shot:
1736
+ cpu = backend.to_cpu(shot)
1737
+ norms.append(float(np.linalg.norm(cpu.mean(axis=0))))
1738
+ norms = np.array(norms, dtype=np.float32)
1739
+
1740
+ # Normalise to [0,1]
1741
+ if norms.max() > 0:
1742
+ norms_n = norms / norms.max()
1743
+ else:
1744
+ norms_n = norms
1745
+
1746
+ # Probability: centre gets high prob
1747
+ prob = np.exp(-vd_beta * norms_n)
1748
+ prob /= prob.sum()
1749
+
1750
+ acs_n = max(0, int(acs_frac * n_shots))
1751
+ sorted_idx = np.argsort(norms)
1752
+ acs_idx = sorted_idx[:acs_n]
1753
+
1754
+ outer_idx = np.setdiff1d(np.arange(n_shots), acs_idx)
1755
+ n_outer_keep = max(0, n_keep - acs_n)
1756
+ outer_prob = prob[outer_idx]
1757
+ outer_prob /= outer_prob.sum()
1758
+ chosen = np.random.choice(outer_idx, size=min(n_outer_keep, len(outer_idx)),
1759
+ replace=False, p=outer_prob)
1760
+
1761
+ mask = np.zeros(n_shots, dtype=bool)
1762
+ mask[acs_idx] = True
1763
+ mask[chosen] = True
1764
+ return mask
1765
+
1766
+
1767
+ def _us_keep_acs(n_shots: int, af: float, params: dict,
1768
+ coords_per_shot: List[Array], backend: Backend,
1769
+ traj_name: str) -> np.ndarray:
1770
+ """
1771
+ Keep shots whose centre lies within the ACS region (innermost fraction of k-space).
1772
+
1773
+ params
1774
+ ------
1775
+ acs_radius_fraction : float
1776
+ Fraction of kmax within which shots are kept (default 0.25).
1777
+ acs_n_lines : int
1778
+ For Cartesian: number of central lines to keep (overrides acs_radius_fraction).
1779
+ """
1780
+ acs_frac = float(params.get("acs_radius_fraction", 0.25))
1781
+ mask = np.zeros(n_shots, dtype=bool)
1782
+
1783
+ norms = []
1784
+ for shot in coords_per_shot:
1785
+ cpu = backend.to_cpu(shot)
1786
+ norms.append(float(np.linalg.norm(cpu.mean(axis=0))))
1787
+ norms = np.array(norms, dtype=np.float32)
1788
+
1789
+ if norms.max() > 0:
1790
+ norms_n = norms / norms.max()
1791
+ else:
1792
+ norms_n = np.zeros(n_shots, dtype=np.float32)
1793
+
1794
+ if "acs_n_lines" in params and "cartesian" in traj_name:
1795
+ # Centre the selection
1796
+ acs_n = int(params["acs_n_lines"])
1797
+ centre_idx = np.argsort(norms_n)[:acs_n]
1798
+ mask[centre_idx] = True
1799
+ else:
1800
+ mask[norms_n <= acs_frac] = True
1801
+
1802
+ if not mask.any():
1803
+ warnings.warn(
1804
+ "keep_acs found no shots inside ACS region. "
1805
+ f"Increase acs_radius_fraction (currently {acs_frac}) or acs_n_lines.",
1806
+ UserWarning, stacklevel=3,
1807
+ )
1808
+ return mask
1809
+
1810
+
1811
+ def _us_poisson_disc_cartesian(n_shots: int, af: float, params: dict,
1812
+ coords_per_shot: List[Array],
1813
+ backend: Backend) -> np.ndarray:
1814
+ """
1815
+ Variable-density Poisson-disc undersampling for Cartesian trajectories.
1816
+
1817
+ Full Poisson-disc is expensive; this uses a simplified VD-random approach
1818
+ with a centre-dense probability map as a practical stand-in compatible
1819
+ with MRI pipelines.
1820
+
1821
+ params
1822
+ ------
1823
+ acs_lines : int
1824
+ Number of central lines always included (default: 24).
1825
+ vd_beta : float
1826
+ Density exponent (default: 3.0).
1827
+ seed : int
1828
+ RNG seed (default: 42).
1829
+ """
1830
+ acs_lines = int(params.get("acs_lines", 24))
1831
+ vd_beta = float(params.get("vd_beta", 3.0))
1832
+ seed = int(params.get("seed", 42))
1833
+ rng = np.random.default_rng(seed)
1834
+
1835
+ # Phase encode index from shot centre
1836
+ norms = []
1837
+ for shot in coords_per_shot:
1838
+ cpu = backend.to_cpu(shot)
1839
+ norms.append(float(np.abs(cpu[:, 1]).mean())) # ky-like axis
1840
+ norms = np.array(norms, dtype=np.float32)
1841
+
1842
+ if norms.max() > 0:
1843
+ norms_n = norms / norms.max()
1844
+ else:
1845
+ norms_n = np.zeros(n_shots, dtype=np.float32)
1846
+
1847
+ sorted_idx = np.argsort(norms_n)
1848
+ acs_idx = sorted_idx[:acs_lines]
1849
+
1850
+ n_keep = max(0, math.ceil(n_shots / af) - acs_lines)
1851
+ remaining = sorted_idx[acs_lines:]
1852
+ prob = np.exp(-vd_beta * norms_n[remaining])
1853
+ if prob.sum() > 0:
1854
+ prob /= prob.sum()
1855
+ else:
1856
+ prob = np.ones(len(remaining), dtype=np.float32) / len(remaining)
1857
+
1858
+ chosen = rng.choice(remaining, size=min(n_keep, len(remaining)),
1859
+ replace=False, p=prob)
1860
+
1861
+ mask = np.zeros(n_shots, dtype=bool)
1862
+ mask[acs_idx] = True
1863
+ mask[chosen] = True
1864
+ return mask
1865
+
1866
+
1867
+ def _us_combined(n_shots: int, af: float, params: dict,
1868
+ coords_per_shot: List[Array],
1869
+ backend: Backend, traj_name: str) -> np.ndarray:
1870
+ """
1871
+ Combined strategy: keep ACS + prefix/random_vd on remaining shots.
1872
+
1873
+ params
1874
+ ------
1875
+ acs_radius_fraction : float (default 0.15)
1876
+ outer_method : str 'prefix' | 'random_vd' | 'drop_every' (default 'prefix')
1877
+ outer_af : float (default = af, applied to outer shots only)
1878
+ """
1879
+ acs_mask = _us_keep_acs(n_shots, af, params, coords_per_shot, backend, traj_name)
1880
+ outer_idx = np.where(~acs_mask)[0]
1881
+ outer_method = str(params.get("outer_method", "prefix"))
1882
+ outer_af = float(params.get("outer_af", af))
1883
+
1884
+ outer_coords = [coords_per_shot[i] for i in outer_idx]
1885
+ n_outer = len(outer_idx)
1886
+
1887
+ if outer_method == "prefix":
1888
+ outer_mask = _us_prefix(n_outer, outer_af, params)
1889
+ elif outer_method == "random_vd":
1890
+ outer_mask = _us_random_vd(n_outer, outer_af, params, outer_coords, backend)
1891
+ elif outer_method == "drop_every":
1892
+ outer_mask = _us_drop_every(n_outer, outer_af, params)
1893
+ else:
1894
+ raise ValueError(
1895
+ f"combined outer_method '{outer_method}' not recognised. "
1896
+ "Use 'prefix', 'random_vd', or 'drop_every'."
1897
+ )
1898
+
1899
+ mask = acs_mask.copy()
1900
+ mask[outer_idx[outer_mask]] = True
1901
+ return mask
1902
+
1903
+
1904
+ def _us_variable_density_spiral(n_shots: int, af: float, params: dict) -> np.ndarray:
1905
+ """
1906
+ Spiral undersampling by removing outer arms.
1907
+ Arms are assumed ordered centre-to-outer; keep first ceil(n/AF).
1908
+ """
1909
+ return _us_prefix(n_shots, af, params)
1910
+
1911
+
1912
+ def _us_shell_based(n_shots: int, af: float, params: dict,
1913
+ coords_per_shot: List[Array], backend: Backend) -> np.ndarray:
1914
+ """
1915
+ Shell-based undersampling for 3D trajectories: keep full inner shells,
1916
+ undersample outer shells progressively.
1917
+
1918
+ params
1919
+ ------
1920
+ n_shells : int
1921
+ Number of concentric shells to divide shots into (default: 4).
1922
+ shell_afs : list[float]
1923
+ Per-shell acceleration factors from inner to outer
1924
+ (default: geometrically increasing from 1 to af).
1925
+ """
1926
+ n_shells = int(params.get("n_shells", 4))
1927
+ shell_afs_param = params.get("shell_afs", None)
1928
+
1929
+ norms = np.array([float(np.linalg.norm(backend.to_cpu(s).mean(axis=0)))
1930
+ for s in coords_per_shot], dtype=np.float32)
1931
+ if norms.max() > 0:
1932
+ norms_n = norms / norms.max()
1933
+ else:
1934
+ norms_n = np.zeros(n_shots, dtype=np.float32)
1935
+
1936
+ shell_edges = np.linspace(0.0, 1.0, n_shells + 1)
1937
+ if shell_afs_param is None:
1938
+ shell_afs = np.geomspace(1.0, af, n_shells)
1939
+ else:
1940
+ shell_afs = np.asarray(shell_afs_param, dtype=np.float64)
1941
+ if len(shell_afs) != n_shells:
1942
+ raise ValueError(
1943
+ f"shell_afs length ({len(shell_afs)}) must equal n_shells ({n_shells})."
1944
+ )
1945
+
1946
+ mask = np.zeros(n_shots, dtype=bool)
1947
+ for si in range(n_shells):
1948
+ idx = np.where((norms_n >= shell_edges[si]) &
1949
+ (norms_n < shell_edges[si + 1]))[0]
1950
+ n_keep = math.ceil(len(idx) / float(shell_afs[si]))
1951
+ chosen = idx[:n_keep]
1952
+ mask[chosen] = True
1953
+
1954
+ return mask
1955
+
1956
+
1957
+ # ---------------------------------------------------------------------------
1958
+ # Public API: undersample_shots
1959
+ # ---------------------------------------------------------------------------
1960
+
1961
+ def undersample_shots(
1962
+ coords_per_shot: List[Array],
1963
+ method: str,
1964
+ AF: float,
1965
+ params: ParamsDict,
1966
+ backend: Backend,
1967
+ trajectory_name: str = "",
1968
+ ) -> Tuple[Array, Optional[List[Array]]]:
1969
+ """
1970
+ Apply an undersampling strategy to a list of shots.
1971
+
1972
+ Parameters
1973
+ ----------
1974
+ coords_per_shot : list of arrays, each shape (Nsamples, Ndims)
1975
+ Full trajectory shots.
1976
+ method : str
1977
+ Undersampling method. One of:
1978
+ 'prefix', 'golden_prefix', 'drop_every', 'poisson_disc_cartesian',
1979
+ 'random_vd', 'keep_acs', 'variable_density_spiral',
1980
+ 'shell_based', 'combined'.
1981
+ AF : float
1982
+ Acceleration factor (≥ 1). Number of retained shots ≈ N / AF.
1983
+ Exact: N_keep = ceil(N_total / AF).
1984
+ params : dict
1985
+ Method-specific parameters (see each _us_* helper for keys).
1986
+ backend : Backend
1987
+ trajectory_name : str, optional
1988
+ Name of the trajectory (used for compatibility checks).
1989
+
1990
+ Returns
1991
+ -------
1992
+ shot_mask : boolean array, shape (N_shots,)
1993
+ True → shot is included.
1994
+ per_sample_masks : list of boolean arrays | None
1995
+ One boolean array of shape (Nsamples,) per *included* shot.
1996
+ Currently None (per-shot masks deferred to downstream partial-Fourier
1997
+ or variable-density readout implementations).
1998
+
1999
+ Raises
2000
+ ------
2001
+ ValueError
2002
+ If method is not supported, AF < 1, or trajectory/method incompatible.
2003
+ TypeError
2004
+ If backend is missing required methods.
2005
+ """
2006
+ _validate_backend(backend)
2007
+
2008
+ if AF < 1.0:
2009
+ raise ValueError(
2010
+ f"AF must be ≥ 1.0 (got {AF}). "
2011
+ "AF < 1 would require more shots than fully sampled — not valid."
2012
+ )
2013
+
2014
+ n_shots = len(coords_per_shot)
2015
+ if n_shots == 0:
2016
+ raise ValueError("coords_per_shot is empty — no shots to undersample.")
2017
+
2018
+ # Compatibility check
2019
+ if trajectory_name:
2020
+ compat = _TRAJ_US_COMPAT.get(trajectory_name, None)
2021
+ if compat is not None and method not in compat:
2022
+ raise ValueError(
2023
+ f"Undersampling method '{method}' is not compatible with "
2024
+ f"trajectory '{trajectory_name}'. "
2025
+ f"Supported methods for this trajectory: {compat}. "
2026
+ "Pass trajectory_name='' to bypass compatibility checking."
2027
+ )
2028
+
2029
+ valid_methods = [
2030
+ "prefix", "golden_prefix", "drop_every", "poisson_disc_cartesian",
2031
+ "random_vd", "keep_acs", "variable_density_spiral", "shell_based", "combined",
2032
+ ]
2033
+ if method not in valid_methods:
2034
+ raise ValueError(
2035
+ f"Unknown undersampling method '{method}'. Valid: {valid_methods}."
2036
+ )
2037
+
2038
+ traj_name = trajectory_name or ""
2039
+
2040
+ if method == "prefix":
2041
+ mask_np = _us_prefix(n_shots, AF, params)
2042
+ elif method == "golden_prefix":
2043
+ mask_np = _us_golden_prefix(n_shots, AF, params)
2044
+ elif method == "drop_every":
2045
+ mask_np = _us_drop_every(n_shots, AF, params)
2046
+ elif method == "poisson_disc_cartesian":
2047
+ mask_np = _us_poisson_disc_cartesian(n_shots, AF, params, coords_per_shot, backend)
2048
+ elif method == "random_vd":
2049
+ mask_np = _us_random_vd(n_shots, AF, params, coords_per_shot, backend)
2050
+ elif method == "keep_acs":
2051
+ mask_np = _us_keep_acs(n_shots, AF, params, coords_per_shot, backend, traj_name)
2052
+ elif method == "variable_density_spiral":
2053
+ mask_np = _us_variable_density_spiral(n_shots, AF, params)
2054
+ elif method == "shell_based":
2055
+ mask_np = _us_shell_based(n_shots, AF, params, coords_per_shot, backend)
2056
+ elif method == "combined":
2057
+ mask_np = _us_combined(n_shots, AF, params, coords_per_shot, backend, traj_name)
2058
+
2059
+ achieved_af = n_shots / max(mask_np.sum(), 1)
2060
+ if abs(achieved_af - AF) > 0.5 * AF:
2061
+ warnings.warn(
2062
+ f"Achieved AF={achieved_af:.2f} differs substantially from requested AF={AF:.2f}. "
2063
+ "Consider adjusting 'drop_every_m', 'acs_lines', or 'acs_radius_fraction'.",
2064
+ UserWarning,
2065
+ stacklevel=2,
2066
+ )
2067
+
2068
+ shot_mask = backend.cast_bool(backend.asarray(mask_np.astype(np.uint8)))
2069
+ return shot_mask, None # per_sample_masks deferred → None
2070
+
2071
+
2072
+ # ---------------------------------------------------------------------------
2073
+ # Public API: get_kspace_shots_and_mask (full pipeline wrapper)
2074
+ # ---------------------------------------------------------------------------
2075
+
2076
+ def get_kspace_shots_and_mask(
2077
+ header: HeaderDict,
2078
+ trajectory: str,
2079
+ undersampling: str,
2080
+ AF: float,
2081
+ traj_params: ParamsDict,
2082
+ us_params: ParamsDict,
2083
+ backend: Backend,
2084
+ ) -> Tuple[List[Array], Array, MetaDict]:
2085
+ """
2086
+ Full pipeline: generate trajectory → apply undersampling → return results.
2087
+
2088
+ Parameters
2089
+ ----------
2090
+ header : dict
2091
+ Parsed NIfTI-MRS header dictionary.
2092
+ trajectory : str
2093
+ Trajectory name (see generate_trajectory for full list).
2094
+ undersampling : str
2095
+ Undersampling method name (see undersample_shots for full list).
2096
+ AF : float
2097
+ Acceleration factor (≥ 1).
2098
+ traj_params : dict
2099
+ Passed verbatim to generate_trajectory.
2100
+ us_params : dict
2101
+ Passed verbatim to undersample_shots.
2102
+ backend : Backend
2103
+ NumpyBackend() or TorchBackend() instance.
2104
+
2105
+ Returns
2106
+ -------
2107
+ coords_per_shot : list of arrays, each shape (Nsamples, Ndims)
2108
+ Coordinates in cycles/m for **all** generated shots (before masking).
2109
+ Use shot_mask to select retained shots.
2110
+ shot_mask : boolean array, shape (N_shots,)
2111
+ True → shot is retained (acquired).
2112
+ meta : dict
2113
+ Extended metadata dict including:
2114
+ trajectory_type, kmax, fov_mm, matrix, n_shots, samples_per_shot,
2115
+ n_shots_retained, achieved_AF, density_estimate, params_used,
2116
+ undersampling_method, AF_requested.
2117
+
2118
+ Example
2119
+ -------
2120
+ >>> from kspace_sampling import get_kspace_shots_and_mask, NumpyBackend
2121
+ >>> header = {
2122
+ ... "dim": [4, 64, 64, 1, 1, 1, 1, 1],
2123
+ ... "pixdim": [1.0, 3.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0],
2124
+ ... "DwellTime": 1e-4,
2125
+ ... "SpectrometerFrequency": [127.74e6],
2126
+ ... }
2127
+ >>> shots, mask, meta = get_kspace_shots_and_mask(
2128
+ ... header, "golden_radial_2d", "prefix", AF=4.0,
2129
+ ... traj_params={"n_shots": 200, "samples_per_shot": 64},
2130
+ ... us_params={},
2131
+ ... backend=NumpyBackend(),
2132
+ ... )
2133
+ >>> # shots: list of 200 arrays each (64, 2)
2134
+ >>> # mask: bool array (200,), ~50 True entries
2135
+ """
2136
+ _validate_backend(backend)
2137
+
2138
+ coords_per_shot, meta = generate_trajectory(trajectory, header, traj_params, backend)
2139
+ shot_mask, per_sample_masks = undersample_shots(
2140
+ coords_per_shot, undersampling, AF, us_params, backend,
2141
+ trajectory_name=trajectory,
2142
+ )
2143
+
2144
+ mask_np = backend.to_cpu(shot_mask).astype(bool)
2145
+ n_retained = int(mask_np.sum())
2146
+ achieved_af = len(coords_per_shot) / max(n_retained, 1)
2147
+
2148
+ meta.update({
2149
+ "undersampling_method": undersampling,
2150
+ "AF_requested": AF,
2151
+ "n_shots_retained": n_retained,
2152
+ "achieved_AF": achieved_af,
2153
+ "us_params_used": us_params,
2154
+ })
2155
+
2156
+ return coords_per_shot, shot_mask, meta
2157
+
2158
+
2159
+ # ---------------------------------------------------------------------------
2160
+ # Simulation Hooks (signatures only; brief/optional implementations)
2161
+ # ---------------------------------------------------------------------------
2162
+
2163
+ def apply_shot_phase_errors(
2164
+ coords_per_shot: List[Array],
2165
+ phase_std_rad: float,
2166
+ backend: Backend,
2167
+ seed: int = 0,
2168
+ ) -> List[Array]:
2169
+ """
2170
+ Simulation hook: inject shot-to-shot phase errors (k-space modulation).
2171
+
2172
+ This does NOT modify coordinates — it returns a list of scalar phase
2173
+ offset values (in radians) per shot that can be applied as complex
2174
+ exponential weights during NUFFT.
2175
+
2176
+ Parameters
2177
+ ----------
2178
+ coords_per_shot : list of arrays
2179
+ phase_std_rad : float
2180
+ Standard deviation of normally distributed random phase offsets [rad].
2181
+ backend : Backend
2182
+ seed : int
2183
+ RNG seed.
2184
+
2185
+ Returns
2186
+ -------
2187
+ list of float
2188
+ One phase offset (radians) per shot.
2189
+ """
2190
+ rng = np.random.default_rng(seed)
2191
+ return [float(rng.normal(0.0, phase_std_rad)) for _ in coords_per_shot]
2192
+
2193
+
2194
+ def apply_gradient_delay(
2195
+ coords_per_shot: List[Array],
2196
+ delay_samples: float,
2197
+ backend: Backend,
2198
+ ) -> List[Array]:
2199
+ """
2200
+ Simulation hook: shift each shot's k-space trajectory by a gradient delay.
2201
+
2202
+ The delay is applied along the readout dimension (axis 0 of each shot).
2203
+
2204
+ k_shifted[n] = k[n + delay_samples] approximated by:
2205
+ k_shifted ≈ k + delay_samples * dk where dk is the per-sample step.
2206
+
2207
+ Parameters
2208
+ ----------
2209
+ coords_per_shot : list of arrays, each (Nsamples, Ndims)
2210
+ delay_samples : float
2211
+ Delay in samples (may be fractional).
2212
+ backend : Backend
2213
+
2214
+ Returns
2215
+ -------
2216
+ list of arrays (same shapes), shifted along readout.
2217
+ """
2218
+ shifted = []
2219
+ for shot in coords_per_shot:
2220
+ cpu = backend.to_cpu(shot)
2221
+ if cpu.shape[0] < 2:
2222
+ shifted.append(shot)
2223
+ continue
2224
+ dk = cpu[1] - cpu[0] # per-sample k-space increment (Ndims,)
2225
+ shift = dk * delay_samples
2226
+ shifted_cpu = cpu + shift[np.newaxis, :]
2227
+ shifted.append(backend.asarray(shifted_cpu))
2228
+ return shifted
2229
+
2230
+
2231
+ def apply_off_resonance_phase(
2232
+ coords_per_shot: List[Array],
2233
+ freq_offset_hz: float,
2234
+ dwell_time_s: float,
2235
+ backend: Backend,
2236
+ ) -> List[float]:
2237
+ """
2238
+ Simulation hook: compute per-sample off-resonance phase for each shot.
2239
+
2240
+ Returns modulation phases φ(t) = 2π * Δf * t_n for downstream application
2241
+ as complex weights. Does not modify coordinate arrays.
2242
+
2243
+ Parameters
2244
+ ----------
2245
+ coords_per_shot : list of arrays, each (Nsamples, Ndims)
2246
+ freq_offset_hz : float
2247
+ Off-resonance frequency in Hz.
2248
+ dwell_time_s : float
2249
+ Sampling interval in seconds (== DwellTime from header).
2250
+ backend : Backend
2251
+
2252
+ Returns
2253
+ -------
2254
+ list of arrays, each (Nsamples,) — phase in radians.
2255
+ """
2256
+ phases = []
2257
+ for shot in coords_per_shot:
2258
+ nsamples = backend.shape(shot)[0]
2259
+ t = backend.arange(0, nsamples) * dwell_time_s
2260
+ phi = 2.0 * math.pi * freq_offset_hz * t
2261
+ phases.append(phi)
2262
+ return phases
2263
+
2264
+
2265
+ # ---------------------------------------------------------------------------
2266
+ # Density Estimate (public wrapper)
2267
+ # ---------------------------------------------------------------------------
2268
+
2269
+ def compute_density_estimate(
2270
+ coords_per_shot: List[Array],
2271
+ shot_mask: Array,
2272
+ meta: MetaDict,
2273
+ backend: Backend,
2274
+ ) -> dict:
2275
+ """
2276
+ Return a refreshed density estimate for a given (possibly undersampled) subset.
2277
+
2278
+ Does NOT compute DCF — it returns an analytic placeholder dict with
2279
+ recomputed subset statistics for use by downstream pipe or iterative DCF code.
2280
+
2281
+ Parameters
2282
+ ----------
2283
+ coords_per_shot : list of arrays (all shots, before masking)
2284
+ shot_mask : boolean array (N_shots,)
2285
+ meta : dict from generate_trajectory or get_kspace_shots_and_mask
2286
+ backend : Backend
2287
+
2288
+ Returns
2289
+ -------
2290
+ dict
2291
+ Updated density estimate dict appropriate for the retained subset.
2292
+ """
2293
+ mask_np = backend.to_cpu(shot_mask).astype(bool)
2294
+ retained = [s for s, m in zip(coords_per_shot, mask_np) if m]
2295
+ n_retained = len(retained)
2296
+ sps = backend.shape(retained[0])[0] if retained else 0
2297
+
2298
+ base = meta.get("density_estimate", {}).copy()
2299
+ base.update({
2300
+ "n_shots_subset": n_retained,
2301
+ "samples_per_shot_subset": sps,
2302
+ "total_samples_subset": n_retained * sps,
2303
+ "achieved_AF": meta.get("n_shots", n_retained) / max(n_retained, 1),
2304
+ "recomputed_for_subset": True,
2305
+ })
2306
+ return base
2307
+
2308
+
2309
+ # ---------------------------------------------------------------------------
2310
+ # Utility: export shots and mask to .npz
2311
+ # ---------------------------------------------------------------------------
2312
+
2313
+ def save_shots_npz(
2314
+ path: str,
2315
+ coords_per_shot: List[Array],
2316
+ shot_mask: Array,
2317
+ meta: MetaDict,
2318
+ backend: Backend,
2319
+ ) -> None:
2320
+ """
2321
+ Save trajectory and mask to a NumPy .npz archive.
2322
+
2323
+ Saved arrays
2324
+ ------------
2325
+ shot_mask : (N_shots,) bool
2326
+ coords_N : (Nsamples, Ndims) float32 for each shot N (0-padded index)
2327
+ meta keys serialised as individual 0-d string arrays.
2328
+
2329
+ Parameters
2330
+ ----------
2331
+ path : str
2332
+ File path (should end in .npz).
2333
+ coords_per_shot : list of arrays
2334
+ shot_mask : boolean array
2335
+ meta : dict
2336
+ backend : Backend
2337
+ """
2338
+ payload: dict = {}
2339
+ payload["shot_mask"] = backend.to_cpu(shot_mask)
2340
+ for i, shot in enumerate(coords_per_shot):
2341
+ payload[f"coords_{i:06d}"] = backend.to_cpu(shot).astype(np.float32)
2342
+ # Save select meta fields as scalars
2343
+ for key in ["trajectory_type", "n_shots", "n_shots_retained",
2344
+ "achieved_AF", "AF_requested", "undersampling_method"]:
2345
+ if key in meta:
2346
+ payload[f"meta_{key}"] = np.array(str(meta[key]))
2347
+ kmax = meta.get("kmax")
2348
+ if kmax:
2349
+ payload["meta_kmax"] = np.array(kmax, dtype=np.float32)
2350
+ np.savez_compressed(path, **payload)
2351
+ print(f"Saved {len(coords_per_shot)} shots + mask → '{path}'")
2352
+
2353
+
2354
+ def load_shots_npz(path: str, backend: Backend) -> Tuple[List[Array], Array, dict]:
2355
+ """
2356
+ Load a .npz archive saved by save_shots_npz.
2357
+
2358
+ Returns
2359
+ -------
2360
+ coords_per_shot, shot_mask, meta_partial
2361
+ """
2362
+ data = np.load(path, allow_pickle=False)
2363
+ shot_mask = backend.cast_bool(backend.asarray(data["shot_mask"].astype(np.uint8)))
2364
+
2365
+ shot_keys = sorted(k for k in data.files if k.startswith("coords_"))
2366
+ coords_per_shot = [backend.asarray(data[k]) for k in shot_keys]
2367
+
2368
+ meta_partial: dict = {}
2369
+ for k in data.files:
2370
+ if k.startswith("meta_"):
2371
+ field = k[len("meta_"):]
2372
+ arr = data[k]
2373
+ try:
2374
+ meta_partial[field] = arr.item()
2375
+ except ValueError:
2376
+ # Multi-element array (e.g. kmax tuple)
2377
+ meta_partial[field] = arr.tolist()
2378
+
2379
+ return coords_per_shot, shot_mask, meta_partial
2380
+
2381
+
2382
+ # ---------------------------------------------------------------------------
2383
+ # Self-test / example block
2384
+ # ---------------------------------------------------------------------------
2385
+
2386
+ def _run_selftest() -> None:
2387
+ """
2388
+ Self-test: exercise the main pipeline with a synthetic 2D golden-radial
2389
+ trajectory and prefix undersampling (AF=4).
2390
+
2391
+ Test description (also serves as usage example):
2392
+ ------------------------------------------------
2393
+ 1. Create a synthetic NIfTI-MRS-style header dict with keys:
2394
+ 'dim', 'pixdim', 'DwellTime', 'SpectrometerFrequency'.
2395
+ 2. Instantiate NumpyBackend.
2396
+ 3. Call get_kspace_shots_and_mask with trajectory='golden_radial_2d',
2397
+ undersampling='prefix', AF=4.0, n_shots=200, samples_per_shot=64.
2398
+ 4. Assert: len(coords_per_shot)==200, each shape (64,2), shot_mask shape==(200,),
2399
+ sum(shot_mask)==ceil(200/4)==50.
2400
+ 5. Save to /tmp/kspace_test.npz and reload — verify round-trip shape.
2401
+ 6. (Optional) Swap to TorchBackend: backend = TorchBackend(device='cpu')
2402
+ and rerun; all returned objects will be torch.Tensor.
2403
+
2404
+ To swap to TorchBackend verbally:
2405
+ Replace NumpyBackend() with TorchBackend() (or TorchBackend(device='cuda')).
2406
+ All coordinate arrays and the shot_mask will be torch.Tensor objects
2407
+ on the specified device, fully compatible with NUFFT libraries like
2408
+ torchkbnufft or sigpy.
2409
+ """
2410
+ print("=" * 60)
2411
+ print("kspace_sampling self-test")
2412
+ print("=" * 60)
2413
+
2414
+ # ----- Synthetic NIfTI-MRS header -----
2415
+ header = {
2416
+ "dim": [4, 64, 64, 1, 1, 1, 1, 1], # dim[1..3] = 64×64×1
2417
+ "pixdim": [1.0, 3.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0], # pixdim[1..3] = 3×3×3 mm
2418
+ "DwellTime": 1e-4, # 100 µs dwell
2419
+ "SpectrometerFrequency": [127.74e6], # 3T 1H
2420
+ }
2421
+
2422
+ backend = NumpyBackend()
2423
+ print(f"Backend: {backend}")
2424
+
2425
+ # ----- Geometry parsing -----
2426
+ geom = read_header_geometry(header)
2427
+ print(f" Geometry: fov_mm={geom['fov_mm']}, matrix={geom['matrix']}, "
2428
+ f"voxel_mm={geom['voxel_mm']}, ndim={geom['ndim']}")
2429
+
2430
+ # ----- Trajectory generation -----
2431
+ traj_params = {"n_shots": 200, "samples_per_shot": 64, "center_out": True}
2432
+ shots, mask, meta = get_kspace_shots_and_mask(
2433
+ header, "golden_radial_2d", "prefix", AF=4.0,
2434
+ traj_params=traj_params, us_params={}, backend=backend,
2435
+ )
2436
+
2437
+ n_shots = len(shots)
2438
+ sps = backend.shape(shots[0])[0]
2439
+ ndims = backend.shape(shots[0])[1]
2440
+ mask_np = backend.to_cpu(mask).astype(bool)
2441
+ n_retained = int(mask_np.sum())
2442
+ expected_retained = math.ceil(n_shots / 4.0)
2443
+
2444
+ print(f" n_shots: {n_shots} (expected 200)")
2445
+ print(f" samples_per_shot: {sps} (expected 64)")
2446
+ print(f" Ndims: {ndims} (expected 2)")
2447
+ print(f" shot_mask shape: {mask_np.shape} (expected (200,))")
2448
+ print(f" n_retained: {n_retained} (expected {expected_retained})")
2449
+ print(f" kmax: {meta['kmax']}")
2450
+ print(f" trajectory_type: {meta['trajectory_type']}")
2451
+
2452
+ assert n_shots == 200, f"Expected 200 shots, got {n_shots}"
2453
+ assert sps == 64, f"Expected 64 samples/shot, got {sps}"
2454
+ assert ndims == 2, f"Expected 2D, got Ndims={ndims}"
2455
+ assert mask_np.shape == (200,), f"shot_mask shape mismatch: {mask_np.shape}"
2456
+ assert n_retained == expected_retained, \
2457
+ f"n_retained={n_retained} != expected {expected_retained}"
2458
+
2459
+ # ----- Round-trip .npz -----
2460
+ import tempfile, os
2461
+ with tempfile.TemporaryDirectory() as tmpdir:
2462
+ fpath = os.path.join(tmpdir, "kspace_test.npz")
2463
+ save_shots_npz(fpath, shots, mask, meta, backend)
2464
+ shots2, mask2, meta2 = load_shots_npz(fpath, backend)
2465
+ assert len(shots2) == 200
2466
+ assert backend.shape(shots2[0]) == (64, 2)
2467
+ assert backend.to_cpu(mask2).astype(bool).sum() == n_retained
2468
+ print(f" Round-trip .npz: OK (loaded {len(shots2)} shots)")
2469
+
2470
+ # ----- 3D phyllotaxis quick smoke test -----
2471
+ header3d = {
2472
+ "dim": [4, 32, 32, 32, 1, 1, 1, 1],
2473
+ "pixdim": [1.0, 5.0, 5.0, 5.0, 1.0, 1.0, 1.0, 1.0],
2474
+ "DwellTime": 1e-4,
2475
+ }
2476
+ shots3d, mask3d, meta3d = get_kspace_shots_and_mask(
2477
+ header3d, "3d_phyllotaxis", "prefix", AF=2.0,
2478
+ traj_params={"n_shots": 100, "samples_per_shot": 32},
2479
+ us_params={}, backend=backend,
2480
+ )
2481
+ assert backend.shape(shots3d[0])[1] == 3, "Expected 3D coordinates"
2482
+ print(f" 3D phyllotaxis: {len(shots3d)} shots, "
2483
+ f"shape per shot: {backend.shape(shots3d[0])}, OK")
2484
+
2485
+ print("All self-tests PASSED.")
2486
+ print("=" * 60)
2487
+
2488
+
2489
+ # ---------------------------------------------------------------------------
2490
+ # Usage example (plain text at module end)
2491
+ # ---------------------------------------------------------------------------
2492
+ #
2493
+ # USAGE EXAMPLE
2494
+ # =============
2495
+ #
2496
+ # Expected header dict keys (NIfTI-MRS standard + common fallbacks):
2497
+ # ------------------------------------------------------------------
2498
+ # Required (standard):
2499
+ # "dim" : list[int], dim[1..3] = [nx, ny, nz]
2500
+ # "pixdim" : list[float], pixdim[1..3] = [vx, vy, vz] in mm
2501
+ # Optional standard fields:
2502
+ # "SpectrometerFrequency": [float] — transmitter frequency in Hz
2503
+ # "DwellTime" : float — ADC dwell time in seconds
2504
+ # "qform_code" : int — qform validity flag
2505
+ # "sform_code" : int — sform validity flag
2506
+ # Fallback (non-standard, accepted if standard absent):
2507
+ # "matrix" : [nx, ny, nz]
2508
+ # "voxel_size_mm": [vx, vy, vz]
2509
+ # "fov_mm" : [fx, fy, fz]
2510
+ # "dwell_time" : float
2511
+ #
2512
+ # Calling get_kspace_shots_and_mask:
2513
+ # -----------------------------------
2514
+ # from kspace_sampling import get_kspace_shots_and_mask, NumpyBackend, TorchBackend
2515
+ #
2516
+ # header = {
2517
+ # "dim": [4, 128, 128, 1, 1, 1, 1, 1],
2518
+ # "pixdim": [1.0, 2.5, 2.5, 2.5, 1.0, 1.0, 1.0, 1.0],
2519
+ # "DwellTime": 4e-6,
2520
+ # "SpectrometerFrequency": [297.2e6],
2521
+ # }
2522
+ #
2523
+ # # NumPy backend
2524
+ # backend = NumpyBackend()
2525
+ #
2526
+ # coords_per_shot, shot_mask, meta = get_kspace_shots_and_mask(
2527
+ # header = header,
2528
+ # trajectory = "golden_radial_2d",
2529
+ # undersampling = "prefix",
2530
+ # AF = 4.0,
2531
+ # traj_params = {"n_shots": 400, "samples_per_shot": 128},
2532
+ # us_params = {},
2533
+ # backend = backend,
2534
+ # )
2535
+ #
2536
+ # # Outputs:
2537
+ # # coords_per_shot : list of 400 numpy arrays, each shape (128, 2)
2538
+ # # coordinates in cycles/m, kx and ky columns
2539
+ # # shot_mask : numpy bool array shape (400,), 100 True entries (AF=4)
2540
+ # # meta : dict with keys:
2541
+ # # trajectory_type="golden_radial_2d", kmax=(kmax_x, kmax_y),
2542
+ # # fov_mm=(320.0, 320.0), matrix=(128,128),
2543
+ # # n_shots=400, n_shots_retained=100, achieved_AF≈4.0,
2544
+ # # density_estimate={...placeholder...}, params_used={...}
2545
+ #
2546
+ # # Select retained shots:
2547
+ # retained = [s for s, m in zip(coords_per_shot, shot_mask) if m]
2548
+ #
2549
+ # # Switch to PyTorch (CUDA):
2550
+ # backend_gpu = TorchBackend(device="cuda")
2551
+ # coords_pt, mask_pt, meta_pt = get_kspace_shots_and_mask(
2552
+ # header, "golden_radial_2d", "prefix", AF=4.0,
2553
+ # traj_params={"n_shots": 400, "samples_per_shot": 128},
2554
+ # us_params={}, backend=backend_gpu,
2555
+ # )
2556
+ # # coords_pt[0] is a torch.Tensor on cuda, shape (128, 2)
2557
+ # # mask_pt is a torch.BoolTensor on cuda, shape (400,)
2558
+ #
2559
+ # ---------------------------------------------------------------------------
2560
+
2561
+ if __name__ == "__main__":
2562
+ _run_selftest()