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/methods.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Simulation methods: configuration of the states
|
|
2
|
+
|
|
3
|
+
A method configures the representation of the magnetization states (dense
|
|
4
|
+
lattice of phase states, isochromats, later sparse slots) and provides
|
|
5
|
+
defaults. It is a static configuration (hashable), which:
|
|
6
|
+
- checks that every operator of the sequence can be represented,
|
|
7
|
+
- builds the initial state (representation, capacity, coordinates),
|
|
8
|
+
- provides defaults and warnings (e.g. capacity, aliasing).
|
|
9
|
+
|
|
10
|
+
Operators select their kernels from the state (`State.representation`), so
|
|
11
|
+
they also apply directly to states.
|
|
12
|
+
|
|
13
|
+
A simulation uses a single method. By default, it is inferred from the
|
|
14
|
+
operators: the simplest representation covering all of them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import dataclasses
|
|
18
|
+
import math
|
|
19
|
+
import warnings
|
|
20
|
+
|
|
21
|
+
import jax.numpy as jnp
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from . import kernels
|
|
25
|
+
from .ops import S, flatten_sequence, get_dephasing_axes, getnshifts
|
|
26
|
+
|
|
27
|
+
# names of the dephasing axes of the shift components (keys of `Lattice.nmax`)
|
|
28
|
+
AXIS_NAMES = ("x", "y", "z")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclasses.dataclass(frozen=True)
|
|
32
|
+
class Method:
|
|
33
|
+
"""Base class of simulation methods"""
|
|
34
|
+
|
|
35
|
+
# representation of the states (cf. kernels.State.representation)
|
|
36
|
+
representation = None
|
|
37
|
+
# names of the core axes of the states (e.g. for labelled outputs)
|
|
38
|
+
core_dims = ()
|
|
39
|
+
|
|
40
|
+
def core_axes(self, sequence):
|
|
41
|
+
"""names of the core axes of the states for a sequence"""
|
|
42
|
+
return self.core_dims
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def coords(self):
|
|
46
|
+
"""coordinates of the core axes (e.g. for labelled outputs)"""
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
def core_coords(self, sequence):
|
|
50
|
+
"""coordinates of the core axes for a sequence (dict: axis -> values)"""
|
|
51
|
+
return self.coords
|
|
52
|
+
|
|
53
|
+
def check(self, sequence):
|
|
54
|
+
"""raise if an operator of the sequence cannot be represented"""
|
|
55
|
+
|
|
56
|
+
def init_state(self, sequence, shape, density=1.0, dtype=None):
|
|
57
|
+
"""initial state (at equilibrium)"""
|
|
58
|
+
raise NotImplementedError()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclasses.dataclass(frozen=True)
|
|
62
|
+
class Lattice(Method):
|
|
63
|
+
"""Dense lattice of phase states (integer shifts)
|
|
64
|
+
|
|
65
|
+
One lattice axis per dephasing direction: "k" if the sequence has only
|
|
66
|
+
scalar shifts along x, else "kx", "ky", "kz" (as many as the longest
|
|
67
|
+
vector shift) and "k_<name>" for named dephasing axes.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
nmax: maximum number of phase states along each axis; states shifted
|
|
71
|
+
beyond are dropped. An int (all axes), a tuple (x, y, z), a dict
|
|
72
|
+
by direction ("x", "y", "z", names of dephasing axes), or None: no
|
|
73
|
+
truncation (capacity: total number of shifts along each axis)
|
|
74
|
+
TODO: default value from the sequence and sample (e.g. relaxation)
|
|
75
|
+
warn: if nmax is None, warn when the capacity exceeds this value
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
nmax: object = None
|
|
79
|
+
warn: int = 256
|
|
80
|
+
|
|
81
|
+
representation = "lattice"
|
|
82
|
+
core_dims = ("k",)
|
|
83
|
+
|
|
84
|
+
def __post_init__(self):
|
|
85
|
+
# hashable (static argument of the compiled program)
|
|
86
|
+
if isinstance(self.nmax, dict):
|
|
87
|
+
object.__setattr__(self, "nmax", tuple(sorted(self.nmax.items())))
|
|
88
|
+
elif isinstance(self.nmax, list):
|
|
89
|
+
object.__setattr__(self, "nmax", tuple(self.nmax))
|
|
90
|
+
|
|
91
|
+
def core_axes(self, sequence):
|
|
92
|
+
return _core_axes(sequence, self.representation)
|
|
93
|
+
|
|
94
|
+
def check(self, sequence):
|
|
95
|
+
for op in _shifts(sequence):
|
|
96
|
+
if not op.integer:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Non-integer shift on the lattice: {op}, "
|
|
99
|
+
"use another method, e.g. `method=Isochromats(n=...)`"
|
|
100
|
+
)
|
|
101
|
+
get_dephasing_axes(sequence) # consistent axes
|
|
102
|
+
self._limits(_directions(sequence)) # nmax consistent with the axes
|
|
103
|
+
|
|
104
|
+
def nstates(self, sequence):
|
|
105
|
+
"""state capacity along each axis: min(number of shifts, nmax)"""
|
|
106
|
+
nshifts = getnshifts(sequence) or {"x": 0}
|
|
107
|
+
limits = self._limits(tuple(nshifts))
|
|
108
|
+
nstates = []
|
|
109
|
+
for nshift, limit in zip(nshifts.values(), limits):
|
|
110
|
+
if limit is not None:
|
|
111
|
+
nstates.append(min(nshift, limit))
|
|
112
|
+
continue
|
|
113
|
+
elif nshift > self.warn:
|
|
114
|
+
warnings.warn(
|
|
115
|
+
f"Large phase-state capacity ({nshift} states, without "
|
|
116
|
+
"truncation): set a maximum number of states, e.g. "
|
|
117
|
+
"`method=Lattice(nmax=...)`",
|
|
118
|
+
stacklevel=4,
|
|
119
|
+
)
|
|
120
|
+
nstates.append(nshift)
|
|
121
|
+
return tuple(nstates)
|
|
122
|
+
|
|
123
|
+
def nstate(self, sequence):
|
|
124
|
+
"""state capacity of a single-axis lattice: min(number of shifts, nmax)"""
|
|
125
|
+
nstates = self.nstates(sequence)
|
|
126
|
+
if len(nstates) != 1:
|
|
127
|
+
raise ValueError("`nstate` requires a single lattice axis: use `nstates`")
|
|
128
|
+
return nstates[0]
|
|
129
|
+
|
|
130
|
+
def init_state(self, sequence, shape, density=1.0, dtype=None):
|
|
131
|
+
axes = self.core_axes(sequence)
|
|
132
|
+
nstates = self.nstates(sequence)
|
|
133
|
+
return kernels.State.at_equilibrium(
|
|
134
|
+
shape, nstates, axes=axes, density=density, dtype=dtype
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _limits(self, directions):
|
|
138
|
+
"""nmax along each direction (None: no truncation)"""
|
|
139
|
+
return _per_direction(self.nmax, directions, "nmax", "lattice axes")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclasses.dataclass(frozen=True)
|
|
143
|
+
class Isochromats(Method):
|
|
144
|
+
"""Isochromats along the dephasing axes (reference simulator)
|
|
145
|
+
|
|
146
|
+
Each isochromat is the magnetization at a position x within the voxel
|
|
147
|
+
(unit: the period of a unit shift along each axis). A shift S(k) is a
|
|
148
|
+
precession depending on the position: M+ -> exp(2i pi k.x) M+, exact for
|
|
149
|
+
any k (integer or not). The voxel signal (F0) is the mean over isochromats.
|
|
150
|
+
The positions are stored in the state (static coordinates of the sample
|
|
151
|
+
axes), so that operators select their kernel from the state.
|
|
152
|
+
|
|
153
|
+
Sample axes: "iso" if the sequence has only scalar shifts along x, else
|
|
154
|
+
"iso_x", "iso_y", "iso_z" (a regular grid, as many axes as the longest
|
|
155
|
+
vector shift) and "iso_<name>" for named dephasing axes.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
n: number of isochromats on a regular grid over one period along each
|
|
159
|
+
axis, x = (j + 1/2) / n - 1/2 (j = 0..n-1): an int (all axes), a
|
|
160
|
+
tuple (x, y, z) or a dict by direction ("x", "y", "z", names of
|
|
161
|
+
dephasing axes)
|
|
162
|
+
positions: explicit positions along a single axis (e.g. random
|
|
163
|
+
samples), instead of n
|
|
164
|
+
warn: warn if a regular grid is too coarse for the sequence (aliasing)
|
|
165
|
+
|
|
166
|
+
On a regular grid, n > nshift along each axis (the total number of shifts)
|
|
167
|
+
gives the same signal as the lattice method without truncation
|
|
168
|
+
(n >= 2 nshift + 1 for all the phase states).
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
n: object = None
|
|
172
|
+
positions: tuple = None
|
|
173
|
+
warn: bool = True
|
|
174
|
+
|
|
175
|
+
representation = "isochromats"
|
|
176
|
+
core_dims = ("iso",)
|
|
177
|
+
|
|
178
|
+
def __post_init__(self):
|
|
179
|
+
n = self.n
|
|
180
|
+
if self.positions is not None:
|
|
181
|
+
positions = np.ravel(np.asarray(self.positions, dtype=float))
|
|
182
|
+
if n is not None and n != positions.size:
|
|
183
|
+
raise ValueError(f"Inconsistent n={n} and {positions.size} positions")
|
|
184
|
+
object.__setattr__(self, "positions", tuple(positions.tolist()))
|
|
185
|
+
object.__setattr__(self, "n", positions.size)
|
|
186
|
+
return
|
|
187
|
+
if isinstance(n, dict):
|
|
188
|
+
n = tuple(sorted(n.items())) # hashable
|
|
189
|
+
elif isinstance(n, list):
|
|
190
|
+
n = tuple(n)
|
|
191
|
+
counts = [v for _, v in n] if _is_items(n) else n
|
|
192
|
+
counts = [counts] if isinstance(counts, int) else counts
|
|
193
|
+
if (
|
|
194
|
+
n is None
|
|
195
|
+
or not counts
|
|
196
|
+
or any(not isinstance(v, int) or v < 1 for v in counts)
|
|
197
|
+
):
|
|
198
|
+
raise ValueError("Isochromats require `n` > 0 (per axis) or `positions`")
|
|
199
|
+
object.__setattr__(self, "n", n)
|
|
200
|
+
if isinstance(n, int): # positions of a single axis (1d)
|
|
201
|
+
object.__setattr__(self, "positions", tuple(_grid(n).tolist()))
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def regular(self):
|
|
205
|
+
"""True if the positions are regular grids"""
|
|
206
|
+
if self.positions is None:
|
|
207
|
+
return True
|
|
208
|
+
return np.allclose(self.positions, _grid(self.n))
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def coords(self):
|
|
212
|
+
"""coordinates of a single sample axis (1d)"""
|
|
213
|
+
if self.positions is None:
|
|
214
|
+
return {}
|
|
215
|
+
return {"iso": np.asarray(self.positions)}
|
|
216
|
+
|
|
217
|
+
def core_axes(self, sequence):
|
|
218
|
+
return _core_axes(sequence, self.representation)
|
|
219
|
+
|
|
220
|
+
def core_coords(self, sequence):
|
|
221
|
+
axes = self.core_axes(sequence)
|
|
222
|
+
if axes == ("iso",) and self.positions is not None:
|
|
223
|
+
return {"iso": np.asarray(self.positions)}
|
|
224
|
+
counts = self._counts(_directions(sequence))
|
|
225
|
+
return {axis: _grid(n) for axis, n in zip(axes, counts)}
|
|
226
|
+
|
|
227
|
+
def check(self, sequence):
|
|
228
|
+
axes = self.core_axes(sequence)
|
|
229
|
+
if not self.regular and axes != ("iso",):
|
|
230
|
+
raise NotImplementedError(
|
|
231
|
+
f"Explicit positions with nd shifts (axes {axes}): use `n`"
|
|
232
|
+
)
|
|
233
|
+
get_dephasing_axes(sequence) # consistent axes
|
|
234
|
+
self._counts(_directions(sequence)) # n consistent with the axes
|
|
235
|
+
|
|
236
|
+
def init_state(self, sequence, shape, density=1.0, dtype=None):
|
|
237
|
+
coords = self.core_coords(sequence)
|
|
238
|
+
nshifts = (getnshifts(sequence) or {"x": 0}).values()
|
|
239
|
+
if self.warn and self.regular:
|
|
240
|
+
for (axis, x), nshift in zip(coords.items(), nshifts):
|
|
241
|
+
if len(x) <= nshift:
|
|
242
|
+
warnings.warn(
|
|
243
|
+
f"Aliasing: {len(x)} isochromats for {nshift:g} shifts along "
|
|
244
|
+
f"{axis!r}, use n > {nshift:g} (e.g. n={2 * math.ceil(nshift) + 1})",
|
|
245
|
+
stacklevel=3,
|
|
246
|
+
)
|
|
247
|
+
density = jnp.asarray(density)
|
|
248
|
+
shape = jnp.broadcast_shapes(tuple(shape), density.shape)
|
|
249
|
+
sizes = tuple(len(x) for x in coords.values())
|
|
250
|
+
F = jnp.zeros(shape + sizes, dtype=dtype or complex)
|
|
251
|
+
density = jnp.broadcast_to(density, shape)
|
|
252
|
+
state = kernels.State(
|
|
253
|
+
F,
|
|
254
|
+
F,
|
|
255
|
+
density,
|
|
256
|
+
axes=tuple(coords),
|
|
257
|
+
coords=tuple(
|
|
258
|
+
(axis, tuple(np.asarray(x).tolist())) for axis, x in coords.items()
|
|
259
|
+
),
|
|
260
|
+
representation=self.representation,
|
|
261
|
+
)
|
|
262
|
+
return state.equilibrium
|
|
263
|
+
|
|
264
|
+
def _counts(self, directions):
|
|
265
|
+
"""number of isochromats along each direction"""
|
|
266
|
+
counts = _per_direction(self.n, directions, "n", "sample axes")
|
|
267
|
+
if any(c is None for c in counts):
|
|
268
|
+
raise ValueError(f"Isochromats n {self.n} for axes {directions}")
|
|
269
|
+
return counts
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def infer_method(sequence):
|
|
273
|
+
"""simplest method covering all operators of the sequence"""
|
|
274
|
+
for op in _shifts(sequence):
|
|
275
|
+
if not op.integer:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"Non-integer shift {op}: set the simulation method explicitly, "
|
|
278
|
+
"e.g. `method=Isochromats(n=...)`"
|
|
279
|
+
)
|
|
280
|
+
return Lattice()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _shifts(sequence):
|
|
284
|
+
return [op for op in flatten_sequence(sequence) if isinstance(op, S)]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _grid(n):
|
|
288
|
+
"""regular grid of n positions over one period, centred on 0"""
|
|
289
|
+
return (np.arange(n) + 0.5) / n - 0.5
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _is_items(value):
|
|
293
|
+
"""True for a tuple of (name, value) pairs (from a dict)"""
|
|
294
|
+
return isinstance(value, tuple) and bool(value) and isinstance(value[0], tuple)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _directions(sequence):
|
|
298
|
+
"""dephasing directions of the sequence's shifts (default: "x")"""
|
|
299
|
+
return tuple(getnshifts(sequence)) or ("x",)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _core_axes(sequence, representation):
|
|
303
|
+
"""names of the core axes storing the directions of the sequence"""
|
|
304
|
+
nd = any(op.nd for op in _shifts(sequence))
|
|
305
|
+
name = kernels.state.core_axis_name
|
|
306
|
+
return tuple(name(d, representation, nd) for d in _directions(sequence))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _per_direction(value, directions, label, kind):
|
|
310
|
+
"""setting along each direction: an int or None (all directions), a tuple
|
|
311
|
+
(x, y, z), or pairs from a dict by direction (missing: None)"""
|
|
312
|
+
if value is None or isinstance(value, int):
|
|
313
|
+
return (value,) * len(directions)
|
|
314
|
+
elif _is_items(value):
|
|
315
|
+
given = dict(value)
|
|
316
|
+
unknown = set(given) - set(directions)
|
|
317
|
+
if unknown:
|
|
318
|
+
raise ValueError(
|
|
319
|
+
f"Unknown axes in {label}: {sorted(unknown)}, axes: {directions}"
|
|
320
|
+
)
|
|
321
|
+
return tuple(given.get(d) for d in directions)
|
|
322
|
+
default = [d for d in directions if d in AXIS_NAMES]
|
|
323
|
+
if len(value) != len(default):
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"{label} {value} has {len(value)} values for {len(default)} {kind} "
|
|
326
|
+
"(x, y, z; named axes: use a dict)"
|
|
327
|
+
)
|
|
328
|
+
given = dict(zip(default, value))
|
|
329
|
+
return tuple(given.get(d) for d in directions)
|
epgx/ops/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Layer 2: operators (pytrees in user units)"""
|
|
2
|
+
|
|
3
|
+
from .dephasing import DephasingAxis, kAxis, tAxis
|
|
4
|
+
from .evolution import E, P, R
|
|
5
|
+
from .functions import (
|
|
6
|
+
Skeleton,
|
|
7
|
+
flatten_operators,
|
|
8
|
+
flatten_sequence,
|
|
9
|
+
get_adc_times,
|
|
10
|
+
get_dephasing_axes,
|
|
11
|
+
getnshift,
|
|
12
|
+
getnshifts,
|
|
13
|
+
unflatten_operators,
|
|
14
|
+
)
|
|
15
|
+
from .operator import (
|
|
16
|
+
NULL,
|
|
17
|
+
RESET,
|
|
18
|
+
SPOILER,
|
|
19
|
+
ZERO_DURATION,
|
|
20
|
+
EmptyOperator,
|
|
21
|
+
Operator,
|
|
22
|
+
Reset,
|
|
23
|
+
Spoiler,
|
|
24
|
+
Wait,
|
|
25
|
+
check_conflicts,
|
|
26
|
+
operator,
|
|
27
|
+
)
|
|
28
|
+
from .probe import ADC, Adc, Probe, Probes
|
|
29
|
+
from .shift import S
|
|
30
|
+
from .transition import Phi, T
|
epgx/ops/dephasing.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Dephasing axes: directions along which shifts dephase the magnetization
|
|
2
|
+
|
|
3
|
+
The default space axes "x", "y", "z" exist implicitly: a vector shift
|
|
4
|
+
`S((kx, ky, kz))` acts along them (a scalar shift along "x"). An independent
|
|
5
|
+
axis, e.g. for a gradient unrelated to the others, is declared with its
|
|
6
|
+
physical scale and passed to the operators (`S(1, axis=diff)`):
|
|
7
|
+
|
|
8
|
+
diff = kAxis("diff", kvalue=q) # space axis, rad/mm per unit shift
|
|
9
|
+
time = tAxis("t", tvalue=tau) # time axis, ms per unit shift (not used yet)
|
|
10
|
+
|
|
11
|
+
Rule: the same axis interacts, different axes do not (no cross terms). The
|
|
12
|
+
simulation collects the axes from the operators (two different axes with the
|
|
13
|
+
same name: error). The representation of the states (method) decides how a
|
|
14
|
+
direction is stored: a lattice axis ("kx", "k_diff"), a sample axis of
|
|
15
|
+
isochromats ("iso_x", "iso_diff")...
|
|
16
|
+
|
|
17
|
+
The scales are carried for the operators that will need physical units
|
|
18
|
+
(diffusion, imaging); they are not used by the shifts.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import jax
|
|
22
|
+
|
|
23
|
+
from ..common import pytree, repr_value, static
|
|
24
|
+
|
|
25
|
+
# default space axes of the shift components
|
|
26
|
+
SPACE_AXES = ("x", "y", "z")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytree
|
|
30
|
+
class DephasingAxis:
|
|
31
|
+
"""Dephasing axis with its physical scale (one of kvalue, tvalue)
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
name: axis name ("x", "y", "z": the default space axes, e.g. to set
|
|
35
|
+
their scale)
|
|
36
|
+
kvalue: space axis: wavenumber of a unit shift (rad/mm)
|
|
37
|
+
tvalue: time axis: duration of a unit shift (ms)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
kvalue: jax.Array = None
|
|
41
|
+
tvalue: jax.Array = None
|
|
42
|
+
name: str = static()
|
|
43
|
+
|
|
44
|
+
def __init__(self, name, *, kvalue=None, tvalue=None):
|
|
45
|
+
if not isinstance(name, str) or not name.isidentifier():
|
|
46
|
+
raise ValueError(f"Invalid dephasing axis name: {name!r}")
|
|
47
|
+
elif (kvalue is None) == (tvalue is None):
|
|
48
|
+
raise ValueError(f"Dephasing axis {name!r}: give one of kvalue, tvalue")
|
|
49
|
+
object.__setattr__(self, "name", name)
|
|
50
|
+
object.__setattr__(self, "kvalue", kvalue)
|
|
51
|
+
object.__setattr__(self, "tvalue", tvalue)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def kind(self):
|
|
55
|
+
"""kind of the axis: "space" or "time" """
|
|
56
|
+
return "space" if self.kvalue is not None else "time"
|
|
57
|
+
|
|
58
|
+
def __repr__(self):
|
|
59
|
+
key = "kvalue" if self.kind == "space" else "tvalue"
|
|
60
|
+
value = self.kvalue if self.kind == "space" else self.tvalue
|
|
61
|
+
return f"DephasingAxis({self.name!r}, {key}={repr_value(value)})"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def kAxis(name, kvalue): # noqa: N802 (constructor in physics notation)
|
|
65
|
+
"""space dephasing axis (cf. DephasingAxis)"""
|
|
66
|
+
return DephasingAxis(name, kvalue=kvalue)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def tAxis(name, tvalue): # noqa: N802 (constructor in physics notation)
|
|
70
|
+
"""time dephasing axis (cf. DephasingAxis)"""
|
|
71
|
+
return DephasingAxis(name, tvalue=tvalue)
|
epgx/ops/evolution.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Evolution operators (relaxation, precession)"""
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
import jax.numpy as jnp
|
|
5
|
+
|
|
6
|
+
from .. import kernels
|
|
7
|
+
from ..kernels import affine as maps
|
|
8
|
+
from .operator import Operator, asparam, check_conflicts, operator, total
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@operator
|
|
12
|
+
class R(Operator):
|
|
13
|
+
"""Evolution operator with dimensionless exponents (cf. kernels.evolve)
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
rT: transverse exponent (complex: relaxation + precession)
|
|
17
|
+
rL: longitudinal exponent
|
|
18
|
+
r0: recovery exponent (None: no recovery)
|
|
19
|
+
cf. Operator for other keyword arguments
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
rT: jax.Array = None
|
|
23
|
+
rL: jax.Array = None
|
|
24
|
+
r0: jax.Array = None
|
|
25
|
+
|
|
26
|
+
def __init__(self, rT=0, rL=0, *, r0=None, duration=None, name=None):
|
|
27
|
+
super().__init__(duration=duration, name=name)
|
|
28
|
+
self._set(rT=asparam(rT), rL=asparam(rL), r0=asparam(r0))
|
|
29
|
+
|
|
30
|
+
def normalize(self, sample=None, scanner=None):
|
|
31
|
+
return {"rT": self.rT, "rL": self.rL, "r0": self.r0}
|
|
32
|
+
|
|
33
|
+
kernel = staticmethod(kernels.evolve)
|
|
34
|
+
affine = staticmethod(maps.evolution_matrix)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@operator
|
|
38
|
+
class E(Operator):
|
|
39
|
+
"""Relaxation and precession operator
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
tau: evolution time (ms)
|
|
43
|
+
T1: longitudinal relaxation time (ms), None: from the sample, if any
|
|
44
|
+
T2: transverse relaxation time (ms), None: from the sample, if any
|
|
45
|
+
(a value given both here and by the sample raises an error)
|
|
46
|
+
g: precession rate (kHz), None: no precession
|
|
47
|
+
(added to the sample frequency offset `df` and the scanner `B0`)
|
|
48
|
+
duration: operator duration (ms), default: 0, True: tau
|
|
49
|
+
cf. Operator for other keyword arguments
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
tau: jax.Array = None
|
|
53
|
+
T1: jax.Array = None
|
|
54
|
+
T2: jax.Array = None
|
|
55
|
+
g: jax.Array = None
|
|
56
|
+
|
|
57
|
+
def __init__(self, tau, T1=None, T2=None, g=None, *, duration=None, name=None):
|
|
58
|
+
tau = asparam(tau)
|
|
59
|
+
duration = tau if duration is True else duration
|
|
60
|
+
super().__init__(duration=duration, name=name)
|
|
61
|
+
self._set(tau=tau, T1=asparam(T1), T2=asparam(T2), g=asparam(g))
|
|
62
|
+
|
|
63
|
+
def normalize(self, sample=None, scanner=None):
|
|
64
|
+
# T1/T2 from the operator or the sample (both: error), frequencies add up
|
|
65
|
+
check_conflicts(self, sample)
|
|
66
|
+
T1 = self.T1 if self.T1 is not None else getattr(sample, "T1", None)
|
|
67
|
+
T2 = self.T2 if self.T2 is not None else getattr(sample, "T2", None)
|
|
68
|
+
g = total(self.g, getattr(sample, "df", None), getattr(scanner, "B0", None))
|
|
69
|
+
return relaxation_exponents(self.tau, T1, T2, g)
|
|
70
|
+
|
|
71
|
+
def sample_fields(self):
|
|
72
|
+
# T1/T2 from the sample unless explicit, df always added
|
|
73
|
+
return tuple(f for f in ("T1", "T2") if getattr(self, f) is None) + ("df",)
|
|
74
|
+
|
|
75
|
+
def sample_conflicts(self, sample):
|
|
76
|
+
# T1/T2 given both by the operator and by the sample
|
|
77
|
+
return tuple(
|
|
78
|
+
f
|
|
79
|
+
for f in ("T1", "T2")
|
|
80
|
+
if getattr(self, f) is not None and getattr(sample, f, None) is not None
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
kernel = staticmethod(kernels.evolve)
|
|
84
|
+
affine = staticmethod(maps.evolution_matrix)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@operator
|
|
88
|
+
class P(Operator):
|
|
89
|
+
"""Precession operator
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
tau: evolution time (ms)
|
|
93
|
+
g: precession rate (kHz)
|
|
94
|
+
duration: operator duration (ms), default: 0, True: tau
|
|
95
|
+
cf. Operator for other keyword arguments
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
tau: jax.Array = None
|
|
99
|
+
g: jax.Array = None
|
|
100
|
+
|
|
101
|
+
def __init__(self, tau, g, *, duration=None, name=None):
|
|
102
|
+
tau = asparam(tau)
|
|
103
|
+
duration = tau if duration is True else duration
|
|
104
|
+
super().__init__(duration=duration, name=name)
|
|
105
|
+
self._set(tau=tau, g=asparam(g))
|
|
106
|
+
|
|
107
|
+
def normalize(self, sample=None, scanner=None):
|
|
108
|
+
# frequencies add up (no relaxation)
|
|
109
|
+
g = total(self.g, getattr(sample, "df", None), getattr(scanner, "B0", None))
|
|
110
|
+
return relaxation_exponents(self.tau, None, None, g)
|
|
111
|
+
|
|
112
|
+
def sample_fields(self):
|
|
113
|
+
return ("df",)
|
|
114
|
+
|
|
115
|
+
kernel = staticmethod(kernels.evolve)
|
|
116
|
+
affine = staticmethod(maps.evolution_matrix)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
#
|
|
120
|
+
# functions
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def relaxation_exponents(tau, T1=None, T2=None, g=None):
|
|
124
|
+
"""kernel exponents of E(tau, T1, T2, g) (None: no relaxation/precession)
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
dict with rT = tau * (1/T2 + 2i pi g), rL = r0 = tau / T1
|
|
128
|
+
"""
|
|
129
|
+
rT = 0.0 if T2 is None else tau / T2
|
|
130
|
+
if g is not None:
|
|
131
|
+
rT = rT + 2j * jnp.pi * g * tau
|
|
132
|
+
if T1 is None:
|
|
133
|
+
return {"rT": rT, "rL": 0.0, "r0": None}
|
|
134
|
+
rL = tau / T1
|
|
135
|
+
return {"rT": rT, "rL": rL, "r0": rL}
|