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/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)
|
epgx/kernels/operator.py
ADDED
|
@@ -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)
|