"""Backend-independent description of an MR sequence state machine."""
from __future__ import annotations
__all__ = [
"AdcRole",
"EventAction",
"EventType",
"RfDefinition",
"RfMode",
"RfShape",
"RfUse",
"SequenceDescription",
"SequenceEvent",
"ShimDefinition",
"decompress_shape",
"ideal_rf_definition",
]
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import IntEnum, IntFlag
from typing import Any
import numpy as np
# ``np.trapz`` was renamed to ``np.trapezoid`` in NumPy 2.0.
_trapezoid = getattr(np, "trapezoid", None) or np.trapz
class EventType(IntEnum):
"""State-machine event type used by Pulserver SEQDESC streams."""
WAIT = 0
RF = 1
ADC = 2
class EventAction(IntFlag):
"""What the sequence does around an event, beyond the event itself.
A Pulseq file has no gradient "use" field, so a crusher cannot be told from
a phase encode by looking at waveforms -- that would mean tracking the
k-space moment through the whole TR. The sequence says instead what each
event's role implies, and the description carries it, which is what lets an
autogenerated stream reach the kernels without a policy being named.
``CRUSH_BEFORE`` and ``CRUSH_AFTER`` are the unbalanced gradients a
refocusing pulse sits between. ``SHIFT_AFTER`` is one played after a
readout, and ``SPOIL_AFTER`` is ideal transverse spoiling there instead.
The values are the bits the packed action word carries, so a description
and a kernel never hold two numberings of the same fact.
"""
NONE = 0
CRUSH_BEFORE = 1
CRUSH_AFTER = 2
SPOIL_AFTER = 8
SHIFT_AFTER = 16
class RfMode(IntEnum):
"""How a pulse's rotation is reached.
The three are alternatives rather than combinable. ``INSTANT`` turns
through a flip angle and a phase. ``PROFILED`` reads the rotation from a
table over slice position and effective flip. ``DYNAMIC`` reads it per
voxel, which is what a pulse whose channel weights vary while it plays
needs -- and which subsumes a profile, since a rotation integrated at the
voxel's own position is one.
"""
INSTANT = 0
PROFILED = 1
DYNAMIC = 2
class RfUse(IntEnum):
"""Pulseq RF-use tag.
The whole tag set, not the subset the operators that ship here happen to
emit. A file written elsewhere may carry any of them, and an operator
written later may emit one nothing emits today.
"""
UNKNOWN = 0
EXCITATION = 1
REFOCUSING = 2
INVERSION = 3
SATURATION = 4
PREPARATION = 5
OTHER = 6
class AdcRole(IntEnum):
"""Relationship between ADC events in a canonical event stream."""
NON_ACQUIRED = 0
SINGLE = 1
ECHO_CENTER = 2
NON_CENTER = 3
@dataclass(frozen=True)
class SequenceEvent:
"""One WAIT, RF, or ADC state-machine event.
``params`` deliberately accepts tensors. This lets a hard-coded sequence
description carry differentiable flip angles, phases, and timings without
introducing a second representation for sequence optimization.
Attributes
----------
type : EventType
Whether this is a wait, an RF pulse or an ADC.
timestamp_us : float or torch.Tensor
When it happens, in microseconds from the start of the stream.
params : tuple
The payload its type defines, read through the named properties rather
than by position.
action : EventAction
What the sequence does around it -- the crushers a refocusing pulse
sits between, the gradient or the ideal spoiling after a readout.
"""
type: EventType
timestamp_us: Any
params: tuple[Any, ...] = ()
action: EventAction = EventAction.NONE
@classmethod
def wait(cls, timestamp_us: Any) -> SequenceEvent:
"""Create a timing-only event."""
return cls(EventType.WAIT, timestamp_us)
@classmethod
def rf(
cls,
timestamp_us: Any,
definition_id: int,
use: RfUse,
amplitude_hz: Any,
phase_rad: Any = 0.0,
frequency_hz: Any = 0.0,
shim_id: int = 0,
slice_select_gradient_hz_per_m: Any = 0.0,
action: EventAction = EventAction.NONE,
) -> SequenceEvent:
"""Create an RF event using the Pulserver SEQDESC field order."""
return cls(
EventType.RF,
timestamp_us,
(
definition_id,
use,
amplitude_hz,
phase_rad,
frequency_hz,
shim_id,
slice_select_gradient_hz_per_m,
),
action,
)
@classmethod
def adc(
cls,
timestamp_us: Any,
role: AdcRole = AdcRole.SINGLE,
phase_rad: Any = 0.0,
*,
is_echo: bool = True,
action: EventAction = EventAction.NONE,
) -> SequenceEvent:
"""Create an ADC event using the Pulserver SEQDESC field order."""
return cls(EventType.ADC, timestamp_us, (role, phase_rad, int(is_echo)), action)
@property
def rf_definition_id(self) -> int:
self._require(EventType.RF)
return int(self.params[0])
@property
def rf_use(self) -> RfUse:
self._require(EventType.RF)
return RfUse(int(self.params[1]))
@property
def rf_amplitude_hz(self) -> Any:
self._require(EventType.RF)
return self.params[2]
@property
def rf_phase_rad(self) -> Any:
self._require(EventType.RF)
return self.params[3]
@property
def rf_frequency_hz(self) -> Any:
self._require(EventType.RF)
return self.params[4]
@property
def rf_shim_id(self) -> int:
self._require(EventType.RF)
return int(self.params[5])
@property
def slice_select_gradient_hz_per_m(self) -> Any:
self._require(EventType.RF)
return self.params[6]
@property
def adc_role(self) -> AdcRole:
self._require(EventType.ADC)
return AdcRole(int(self.params[0]))
@property
def adc_phase_rad(self) -> Any:
self._require(EventType.ADC)
return self.params[1]
@property
def is_echo(self) -> bool:
"""Whether this ADC position reaches the k-space origin."""
self._require(EventType.ADC)
return bool(int(self.params[2]))
def _require(self, expected: EventType) -> None:
if self.type is not expected:
raise AttributeError(
f"{expected.name} payload requested from {self.type.name} event"
)
@dataclass(frozen=True)
class RfShape:
"""One Pulseq derivative/run-length encoded shape.
Attributes
----------
num_uncompressed : int
How many samples the shape decodes to.
samples : array-like
The encoded samples, decoded by :meth:`decompress`.
"""
num_uncompressed: int
samples: Any = field(repr=False, compare=False)
def decompress(self, scale: float = 1.0) -> np.ndarray:
"""Return the decoded shape."""
return decompress_shape(self.samples, self.num_uncompressed, scale=scale)
@dataclass(frozen=True)
class RfDefinition:
"""RF definition referenced by RF events.
Attributes
----------
id : int
What an RF event names this pulse by.
bandwidth_hz : float
The pulse's bandwidth, which is the gradient the slice select is
quoted against.
num_bands : int
How many slices the pulse excites at once.
band_frequency_offsets_hz : tuple of float
Where each band sits, in Hz off the centre frequency.
band_bandwidth_hz : float
The bandwidth of one band.
total_b1sq_power : float
The pulse's integrated squared amplitude, which is what a power limit
is read against.
magnitude : RfShape or tuple of RfShape
The envelope, or one envelope per transmit channel.
phase : RfShape or tuple of RfShape, optional
The phase, matching ``magnitude``. ``None`` for a pulse of constant
phase.
time : RfShape, optional
Sample times, for a pulse on a non-uniform raster. ``None`` for a
uniform one.
gradient : RfShape, optional
The slice-select gradient per sample, in units ``bandwidth_hz`` is
quoted at. ``None`` where the gradient holds.
"""
id: int
bandwidth_hz: float
num_bands: int
band_frequency_offsets_hz: tuple[float, ...]
band_bandwidth_hz: float
total_b1sq_power: float
magnitude: RfShape | tuple[RfShape, ...]
phase: RfShape | tuple[RfShape, ...] | None = None
time: RfShape | None = None
gradient: RfShape | None = None
def __post_init__(self) -> None:
if isinstance(self.magnitude, tuple):
if not self.magnitude:
raise ValueError(f"RF definition {self.id} drives no channel")
if isinstance(self.phase, tuple):
if len(self.phase) != len(self.magnitude):
raise ValueError(
f"RF definition {self.id}: {len(self.magnitude)} "
f"magnitude channels against {len(self.phase)} phase"
)
elif self.phase is not None:
raise ValueError(
f"RF definition {self.id}: a per-channel magnitude takes a "
f"phase per channel, or none"
)
elif isinstance(self.phase, tuple):
raise ValueError(
f"RF definition {self.id}: a phase per channel takes a "
f"magnitude per channel"
)
@property
def channel_count(self) -> int:
"""How many transmit channels drive their own waveform."""
return len(self.magnitude) if isinstance(self.magnitude, tuple) else 1
def channel_envelopes(self) -> np.ndarray:
"""Return the normalized complex envelope per channel, ``(C, N)``."""
magnitudes = (
self.magnitude if isinstance(self.magnitude, tuple) else (self.magnitude,)
)
if isinstance(self.phase, tuple):
phases: tuple[RfShape | None, ...] = self.phase
else:
phases = (self.phase,) * len(magnitudes)
channels = []
for magnitude_shape, phase_shape in zip(magnitudes, phases, strict=False):
magnitude = magnitude_shape.decompress()
if phase_shape is None:
channels.append(magnitude.astype(np.complex64))
continue
phase = phase_shape.decompress(scale=2.0 * np.pi)
if phase.size != magnitude.size:
raise ValueError(
f"RF definition {self.id}: magnitude/phase size mismatch"
)
channels.append(magnitude * np.exp(1j * phase))
widths = {channel.size for channel in channels}
if len(widths) > 1:
raise ValueError(
f"RF definition {self.id}: channels of {sorted(widths)} samples"
)
return np.stack(channels)
def complex_envelope(self) -> np.ndarray:
"""Return the normalized complex RF envelope.
``(samples,)`` for a pulse every channel plays alike, ``(channels,
samples)`` for one whose channels each carry their own waveform.
"""
channels = self.channel_envelopes()
return channels[0] if self.channel_count == 1 else channels
def combined_envelope(self) -> np.ndarray:
"""Return the envelope a voxel of unit sensitivity on every channel sees.
The sum over channels, ``(samples,)``. This is the shape the flip
angle, the integral and the saturation are all read against, so each
keeps the meaning it has for a pulse driving a single channel.
"""
return self.channel_envelopes().sum(axis=0)
def gradient_waveform(self) -> np.ndarray | None:
"""Return the slice-select gradient per sample, or ``None`` if it holds.
In units of the gradient ``bandwidth_hz`` is quoted at, so a shape of
ones is the gradient the scanner does not move and reproduces the
constant case exactly. What a spin at a normalized position accrues
while sample ``s`` lasts is ``bandwidth_hz * position * g[s]`` in Hz.
Raises
------
ValueError: if the gradient and the envelope disagree on how many
samples the pulse has.
"""
if self.gradient is None:
return None
waveform = self.gradient.decompress()
samples = self.channel_envelopes().shape[1]
if waveform.size != samples:
raise ValueError(
f"RF definition {self.id}: the gradient carries "
f"{waveform.size} samples and the pulse {samples}"
)
return waveform
def sample_durations(self, *, rf_raster_time_s: float = 1e-6) -> np.ndarray:
"""Return how long each sample holds its field, in seconds.
``time`` names where along the pulse each sample sits, so what a sample
lasts is the distance between the midpoints either side of it, and the
whole spacing to its one neighbour at the two ends. A definition
declaring no time is played one sample per raster step.
This is what the rotation a pulse performs is composed from: the field
is held constant across a sample, so a longer sample turns further and
lets a spin off the slice centre accrue more phase.
Raises
------
ValueError: if the declared times and the envelope disagree on how
many samples the pulse has, or the times do not advance.
"""
samples = int(self.channel_envelopes().shape[1])
if self.time is None or samples < 2:
return np.full(samples, float(rf_raster_time_s), dtype=np.float64)
time_s = self.time.decompress(scale=rf_raster_time_s).astype(np.float64)
if time_s.size != samples:
raise ValueError(
f"RF definition {self.id}: {time_s.size} sample times against "
f"{samples} samples"
)
if not np.all(np.diff(time_s) > 0.0):
raise ValueError(
f"RF definition {self.id}: the sample times do not advance, so "
f"they do not say how long a sample lasts"
)
return np.gradient(time_s)
def rf_mode(self) -> RfMode:
"""Return which rotation this pulse's waveform asks the kernels for.
Memoized on the (immutable) definition: every event naming this pulse
asks the same question, and answering it reads the whole envelope.
"""
cached = getattr(self, "_mode_cache", None)
if cached is not None:
return cached
if self.channel_count > 1:
mode = RfMode.DYNAMIC
elif self._turns_about_one_axis():
mode = RfMode.INSTANT
else:
mode = RfMode.PROFILED
object.__setattr__(self, "_mode_cache", mode)
return mode
def _turns_about_one_axis(self) -> bool:
"""Whether a flip angle and a phase already say what this pulse does.
A rectangle played without slice selection holds its effective field
fixed for its whole duration, so integrating it lands on the rotation a
hard pulse performs -- and lands there exactly, where reading a
tabulated one at a flip between knots would not.
"""
if self.bandwidth_hz != 0.0:
return False
envelope = self.combined_envelope()
if envelope.size < 2:
return True
return bool(np.all(envelope == envelope[0]))
def integral(
self,
*,
rf_raster_time_s: float = 1e-6,
) -> complex:
"""Return the complex time integral of the normalized envelope.
The envelope is normalized, so this depends only on the pulse shape and
the raster, never on the amplitude of a particular occurrence. A
sequence typically reuses one definition for every refocusing pulse and
re-evaluates it on every simulation, so the result is memoized on the
(immutable) definition.
"""
cache = getattr(self, "_integral_cache", None)
if cache is None:
cache = {}
object.__setattr__(self, "_integral_cache", cache)
elif rf_raster_time_s in cache:
return cache[rf_raster_time_s]
envelope = self.combined_envelope()
if envelope.size < 2:
cache[rf_raster_time_s] = 0.0j
return 0.0j
if self.time is None:
time_s = (
np.arange(envelope.size, dtype=np.float64) + 0.5
) * rf_raster_time_s
else:
time_s = self.time.decompress(scale=rf_raster_time_s).astype(np.float64)
if time_s.size != envelope.size:
raise ValueError(
f"RF definition {self.id}: envelope/time size mismatch"
)
area = complex(_trapezoid(envelope, time_s))
cache[rf_raster_time_s] = area
return area
def saturation(self, *, rf_raster_time_s: float = 1e-6) -> float:
"""Return what this shape deposits in a bound pool, per flip squared.
The saturation an RF pulse applies to a pool with no transverse
magnetization is ``-pi gamma**2 int B1**2 dt * G(df)``. Writing the
pulse as a normalized envelope ``w`` driven to a flip angle,
``B1 = w / (gamma int w dt)`` per radian, so::
-pi gamma**2 int B1**2 dt == -pi int |w|**2 dt / |int w dt|**2
and the gyromagnetic ratio cancels: what is left depends on the shape
alone. Multiplied by the square of the flip a voxel actually takes and
by the lineshape at the offset it is played at, this is the exponent of
the bound pool's saturation.
In 1/s. A pulse with no area saturates nothing that a flip angle can
describe, so it reports zero.
"""
cache = getattr(self, "_saturation_cache", None)
if cache is None:
cache = {}
object.__setattr__(self, "_saturation_cache", cache)
elif rf_raster_time_s in cache:
return cache[rf_raster_time_s]
area = abs(self.integral(rf_raster_time_s=rf_raster_time_s))
envelope = self.combined_envelope()
if area == 0.0 or envelope.size < 2:
cache[rf_raster_time_s] = 0.0
return 0.0
if self.time is None:
time_s = (
np.arange(envelope.size, dtype=np.float64) + 0.5
) * rf_raster_time_s
else:
time_s = self.time.decompress(scale=rf_raster_time_s).astype(np.float64)
power = float(_trapezoid(np.abs(envelope) ** 2, time_s))
value = -np.pi * power / (area * area)
cache[rf_raster_time_s] = value
return value
def flip_angle(
self,
amplitude_hz: Any,
*,
rf_raster_time_s: float = 1e-6,
) -> tuple[Any, Any]:
"""Return ``(flip_rad, integral_phase_rad)`` for one RF occurrence.
Tensor amplitudes remain tensors, so both reverse- and forward-mode
automatic differentiation pass through a description unchanged.
"""
area = self.integral(rf_raster_time_s=rf_raster_time_s)
try:
import torch
except ImportError: # pragma: no cover - Torch is a required dependency
torch = None
if torch is not None and isinstance(amplitude_hz, torch.Tensor):
# The area is a Python number, so it multiplies a tensor without
# being made one first. A pulse whose envelope integrates to a real
# number -- every shape that is symmetric about its centre, which
# is most of them -- keeps the product real, and a real product is
# both cheaper per event and the only kind the real-subspace
# kernels can be handed.
signed_area = amplitude_hz * (area.real if area.imag == 0.0 else area)
return 2.0 * torch.pi * torch.abs(signed_area), torch.angle(signed_area)
signed_area = amplitude_hz * area
return 2.0 * np.pi * abs(signed_area), np.angle(signed_area)
[docs]
@dataclass(frozen=True)
class ShimDefinition:
"""One transmit-shim definition: a complex weight per channel.
The weights hold for the whole pulse, and every channel plays the same
waveform -- static parallel transmit. :mod:`._transmit` combines them with
the per-channel sensitivities into the field a voxel sees.
Attributes
----------
id : int
What an RF event names this shim by.
magnitudes : tuple of float
One weight per transmit channel.
phases_rad : tuple of float
One phase per transmit channel, in radians.
"""
id: int
magnitudes: tuple[float, ...]
phases_rad: tuple[float, ...]
[docs]
@dataclass(frozen=True)
class SequenceDescription:
"""Event stream and RF resources for one sequence or subsequence.
``crusher_dephasing_rad`` is the dephasing the sequence's unbalanced
gradient winds across one voxel of ``voxel_size_m``. Their ratio sets the
b-factor diffusion is damped by and the phase each dephasing order turns
through under flow, so a dephasing of zero leaves both terms out however
large the coefficient behind them.
``voxel_size_m`` also stands on its own: spins cross a voxel of that size
at their own speed whether or not a gradient is playing, which is what
washes the voxel out and replaces it with unexcited magnetization. Leaving
it ``None`` declares no voxel, and a spin velocity then has nothing to
cross.
Attributes
----------
subsequence_index : int
Which subsequence of a multi-part acquisition this is.
tr_duration_us : float or torch.Tensor
How long one playing of this stream holds the timeline, in
microseconds.
events : tuple of SequenceEvent
The stream itself, in the order it is played.
rf_definitions : dict
``{id: RfDefinition}`` -- the pulses the RF events name.
shim_definitions : dict
``{id: ShimDefinition}`` -- the transmit shims the RF events drive.
crusher_dephasing_rad : float
Dephasing wound across one voxel by the sequence's unbalanced
gradient.
voxel_size_m : float, optional
The voxel a spin crosses, and the length the dephasing is wound
across. ``None`` declares no voxel.
rf_raster_time_s : float
The dwell the RF shapes are sampled on. A pulse is integrated against
it, so a stream carrying shapes on a 2 microsecond raster and read on a
1 microsecond one turns half the angle it should.
"""
subsequence_index: int
tr_duration_us: Any
events: tuple[SequenceEvent, ...]
rf_definitions: dict[int, RfDefinition]
shim_definitions: dict[int, ShimDefinition] = field(default_factory=dict)
crusher_dephasing_rad: float = 0.0
voxel_size_m: float | None = None
rf_raster_time_s: float = 1e-6
[docs]
@classmethod
def from_operators(
cls,
*parts: Any,
rf_definitions: Mapping[int, RfDefinition] | None = None,
shim_definitions: Mapping[int, ShimDefinition] | None = None,
subsequence_index: int = 0,
crusher_dephasing_rad: float = 0.0,
voxel_size_m: float | None = None,
start_s: Any = 0.0,
) -> SequenceDescription:
"""Lay operators out end to end and return the stream they make.
The other way in besides a scanner: what a sequence written here plays,
in the same object one that arrived would be read into.
Parameters
----------
parts : Operator, optional
The operators, in the order they play.
rf_definitions : mapping, optional
The pulses the events drive, by id. One ideal pulse under id zero
when not given, which is what an operator naming no shape asks for.
shim_definitions : mapping, optional
The transmit shims the pulses are driven on, by id.
subsequence_index : int, optional
Which subsequence of a scan this is.
crusher_dephasing_rad : float, optional
The turn one crusher puts across a voxel. Diffusion and flow are
read off it and are zero without it.
voxel_size_m : float, optional
The distance that turn is put across.
start_s : float or torch.Tensor, optional
When the first operator starts.
Returns
-------
The stream, lasting as long as the operators laid out end to end.
"""
from ._operators import compose
events, duration_s = compose(*parts, start_s=start_s)
return cls(
subsequence_index=subsequence_index,
tr_duration_us=1e6 * duration_s,
events=events,
rf_definitions=dict(
{0: ideal_rf_definition()} if rf_definitions is None else rf_definitions
),
shim_definitions=dict(shim_definitions or {}),
crusher_dephasing_rad=crusher_dephasing_rad,
voxel_size_m=voxel_size_m,
)
[docs]
@classmethod
def from_pulseq(cls, source: Any, **settings: Any) -> SequenceDescription:
"""Return what one repetition of a Pulseq sequence plays.
Parameters
----------
source : str, Path or sequence
A ``.seq`` file, or a sequence in memory with pypulseq's reading
interface -- pypulseq's own ``Sequence``, or pypulseqpp's.
settings : Any
``tr_index`` to name which repetition to read, and the
``subsequence_index``, ``crusher_dephasing_rad`` and
``voxel_size_m`` the description carries.
Returns
-------
The stream, in the same object a scanner's own would arrive in.
"""
from ._pulseq import read_pulseq_description
return read_pulseq_description(source, **settings)
[docs]
def plot(self, axis: Any = None, *, upto_s: float | None = None) -> Any:
"""Draw the stream: pulses as stems, samples as markers.
What a layout actually laid down, which is easier to check here than
in the signal it produces. A pulse's stem is as tall as the angle it
turns.
Parameters
----------
axis : matplotlib.axes.Axes, optional
Where to draw. A new figure when not given.
upto_s : float, optional
Stop after this much of the stream, for a repetition long enough
that all of it is a smear.
Returns
-------
The axis drawn on.
Raises
------
ImportError: if matplotlib is not installed, which is not a
dependency of this package.
"""
try:
import matplotlib.pyplot as plt
except ImportError as reason: # pragma: no cover - depends on the env
raise ImportError(
"plotting a description needs matplotlib, which blochsim does "
"not depend on; pip install matplotlib"
) from reason
axis = plt.subplots()[1] if axis is None else axis
limit = float("inf") if upto_s is None else upto_s * 1e6
for event in self.events:
when = float(event.timestamp_us)
if when > limit:
break
if event.type is EventType.RF:
axis.vlines(
when / 1000.0,
0.0,
float(event.rf_amplitude_hz) * 180.0 / np.pi,
color="crimson",
lw=2.5,
)
elif event.type is EventType.ADC:
axis.plot(when / 1000.0, 0.0, "v", color="tab:blue", ms=9)
axis.plot([], [], color="crimson", lw=2.5, label="RF, height is the flip")
axis.plot([], [], "v", color="tab:blue", ms=9, label="ADC")
axis.set(xlabel="time [ms]", ylabel="flip angle [deg]")
axis.grid(alpha=0.3)
return axis
@property
def adc_events(self) -> tuple[SequenceEvent, ...]:
"""Return ADC events in state-machine order."""
return tuple(event for event in self.events if event.type is EventType.ADC)
def decompress_shape(
packed: Any,
num_uncompressed: int,
*,
scale: float = 1.0,
) -> np.ndarray:
"""Decode Pulseq derivative/run-length shape compression."""
packed = np.asarray(packed, dtype=np.float32).reshape(-1)
if num_uncompressed < 0:
raise ValueError("negative uncompressed shape length")
if packed.size == num_uncompressed:
return (packed * scale).astype(np.float32, copy=True)
if num_uncompressed == 0 and packed.size == 0:
return np.empty(0, dtype=np.float32)
delta = np.empty(num_uncompressed, dtype=np.float32)
source = 0
target = 0
while source < packed.size:
value = packed[source]
if source + 1 < packed.size and packed[source + 1] == value:
if source + 2 >= packed.size:
raise ValueError("truncated Pulseq RLE triplet")
count_value = float(packed[source + 2]) + 2.0
count = round(count_value)
if abs(count_value - count) > 1e-6 or count < 2:
raise ValueError("invalid Pulseq RLE repeat count")
if target + count > num_uncompressed:
raise ValueError("Pulseq RLE shape expands past declared length")
delta[target : target + count] = value
target += count
source += 3
else:
if target >= num_uncompressed:
raise ValueError("Pulseq shape expands past declared length")
delta[target] = value
target += 1
source += 1
if target != num_uncompressed:
raise ValueError(
f"Pulseq shape expanded to {target}, expected {num_uncompressed}"
)
return (np.cumsum(delta, dtype=np.float32) * scale).astype(np.float32)
[docs]
def rf_definition(
waveform: Any,
*,
dwell_s: float,
bandwidth_hz: float = 0.0,
definition_id: int = 0,
band_frequency_offsets_hz: Any = None,
band_bandwidth_hz: float | None = None,
rf_raster_time_s: float = 1e-6,
) -> RfDefinition:
"""Return the definition of a pulse from the envelope a scanner plays.
The envelope is whatever a designer or a Pulseq block hands over -- a
complex waveform on a uniform raster, in any amplitude units, one row per
transmit channel where there is more than one. What it is scaled to does
not matter and must not be relied on: it is rescaled here so that its
integral is ``1 / (2 pi)`` seconds, which is what makes an event's
amplitude, written in radians, the flip that event turns. A pulse handed
over peak-normalized and left that way turns a fraction of a degree and
returns a signal of nothing, which is the one mistake this exists to stop.
The pulse's own phase is kept rather than divided out, so a linear-phase
pulse keeps the axis it turns about.
Parameters
----------
waveform : array-like
The complex envelope, ``(samples,)`` or ``(channels, samples)``.
dwell_s : float
How long one sample is held, in seconds.
bandwidth_hz : float, optional
The pulse's bandwidth, which the slice select is quoted against. Zero
-- the default -- is a pulse that selects nothing and stays on the
instant operator, and a non-zero one is what makes a pulse slice
selective. There is no separate flag for that, here or in the stream a
description arrives on.
definition_id : int, optional
The identifier events name this pulse by.
band_frequency_offsets_hz : sequence of float, optional
Where each band sits, for a multiband pulse. One band at zero when not
given.
band_bandwidth_hz : float, optional
The bandwidth of one band, defaulting to ``bandwidth_hz``.
rf_raster_time_s : float, optional
The raster the sample times are written against, which is the one the
run will read them back with. Sample times are carried in units of it
rather than in seconds, so a pulse whose dwell is not the raster still
says how long it lasts.
Returns
-------
RfDefinition
The pulse, scaled so an event amplitude in radians is a flip in
radians.
Raises
------
ValueError
If the envelope is empty, if ``dwell_s`` is not positive, or if the
waveform integrates to nothing and so has no flip angle to be read
against.
"""
samples = np.asarray(waveform)
if samples.ndim == 1:
samples = samples[None, :]
if samples.ndim != 2 or samples.size == 0:
raise ValueError(
"an RF envelope is (samples,) or (channels, samples), and not empty"
)
if not dwell_s > 0.0:
raise ValueError(f"dwell_s must be positive, not {dwell_s!r}")
if not rf_raster_time_s > 0.0:
raise ValueError(f"rf_raster_time_s must be positive, not {rf_raster_time_s!r}")
count = samples.shape[-1]
# In units of the raster, which is how a description carries sample times
# and how a run reads them back.
times = RfShape(
count,
(np.arange(count, dtype=np.float64) * dwell_s / rf_raster_time_s).astype(
np.float32
),
)
def shapes(values: np.ndarray) -> Any:
rows = tuple(RfShape(count, row.astype(np.float32)) for row in values)
return rows[0] if len(rows) == 1 else rows
def built(values: np.ndarray, **extra: Any) -> RfDefinition:
return RfDefinition(
id=definition_id,
bandwidth_hz=float(bandwidth_hz),
num_bands=len(offsets),
band_frequency_offsets_hz=offsets,
band_bandwidth_hz=float(
bandwidth_hz if band_bandwidth_hz is None else band_bandwidth_hz
),
magnitude=shapes(np.abs(values)),
phase=shapes(np.angle(values) / (2.0 * np.pi)),
time=times,
**extra,
)
offsets = (
(0.0,)
if band_frequency_offsets_hz is None
else tuple(float(value) for value in band_frequency_offsets_hz)
)
if not 1 <= len(offsets) <= 8:
raise ValueError(
f"a pulse carries one to eight bands, not {len(offsets)}: that is "
f"how many a description has room for"
)
# Scaled against the integral the definition itself reads, rather than one
# taken here: the two would have to agree about the quadrature and about
# what the last sample's duration is, and only one of them decides.
area = abs(
built(samples, total_b1sq_power=0.0).integral(rf_raster_time_s=rf_raster_time_s)
)
if area <= 0.0:
raise ValueError(
"this envelope integrates to nothing, so it has no flip angle to "
"be read against"
)
scaled = samples / (2.0 * np.pi * area)
return built(scaled, total_b1sq_power=float((np.abs(scaled) ** 2).sum() * dwell_s))
def ideal_rf_definition(definition_id: int = 0) -> RfDefinition:
"""Return the definition of a pulse that turns instantly and selects nothing.
Its envelope integrates to exactly ``1 / (2 pi)`` seconds, so an event
amplitude expressed in radians comes back unchanged as the flip that event
turns. It declares no bandwidth, which is what keeps a sequence built from
it on the instant operator rather than on a tabulated rotation.
Parameters
----------
definition_id : int, optional
The identifier events name this pulse by.
Returns
-------
RfDefinition
The ideal hard pulse.
"""
duration_us = 1e6 / (2.0 * np.pi)
return RfDefinition(
id=definition_id,
bandwidth_hz=0.0,
num_bands=1,
band_frequency_offsets_hz=(0.0,),
band_bandwidth_hz=0.0,
total_b1sq_power=0.0,
magnitude=RfShape(2, np.ones(2, dtype=np.float32)),
time=RfShape(2, np.asarray([0.0, duration_us], dtype=np.float32)),
)