"""ADC sampling locations in k-space, and the trajectory between them."""
from __future__ import annotations
import numpy as np
from . import _style
from ._seqeyes import blocks_for
#: What each letter of a ``plane`` draws: the row of the coordinates, and the
#: axis label. ``f`` is the transmit frequency of the slice a sample came from.
_AXES = {
"x": (0, "$k_x$ [1/m]"),
"y": (1, "$k_y$ [1/m]"),
"z": (2, "$k_z$ [1/m]"),
"f": (3, r"$\Delta f_z$ [Hz]"),
}
_PANELS = {
None: (None,),
"echo": ("echo",),
"shot": ("shot",),
"order": ("shot", "echo"),
}
def _marker_size(points: np.ndarray) -> float:
"""Return a scatter marker area scaled to the density of ``points``."""
span = max(float(np.ptp(points)), 1e-12)
grid = np.round(points / (span / 512.0)).astype(np.int64)
distinct = max(len(np.unique(grid, axis=1)), 1)
return float(np.clip(2.0e3 / distinct, 1.5, 24.0))
def _frame(axis, points: np.ndarray) -> None:
"""Limit ``axis`` to the range spanned by ``points``, with a small margin."""
setters = [axis.set_xlim, axis.set_ylim]
if hasattr(axis, "set_zlim"):
setters.append(axis.set_zlim)
for values, setter in zip(points, setters, strict=False):
low, high = float(np.min(values)), float(np.max(values))
margin = 0.05 * max(high - low, 1e-12)
setter(low - margin, high + margin)
_box(axis)
def _box(axis) -> None:
"""Draw the frame in the house furniture tone.
A 3D axis carries its own panes, axis lines and grid, in a light grey the
rcParams do not reach. Left as they are, they are invisible on white paper
and the brightest thing in the figure on a dark page.
"""
if not hasattr(axis, "zaxis"):
_style.axis_style(axis)
return
for spatial in (axis.xaxis, axis.yaxis, axis.zaxis):
spatial.line.set_color(_style.FAINT)
spatial.set_pane_color((0.0, 0.0, 0.0, 0.0))
spatial._axinfo["grid"]["color"] = _style.FAINT
spatial.label.set_color(_style.MUTED)
axis.tick_params(colors=_style.MUTED, labelsize=8)
def _within(path: np.ndarray, points: np.ndarray) -> np.ndarray:
"""Return ``path`` with the samples outside the box around ``points`` set to NaN.
A 3D axis draws a line beyond its own limits, so a prewinder or a spoiler
that leaves the sampled volume would set the apparent scale of the figure.
Breaking the polyline there leaves the trajectory between the sampling
locations and nothing else.
"""
low = points.min(axis=1)[:, None]
high = points.max(axis=1)[:, None]
margin = 0.05 * np.maximum(high - low, 1e-12)
outside = ((path < low - margin) | (path > high + margin)).any(axis=0)
return np.where(outside, np.nan, path)
def _readout_trains(seq, first: int, last: int):
"""Return samples per readout, the pulses opening each echo train, and their mapping.
``train`` is 1-based into the opening pulses: a readout follows the last
excitation played at or before its block.
"""
found = seq.waveforms_and_times(block_range=(first, last), compat=False)
counts = np.asarray(found.adc.num_samples, dtype=int)
if not counts.size:
raise ValueError("plot_kspace(): the range holds no ADC samples")
opened = found.rf.of("excitation", "undefined")
train = np.searchsorted(
np.asarray(opened.block), np.asarray(found.adc.block), side="right"
)
return counts, opened, train
def sampling_order(seq, first: int, last: int) -> tuple[np.ndarray, np.ndarray]:
"""Shot and echo index of each ADC sample.
The echo is the ``ECO`` label where the sequence sets one, and otherwise
the readout's rank after the excitation that opened its train. A shot
begins wherever the echo returns to zero.
Parameters
----------
seq : pypulseqpp.Sequence
The sequence to read.
first, last : int
The 1-based block range to follow.
Returns
-------
shot : numpy.ndarray
The shot each ADC sample belongs to, one value per sample, aligned
with the ``k_traj_adc`` of the same range.
echo : numpy.ndarray
The echo index within that shot, on the same alignment.
"""
counts, _, train = _readout_trains(seq, first, last)
echo = seq.evaluate_labels(evolution="adc", block_range=(first, last)).get("ECO")
if echo is None or np.size(echo) != train.size:
# ``train`` does not decrease, so the first index holding each value is
# where that train started.
echo = np.arange(train.size) - np.searchsorted(train, train, side="left")
echo = np.asarray(echo, dtype=int).reshape(-1)
shot = np.maximum(np.cumsum(echo == 0) - 1, 0)
return np.repeat(shot, counts), np.repeat(echo, counts)
def _slice_offsets(seq, first: int, last: int) -> np.ndarray:
"""Return the excitation frequency offset of each sample's slice, in Hz."""
counts, opened, train = _readout_trains(seq, first, last)
offsets = np.asarray(opened.freq_offset, dtype=float)
if not offsets.size:
return np.zeros(int(counts.sum()))
return np.repeat(offsets[np.clip(train - 1, 0, offsets.size - 1)], counts)
[docs]
def plot_kspace(
seq,
*,
time_range=None,
block_range=None,
tr_range=None,
plane: str | None = None,
show_trajectory: bool = True,
color_by: str | None = None,
plot_now: bool = True,
):
"""Plot the ADC sampling locations in k-space.
Parameters
----------
seq : Sequence
The sequence to draw.
time_range, block_range, tr_range : sequence, default=None
The part of the sequence to draw, as for :meth:`Sequence.plot`; at
most one. The whole sequence by default.
plane : {"xy", "xz", "yz", ...}, default=None
Two of ``x``, ``y``, ``z`` and ``f`` to project onto, where ``f`` is
the transmit frequency of each sample's slice. By default a trajectory
confined to a plane is drawn in it, and any other in 3D.
show_trajectory : bool, default=True
Also draw the trajectory between samples. The axis limits are set
from the sampling locations either way, so a prewinder or spoiler is
clipped rather than setting the scale.
color_by : {"echo", "shot", "order"}, default=None
Colour samples by echo index within the shot, by shot index, or both
side by side. A panel whose index never varies is dropped.
plot_now : bool, default=True
Show the figure before returning.
Returns
-------
matplotlib.figure.Figure
The figure the sampling locations were drawn on.
Raises
------
ValueError
If more than one range is given, ``color_by`` or ``plane`` is not one
of the values listed, or the range holds no ADC samples.
Notes
-----
Coordinates are k-space in 1/m, after each block's rotation.
"""
from matplotlib import pyplot as plt
panels = _PANELS.get(color_by, ())
if not panels:
raise ValueError(
f"plot_kspace(): color_by must be echo, shot, order or None, not {color_by!r}"
)
first, last = blocks_for(seq, time_range, block_range, tr_range)
found = seq._kspace(block_range=(first, last), samples_only=not show_trajectory)
adc = np.asarray(found["k_traj_adc"], dtype=float)
if adc.size == 0:
raise ValueError("plot_kspace(): the range holds no ADC samples")
indices = {}
if color_by is not None:
shot, echo = sampling_order(seq, first, last)
indices = {"shot": shot, "echo": echo}
panels = tuple(name for name in panels if np.ptp(indices[name])) or (panels[0],)
spread = max(float(np.ptp(adc)), 1e-12)
used = [axis for axis in range(3) if np.ptp(adc[axis]) > 1e-9 * spread]
if plane is None and len(used) <= 2:
used += [axis for axis in range(3) if axis not in used]
plane = "".join("xyz"[axis] for axis in used[:2])
if plane is not None and (len(plane) != 2 or any(c not in _AXES for c in plane)):
raise ValueError(
f"plot_kspace(): plane must be two of {', '.join(_AXES)}, not {plane!r}"
)
coords = np.vstack([adc, np.zeros(adc.shape[1])])
if plane is not None and "f" in plane:
coords[3] = _slice_offsets(seq, first, last)
drawn_rows = [_AXES[c][0] for c in plane] if plane else [0, 1, 2]
path = (
np.asarray(found["k_traj"], dtype=float)
if show_trajectory and 3 not in drawn_rows
else None
)
size = 1.5 if color_by is None else _marker_size(coords[drawn_rows])
figure = plt.figure(figsize=(5.5 * len(panels), 5.0))
for column, name in enumerate(panels, start=1):
values = indices.get(name)
shared = {
"s": size,
"c": "C0" if values is None else values,
"cmap": None if values is None else _style.SAMPLING,
}
if plane is None:
axis = figure.add_subplot(1, len(panels), column, projection="3d")
if path is not None:
inside = _within(path[:3], adc)
axis.plot(inside[0], inside[1], inside[2], lw=0.4, color=_style.MUTED)
drawn = axis.scatter(adc[0], adc[1], adc[2], **shared)
axis.set_xlabel(_AXES["x"][1])
axis.set_ylabel(_AXES["y"][1])
axis.set_zlabel(_AXES["z"][1])
else:
one, two = drawn_rows
axis = figure.add_subplot(1, len(panels), column)
if path is not None:
axis.plot(path[one], path[two], lw=0.4, color=_style.MUTED)
drawn = axis.scatter(coords[one], coords[two], **shared)
axis.set_xlabel(_AXES[plane[0]][1])
axis.set_ylabel(_AXES[plane[1]][1])
if 3 not in drawn_rows:
axis.set_aspect("equal", adjustable="box")
_frame(axis, coords[drawn_rows])
if values is not None:
figure.colorbar(drawn, ax=axis, label=f"{name} index", shrink=0.85)
figure.tight_layout()
if plot_now:
plt.show()
return figure