Repository: PTB-MR/mrpro
Pull request #622: Add EPG Signalmodel
Author: login: fzimmermann89
Created: 2025-01-26T22:46:19Z | Merged: 2025-03-30T18:13:01Z

Block-based EPG and a fixed cMRF signal model


Diff:
diff --git a/src/mrpro/operators/models/EPG.py b/src/mrpro/operators/models/EPG.py
new file mode 100644
index 000000000..835e51101
--- /dev/null
+++ b/src/mrpro/operators/models/EPG.py
@@ -0,0 +1,685 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse in rad
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3 (f_plus, f_minus, z), n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3 (f_plus, f_minus, z), n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery.
+        Shape `..., 3 (f_plus, f_minus, z), n`
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the acquired signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Should match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the state tensor on.
+    dtype
+        Data type of the state tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape, 3 (f_plus, f_minus, z), n`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF pulse block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle in rad
+        phase
+            initial rf phase in rad
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF pulse to the EPG state.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states
+
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+        if (self.te > self.tr).any():
+            raise ValueError(f'echotime ({self.te}) should be smaller than repetition time ({self.tr}).')
+        if (self.te < 0).any():
+            raise ValueError(f'Negative echo time ({self.te.amin()}) not allowed.')
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states
+
+        Returns
+        -------
+            EPG configuration states after the block and the acquired signals
+        """
+        signal = []
+        # +1 for time dimension
+        unsqueezed = unsqueeze_tensors_right(
+            self.flip_angles, self.rf_phases, self.te, self.tr, ndim=parameters.ndim + 1
+        )
+        for flip_angle, rf_phase, te, tr in zip(*unsqueezed, strict=True):
+            states = rf(states, rf_matrix(flip_angle, rf_phase, parameters.relative_b1))
+            states = relax(states, relax_matrix(te, parameters.t1, parameters.t2))
+            signal.append(acquisition(states, parameters.m0))
+            states = gradient_dephasing(states)
+            states = relax(states, relax_matrix((tr - te), parameters.t1, parameters.t2))
+        return states, tuple(signal)
+
+
+class InversionBlock(EPGBlock):
+    """T1 Inversion Preparation Block.
+
+    The inversion pulse is assumed to be B1 insensitive.
+    """
+
+    def __init__(self, inversion_time: torch.Tensor | float) -> None:
+        """Initialize the inversion block.
+
+        Parameters
+        ----------
+        inversion_time
+            Inversion time
+        """
+        super().__init__()
+
+        self.inversion_time = torch.as_tensor(inversion_time)
+        if (self.inversion_time < 0).any():
+            raise ValueError(f'Negative inversion time ({self.inversion_time.amin()}) not allowed.')
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the inversion block to the EPG state.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(0.0)))
+        states = relax(states, relax_matrix(self.inversion_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.inversion_time
+
+
+class T2PrepBlock(EPGBlock):
+    """T2 Preparation Block.
+
+    Consists of a 90° pulse, relaxation during TE/2, 180° pulse, relaxation during TE/2 and a -90° pulse.
+
+    The pulses are assumed to be B1 insensitive.
+    """
+
+    def __init__(self, te: torch.Tensor | float) -> None:
+        """Initialize the T2 preparation block.
+
+        Parameters
+        ----------
+        te
+            Echo time of the T2 preparation block
+        """
+        super().__init__()
+
+        self.te = torch.as_tensor(te)
+        if (self.te < 0).any():
+            raise ValueError(f'Negative echo time ({self.te.amin()}) not allowed.')
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the T2 preparation block to the EPG state.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        # 90° pulse -> relaxation during TE/2 -> 180° pulse -> relaxation during TE/2 -> -90° pulse
+        te2_relax = relax_matrix(self.te / 2, parameters.t1, parameters.t2)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), torch.tensor(0.0)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(torch.pi / 2)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), -torch.tensor(torch.pi)))
+        # Spoiler
+        states = gradient_dephasing(states)
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.te
+
+
+class DelayBlock(EPGBlock):
+    """Delay Block."""
+
+    def __init__(self, delay_time: torch.Tensor | float) -> None:
+        """Initialize the delay block.
+
+        Parameters
+        ----------
+        delay_time
+            Delay time
+        """
+        super().__init__()
+        self.delay_time = torch.as_tensor(delay_time)
+        if (self.delay_time < 0).any():
+            raise ValueError(f'Negative delay time ({self.delay_time.amin()})')
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the delay block to the EPG state.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        (delay_time,) = unsqueeze_tensors_right(self.delay_time, ndim=parameters.ndim)
+        states = relax(states, relax_matrix(delay_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.delay_time
+
+
+class EPGSequence(torch.nn.ModuleList, EPGBlock):
+    """Sequene of EPG blocks.
+
+    A sequence as multiple blocks, such as preparation pulses, acquisition blocks and delays.
+
+    Classic MRF with a single inversion-pulse at the beginning followed by a train of RF pulses with different flip
+    angles as described in [MA2013]_ can be simulated by the following sequence:
+     - InversionBlock
+     - FispBlock with varying flip_angles
+
+        You can also nest `EPGSequence`, i.e., use it as a block in another `EPGSequence`. This allows to describe
+        complex MR acquisitions with multiple repetitions of the same blocks.
+
+
+    References
+    ----------
+    .. [MA2013] Ma, D et al.(2013) Magnetic resonance fingerprinting. Nature 495 http://dx.doi.org/10.1038/nature11971
+
+
+    """
+
+    def __init__(self, blocks: Sequence[EPGBlock] = ()) -> None:
+        """Initialize an EPG Sequence.
+
+        Parameters
+        ----------
+        blocks
+            blocks such as RF, delays, acquisitions etc.
+        """
+        torch.nn.ModuleList.__init__(self, blocks)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the sequence of blocks to the EPG states.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+
+        Returns
+        -------
+            EPG configuration states after the sequence of blocks and the acquired signals
+        """
+        signals: list[torch.Tensor] = []
+        block: EPGBlock
+        for block in self:
+            states, signal = block(parameters, states)
+            signals.extend(signal)
+        return states, tuple(signals)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return sum(block.duration for block in self)
+
+
+__all__ = [
+    'AcquisitionBlock',
+    'DelayBlock',
+    'EPGSequence',
+    'FispBlock',
+    'GradientDephasingBlock',
+    'InversionBlock',
+    'Parameters',
+    'RFBlock',
+    'T2PrepBlock',
+    'initial_state',
+]
diff --git a/src/mrpro/operators/models/__init__.py b/src/mrpro/operators/models/__init__.py
index ec540b247..8f1e09151 100644
--- a/src/mrpro/operators/models/__init__.py
+++ b/src/mrpro/operators/models/__init__.py
@@ -6,8 +6,13 @@
 from mrpro.operators.models.WASABI import WASABI
 from mrpro.operators.models.WASABITI import WASABITI
 from mrpro.operators.models.MonoExponentialDecay import MonoExponentialDecay
+from mrpro.operators.models.cMRF import CardiacFingerprinting
 from mrpro.operators.models.TransientSteadyStateWithPreparation import TransientSteadyStateWithPreparation
+from mrpro.operators.models import EPG
+
 __all__ = [
+    "CardiacFingerprinting",
+    "EPG",
     "InversionRecovery",
     "MOLLI",
     "MonoExponentialDecay",
@@ -15,4 +20,4 @@
     "TransientSteadyStateWithPreparation",
     "WASABI",
     "WASABITI"
-]
+]
\ No newline at end of file
diff --git a/src/mrpro/operators/models/cMRF.py b/src/mrpro/operators/models/cMRF.py
new file mode 100644
index 000000000..130c4b247
--- /dev/null
+++ b/src/mrpro/operators/models/cMRF.py
@@ -0,0 +1,116 @@
+"""Cardiac MR Fingerprinting signal model (EPG)."""
+
+from collections.abc import Sequence
+
+import torch
+
+from mrpro.operators.models.EPG import DelayBlock, EPGSequence, FispBlock, InversionBlock, Parameters, T2PrepBlock
+from mrpro.operators.SignalModel import SignalModel
+
+
+class CardiacFingerprinting(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor]):
+    """Cardiac MR Fingerprinting signal model.
+
+    This model simulates a cardiac MR fingerprinting sequence as described in [HAMI2017]_ and [HAMI2020]_ using the
+    extended phase graph (`~mrpro.operators.models.EPG`) formalism.
+
+    It is a three-fold repetition of
+
+    .. code-block:: text
+
+        Block 0          Block 1          Block 2          Block 3          Block 4
+        R-peak           R-peak           R-peak           R-peak           R-peak
+        |----------------|----------------|----------------|----------------|----------------
+         [INV 30ms][ACQ]           [ACQ]   [T2-prep][ACQ]   [T2-prep][ACQ]  [T2-prep][ACQ]
+
+    .. note::
+
+       This model is on purpose not flexible in all design choices. Instead, consider writing a custom
+       `~mrpro.operators.SignalModel` based on this implementation if you need to simulate a different sequence.
+
+    References
+    ----------
+    .. [HAMI2017] Hamilton, J. I. et al. (2017) MR fingerprinting for rapid quantification of myocardial T1, T2, and
+           proton spin density. Magn. Reson. Med. 77 http://doi.wiley.com/10.1002/mrm.26216
+    .. [HAMI2020] Hamilton, J.I. et al. (2020) Simultaneous Mapping of T1 and T2 Using Cardiac Magnetic Resonance
+           Fingerprinting in a Cohort of Healthy Subjects at 1.5T. J Magn Reson Imaging, 52: 1044-1052. https://doi.org/10.1002/jmri.27155
+    """
+
+    def __init__(
+        self,
+        acquisition_times: torch.Tensor | Sequence[int] = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14),
+        echo_time: float = 0.0015,
+        repetition_time: float = 0.005,
+        t2_prep_echo_times: tuple[float, float, float] = (0.03, 0.05, 0.08),
+    ) -> None:
+        """Initialize the Cardiac MR Fingerprinting signal model.
+
+        Parameters
+        ----------
+        acquisition_times
+            Times of the acquisistions in s.
+            Either 15 values (the first acquisition of each block) or 705 values for all acquisitions.
+            In both cases only the time of the first acquisition of each block will be used to calculate the delays.
+
+        echo_time
+            TE in s
+        repetition_time
+            TR in s
+        t2_prep_echo_times
+            Echo times of the three T2 preparation blocks in s
+        """
+        super().__init__()
+        self.sequence = EPGSequence()
+        max_flip_angles_deg = [12.5, 18.75, 25, 25, 25, 12.5, 18.75, 25, 25.0, 25, 12.5, 18.75, 25, 25, 25]
+        if len(acquisition_times) == 15:
+            block_time = torch.as_tensor(acquisition_times)
+        elif len(acquisition_times) == 705:
+            block_time = torch.as_tensor(acquisition_times[::47])
+        else:
+            raise ValueError(f'Invalid number of acquisition times: {len(acquisition_times)}. Must be 15 or 705.')
+        for i in range(15):
+            block = EPGSequence()
+            match i % 5:
+                case 0:
+                    block.append(InversionBlock(0.020))
+                case 1:
+                    pass
+                case 2:
+                    block.append(T2PrepBlock(t2_prep_echo_times[0]))
+                case 3:
+                    block.append(T2PrepBlock(t2_prep_echo_times[1]))
+                case 4:
+                    block.append(T2PrepBlock(t2_prep_echo_times[2]))
+            flip_angles = torch.deg2rad(
+                torch.cat((torch.linspace(4, max_flip_angles_deg[i], 16), torch.full((31,), max_flip_angles_deg[i])))
+            )
+            block.append(FispBlock(flip_angles, 0.0, tr=repetition_time, te=echo_time))
+
+            if i > 0:
+                delay = (block_time[i] - block_time[i - 1]) - block.duration
+                if (delay < 0).any():
+                    raise ValueError(f'Block {i} would start before the previous block finished. ')
+                self.sequence.append(DelayBlock(delay))
+            self.sequence.append(block)
+
+    def forward(self, m0: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> tuple[torch.Tensor]:
+        """Simulate the Cardiac MR Fingerprinting signal.
+
+        Parameters
+        ----------
+        m0
+            steady state magnetization (complex)
+        t1
+            longitudinal relaxation time T1 in s
+        t2
+            transversal relaxation time T2 in s
+
+
+        Returns
+        -------
+            Simulated Cardiac MR Fingerprinting signal with the different acquisitions in the first dimension.
+        """
+        parameters = Parameters(m0, t1, t2)
+        _, signals = self.sequence(parameters, states=20)
+        signal = torch.stack(signals, dim=0)
+        return (signal,)
diff --git a/src/mrpro/utils/__init__.py b/src/mrpro/utils/__init__.py
index a58cce7cc..7fe35155b 100644
--- a/src/mrpro/utils/__init__.py
+++ b/src/mrpro/utils/__init__.py
@@ -34,4 +34,4 @@
     "unsqueeze_tensors_left",
     "unsqueeze_tensors_right",
     "zero_pad_or_crop"
-]
\ No newline at end of file
+]
diff --git a/src/mrpro/utils/reshape.py b/src/mrpro/utils/reshape.py
index 240ba6b4e..977de83d1 100644
--- a/src/mrpro/utils/reshape.py
+++ b/src/mrpro/utils/reshape.py
@@ -26,7 +26,8 @@ def unsqueeze_right(x: torch.Tensor, n: int) -> torch.Tensor:
     -------
         unsqueezed tensor (view)
     """
-    return x.reshape(*x.shape, *(n * (1,)))
+    new_shape = list(x.shape) + [1] * n
+    return x.reshape(new_shape)
 
 
 def unsqueeze_left(x: torch.Tensor, n: int) -> torch.Tensor:
@@ -47,7 +48,8 @@ def unsqueeze_left(x: torch.Tensor, n: int) -> torch.Tensor:
     -------
         unsqueezed tensor (view)
     """
-    return x.reshape(*(n * (1,)), *x.shape)
+    new_shape = [1] * n + list(x.shape)
+    return x.reshape(new_shape)
 
 
 def unsqueeze_at(x: torch.Tensor, dim: int, n: int) -> torch.Tensor:
diff --git a/tests/operators/models/test_cmrf.py b/tests/operators/models/test_cmrf.py
new file mode 100644
index 000000000..fd8dfdae1
--- /dev/null
+++ b/tests/operators/models/test_cmrf.py
@@ -0,0 +1,76 @@
+from collections.abc import Sequence
+
+import pytest
+import torch
+from mrpro.operators.models.cMRF import CardiacFingerprinting
+from mrpro.utils.reshape import unsqueeze_right
+from tests import RandomGenerator, autodiff_test
+
+
+def test_cmrf_basic(parameter_shape: Sequence[int] = (2, 1, 3, 4, 2)) -> None:
+    """Test the CMRF model."""
+    acquisition_times = unsqueeze_right(torch.linspace(0, 15, 705), len(parameter_shape)).expand(-1, *parameter_shape)
+    model = CardiacFingerprinting(acquisition_times=acquisition_times)
+    rng = RandomGenerator(0)
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+    (signal,) = model(m0, t1, t2)
+    assert signal.isfinite().all()
+    assert signal.shape == (705, *parameter_shape)
+
+
+def test_cmrf_autodiff(parameter_shape: Sequence[int] = (2, 1, 3, 4, 2)) -> None:
+    """Test autodiff works for cMRF model."""
+    rng = RandomGenerator(8)
+    acquisition_times = rng.float32_tensor(15, low=0.8, high=2).cumsum(0)
+    model = CardiacFingerprinting(acquisition_times=acquisition_times)
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+    autodiff_test(model, m0, t1, t2)
+
+
+@pytest.mark.cuda
+def test_cmrf_cuda(parameter_shape: Sequence[int] = (2,)) -> None:
+    """Test the cMRF model works on cuda devices."""
+    rng = RandomGenerator(8)
+    acquisition_times = rng.float32_tensor(15, low=0.8, high=2).cumsum(0)
+
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+
+    # Create on CPU, transfer to GPU and run on GPU
+    model = CardiacFingerprinting(acquisition_times)
+    model.cuda()
+    (signal,) = model(m0.cuda(), t1.cuda(), t2.cuda())
+    assert signal.is_cuda
+    assert signal.isfinite().all()
+
+    # Create on GPU and run on GPU
+    model = CardiacFingerprinting(acquisition_times.cuda())
+    (signal,) = model(m0.cuda(), t1.cuda(), t2.cuda())
+    assert signal.is_cuda
+    assert signal.isfinite().all()
+
+    # Create on GPU, transfer to CPU and run on CPU
+    model = CardiacFingerprinting(acquisition_times.cuda())
+    model.cpu()
+    (signal,) = model(m0, t1, t2)
+    assert signal.is_cpu
+    assert signal.isfinite().all()
+
+
+def test_cmrf_invalid():
+    """Test invalid parameters for cMRF model."""
+    with pytest.raises(ValueError, match='acquisition times'):
+        CardiacFingerprinting(acquisition_times=torch.ones(16))
+    with pytest.raises(ValueError, match='would start before the previous block finished'):
+        CardiacFingerprinting(acquisition_times=torch.ones(15))
+    with pytest.raises(ValueError, match='would start before the previous block finished'):
+        CardiacFingerprinting(repetition_time=5.0)
+    with pytest.raises(ValueError, match='should be smaller than repetition time'):
+        CardiacFingerprinting(echo_time=0.006)
+    with pytest.raises(ValueError, match='Negative echo time'):
+        CardiacFingerprinting(t2_prep_echo_times=(-0.01, 0.05, 0.08))
diff --git a/tests/operators/models/test_epg.py b/tests/operators/models/test_epg.py
new file mode 100644
index 000000000..d1985ac25
--- /dev/null
+++ b/tests/operators/models/test_epg.py
@@ -0,0 +1,150 @@
+"""Tests for EPG signal models."""
+
+import pytest
+import torch
+from mrpro.operators.models.EPG import DelayBlock, EPGSequence, FispBlock, InversionBlock, Parameters, T2PrepBlock
+from mrpro.operators.SignalModel import SignalModel
+from tests import RandomGenerator
+from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS
+
+
+class EpgFispModel(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]):
+    """A simple EPG model of a Fisp sequence for testing."""
+
+    def __init__(
+        self,
+        flip_angles: float | torch.Tensor = torch.pi,
+        rf_phases: float | torch.Tensor = 0,
+        tr: float | torch.Tensor = 0.01,
+        te: float | torch.Tensor = 0.005,
+        n_states: int = 10,
+    ):
+        super().__init__()
+        self.sequence = EPGSequence()
+        self.sequence.append(FispBlock(flip_angles, rf_phases, te, tr))
+        self.n_states = n_states
+
+    def forward(
+        self, m0: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor, b1_relative: torch.Tensor
+    ) -> tuple[torch.Tensor]:
+        """Simulate the signal.
+
+        Parameters
+        ----------
+        m0
+            Steady state magnetization (complex)
+        t1
+            longitudinal relaxation time T1
+        t2
+            transversal relaxation time T2
+        b1_relative
+            relative B1 scaling (complex)
+
+
+        Returns
+        -------
+            Signal of Fisp sequence.
+        """
+        parameters = Parameters(m0, t1, t2, b1_relative)
+        _, signals = self.sequence(parameters, states=self.n_states)
+        signal = torch.stack(list(signals), dim=0)
+        return (signal,)
+
+
+def test_EpgFisp_not_enough_states() -> None:
+    """Verify error for less than 2 states."""
+    epg_model = EpgFispModel(n_states=1)
+    with pytest.raises(ValueError, match='Number of states should be at least 2'):
+        epg_model(torch.ones((1,)), torch.ones((1,)), torch.ones((1,)), torch.ones((1,)))
+
+
+def test_EpgFisp_parameter_broadcasting() -> None:
+    """Verify correct broadcasting of values."""
+    te = tr = rf_phases = torch.ones((1,))
+    flip_angles = torch.ones((20,))
+    epg_model = EpgFispModel(flip_angles=flip_angles, rf_phases=rf_phases, te=te, tr=tr)
+    m0 = t1 = t2 = b1_relative = torch.randn((30,))
+    (epg_signal,) = epg_model(m0, t1, t2, b1_relative)
+    assert epg_signal.shape == (20, 30)
+
+
+def test_EpgFisp_parameter_mismatch() -> None:
+    """Verify error for shape mismatch."""
+    flip_angles = rf_phases = tr = torch.ones((1, 2))
+    te = torch.ones((1, 3))
+    with pytest.raises(ValueError, match='Shapes of flip_angles'):
+        EpgFispModel(flip_angles=flip_angles, rf_phases=rf_phases, te=te, tr=tr)
+
+
+@SHAPE_VARIATIONS_SIGNAL_MODELS
+def test_EpgFisp_shape(parameter_shape, contrast_dim_shape, signal_shape) -> None:
+    """Test correct signal shapes."""
+    rng = RandomGenerator(0)
+    flip_angles = rng.float32_tensor(contrast_dim_shape, low=1e-5, high=5)
+    rf_phases = rng.float32_tensor(contrast_dim_shape, low=1e-5, high=0.5)
+    te = rng.float32_tensor(contrast_dim_shape, low=1e-5, high=0.01)
+    tr = rng.float32_tensor(contrast_dim_shape, low=0.01, high=0.05)
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+    relative_b1 = rng.complex64_tensor(parameter_shape)
+
+    model_op = EpgFispModel(flip_angles=flip_angles, rf_phases=rf_phases, te=te, tr=tr)
+    (signal,) = model_op(m0, t1, t2, relative_b1)
+    assert signal.shape == signal_shape
+
+
+def test_EpgFisp_inversion_recovery() -> None:
+    """EPG simulation of single-line inversion recovery sequence.
+
+    Obtaining a single point at different times after an inversion pulse follows a mono-exponential model.
+    """
+    t1 = torch.as_tensor([100, 200, 300, 400, 500, 1000, 2000, 4000]) * 1e-3
+    t2 = torch.as_tensor([20, 80, 160, 320, 20, 80, 160, 320]) * 1e-3
+    m0 = torch.ones_like(t1, dtype=torch.complex64)
+
+    # inversion times
+    ti_list = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.5, 2.0, 2.5]
+
+    # analytical signal
+    analytical_signal = m0 * (1 - 2 * torch.exp(-(torch.as_tensor(ti_list)[..., None] / t1)))
+
+    # single readout per inversion block with 90° pulse and very short echo time to avoid T2 effects
+    sequence = EPGSequence()
+    for ti in ti_list:
+        sequence.append(InversionBlock(inversion_time=ti))
+        sequence.append(FispBlock(flip_angles=torch.pi / 2, rf_phases=torch.pi / 2, tr=0.007, te=1e-6))
+        sequence.append(DelayBlock(delay_time=40))
+    parameters = Parameters(m0, t1, t2)
+    _, signals = sequence(parameters)
+    epg_signal = torch.stack(list(signals), dim=0)
+
+    torch.testing.assert_close(epg_signal, analytical_signal, rtol=1e-3, atol=1e-3)
+
+
+def test_EpgFisp_t2_preparation() -> None:
+    """EPG simulation of single-line T2-prep sequence.
+
+    Obtaining a single point at different TEs of a T2-prep pulse follows a mono-exponential model.
+    """
+    t1 = torch.as_tensor([100, 200, 300, 400, 500, 1000, 2000, 4000]) * 1e-3
+    t2 = torch.as_tensor([20, 80, 160, 320, 20, 80, 160, 320]) * 1e-3
+    m0 = torch.ones_like(t1, dtype=torch.complex64)
+
+    # echo times
+    t2_prep_te_times = [0, 0.02, 0.04, 0.08, 0.2]
+
+    # analytical signal
+    analytical_signal = m0 * torch.exp(-(torch.as_tensor(t2_prep_te_times)[:, None] / t2))
+
+    # single readout per T2-prep block with 90° pulse and very short echo time to avoid T2 effects during acquisition
+    sequence = EPGSequence()
+    for te in t2_prep_te_times:
+        sequence.append(T2PrepBlock(te=te))
+        sequence.append(FispBlock(flip_angles=torch.pi / 2, rf_phases=torch.pi / 2, tr=0.007, te=1e-6))
+        sequence.append(DelayBlock(delay_time=40))
+    parameters = Parameters(m0, t1, t2)
+    _, signals = sequence(parameters)
+    epg_signal = torch.stack(list(signals), dim=0)
+
+    torch.testing.assert_close(epg_signal, analytical_signal, rtol=1e-3, atol=1e-3)


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a new EPG signal model along with a Cardiac MR Fingerprinting model and corresponding tests, and refactors tensor reshaping utilities.  
- Added tests for EPG signal models in tests/operators/models/test_epg.py.  
- Refined tensor reshaping functions in src/mrpro/utils/reshape.py.  
- Implemented a new CardiacFingerprinting model in src/mrpro/operators/models/cMRF.py and updated module exports in __init__.py.

### Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

| File | Description |
| ---- | ----------- |
| tests/operators/models/test_epg.py | New tests for verifying EPG signal model functionality. |
| src/mrpro/utils/reshape.py | Improved unsqueeze functions for clearer tensor reshaping. |
| src/mrpro/operators/models/cMRF.py | Added CardiacFingerprinting model using EPG blocks. |
| src/mrpro/operators/models/__init__.py | Exported new CardiacFingerprinting and EPG modules. |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/mrpro/operators/models/cMRF.py:71**
* [nitpick] Consider extracting the magic number 47 used for slicing 'acquisition_times' into a named constant to improve readability and maintainability.
```
block_time = torch.as_tensor(acquisition_times[::47])
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-03-28T21:47:01Z
user: login: ckolbPTB
body: Just a few small things.

