Source code for pulserver.ir._convert

"""Conversion of a Pulseq sequence into the scanner's segmented binary IR."""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass, field
from enum import IntEnum
from pathlib import Path
from typing import Any

import pypulseqpp as pp

from .._accelerators import require
from ..mrd._sequence import read_chain
from ._checks import SarRatio
from ._source import conversion_payload


def _rasters(system: pp.Opts) -> tuple[float, ...]:
    """Return the RF, gradient, ADC and block rasters, in us."""
    return (
        system.rf_raster_time * 1e6,
        system.grad_raster_time * 1e6,
        system.adc_raster_time * 1e6,
        system.block_duration_raster * 1e6,
    )


[docs] class Format(IntEnum): """What a quantity is stored as.""" FLOAT32 = 0 INT16 = 1 INT32 = 2
[docs] @dataclass(frozen=True) class Quantity: """The format one quantity is stored in, and what one integer step means. ``step`` is the quantity's SI value of one integer step, so a stored value ``v`` means ``v * step``. It is 0 for a float, which is stored in its SI unit. """ format: Format = Format.FLOAT32 step: float = 0.0
[docs] @dataclass(frozen=True) class VendorProfile: """What a cache holds its numbers as, for the machine that reads it. A sequencer that plays integers is given integers, already scaled, so it converts nothing while it plays; a reader that works in SI is given floats. Every quantity defaults to a float in its SI unit. The steps belong to the machine and arrive with its other limits. Nothing here supplies one. """ grad_sample: Quantity = field(default_factory=Quantity) grad_amplitude: Quantity = field(default_factory=Quantity) rf_sample: Quantity = field(default_factory=Quantity) rf_amplitude: Quantity = field(default_factory=Quantity) rf_phase: Quantity = field(default_factory=Quantity) rf_frequency: Quantity = field(default_factory=Quantity)
[docs] def as_pairs(self) -> tuple[float, ...]: """Return a format and a step per quantity, as the extension takes them.""" return tuple( value for quantity in ( self.grad_sample, self.grad_amplitude, self.rf_sample, self.rf_amplitude, self.rf_phase, self.rf_frequency, ) for value in (float(int(quantity.format)), float(quantity.step)) )
[docs] @dataclass(frozen=True) class Grouping: """Rule by which the blocks of a repetition are grouped into virtual segments. The repetition is cut into contiguous runs of blocks, each a virtual segment the interpreter prepares once and plays as segment instances. A boundary may fall between two blocks only where every gradient is within :attr:`boundary_gradient_hz_per_m` of zero at the join. A boundary must fall where the ``NOROT`` or ``PMC`` label changes, since the interpreter sets one prescription rotation per segment instance; conversion fails when such a change falls under a gradient. The ``split_*`` attributes add boundaries. The default suits an interpreter that begins a segment by setting its gradients, so that a boundary falls only where they are at rest. Attributes ---------- boundary_gradient_hz_per_m Largest gradient amplitude at a join, in Hz/m, at which a boundary may fall. An interpreter that can begin a segment under a gradient states a large value. split_by_pulses, split_by_readouts Whether runs that play different RF events, or different ADC events, are different virtual segments. split_navigators Whether a navigator readout is a virtual segment of its own. split_edge_delays Whether a pure-delay block at the edge of a virtual segment is a virtual segment of its own. An interpreter that inserts transmit-to-receive switching time at every segment boundary needs as few segments as possible, and sets this False. """ boundary_gradient_hz_per_m: float = 100.0 split_by_pulses: bool = True split_by_readouts: bool = True split_navigators: bool = True split_edge_delays: bool = True
[docs] def as_values(self) -> tuple[float, ...]: """Return the rule as the extension takes it, in the order C declares it.""" return ( float(self.boundary_gradient_hz_per_m), float(self.split_by_pulses), float(self.split_by_readouts), float(self.split_navigators), float(self.split_edge_delays), )
[docs] @dataclass(frozen=True) class WaveBudget: """What a playout's waveform memory affords the waves. A property of the playout, not of the sequence: the C library's ``pulseg_wave_budget``. :func:`convert` lays the waves out for it, and a playout refuses a cache laid out for another budget. Attributes ---------- max_samples Samples each gradient axis holds for waves. raster_us The playout's gradient raster, in µs per sample. load_us_per_sample Time to sample and load one sample on one axis, in µs; 0 leaves the loading unchecked. headroom Share of the playout's time its loading may take. slots Slots per segment position a streamed layout rings through, at least 2: one more than the segment instances the loading may run ahead of the playout. Raises ------ ValueError If the raster or the headroom is not positive, the memory or the load rate is negative, or there are fewer than two slots. """ max_samples: int raster_us: float load_us_per_sample: float = 0.0 headroom: float = 0.5 slots: int = 2 def __post_init__(self) -> None: if self.raster_us <= 0.0 or self.headroom <= 0.0: raise ValueError("the raster and the headroom must be positive") if self.max_samples < 0 or self.load_us_per_sample < 0.0: raise ValueError("the memory and the load rate cannot be negative") if self.slots < 2: raise ValueError("a streamed layout rings through at least two slots")
def _held(budget: WaveBudget | None) -> tuple[int, float, float, float, int] | None: """Return the budget as the extension takes it.""" if budget is None: return None return ( budget.max_samples, budget.raster_us, budget.load_us_per_sample, budget.headroom, budget.slots, )
[docs] def cache_path(seq_path: Path | str, cache_ext: str = ".pseg") -> Path: """Return the cache file of a sequence: its last suffix replaced by ``cache_ext``.""" return Path(seq_path).with_suffix(cache_ext)
[docs] def chain(seq_path: Path | str) -> list[Path]: """Return the files of the ``NextSequence`` chain starting at a sequence file, in play order. Raises ------ FileNotFoundError If a file of the chain does not exist. ValueError If the chain names a file it has already played. """ return [path for path, _ in read_chain(seq_path)]
[docs] def convert( seq_path: Path | str, system: pp.Opts, *, fov_offset: Sequence[float] | None = None, vendor: int = 0, label_column_map: Sequence[int] = (0, 1, 2), cache_ext: str = ".pseg", verify_signature: bool = True, sar_ratios: Sequence[SarRatio] | None = None, wave_budget: WaveBudget | None = None, profile: VendorProfile | None = None, grouping: Grouping | None = None, designed: list[tuple[Path, pp.Sequence]] | None = None, ) -> Path: """Segment a sequence file and write its IR cache beside it. The ``NextSequence`` chain starting at the file is read as the subsequences of one scan. An existing cache at the destination is replaced. RF vendor statistics are left at zero: ``vendor`` only tags the cache for the reader that loads it, which must be built for that vendor. The files are expected in the logical frame, and each is moved to ``fov_offset`` with :func:`prescribe` before it is segmented. The prescription's rotation is not applied here: the scanner plays the cache through its rotation matrix, composed after each block's own rotation. ADC phase modulation is not stored in the cache: the reconstruction proxy applies it to the received samples from the sequence (:class:`~pulserver.proxy.SequenceTable`). Parameters ---------- seq_path Text or binary Pulseq file. system Limits and rasters the scan is segmented under. fov_offset Translation of the field-of-view centre along the logical readout, phase and slice axes, in metres. None or zero leaves the files as designed. vendor ``PULSEG_VENDOR_*`` code; 0 is vendor-neutral. label_column_map Pulseq label state indices filling the three ADC label columns: 0 SLC, 1 PHS, 2 REP, 3 AVG, 4 SEG, 5 SET, 6 ECO, 7 PAR, 8 LIN, 9 ACQ. cache_ext Extension of the cache file, dot included. verify_signature Refuse a file whose contents do not match the signature it carries. A file carrying none is read either way. sar_ratios One per file of the chain, as :func:`sar_ratios` returns them, written into each subsequence of the cache; zero when None. profile What the cache holds its numbers as; every quantity a float in its SI unit when left out. grouping How the blocks of a repetition are grouped into virtual segments. Left out, ``Grouping()``: a boundary falls only where every gradient is within 100 Hz/m of zero, and must fall where the ``NOROT`` or ``PMC`` label changes. wave_budget The waveform memory of the playout the cache is for, which the cache lays the waves out in (:func:`plan_waves`); None holds every wave at once on the gradient raster of the chain's first file. designed The chain as :func:`pulserver.mrd.designed_chain` returns it, segmented in place of reading and verifying the files, and moved to ``fov_offset`` in place. Returns ------- Path The cache file. Raises ------ ValueError If a file of the chain cannot be read, verified or segmented, ``sar_ratios`` does not give one per file, or the waves fit neither layout of ``wave_budget`` or cannot be loaded in time. OSError If no cache was written. """ cache_path(Path(seq_path), cache_ext).unlink(missing_ok=True) payload = _payload(Path(seq_path), system, verify_signature, fov_offset, designed) return _write_cache( seq_path, system, payload, vendor=vendor, label_column_map=label_column_map, cache_ext=cache_ext, sar_ratios=sar_ratios, wave_budget=wave_budget, profile=profile, grouping=grouping, )
def _write_cache( seq_path: Path | str, system: pp.Opts, payload: list[dict[str, Any]], *, vendor: int = 0, label_column_map: Sequence[int] = (0, 1, 2), cache_ext: str = ".pseg", sar_ratios: Sequence[SarRatio] | None = None, wave_budget: WaveBudget | None = None, profile: VendorProfile | None = None, grouping: Grouping | None = None, ) -> Path: """Segment the libraries :func:`_payload` returned and write the cache, as :func:`convert`. The libraries are copies, so the sequences they were taken from may change while this runs; the segmentation releases the GIL. """ seq_path = Path(seq_path) target = cache_path(seq_path, cache_ext) target.unlink(missing_ok=True) if sar_ratios is not None: if len(sar_ratios) != len(payload): raise ValueError( f"expected one SAR ratio per file of the chain, {len(payload)}; " f"got {len(sar_ratios)}" ) for libraries, ratio in zip(payload, sar_ratios, strict=True): libraries["reserved"]["vop_sar_ratio"] = float(ratio.local_sar) libraries["reserved"]["vop_global_sar_ratio"] = float(ratio.global_sar) require("convert_libraries")( payload, str(seq_path), *_rasters(system), int(vendor), list(label_column_map), cache_ext, _held(wave_budget), None if profile is None else profile.as_pairs(), None if grouping is None else grouping.as_values(), ) if not target.is_file(): raise OSError(f"no cache was written for {seq_path}") return target
[docs] def summary( seq_path: Path | str, system: pp.Opts, *, cache_ext: str | None = None, label_column_map: Sequence[int] = (0, 1, 2), ) -> dict[str, Any]: """Return the segmentation of a sequence: subsequences, segments and readouts. Each subsequence lists its unique RF definitions under ``rf``: the flip angle in degrees at the largest amplitude the definition plays, as ``pypulseqpp.Sequence.rf_flip_angles`` gives it; and the bandwidth at half the spectral peak, the number of bands, each band's offset from the carrier and the widest band's bandwidth, all in Hz, as ``pypulseqpp.calc_rf_bandwidth`` measures them; and ``b1sq_integral_s``, the integral of the squared envelope scaled to unit peak, in s: ``pypulseqpp.calc_rf_power``'s energy over its peak power, both summed over a dynamic pTx pulse's channels. ``vop_sar_ratio`` and ``vop_global_sar_ratio`` are those the cache was written with, zero when the chain is read and segmented again. ``readout_labels`` lists, per readout in play order, the values of the three labels ``label_column_map`` selects, as in force at that readout. ``waves`` lists the subsequence's waves, as :func:`play` indexes them: each one's point count, and its largest magnitude along x, y and z over that of the largest gradient event it combines. ``grad_raster_us`` is the raster the file's gradient shapes are sampled on, in µs. With ``cache_ext``, the cache beside the file is loaded instead of the chain being read and segmented again; this build loads only vendor-neutral caches, and only when the size recorded in the cache matches the file. Raises ------ ValueError If the file cannot be read or the cache cannot be loaded. """ seq_path = Path(seq_path) if cache_ext is None: return require("summary_from_libraries")( _payload(seq_path, system, verify_signature=False), *_rasters(system), list(label_column_map), ) return require("summary_from_cache")( str(cache_path(seq_path, cache_ext)), seq_path.stat().st_size )
[docs] def prescribe(sequence: pp.Sequence, fov_offset: Sequence[float]) -> pp.Sequence: """Move a logical-frame sequence to a prescribed field-of-view centre, in place. An offset along the slice axis becomes an RF frequency, an in-plane offset an RF and ADC phase, each referenced to the excitation it follows; blocks labelled ``NOPOS`` are exempt. The gradient area is counted from the sequence's first block. A block that carries a rotation extension is moved by the gradients it plays: those it draws, turned by that rotation. Parameters ---------- sequence One file of a chain, as designed. fov_offset Translation along the logical readout, phase and slice axes, in metres. Returns ------- pypulseqpp.Sequence ``sequence`` itself. Raises ------ ValueError If ``fov_offset`` is not three values. """ shift = tuple(float(v) for v in fov_offset) if len(shift) != 3: raise ValueError(f"fov_offset takes three values, got {len(shift)}") if any(shift): pp.TransformFOV(translation=shift, through_rotation=True).apply_to_sequence( sequence, in_place=True ) return sequence
[docs] def play( seq_path: Path | str, cache_ext: str = ".pseg", *, waveforms: bool = False ) -> dict[str, Any]: """Walk the cache beside a sequence file as the scanner's playout does. The cache is loaded by the C library a scanner links, and its execution stream is walked with that library's cursor: one entry per played block, in play order, across the subsequences of the chain. This build loads only vendor-neutral caches, and only when the size recorded in the cache matches the file. Returns ------- dict of str to ndarray One entry per played block in each: - ``subsequence``, ``segment``: chain file and cache segment indices; - ``duration_us``: block duration, in µs; - ``rf_amp_hz``, ``rf_freq_hz``, ``rf_phase_rad``: RF amplitude (gamma B1) and frequency and phase offsets, with ppm offsets resolved at the field strength the cache was converted under; 0 without RF; - ``rf_use``: the ``PULSEG_RF_USE_*`` code of the RF event, 1 for an excitation and 2 for a refocusing pulse; a pulse the file leaves unlabelled takes the use pypulseqpp detects when the chain is read, and an event a cache carries without one is a refocusing pulse at a flip angle of 162 to 198 degrees and an excitation otherwise; 0 without RF; - ``rf_delay_us``: RF delay from the block's start, in µs; - ``rf_channels``: the transmit channels the RF waveform holds, one after another over one time base for a dynamic pTx pulse; 0 without RF; - ``rf_grad_constant``, ``rf_grad_level``: 1 where every instance of the block's position plays its RF pulse under one gradient, steady from the pulse's first sample to its last as ``pypulseqpp.Sequence.rf_gradients`` finds it, and the block carries no rotation, so the scanner may move the excitation by a carrier offset; and ``(blocks, 3)``, that gradient along x, y and z over the amplitude of the event playing it there; - ``gradient_hz_per_m``: ``(blocks, 3)``, the amplitude along the logical x, y and z axes: the factor on the shape of the gradient event, or on the wave, the block plays there, each normalised to a largest magnitude of one; - ``wave``: the wave the block plays, as indexed by the subsequence's ``waves`` in :func:`summary`, or -1 for a block that plays its gradient events as they are. A block plays one at every segment position where an instance carries a rotation other than the identity, or plays a gradient definition or shape other than the one the position's events are prepared with: its gradient events combined and turned by its rotation, so that the prescription's rotation is the only one the scanner applies; - ``norot``, ``nopos``: the block's NOROT and NOPOS flags; - ``adc``, ``adc_freq_hz``, ``adc_phase_rad``: whether the block acquires, and its frequency and phase offsets; - ``adc_delay_us``, ``adc_dwell_ns``, ``adc_samples``: the ADC delay from the block's start, dwell time and sample count; 0 without ADC; - ``trid``: the TRID group in force, 0 when ungrouped. With ``waveforms``, also: - ``rf_center_us``: the time of the RF centre the design records, from the block's start, in µs; NaN without RF; - ``rf_time_us``, ``rf_waveform_hz``: the samples of every played RF pulse, concatenated in play order: their times from the block's start, in µs, and the instance's amplitude times the magnitude shape and ``exp(i phase)`` of the phase shape its definition carries, complex, in Hz. The pulse plays each sample turned by the phase offset and advancing at the frequency offset from its start. The channels of a pTx pulse follow one another, each over the one time base; - ``rf_span``: ``(blocks, 2)``, the start and stop of each block's RF in those arrays; empty without RF; - ``gradient_time_us``, ``gradient_waveform_hz_per_m``: the corners of every played gradient, concatenated in play order: their times from the block's start, in µs, and the gradient there, the amplitude times the shape or wave the instance plays. A waveform on the gradient raster holds its end values over the half raster intervals before its first sample and after its last. A rotated wave takes the corners of every gradient event it combines, or the raster centres across them when one is an arbitrary gradient on the raster; - ``gradient_span``: ``(blocks, 3, 2)``, the start and stop of each block's gradient along x, y and z in those arrays; empty without one; - ``adc_phase_modulation_rad``: the phase modulation of every played readout, one phase per sample in radians, concatenated in play order; the receiver phase of a sample is the ADC phase offset, plus its frequency offset times the time since the ADC's start, plus this. Empty for a cache converted here, which leaves the modulation to the reconstruction proxy; - ``adc_modulation_span``: ``(blocks, 2)``, the start and stop of each block's modulation in that array; empty without one. Raises ------ ValueError If the cache cannot be loaded. RuntimeError With ``waveforms``, if a block's gradient holds another number of samples than the segment position it plays at is prepared with, or its wave more points than that position reserves. """ seq_path = Path(seq_path) return require("play_cache")( str(cache_path(seq_path, cache_ext)), seq_path.stat().st_size, waveforms )
def _payload( seq_path: Path, system: pp.Opts, verify_signature: bool, fov_offset: Sequence[float] | None = None, designed: list[tuple[Path, pp.Sequence]] | None = None, ) -> list[dict[str, Any]]: """Read the chain and return each file's libraries, in play order, prescribed to ``fov_offset``. A file the reader refuses raises ``ValueError``, whatever the reader itself raised. """ if designed is not None: chain_read = designed else: try: chain_read = read_chain(seq_path, verify=verify_signature) except RuntimeError as failure: raise ValueError(f"cannot read {seq_path}: {failure}") from failure payload = [] for _, sequence in chain_read: if fov_offset is not None: prescribe(sequence, fov_offset) payload.append(conversion_payload(sequence, system)) return payload