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/ops/functions.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Sequence functions"""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
|
|
5
|
+
import jax
|
|
6
|
+
import jax.numpy as jnp
|
|
7
|
+
|
|
8
|
+
from .operator import Operator
|
|
9
|
+
from .probe import Probe
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def flatten_sequence(sequence):
|
|
13
|
+
"""return a flat list of operators from a (nested) list of operators"""
|
|
14
|
+
if isinstance(sequence, Operator):
|
|
15
|
+
return [sequence]
|
|
16
|
+
flat = []
|
|
17
|
+
for item in sequence:
|
|
18
|
+
if isinstance(item, (list, tuple)):
|
|
19
|
+
flat.extend(flatten_sequence(item))
|
|
20
|
+
elif isinstance(item, Operator):
|
|
21
|
+
flat.append(item)
|
|
22
|
+
else:
|
|
23
|
+
raise ValueError(f"Invalid operator: {item}")
|
|
24
|
+
return flat
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def getnshift(sequence):
|
|
28
|
+
"""number of phase-state shifts of the sequence
|
|
29
|
+
|
|
30
|
+
Upper bound of the state capacity needed on the lattice (sum of |k|).
|
|
31
|
+
"""
|
|
32
|
+
return sum(op.nshift for op in flatten_sequence(sequence))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def getnshifts(sequence):
|
|
36
|
+
"""number of phase-state shifts of the sequence along each dephasing direction
|
|
37
|
+
|
|
38
|
+
Upper bound of the state capacity needed along each direction (sum of
|
|
39
|
+
|k|), as a dict {direction: count}: "x", "y", "z" first (as many as the
|
|
40
|
+
longest vector shift), then the named axes in order of appearance.
|
|
41
|
+
"""
|
|
42
|
+
counts = {}
|
|
43
|
+
for op in flatten_sequence(sequence):
|
|
44
|
+
for direction, s in getattr(op, "shifts", {}).items():
|
|
45
|
+
counts[direction] = counts.get(direction, 0) + abs(s)
|
|
46
|
+
order = [d for d in ("x", "y", "z") if d in counts]
|
|
47
|
+
order += [d for d in counts if d not in ("x", "y", "z")]
|
|
48
|
+
return {d: counts[d] for d in order}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_dephasing_axes(sequence):
|
|
52
|
+
"""dephasing axes declared by the operators, by name (cf. DephasingAxis)
|
|
53
|
+
|
|
54
|
+
Two different axes with the same name raise an error (different kinds, or
|
|
55
|
+
different concrete scales).
|
|
56
|
+
"""
|
|
57
|
+
axes = {}
|
|
58
|
+
for op in flatten_sequence(sequence):
|
|
59
|
+
axis = getattr(op, "axis", None)
|
|
60
|
+
if axis is None:
|
|
61
|
+
continue
|
|
62
|
+
other = axes.setdefault(axis.name, axis)
|
|
63
|
+
if other is not axis and not _same_axis(axis, other):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Two different dephasing axes named {axis.name!r}: {other!r}, {axis!r}"
|
|
66
|
+
)
|
|
67
|
+
return axes
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _same_axis(a, b):
|
|
71
|
+
"""same kind and scale (scales compared when concrete)"""
|
|
72
|
+
if a.kind != b.kind:
|
|
73
|
+
return False
|
|
74
|
+
va, vb = (a.kvalue, b.kvalue) if a.kind == "space" else (a.tvalue, b.tvalue)
|
|
75
|
+
if isinstance(va, jax.core.Tracer) or isinstance(vb, jax.core.Tracer):
|
|
76
|
+
return True
|
|
77
|
+
return bool(jnp.all(jnp.asarray(va) == jnp.asarray(vb)))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_adc_times(sequence):
|
|
81
|
+
"""return ADC opening times (cf. operator's `duration` keyword)
|
|
82
|
+
|
|
83
|
+
The time of a probe includes its own duration.
|
|
84
|
+
"""
|
|
85
|
+
operators = flatten_sequence(sequence)
|
|
86
|
+
probes = [i for i, op in enumerate(operators) if isinstance(op, Probe)]
|
|
87
|
+
if not probes:
|
|
88
|
+
return jnp.zeros(0)
|
|
89
|
+
# one cumulative sum (not a sum per operator: compact when traced)
|
|
90
|
+
durations = jnp.stack(jnp.broadcast_arrays(*[op.duration for op in operators]))
|
|
91
|
+
return jnp.cumsum(durations, axis=0)[jnp.array(probes)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
#
|
|
95
|
+
# flattening preserving identity
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclasses.dataclass(frozen=True)
|
|
99
|
+
class Skeleton:
|
|
100
|
+
"""Static structure of a sequence (cf. `flatten_operators`), hashable
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
structure: nested tuples (lists of the sequence) of operator indices
|
|
104
|
+
operators: for each distinct operator, (treedef, indices of its leaves)
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
structure: object
|
|
108
|
+
operators: tuple
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def flatten_operators(sequence):
|
|
112
|
+
"""flatten a sequence, preserving the identity of shared objects
|
|
113
|
+
|
|
114
|
+
Unlike `jax.tree.flatten`, an operator appearing several times in the
|
|
115
|
+
sequence (or an array shared by several operators) gives a single set of
|
|
116
|
+
leaves, so that the rebuilt sequence shares the same objects (cf. the
|
|
117
|
+
compiler: shared operators are constants of the scans).
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
leaves (distinct by identity), Skeleton (static, hashable)
|
|
121
|
+
"""
|
|
122
|
+
leaves, leaf_index = [], {}
|
|
123
|
+
operators, op_index = [], {}
|
|
124
|
+
|
|
125
|
+
def flatten(item):
|
|
126
|
+
if isinstance(item, Operator):
|
|
127
|
+
if id(item) not in op_index:
|
|
128
|
+
op_leaves, treedef = jax.tree.flatten(item)
|
|
129
|
+
indices = []
|
|
130
|
+
for leaf in op_leaves:
|
|
131
|
+
if id(leaf) not in leaf_index:
|
|
132
|
+
leaf_index[id(leaf)] = len(leaves)
|
|
133
|
+
leaves.append(leaf)
|
|
134
|
+
indices.append(leaf_index[id(leaf)])
|
|
135
|
+
op_index[id(item)] = len(operators)
|
|
136
|
+
operators.append((treedef, tuple(indices)))
|
|
137
|
+
return op_index[id(item)]
|
|
138
|
+
elif isinstance(item, (list, tuple)):
|
|
139
|
+
return tuple(flatten(sub) for sub in item)
|
|
140
|
+
raise ValueError(f"Invalid operator: {item}")
|
|
141
|
+
|
|
142
|
+
structure = flatten(sequence)
|
|
143
|
+
return leaves, Skeleton(structure, tuple(operators))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def unflatten_operators(skeleton, leaves):
|
|
147
|
+
"""rebuild a sequence (nested lists) from its leaves (cf. `flatten_operators`)"""
|
|
148
|
+
operators = [
|
|
149
|
+
jax.tree.unflatten(treedef, [leaves[i] for i in indices])
|
|
150
|
+
for treedef, indices in skeleton.operators
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
def unflatten(item):
|
|
154
|
+
if isinstance(item, int):
|
|
155
|
+
return operators[item]
|
|
156
|
+
return [unflatten(sub) for sub in item]
|
|
157
|
+
|
|
158
|
+
return unflatten(skeleton.structure)
|
epgx/ops/operator.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Base operator class and basic operators
|
|
2
|
+
|
|
3
|
+
Operators are immutable pytrees: numerical arguments are leaves (traceable,
|
|
4
|
+
differentiable), structural arguments are static metadata. Two operators with
|
|
5
|
+
the same type and static metadata have the same pytree structure, whatever
|
|
6
|
+
their numerical values (used by the sequence compiler to detect repeated blocks).
|
|
7
|
+
|
|
8
|
+
Note: `name` is static metadata, i.e. part of the structure.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
|
|
13
|
+
import jax
|
|
14
|
+
import jax.numpy as jnp
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .. import kernels
|
|
18
|
+
from ..axes import is_axis
|
|
19
|
+
from ..common import pytree, repr_value, static
|
|
20
|
+
from ..kernels import affine as maps
|
|
21
|
+
from .dephasing import DephasingAxis
|
|
22
|
+
|
|
23
|
+
# class decorator for operators (cf. common.pytree)
|
|
24
|
+
operator = pytree
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@operator
|
|
28
|
+
class Operator:
|
|
29
|
+
"""Base operator class (does nothing)
|
|
30
|
+
|
|
31
|
+
Subclasses define:
|
|
32
|
+
normalize(sample, scanner): user parameters -> layer-1 kernel parameters
|
|
33
|
+
kernel: layer-1 kernel (class attribute), applied as
|
|
34
|
+
kernel(state, **normalize(sample, scanner))
|
|
35
|
+
affine: builder of the equivalent affine map (class attribute, cf.
|
|
36
|
+
kernels.affine), called as affine(**normalize(sample, scanner));
|
|
37
|
+
None if the operator is not an affine map (e.g. shift, probe).
|
|
38
|
+
Used to merge consecutive operators.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
duration: operator duration (ms), used for timing (e.g. ADC times)
|
|
42
|
+
name: (static) operator name (default: class name)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
duration: jax.Array = 0.0
|
|
46
|
+
name: str = static()
|
|
47
|
+
|
|
48
|
+
def __init__(self, *, duration=None, name=None):
|
|
49
|
+
self._set(duration=_duration(duration), name=name)
|
|
50
|
+
|
|
51
|
+
def __repr__(self):
|
|
52
|
+
"""name, or class name and parameters (e.g. "T(90, 90)")"""
|
|
53
|
+
if self.name:
|
|
54
|
+
return self.name
|
|
55
|
+
params = [
|
|
56
|
+
repr_value(getattr(self, f.name))
|
|
57
|
+
for f in dataclasses.fields(self)
|
|
58
|
+
if f.name not in ("duration", "name") and not f.metadata.get("static")
|
|
59
|
+
]
|
|
60
|
+
static = [
|
|
61
|
+
repr(getattr(self, f.name))
|
|
62
|
+
for f in dataclasses.fields(self)
|
|
63
|
+
if f.name != "name" and f.metadata.get("static")
|
|
64
|
+
]
|
|
65
|
+
return f"{type(self).__name__}({', '.join(static + params + self._repr_duration())})"
|
|
66
|
+
|
|
67
|
+
def _repr_duration(self):
|
|
68
|
+
"""["duration=..."] if the duration is not 0, else []"""
|
|
69
|
+
duration = self.duration
|
|
70
|
+
if (
|
|
71
|
+
is_axis(duration)
|
|
72
|
+
or isinstance(duration, jax.core.Tracer)
|
|
73
|
+
or np.any(np.asarray(duration) != 0)
|
|
74
|
+
):
|
|
75
|
+
return [f"duration={repr_value(duration)}"]
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def nshift(self):
|
|
80
|
+
"""number of phase-state shifts"""
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
def normalize(self, sample=None, scanner=None):
|
|
84
|
+
"""kernel parameters (dimensionless), from user units"""
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
def sample_fields(self):
|
|
88
|
+
"""sample properties read by `normalize` (cf. simulate's sample check)"""
|
|
89
|
+
return ()
|
|
90
|
+
|
|
91
|
+
def sample_conflicts(self, sample):
|
|
92
|
+
"""properties given both by the operator and by the sample (an error)"""
|
|
93
|
+
return ()
|
|
94
|
+
|
|
95
|
+
# layer-1 kernel applied with the normalized parameters (to override)
|
|
96
|
+
kernel = staticmethod(lambda state: state)
|
|
97
|
+
# affine map builder (to override), None: not mergeable
|
|
98
|
+
affine = None
|
|
99
|
+
|
|
100
|
+
def __call__(self, state, sample=None, scanner=None):
|
|
101
|
+
"""apply operator to state"""
|
|
102
|
+
return self.kernel(state, **self.normalize(sample, scanner))
|
|
103
|
+
|
|
104
|
+
def _set(self, **values):
|
|
105
|
+
"""set attributes (frozen dataclass)"""
|
|
106
|
+
for name, value in values.items():
|
|
107
|
+
object.__setattr__(self, name, value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@operator
|
|
111
|
+
class EmptyOperator(Operator):
|
|
112
|
+
"""Empty operator: does nothing"""
|
|
113
|
+
|
|
114
|
+
affine = staticmethod(maps.identity)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@operator
|
|
118
|
+
class Wait(EmptyOperator):
|
|
119
|
+
"""Empty operator with given duration"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, duration, *, name=None):
|
|
122
|
+
super().__init__(duration=duration, name=name)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@operator
|
|
126
|
+
class Spoiler(Operator):
|
|
127
|
+
"""Perfect spoiler: destroy transverse magnetization
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
axis: None (all transverse states), or a dephasing axis (`DephasingAxis`
|
|
131
|
+
or name, e.g. "diff"): keep the states of index 0 along it (F and
|
|
132
|
+
Z), i.e. remove the states dephased along that axis only
|
|
133
|
+
cf. Operator for other keyword arguments
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
direction: str = static()
|
|
137
|
+
axis: DephasingAxis = None
|
|
138
|
+
|
|
139
|
+
def __init__(self, axis=None, *, duration=None, name=None):
|
|
140
|
+
super().__init__(duration=duration, name=name)
|
|
141
|
+
direction = axis.name if isinstance(axis, DephasingAxis) else axis
|
|
142
|
+
axis = axis if isinstance(axis, DephasingAxis) else None
|
|
143
|
+
if direction is not None and not isinstance(direction, str):
|
|
144
|
+
raise TypeError(f"Invalid dephasing axis: {direction!r}")
|
|
145
|
+
self._set(direction=direction, axis=axis)
|
|
146
|
+
|
|
147
|
+
def __repr__(self):
|
|
148
|
+
if self.name:
|
|
149
|
+
return self.name
|
|
150
|
+
args = [] if self.direction is None else [f"axis={self.direction!r}"]
|
|
151
|
+
return f"Spoiler({', '.join(args + self._repr_duration())})"
|
|
152
|
+
|
|
153
|
+
def normalize(self, sample=None, scanner=None):
|
|
154
|
+
return {} if self.direction is None else {"direction": self.direction}
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def kernel(state, direction=None):
|
|
158
|
+
if direction is None:
|
|
159
|
+
return kernels.spoil(state)
|
|
160
|
+
return kernels.spoil_axis(state, direction)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def affine(self):
|
|
164
|
+
# spoiling along one axis depends on the states: not an affine map
|
|
165
|
+
return maps.spoil_matrix if self.direction is None else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@operator
|
|
169
|
+
class Reset(Operator):
|
|
170
|
+
"""Reset magnetization (return to equilibrium)"""
|
|
171
|
+
|
|
172
|
+
kernel = staticmethod(kernels.reset)
|
|
173
|
+
affine = staticmethod(maps.reset_matrix)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
#
|
|
177
|
+
# functions
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def asparam(value):
|
|
181
|
+
"""convert operator parameter to array (None and Axis are kept)"""
|
|
182
|
+
if value is None or is_axis(value):
|
|
183
|
+
return value
|
|
184
|
+
return jnp.asarray(value)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def total(*values):
|
|
188
|
+
"""sum of values, ignoring None (None if all are None)"""
|
|
189
|
+
values = [value for value in values if value is not None]
|
|
190
|
+
if not values:
|
|
191
|
+
return None
|
|
192
|
+
return sum(values[1:], start=values[0])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def check_conflicts(op, sample):
|
|
196
|
+
"""raise if properties are given both by the operator and by the sample"""
|
|
197
|
+
conflicts = op.sample_conflicts(sample)
|
|
198
|
+
if conflicts:
|
|
199
|
+
names = ", ".join(conflicts)
|
|
200
|
+
raise ValueError(
|
|
201
|
+
f"{names} given both by {op!r} and by the sample: remove {names} "
|
|
202
|
+
"from the operator (sample values) or from the sample (operator values)"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# default duration, shared by all operators: a single leaf (constant of the
|
|
207
|
+
# scans, one argument of the compiled program), never stacked
|
|
208
|
+
ZERO_DURATION = jnp.asarray(0.0)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _duration(duration):
|
|
212
|
+
"""check and convert duration (None: shared ZERO_DURATION)"""
|
|
213
|
+
if duration is None:
|
|
214
|
+
return ZERO_DURATION
|
|
215
|
+
values = duration.values if is_axis(duration) else duration
|
|
216
|
+
if not isinstance(values, jax.core.Tracer) and np.any(np.asarray(values) < 0):
|
|
217
|
+
raise ValueError(f"Cannot have duration < 0: {duration}")
|
|
218
|
+
return asparam(duration)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# pre-instanciated operators
|
|
222
|
+
NULL = EmptyOperator(name="NULL")
|
|
223
|
+
SPOILER = Spoiler(name="Spoiler")
|
|
224
|
+
RESET = Reset(name="Reset")
|
epgx/ops/probe.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Probe operators (signal acquisition)
|
|
2
|
+
|
|
3
|
+
ADC phase follows the scanner convention: the ADC phase equals the phase of the
|
|
4
|
+
RF pulse to compensate (`Adc(phase=phi)` after `T(alpha, phi)`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import jax
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .. import kernels
|
|
11
|
+
from .operator import Operator, asparam, operator, static
|
|
12
|
+
|
|
13
|
+
DEG = np.pi / 180
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@operator
|
|
17
|
+
class Probe(Operator):
|
|
18
|
+
"""Base probe operator: leaves the state unchanged, acquires a value"""
|
|
19
|
+
|
|
20
|
+
def acquire(self, state, sample=None, scanner=None):
|
|
21
|
+
"""acquired value (to override)"""
|
|
22
|
+
raise NotImplementedError()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@operator
|
|
26
|
+
class Adc(Probe):
|
|
27
|
+
"""ADC: acquire a state attribute, with weights, reduction and phase
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
attr: probed attribute ("F0", "Z0", "F", "Fminus", "Z", "states")
|
|
31
|
+
phase: ADC phase (degree), scanner convention, applied after reduction
|
|
32
|
+
reduce: None/False (no reduction), True (all batch axes),
|
|
33
|
+
axis name (str), positional axis (int), or tuple of these
|
|
34
|
+
An int i refers to the unnamed axis dim_i (whatever its position
|
|
35
|
+
in the layout); named axes are reduced by name only.
|
|
36
|
+
If `weights` is set and `reduce` is None: sum along the weights' axes
|
|
37
|
+
weights: weights (Axis or unnamed array) broadcast with the batch axes
|
|
38
|
+
cf. Operator for other keyword arguments
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
attr: str = static("F0")
|
|
42
|
+
reduce: object = static()
|
|
43
|
+
phase: jax.Array = None
|
|
44
|
+
weights: jax.Array = None
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
attr="F0",
|
|
49
|
+
*,
|
|
50
|
+
phase=None,
|
|
51
|
+
reduce=None,
|
|
52
|
+
weights=None,
|
|
53
|
+
duration=None,
|
|
54
|
+
name=None,
|
|
55
|
+
):
|
|
56
|
+
if attr not in kernels.probe.ATTRIBUTES:
|
|
57
|
+
raise ValueError(f"Invalid attribute: {attr}")
|
|
58
|
+
if isinstance(reduce, list):
|
|
59
|
+
reduce = tuple(reduce) # static metadata must be hashable
|
|
60
|
+
super().__init__(duration=duration, name=name)
|
|
61
|
+
self._set(
|
|
62
|
+
attr=attr, reduce=reduce, phase=asparam(phase), weights=asparam(weights)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def resolve_axes(self, layout):
|
|
66
|
+
"""convert `reduce` to positions in the batch layout (cf. axes.resolve)"""
|
|
67
|
+
reduce = self.reduce
|
|
68
|
+
if reduce is None and self.weights is not None:
|
|
69
|
+
# weights' axes (named, or positional given the alignment)
|
|
70
|
+
reduce = layout.names_of(self.weights)
|
|
71
|
+
if reduce is None or isinstance(reduce, bool):
|
|
72
|
+
return self
|
|
73
|
+
reduce = (reduce,) if isinstance(reduce, (str, int)) else tuple(reduce)
|
|
74
|
+
return self.replace(reduce=tuple(sorted(layout.index(ax) for ax in reduce)))
|
|
75
|
+
|
|
76
|
+
def acquire(self, state, sample=None, scanner=None):
|
|
77
|
+
phase = None if self.phase is None else self.phase * DEG
|
|
78
|
+
return kernels.adc(
|
|
79
|
+
state, self.attr, phase=phase, weights=self.weights, axes=self.reduce
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@operator
|
|
84
|
+
class Probes(Probe):
|
|
85
|
+
"""Several probes acquired at the same time (acquired value: tuple)
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
probes: sequence of Probe operators
|
|
89
|
+
cf. Operator for other keyword arguments
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
probes: tuple = None
|
|
93
|
+
|
|
94
|
+
def __init__(self, probes, *, duration=None, name=None):
|
|
95
|
+
probes = tuple(probes)
|
|
96
|
+
if not all(isinstance(probe, Probe) for probe in probes):
|
|
97
|
+
raise TypeError(f"Expected Probe operators: {probes}")
|
|
98
|
+
super().__init__(duration=duration, name=name)
|
|
99
|
+
self._set(probes=probes)
|
|
100
|
+
|
|
101
|
+
def __repr__(self):
|
|
102
|
+
return self.name or f"Probes({', '.join(map(repr, self.probes))})"
|
|
103
|
+
|
|
104
|
+
def resolve_axes(self, layout):
|
|
105
|
+
probes = tuple(
|
|
106
|
+
probe.resolve_axes(layout) if hasattr(probe, "resolve_axes") else probe
|
|
107
|
+
for probe in self.probes
|
|
108
|
+
)
|
|
109
|
+
return self.replace(probes=probes)
|
|
110
|
+
|
|
111
|
+
def acquire(self, state, sample=None, scanner=None):
|
|
112
|
+
return tuple(probe.acquire(state, sample, scanner) for probe in self.probes)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ADC instance (returns F0)
|
|
116
|
+
ADC = Adc(name="ADC")
|
epgx/ops/shift.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Shift operators (gradient dephasing)"""
|
|
2
|
+
|
|
3
|
+
import numbers
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .. import kernels
|
|
8
|
+
from .dephasing import SPACE_AXES, DephasingAxis
|
|
9
|
+
from .operator import Operator, operator, static
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@operator
|
|
13
|
+
class S(Operator):
|
|
14
|
+
"""Shift operator (dephasing by k cycles across the dephasing period)
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
k: static number (1d) or tuple of up to 3 numbers (x, y, z), non zero.
|
|
18
|
+
A number shifts along x; missing components are 0.
|
|
19
|
+
Integer shifts are supported by all methods (e.g. Lattice),
|
|
20
|
+
non-integer shifts by some methods only (e.g. Isochromats).
|
|
21
|
+
axis: dephasing axis of a scalar shift: a `DephasingAxis` (e.g.
|
|
22
|
+
`kAxis("diff", kvalue=...)`) or its name; default: "x" (and "y",
|
|
23
|
+
"z" for vector shifts). Different axes do not interact.
|
|
24
|
+
cf. Operator for other keyword arguments
|
|
25
|
+
|
|
26
|
+
The kernel depends on the representation of the state
|
|
27
|
+
(`State.representation`): `shift_lattice` (phase states, "lattice"),
|
|
28
|
+
`shift_samples` (isochromats, "isochromats").
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
k: int = static()
|
|
32
|
+
direction: str = static()
|
|
33
|
+
axis: DephasingAxis = None
|
|
34
|
+
|
|
35
|
+
def __init__(self, k, *, axis=None, duration=None, name=None):
|
|
36
|
+
super().__init__(duration=duration, name=name)
|
|
37
|
+
k = _check_shift(k)
|
|
38
|
+
direction = None
|
|
39
|
+
if axis is not None:
|
|
40
|
+
if isinstance(k, tuple):
|
|
41
|
+
raise ValueError(f"A shift along an axis is a number, got: {k}")
|
|
42
|
+
elif isinstance(axis, str):
|
|
43
|
+
direction, axis = axis, None
|
|
44
|
+
elif isinstance(axis, DephasingAxis):
|
|
45
|
+
if axis.kind != "space":
|
|
46
|
+
raise NotImplementedError(
|
|
47
|
+
f"Shifts along time axes ({axis!r}): not implemented yet"
|
|
48
|
+
)
|
|
49
|
+
direction = axis.name
|
|
50
|
+
else:
|
|
51
|
+
raise TypeError(f"Invalid dephasing axis: {axis!r}")
|
|
52
|
+
self._set(k=k, direction=direction, axis=axis)
|
|
53
|
+
|
|
54
|
+
def __repr__(self):
|
|
55
|
+
if self.name:
|
|
56
|
+
return self.name
|
|
57
|
+
axis = [] if self.direction is None else [f"axis={self.direction!r}"]
|
|
58
|
+
return f"S({', '.join([repr(self.k)] + axis + self._repr_duration())})"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def nshift(self):
|
|
62
|
+
"""number of phase-state shifts (largest component for nd shifts)"""
|
|
63
|
+
return max(abs(s) for s in self.components)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def integer(self):
|
|
67
|
+
"""True if all shift components are integers"""
|
|
68
|
+
return all(isinstance(s, int) for s in self.components)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def components(self):
|
|
72
|
+
"""shift components as a tuple"""
|
|
73
|
+
return self.k if isinstance(self.k, tuple) else (self.k,)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def shifts(self):
|
|
77
|
+
"""shift components by dephasing direction (dict)"""
|
|
78
|
+
if self.direction is not None:
|
|
79
|
+
return {self.direction: self.k}
|
|
80
|
+
return dict(zip(SPACE_AXES, self.components))
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def nd(self):
|
|
84
|
+
"""True if the shift needs nd storage (vector, or a named axis)"""
|
|
85
|
+
return isinstance(self.k, tuple) or self.direction not in (None, "x")
|
|
86
|
+
|
|
87
|
+
def normalize(self, sample=None, scanner=None):
|
|
88
|
+
return {"k": self.shifts}
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def kernel(state, k):
|
|
92
|
+
"""shift kernel for the representation of the state"""
|
|
93
|
+
if state.representation == "lattice":
|
|
94
|
+
return kernels.shift_lattice(state, k)
|
|
95
|
+
elif state.representation == "isochromats":
|
|
96
|
+
return kernels.shift_samples(state, k)
|
|
97
|
+
raise ValueError(f"No shift kernel for representation: {state.representation}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
#
|
|
101
|
+
# functions
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _number(s):
|
|
105
|
+
"""static number: int if integer, else float (bool and arrays are rejected)"""
|
|
106
|
+
if isinstance(s, bool) or not isinstance(s, numbers.Real):
|
|
107
|
+
raise TypeError(f"Shifts must be static numbers, got: {s!r}")
|
|
108
|
+
return int(s) if float(s).is_integer() else float(s)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check_shift(k):
|
|
112
|
+
"""check and convert shift value (static number or tuple of numbers)"""
|
|
113
|
+
k = tuple(_number(s) for s in k) if isinstance(k, (tuple, list)) else _number(k)
|
|
114
|
+
if isinstance(k, tuple) and not 1 <= len(k) <= 3:
|
|
115
|
+
raise ValueError(f"Shifts have 1 to 3 components (x, y, z), got: {k}")
|
|
116
|
+
if np.all(np.asarray(k) == 0):
|
|
117
|
+
raise ValueError("Cannot have k == 0")
|
|
118
|
+
return k
|
epgx/ops/transition.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Transition operators (instantaneous RF pulses)"""
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .. import kernels
|
|
7
|
+
from ..kernels import affine as maps
|
|
8
|
+
from .operator import Operator, asparam, operator
|
|
9
|
+
|
|
10
|
+
DEG = np.pi / 180
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@operator
|
|
14
|
+
class T(Operator):
|
|
15
|
+
"""Transition operator (instantaneous RF pulse)
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
alpha: flip angle (degree)
|
|
19
|
+
phi: phase (degree)
|
|
20
|
+
cf. Operator for other keyword arguments
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
alpha: jax.Array = None
|
|
24
|
+
phi: jax.Array = None
|
|
25
|
+
|
|
26
|
+
def __init__(self, alpha, phi, *, duration=None, name=None):
|
|
27
|
+
super().__init__(duration=duration, name=name)
|
|
28
|
+
self._set(alpha=asparam(alpha), phi=asparam(phi))
|
|
29
|
+
|
|
30
|
+
def normalize(self, sample=None, scanner=None):
|
|
31
|
+
# B1 scaling of the flip angle
|
|
32
|
+
B1 = 1.0 if scanner is None else scanner.B1
|
|
33
|
+
return {"alpha": self.alpha * B1 * DEG, "phi": self.phi * DEG}
|
|
34
|
+
|
|
35
|
+
kernel = staticmethod(kernels.rotate)
|
|
36
|
+
affine = staticmethod(maps.rotation_matrix)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@operator
|
|
40
|
+
class Phi(Operator):
|
|
41
|
+
"""Phase offset (z-axis rotation)
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
phi: phase (degree)
|
|
45
|
+
cf. Operator for other keyword arguments
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
phi: jax.Array = None
|
|
49
|
+
|
|
50
|
+
def __init__(self, phi, *, duration=None, name=None):
|
|
51
|
+
super().__init__(duration=duration, name=name)
|
|
52
|
+
self._set(phi=asparam(phi))
|
|
53
|
+
|
|
54
|
+
def normalize(self, sample=None, scanner=None):
|
|
55
|
+
return {"phi": self.phi * DEG}
|
|
56
|
+
|
|
57
|
+
kernel = staticmethod(kernels.phase)
|
|
58
|
+
affine = staticmethod(maps.phase_matrix)
|