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/plotting.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""EPG plots / sequence diagrams
|
|
2
|
+
|
|
3
|
+
The data (`functions.trace`) is separated from the rendering (`plot_epg`):
|
|
4
|
+
rendering never simulates a `Trace`. Requires the `plot` extra (matplotlib,
|
|
5
|
+
cmocean).
|
|
6
|
+
|
|
7
|
+
Rendering:
|
|
8
|
+
- all operators are applied
|
|
9
|
+
- F and Z lines are drawn during every operator with a duration
|
|
10
|
+
- F lines are coloured by their phase (cmocean's cyclic `phase` colormap)
|
|
11
|
+
- echoes are marked and labelled by their amplitude
|
|
12
|
+
- nd lattices (kgrid, yaxis) are not supported yet
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import cmocean
|
|
16
|
+
import numpy as np
|
|
17
|
+
from matplotlib import colors, gridspec, ticker
|
|
18
|
+
from matplotlib import pyplot as plt
|
|
19
|
+
from matplotlib.collections import LineCollection
|
|
20
|
+
|
|
21
|
+
from . import ops
|
|
22
|
+
from .functions import Trace, trace
|
|
23
|
+
|
|
24
|
+
# phase colormap: cyclic, nearly constant lightness (faint states remain
|
|
25
|
+
# visible), cf. Thyng et al. Oceanography 2016;29:9-13. doi:10.5670/oceanog.2016.66
|
|
26
|
+
PHASE_CMAP = cmocean.cm.phase
|
|
27
|
+
ECHOES = ("adc", "all", None)
|
|
28
|
+
# keyword arguments of plot_epg (not passed to a parametrized sequence)
|
|
29
|
+
PLOT_ARGUMENTS = (
|
|
30
|
+
"title",
|
|
31
|
+
"figname",
|
|
32
|
+
"calpha",
|
|
33
|
+
"cwidth",
|
|
34
|
+
"echoes",
|
|
35
|
+
"threshold",
|
|
36
|
+
"echo_threshold",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def show():
|
|
41
|
+
"""shortcut for plt.show"""
|
|
42
|
+
plt.show()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def plot_epg(
|
|
46
|
+
seq,
|
|
47
|
+
sample=None,
|
|
48
|
+
scanner=None,
|
|
49
|
+
*,
|
|
50
|
+
title=None,
|
|
51
|
+
figname=None,
|
|
52
|
+
calpha=0.5,
|
|
53
|
+
cwidth=0,
|
|
54
|
+
echoes="adc",
|
|
55
|
+
threshold=1e-5,
|
|
56
|
+
echo_threshold=1e-3,
|
|
57
|
+
**options,
|
|
58
|
+
):
|
|
59
|
+
"""plot RF/gradients timeline and EPG diagram
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
seq: Trace, sequence (traced with `sample`, `scanner` and `options`,
|
|
63
|
+
cf. functions.trace, e.g. `select={"T2": 1}`), or parametrized
|
|
64
|
+
sequence (`@sequence`): `options` then also hold the arguments of
|
|
65
|
+
its function, cf. `Sequence.trace` (e.g.
|
|
66
|
+
`plot_epg(mse, esp=10.0, necho=4)`)
|
|
67
|
+
title, figname: title and name of the figure
|
|
68
|
+
calpha, cwidth: power of the state magnitude for the opacity and the
|
|
69
|
+
width of the lines
|
|
70
|
+
echoes: mark the amplitude of F0 at the ADCs ("adc"), at all operators
|
|
71
|
+
("all"), or not at all (None)
|
|
72
|
+
threshold: states of smaller magnitude are not drawn
|
|
73
|
+
echo_threshold: echoes of smaller amplitude are not marked
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
matplotlib Figure
|
|
77
|
+
"""
|
|
78
|
+
if isinstance(seq, Trace):
|
|
79
|
+
data = seq
|
|
80
|
+
elif hasattr(seq, "trace"):
|
|
81
|
+
# parametrized sequence (layer 3): arguments of its function in options
|
|
82
|
+
shared = set(PLOT_ARGUMENTS) & set(seq.signature.parameters)
|
|
83
|
+
if shared:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Argument(s) {sorted(shared)} of {seq!r} shadowed by plot_epg's "
|
|
86
|
+
"own arguments: trace the sequence first (`seq.trace(...)`)"
|
|
87
|
+
)
|
|
88
|
+
data = seq.trace(sample=sample, scanner=scanner, **options)
|
|
89
|
+
else:
|
|
90
|
+
data = trace(seq, sample, scanner, **options)
|
|
91
|
+
if data.representation != "lattice":
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"EPG diagrams require phase states, got: {data.representation}"
|
|
94
|
+
)
|
|
95
|
+
elif not isinstance(data.axis, str):
|
|
96
|
+
raise NotImplementedError(
|
|
97
|
+
f"EPG diagrams of nd lattices (axes {data.axis}) are not supported yet"
|
|
98
|
+
)
|
|
99
|
+
if echoes not in ECHOES:
|
|
100
|
+
raise ValueError(f"Invalid echoes: {echoes!r}, expected one of {ECHOES}")
|
|
101
|
+
|
|
102
|
+
F, Z = np.asarray(data.F), np.asarray(data.Z)
|
|
103
|
+
times, k = np.asarray(data.times, dtype=float), np.asarray(data.coords)
|
|
104
|
+
operators = data.operators
|
|
105
|
+
center = k.size // 2
|
|
106
|
+
|
|
107
|
+
fig = plt.figure(figname, figsize=(8, 6))
|
|
108
|
+
grid = gridspec.GridSpec(3, 1, figure=fig, height_ratios=(1, 1, 8))
|
|
109
|
+
ax_rf = fig.add_subplot(grid[0, 0])
|
|
110
|
+
ax_grad = fig.add_subplot(grid[1, 0], sharex=ax_rf)
|
|
111
|
+
ax_epg = fig.add_subplot(grid[2, 0], sharex=ax_rf)
|
|
112
|
+
|
|
113
|
+
# state lines
|
|
114
|
+
fsegs, fcolors, fwidths = [], [], []
|
|
115
|
+
zsegs, zcolors, zwidths = [], [], []
|
|
116
|
+
cmap = PHASE_CMAP
|
|
117
|
+
norm = colors.Normalize(-180, 180)
|
|
118
|
+
for i, op in enumerate(operators):
|
|
119
|
+
start, end = times[i], times[i + 1]
|
|
120
|
+
if end <= start:
|
|
121
|
+
continue
|
|
122
|
+
shift = op.k if isinstance(op, ops.S) else 0
|
|
123
|
+
fmag, zmag = np.minimum(np.abs(F[i + 1]), 1), np.minimum(np.abs(Z[i + 1]), 1)
|
|
124
|
+
for j in np.flatnonzero(fmag > threshold):
|
|
125
|
+
fsegs.append([(start, k[j] - shift), (end, k[j])])
|
|
126
|
+
fcolors.append(cmap(norm(np.angle(F[i + 1, j], deg=True)))[:3])
|
|
127
|
+
fwidths.append(fmag[j])
|
|
128
|
+
# Z(-k) = conj(Z(k)): positive states only
|
|
129
|
+
for j in np.flatnonzero((zmag > threshold) & (k >= 0)):
|
|
130
|
+
zsegs.append([(start, k[j]), (end, k[j])])
|
|
131
|
+
zcolors.append((0, 0, 0))
|
|
132
|
+
zwidths.append(zmag[j])
|
|
133
|
+
for label, segs, rgb, mags, style in [
|
|
134
|
+
("F", fsegs, fcolors, fwidths, "-"),
|
|
135
|
+
("Z", zsegs, zcolors, zwidths, ":"),
|
|
136
|
+
]:
|
|
137
|
+
mags = np.asarray(mags, dtype=float)
|
|
138
|
+
rgba = np.concatenate(
|
|
139
|
+
[np.reshape(rgb, (-1, 3)), mags[:, None] ** calpha], axis=1
|
|
140
|
+
)
|
|
141
|
+
lines = LineCollection(
|
|
142
|
+
segs, colors=rgba, linewidths=2 * mags**cwidth, linestyles=style
|
|
143
|
+
)
|
|
144
|
+
lines.set_label(label)
|
|
145
|
+
ax_epg.add_collection(lines)
|
|
146
|
+
|
|
147
|
+
# operators: RF pulses, gradients, ADCs and echoes
|
|
148
|
+
kmax = max([1] + [abs(y) for seg in fsegs for _, y in seg])
|
|
149
|
+
for i, op in enumerate(operators):
|
|
150
|
+
start, end = times[i], times[i + 1]
|
|
151
|
+
if isinstance(op, ops.T):
|
|
152
|
+
alpha, phi = float(np.ravel(op.alpha)[0]), float(np.ravel(op.phi)[0])
|
|
153
|
+
ax_rf.vlines(end, 0, alpha, color="k")
|
|
154
|
+
va = "bottom" if alpha > 0 else "top"
|
|
155
|
+
ax_rf.annotate(
|
|
156
|
+
f"{alpha:.0f}°", (end, alpha + 2 * np.sign(alpha)), va=va, ha="center"
|
|
157
|
+
)
|
|
158
|
+
if not np.isclose(phi, 0):
|
|
159
|
+
# opposite side of the flip angle
|
|
160
|
+
ax_rf.annotate(
|
|
161
|
+
f"φ={phi:.0f}°",
|
|
162
|
+
(end, 0),
|
|
163
|
+
va="top" if alpha >= 0 else "bottom",
|
|
164
|
+
ha="center",
|
|
165
|
+
fontsize=8,
|
|
166
|
+
)
|
|
167
|
+
ax_epg.scatter(
|
|
168
|
+
end, 0, marker="o", color="gray", facecolors="white", zorder=10
|
|
169
|
+
)
|
|
170
|
+
ax_epg.axvline(end, linestyle=":", color="gray", alpha=0.5)
|
|
171
|
+
elif isinstance(op, ops.S):
|
|
172
|
+
ax_grad.fill_between([start, end], [op.k] * 2, color="gray", alpha=0.3)
|
|
173
|
+
elif isinstance(op, ops.Probe):
|
|
174
|
+
ax_epg.scatter(end, 0, marker="v", color="gray", zorder=10)
|
|
175
|
+
|
|
176
|
+
# "all": states at the end of each time point (once per time)
|
|
177
|
+
last = i + 2 == times.size or times[i + 2] > end
|
|
178
|
+
if (echoes == "all" and last) or (
|
|
179
|
+
echoes == "adc" and isinstance(op, ops.Probe)
|
|
180
|
+
):
|
|
181
|
+
amplitude = np.abs(F[i + 1, center])
|
|
182
|
+
if amplitude > echo_threshold:
|
|
183
|
+
ax_epg.annotate(
|
|
184
|
+
f"{amplitude:.2f}",
|
|
185
|
+
(end, 0),
|
|
186
|
+
xytext=(0, 6),
|
|
187
|
+
textcoords="offset points",
|
|
188
|
+
ha="center",
|
|
189
|
+
va="bottom",
|
|
190
|
+
fontsize=8,
|
|
191
|
+
color="tab:red",
|
|
192
|
+
zorder=11,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# axes
|
|
196
|
+
margin = 3e-2 * max(times[-1], 1e-9)
|
|
197
|
+
ax_epg.set_xlim(times[0] - margin, times[-1] + margin)
|
|
198
|
+
ax_epg.set_ylim(-1.05 * kmax, 1.05 * kmax)
|
|
199
|
+
ax_epg.yaxis.set_major_locator(ticker.MaxNLocator(integer=True))
|
|
200
|
+
ax_epg.set_ylabel(data.axis)
|
|
201
|
+
ax_epg.set_xlabel("time (ms)")
|
|
202
|
+
ax_epg.axhline(0, color="k", zorder=-1)
|
|
203
|
+
# phase colorbar (inset: the time axes stay aligned)
|
|
204
|
+
inset = ax_epg.inset_axes([0.015, 0.72, 0.012, 0.25])
|
|
205
|
+
colorbar = fig.colorbar(plt.cm.ScalarMappable(norm=norm, cmap=cmap), cax=inset)
|
|
206
|
+
colorbar.set_ticks([-180, 0, 180])
|
|
207
|
+
colorbar.ax.tick_params(labelsize=8)
|
|
208
|
+
colorbar.set_label("phase (°)", fontsize=8)
|
|
209
|
+
|
|
210
|
+
ax_rf.set_ylim(-180, 180)
|
|
211
|
+
for ax, label in [(ax_rf, "Rf"), (ax_grad, "G")]:
|
|
212
|
+
ax.axhline(0, color="k")
|
|
213
|
+
ax.annotate(
|
|
214
|
+
label,
|
|
215
|
+
xy=(-1e-2, 0.5),
|
|
216
|
+
ha="right",
|
|
217
|
+
va="center",
|
|
218
|
+
xycoords="axes fraction",
|
|
219
|
+
weight="bold",
|
|
220
|
+
)
|
|
221
|
+
ax.axis("off")
|
|
222
|
+
|
|
223
|
+
if title:
|
|
224
|
+
fig.suptitle(title)
|
|
225
|
+
fig.tight_layout()
|
|
226
|
+
return fig
|
epgx/progress.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Progress display of simulations (`simulate(..., disp=True)`)
|
|
2
|
+
|
|
3
|
+
The operators run in compiled code (lax.scan, jit), so progress is reported
|
|
4
|
+
from the program with `jax.debug.callback`: once per top-level unrolled
|
|
5
|
+
segment and per iteration of top-level scans (cf. compiler monitor). Each
|
|
6
|
+
callback takes a scalar of the state, so that it follows the computation.
|
|
7
|
+
|
|
8
|
+
The host-side `Progress` opens a bar on the first tick of a run and closes it
|
|
9
|
+
when the total number of operators is reached: a cached compiled program shows
|
|
10
|
+
one bar per call. Display: tqdm if installed (e.g. notebooks), else a text bar.
|
|
11
|
+
|
|
12
|
+
Limits: compilation is not shown; under `grad`, only the forward pass ticks;
|
|
13
|
+
under `vmap`, the callbacks run for each element.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
import jax
|
|
20
|
+
import jax.numpy as jnp
|
|
21
|
+
|
|
22
|
+
DESCRIPTION = "Simulating"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Progress:
|
|
26
|
+
"""host-side progress of a run of `total` operators"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, total, description=DESCRIPTION):
|
|
29
|
+
self.total = total
|
|
30
|
+
self.description = description
|
|
31
|
+
self.done = 0
|
|
32
|
+
self._bar = None
|
|
33
|
+
|
|
34
|
+
def tick(self, count, *_):
|
|
35
|
+
"""`count` more operators done (callback from the compiled program)"""
|
|
36
|
+
if self._bar is None:
|
|
37
|
+
self._bar = make_bar(self.total, self.description)
|
|
38
|
+
self._bar.update(count)
|
|
39
|
+
self.done += count
|
|
40
|
+
if self.done >= self.total:
|
|
41
|
+
self._bar.close()
|
|
42
|
+
self._bar, self.done = None, 0
|
|
43
|
+
|
|
44
|
+
def monitor(self, state, count):
|
|
45
|
+
"""compiler monitor: tick from the program (cf. compiler.Program.run)"""
|
|
46
|
+
# data dependency on the state: the tick follows the computation
|
|
47
|
+
dependency = jnp.real(jnp.ravel(state.F)[0])
|
|
48
|
+
jax.debug.callback(functools.partial(self.tick, count), dependency)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def make_bar(total, description):
|
|
52
|
+
"""tqdm bar if available, else text bar (same update/close interface)"""
|
|
53
|
+
try:
|
|
54
|
+
from tqdm.auto import tqdm
|
|
55
|
+
except ImportError:
|
|
56
|
+
return TextBar(total, description)
|
|
57
|
+
return tqdm(total=total, desc=description, unit="op")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TextBar:
|
|
61
|
+
"""text progress bar"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, total, description="", size=40, file=None):
|
|
64
|
+
self.total, self.description, self.size = total, description, size
|
|
65
|
+
self.file = file or sys.stdout
|
|
66
|
+
self.done = 0
|
|
67
|
+
self._show()
|
|
68
|
+
|
|
69
|
+
def update(self, count):
|
|
70
|
+
self.done = min(self.done + count, self.total)
|
|
71
|
+
self._show()
|
|
72
|
+
|
|
73
|
+
def close(self):
|
|
74
|
+
self.file.write("\n")
|
|
75
|
+
self.file.flush()
|
|
76
|
+
|
|
77
|
+
def _show(self):
|
|
78
|
+
filled = int(self.size * self.done / max(self.total, 1))
|
|
79
|
+
bar = "#" * filled + "." * (self.size - filled)
|
|
80
|
+
self.file.write(f"\r{self.description}: [{bar}] {self.done}/{self.total}")
|
|
81
|
+
self.file.flush()
|
epgx/sample.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Sample (tissue properties) and Scanner (hardware properties)
|
|
2
|
+
|
|
3
|
+
Field values are scalars, Axis (named axes) or unnamed arrays. In Sample and
|
|
4
|
+
Scanner, 1d arrays are automatically named after their field, e.g.
|
|
5
|
+
`Sample(T2=[30, 40, 50])` defines the axis "T2" (grid of values).
|
|
6
|
+
|
|
7
|
+
Units: ms (T1, T2), kHz (df, B0), relative (PD, B1).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import jax
|
|
11
|
+
import jax.numpy as jnp
|
|
12
|
+
|
|
13
|
+
from .axes import Axis, is_axis
|
|
14
|
+
from .common import pytree, repr_value, static
|
|
15
|
+
|
|
16
|
+
COMPARTMENT = "compartment"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _field(name, value):
|
|
20
|
+
"""convert field value: None, scalar, Axis, or array (1d: named after field)"""
|
|
21
|
+
if value is None or is_axis(value):
|
|
22
|
+
return value
|
|
23
|
+
value = jnp.asarray(value)
|
|
24
|
+
if value.ndim == 1:
|
|
25
|
+
return Axis(name, value)
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytree
|
|
30
|
+
class Sample:
|
|
31
|
+
"""Tissue properties
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
PD: proton density (or compartment fraction)
|
|
35
|
+
T1: longitudinal relaxation time (ms), None: no T1 relaxation
|
|
36
|
+
T2: transverse relaxation time (ms), None: no T2 relaxation
|
|
37
|
+
df: frequency offset (kHz), e.g. chemical shift
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
compartments: (static) compartment labels (cf. `Sample.compartments`)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
PD: jax.Array = None
|
|
44
|
+
T1: jax.Array = None
|
|
45
|
+
T2: jax.Array = None
|
|
46
|
+
df: jax.Array = None
|
|
47
|
+
compartments: tuple = static()
|
|
48
|
+
|
|
49
|
+
FIELDS = ("PD", "T1", "T2", "df")
|
|
50
|
+
|
|
51
|
+
def __init__(self, PD=1.0, T1=None, T2=None, df=None):
|
|
52
|
+
for name, value in zip(self.FIELDS, (PD, T1, T2, df)):
|
|
53
|
+
object.__setattr__(self, name, _field(name, value))
|
|
54
|
+
object.__setattr__(self, "compartments", None)
|
|
55
|
+
|
|
56
|
+
def __repr__(self):
|
|
57
|
+
fields = [
|
|
58
|
+
f"{name}={repr_value(getattr(self, name))}"
|
|
59
|
+
for name in self.FIELDS
|
|
60
|
+
if getattr(self, name) is not None
|
|
61
|
+
]
|
|
62
|
+
if self.compartments:
|
|
63
|
+
fields.append(f"compartments={self.compartments}")
|
|
64
|
+
return f"Sample({', '.join(fields)})"
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_compartments(cls, **samples):
|
|
68
|
+
"""multi-compartment sample, stacked along the "compartment" axis
|
|
69
|
+
|
|
70
|
+
PD values are the compartment fractions. Field values must be scalars or
|
|
71
|
+
Axis (named). Fields unset in some compartments only take neutral values
|
|
72
|
+
(T1, T2: infinite, df: 0).
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
Sample.from_compartments(
|
|
76
|
+
water=Sample(PD=0.7, T1=1000, T2=30),
|
|
77
|
+
fat=Sample(PD=0.3, T1=300, T2=80, df=-0.42),
|
|
78
|
+
)
|
|
79
|
+
"""
|
|
80
|
+
if not samples:
|
|
81
|
+
raise ValueError("At least one compartment is required")
|
|
82
|
+
labels = tuple(samples)
|
|
83
|
+
new = object.__new__(cls)
|
|
84
|
+
for name in cls.FIELDS:
|
|
85
|
+
values = [getattr(sample, name) for sample in samples.values()]
|
|
86
|
+
object.__setattr__(new, name, _stack(name, values))
|
|
87
|
+
object.__setattr__(new, "compartments", labels)
|
|
88
|
+
return new
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytree
|
|
92
|
+
class Scanner:
|
|
93
|
+
"""Hardware properties
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
B1: relative transmit field (scales flip angles)
|
|
97
|
+
B0: frequency offset (kHz)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
B1: jax.Array = None
|
|
101
|
+
B0: jax.Array = None
|
|
102
|
+
|
|
103
|
+
def __init__(self, B1=1.0, B0=0.0):
|
|
104
|
+
object.__setattr__(self, "B1", _field("B1", B1))
|
|
105
|
+
object.__setattr__(self, "B0", _field("B0", B0))
|
|
106
|
+
|
|
107
|
+
def __repr__(self):
|
|
108
|
+
return f"Scanner(B1={repr_value(self.B1)}, B0={repr_value(self.B0)})"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
#
|
|
112
|
+
# functions
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# neutral values of unset fields, when other compartments set them
|
|
116
|
+
NEUTRAL = {"T1": jnp.inf, "T2": jnp.inf, "df": 0.0}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _stack(name, values):
|
|
120
|
+
"""stack field values of several compartments along the compartment axis"""
|
|
121
|
+
if all(value is None for value in values):
|
|
122
|
+
return None
|
|
123
|
+
values = [NEUTRAL[name] if value is None else value for value in values]
|
|
124
|
+
if any(not is_axis(v) and jnp.ndim(v) > 0 for v in values):
|
|
125
|
+
raise ValueError(f"Field {name}: compartment values must be scalars or Axis")
|
|
126
|
+
|
|
127
|
+
# union of named axes (by first appearance) and their sizes
|
|
128
|
+
sizes = {}
|
|
129
|
+
for value in filter(is_axis, values):
|
|
130
|
+
for axis, size in value.sizes.items():
|
|
131
|
+
if sizes.setdefault(axis, size) != size:
|
|
132
|
+
raise ValueError(f"Inconsistent sizes for axis {axis}")
|
|
133
|
+
if COMPARTMENT in sizes:
|
|
134
|
+
raise ValueError(f"Reserved axis name: {COMPARTMENT}")
|
|
135
|
+
names = tuple(sizes)
|
|
136
|
+
shape = tuple(sizes.values())
|
|
137
|
+
|
|
138
|
+
stacked = [
|
|
139
|
+
value.expand(names, sizes) if is_axis(value) else jnp.broadcast_to(value, shape)
|
|
140
|
+
for value in values
|
|
141
|
+
]
|
|
142
|
+
return Axis((COMPARTMENT,) + names, jnp.stack(stacked))
|