As far as I can remember we decided not to mention units as long as they are SI units. Here we often mention [rad] and [s]. Should we remove this?
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-29T19:49:32Z
user: login: fzimmermann89
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-29T23:56:30Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a block-based EPG signal model along with a fixed cMRF signal model and adds comprehensive tests for both. The changes include:
- New tests for EPG and cMRF models in tests/operators/models/
- Updates to the reshape utility functions in src/mrpro/utils/reshape.py
- A new CardiacFingerprinting signal model in src/mrpro/operators/models/cMRF.py and corresponding updates in __init__.py

### Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| tests/operators/models/test_epg.py | Adds tests verifying the behavior of the new EPG signal model including broadcasting, state count, and signal shape checks. |
| tests/operators/models/test_cmrf.py | Implements tests for basic functionality, autodiff, CUDA compatibility, and invalid input handling for the cMRF model. |
| src/mrpro/utils/reshape.py | Updates the unsqueeze functions to improve readability by explicitly constructing the new shape. |
| src/mrpro/operators/models/cMRF.py | Introduces the CardiacFingerprinting signal model using the EPG formalism with fixed sequence blocks. |
| src/mrpro/operators/models/__init__.py | Updates the module exports to include the new models. |
</details>





state: COMMENTED
author_association: NONE
submitted_at: 2025-03-30T01:41:19Z
user: login: ckolbPTB
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-30T18:12:21Z

