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