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/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}