Review comments:
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        Phase of the RF pulse in rad
```
created_at: 2025-03-29T18:59:44Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
```
created_at: 2025-03-29T19:00:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
```
created_at: 2025-03-29T19:03:05Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        EPG configuration states after gradient. Shape `..., 3 (f_plus, f_minus, z), n`
```
created_at: 2025-03-29T19:03:21Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        EPG configuration states after RF pulse. Shape `..., 3 (f_plus, f_minus, z), n`
```
created_at: 2025-03-29T19:04:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        EPG configuration states after relaxation and recovery. Shape `..., 3 (f_plus, f_minus, z), n`
```
created_at: 2025-03-29T19:06:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
    """Calculate the acquired signal from the EPG state."""
```
created_at: 2025-03-29T19:06:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        Shape of the state tensor, excluding the last two dimensions. Should match the shape of the parameters.
```
created_at: 2025-03-29T19:06:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        Device to create the state tensor on.
```
created_at: 2025-03-29T19:07:12Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        Data type of the state tensor.
```
created_at: 2025-03-29T19:07:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        Initial EPG state tensor. Shape `*shape, 3 (f_plus, f_minus, z), n`
```
created_at: 2025-03-29T19:08:05Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        """Initialize RF pulse block.
```
created_at: 2025-03-29T19:08:49Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
            flip angle in rad
