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