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/__init__.py +19 -0
- epgx/axes.py +285 -0
- epgx/common.py +63 -0
- epgx/compiler.py +347 -0
- epgx/diff.py +242 -0
- epgx/examples.py +146 -0
- epgx/functions.py +602 -0
- epgx/kernels/__init__.py +9 -0
- epgx/kernels/affine.py +187 -0
- epgx/kernels/evolution.py +40 -0
- epgx/kernels/operator.py +32 -0
- epgx/kernels/probe.py +107 -0
- epgx/kernels/shift.py +106 -0
- epgx/kernels/state.py +447 -0
- epgx/kernels/transition.py +48 -0
- epgx/library/__init__.py +3 -0
- epgx/library/sequences.py +210 -0
- epgx/lowering.py +95 -0
- epgx/merging.py +137 -0
- epgx/methods.py +329 -0
- epgx/ops/__init__.py +30 -0
- epgx/ops/dephasing.py +71 -0
- epgx/ops/evolution.py +135 -0
- epgx/ops/functions.py +158 -0
- epgx/ops/operator.py +224 -0
- epgx/ops/probe.py +116 -0
- epgx/ops/shift.py +118 -0
- epgx/ops/transition.py +58 -0
- epgx/plotting.py +226 -0
- epgx/progress.py +81 -0
- epgx/sample.py +142 -0
- epgx/sequences.py +850 -0
- epgx/stats.py +117 -0
- epgx-0.1.dev0.dist-info/METADATA +64 -0
- epgx-0.1.dev0.dist-info/RECORD +38 -0
- epgx-0.1.dev0.dist-info/WHEEL +5 -0
- epgx-0.1.dev0.dist-info/licenses/LICENSE +28 -0
- epgx-0.1.dev0.dist-info/top_level.txt +1 -0
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)
|
epgx/library/__init__.py
ADDED