```
created_at: 2025-03-29T19:09:01Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
            initial rf phase in rad
```
created_at: 2025-03-29T19:09:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
        """Apply the RF pulse to the EPG state.
```
created_at: 2025-03-29T19:09:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and the acquired signals
+        """
+        signal = []
+        # +1 for time dimension
+        unsqueezed = unsqueeze_tensors_right(
+            self.flip_angles, self.rf_phases, self.te, self.tr, ndim=parameters.ndim + 1
+        )
+        for flip_angle, rf_phase, te, tr in zip(*unsqueezed, strict=True):
+            states = rf(states, rf_matrix(flip_angle, rf_phase, parameters.relative_b1))
+            states = relax(states, relax_matrix(te, parameters.t1, parameters.t2))
+            signal.append(acquisition(states, parameters.m0))
+            states = gradient_dephasing(states)
+            states = relax(states, relax_matrix((tr - te), parameters.t1, parameters.t2))
+        return states, tuple(signal)
+
+
+class InversionBlock(EPGBlock):
+    """T1 Inversion Preparation Block.
+
+    The inversion pulse is assumed to be B1 insensitive.
+    """
+
+    def __init__(self, inversion_time: torch.Tensor | float) -> None:
+        """Initialize the inversion block.
+
+        Parameters
+        ----------
+        inversion_time
+            Inversion time
+        """
+        super().__init__()
+
+        self.inversion_time = torch.as_tensor(inversion_time)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the inversion block to the EPG state.
+
+        Parameters
+        ----------
+        states
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: order of states and parameters need to switched
created_at: 2025-03-29T19:10:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        states
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: Order of states and parameters needs to be switched
created_at: 2025-03-29T19:11:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: Order of states and parameters needs to be switched
created_at: 2025-03-29T19:11:29Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: Order of states and parameters needs to be switched
created_at: 2025-03-29T19:11:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: Order of states and parameters needs to be switched
created_at: 2025-03-29T19:11:43Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and the acquired signals
+        """
+        signal = []
+        # +1 for time dimension
+        unsqueezed = unsqueeze_tensors_right(
+            self.flip_angles, self.rf_phases, self.te, self.tr, ndim=parameters.ndim + 1
+        )
+        for flip_angle, rf_phase, te, tr in zip(*unsqueezed, strict=True):
+            states = rf(states, rf_matrix(flip_angle, rf_phase, parameters.relative_b1))
+            states = relax(states, relax_matrix(te, parameters.t1, parameters.t2))
+            signal.append(acquisition(states, parameters.m0))
+            states = gradient_dephasing(states)
+            states = relax(states, relax_matrix((tr - te), parameters.t1, parameters.t2))
+        return states, tuple(signal)
+
+
+class InversionBlock(EPGBlock):
+    """T1 Inversion Preparation Block.
+
+    The inversion pulse is assumed to be B1 insensitive.
+    """
+
+    def __init__(self, inversion_time: torch.Tensor | float) -> None:
+        """Initialize the inversion block.
+
+        Parameters
+        ----------
+        inversion_time
+            Inversion time
+        """
+        super().__init__()
+
+        self.inversion_time = torch.as_tensor(inversion_time)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the inversion block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(0.0)))
+        states = relax(states, relax_matrix(self.inversion_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.inversion_time
+
+
+class T2PrepBlock(EPGBlock):
+    """T2 Preparation Block.
+
+    Consists of a 90° pulse, relaxation during TE/2, 180° pulse, relaxation during TE/2 and a -90° pulse.
+
+    The pulses are assumed to be B1 insensitive.
+    """
+
+    def __init__(self, te: torch.Tensor | float) -> None:
+        """Initialize the T2 preparation block.
+
+        Parameters
+        ----------
+        te
+            Echo time of the T2 preparation block
+        """
+        super().__init__()
+
+        self.te = torch.as_tensor(te)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the T2 preparation block to the EPG state.
+
+        Parameters
+        ----------
+        states
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: Order of states and parameters needs to be switched
created_at: 2025-03-29T19:12:16Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and the acquired signals
+        """
+        signal = []
+        # +1 for time dimension
+        unsqueezed = unsqueeze_tensors_right(
+            self.flip_angles, self.rf_phases, self.te, self.tr, ndim=parameters.ndim + 1
+        )
+        for flip_angle, rf_phase, te, tr in zip(*unsqueezed, strict=True):
+            states = rf(states, rf_matrix(flip_angle, rf_phase, parameters.relative_b1))
+            states = relax(states, relax_matrix(te, parameters.t1, parameters.t2))
+            signal.append(acquisition(states, parameters.m0))
+            states = gradient_dephasing(states)
+            states = relax(states, relax_matrix((tr - te), parameters.t1, parameters.t2))
+        return states, tuple(signal)
+
+
+class InversionBlock(EPGBlock):
+    """T1 Inversion Preparation Block.
+
+    The inversion pulse is assumed to be B1 insensitive.
+    """
+
+    def __init__(self, inversion_time: torch.Tensor | float) -> None:
+        """Initialize the inversion block.
+
+        Parameters
+        ----------
+        inversion_time
+            Inversion time
+        """
+        super().__init__()
+
+        self.inversion_time = torch.as_tensor(inversion_time)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the inversion block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(0.0)))
+        states = relax(states, relax_matrix(self.inversion_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.inversion_time
+
+
+class T2PrepBlock(EPGBlock):
+    """T2 Preparation Block.
+
+    Consists of a 90° pulse, relaxation during TE/2, 180° pulse, relaxation during TE/2 and a -90° pulse.
+
+    The pulses are assumed to be B1 insensitive.
+    """
+
+    def __init__(self, te: torch.Tensor | float) -> None:
+        """Initialize the T2 preparation block.
+
+        Parameters
+        ----------
+        te
+            Echo time of the T2 preparation block
+        """
+        super().__init__()
+
+        self.te = torch.as_tensor(te)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the T2 preparation block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        # 90° pulse -> relaxation during TE/2 -> 180° pulse -> relaxation during TE/2 -> -90° pulse
+        te2_relax = relax_matrix(self.te / 2, parameters.t1, parameters.t2)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), torch.tensor(0.0)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(torch.pi / 2)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), -torch.tensor(torch.pi)))
+        # Spoiler
+        states = gradient_dephasing(states)
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.te
+
+
+class DelayBlock(EPGBlock):
+    """Delay Block."""
+
+    def __init__(self, delay_time: torch.Tensor | float) -> None:
+        """Initialize the delay block.
+
+        Parameters
+        ----------
+        delay_time
+            Delay time
+        """
+        super().__init__()
+        self.delay_time = torch.as_tensor(delay_time)
+        if (self.delay_time < 0).any():
+            raise ValueError(f'Negative delay time ({self.delay_time.amin()})')
+
+    def forward(
+        self,
+        parameters: Parameters,
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: Order of states and parameters needs to be switched
created_at: 2025-03-29T19:12:27Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and the acquired signals
+        """
+        signal = []
+        # +1 for time dimension
+        unsqueezed = unsqueeze_tensors_right(
+            self.flip_angles, self.rf_phases, self.te, self.tr, ndim=parameters.ndim + 1
+        )
+        for flip_angle, rf_phase, te, tr in zip(*unsqueezed, strict=True):
+            states = rf(states, rf_matrix(flip_angle, rf_phase, parameters.relative_b1))
+            states = relax(states, relax_matrix(te, parameters.t1, parameters.t2))
+            signal.append(acquisition(states, parameters.m0))
+            states = gradient_dephasing(states)
+            states = relax(states, relax_matrix((tr - te), parameters.t1, parameters.t2))
+        return states, tuple(signal)
+
+
+class InversionBlock(EPGBlock):
+    """T1 Inversion Preparation Block.
+
+    The inversion pulse is assumed to be B1 insensitive.
+    """
+
+    def __init__(self, inversion_time: torch.Tensor | float) -> None:
+        """Initialize the inversion block.
+
+        Parameters
+        ----------
+        inversion_time
+            Inversion time
+        """
+        super().__init__()
+
+        self.inversion_time = torch.as_tensor(inversion_time)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the inversion block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(0.0)))
+        states = relax(states, relax_matrix(self.inversion_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.inversion_time
+
+
+class T2PrepBlock(EPGBlock):
+    """T2 Preparation Block.
+
+    Consists of a 90° pulse, relaxation during TE/2, 180° pulse, relaxation during TE/2 and a -90° pulse.
+
+    The pulses are assumed to be B1 insensitive.
+    """
+
+    def __init__(self, te: torch.Tensor | float) -> None:
+        """Initialize the T2 preparation block.
+
+        Parameters
+        ----------
+        te
+            Echo time of the T2 preparation block
+        """
+        super().__init__()
+
+        self.te = torch.as_tensor(te)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the T2 preparation block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        # 90° pulse -> relaxation during TE/2 -> 180° pulse -> relaxation during TE/2 -> -90° pulse
+        te2_relax = relax_matrix(self.te / 2, parameters.t1, parameters.t2)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), torch.tensor(0.0)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(torch.pi / 2)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), -torch.tensor(torch.pi)))
+        # Spoiler
+        states = gradient_dephasing(states)
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.te
+
+
+class DelayBlock(EPGBlock):
+    """Delay Block."""
+
+    def __init__(self, delay_time: torch.Tensor | float) -> None:
+        """Initialize the delay block.
+
+        Parameters
+        ----------
+        delay_time
+            Delay time
+        """
+        super().__init__()
+        self.delay_time = torch.as_tensor(delay_time)
+        if (self.delay_time < 0).any():
+            raise ValueError(f'Negative delay time ({self.delay_time.amin()})')
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the delay block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        (delay_time,) = unsqueeze_tensors_right(self.delay_time, ndim=parameters.ndim)
+        states = relax(states, relax_matrix(delay_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.delay_time
+
+
+class EPGSequence(torch.nn.ModuleList, EPGBlock):
+    """Sequene of EPG blocks.
+
+    The sequence as multiple blocks, such as preparation pulses, acquisition blocks and delays.
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
    A sequence as multiple blocks, such as preparation pulses, acquisition blocks and delays.
```
created_at: 2025-03-29T19:36:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and the acquired signals
+        """
+        signal = []
+        # +1 for time dimension
+        unsqueezed = unsqueeze_tensors_right(
+            self.flip_angles, self.rf_phases, self.te, self.tr, ndim=parameters.ndim + 1
+        )
+        for flip_angle, rf_phase, te, tr in zip(*unsqueezed, strict=True):
+            states = rf(states, rf_matrix(flip_angle, rf_phase, parameters.relative_b1))
+            states = relax(states, relax_matrix(te, parameters.t1, parameters.t2))
+            signal.append(acquisition(states, parameters.m0))
+            states = gradient_dephasing(states)
+            states = relax(states, relax_matrix((tr - te), parameters.t1, parameters.t2))
+        return states, tuple(signal)
+
+
+class InversionBlock(EPGBlock):
+    """T1 Inversion Preparation Block.
+
+    The inversion pulse is assumed to be B1 insensitive.
+    """
+
+    def __init__(self, inversion_time: torch.Tensor | float) -> None:
+        """Initialize the inversion block.
+
+        Parameters
+        ----------
+        inversion_time
+            Inversion time
+        """
+        super().__init__()
+
+        self.inversion_time = torch.as_tensor(inversion_time)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the inversion block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(0.0)))
+        states = relax(states, relax_matrix(self.inversion_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.inversion_time
+
+
+class T2PrepBlock(EPGBlock):
+    """T2 Preparation Block.
+
+    Consists of a 90° pulse, relaxation during TE/2, 180° pulse, relaxation during TE/2 and a -90° pulse.
+
+    The pulses are assumed to be B1 insensitive.
+    """
+
+    def __init__(self, te: torch.Tensor | float) -> None:
+        """Initialize the T2 preparation block.
+
+        Parameters
+        ----------
+        te
+            Echo time of the T2 preparation block
+        """
+        super().__init__()
+
+        self.te = torch.as_tensor(te)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the T2 preparation block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        # 90° pulse -> relaxation during TE/2 -> 180° pulse -> relaxation during TE/2 -> -90° pulse
+        te2_relax = relax_matrix(self.te / 2, parameters.t1, parameters.t2)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), torch.tensor(0.0)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(torch.pi / 2)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), -torch.tensor(torch.pi)))
+        # Spoiler
+        states = gradient_dephasing(states)
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.te
+
+
+class DelayBlock(EPGBlock):
+    """Delay Block."""
+
+    def __init__(self, delay_time: torch.Tensor | float) -> None:
+        """Initialize the delay block.
+
+        Parameters
+        ----------
+        delay_time
+            Delay time
+        """
+        super().__init__()
+        self.delay_time = torch.as_tensor(delay_time)
+        if (self.delay_time < 0).any():
+            raise ValueError(f'Negative delay time ({self.delay_time.amin()})')
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the delay block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        (delay_time,) = unsqueeze_tensors_right(self.delay_time, ndim=parameters.ndim)
+        states = relax(states, relax_matrix(delay_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.delay_time
+
+
+class EPGSequence(torch.nn.ModuleList, EPGBlock):
+    """Sequene of EPG blocks.
+
+    The sequence as multiple blocks, such as preparation pulses, acquisition blocks and delays.
+
+    Classic MRF with a single inversion-pulse at the beginning followed by a train of RF pulses with different flip
+    angles as described in [MA2013]_ Ma, D. et al. Magnetic resonance fingerprinting. Nature 495, 187-192 (2013)
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
    angles as described in [MA2013]_
```
created_at: 2025-03-29T19:36:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and the acquired signals
+        """
+        signal = []
+        # +1 for time dimension
+        unsqueezed = unsqueeze_tensors_right(
+            self.flip_angles, self.rf_phases, self.te, self.tr, ndim=parameters.ndim + 1
+        )
+        for flip_angle, rf_phase, te, tr in zip(*unsqueezed, strict=True):
+            states = rf(states, rf_matrix(flip_angle, rf_phase, parameters.relative_b1))
+            states = relax(states, relax_matrix(te, parameters.t1, parameters.t2))
+            signal.append(acquisition(states, parameters.m0))
+            states = gradient_dephasing(states)
+            states = relax(states, relax_matrix((tr - te), parameters.t1, parameters.t2))
+        return states, tuple(signal)
+
+
+class InversionBlock(EPGBlock):
+    """T1 Inversion Preparation Block.
+
+    The inversion pulse is assumed to be B1 insensitive.
+    """
+
+    def __init__(self, inversion_time: torch.Tensor | float) -> None:
+        """Initialize the inversion block.
+
+        Parameters
+        ----------
+        inversion_time
+            Inversion time
+        """
+        super().__init__()
+
+        self.inversion_time = torch.as_tensor(inversion_time)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the inversion block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(0.0)))
+        states = relax(states, relax_matrix(self.inversion_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.inversion_time
+
+
+class T2PrepBlock(EPGBlock):
+    """T2 Preparation Block.
+
+    Consists of a 90° pulse, relaxation during TE/2, 180° pulse, relaxation during TE/2 and a -90° pulse.
+
+    The pulses are assumed to be B1 insensitive.
+    """
+
+    def __init__(self, te: torch.Tensor | float) -> None:
+        """Initialize the T2 preparation block.
+
+        Parameters
+        ----------
+        te
+            Echo time of the T2 preparation block
+        """
+        super().__init__()
+
+        self.te = torch.as_tensor(te)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the T2 preparation block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        # 90° pulse -> relaxation during TE/2 -> 180° pulse -> relaxation during TE/2 -> -90° pulse
+        te2_relax = relax_matrix(self.te / 2, parameters.t1, parameters.t2)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), torch.tensor(0.0)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(torch.pi / 2)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), -torch.tensor(torch.pi)))
+        # Spoiler
+        states = gradient_dephasing(states)
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.te
+
+
+class DelayBlock(EPGBlock):
+    """Delay Block."""
+
+    def __init__(self, delay_time: torch.Tensor | float) -> None:
+        """Initialize the delay block.
+
+        Parameters
+        ----------
+        delay_time
+            Delay time
+        """
+        super().__init__()
+        self.delay_time = torch.as_tensor(delay_time)
+        if (self.delay_time < 0).any():
+            raise ValueError(f'Negative delay time ({self.delay_time.amin()})')
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the delay block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        (delay_time,) = unsqueeze_tensors_right(self.delay_time, ndim=parameters.ndim)
+        states = relax(states, relax_matrix(delay_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.delay_time
+
+
+class EPGSequence(torch.nn.ModuleList, EPGBlock):
+    """Sequene of EPG blocks.
+
+    The sequence as multiple blocks, such as preparation pulses, acquisition blocks and delays.
+
+    Classic MRF with a single inversion-pulse at the beginning followed by a train of RF pulses with different flip
+    angles as described in [MA2013]_ Ma, D. et al. Magnetic resonance fingerprinting. Nature 495, 187-192 (2013)
+    [http://dx.doi.org/10.1038/nature11971] can be simulated by the following sequence:
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
    can be simulated by the following sequence:
```
created_at: 2025-03-29T19:37:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and the acquired signals
+        """
+        signal = []
+        # +1 for time dimension
+        unsqueezed = unsqueeze_tensors_right(
+            self.flip_angles, self.rf_phases, self.te, self.tr, ndim=parameters.ndim + 1
+        )
+        for flip_angle, rf_phase, te, tr in zip(*unsqueezed, strict=True):
+            states = rf(states, rf_matrix(flip_angle, rf_phase, parameters.relative_b1))
+            states = relax(states, relax_matrix(te, parameters.t1, parameters.t2))
+            signal.append(acquisition(states, parameters.m0))
+            states = gradient_dephasing(states)
+            states = relax(states, relax_matrix((tr - te), parameters.t1, parameters.t2))
+        return states, tuple(signal)
+
+
+class InversionBlock(EPGBlock):
+    """T1 Inversion Preparation Block.
+
+    The inversion pulse is assumed to be B1 insensitive.
+    """
+
+    def __init__(self, inversion_time: torch.Tensor | float) -> None:
+        """Initialize the inversion block.
+
+        Parameters
+        ----------
+        inversion_time
+            Inversion time
+        """
+        super().__init__()
+
+        self.inversion_time = torch.as_tensor(inversion_time)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the inversion block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(0.0)))
+        states = relax(states, relax_matrix(self.inversion_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.inversion_time
+
+
+class T2PrepBlock(EPGBlock):
+    """T2 Preparation Block.
+
+    Consists of a 90° pulse, relaxation during TE/2, 180° pulse, relaxation during TE/2 and a -90° pulse.
+
+    The pulses are assumed to be B1 insensitive.
+    """
+
+    def __init__(self, te: torch.Tensor | float) -> None:
+        """Initialize the T2 preparation block.
+
+        Parameters
+        ----------
+        te
+            Echo time of the T2 preparation block
+        """
+        super().__init__()
+
+        self.te = torch.as_tensor(te)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the T2 preparation block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        # 90° pulse -> relaxation during TE/2 -> 180° pulse -> relaxation during TE/2 -> -90° pulse
+        te2_relax = relax_matrix(self.te / 2, parameters.t1, parameters.t2)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), torch.tensor(0.0)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(torch.pi / 2)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), -torch.tensor(torch.pi)))
+        # Spoiler
+        states = gradient_dephasing(states)
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.te
+
+
+class DelayBlock(EPGBlock):
+    """Delay Block."""
+
+    def __init__(self, delay_time: torch.Tensor | float) -> None:
+        """Initialize the delay block.
+
+        Parameters
+        ----------
+        delay_time
+            Delay time
+        """
+        super().__init__()
+        self.delay_time = torch.as_tensor(delay_time)
+        if (self.delay_time < 0).any():
+            raise ValueError(f'Negative delay time ({self.delay_time.amin()})')
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the delay block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        (delay_time,) = unsqueeze_tensors_right(self.delay_time, ndim=parameters.ndim)
+        states = relax(states, relax_matrix(delay_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.delay_time
+
+
+class EPGSequence(torch.nn.ModuleList, EPGBlock):
+    """Sequene of EPG blocks.
+
+    The sequence as multiple blocks, such as preparation pulses, acquisition blocks and delays.
+
+    Classic MRF with a single inversion-pulse at the beginning followed by a train of RF pulses with different flip
+    angles as described in [MA2013]_ Ma, D. et al. Magnetic resonance fingerprinting. Nature 495, 187-192 (2013)
+    [http://dx.doi.org/10.1038/nature11971] can be simulated by the following sequence:
+     - InversionBlock
+     - FispBlock with varying flip_angles
+
+    You can also nest `EPGSequence`, i.e., use it as a block in another`EPGSequence`. This allows to create complex
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
    You can also nest `EPGSequence`, i.e., use it as a block in another `EPGSequence`. This allows to describe complex MR acquisitions
```
created_at: 2025-03-29T19:38:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
+        flip_angles_, rf_phases_, te_, tr_ = map(torch.as_tensor, (flip_angles, rf_phases, te, tr))
+        try:
+            self.flip_angles, self.rf_phases, self.te, self.tr = torch.broadcast_tensors(
+                flip_angles_, rf_phases_, te_, tr_
+            )
+        except RuntimeError:
+            raise ValueError(
+                f'Shapes of flip_angles ({flip_angles_.shape}), rf_phases ({rf_phases_.shape}), te ({te_.shape}) and '
+                f'tr ({tr_.shape}) cannot be broadcasted.',
+            ) from None
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.tr.sum(0)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the FISP block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and the acquired signals
+        """
+        signal = []
+        # +1 for time dimension
+        unsqueezed = unsqueeze_tensors_right(
+            self.flip_angles, self.rf_phases, self.te, self.tr, ndim=parameters.ndim + 1
+        )
+        for flip_angle, rf_phase, te, tr in zip(*unsqueezed, strict=True):
+            states = rf(states, rf_matrix(flip_angle, rf_phase, parameters.relative_b1))
+            states = relax(states, relax_matrix(te, parameters.t1, parameters.t2))
+            signal.append(acquisition(states, parameters.m0))
+            states = gradient_dephasing(states)
+            states = relax(states, relax_matrix((tr - te), parameters.t1, parameters.t2))
+        return states, tuple(signal)
+
+
+class InversionBlock(EPGBlock):
+    """T1 Inversion Preparation Block.
+
+    The inversion pulse is assumed to be B1 insensitive.
+    """
+
+    def __init__(self, inversion_time: torch.Tensor | float) -> None:
+        """Initialize the inversion block.
+
+        Parameters
+        ----------
+        inversion_time
+            Inversion time
+        """
+        super().__init__()
+
+        self.inversion_time = torch.as_tensor(inversion_time)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the inversion block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(0.0)))
+        states = relax(states, relax_matrix(self.inversion_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.inversion_time
+
+
+class T2PrepBlock(EPGBlock):
+    """T2 Preparation Block.
+
+    Consists of a 90° pulse, relaxation during TE/2, 180° pulse, relaxation during TE/2 and a -90° pulse.
+
+    The pulses are assumed to be B1 insensitive.
+    """
+
+    def __init__(self, te: torch.Tensor | float) -> None:
+        """Initialize the T2 preparation block.
+
+        Parameters
+        ----------
+        te
+            Echo time of the T2 preparation block
+        """
+        super().__init__()
+
+        self.te = torch.as_tensor(te)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the T2 preparation block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        # 90° pulse -> relaxation during TE/2 -> 180° pulse -> relaxation during TE/2 -> -90° pulse
+        te2_relax = relax_matrix(self.te / 2, parameters.t1, parameters.t2)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), torch.tensor(0.0)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi), torch.tensor(torch.pi / 2)))
+        states = relax(states, te2_relax)
+        states = rf(states, rf_matrix(torch.tensor(torch.pi / 2), -torch.tensor(torch.pi)))
+        # Spoiler
+        states = gradient_dephasing(states)
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.te
+
+
+class DelayBlock(EPGBlock):
+    """Delay Block."""
+
+    def __init__(self, delay_time: torch.Tensor | float) -> None:
+        """Initialize the delay block.
+
+        Parameters
+        ----------
+        delay_time
+            Delay time
+        """
+        super().__init__()
+        self.delay_time = torch.as_tensor(delay_time)
+        if (self.delay_time < 0).any():
+            raise ValueError(f'Negative delay time ({self.delay_time.amin()})')
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the delay block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        (delay_time,) = unsqueeze_tensors_right(self.delay_time, ndim=parameters.ndim)
+        states = relax(states, relax_matrix(delay_time, parameters.t1, parameters.t2))
+        return states, ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return self.delay_time
+
+
+class EPGSequence(torch.nn.ModuleList, EPGBlock):
+    """Sequene of EPG blocks.
+
+    The sequence as multiple blocks, such as preparation pulses, acquisition blocks and delays.
+
+    Classic MRF with a single inversion-pulse at the beginning followed by a train of RF pulses with different flip
+    angles as described in [MA2013]_ Ma, D. et al. Magnetic resonance fingerprinting. Nature 495, 187-192 (2013)
+    [http://dx.doi.org/10.1038/nature11971] can be simulated by the following sequence:
+     - InversionBlock
+     - FispBlock with varying flip_angles
+
+    You can also nest `EPGSequence`, i.e., use it as a block in another`EPGSequence`. This allows to create complex
+    with multiple repetitions of the same blocks. F
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: ```suggestion
    with multiple repetitions of the same blocks.
```
created_at: 2025-03-29T19:38:29Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,119 @@
+"""Cardiac MR Fingerprinting signal model (EPG)."""
+
+from collections.abc import Sequence
+
+import torch
+
+from mrpro.operators.models.EPG import DelayBlock, EPGSequence, FispBlock, InversionBlock, Parameters, T2PrepBlock
+from mrpro.operators.SignalModel import SignalModel
+
+
+class CardiacFingerprinting(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor]):
+    """Cardiac MR Fingerprinting signal model.
+
+    This model simulates a cardiac MR fingerprinting sequence as described in [HAMI2020]_ using the extended phase
+    graph (`~mrpro.operators.models.EPG`) formalism.
+
+    It is a three-fold repetition of
path: src/mrpro/operators/models/cMRF.py
user: login: ckolbPTB
body: This overview including the note does not render in the documentation
created_at: 2025-03-29T19:42:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,119 @@
+"""Cardiac MR Fingerprinting signal model (EPG)."""
+
+from collections.abc import Sequence
+
+import torch
+
+from mrpro.operators.models.EPG import DelayBlock, EPGSequence, FispBlock, InversionBlock, Parameters, T2PrepBlock
+from mrpro.operators.SignalModel import SignalModel
+
+
+class CardiacFingerprinting(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor]):
+    """Cardiac MR Fingerprinting signal model.
+
+    This model simulates a cardiac MR fingerprinting sequence as described in [HAMI2020]_ using the extended phase
+    graph (`~mrpro.operators.models.EPG`) formalism.
+
+    It is a three-fold repetition of
+
+            Block 0             Block 1                Block 2               Block 3           Block 4
+    R-peak               R-peak                R-peak              R-peak               R-peak
+    |--------------------|--------------------|--------------------|--------------------|----------------------
+      [INV TI=30ms][ACQ]        [ACQ]              [T2-prep 0][ACQ]     [T2-prep 1][ACQ]      [T2-prep 2s][ACQ]
+
+
+    ```{note}
+    This model is on purpose not flexible in all design choices. Instead, consider writing a custom
+    `~mrpro.operators.SignalModel` based on this implementation if you need to simulate a different sequence.
+    ```
+
+    References
+    ----------
+    .. [HAMI2017] Hamilton, J. I. et al. (2017) MR fingerprinting for rapid quantification of myocardial T1, T2, and
+            proton spin density. Magn. Reson. Med. 77 http://doi.wiley.com/10.1002/mrm.26216
+    .. [HAMI2020]  Hamilton, J.I. et al. (2020) Simultaneous Mapping of T1 and T2 Using Cardiac Magnetic Resonance
+            Fingerprinting in a Cohort of Healthy Subjects at 1.5T. J Magn Reson Imaging, 52: 1044-1052. https://doi.org/10.1002/jmri.27155]
+    """
+
+    def __init__(
+        self,
+        acquisition_times: torch.Tensor | Sequence[int] = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14),
+        echo_time: float = 0.015,
path: src/mrpro/operators/models/cMRF.py
user: login: ckolbPTB
body: ```suggestion
        echo_time: float = 0.0015,
```
created_at: 2025-03-29T19:42:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,119 @@
+"""Cardiac MR Fingerprinting signal model (EPG)."""
+
+from collections.abc import Sequence
+
+import torch
+
+from mrpro.operators.models.EPG import DelayBlock, EPGSequence, FispBlock, InversionBlock, Parameters, T2PrepBlock
+from mrpro.operators.SignalModel import SignalModel
+
+
+class CardiacFingerprinting(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor]):
+    """Cardiac MR Fingerprinting signal model.
+
+    This model simulates a cardiac MR fingerprinting sequence as described in [HAMI2020]_ using the extended phase
+    graph (`~mrpro.operators.models.EPG`) formalism.
+
+    It is a three-fold repetition of
+
+            Block 0             Block 1                Block 2               Block 3           Block 4
+    R-peak               R-peak                R-peak              R-peak               R-peak
+    |--------------------|--------------------|--------------------|--------------------|----------------------
+      [INV TI=30ms][ACQ]        [ACQ]              [T2-prep 0][ACQ]     [T2-prep 1][ACQ]      [T2-prep 2s][ACQ]
+
+
+    ```{note}
+    This model is on purpose not flexible in all design choices. Instead, consider writing a custom
+    `~mrpro.operators.SignalModel` based on this implementation if you need to simulate a different sequence.
+    ```
+
+    References
+    ----------
+    .. [HAMI2017] Hamilton, J. I. et al. (2017) MR fingerprinting for rapid quantification of myocardial T1, T2, and
+            proton spin density. Magn. Reson. Med. 77 http://doi.wiley.com/10.1002/mrm.26216
+    .. [HAMI2020]  Hamilton, J.I. et al. (2020) Simultaneous Mapping of T1 and T2 Using Cardiac Magnetic Resonance
+            Fingerprinting in a Cohort of Healthy Subjects at 1.5T. J Magn Reson Imaging, 52: 1044-1052. https://doi.org/10.1002/jmri.27155]
+    """
+
+    def __init__(
+        self,
+        acquisition_times: torch.Tensor | Sequence[int] = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14),
+        echo_time: float = 0.015,
+        repetition_time: float = 0.005,
+        t2_prep_echo_times: tuple[float, float, float] = (0.03, 0.05, 0.08),
+    ) -> None:
+        """Initialize the Cardiac MR Fingerprinting signal model.
+
+        Parameters
+        ----------
+        acquisition_times
+            Times of the acquisistions in s.
+            Either 15 values (the first acquisition of each block) or 705 values for all acquisitions.
+            In both cases only the time of the first acquisition of each block will be used to calculate the delays.
+
+        echo_time
+            TE in s
+        repetition_time
+            TR in s
+        t2_prep_echo_times
+            Echo times of the three T2 preparation blocks in s
+
+        Returns
path: src/mrpro/operators/models/cMRF.py
user: login: ckolbPTB
body: This does not return anything
created_at: 2025-03-29T19:44:14Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,167 @@
+"""Tests for EPG signal models."""
+
+from collections.abc import Sequence
+
+import pytest
+import torch
+from mrpro.operators.models import CardiacFingerprinting
+from mrpro.operators.models.EPG import DelayBlock, EPGSequence, FispBlock, InversionBlock, Parameters, T2PrepBlock
+from mrpro.operators.SignalModel import SignalModel
+from tests import RandomGenerator
+from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS
+
+
+class EpgFispModel(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]):
+    """An simple EPG Model for testing."""
path: tests/operators/models/test_epg.py
user: login: ckolbPTB
body: ```suggestion
    """A simple EPG model of a Fisp sequence for testing."""
```
created_at: 2025-03-29T19:45:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,167 @@
+"""Tests for EPG signal models."""
+
+from collections.abc import Sequence
+
+import pytest
+import torch
+from mrpro.operators.models import CardiacFingerprinting
+from mrpro.operators.models.EPG import DelayBlock, EPGSequence, FispBlock, InversionBlock, Parameters, T2PrepBlock
+from mrpro.operators.SignalModel import SignalModel
+from tests import RandomGenerator
+from tests.operators.models.conftest import SHAPE_VARIATIONS_SIGNAL_MODELS
+
+
+class EpgFispModel(SignalModel[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]):
+    """An simple EPG Model for testing."""
+
+    def __init__(
+        self,
+        flip_angles: float | torch.Tensor = torch.pi,
+        rf_phases: float | torch.Tensor = 0,
+        tr: float | torch.Tensor = 0.01,
+        te: float | torch.Tensor = 0.005,
+        n_states: int = 10,
+    ):
+        super().__init__()
+        self.sequence = EPGSequence()
+        self.sequence.append(FispBlock(flip_angles, rf_phases, tr, te))
+        self.n_states = n_states
+
+    def forward(
+        self, m0: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor, b1_relative: torch.Tensor
+    ) -> tuple[torch.Tensor]:
+        """Simulate the signal.
+
+        Parameters
+        ----------
+        m0
+            Steady state magnetization (complex)
+        t1
+            longitudinal relaxation time T1
+        t2
+            transversal relaxation time T2
+        b1_relative
+            relative B1 scaling (complex)
+
+
+        Returns
+        -------
+            Signal of Fisp sequence.
+        """
+        parameters = Parameters(m0, t1, t2, b1_relative)
+        _, signals = self.sequence(parameters, states=self.n_states)
+        signal = torch.stack(list(signals), dim=0)
+        return (signal,)
+
+
+def test_EpgFisp_not_enough_states() -> None:
+    """Verify error for less than 2 states."""
+    epg_model = EpgFispModel(n_states=1)
+    with pytest.raises(ValueError, match='Number of states should be at least 2'):
+        epg_model(torch.ones((1,)), torch.ones((1,)), torch.ones((1,)), torch.ones((1,)))
+
+
+def test_EpgFisp_parameter_broadcasting() -> None:
+    """Verify correct broadcasting of values."""
+    te = tr = rf_phases = torch.ones((1,))
+    flip_angles = torch.ones((20,))
+    epg_model = EpgFispModel(flip_angles=flip_angles, rf_phases=rf_phases, te=te, tr=tr)
+    m0 = t1 = t2 = b1_relative = torch.randn((30,))
+    (epg_signal,) = epg_model(m0, t1, t2, b1_relative)
+    assert epg_signal.shape == (20, 30)
+
+
+def test_EpgFisp_parameter_mismatch() -> None:
+    """Verify error for shape mismatch."""
+    flip_angles = rf_phases = tr = torch.ones((1, 2))
+    te = torch.ones((1, 3))
+    with pytest.raises(ValueError, match='Shapes of flip_angles'):
+        EpgFispModel(flip_angles=flip_angles, rf_phases=rf_phases, te=te, tr=tr)
+
+
+@SHAPE_VARIATIONS_SIGNAL_MODELS
+def test_EpgFisp_shape(parameter_shape, contrast_dim_shape, signal_shape) -> None:
+    """Test correct signal shapes."""
+    rng = RandomGenerator(0)
+    flip_angles = rng.float32_tensor(contrast_dim_shape, low=1e-5, high=5)
+    rf_phases = rng.float32_tensor(contrast_dim_shape, low=1e-5, high=0.5)
+    te = rng.float32_tensor(contrast_dim_shape, low=1e-5, high=0.01)
+    tr = rng.float32_tensor(contrast_dim_shape, low=1e-5, high=0.05)
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+    relative_b1 = rng.complex64_tensor(parameter_shape)
+
+    model_op = EpgFispModel(flip_angles=flip_angles, rf_phases=rf_phases, te=te, tr=tr)
+    (signal,) = model_op(m0, t1, t2, relative_b1)
+    assert signal.shape == signal_shape
+
+
+def test_EpgFisp_inversion_recovery() -> None:
+    """EPG simulation of single-line inversion recovery sequence.
+
+    Obtaining a single point at different times after an inversion pulse follows a mono-exponential model.
+    """
+    t1 = torch.as_tensor([100, 200, 300, 400, 500, 1000, 2000, 4000]) * 1e-3
+    t2 = torch.as_tensor([20, 80, 160, 320, 20, 80, 160, 320]) * 1e-3
+    m0 = torch.ones_like(t1, dtype=torch.complex64)
+
+    # inversion times
+    ti_list = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.5, 2.0, 2.5]
+
+    # analytical signal
+    analytical_signal = m0 * (1 - 2 * torch.exp(-(torch.as_tensor(ti_list)[..., None] / t1)))
+
+    # single readout per inversion block with 90° pulse and very short echo time to avoid T2 effects
+    sequence = EPGSequence()
+    for ti in ti_list:
+        sequence.append(InversionBlock(inversion_time=ti))
+        sequence.append(FispBlock(flip_angles=torch.pi / 2, rf_phases=torch.pi / 2, tr=0.007, te=1e-6))
+        sequence.append(DelayBlock(delay_time=40))
+    parameters = Parameters(m0, t1, t2)
+    _, signals = sequence(parameters)
+    epg_signal = torch.stack(list(signals), dim=0)
+
+    torch.testing.assert_close(epg_signal, analytical_signal, rtol=1e-3, atol=1e-3)
+
+
+def test_EpgFisp_t2_preparation() -> None:
+    """EPG simulation of single-line T2-prep sequence.
+
+    Obtaining a single point at different TEs of a T2-prep pulse follows a mono-exponential model.
+    """
+    t1 = torch.as_tensor([100, 200, 300, 400, 500, 1000, 2000, 4000]) * 1e-3
+    t2 = torch.as_tensor([20, 80, 160, 320, 20, 80, 160, 320]) * 1e-3
+    m0 = torch.ones_like(t1, dtype=torch.complex64)
+
+    # echo times
+    t2_prep_te_times = [0, 0.02, 0.04, 0.08, 0.2]
+
+    # analytical signal
+    analytical_signal = m0 * torch.exp(-(torch.as_tensor(t2_prep_te_times)[:, None] / t2))
+
+    # single readout per T2-prep block with 90° pulse and very short echo time to avoid T2 effects during acquisition
+    sequence = EPGSequence()
+    for te in t2_prep_te_times:
+        sequence.append(T2PrepBlock(te=te))
+        sequence.append(FispBlock(flip_angles=torch.pi / 2, rf_phases=torch.pi / 2, tr=0.007, te=1e-6))
+        sequence.append(DelayBlock(delay_time=40))
+    parameters = Parameters(m0, t1, t2)
+    _, signals = sequence(parameters)
+    epg_signal = torch.stack(list(signals), dim=0)
+
+    torch.testing.assert_close(epg_signal, analytical_signal, rtol=1e-3, atol=1e-3)
+
+
+def test_cmrf_model(parameter_shape: Sequence[int] = (2, 5, 10, 10, 10)) -> None:
path: tests/operators/models/test_epg.py
user: login: ckolbPTB
body: This should be in separate file
created_at: 2025-03-29T19:46:02Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
path: src/mrpro/operators/models/EPG.py
user: login: ckolbPTB
body: Should we verify here that tr > te?
created_at: 2025-03-29T19:47:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,675 @@
+"""Extended phase graph (EPG) signal models."""
+
+# Based on https://github.com/fzimmermann89/epgtorch/
+# Copyright (c) 2022 Felix Zimmermann, MIT License
+
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from dataclasses import dataclass
+
+import torch
+
+from mrpro.data.MoveDataMixin import MoveDataMixin
+from mrpro.utils.reshape import unsqueeze_tensors_right
+from mrpro.utils.TensorAttributeMixin import TensorAttributeMixin
+
+
+@dataclass
+class Parameters(MoveDataMixin):
+    """Tissue parameters for EPG simulation."""
+
+    m0: torch.Tensor
+    """Steay state magnetization (complex)"""
+
+    t1: torch.Tensor
+    """T1 relaxation time [s]"""
+
+    t2: torch.Tensor
+    """T2 relaxation time [s]"""
+
+    relative_b1: torch.Tensor | None = None
+    """Relative B1 scaling factor (complex)"""
+
+    @property
+    def device(self) -> torch.device:
+        """Device of the parameters."""
+        device = super().device
+        assert device is not None  # mypy # noqa: S101
+        return device
+
+    @property
+    def shape(self) -> torch.Size:
+        """Broadcasted shape of the parameters."""
+        shape = torch.broadcast_shapes(self.t1.shape, self.t2.shape, self.m0.shape)
+        if self.relative_b1 is not None:
+            shape = torch.broadcast_shapes(shape, self.relative_b1.shape)
+        return shape
+
+    @property
+    def dtype(self) -> torch.dtype:
+        """Promoted data type of the parameters."""
+        dtype = torch.promote_types(self.t1.dtype, self.t2.dtype)
+        dtype = torch.promote_types(dtype, self.m0.dtype)
+        if self.relative_b1 is not None:
+            dtype = torch.promote_types(dtype, self.relative_b1.dtype)
+        return dtype
+
+    @property
+    def ndim(self) -> int:
+        """Number of dimensions of the parameters."""
+        ndim = max(self.t1.ndim, self.t2.ndim, self.m0.ndim)
+        if self.relative_b1 is not None:
+            ndim = max(ndim, self.relative_b1.ndim)
+        return ndim
+
+
+@torch.jit.script
+def rf_matrix(
+    flip_angle: torch.Tensor,
+    phase: torch.Tensor,
+    relative_b1: torch.Tensor | None = None,
+) -> torch.Tensor:
+    """Initialize the rotation matrix describing the RF pulse.
+
+    Parameters
+    ----------
+    flip_angle
+        Flip angle of the RF pulse in rad
+    phase
+        Phase of the RF pulse
+    relative_b1
+        Scaling of flip angle due to B1 inhomogeneities
+
+    Returns
+    -------
+        Matrix describing the mixing of the EPG configuration states due to an RF pulse.
+    """
+    if relative_b1 is not None:
+        # relative_b1 and flip_angle need to be correctly broadcasted at this point
+        flip_angle = flip_angle * relative_b1.abs()
+        phase = phase + relative_b1.angle()
+    cosa = torch.cos(flip_angle)
+    sina = torch.sin(flip_angle)
+    cosa2 = (cosa + 1) / 2
+    sina2 = 1 - cosa2
+    ejp = torch.polar(torch.tensor(1.0), phase)
+    inv_ejp = 1 / ejp
+    new_shape = flip_angle.shape + (3, 3)  # noqa: RUF005 # not supported in torchscript
+
+    return torch.stack(
+        [
+            cosa2 + 0.0j,
+            ejp**2 * sina2,
+            -1.0j * ejp * sina,
+            inv_ejp**2 * sina2,
+            cosa2 + 0.0j,
+            1.0j * inv_ejp * sina,
+            -0.5j * inv_ejp * sina,
+            0.5j * ejp * sina,
+            cosa + 0.0j,
+        ],
+        -1,
+    ).reshape(new_shape)
+
+
+def rf(state: torch.Tensor, matrix: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through an RF rotation.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n `
+    matrix
+        Rotation matrix describing the mixing of the EPG configuration states due to an RF pulse.
+        Shape `..., 3, 3`
+
+    Returns
+    -------
+        EPG configuration states after RF pulse. Shape `..., 3, n`
+    """
+    return matrix @ state
+
+
+@torch.jit.script
+def gradient_dephasing(state: torch.Tensor) -> torch.Tensor:
+    """Propagate EPG states through a "unit" gradient.
+
+    Parameters
+    ----------
+    state
+        EPG configuration states. Shape `..., 3 (f_plus, f_minus, z), n`
+        with n being the number of configuration states > 1
+
+    Returns
+    -------
+        EPG configuration states after gradient. Shape `..., 3, n`
+    """
+    zero = state.new_zeros(state.shape[:-2] + (1,))
+    f_plus = torch.cat((state[..., 1, 1:2].conj(), state[..., 0, :-1]), dim=-1)
+    f_minus = torch.cat((state[..., 1, 1:], zero), -1)
+    z = state[..., 2, :]
+    return torch.stack((f_plus, f_minus, z), -2)
+
+
+@torch.jit.script
+def relax_matrix(relaxation_time: torch.Tensor, t1: torch.Tensor, t2: torch.Tensor) -> torch.Tensor:
+    """Calculate relaxation vector.
+
+    Parameters
+    ----------
+    relaxation_time
+        relaxation time
+    t1
+        longitudinal relaxation time
+    t2
+        transversal relaxation time
+
+    Returns
+    -------
+        vector describing relaxation
+    """
+    e2 = torch.exp(-relaxation_time / t2)
+    e1 = torch.exp(-relaxation_time / t1)
+    e1, e2 = torch.broadcast_tensors(e1, e2)
+    return torch.stack([e2, e2, e1], dim=-1)
+
+
+@torch.jit.script
+def relax(states: torch.Tensor, relaxation_matrix: torch.Tensor, t1_recovery: bool = True) -> torch.Tensor:
+    """Propagate EPG states through a period of relaxation and recovery.
+
+    Parameters
+    ----------
+    states
+        EPG configuration states Fplus, Fminus, Z
+    relaxation_matrix
+        matrix describing EPG relaxation
+    t1_recovery
+        recovery of longitudinal EPG states
+
+    Returns
+    -------
+        EPG configuration states after relaxation and recovery
+    """
+    states = relaxation_matrix[..., None] * states
+
+    if t1_recovery:
+        states[..., 2, 0] = states[..., 2, 0] + (1 - relaxation_matrix[..., -1])
+    return states
+
+
+def acquisition(state: torch.Tensor, m0: torch.Tensor) -> torch.Tensor:
+    """Calculate the signal from the EPG state."""
+    return m0 * state[..., 0, 0]
+
+
+def initial_state(
+    shape: Sequence[int], n_states: int = 20, device: torch.device | str = 'cpu', dtype: torch.dtype = torch.complex64
+) -> torch.Tensor:
+    """Generate initial EPG states.
+
+    Parameters
+    ----------
+    shape
+        Shape of the state tensor, excluding the last two dimensions. Shouöd match the parameters shape.
+    n_states
+        Number of EPG configuration states.
+    device
+        Device to create the tensor on.
+    dtype
+        Data type of the tensor.
+
+    Returns
+    -------
+        Initial EPG state tensor. Shape `*shape,3, n_state)`
+    """
+    if n_states < 2:
+        raise ValueError('Number of states should be at least 2.')
+    states = torch.zeros(*shape, 3, n_states, device=device, dtype=dtype)
+    # Start in equilibrium state
+    states[..., :, 0] = torch.tensor((0.0, 0, 1.0))
+    return states
+
+
+class EPGBlock(TensorAttributeMixin, ABC):
+    """Base class for EPG blocks."""
+
+    def __call__(
+        self, parameters: Parameters, states: torch.Tensor | int = 20
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block.
+
+        Parameters
+        ----------
+        parameters
+            Tissue parameters
+        states
+            EPG configuration states.
+            If an integer value, the equilibrium state (0, 0, 1) will be initialized with
+            the given number of EPG configuration states. The number should be large enough to capture the
+            signal dynamics. More states increase the accuracy of the simulation but also the computational cost.
+        """
+        if isinstance(states, int):
+            states = initial_state(
+                parameters.shape, n_states=states, device=parameters.device, dtype=parameters.dtype.to_complex()
+            )
+        return super().__call__(parameters, states)
+
+    @abstractmethod
+    def forward(self, parameters: Parameters, states: torch.Tensor) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the block."""
+        raise NotImplementedError
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        raise NotImplementedError
+
+
+class RFBlock(EPGBlock):
+    """RF pulse block."""
+
+    def __init__(self, flip_angle: torch.Tensor | float, phase: torch.Tensor | float) -> None:
+        """Initialize RF block.
+
+        Parameters
+        ----------
+        flip_angle
+            flip angle [rad]
+        phase
+            initial rf phase
+        """
+        super().__init__()
+        self.flip_angle = torch.as_tensor(flip_angle)
+        self.phase = torch.as_tensor(phase)
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the RF to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        matrix = rf_matrix(self.flip_angle, self.phase, parameters.relative_b1)
+        return rf(states, matrix), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class AcquisitionBlock(EPGBlock):
+    """Acquisition block."""
+
+    def forward(
+        self,
+        parameters: Parameters,
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the acquisition block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states (unchanged) after the block and the acquired signal
+        """
+        return states, (acquisition(states, parameters.m0),)
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class GradientDephasingBlock(EPGBlock):
+    """Gradient dephasing block."""
+
+    def forward(
+        self,
+        parameters: Parameters,  # noqa: ARG002
+        states: torch.Tensor,
+    ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]:
+        """Apply the gradient dephasing block to the EPG state.
+
+        Parameters
+        ----------
+        states
+            EPG configuration states
+        parameters
+            Tissue parameters
+
+        Returns
+        -------
+            EPG configuration states after the block and an empty list
+        """
+        return gradient_dephasing(states), ()
+
+    @property
+    def duration(self) -> torch.Tensor:
+        """Duration of the block."""
+        return torch.tensor(0.0)
+
+
+class FispBlock(EPGBlock):
+    """FISP data acquisition block.
+
+    The block consists of a series of RF pulses with different flip angles and phases followed by an acquisition:
+        - rf with flip_angle[i] and rf_phase[i]
+        - relax for te[i]
+        - acquisition
+        - gradient_dephasing
+        - relax for tr[i] - te[i]
+    """
+
+    flip_angles: torch.Tensor
+    rf_phases: torch.Tensor
+    te: torch.Tensor
+    tr: torch.Tensor
+
+    def __init__(
+        self,
+        flip_angles: torch.Tensor | float,
+        rf_phases: torch.Tensor | float,
+        te: torch.Tensor | float,
+        tr: torch.Tensor | float,
+    ) -> None:
+        """Initialize the FISP block.
+
+        Parameters
+        ----------
+        flip_angles
+            Flip angles of the RF pulses in rad
+        rf_phases
+            Phase of the RF pulses in rad
+        te
+            Echo time
+        tr
+            Repetition time
+        """
+        super().__init__()
+
path: src/mrpro/operators/models/EPG.py
user: login: fzimmermann89
body: yes
created_at: 2025-03-29T23:56:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+from collections.abc import Sequence
+
+import pytest
+import torch
+from mrpro.operators.models.cMRF import CardiacFingerprinting
+from mrpro.utils.reshape import unsqueeze_right
+from tests import RandomGenerator, autodiff_test
+
+
+def test_cmrf_basic(parameter_shape: Sequence[int] = (2, 1, 3, 4, 2)) -> None:
+    """Test the CMRF model."""
+    acquisition_times = unsqueeze_right(torch.linspace(0, 15, 705), len(parameter_shape)).expand(-1, *parameter_shape)
+    model = CardiacFingerprinting(acquisition_times=acquisition_times)
+    rng = RandomGenerator(0)
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+    (signal,) = model(m0, t1, t2)
+    assert signal.isfinite().all()
+    assert signal.shape == (705, *parameter_shape)
+
+
+def test_cmrf_autodiff(parameter_shape: Sequence[int] = (2, 1, 3, 4, 2)) -> None:
+    """Test autodiff works for cMRF model."""
+    rng = RandomGenerator(8)
+    acquisition_times = rng.float32_tensor(15, low=0.8, high=2).cumsum(0)
+    model = CardiacFingerprinting(acquisition_times=acquisition_times)
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+    autodiff_test(model, m0, t1, t2)
+
+
+@pytest.mark.cuda
+def test_cmrf_cuda(parameter_shape: Sequence[int] = (2,)) -> None:
+    """Test the cMRF model works on cuda devices."""
+    rng = RandomGenerator(8)
+    acquisition_times = rng.random_float_tensor(15, low=0.8, high=2).cumsum()
+
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+
+    # Create on CPU, transfer to GPU and run on GPU
+    model = CardiacFingerprinting(acquisition_times)
+    model.cuda()
+    (signal,) = model(m0.cuda(), t1.cuda(), t2.cuda())
+    assert signal.is_cuda
+    assert signal.isfinite().all()
+
+    # Create on GPU and run on GPU
+    model = CardiacFingerprinting(acquisition_times.cuda)
path: tests/operators/models/test_cmrf.py
user: login: Copilot
body: The call to move 'acquisition_times' to CUDA is missing parentheses; change 'acquisition_times.cuda' to 'acquisition_times.cuda()' to properly convert the tensor.
```suggestion
    model = CardiacFingerprinting(acquisition_times.cuda())
