epgx 0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
epgx/kernels/state.py ADDED
@@ -0,0 +1,447 @@
1
+ """Phase-state container
2
+
3
+ The state holds the transverse (F) and longitudinal (Z) phase states
4
+ in two arrays of shape `(*shape, *core)`:
5
+ - `shape`: batch axes (broadcast with operator parameters)
6
+ - `core`: structural axes of the phase states, named by `State.axes`
7
+
8
+ Representations (`State.representation`, static), and their core axes:
9
+ "lattice": phase states (EPG), lattice axes "k" (1d), or "kx", "ky", "kz"
10
+ (dense nd-lattice, one axis per shift component):
11
+ k = -nstate..nstate (size 2 * nstate + 1). F holds the F+ states over
12
+ the whole lattice; F- states are given by symmetry:
13
+ F-(k) = conj(F+(-k)), Z(-k) = conj(Z(k))
14
+ "isochromats": samples of the magnetization, sample axis "iso" with
15
+ coordinates (positions, `State.coords`): F = M+, Z = Mz;
16
+ F- = conj(F+) at the same sample; the voxel signal is the mean
17
+
18
+ The representation defines the kind of each core axis (lattice or sample),
19
+ which the helpers rely on; operators select their kernels from it.
20
+
21
+ The equilibrium is not stored as a full state array: it is
22
+ Z = density at k=0 on lattice axes, and at all samples on sample axes, with
23
+ `density` of shape `shape` (all leaves share the batch axes, so that `vmap`,
24
+ `tree.map` etc. apply uniformly).
25
+
26
+ Kernels must not index the core axes directly, but use the helpers
27
+ (`expand_param`, `mirror`, `add_equilibrium`, `F0`, `lattice_axes`...), so that
28
+ new representations (nd-lattice, moments, sparse slots...) only require
29
+ extending this module. Operations that are representation-specific by nature
30
+ (e.g. shift) dispatch on `State.representation`.
31
+ """
32
+
33
+ import dataclasses
34
+ import functools
35
+ import numbers
36
+
37
+ import jax
38
+ import jax.numpy as jnp
39
+
40
+ NAX = jnp.newaxis
41
+
42
+ # representations: allowed core axes and their kind
43
+ # lattice axes: phase-state indices (mirrored by conjugation)
44
+ # sample axes: samples of the magnetization (averaged for the signal)
45
+ # default lattice and sample axes of nd shifts (x, y, z components of k)
46
+ LATTICE_AXES = ("kx", "ky", "kz")
47
+ SAMPLE_AXES = ("iso_x", "iso_y", "iso_z")
48
+
49
+ # core axes of each representation: fixed names, and prefixes of the axes of
50
+ # named dephasing directions (e.g. "k_diff", "iso_diff"), with their kind
51
+ REPRESENTATIONS = {
52
+ "lattice": {"k": "lattice", "kx": "lattice", "ky": "lattice", "kz": "lattice"},
53
+ "isochromats": {
54
+ "iso": "sample",
55
+ "iso_x": "sample",
56
+ "iso_y": "sample",
57
+ "iso_z": "sample",
58
+ },
59
+ }
60
+ PREFIXES = {"lattice": ("k_", "lattice"), "isochromats": ("iso_", "sample")}
61
+
62
+
63
+ def core_axis_name(direction, representation, nd=True):
64
+ """name of the core axis storing a dephasing direction
65
+
66
+ Args:
67
+ direction: "x", "y", "z" or the name of a dephasing axis
68
+ representation: "lattice" or "isochromats"
69
+ nd: False for a single default axis ("k", "iso")
70
+ """
71
+ prefix = PREFIXES[representation][0]
72
+ if not nd:
73
+ return prefix[:-1] # "k", "iso"
74
+ elif representation == "lattice" and direction in ("x", "y", "z"):
75
+ return "k" + direction # "kx", "ky", "kz"
76
+ return prefix + direction
77
+
78
+
79
+ @functools.partial(
80
+ jax.tree_util.register_dataclass,
81
+ data_fields=["F", "Z", "density"],
82
+ meta_fields=["axes", "coords", "representation"],
83
+ )
84
+ @dataclasses.dataclass(frozen=True)
85
+ class State:
86
+ """Phase states (pytree)
87
+
88
+ Attributes:
89
+ F: transverse states F+ (*shape, *core)
90
+ Z: longitudinal states (*shape, *core)
91
+ density: proton density, i.e. equilibrium Z(k=0) or Z of each sample (*shape)
92
+ axes: (static) names of the core axes
93
+ coords: (static) coordinates of the sample axes, as a tuple of
94
+ (axis name, tuple of values) pairs, e.g. isochromat positions;
95
+ None for lattice axes (coordinates given by the indices)
96
+ representation: (static) representation of the states ("lattice",
97
+ "isochromats"), defining the kind of the core axes
98
+
99
+ Use the constructors (`at_equilibrium`, `from_states`), which broadcast `density`.
100
+ """
101
+
102
+ F: jax.Array
103
+ Z: jax.Array
104
+ density: jax.Array
105
+ axes: tuple = ("k",)
106
+ coords: tuple = None
107
+ representation: str = "lattice"
108
+
109
+ # constructors
110
+
111
+ @classmethod
112
+ def at_equilibrium(cls, shape=(), nstate=0, *, axes=None, density=1, dtype=None):
113
+ """Init state at equilibrium (Z0 = density)
114
+
115
+ Args:
116
+ shape: batch shape (broadcast with density's shape)
117
+ nstate: number of phase states (capacity: 2 * nstate + 1), int or
118
+ tuple of int (one per lattice axis)
119
+ axes: names of the lattice axes (default: "k" for an int nstate,
120
+ "kx", "ky", "kz" for a tuple)
121
+ density: proton density (scalar or array)
122
+ dtype: complex dtype (default: JAX's default complex type)
123
+ """
124
+ dtype = dtype or complex
125
+ scalar = isinstance(nstate, numbers.Integral)
126
+ nstates = (int(nstate),) if scalar else tuple(int(n) for n in nstate)
127
+ if axes is None:
128
+ axes = ("k",) if scalar else LATTICE_AXES[: len(nstates)]
129
+ if len(axes) != len(nstates):
130
+ raise ValueError(f"Expected one nstate per axis {axes}, got {nstate}")
131
+ density = jnp.asarray(density)
132
+ shape = jnp.broadcast_shapes(tuple(shape), density.shape)
133
+ density = jnp.broadcast_to(density, shape)
134
+ F = jnp.zeros(shape + tuple(2 * n + 1 for n in nstates), dtype=dtype)
135
+ return cls(F, F, density, axes=tuple(axes)).equilibrium
136
+
137
+ @classmethod
138
+ def from_states(cls, states, *, density=1):
139
+ """Init state from a [F+, F-, Z] states array (..., 2 * nstate + 1, 3)
140
+
141
+ The F- column (states[..., 1]) is dropped: it is assumed to match F+.
142
+
143
+ Args:
144
+ states: [F+, F-, Z] states array
145
+ density: proton density (broadcastable to the batch shape)
146
+ """
147
+ states = jnp.asarray(states)
148
+ if states.ndim < 2 or states.shape[-1] != 3:
149
+ raise ValueError(
150
+ f"Expected states of shape (..., nk, 3), got {states.shape}"
151
+ )
152
+ elif states.shape[-2] % 2 != 1:
153
+ raise ValueError(f"The number of states must be odd: {states.shape[-2]}")
154
+ if not jnp.iscomplexobj(states):
155
+ states = states.astype(complex)
156
+ density = jnp.asarray(density)
157
+ shape = states.shape[:-2]
158
+ if jnp.broadcast_shapes(shape, density.shape) != shape:
159
+ raise ValueError(
160
+ f"Density shape {density.shape} does not broadcast to {shape}"
161
+ )
162
+ density = jnp.broadcast_to(density, shape)
163
+ return cls(states[..., 0], states[..., 2], density)
164
+
165
+ # properties
166
+
167
+ @property
168
+ def naxes(self):
169
+ """number of core axes"""
170
+ return len(self.axes)
171
+
172
+ @property
173
+ def shape(self):
174
+ """batch shape"""
175
+ return self.F.shape[: self.F.ndim - self.naxes]
176
+
177
+ @property
178
+ def ndim(self):
179
+ """number of batch dimensions"""
180
+ return len(self.shape)
181
+
182
+ @property
183
+ def lattice_axes(self):
184
+ """positions of the lattice axes in F and Z (raise if unknown axis)"""
185
+ return tuple(
186
+ self.ndim + i
187
+ for i, ax in enumerate(self.axes)
188
+ if self.axis_kind(ax) == "lattice"
189
+ )
190
+
191
+ @property
192
+ def sample_axes(self):
193
+ """positions of the sample axes in F and Z (raise if unknown axis)"""
194
+ return tuple(
195
+ self.ndim + i
196
+ for i, ax in enumerate(self.axes)
197
+ if self.axis_kind(ax) == "sample"
198
+ )
199
+
200
+ def axis_kind(self, axis):
201
+ """kind of a core axis in the representation ("lattice" or "sample")"""
202
+ return axis_kind(axis, self.representation)
203
+
204
+ def core_axis(self, direction):
205
+ """position (in F and Z) of the core axis storing a dephasing direction
206
+
207
+ Args:
208
+ direction: "x", "y", "z" or the name of a dephasing axis ("x" is
209
+ also the single default axis "k" or "iso")
210
+ """
211
+ names = [core_axis_name(direction, self.representation)]
212
+ if direction == "x":
213
+ names.append(core_axis_name(direction, self.representation, nd=False))
214
+ for name in names:
215
+ if name in self.axes:
216
+ return self.ndim + self.axes.index(name)
217
+ raise ValueError(
218
+ f"No core axis for the dephasing direction {direction!r} "
219
+ f"(axes: {self.axes})"
220
+ )
221
+
222
+ def coordinates(self, axis):
223
+ """coordinates of a sample axis (array of the core axis size)"""
224
+ coords = dict(self.coords or ())
225
+ if axis not in coords:
226
+ raise ValueError(f"No coordinates for axis {axis!r} (axes: {self.axes})")
227
+ return jnp.asarray(coords[axis])
228
+
229
+ @property
230
+ def nstates(self):
231
+ """number of phase states along each lattice axis"""
232
+ return tuple((self.F.shape[axis] - 1) // 2 for axis in self.lattice_axes)
233
+
234
+ @property
235
+ def nstate(self):
236
+ """number of phase states (single lattice axis only)"""
237
+ nstates = self.nstates
238
+ if len(nstates) != 1:
239
+ raise ValueError(
240
+ f"`nstate` requires a single lattice axis, got {self.axes}: use `nstates`"
241
+ )
242
+ return nstates[0]
243
+
244
+ @property
245
+ def dtype(self):
246
+ return self.F.dtype
247
+
248
+ @property
249
+ def i0(self):
250
+ """index of the k=0 state along the lattice axes"""
251
+ return tuple((self.F.shape[axis] - 1) // 2 for axis in self.lattice_axes)
252
+
253
+ @property
254
+ def F0(self):
255
+ """voxel signal: F+(k=0) on lattice axes, mean over sample axes"""
256
+ return self._voxel(self.F)
257
+
258
+ @property
259
+ def Z0(self):
260
+ """longitudinal magnetization: Z(k=0) on lattice axes, mean over sample axes"""
261
+ return self._voxel(self.Z)
262
+
263
+ @property
264
+ def equilibrium(self):
265
+ """equilibrium state with same layout and shape (F = 0, Z = density)"""
266
+ zeros = jnp.zeros_like(self.F)
267
+ return self.replace(F=zeros, Z=self.add_equilibrium(zeros))
268
+
269
+ @property
270
+ def Fminus(self):
271
+ """transverse states F-"""
272
+ return self.mirror(self.F)
273
+
274
+ @property
275
+ def states(self):
276
+ """[F+, F-, Z] states array (..., 2 * nstate + 1, 3)"""
277
+ if self.axes != ("k",):
278
+ raise NotImplementedError(
279
+ f"No [F+, F-, Z] states layout for axes: {self.axes}"
280
+ )
281
+ return jnp.stack([self.F, self.Fminus, self.Z], axis=-1)
282
+
283
+ # helpers
284
+
285
+ def expand_param(self, value, core=False):
286
+ """expand parameter to broadcast with the state arrays
287
+
288
+ Args:
289
+ value: parameter with batch axes (core=False), or with batch and core
290
+ axes (core=True, returned as is)
291
+ """
292
+ value = jnp.asarray(value)
293
+ if core:
294
+ return value
295
+ return value[(...,) + (NAX,) * self.naxes]
296
+
297
+ def prepare(self, *params, expand=True, core=False):
298
+ """prepare state and kernel parameters
299
+
300
+ Parameters are cast to the state's precision (real parameters to the
301
+ real dtype, complex parameters to the complex dtype), the state is
302
+ broadcast to the common batch shape (right-aligned), and parameters
303
+ are expanded to broadcast with the state arrays. None is passed through.
304
+
305
+ Args:
306
+ *params: kernel parameters (broadcastable to the batch shape) or None
307
+ expand: if False, parameters keep their batch shape
308
+ core: if True, parameters have the core axes (`(*shape, *core)`)
309
+
310
+ Returns:
311
+ state, list of parameters
312
+ """
313
+ rdtype = jnp.finfo(self.dtype).dtype
314
+ params = [
315
+ None
316
+ if param is None
317
+ else jnp.asarray(
318
+ param, dtype=self.dtype if jnp.iscomplexobj(param) else rdtype
319
+ )
320
+ for param in params
321
+ ]
322
+ ncore = self.naxes if core else 0
323
+ shapes = [p.shape[: p.ndim - ncore] for p in params if p is not None]
324
+ state = self.broadcast(*shapes)
325
+ if expand:
326
+ params = [
327
+ None if param is None else state.expand_param(param, core=core)
328
+ for param in params
329
+ ]
330
+ return state, params
331
+
332
+ def broadcast(self, *shapes):
333
+ """broadcast state (F, Z and density) to a common batch shape"""
334
+ shape = jnp.broadcast_shapes(self.shape, *shapes)
335
+ if shape == self.shape:
336
+ return self
337
+ core = self.F.shape[self.ndim :]
338
+ return self.replace(
339
+ F=jnp.broadcast_to(self.F, shape + core),
340
+ Z=jnp.broadcast_to(self.Z, shape + core),
341
+ density=jnp.broadcast_to(self.density, shape),
342
+ )
343
+
344
+ def mirror(self, arr):
345
+ """return conj(arr(-k)) for an array of shape (*shape, *core)"""
346
+ return mirror(arr, self.axes, self.representation)
347
+
348
+ def add_equilibrium(self, arr, scale=1.0):
349
+ """add the equilibrium magnetization (times scale) to a Z-like array
350
+
351
+ Args:
352
+ arr: array of shape (*shape, *core)
353
+ scale: factor broadcastable to the batch shape
354
+ """
355
+ value = jnp.asarray(scale) * self.density
356
+ value = value[(...,) + (NAX,) * len(self.sample_axes)]
357
+ return arr.at[(..., *self._equilibrium_index)].add(value)
358
+
359
+ def replace(self, **changes):
360
+ """return new state with given fields replaced"""
361
+ return dataclasses.replace(self, **changes)
362
+
363
+ def resize(self, nstate):
364
+ """pad (with zeros) or crop phase states to nstate
365
+
366
+ Args:
367
+ nstate: int (all lattice axes) or tuple of int (one per lattice axis)
368
+ """
369
+ nstates = self.nstates
370
+ if isinstance(nstate, int):
371
+ nstate = (nstate,) * len(nstates)
372
+ elif len(nstate) != len(nstates):
373
+ raise ValueError(f"Expected {len(nstates)} values for nstate: {nstate}")
374
+ F, Z = self.F, self.Z
375
+ for axis, new, old in zip(self.lattice_axes, nstate, nstates):
376
+ if new != old:
377
+ F = _resize(F, new - old, axis)
378
+ Z = _resize(Z, new - old, axis)
379
+ if F is self.F:
380
+ return self
381
+ return self.replace(F=F, Z=Z)
382
+
383
+ # private
384
+
385
+ @property
386
+ def _equilibrium_index(self):
387
+ """core index of the equilibrium: k=0 on lattice axes, all samples"""
388
+ index = []
389
+ for i, ax in enumerate(self.axes):
390
+ if self.axis_kind(ax) == "lattice":
391
+ index.append((self.F.shape[self.ndim + i] - 1) // 2)
392
+ else:
393
+ index.append(slice(None))
394
+ return tuple(index)
395
+
396
+ def _voxel(self, arr):
397
+ """value at k=0 on lattice axes, mean over sample axes"""
398
+ value = arr[(..., *self._equilibrium_index)]
399
+ nsample = len(self.sample_axes)
400
+ if nsample == 0:
401
+ return value
402
+ return value.mean(axis=tuple(range(-nsample, 0)))
403
+
404
+
405
+ #
406
+ # functions
407
+
408
+
409
+ def mirror(arr, axes, representation="lattice"):
410
+ """return conj(arr(-k)) (reverse lattice axes and conjugate)
411
+
412
+ Sample axes are not reversed: F- = conj(F+) at the same sample.
413
+ """
414
+ offset = arr.ndim - len(axes)
415
+ flip = tuple(
416
+ offset + i
417
+ for i, ax in enumerate(axes)
418
+ if axis_kind(ax, representation) == "lattice"
419
+ )
420
+ return jnp.flip(arr, axis=flip).conj()
421
+
422
+
423
+ def axis_kind(axis, representation="lattice"):
424
+ """kind of a core axis in a representation: "lattice" or "sample" (raise if invalid)"""
425
+ if representation not in REPRESENTATIONS:
426
+ raise ValueError(f"Unknown representation: {representation}")
427
+ kinds = REPRESENTATIONS[representation]
428
+ prefix, kind = PREFIXES[representation]
429
+ if axis in kinds:
430
+ return kinds[axis]
431
+ elif axis.startswith(prefix) and axis[len(prefix) :].isidentifier():
432
+ return kind # axis of a named dephasing direction
433
+ raise ValueError(
434
+ f"Invalid core axis {axis!r} for representation {representation!r} "
435
+ f"(allowed: {tuple(kinds)}, or {prefix}<name>)"
436
+ )
437
+
438
+
439
+ def _resize(arr, diff, axis):
440
+ """symmetric pad (diff > 0) or crop (diff < 0) along axis"""
441
+ if diff > 0:
442
+ pad = [(0, 0)] * arr.ndim
443
+ pad[axis] = (diff, diff)
444
+ return jnp.pad(arr, pad)
445
+ index = [slice(None)] * arr.ndim
446
+ index[axis] = slice(-diff, arr.shape[axis] + diff)
447
+ return arr[tuple(index)]
@@ -0,0 +1,48 @@
1
+ """Transition kernels (instantaneous RF pulses)
2
+
3
+ Rotation T(alpha, phi) = Rz(phi) Rx(alpha) Rz(-phi), applied to each
4
+ [F+, F-, Z] phase state. Only the F+ and Z rows are
5
+ computed: F- follows from the symmetry F-(k) = conj(F+(-k)).
6
+
7
+ Angles are in radians.
8
+ """
9
+
10
+ import jax.numpy as jnp
11
+
12
+
13
+ def rotate(state, alpha, phi):
14
+ """RF rotation (instantaneous pulse)
15
+
16
+ Args:
17
+ state: State
18
+ alpha: flip angle (rad), broadcast with the batch shape
19
+ phi: phase (rad), broadcast with the batch shape
20
+
21
+ Returns:
22
+ new State
23
+ """
24
+ state, (alpha, phi) = state.prepare(alpha, phi)
25
+ F, Fm, Z = state.F, state.mirror(state.F), state.Z
26
+
27
+ cos2 = jnp.cos(alpha / 2) ** 2
28
+ sin2 = jnp.sin(alpha / 2) ** 2
29
+ cos, sin = jnp.cos(alpha), jnp.sin(alpha)
30
+ eiphi = jnp.exp(1j * phi)
31
+
32
+ F_ = cos2 * F + sin2 * eiphi**2 * Fm - 1j * sin * eiphi * Z
33
+ Z_ = 0.5j * sin * (eiphi * Fm - eiphi.conj() * F) + cos * Z
34
+ return state.replace(F=F_, Z=Z_)
35
+
36
+
37
+ def phase(state, phi):
38
+ """phase offset (z-axis rotation): F+ -> exp(i phi) F+
39
+
40
+ Args:
41
+ state: State
42
+ phi: phase (rad), broadcast with the batch shape
43
+
44
+ Returns:
45
+ new State
46
+ """
47
+ state, (phi,) = state.prepare(phi)
48
+ return state.replace(F=jnp.exp(1j * phi) * state.F)
@@ -0,0 +1,3 @@
1
+ """Reference resources: commonly used sequences (later: pulses, other resources)"""
2
+
3
+ from . import sequences