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/affine.py ADDED
@@ -0,0 +1,187 @@
1
+ """Affine maps on the magnetization states (operator merging)
2
+
3
+ Several operators act on each state (each phase state k, or each isochromat)
4
+ as an affine map on the vector v = (F+, F-, Z):
5
+
6
+ v' = M v + o * eq
7
+
8
+ with M a 3x3 matrix and o an offset (recovery) multiplied by the equilibrium
9
+ magnetization (density at k=0, or at each isochromat), both per batch element:
10
+ arrays of shape (*shape, 3, 3) and (*shape, 3).
11
+
12
+ The composition of affine maps is an affine map: consecutive operators between
13
+ barriers (shifts, probes) can be merged into a single map (`apply_affine`).
14
+
15
+ Builders return (M, o) from the kernels' (dimensionless) parameters:
16
+ rotation_matrix(alpha, phi) rotate
17
+ phase_matrix(phi) phase
18
+ evolution_matrix(rT, rL, r0) evolve
19
+ spoil_matrix() spoil
20
+ reset_matrix() reset
21
+
22
+ `AffineAlgebra` gathers these operations for the merging pass (cf. merging).
23
+ """
24
+
25
+ import jax
26
+ import jax.numpy as jnp
27
+
28
+
29
+ def apply_affine(state, matrix, offset):
30
+ """apply an affine map to the states: v' = M v + o * eq
31
+
32
+ Args:
33
+ state: State
34
+ matrix: (*shape, 3, 3) matrix acting on (F+, F-, Z)
35
+ offset: (*shape, 3) offset, times the equilibrium magnetization
36
+
37
+ Only the F+ and Z rows are applied (F- follows by symmetry).
38
+
39
+ Returns:
40
+ new State
41
+ """
42
+ matrix, offset = jnp.asarray(matrix), jnp.asarray(offset)
43
+ state = state.broadcast(matrix.shape[:-2], offset.shape[:-1])
44
+ coefs = [matrix[..., i, j] for i in (0, 2) for j in range(3)]
45
+ _, (m00, m01, m02, m20, m21, m22) = state.prepare(*coefs)
46
+ _, (o0, o2) = state.prepare(offset[..., 0], offset[..., 2], expand=False)
47
+ F, Fm, Z = state.F, state.mirror(state.F), state.Z
48
+ F_ = state.add_equilibrium(m00 * F + m01 * Fm + m02 * Z, o0)
49
+ Z_ = state.add_equilibrium(m20 * F + m21 * Fm + m22 * Z, o2)
50
+ return state.replace(F=F_, Z=Z_)
51
+
52
+
53
+ def compose(*maps):
54
+ """composition of affine maps, applied in the given order
55
+
56
+ Args:
57
+ maps: (M, o) pairs, first applied first
58
+
59
+ Returns:
60
+ (M, o) of the composed map
61
+ """
62
+ matrix, offset = maps[0]
63
+ for m, o in maps[1:]:
64
+ # explicit elementwise products: much faster than batched 3x3 matmul
65
+ matrix = (m[..., :, :, None] * matrix[..., None, :, :]).sum(-2)
66
+ offset = (m * offset[..., None, :]).sum(-1) + o
67
+ return matrix, offset
68
+
69
+
70
+ def power(affine_map, n):
71
+ """affine map applied n times (static n), by repeated squaring"""
72
+ if n < 1:
73
+ raise ValueError(f"Expected n >= 1, got {n}")
74
+ result, base = None, affine_map
75
+ while n:
76
+ if n & 1:
77
+ result = base if result is None else compose(result, base)
78
+ n >>= 1
79
+ if n:
80
+ base = compose(base, base)
81
+ return result
82
+
83
+
84
+ def identity(shape=(), dtype=complex):
85
+ """identity map"""
86
+ matrix = jnp.broadcast_to(jnp.eye(3, dtype=dtype), tuple(shape) + (3, 3))
87
+ return matrix, jnp.zeros(tuple(shape) + (3,), dtype=dtype)
88
+
89
+
90
+ def fold(stacked):
91
+ """composition of stacked maps (leading axis), in order
92
+
93
+ Sequential composition (measured faster than associative_scan or a pairwise
94
+ tree on CPU).
95
+ """
96
+ matrices, _ = stacked
97
+ init = identity(matrices.shape[1:-2], matrices.dtype)
98
+
99
+ def body(carry, affine_map):
100
+ return compose(carry, affine_map), None
101
+
102
+ composed, _ = jax.lax.scan(body, init, stacked)
103
+ return composed
104
+
105
+
106
+ class AffineAlgebra:
107
+ """affine maps (M, o) as a map algebra for the merging pass (cf. merging)
108
+
109
+ Maps are built by the operators' `affine` attribute (function of the kernel
110
+ parameters -> map, cf. ops.Operator.affine); they do not depend on the
111
+ representation of the states (the run context is not used).
112
+ """
113
+
114
+ @staticmethod
115
+ def mergeable(op):
116
+ return getattr(op, "affine", None) is not None
117
+
118
+ @staticmethod
119
+ def build(op, params, context):
120
+ return op.affine(**params)
121
+
122
+ @staticmethod
123
+ def apply(state, value):
124
+ return apply_affine(state, *value)
125
+
126
+ compose = staticmethod(compose)
127
+ power = staticmethod(power)
128
+ fold = staticmethod(fold)
129
+
130
+
131
+ #
132
+ # builders
133
+
134
+
135
+ def rotation_matrix(alpha, phi):
136
+ """affine map of `rotate`"""
137
+ alpha, phi = jnp.broadcast_arrays(jnp.asarray(alpha), jnp.asarray(phi))
138
+ cos2, sin2 = jnp.cos(alpha / 2) ** 2, jnp.sin(alpha / 2) ** 2
139
+ sin, cos = jnp.sin(alpha), jnp.cos(alpha)
140
+ e1, e2 = jnp.exp(1j * phi), jnp.exp(2j * phi)
141
+ rows = [
142
+ [cos2 + 0j, sin2 * e2, -1j * sin * e1],
143
+ [sin2 * e2.conj(), cos2 + 0j, 1j * sin * e1.conj()],
144
+ [-0.5j * sin * e1.conj(), 0.5j * sin * e1, cos + 0j],
145
+ ]
146
+ matrix = jnp.stack([jnp.stack(row, axis=-1) for row in rows], axis=-2)
147
+ return matrix, jnp.zeros(matrix.shape[:-1], dtype=matrix.dtype)
148
+
149
+
150
+ def phase_matrix(phi):
151
+ """affine map of `phase`"""
152
+ e1 = jnp.exp(1j * jnp.asarray(phi))
153
+ return _diagonal(e1, e1.conj(), jnp.ones_like(e1))
154
+
155
+
156
+ def evolution_matrix(rT=0, rL=0, r0=None):
157
+ """affine map of `evolve` (exponents broadcast with the batch shape)"""
158
+ rT, rL = jnp.asarray(rT) + 0j, jnp.asarray(rL)
159
+ shape = jnp.broadcast_shapes(
160
+ rT.shape, rL.shape, jnp.shape(r0) if r0 is not None else ()
161
+ )
162
+ eT = jnp.broadcast_to(jnp.exp(-rT), shape)
163
+ matrix, offset = _diagonal(
164
+ eT.conj(), eT, jnp.broadcast_to(jnp.exp(-rL), shape) + 0j
165
+ )
166
+ if r0 is not None:
167
+ recovery = jnp.broadcast_to(-jnp.expm1(-jnp.asarray(r0)), shape)
168
+ offset = offset.at[..., 2].set(recovery)
169
+ return matrix, offset
170
+
171
+
172
+ def spoil_matrix():
173
+ """affine map of `spoil` (F = 0)"""
174
+ return _diagonal(0j, 0j, 1 + 0j)
175
+
176
+
177
+ def reset_matrix():
178
+ """affine map of `reset` (return to equilibrium)"""
179
+ matrix = jnp.zeros((3, 3), dtype=complex)
180
+ return matrix, jnp.array([0, 0, 1], dtype=complex)
181
+
182
+
183
+ def _diagonal(d0, d1, d2):
184
+ d0, d1, d2 = jnp.broadcast_arrays(jnp.asarray(d0), jnp.asarray(d1), jnp.asarray(d2))
185
+ matrix = jnp.zeros(d0.shape + (3, 3), dtype=jnp.result_type(d0, d1, d2, complex))
186
+ matrix = matrix.at[..., 0, 0].set(d0).at[..., 1, 1].set(d1).at[..., 2, 2].set(d2)
187
+ return matrix, jnp.zeros(d0.shape + (3,), dtype=matrix.dtype)
@@ -0,0 +1,40 @@
1
+ """Evolution kernels (relaxation, precession, recovery)
2
+
3
+ Dimensionless exponents (cf. the `R(rT, rL, r0)` operator):
4
+ F- -> exp(-rT) F-, i.e. F+ -> exp(-conj(rT)) F+
5
+ Z -> exp(-rL) Z + (1 - exp(-r0)) * equilibrium
6
+
7
+ For E(tau, T1, T2, g): rT = tau * (1/T2 + 2i pi g), rL = r0 = tau / T1.
8
+
9
+ Exponents may vary along the core axes (`core=True`), e.g. a shift on
10
+ isochromats is a precession depending on their positions: rT = 2i pi k x.
11
+ """
12
+
13
+ import jax.numpy as jnp
14
+
15
+
16
+ def evolve(state, rT=0, rL=0, r0=None, *, core=False):
17
+ """relaxation / precession / recovery
18
+
19
+ Args:
20
+ state: State
21
+ rT: transverse exponent (complex: relaxation + precession)
22
+ rL: longitudinal exponent (real)
23
+ r0: recovery exponent (real), None: no recovery
24
+ core: if False, exponents are broadcast with the batch shape; if True,
25
+ with the batch and core axes (`(*shape, *core)`)
26
+
27
+ Returns:
28
+ new State
29
+ """
30
+ state, (rT, rL, r0) = state.prepare(rT, rL, r0, expand=False, core=core)
31
+ F = state.expand_param(jnp.exp(-jnp.conj(rT)), core=core) * state.F
32
+ Z = state.expand_param(jnp.exp(-rL), core=core) * state.Z
33
+ if r0 is not None:
34
+ # 1 - exp(-r0), accurate for small r0
35
+ recovery = -jnp.expm1(-r0)
36
+ if core:
37
+ Z = Z + recovery * state.add_equilibrium(jnp.zeros_like(Z))
38
+ else:
39
+ Z = state.add_equilibrium(Z, recovery)
40
+ return state.replace(F=F, Z=Z)
@@ -0,0 +1,32 @@
1
+ """Spoiler and reset kernels"""
2
+
3
+ import jax.numpy as jnp
4
+
5
+
6
+ def spoil(state):
7
+ """perfect spoiler: set transverse states to 0"""
8
+ return state.replace(F=jnp.zeros_like(state.F))
9
+
10
+
11
+ def spoil_axis(state, direction):
12
+ """keep the states of index 0 along a dephasing direction (F and Z)
13
+
14
+ States dephased along the direction are removed: on the lattice, all
15
+ states with a nonzero index along its axis; on samples (isochromats), the
16
+ magnetization is replaced by its mean along the axis.
17
+ """
18
+ axis = state.core_axis(direction)
19
+ if state.axis_kind(state.axes[axis - state.ndim]) == "sample":
20
+ F = jnp.broadcast_to(state.F.mean(axis=axis, keepdims=True), state.F.shape)
21
+ Z = jnp.broadcast_to(state.Z.mean(axis=axis, keepdims=True), state.Z.shape)
22
+ return state.replace(F=F, Z=Z)
23
+ size = state.F.shape[axis]
24
+ mask = (jnp.arange(size) == size // 2).reshape(
25
+ (size,) + (1,) * (state.F.ndim - axis - 1)
26
+ )
27
+ return state.replace(F=state.F * mask, Z=state.Z * mask)
28
+
29
+
30
+ def reset(state):
31
+ """return to equilibrium (capacity and batch shape are kept)"""
32
+ return state.equilibrium
epgx/kernels/probe.py ADDED
@@ -0,0 +1,107 @@
1
+ """Probe kernels (signal acquisition)
2
+
3
+ readout: quantity probed from the state (F0, Z0, F, Fminus, Z, states)
4
+ reduce: weighted sum over batch axes (static indices)
5
+ demodulate: removal of the receiver phase (scanner convention)
6
+ adc: readout -> weights -> reduce -> demodulate
7
+
8
+ Axis indices refer to the batch axes; values may have `ncore` trailing axes
9
+ (e.g. core axes of F and Z), which are never reduced. Readouts always keep the
10
+ batch axes first, so `ncore` is derived from the shapes (layout independent).
11
+ Weights and phases are broadcast with the batch axes (right-aligned).
12
+
13
+ ADC phase follows the scanner convention: the ADC phase equals the phase of the
14
+ RF pulse to compensate, and the signal is multiplied by exp(-i phase).
15
+ """
16
+
17
+ import jax.numpy as jnp
18
+
19
+ NAX = jnp.newaxis
20
+
21
+ ATTRIBUTES = ["F0", "Z0", "F", "Fminus", "Z", "states"]
22
+
23
+
24
+ def readout(state, attr="F0"):
25
+ """quantity probed from the state
26
+
27
+ Args:
28
+ state: State
29
+ attr: "F0", "Z0" (batch shape), "F", "Fminus", "Z" (batch and core axes),
30
+ "states" ([F+, F-, Z] layout, 1d lattice only)
31
+ """
32
+ if attr not in ATTRIBUTES:
33
+ raise ValueError(f"Unknown attribute: {attr}, expected one of {ATTRIBUTES}")
34
+ return getattr(state, attr)
35
+
36
+
37
+ def reduce(value, axes=None, weights=None, *, ncore=0):
38
+ """weighted sum over batch axes
39
+
40
+ Args:
41
+ value: array of shape (*shape, *core) with len(core) == ncore
42
+ axes: None (no sum, or weights' axes if weights is set), True (all batch axes),
43
+ int or tuple of int (batch axes, negative: from the last batch axis)
44
+ weights: weights broadcast with the batch axes (right-aligned)
45
+ ncore: number of trailing non-batch axes in value
46
+
47
+ Returns:
48
+ array
49
+ """
50
+ if weights is not None:
51
+ weights = _cast(weights, value)
52
+ value = value * weights[(...,) + (NAX,) * ncore]
53
+ if axes is None:
54
+ # sum along the weights' axes (trailing batch axes)
55
+ axes = tuple(range(-weights.ndim, 0))
56
+ if axes is None or axes is False:
57
+ return value
58
+ ndim = value.ndim - ncore
59
+ if axes is True:
60
+ axes = tuple(range(ndim))
61
+ axes = (axes,) if isinstance(axes, int) else tuple(axes)
62
+ if not all(-ndim <= ax < ndim for ax in axes):
63
+ raise ValueError(f"Invalid axes {axes} for {ndim} batch dimension(s)")
64
+ return value.sum(axis=tuple(ax % ndim for ax in axes))
65
+
66
+
67
+ def demodulate(value, phase, *, ncore=0):
68
+ """remove receiver phase: value * exp(-i phase)
69
+
70
+ Args:
71
+ value: array of shape (*shape, *core) with len(core) == ncore
72
+ phase: phase (rad), broadcast with the batch axes (right-aligned)
73
+ ncore: number of trailing non-batch axes in value
74
+ """
75
+ phase = _cast(phase, value, real=True)
76
+ return value * jnp.exp(-1j * phase)[(...,) + (NAX,) * ncore]
77
+
78
+
79
+ def adc(state, attr="F0", *, phase=None, weights=None, axes=None):
80
+ """acquire signal: readout -> weights -> reduce -> demodulate
81
+
82
+ Args:
83
+ state: State
84
+ attr: probed attribute (cf. `readout`)
85
+ phase: ADC phase (rad), applied after reduction (scanner convention)
86
+ weights, axes: cf. `reduce`
87
+ """
88
+ value = readout(state, attr)
89
+ # readouts keep the batch axes first: any trailing axis is a non-batch axis
90
+ ncore = value.ndim - state.ndim
91
+ value = reduce(value, axes, weights, ncore=ncore)
92
+ if phase is not None:
93
+ value = demodulate(value, phase, ncore=ncore)
94
+ return value
95
+
96
+
97
+ #
98
+ # functions
99
+
100
+
101
+ def _cast(param, value, real=False):
102
+ """cast parameter to the precision of value (keep real/complex kind)"""
103
+ dtype = jnp.result_type(value)
104
+ rdtype = jnp.finfo(dtype).dtype if jnp.issubdtype(dtype, jnp.inexact) else dtype
105
+ if real or not jnp.iscomplexobj(param):
106
+ return jnp.asarray(param, dtype=rdtype)
107
+ return jnp.asarray(param, dtype=dtype)
epgx/kernels/shift.py ADDED
@@ -0,0 +1,106 @@
1
+ """Shift kernels (gradient dephasing), named by state representation
2
+
3
+ shift_lattice: integer shift on the dense lattice (1d: regular EPG,
4
+ nd: 3d gradient extension)
5
+ shift_samples: samples of the magnetization (isochromats), any k: a shift is
6
+ a precession depending on the position x, M+ -> exp(2i pi k x) M+
7
+ (future) shift_slots: sparse slots with stored coordinates (integer or
8
+ float k, accumulated time); may be split by
9
+ merge rule (exact, grid, moments)
10
+
11
+ Shifting F+(k) -> F+(k + s) also shifts F-(k) -> F-(k - s), by symmetry
12
+ F-(k) = conj(F+(-k)); Z states are not shifted.
13
+ """
14
+
15
+ import jax.numpy as jnp
16
+ import numpy as np
17
+
18
+ from .evolution import evolve
19
+
20
+
21
+ def shift_samples(state, k):
22
+ """shift of the transverse magnetization of samples (e.g. isochromats)
23
+
24
+ A shift is a precession depending on the position x of each sample
25
+ (state coordinates, unit: period of a unit shift): M+ -> exp(2i pi k.x) M+,
26
+ with one sample axis per dephasing direction.
27
+
28
+ Args:
29
+ state: State with sample axes (with coordinates)
30
+ k: dict {direction: number} ("x", "y", "z" or a named dephasing axis;
31
+ any real values), or a number (along "x") or a tuple (x, y, z)
32
+
33
+ Returns:
34
+ new State
35
+ """
36
+ phase = 0.0
37
+ core = state.F.shape[state.ndim :]
38
+ for direction, s in shifts_by_direction(k).items():
39
+ if s == 0:
40
+ continue
41
+ axis = state.core_axis(direction) - state.ndim
42
+ x = state.coordinates(state.axes[axis])
43
+ shape = [1] * len(core)
44
+ shape[axis] = x.size
45
+ phase = phase + s * x.reshape(shape) # grid of positions (k.x)
46
+ return evolve(state, 2j * jnp.pi * phase, core=True)
47
+
48
+
49
+ def shift_lattice(state, k):
50
+ """integer shift of the transverse states on the (fixed-capacity) lattice
51
+
52
+ States shifted beyond the capacity are dropped.
53
+
54
+ Args:
55
+ state: State with lattice axes
56
+ k: dict {direction: static int} ("x", "y", "z" or a named dephasing
57
+ axis), or an int (along "x") or a tuple of int (x, y, z)
58
+
59
+ Returns:
60
+ new State
61
+ """
62
+ F = state.F
63
+ for direction, s in shifts_by_direction(k).items():
64
+ if not _isint(s):
65
+ raise TypeError(f"Shifts must be static integers, got: {k}")
66
+ elif s == 0:
67
+ continue
68
+ F = _shift(F, int(s), state.core_axis(direction))
69
+ return state.replace(F=F)
70
+
71
+
72
+ def shifts_by_direction(k):
73
+ """shift components as a dict {direction: value}
74
+
75
+ A number is a shift along "x", a tuple the components along "x", "y", "z".
76
+ """
77
+ if isinstance(k, dict):
78
+ return dict(k)
79
+ k = tuple(k) if isinstance(k, (tuple, list)) else (k,)
80
+ if len(k) > 3:
81
+ raise ValueError(f"Shifts have 1 to 3 components (x, y, z), got: {k}")
82
+ return dict(zip(("x", "y", "z"), k))
83
+
84
+
85
+ #
86
+ # functions
87
+
88
+
89
+ def _isint(value):
90
+ return isinstance(value, (int, np.integer)) and not isinstance(value, bool)
91
+
92
+
93
+ def _shift(arr, shift, axis):
94
+ """shift array along axis with zero fill (out-of-range values are dropped)"""
95
+ size = arr.shape[axis]
96
+ if shift == 0:
97
+ return arr
98
+ elif abs(shift) >= size:
99
+ return jnp.zeros_like(arr)
100
+ index = [slice(None)] * arr.ndim
101
+ pad = [(0, 0)] * arr.ndim
102
+ if shift > 0:
103
+ index[axis], pad[axis] = slice(0, size - shift), (shift, 0)
104
+ else:
105
+ index[axis], pad[axis] = slice(-shift, size), (0, -shift)
106
+ return jnp.pad(arr[tuple(index)], pad)