Source code for blochsim.simulators.truefisp

"""A balanced SSFP train played with pulses of finite duration."""

from __future__ import annotations

__all__ = ["TrueFISPSimulator"]

import numpy.typing as npt
import torch

from ..model import Simulator, SpinPhysics
from ..sequence import Readout, module
from ._pulses import excitation, half_alpha_parts, inversion_parts
from .flash import _per_shot


[docs] class TrueFISPSimulator(Simulator): """A balanced SSFP train from equilibrium, optionally inverted first. The pulses alternate in phase and each sample is demodulated by the phase of its own pulse. An alpha/2 pulse of opposite phase, played before the train, damps the oscillation of the approach to the steady state [1]_; after an inversion, the train is the inversion-recovery TrueFISP whose recovery curve carries T1, T2 and the proton density together [2]_. Every pulse is a windowed sinc played sample by sample, so the magnetization relaxes, precesses off resonance and exchanges between pools while it plays, unless ``pulse_duration`` is zero and each is an instantaneous rotation. This is the sequence BART's ``sim`` plays as ``BSSFP`` and ``IR-BSSFP``, and with instantaneous pulses the one its ``epg`` plays as bSSFP. References ---------- .. [1] Deimling, M., Heid, O., "Magnetization prepared True FISP imaging", Proceedings of the International Society for Magnetic Resonance in Medicine 2 (1994), p. 495. .. [2] Schmitt, P., Griswold, M. A., Jakob, P. M., et al., "Inversion recovery TrueFISP: quantification of T1, T2, and spin density", Magnetic Resonance in Medicine 51.4 (2004), pp. 661-667. https://doi.org/10.1002/mrm.20058 Examples -------- .. exec:: from blochsim.simulators import TrueFISPSimulator sequence = TrueFISPSimulator( flip=45.0, TR=4.5, nshots=200, inversion="adiabatic" ) signal = sequence.simulate(T1=1000.0, T2=100.0) print(signal.shape) """ model = SpinPhysics( properties={ "T1": "t1_ms", "T2": "t2_ms", "M0": "m0", "B1": "b1", "B0": "b0_hz", }, ) # A balanced train winds nothing on, so order zero is the whole state. states = 1
[docs] def layout( self, *, flip: float | npt.ArrayLike, TR: float, nshots: int, TE: float | None = None, pulse_duration: float = 1.0, bandwidth_time: float = 4.0, half_alpha: bool = True, preparation: float | None = None, inversion: str | None = None, inversion_duration: float = 10.0, inversion_phase: float = 0.0, spoiler: float = 0.0, dwell: float = 0.01, ) -> list: """Return the train, one sample per shot. Parameters ---------- flip : float or array-like Flip angle in degrees, scalar or one per shot. TR : float Repetition time in milliseconds, pulse centre to pulse centre. nshots : int Excitations in the train. TE : float, optional Echo time in milliseconds, from the centre of the pulse; half the repetition time when not given, where a balanced train refocuses. pulse_duration : float, optional Duration of each pulse in milliseconds. Zero plays each as an instantaneous rotation. bandwidth_time : float, optional Zero crossings of the Hamming-windowed sinc across the pulse. half_alpha : bool, optional Whether an alpha/2 pulse prepares the train. preparation : float, optional Time in milliseconds from the start of the alpha/2 pulse to the start of the train's first pulse, half the repetition time when not given. Zero is an instantaneous alpha/2 rotation at the start of the train. inversion : {None, "ideal", "adiabatic"}, optional What comes before the preparation: nothing, an instantaneous inversion scaled by ``inv_efficiency``, or a hyperbolic secant played sample by sample. inversion_duration : float, optional Duration of the adiabatic inversion in milliseconds. inversion_phase : float, optional Phase of the adiabatic inversion in degrees, relative to the train's first pulse, which decides where the transverse magnetization the inversion leaves points. spoiler : float, optional Time in milliseconds between the inversion and the preparation, at the end of which the transverse magnetization is spoiled. dwell : float, optional How long each sample of a pulse is held, in milliseconds. Raises ------ ValueError If the sample falls inside the pulse or after the next one, if the preparation is shorter than its pulse, if ``flip`` is neither scalar nor one per shot, or if a pulse is not a whole number of samples. """ pulse_s, dwell_s = 1e-3 * pulse_duration, 1e-3 * dwell repetition_s = 1e-3 * TR echo_s = repetition_s / 2 if TE is None else 1e-3 * TE if echo_s < pulse_s / 2 or pulse_s / 2 + echo_s > repetition_s: raise ValueError( "TE is measured from the centre of the pulse, so it is at least " "half the pulse and ends before the next one begins" ) angles = _per_shot(flip, nshots) pulse = excitation(pulse_s, dwell_s, bandwidth_time=bandwidth_time) parts = inversion_parts( inversion, duration_s=1e-3 * inversion_duration, phase_rad=torch.deg2rad(torch.as_tensor(inversion_phase)), spoiler_s=1e-3 * spoiler, dwell_s=dwell_s, ) if half_alpha: spacing_s = repetition_s / 2 if preparation is None else 1e-3 * preparation parts += half_alpha_parts( angles[0], spacing_s=spacing_s, duration_s=pulse_s, dwell_s=dwell_s, bandwidth_time=bandwidth_time, ) for shot, angle in enumerate(angles.unbind(0)): turn = torch.pi * (shot % 2) parts.append( module( pulse(angle, turn), (pulse_s / 2 + echo_s, Readout(turn)), duration_s=repetition_s, ) ) return parts