Source code for pypulseqpp._results

"""Named waveform and timing results returned under ``compat=False``."""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

__all__ = [
    "AdcEchoes",
    "AdcTimes",
    "EventDefinitions",
    "GradientStatistics",
    "RfBandwidth",
    "RfDefinition",
    "RfGradients",
    "RfInstances",
    "RfTimes",
    "Waveforms",
    "WaveformsAndTimes",
]

#: Pulseq's RF uses, in the order `supported_rf_uses` lists them.
RF_USES = (
    "excitation",
    "refocusing",
    "inversion",
    "saturation",
    "preparation",
    "other",
    "undefined",
)

#: What a `use` reads as in a library row, which is its first letter.
_BY_CODE = {use[0]: use for use in RF_USES}


def use_of(code: str) -> str:
    """Return the RF use a stored code denotes; `undefined` for any other code.

    Parameters
    ----------
    code : str
        A use as a library row records it, which is its first letter.

    Returns
    -------
    str
        One of `RF_USES`.
    """
    return _BY_CODE.get(code, "undefined")


@dataclass(frozen=True)
class Waveforms:
    """Gradient corner waveforms and an optional complex RF envelope.

    Attributes
    ----------
    gx, gy, gz : numpy.ndarray
        ``(2, n)``: time (s), then gradient amplitude (Hz/m), after each
        block's rotation.
    rf : numpy.ndarray or None
        ``(2, n)``: time (s), then complex RF amplitude (Hz); None unless RF
        expansion was requested.
    """

    gx: np.ndarray
    gy: np.ndarray
    gz: np.ndarray
    rf: np.ndarray | None = None

    @property
    def channels(self) -> list[np.ndarray]:
        """The gradient channels as upstream returns them: a list, with RF appended."""
        channels = [self.gx, self.gy, self.gz]
        if self.rf is not None:
            channels.append(self.rf)
        return channels

    @property
    def duration(self) -> float:
        """Latest time present on any channel, in seconds."""
        ends = [channel[0, -1] for channel in self.channels if channel.shape[1]]
        return float(max(ends)) if ends else 0.0


@dataclass(frozen=True)
class RfTimes:
    """RF pulse centre times in play order, for all Pulseq RF uses.

    Attributes
    ----------
    t : numpy.ndarray
        Centre time of each pulse (s).
    freq_offset : numpy.ndarray
        Frequency offset (Hz), ppm offset included.
    phase_offset : numpy.ndarray
        Phase offset (rad), ppm offset included, plus
        ``2*pi*freq_offset*centre`` where ``centre`` is the pulse centre
        relative to the start of the RF event.
    use : tuple[str, ...]
        RF use of each pulse, one of `RF_USES`.
    block : numpy.ndarray
        1-based block of each pulse.
    """

    t: np.ndarray
    freq_offset: np.ndarray
    phase_offset: np.ndarray
    use: tuple[str, ...]
    block: np.ndarray

    def of(self, *uses: str) -> RfTimes:
        """Select pulses with any of the specified use tags.

        An undefined use is not implicitly included with excitation; request
        both tags to reproduce the PyPulseq excitation group.

        Parameters
        ----------
        *uses : str
            The uses to keep, each one of `RF_USES`.

        Returns
        -------
        RfTimes
            The pulses carrying one of ``uses``, in play order.

        Raises
        ------
        ValueError
            If a use is not one of `RF_USES`.
        """
        unknown = set(uses) - set(RF_USES)
        if unknown:
            raise ValueError(
                f"unknown RF use(s) {sorted(unknown)}; expected one of {RF_USES}"
            )
        keep = np.array([tag in set(uses) for tag in self.use], dtype=bool)
        return RfTimes(
            t=self.t[keep],
            freq_offset=self.freq_offset[keep],
            phase_offset=self.phase_offset[keep],
            use=tuple(tag for tag, taken in zip(self.use, keep, strict=True) if taken),
            block=self.block[keep],
        )

    @property
    def tfp(self) -> np.ndarray:
        """``(3, n)``: time over frequency over phase, upstream's packing."""
        return np.vstack((self.t, self.freq_offset, self.phase_offset))

    def __len__(self) -> int:
        return int(self.t.size)


@dataclass(frozen=True)
class AdcTimes:
    """ADC sampling times and offsets, per window and per sample.

    Attributes
    ----------
    t : numpy.ndarray
        Per sample: time (s) from the start of the scan.
    freq_offset, phase_offset : numpy.ndarray
        Per window: the stored frequency offset (Hz) and phase offset (rad),
        without ppm terms.
    phase_modulation : numpy.ndarray
        Per sample: phase modulation (rad).
    sample_phase : numpy.ndarray
        Per sample: phase (rad), including ppm terms, phase modulation and
        ``2*pi*frequency*time`` accumulated within the ADC event.
    sample_frequency : numpy.ndarray
        Per sample: frequency (Hz).
    block : numpy.ndarray
        Per window: 1-based block.
    num_samples : numpy.ndarray
        Per window: number of samples.
    """

    t: np.ndarray
    freq_offset: np.ndarray
    phase_offset: np.ndarray
    phase_modulation: np.ndarray
    sample_phase: np.ndarray
    sample_frequency: np.ndarray
    block: np.ndarray
    num_samples: np.ndarray

    @property
    def fp(self) -> np.ndarray:
        """``(n_windows, 2)``: frequency over phase, upstream's ``fp_adc``."""
        return np.stack((self.freq_offset, self.phase_offset), axis=-1)

    def __len__(self) -> int:
        return int(self.t.size)


