diff --git a/.github/workflows/push_pr.yml b/.github/workflows/push_pr.yml index 49bd2d2..02c0038 100644 --- a/.github/workflows/push_pr.yml +++ b/.github/workflows/push_pr.yml @@ -39,4 +39,10 @@ jobs: shell: bash -l {0} run: | pip install . + pytest -m "not matlab_seq_comp and not sigpy" pypulseq/tests + - name: Run pytest[sigpy] + shell: bash -l {0} + run: | + pip install .[sigpy] pytest -m "not matlab_seq_comp" pypulseq/tests + continue-on-error: true \ No newline at end of file diff --git a/README.md b/README.md index fe67f29..346acc5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ PyPulseq is available on the python Package Index [PyPi](https://pypi.org/projec `pip install pypulseq` +To use the [sigpy](https://sigpy.readthedocs.io/en/latest/) functionality of `make_sigpy_pulse.py` run `pip install pypulseq[sigpy]` to install the required dependencies and enable this functionality. + The latest features and minor bug fixes might not be included in the latest release version. If you want to use the bleeding edge version of PyPulseq, you can install it directly from the development branch of this repository using the command `pip install git+https://github.com/imr-framework/pypulseq@dev` diff --git a/pypulseq/__init__.py b/pypulseq/__init__.py index 5ba8239..17f06d7 100755 --- a/pypulseq/__init__.py +++ b/pypulseq/__init__.py @@ -34,7 +34,6 @@ def round_half_up(n, decimals=0): from pypulseq.make_arbitrary_grad import make_arbitrary_grad from pypulseq.make_arbitrary_rf import make_arbitrary_rf from pypulseq.make_block_pulse import make_block_pulse -from pypulseq.make_sigpy_pulse import * from pypulseq.make_delay import make_delay from pypulseq.make_digital_output_pulse import make_digital_output_pulse from pypulseq.make_extended_trapezoid import make_extended_trapezoid diff --git a/pypulseq/make_adiabatic_pulse.py b/pypulseq/make_adiabatic_pulse.py index c2db5f7..c18d3ce 100644 --- a/pypulseq/make_adiabatic_pulse.py +++ b/pypulseq/make_adiabatic_pulse.py @@ -4,7 +4,6 @@ import numpy as np import math -from sigpy.mri.rf import hypsec, wurst from pypulseq import eps from pypulseq.calc_duration import calc_duration @@ -19,13 +18,13 @@ def make_adiabatic_pulse( pulse_type: str, adiabaticity: int = 4, bandwidth: int = 40000, - beta: int = 800, + beta: float = 800.0, delay: float = 0, duration: float = 10e-3, - dwell: float = 0, + dwell: Union[float, None] = None, freq_offset: float = 0, - max_grad: float = 0, - max_slew: float = 0, + max_grad: Union[float, None] = None, + max_slew: Union[float, None] = None, n_fac: int = 40, mu: float = 4.9, phase_offset: float = 0, @@ -96,11 +95,11 @@ def make_adiabatic_pulse( Delay in seconds (s). duration : float, default=10e-3 Pulse time (s). - dwell : float, default=0 + dwell : float, default=None freq_offset : float, default=0 - max_grad : float, default=0 + max_grad : float, default=None Maximum gradient strength. - max_slew : float, default=0 + max_slew : float, default=None Maximum slew rate. mu : float, default=4.9 Constant determining amplitude of frequency sweep. @@ -136,134 +135,379 @@ def make_adiabatic_pulse( If invalid pulse use is encountered. If slice thickness is not provided but slice-selective trapezoid event is expected. """ - if system == None: + if system is None: system = Opts.default - + + if return_gz and slice_thickness <= 0: + raise ValueError("Slice thickness must be provided") + valid_pulse_types = ["hypsec", "wurst"] - if pulse_type != "" and pulse_type not in valid_pulse_types: - raise ValueError( - f"Invalid type parameter. Must be one of {valid_pulse_types}.Passed: {pulse_type}" - ) - valid_pulse_uses = get_supported_rf_uses() - if use != "" and use not in valid_pulse_uses: - raise ValueError( - f"Invalid use parameter. Must be one of {valid_pulse_uses}. Passed: {use}" - ) - - if dwell == 0: + if (not pulse_type) or (pulse_type not in valid_pulse_types): + raise ValueError(f"Invalid type parameter. Must be one of {valid_pulse_types}.Passed: {pulse_type}") + valid_rf_use_labels = get_supported_rf_uses() + if use != "" and use not in valid_rf_use_labels: + raise ValueError(f"Invalid use parameter. Must be one of {valid_rf_use_labels}. Passed: {use}") + + if dwell is None: dwell = system.rf_raster_time + # WTC and PS - we have no idea why eps is added here. Leaving for now. n_raw = round(duration / dwell + eps) - # Number of points must be divisible by 4 - requirement of sigpy.mri - N = math.floor(n_raw / 4) * 4 + # Number of points must be divisible by 4 - requirement of individual pulse functions + n_samples = math.floor(n_raw / 4) * 4 if pulse_type == "hypsec": - am, fm = hypsec(n=N, beta=beta, mu=mu, dur=duration) + amp_mod, freq_mod = _hypsec(n=n_samples, beta=beta, mu=mu, dur=duration) elif pulse_type == "wurst": - am, fm = wurst(n=N, n_fac=n_fac, bw=bandwidth, dur=duration) - else: - raise ValueError("Unsupported adiabatic pulse type.") + amp_mod, freq_mod = _wurst(n=n_samples, n_fac=n_fac, bw=bandwidth, dur=duration) - pm = np.cumsum(fm) * dwell + phase_mod = np.cumsum(freq_mod) * dwell - ifm = np.argmin(np.abs(fm)) - dfm = abs(fm[ifm]) + min_abs_freq_idx = np.argmin(np.abs(freq_mod)) + min_abs_freq_value = abs(freq_mod[min_abs_freq_idx]) # Find rate of change of frequency at the center of the pulse - if dfm == 0: - pm0 = pm[ifm] - am0 = am[ifm] - roc_fm0 = abs(fm[ifm + 1] - fm[ifm - 1]) / 2 / dwell + if min_abs_freq_value == 0: + phase_at_zero_freq = phase_mod[min_abs_freq_idx] + amp_at_zero_freq = amp_mod[min_abs_freq_idx] + rate_of_freq_change = abs(freq_mod[min_abs_freq_idx + 1] - freq_mod[min_abs_freq_idx - 1]) / (2 * dwell) else: # We need to bracket the zero-crossing - if fm[ifm] * fm[ifm + 1] < 0: - b = 1 - else: - b = -1 + b = 1 if freq_mod[min_abs_freq_idx] * freq_mod[min_abs_freq_idx + 1] < 0 else -1 + diff_freq = freq_mod[min_abs_freq_idx + b] - freq_mod[min_abs_freq_idx] - pm0 = (pm[ifm] * fm[ifm + b] - pm[ifm + b] * fm[ifm]) / (fm[ifm + b] - fm[ifm]) - am0 = (am[ifm] * fm[ifm + b] - am[ifm + b] * fm[ifm]) / (fm[ifm + b] - fm[ifm]) - roc_fm0 = abs(fm[ifm] - fm[ifm + b]) / dwell + phase_at_zero_freq = ( + phase_mod[min_abs_freq_idx] * freq_mod[min_abs_freq_idx + b] + - phase_mod[min_abs_freq_idx + b] * freq_mod[min_abs_freq_idx] + ) / diff_freq - pm -= pm0 - a = (roc_fm0 * adiabaticity) ** 0.5 / 2 / np.pi / am0 + amp_at_zero_freq = ( + amp_mod[min_abs_freq_idx] * freq_mod[min_abs_freq_idx + b] + - amp_mod[min_abs_freq_idx + b] * freq_mod[min_abs_freq_idx] + ) / diff_freq - signal = a * am * np.exp(1j * pm) + rate_of_freq_change = abs(freq_mod[min_abs_freq_idx] - freq_mod[min_abs_freq_idx + b]) / dwell - if N != n_raw: - n_pad = n_raw - N - signal = [ - np.zeros(1, n_pad - math.floor(n_pad / 2)), - signal, - np.zeros(1, math.floor(n_pad / 2)), - ] - N = n_raw + # Adjust phase modulation and calculate amplitude + phase_mod -= phase_at_zero_freq + amp = np.sqrt(rate_of_freq_change * adiabaticity) / (2 * np.pi * amp_at_zero_freq) - t = (np.arange(1, N + 1) - 0.5) * dwell + # Create the modulated signal + signal = amp * amp_mod * np.exp(1j * phase_mod) + + # Adjust the number of samples if needed + if n_samples != n_raw: + n_pad = n_raw - n_samples + pad_left = n_pad // 2 + pad_right = n_pad - pad_left + signal = np.pad(signal, (pad_left, pad_right), mode="constant") + n_samples = n_raw + + # Calculate time points + t = (np.arange(n_samples) + 0.5) * dwell rf = SimpleNamespace() rf.type = "rf" rf.signal = signal rf.t = t - rf.shape_dur = N * dwell + rf.shape_dur = n_samples * dwell rf.freq_offset = freq_offset rf.phase_offset = phase_offset rf.dead_time = system.rf_dead_time rf.ringdown_time = system.rf_ringdown_time rf.delay = delay - if use != "": - rf.use = use - else: - rf.use = "inversion" + rf.use = use if use != "" else "inversion" if rf.dead_time > rf.delay: rf.delay = rf.dead_time if return_gz: - if slice_thickness <= 0: - raise ValueError("Slice thickness must be provided") - - if max_grad > 0: - system = copy(system) - system.max_grad = max_grad + if max_grad is not None: + max_grad_slice_select = max_grad + else: + # Set to zero, not None for compatibility with existing make_trapezoid + max_grad_slice_select = 0 - if max_slew > 0: - system = copy(system) - system.max_slew = max_slew + if max_slew is not None: + max_slew_slice_select = max_slew + else: + # Set to zero, not None for compatibility with existing make_trapezoid + max_slew_slice_select = 0 if pulse_type == "hypsec": bandwidth = mu * beta / np.pi elif pulse_type == "wurst": bandwidth = bandwidth - else: - raise ValueError("Unsupported adiabatic pulse type.") center_pos, _ = calc_rf_center(rf) amplitude = bandwidth / slice_thickness area = amplitude * duration gz = make_trapezoid( - channel="z", system=system, flat_time=duration, flat_area=area - ) + channel="z", + system=system, + flat_time=duration, + flat_area=area, + max_grad=max_grad_slice_select, + max_slew=max_slew_slice_select) gzr = make_trapezoid( channel="z", system=system, area=-area * (1 - center_pos) - 0.5 * (gz.area - area), - ) + max_grad=max_grad_slice_select, + max_slew=max_slew_slice_select) if rf.delay > gz.rise_time: # Round-up to gradient raster - gz.delay = ( - math.ceil((rf.delay - gz.rise_time) / system.grad_raster_time) - * system.grad_raster_time - ) + gz.delay = math.ceil((rf.delay - gz.rise_time) / system.grad_raster_time) * system.grad_raster_time if rf.delay < (gz.rise_time + gz.delay): rf.delay = gz.rise_time + gz.delay - if rf.ringdown_time > 0 and return_delay: + if rf.ringdown_time > 0: delay = make_delay(calc_duration(rf) + rf.ringdown_time) + else: + delay = make_delay(calc_duration(rf)) if return_gz and return_delay: return rf, gz, gzr, delay + elif return_delay: + return rf, delay elif return_gz: return rf, gz, gzr else: return rf + + +"""Adiabatic Pulse Design functions. + The below functions are originally from ssigpy/sigpy/mri/rf/adiabatic.py + Used under the terms of the Sigpy BSD 3-clause licence. + + Copyright (c) 2016, Frank Ong. + Copyright (c) 2016, The Regents of the University of California. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the distribution. + + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" + + +def _bir4(n: int, beta: float, kappa: float, theta: float, dw0: np.ndarray): + r"""Design a BIR-4 adiabatic pulse. + + BIR-4 is equivalent to two BIR-1 pulses back-to-back. + + Args: + n (int): number of samples (should be a multiple of 4). + beta (float): AM waveform parameter. + kappa (float): FM waveform parameter. + theta (float): flip angle in radians. + dw0: FM waveform scaling (radians/s). + + Returns: + 2-element tuple containing + + - **a** (*array*): AM waveform. + - **om** (*array*): FM waveform (radians/s). + + References: + Staewen, R.S. et al. (1990). '3-D FLASH Imaging using a single surface + coil and a new adiabatic pulse, BIR-4'. + Invest. Radiology, 25:559-567. + """ + + dphi = np.pi + theta / 2 + + t = np.arange(0, n) / n + + a1 = np.tanh(beta * (1 - 4 * t[: n // 4])) + a2 = np.tanh(beta * (4 * t[n // 4 : n // 2] - 1)) + a3 = np.tanh(beta * (3 - 4 * t[n // 2 : 3 * n // 4])) + a4 = np.tanh(beta * (4 * t[3 * n // 4 :] - 3)) + + a = np.concatenate((a1, a2, a3, a4)).astype(np.complex64) + a[n // 4 : 3 * n // 4] = a[n // 4 : 3 * n // 4] * np.exp(1j * dphi) + + om1 = dw0 * np.tan(kappa * 4 * t[: n // 4]) / np.tan(kappa) + om2 = dw0 * np.tan(kappa * (4 * t[n // 4 : n // 2] - 2)) / np.tan(kappa) + om3 = dw0 * np.tan(kappa * (4 * t[n // 2 : 3 * n // 4] - 2)) / np.tan(kappa) + om4 = dw0 * np.tan(kappa * (4 * t[3 * n // 4 :] - 4)) / np.tan(kappa) + + om = np.concatenate((om1, om2, om3, om4)) + + return a, om + + +def _hypsec(n: int = 512, beta: float = 800.0, mu: float = 4.9, dur: float = 0.012): + r"""Design a hyperbolic secant adiabatic pulse. + + mu * beta becomes the amplitude of the frequency sweep + + Args: + n (int): number of samples (should be a multiple of 4). + beta (float): AM waveform parameter. + mu (float): a constant, determines amplitude of frequency sweep. + dur (float): pulse time (s). + + Returns: + 2-element tuple containing + + - **a** (*array*): AM waveform. + - **om** (*array*): FM waveform (radians/s). + + References: + Baum, J., Tycko, R. and Pines, A. (1985). 'Broadband and adiabatic + inversion of a two-level system by phase-modulated pulses'. + Phys. Rev. A., 32:3435-3447. + """ + + t = np.arange(-n // 2, n // 2) / n * dur + + a = np.cosh(beta * t) ** -1 + om = -mu * beta * np.tanh(beta * t) + + return a, om + + +def _wurst(n: int = 512, n_fac: int = 40, bw: float = 40e3, dur: float = 2e-3): + r"""Design a WURST (wideband, uniform rate, smooth truncation) adiabatic + inversion pulse + + Args: + n (int): number of samples (should be a multiple of 4). + n_fac (int): power to exponentiate to within AM term. ~20 or greater is + typical. + bw (float): pulse bandwidth. + dur (float): pulse time (s). + + + Returns: + 2-element tuple containing + + - **a** (*array*): AM waveform. + - **om** (*array*): FM waveform (radians/s). + + References: + Kupce, E. and Freeman, R. (1995). 'Stretched Adiabatic Pulses for + Broadband Spin Inversion'. + J. Magn. Reson. Ser. A., 117:246-256. + """ + + t = np.arange(0, n) * dur / n + + a = 1 - np.power(np.abs(np.cos(np.pi * t / dur)), n_fac) + om = np.linspace(-bw / 2, bw / 2, n) * 2 * np.pi + + return a, om + + +def _goia_wurst( + n: int = 512, + dur: float = 3.5e-3, + f: float = 0.9, + n_b1: int = 16, + m_grad: int = 4, + b1_max: float = 817, + bw: float = 20000, +): + r"""Design a GOIA (gradient offset independent adiabaticity) WURST + inversion pulse + + Args: + n (int): number of samples. + dur (float): pulse duration (s). + f (float): [0,1] gradient modulation factor + n_b1 (int): order for B1 modulation + m_grad (int): order for gradient modulation + b1_max (float): maximum b1 (Hz) + bw (float): pulse bandwidth (Hz) + + Returns: + 3-element tuple containing: + + - **a** (*array*): AM waveform (Hz) + - **om** (*array*): FM waveform (Hz) + - **g** (*array*): normalized gradient waveform + + References: + O. C. Andronesi, S. Ramadan, E.-M. Ratai, D. Jennings, C. E. Mountford, + A. G. Sorenson. + J Magn Reson, 203:283-293, 2010. + + """ + + t = np.arange(0, n) * dur / n + + a = b1_max * (1 - np.abs(np.sin(np.pi / 2 * (2 * t / dur - 1))) ** n_b1) + g = (1 - f) + f * np.abs(np.sin(np.pi / 2 * (2 * t / dur - 1))) ** m_grad + om = np.cumsum((a**2) / g) * dur / n + om = om - om[n // 2 + 1] + om = g * om + om = om / np.max(np.abs(om)) * bw / 2 + + return a, om, g + + +def _bloch_siegert_fm( + n: int = 512, + dur: float = 2e-3, + b1p: float = 20.0, + k: float = 42.0, + gamma: Union[float, None] = None, +): + r""" + U-shaped FM waveform for adiabatic Bloch-Siegert :math:`B_1^{+}` mapping + and spatial encoding. + + Args: + n (int): number of time points + dur (float): duration in seconds + b1p (float): nominal amplitude of constant AM waveform + k (float): design parameter that affects max in-band + perturbation + gamma (float): gyromagnetic ratio + + Returns: + om (array): FM waveform (radians/s). + + References: + M. M. Khalighi, B. K. Rutt, and A. B. Kerr. + Adiabatic RF pulse design for Bloch-Siegert B1+ mapping. + Magn Reson Med, 70(3):829–835, 2013. + + M. Jankiewicz, J. C. Gore, and W. A. Grissom. + Improved encoding pulses for Bloch-Siegert B1+ mapping. + J Magn Reson, 226:79–87, 2013. + + """ + + # set gamma to PyPulseq default if not provided + if gamma is None: + gamma = 2 * np.pi * 42.576e6 + + t = np.arange(1, n // 2) * dur / n + + om = gamma * b1p / np.sqrt((1 - gamma * b1p / k * t) ** -2 - 1) + om = np.concatenate((om, om[::-1])) + + return om diff --git a/pypulseq/make_sigpy_pulse.py b/pypulseq/make_sigpy_pulse.py index 9eca271..9970691 100644 --- a/pypulseq/make_sigpy_pulse.py +++ b/pypulseq/make_sigpy_pulse.py @@ -4,8 +4,17 @@ from copy import copy import numpy as np -import sigpy.mri.rf as rf -import sigpy.plot as pl +try: + import sigpy.mri.rf as rf + import sigpy.plot as pl +except ModuleNotFoundError as exc: + import warnings + warnings.warn( + 'Sigpy dependency not found, please install it to use ' + 'the sigpy pulse functions: sigpy_n_seq, make_slr, make_sms. ' + 'Use "pip install pypulse[sigpy]" to install.', + UserWarning) + raise exc from pypulseq.make_trapezoid import make_trapezoid from pypulseq.opts import Opts diff --git a/pypulseq/tests/pytest.ini b/pypulseq/tests/pytest.ini index 0f760ea..1ab8283 100644 --- a/pypulseq/tests/pytest.ini +++ b/pypulseq/tests/pytest.ini @@ -1,3 +1,4 @@ [pytest] markers = - matlab_seq_comp: marks tests as comparison with matlab generated sequence (deselect with '-m "not matlab_seq_comp"') \ No newline at end of file + matlab_seq_comp: marks tests as comparison with matlab generated sequence (deselect with '-m "not matlab_seq_comp"') + sigpy: marks tests as those which require a sigpy installation (deselect with '-m "not sigpy"') \ No newline at end of file diff --git a/pypulseq/tests/test_make_adiabatic_pulse.py b/pypulseq/tests/test_make_adiabatic_pulse.py new file mode 100644 index 0000000..0187cc4 --- /dev/null +++ b/pypulseq/tests/test_make_adiabatic_pulse.py @@ -0,0 +1,113 @@ +"""Tests for the make_adiabatic_pulse.py module + +Will Clarke, University of Oxford, 2024 +""" + +import pytest +import itertools + +import numpy as np + +from pypulseq.supported_labels_rf_use import get_supported_rf_uses +from pypulseq import make_adiabatic_pulse + + +def test_pulse_select(): + valid_rf_use_labels = get_supported_rf_uses() + valid_pulse_types = ('hypsec', 'wurst') + + # Check all use and valid pulse combinations return a sensible object + # with default parameters. + for pulse_type, use_label in itertools.product(valid_pulse_types, valid_rf_use_labels): + rf_obj = make_adiabatic_pulse( + pulse_type=pulse_type, + use=use_label + ) + assert rf_obj.type == 'rf' + assert rf_obj.use == use_label + + # Check the appropriate errors are raised if we specify nonsense + with pytest.raises( + ValueError, + match="Invalid type parameter. Must be one of "): + make_adiabatic_pulse(pulse_type="not a pulse type") + + with pytest.raises( + ValueError, + match="Invalid type parameter. Must be one of "): + make_adiabatic_pulse(pulse_type="") + + with pytest.raises( + ValueError, + match="Invalid use parameter. Must be one of "): + make_adiabatic_pulse( + pulse_type="hypsec", + use='not a use') + + # Default use case + rf_obj = make_adiabatic_pulse(pulse_type="hypsec") + assert rf_obj.use == "inversion" + + +def test_option_requirements(): + + # Require non-zero slice thickness if grad requested + with pytest.raises( + ValueError, + match="Slice thickness must be provided"): + make_adiabatic_pulse( + pulse_type="hypsec", + return_gz=True) + + _, gz, gzr = make_adiabatic_pulse( + pulse_type="hypsec", + return_gz=True, + slice_thickness=1) + assert gz.type == 'trap' + assert gzr.type == 'trap' + + # Assert delay is returned if requested + _, delay = make_adiabatic_pulse( + pulse_type="hypsec", + return_gz=False, + return_delay=True) + assert delay.type == 'delay' + + _, _, _, delay = make_adiabatic_pulse( + pulse_type="hypsec", + return_gz=True, + slice_thickness=1, + return_delay=True) + assert delay.type == 'delay' + + +# My intention was to test that the rephase gradient area is appropriate, +# but this doesn't pass and I'm highly suspicious of the calculation in +# the code +def test_returned_grads(): + pass + # _, gz, gzr = make_adiabatic_pulse( + # pulse_type="hypsec", + # return_gz=True, + # slice_thickness=1) + # assert np.isclose(-gz.area / 2, gzr.area) + + +def test_hypsec_options(): + pobj = make_adiabatic_pulse( + pulse_type="hypsec", + beta=700, + mu=6, + duration=0.05) + + assert np.isclose(pobj.shape_dur, 0.05) + + +def test_wurst_options(): + pobj = make_adiabatic_pulse( + pulse_type="wurst", + n_fac=25, + bandwidth=30000, + duration=0.05) + + assert np.isclose(pobj.shape_dur, 0.05) diff --git a/pypulseq/tests/test_sigpy.py b/pypulseq/tests/test_sigpy.py index 6f208c3..627d899 100644 --- a/pypulseq/tests/test_sigpy.py +++ b/pypulseq/tests/test_sigpy.py @@ -1,129 +1,146 @@ # sms - check MB # slr - check slice profile -import unittest +import pytest import numpy as np -import sigpy.mri.rf as rf import pypulseq as pp -from pypulseq.make_sigpy_pulse import sigpy_n_seq from pypulseq.opts import Opts from pypulseq.sigpy_pulse_opts import SigpyPulseOpts -class TestSigpyPulseMethods(unittest.TestCase): - def test_slr(self): - print("Testing SLR design") - - time_bw_product = 4 - slice_thickness = 3e-3 # Slice thickness - flip_angle = np.pi / 2 - # Set system limits - system = Opts( - max_grad=32, - grad_unit="mT/m", - max_slew=130, - slew_unit="T/m/s", - rf_ringdown_time=30e-6, - rf_dead_time=100e-6, - ) - pulse_cfg = SigpyPulseOpts( - pulse_type="slr", - ptype="st", - ftype="ls", - d1=0.01, - d2=0.01, - cancel_alpha_phs=False, - n_bands=3, - band_sep=20, - phs_0_pt="None", - ) - rfp, gz, _, pulse = sigpy_n_seq( - flip_angle=flip_angle, - system=system, - duration=3e-3, - slice_thickness=slice_thickness, - time_bw_product=4, - return_gz=True, - pulse_cfg=pulse_cfg, - plot=False, - ) - - seq = pp.Sequence() - seq.add_block(rfp) - - [a, b] = rf.sim.abrm( - pulse, - np.arange( - -20 * time_bw_product, 20 * time_bw_product, 40 * time_bw_product / 2000 - ), - True, - ) - Mxy = 2 * np.multiply(np.conj(a), b) - # pl.LinePlot(Mxy) - # print(np.sum(np.abs(Mxy))) - # peaks, dict = sis.find_peaks(np.abs(Mxy),threshold=0.5, plateau_size=40) - plateau_widths = np.sum(np.abs(Mxy) > 0.8) - self.assertTrue(29, plateau_widths) - - def test_sms(self): - print("Testing SMS design") - - time_bw_product = 4 - slice_thickness = 3e-3 # Slice thickness - flip_angle = np.pi / 2 - n_bands = 3 - # Set system limits - system = Opts( - max_grad=32, - grad_unit="mT/m", - max_slew=130, - slew_unit="T/m/s", - rf_ringdown_time=30e-6, - rf_dead_time=100e-6, - ) - pulse_cfg = SigpyPulseOpts( - pulse_type="sms", - ptype="st", - ftype="ls", - d1=0.01, - d2=0.01, - cancel_alpha_phs=False, - n_bands=n_bands, - band_sep=20, - phs_0_pt="None", - ) - rfp, gz, _, pulse = sigpy_n_seq( - flip_angle=flip_angle, - system=system, - duration=3e-3, - slice_thickness=slice_thickness, - time_bw_product=4, - return_gz=True, - pulse_cfg=pulse_cfg, - plot=False - ) - - seq = pp.Sequence() - seq.add_block(rfp) - - [a, b] = rf.sim.abrm( - pulse, - np.arange( - -20 * time_bw_product, 20 * time_bw_product, 40 * time_bw_product / 2000 - ), - True, - ) - Mxy = 2 * np.multiply(np.conj(a), b) - # pl.LinePlot(Mxy) - # print(np.sum(np.abs(Mxy))) - # peaks, dict = sis.find_peaks(np.abs(Mxy),threshold=0.5, plateau_size=40) - plateau_widths = np.sum(np.abs(Mxy) > 0.8) - self.assertEqual( - 29 * n_bands, plateau_widths - ) # if slr has 29 > 0.8, then sms with MB = n_bands - - -if __name__ == "__main__": - unittest.main() +def test_sigpy_import(): + warn_str = r'Sigpy dependency not found, please install it to use '\ + r'the sigpy pulse functions: sigpy_n_seq, make_slr, make_sms. '\ + r'Use "pip install pypulse\[sigpy\]" to install.' + try: + from pypulseq.make_sigpy_pulse import sigpy_n_seq + except (ImportError, ModuleNotFoundError): + with pytest.raises( + (ImportError, ModuleNotFoundError)): + with pytest.warns( + UserWarning, + match=warn_str): + from pypulseq.make_sigpy_pulse import sigpy_n_seq + + +@pytest.mark.sigpy +def test_slr(): + from pypulseq.make_sigpy_pulse import sigpy_n_seq + import sigpy.mri.rf as rf + + print("Testing SLR design") + + time_bw_product = 4 + slice_thickness = 3e-3 # Slice thickness + flip_angle = np.pi / 2 + # Set system limits + system = Opts( + max_grad=32, + grad_unit="mT/m", + max_slew=130, + slew_unit="T/m/s", + rf_ringdown_time=30e-6, + rf_dead_time=100e-6, + ) + pulse_cfg = SigpyPulseOpts( + pulse_type="slr", + ptype="st", + ftype="ls", + d1=0.01, + d2=0.01, + cancel_alpha_phs=False, + n_bands=3, + band_sep=20, + phs_0_pt="None", + ) + rfp, gz, _, pulse = sigpy_n_seq( + flip_angle=flip_angle, + system=system, + duration=3e-3, + slice_thickness=slice_thickness, + time_bw_product=4, + return_gz=True, + pulse_cfg=pulse_cfg, + plot=False, + ) + + seq = pp.Sequence() + seq.add_block(rfp) + + [a, b] = rf.sim.abrm( + pulse, + np.arange( + -20 * time_bw_product, 20 * time_bw_product, 40 * time_bw_product / 2000 + ), + True, + ) + Mxy = 2 * np.multiply(np.conj(a), b) + # pl.LinePlot(Mxy) + # print(np.sum(np.abs(Mxy))) + # peaks, dict = sis.find_peaks(np.abs(Mxy),threshold=0.5, plateau_size=40) + plateau_widths = np.sum(np.abs(Mxy) > 0.8) + assert 29 == plateau_widths + + +@pytest.mark.sigpy +def test_sms(): + from pypulseq.make_sigpy_pulse import sigpy_n_seq + import sigpy.mri.rf as rf + + print("Testing SMS design") + + time_bw_product = 4 + slice_thickness = 3e-3 # Slice thickness + flip_angle = np.pi / 2 + n_bands = 3 + # Set system limits + system = Opts( + max_grad=32, + grad_unit="mT/m", + max_slew=130, + slew_unit="T/m/s", + rf_ringdown_time=30e-6, + rf_dead_time=100e-6, + ) + pulse_cfg = SigpyPulseOpts( + pulse_type="sms", + ptype="st", + ftype="ls", + d1=0.01, + d2=0.01, + cancel_alpha_phs=False, + n_bands=n_bands, + band_sep=20, + phs_0_pt="None", + ) + rfp, gz, _, pulse = sigpy_n_seq( + flip_angle=flip_angle, + system=system, + duration=3e-3, + slice_thickness=slice_thickness, + time_bw_product=4, + return_gz=True, + pulse_cfg=pulse_cfg, + plot=False + ) + + seq = pp.Sequence() + seq.add_block(rfp) + + [a, b] = rf.sim.abrm( + pulse, + np.arange( + -20 * time_bw_product, 20 * time_bw_product, 40 * time_bw_product / 2000 + ), + True, + ) + Mxy = 2 * np.multiply(np.conj(a), b) + # pl.LinePlot(Mxy) + # print(np.sum(np.abs(Mxy))) + # peaks, dict = sis.find_peaks(np.abs(Mxy),threshold=0.5, plateau_size=40) + plateau_widths = np.sum(np.abs(Mxy) > 0.8) + # if slr has 29 > 0.8, then sms with MB = n_bands + assert (29 * n_bands) == plateau_widths + diff --git a/setup.py b/setup.py index 52cbf79..40f1675 100644 --- a/setup.py +++ b/setup.py @@ -36,8 +36,10 @@ def _get_long_description() -> str: "matplotlib>=3.5.2", "numpy>=1.19.5", "scipy>=1.8.1", - "sigpy>=0.1.26", ], + extras_require={ + "sigpy": ["sigpy>=0.1.26", ], + }, license="License :: OSI Approved :: GNU Affero General Public License v3", long_description=_get_long_description(), long_description_content_type="text/markdown",