```
created_at: 2025-03-30T01:41:19Z
author_association: NONE
diff_hunk: @@ -0,0 +1,76 @@
+from collections.abc import Sequence
+
+import pytest
+import torch
+from mrpro.operators.models.cMRF import CardiacFingerprinting
+from mrpro.utils.reshape import unsqueeze_right
+from tests import RandomGenerator, autodiff_test
+
+
+def test_cmrf_basic(parameter_shape: Sequence[int] = (2, 1, 3, 4, 2)) -> None:
+    """Test the CMRF model."""
+    acquisition_times = unsqueeze_right(torch.linspace(0, 15, 705), len(parameter_shape)).expand(-1, *parameter_shape)
+    model = CardiacFingerprinting(acquisition_times=acquisition_times)
+    rng = RandomGenerator(0)
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+    (signal,) = model(m0, t1, t2)
+    assert signal.isfinite().all()
+    assert signal.shape == (705, *parameter_shape)
+
+
+def test_cmrf_autodiff(parameter_shape: Sequence[int] = (2, 1, 3, 4, 2)) -> None:
+    """Test autodiff works for cMRF model."""
+    rng = RandomGenerator(8)
+    acquisition_times = rng.float32_tensor(15, low=0.8, high=2).cumsum(0)
+    model = CardiacFingerprinting(acquisition_times=acquisition_times)
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+    autodiff_test(model, m0, t1, t2)
+
+
+@pytest.mark.cuda
+def test_cmrf_cuda(parameter_shape: Sequence[int] = (2,)) -> None:
+    """Test the cMRF model works on cuda devices."""
+    rng = RandomGenerator(8)
+    acquisition_times = rng.random_float_tensor(15, low=0.8, high=2).cumsum()
+
+    t1 = rng.float32_tensor(parameter_shape, low=1e-5, high=5)
+    t2 = rng.float32_tensor(parameter_shape, low=1e-5, high=0.5)
+    m0 = rng.complex64_tensor(parameter_shape)
+
+    # Create on CPU, transfer to GPU and run on GPU
+    model = CardiacFingerprinting(acquisition_times)
+    model.cuda()
+    (signal,) = model(m0.cuda(), t1.cuda(), t2.cuda())
+    assert signal.is_cuda
+    assert signal.isfinite().all()
+
+    # Create on GPU and run on GPU
+    model = CardiacFingerprinting(acquisition_times.cuda)
+    (signal,) = model(m0.cuda(), t1.cuda(), t2.cuda())
+    assert signal.is_cuda
+    assert signal.isfinite().all()
+
+    # Create on GPU, transfer to CPU and run on CPU
+    model = CardiacFingerprinting(acquisition_times.cuda)
+    model.cpu()
+    (signal,) = model(m0, t1, t2)
+    assert signal.is_cpu
+    assert signal.isfinite().all()
+
+
+def test_cmrf_invalid():
+    """Test invalid parameters for cMRF model."""
+    with pytest.raises(ValueError, match='acquisition times'):
+        CardiacFingerprinting(acquisition_times=torch.ones(16))
+    with pytest.raises(ValueError, match='would start before the previous block finished'):
+        CardiacFingerprinting(acquisition_times=torch.ones(15))
+    with pytest.raises(ValueError, match='would start before the previous block finished'):
+        CardiacFingerprinting(repetition_time=5.0)
+    with pytest.raises(ValueError, match='should be smaller than repetitinon time'):
path: tests/operators/models/test_cmrf.py
user: login: Copilot
body: There is a typo in the error message: 'repetitinon' should be corrected to 'repetition'.
```suggestion
    with pytest.raises(ValueError, match='should be smaller than repetition time'):