@dataclass(frozen=True)
class WaveformsAndTimes:
    """Waveforms, RF centres and ADC timing, including all RF use tags.

    The PyPulseq-compatible tuple is ``(waveforms.channels,
    rf.of("excitation", "undefined").tfp, rf.of("refocusing").tfp,
    adc.t, adc.fp)``.
    """

    waveforms: Waveforms
    rf: RfTimes
    adc: AdcTimes


@dataclass(frozen=True)
class RfBandwidth:
    """RF bandwidth, the spectrum it was measured on, and the bands in it.

    Frequencies are in Hz. ``frequency`` is centred on where the pulse is
    tuned; ``band_offsets`` are relative to that tuning, the pulse's own
    frequency offset, so a single-band pulse has one band at zero. The
    PyPulseq-compatible tuple is ``(bandwidth, spectrum, frequency)``.

    Attributes
    ----------
    bandwidth : float
        Width between the outermost crossings at ``cutoff`` of the peak, over
        every band.
    spectrum : numpy.ndarray
        Complex Fourier transform of the envelope, one value per ``frequency``.
    frequency : numpy.ndarray
        Frequency of each bin of ``spectrum``. Upstream's axis, which the
        compatible tuple returns, labels every bin one bin low.
    band_offsets : numpy.ndarray
        Magnitude-weighted centre of each band, lowest first.
    band_bandwidths : numpy.ndarray
        Width of each band at ``cutoff`` of that band's own peak.
    """

    bandwidth: float
    spectrum: np.ndarray
    frequency: np.ndarray
    band_offsets: np.ndarray
    band_bandwidths: np.ndarray

    @property
    def num_bands(self) -> int:
        """Number of bands; one for a pulse with a single passband."""
        return int(self.band_offsets.size)


@dataclass(frozen=True)
class AdcEchoes:
    """Per readout, the axes its k-space moves along and the samples nearest the centre.

    One entry per block that acquires, in play order; see
    :meth:`pypulseqpp.Sequence.adc_echoes`.

    Attributes
    ----------
    block : NDArray[np.int32]
        ``(n,)``: the 1-based block.
    num_samples : NDArray[np.int32]
        ``(n,)``: the samples it acquires.
    first_sample : NDArray[np.int64]
        ``(n,)``: the column of its first sample in
        :meth:`pypulseqpp.Sequence.adc_kspace`.
    moving : NDArray[np.bool_]
        ``(n, 3)``: whether its k-space moves along x, y and z.
    echo : NDArray[np.int32]
        ``(n, 2)``: the first and last 0-based sample no further from the
        centre of k-space, over the moving axes, than the nearest sample plus
        1% of the larger k step beside it; -1 for a readout that does not move
        or has fewer than two samples.
    start : NDArray[np.float64] or None
        ``(n, 3)``: k-space at its first sample, in 1/m.
    path : NDArray[np.int32] or None
        ``(n,)``: the entry of :attr:`paths` it plays.
    paths : tuple of NDArray[np.float64] or None
        Each distinct ``(3, samples)`` path from a readout's first sample to
        each of its samples, in 1/m, along the axes before the block's
        rotation, so that readout ``r`` is sampled at
        ``start[r, :, None] + R @ paths[path[r]]``, as
        :meth:`pypulseqpp.Sequence.adc_kspace` places it, ``R`` being
        ``rotations[rotation[r]]``, or the identity where that is -1.
        Readouts whose gradients differ only before their first sample, or
        only in their rotation, share one.
    rotation : NDArray[np.int32] or None
        ``(n,)``: the entry of :attr:`rotations` it plays under, -1 for none.
    rotations : NDArray[np.float64] or None
        ``(m, 3, 3)``: each distinct block rotation a readout plays under.

    :attr:`start`, :attr:`path`, :attr:`paths`, :attr:`rotation` and
    :attr:`rotations` are None when an RF pulse
    plays in a block that acquires, unless its use is recorded as neither
    excitation nor refocusing, and when more than one readout in 16 has a
    path of its own.
    """

    block: np.ndarray
    num_samples: np.ndarray
    first_sample: np.ndarray
    moving: np.ndarray
    echo: np.ndarray
    start: np.ndarray | None = None
    path: np.ndarray | None = None
    paths: tuple[np.ndarray, ...] | None = None
    rotation: np.ndarray | None = None
    rotations: np.ndarray | None = None