```
created_at: 2025-03-30T01:41:19Z
author_association: NONE

PR comments:
user: login: github-actions[bot]
created_at: 2025-01-28T08:21:54Z
body: <!-- documentation build 622 -->
### :books: Documentation
:file_folder: [Download as zip](https://github.com/PTB-MR/mrpro/actions/runs/14155567521/artifacts/2846499592)
:mag: [View online](https://zimf.de/zipserve/https://github.com/PTB-MR/mrpro/actions/runs/14155567521/artifacts/2846499592/)
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-02-08T01:49:32Z
body: <!-- Pytest Coverage Comment: coverage_report_pr -->
<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/README.md"><img alt="Coverage" src="https://img.shields.io/badge/Coverage-92%25-brightgreen.svg" /></a><details><summary>Coverage Report </summary><table><tr><th>File</th><th>Stmts</th><th>Miss</th><th>Cover</th><th>Missing</th></tr><tbody><tr><td colspan="5"><b>src/mrpro</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/_version.py">_version.py</a></td><td>6</td><td>2</td><td>67%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/_version.py#L7-L8">7&ndash;8</a></td></tr><tr><td colspan="5"><b>src/mrpro/algorithms/csm</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/csm/inati.py">inati.py</a></td><td>24</td><td>1</td><td>96%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/csm/inati.py#L44">44</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/csm/walsh.py">walsh.py</a></td><td>16</td><td>1</td><td>94%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/csm/walsh.py#L53">53</a></td></tr><tr><td colspan="5"><b>src/mrpro/algorithms/dcf</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/dcf/dcf_voronoi.py">dcf_voronoi.py</a></td><td>55</td><td>4</td><td>93%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/dcf/dcf_voronoi.py#L15">15</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/dcf/dcf_voronoi.py#L55-L56">55&ndash;56</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/dcf/dcf_voronoi.py#L89">89</a></td></tr><tr><td colspan="5"><b>src/mrpro/algorithms/optimizers</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/optimizers/adam.py">adam.py</a></td><td>22</td><td>1</td><td>95%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/optimizers/adam.py#L104">104</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/optimizers/pdhg.py">pdhg.py</a></td><td>81</td><td>3</td><td>96%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/optimizers/pdhg.py#L178-L179">178&ndash;179</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/optimizers/pdhg.py#L185">185</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/optimizers/pgd.py">pgd.py</a></td><td>53</td><td>4</td><td>92%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/optimizers/pgd.py#L107">107</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/optimizers/pgd.py#L152-L155">152&ndash;155</a></td></tr><tr><td colspan="5"><b>src/mrpro/algorithms/reconstruction</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py">DirectReconstruction.py</a></td><td>28</td><td>16</td><td>43%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py#L59-L79">59&ndash;79</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/DirectReconstruction.py#L93">93</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py">IterativeSENSEReconstruction.py</a></td><td>13</td><td>1</td><td>92%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/IterativeSENSEReconstruction.py#L79">79</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/Reconstruction.py">Reconstruction.py</a></td><td>50</td><td>22</td><td>56%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/Reconstruction.py#L42">42</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/Reconstruction.py#L54-L56">54&ndash;56</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/Reconstruction.py#L80-L87">80&ndash;87</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/Reconstruction.py#L108-L117">108&ndash;117</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/RegularizedIterativeSENSEReconstruction.py">RegularizedIterativeSENSEReconstruction.py</a></td><td>41</td><td>17</td><td>59%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/RegularizedIterativeSENSEReconstruction.py#L97-L101">97&ndash;101</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/algorithms/reconstruction/RegularizedIterativeSENSEReconstruction.py#L115-L140">115&ndash;140</a></td></tr><tr><td colspan="5"><b>src/mrpro/data</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/AcqInfo.py">AcqInfo.py</a></td><td>145</td><td>5</td><td>97%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/AcqInfo.py#L42">42</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/AcqInfo.py#L121-L122">121&ndash;122</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/AcqInfo.py#L124">124</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/AcqInfo.py#L233">233</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/CsmData.py">CsmData.py</a></td><td>29</td><td>3</td><td>90%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/CsmData.py#L15">15</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/CsmData.py#L84-L86">84&ndash;86</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/DcfData.py">DcfData.py</a></td><td>45</td><td>8</td><td>82%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/DcfData.py#L18">18</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/DcfData.py#L66">66</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/DcfData.py#L78-L83">78&ndash;83</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/EncodingLimits.py">EncodingLimits.py</a></td><td>81</td><td>3</td><td>96%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/EncodingLimits.py#L33">33</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/EncodingLimits.py#L123">123</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/EncodingLimits.py#L126">126</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IData.py">IData.py</a></td><td>59</td><td>8</td><td>86%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IData.py#L118">118</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IData.py#L132">132</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IData.py#L159-L167">159&ndash;167</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IHeader.py">IHeader.py</a></td><td>136</td><td>12</td><td>91%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IHeader.py#L69-L72">69&ndash;72</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IHeader.py#L253">253</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IHeader.py#L257">257</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IHeader.py#L261">261</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IHeader.py#L265">265</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/IHeader.py#L299-L303">299&ndash;303</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py">KData.py</a></td><td>202</td><td>30</td><td>85%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L113-L114">113&ndash;114</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L129">129</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L136">136</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L147-L158">147&ndash;158</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L167">167</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L175">175</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L184">184</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L218">218</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L240-L242">240&ndash;242</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L278-L279">278&ndash;279</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L334-L345">334&ndash;345</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L474">474</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L476">476</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KData.py#L546">546</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py">KHeader.py</a></td><td>141</td><td>21</td><td>85%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py#L24">24</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py#L109-L115">109&ndash;115</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py#L142">142</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py#L190">190</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py#L197-L198">197&ndash;198</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py#L201">201</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py#L208">208</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py#L225-L232">225&ndash;232</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KHeader.py#L240-L251">240&ndash;251</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KNoise.py">KNoise.py</a></td><td>31</td><td>15</td><td>52%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KNoise.py#L39-L52">39&ndash;52</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KNoise.py#L56-L61">56&ndash;61</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KTrajectory.py">KTrajectory.py</a></td><td>108</td><td>3</td><td>97%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KTrajectory.py#L166">166</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KTrajectory.py#L168">168</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/KTrajectory.py#L188">188</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py">MoveDataMixin.py</a></td><td>140</td><td>18</td><td>87%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L28">28</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L126">126</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L142">142</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L156-L158">156&ndash;158</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L220">220</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L336-L338">336&ndash;338</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L351">351</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L430">430</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L450-L451">450&ndash;451</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L453">453</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L468-L469">468&ndash;469</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/MoveDataMixin.py#L471">471</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/QData.py">QData.py</a></td><td>39</td><td>7</td><td>82%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/QData.py#L42">42</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/QData.py#L65-L73">65&ndash;73</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py">Rotation.py</a></td><td>719</td><td>35</td><td>95%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L101">101</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L199">199</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L336">336</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L434">434</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L478">478</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L496">496</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L583">583</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L585">585</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L594">594</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L628">628</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L630">630</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L693">693</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L770">770</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L775">775</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L778">778</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L793">793</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L810">810</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L815">815</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L891">891</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1079">1079</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1084">1084</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1087">1087</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1111">1111</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1115">1115</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1243">1243</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1245">1245</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1253-L1254">1253&ndash;1254</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1318">1318</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1400">1400</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1703">1703</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1855">1855</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1890">1890</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L1894">1894</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/Rotation.py#L2084">2084</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py">SpatialDimension.py</a></td><td>232</td><td>20</td><td>91%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L34">34</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L104">104</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L148">148</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L154">154</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L274-L276">274&ndash;276</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L289-L291">289&ndash;291</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L325">325</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L343">343</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L356">356</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L369">369</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L382">382</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L395">395</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L404-L405">404&ndash;405</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L420">420</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/SpatialDimension.py#L429">429</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/acq_filters.py">acq_filters.py</a></td><td>12</td><td>1</td><td>92%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/acq_filters.py#L47">47</a></td></tr><tr><td colspan="5"><b>src/mrpro/data/traj_calculators</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py">KTrajectoryCalculator.py</a></td><td>27</td><td>1</td><td>96%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/traj_calculators/KTrajectoryCalculator.py#L84">84</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py">KTrajectoryIsmrmrd.py</a></td><td>19</td><td>1</td><td>95%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/data/traj_calculators/KTrajectoryIsmrmrd.py#L57">57</a></td></tr><tr><td colspan="5"><b>src/mrpro/operators</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/AveragingOp.py">AveragingOp.py</a></td><td>34</td><td>2</td><td>94%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/AveragingOp.py#L53">53</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/AveragingOp.py#L78">78</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/CartesianSamplingOp.py">CartesianSamplingOp.py</a></td><td>97</td><td>4</td><td>96%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/CartesianSamplingOp.py#L141">141</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/CartesianSamplingOp.py#L180">180</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/CartesianSamplingOp.py#L255">255</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/CartesianSamplingOp.py#L322">322</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/ConstraintsOp.py">ConstraintsOp.py</a></td><td>60</td><td>2</td><td>97%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/ConstraintsOp.py#L46">46</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/ConstraintsOp.py#L48">48</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/EndomorphOperator.py">EndomorphOperator.py</a></td><td>32</td><td>2</td><td>94%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/EndomorphOperator.py#L52">52</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/EndomorphOperator.py#L58">58</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/FiniteDifferenceOp.py">FiniteDifferenceOp.py</a></td><td>27</td><td>2</td><td>93%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/FiniteDifferenceOp.py#L40">40</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/FiniteDifferenceOp.py#L105">105</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/FourierOp.py">FourierOp.py</a></td><td>90</td><td>3</td><td>97%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/FourierOp.py#L185">185</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/FourierOp.py#L257">257</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/FourierOp.py#L262">262</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/Functional.py">Functional.py</a></td><td>77</td><td>9</td><td>88%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/Functional.py#L20-L22">20&ndash;22</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/Functional.py#L117">117</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/Functional.py#L119">119</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/Functional.py#L226-L228">226&ndash;228</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/Functional.py#L242">242</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/GridSamplingOp.py">GridSamplingOp.py</a></td><td>136</td><td>9</td><td>93%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/GridSamplingOp.py#L71-L72">71&ndash;72</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/GridSamplingOp.py#L81-L82">81&ndash;82</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/GridSamplingOp.py#L89-L90">89&ndash;90</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/GridSamplingOp.py#L93">93</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/GridSamplingOp.py#L95">95</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/GridSamplingOp.py#L97">97</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperator.py">LinearOperator.py</a></td><td>201</td><td>8</td><td>96%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperator.py#L107">107</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperator.py#L217">217</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperator.py#L244">244</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperator.py#L251">251</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperator.py#L292">292</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperator.py#L301">301</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperator.py#L309">309</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperator.py#L326">326</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py">LinearOperatorMatrix.py</a></td><td>162</td><td>19</td><td>88%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L82">82</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L119">119</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L152">152</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L161">161</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L166">166</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L175-L178">175&ndash;178</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L191-L194">191&ndash;194</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L202-L203">202&ndash;203</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L208-L209">208&ndash;209</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L221">221</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L310">310</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L337">337</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/LinearOperatorMatrix.py#L364">364</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/MultiIdentityOp.py">MultiIdentityOp.py</a></td><td>13</td><td>2</td><td>85%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/MultiIdentityOp.py#L43">43</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/MultiIdentityOp.py#L48">48</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py">NonUniformFastFourierOp.py</a></td><td>190</td><td>10</td><td>95%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L69">69</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L96">96</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L206">206</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L208">208</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L242">242</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L244">244</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L321">321</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L375">375</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L425">425</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/NonUniformFastFourierOp.py#L430">430</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/Operator.py">Operator.py</a></td><td>79</td><td>2</td><td>97%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/Operator.py#L32">32</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/Operator.py#L88">88</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/ProximableFunctionalSeparableSum.py">ProximableFunctionalSeparableSum.py</a></td><td>39</td><td>3</td><td>92%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/ProximableFunctionalSeparableSum.py#L50">50</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/ProximableFunctionalSeparableSum.py#L103">103</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/ProximableFunctionalSeparableSum.py#L110">110</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/SliceProjectionOp.py">SliceProjectionOp.py</a></td><td>174</td><td>8</td><td>95%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/SliceProjectionOp.py#L45">45</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/SliceProjectionOp.py#L62">62</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/SliceProjectionOp.py#L64">64</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/SliceProjectionOp.py#L70">70</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/SliceProjectionOp.py#L206">206</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/SliceProjectionOp.py#L227">227</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/SliceProjectionOp.py#L260">260</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/SliceProjectionOp.py#L300">300</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/WaveletOp.py">WaveletOp.py</a></td><td>119</td><td>5</td><td>96%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/WaveletOp.py#L151">151</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/WaveletOp.py#L169">169</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/WaveletOp.py#L204">204</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/WaveletOp.py#L209">209</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/WaveletOp.py#L232">232</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/ZeroPadOp.py">ZeroPadOp.py</a></td><td>16</td><td>1</td><td>94%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/ZeroPadOp.py#L30">30</a></td></tr><tr><td colspan="5"><b>src/mrpro/operators/models</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py">EPG.py</a></td><td>203</td><td>40</td><td>80%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L87-L99">87&ndash;99</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L147-L151">147&ndash;151</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L171-L174">171&ndash;174</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L195-L199">195&ndash;199</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L262">262</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L267">267</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L283-L285">283&ndash;285</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L305-L306">305&ndash;306</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L311">311</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L335">335</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L340">340</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L365">365</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L370">370</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L424">424</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L481">481</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L582">582</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/operators/models/EPG.py#L609">609</a></td></tr><tr><td colspan="5"><b>src/mrpro/phantoms</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py">brainweb.py</a></td><td>276</td><td>31</td><td>89%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L326-L336">326&ndash;336</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L372">372</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L428-L431">428&ndash;431</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L453-L454">453&ndash;454</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L459-L460">459&ndash;460</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L462-L463">462&ndash;463</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L467">467</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L475">475</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L482-L483">482&ndash;483</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L521">521</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L587-L588">587&ndash;588</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L607-L610">607&ndash;610</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L621">621</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L623">623</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L656-L657">656&ndash;657</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/phantoms/brainweb.py#L670">670</a></td></tr><tr><td colspan="5"><b>src/mrpro/utils</b></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/filters.py">filters.py</a></td><td>62</td><td>2</td><td>97%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/filters.py#L44">44</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/filters.py#L49">49</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/indexing.py">indexing.py</a></td><td>170</td><td>1</td><td>99%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/indexing.py#L331">331</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/reshape.py">reshape.py</a></td><td>97</td><td>2</td><td>98%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/reshape.py#L112">112</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/reshape.py#L303">303</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/slice_profiles.py">slice_profiles.py</a></td><td>47</td><td>6</td><td>87%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/slice_profiles.py#L21">21</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/slice_profiles.py#L37">37</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/slice_profiles.py#L116-L119">116&ndash;119</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/slice_profiles.py#L152">152</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/sliding_window.py">sliding_window.py</a></td><td>34</td><td>1</td><td>97%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/sliding_window.py#L34">34</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/split_idx.py">split_idx.py</a></td><td>10</td><td>2</td><td>80%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/split_idx.py#L43">43</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/split_idx.py#L47">47</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/summarize_tensorvalues.py">summarize_tensorvalues.py</a></td><td>12</td><td>3</td><td>75%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/summarize_tensorvalues.py#L23">23</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/summarize_tensorvalues.py#L25">25</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/summarize_tensorvalues.py#L27">27</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/typing.py">typing.py</a></td><td>69</td><td>55</td><td>20%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/typing.py#L9-L235">9&ndash;235</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py">unit_conversion.py</a></td><td>60</td><td>14</td><td>77%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L32">32</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L40">40</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L42">42</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L49">49</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L51">51</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L58">58</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L60">60</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L69">69</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L80">80</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L82">82</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L101">101</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L103">103</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L124">124</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/unit_conversion.py#L126">126</a></td></tr><tr><td>&nbsp; &nbsp;<a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/zero_pad_or_crop.py">zero_pad_or_crop.py</a></td><td>31</td><td>6</td><td>81%</td><td><a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/zero_pad_or_crop.py#L26">26</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/zero_pad_or_crop.py#L30">30</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/zero_pad_or_crop.py#L55">55</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/zero_pad_or_crop.py#L58">58</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/zero_pad_or_crop.py#L61">61</a>, <a href="https://github.com/PTB-MR/mrpro/blob/b1a5ff57fc6e034767d1c7a35bab78d6108bab4b/src/mrpro/utils/zero_pad_or_crop.py#L64">64</a></td></tr><tr><td><b>TOTAL</b></td><td><b>6259</b></td><td><b>517</b></td><td><b>92%</b></td><td>&nbsp;</td></tr></tbody></table></details>

| Tests | Skipped | Failures | Errors | Time |
| ----- | ------- | -------- | -------- | ------------------ |
| 2790 | 0 :zzz: | 0 :x: | 0 :fire: | 1m 50s :stopwatch: |

author_association: CONTRIBUTOR
user: login: fzimmermann89
created_at: 2025-02-08T19:43:20Z
body: @schuenke @ckolbPTB 
It seems like inductor/triton/torch.compile does not support complex numbers. And will not in the foreseeable future if I had to guess..
We could split state tensor and the RF matrix in a real and an imaginary matrix. This would make the code a bit more ugly and complex. 
We could have a StateTensor class that hides this, or a custom ComplexTensor that is a torch.Tensor subclass, see https://github.com/albanD/subclass_zoo/blob/main/complex_tensor.py . Both should be supported by torch.compile.
I don't know how much this would matter performance wise, at most a factor of 2. Would we even in this best-case scenario be willing to have this complexity here? If not, I will not experiment with it and only use torchscropt for some of the hot functions.




author_association: MEMBER
user: login: fzimmermann89
created_at: 2025-02-27T17:17:41Z
body: TODOs:

- [ ] Tests
- [ ]  Evaluate numerical results
- [ ]  TSE sequence (or different PR)
- [ ] Add example after merging dictionary matching operator of cmrf reconstruction (or different PR)
author_association: MEMBER
user: login: fzimmermann89
created_at: 2025-02-27T17:18:47Z
body: @ckolbPTB do you want to take over?
author_association: MEMBER
user: login: fzimmermann89
created_at: 2025-03-25T09:53:28Z
body: @ckolbPTB Do we want to merge this first and then do #734 against main?
author_association: MEMBER
user: login: ckolbPTB
created_at: 2025-03-25T10:28:55Z
body: I would prefer to merge #734 into this PR first to avoid adding the `EPGSignalModel `class and then removing it in the next PR again. 
author_association: COLLABORATOR
user: login: fzimmermann89
created_at: 2025-03-30T01:59:43Z
body: @ckolbPTB feel free to merge
author_association: MEMBER