@dataclass(frozen=True)
class RfGradients:
    """The gradient each RF pulse plays under, along the channel axes.

    One entry per block with RF, in play order; see
    :meth:`pypulseqpp.Sequence.rf_gradients`.

    Attributes
    ----------
    block : NDArray[np.int32]
        ``(n,)``: the 1-based block.
    steady : NDArray[np.bool_]
        ``(n, 3)``: whether the gradient along x, y and z holds one value from
        the pulse's first sample to its last; an axis without a gradient is
        steady at zero.
    gradient : NDArray[np.float64]
        ``(n, 3)``: the gradient along x, y and z at the pulse's centre, in
        Hz/m.
    """

    block: np.ndarray
    steady: np.ndarray
    gradient: np.ndarray


@dataclass(frozen=True)
class EventDefinitions:
    """The definition each event of a sequence was deduplicated onto.

    Entry ``i`` is event id ``i + 1``, and the value is a definition id
    counted from 1. Events sharing a definition play the same thing at
    different amplitudes, offsets or phases: the per-playout parameters are
    what separates them, so a pulse generator materialises memory once per
    definition rather than once per event. See
    :meth:`pypulseqpp.Sequence.event_definitions`.

    Attributes
    ----------
    rf : NDArray[np.int32]
        ``(r,)``: by RF id. Magnitude, phase and time shapes, delay, centre
        and use make the definition; the amplitude and the frequency and
        phase offsets, ppm offsets included, do not.
    gradient : NDArray[np.int32]
        ``(g,)``: by gradient id, trapezoids and arbitrary gradients under one
        numbering. A trapezoid's rise, flat and fall times and delay make the
        definition, an arbitrary gradient's time shape and delay do; neither
        amplitude does, and an arbitrary gradient's waveform belongs to the
        playout.
    adc : NDArray[np.int32]
        ``(a,)``: by ADC id. Sample count, dwell and delay make the
        definition; the frequency and phase offsets and the phase
        modulation do not.
    """

    rf: np.ndarray
    gradient: np.ndarray
    adc: np.ndarray


[docs] @dataclass(frozen=True) class RfDefinition: """One RF pulse as the instances of a sequence play it, at unit peak magnitude. An instance plays the RF amplitude ``amplitude * peak_hz * waveform`` on its transmit channels, in Hz, at the sample times ``time``; see :meth:`pypulseqpp.Sequence.rf_instances`. Attributes ---------- waveform : NDArray[np.complex128] ``(channels, samples)``: the complex RF waveform of each transmit channel, scaled to unit peak magnitude over all channels and samples, with the block's RF shim applied. A single-transmit pulse has one row, a dynamic pTx pulse one per channel, and a single-transmit pulse under an RF shim one per shim weight. The RF event's frequency and phase offsets are playout parameters and are not applied. peak_hz : float The peak magnitude, over all channels and samples, of the RF amplitude of the base instance, in Hz. time : NDArray[np.float64] ``(samples,)``: the sample times in s from the start of the RF event, its delay excluded. The channels share one time base. delay : float The delay of the RF event within its block, in s. center : float The RF pulse centre in s from the start of the RF event, as the event states it. use : str The RF use: ``'excitation'``, ``'refocusing'``, ``'inversion'``, ``'saturation'``, ``'preparation'``, ``'other'`` or ``'undefined'``. flip_deg : float The flip angle of the base instance's RF event in degrees, as :meth:`pypulseqpp.Sequence.rf_flip_angles` gives it, so without the RF shim. """ waveform: np.ndarray peak_hz: float time: np.ndarray delay: float center: float use: str flip_deg: float
[docs] @dataclass(frozen=True) class RfInstances: """The RF events a sequence plays, and the distinct RF pulses they play. One instance per block that contains an RF event, in block order; see :meth:`pypulseqpp.Sequence.rf_instances`. Attributes ---------- definitions : tuple[RfDefinition, ...] The distinct pulses, numbered in order of first play. definition : NDArray[np.intp] ``(n,)``: the index into ``definitions`` of the pulse each instance plays. amplitude : NDArray[np.float64] ``(n,)``: the RF amplitude of each instance over that of the base instance of its definition, which is the first instance with a nonzero RF amplitude and so has amplitude 1. A definition played only at amplitude zero has amplitude 1 for every instance and a zero ``peak_hz``. """ definitions: tuple[RfDefinition, ...] definition: np.ndarray amplitude: np.ndarray
@dataclass(frozen=True) class GradientStatistics: """Statistics of each gradient event's waveform, along its channel axis. Entry ``i`` is gradient id ``i + 1``; see :meth:`pypulseqpp.Sequence.gradient_statistics`. Attributes ---------- peak_slew : NDArray[np.float64] ``(g,)``: the steepest slew rate, in Hz/m/s. energy : NDArray[np.float64] ``(g,)``: the integral of the squared gradient, in (Hz/m)^2 s. slew_energy : NDArray[np.float64] ``(g,)``: the integral of the squared slew rate, in (Hz/m/s)^2 s. """ peak_slew: np.ndarray energy: np.ndarray slew_energy: np.ndarray