Repository: openmrslab/suspect

Issue:
ENH: auto-phasing
Add some algorithms for automatic phase estimation. Good first candidate is mag/real comparison, could also look at Ernst (minimise integral of imaginary component).

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/12379758/badge)](https://coveralls.io/builds/12379758)

Coverage increased (+0.3%) to 79.735% when pulling **cce3e373e9c39fbcdc2a185f9597c06d4a51f87a on 76_autophasing** into **153f01f32f025eedfaefc655fb14621d087dd113 on master**.


Patch:
diff --git a/suspect/basis/__init__.py b/suspect/basis/__init__.py
index e0296f9..257e640 100644
--- a/suspect/basis/__init__.py
+++ b/suspect/basis/__init__.py
@@ -1,7 +1,9 @@
 import numpy
+from ..mrsobjects import MRSData
 
 
-def gaussian(time_axis, frequency, phase, fwhm):
+def gaussian(time_axis, frequency, phase, fwhm, f0=123.0):
+    dt = time_axis[1] - time_axis[0]
     oscillatory_term = numpy.exp(2j * numpy.pi * (frequency * time_axis) + 1j * phase)
     damping = numpy.exp(-time_axis ** 2 / 4 * numpy.pi ** 2 / numpy.log(2) * fwhm ** 2)
     fid = oscillatory_term * damping
@@ -13,10 +15,11 @@ def gaussian(time_axis, frequency, phase, fwhm):
     # the chosen df does not affect the area, so we divide by df, which is
     # equivalent to multiplying by dt * np, then the np terms cancel and we
     # are left with the dt term (and a 2 because fid[0] = 0.5, not 1)
-    return fid * (time_axis[1] - time_axis[0]) * 2.0
+    fid = fid * dt * 2.0
+    return MRSData(fid, dt, f0)
 
 
-def lorentzian(time_axis, frequency, phase, fwhm):
+def lorentzian(time_axis, frequency, phase, fwhm, f0=123.0):
     oscillatory_term = numpy.exp(1j * (2 * numpy.pi * frequency * time_axis + phase))
     damping = numpy.exp(-time_axis * numpy.pi * fwhm)
     fid = oscillatory_term * damping
diff --git a/suspect/processing/__init__.py b/suspect/processing/__init__.py
index 4944019..68d8c93 100644
--- a/suspect/processing/__init__.py
+++ b/suspect/processing/__init__.py
@@ -1,2 +1,2 @@
-from . import frequency_correction, channel_combination, denoising, water_suppression
+from . import frequency_correction, channel_combination, denoising, water_suppression, phase
 from suspect.processing._apodize import *
\ No newline at end of file
diff --git a/suspect/processing/phase.py b/suspect/processing/phase.py
new file mode 100644
index 0000000..acfe9dd
--- /dev/null
+++ b/suspect/processing/phase.py
@@ -0,0 +1,161 @@
+import lmfit
+import numpy as np
+
+
+def mag_real(data, *args, range_hz=None, range_ppm=None):
+    """
+    Estimates the zero and first order phase parameters which minimise the
+    difference between the real part of the spectrum and the magnitude. Note
+    that these are the phase correction terms, designed to be used directly
+    in the adjust_phase() function without negation.
+    
+    Parameters
+    ----------
+    data: MRSBase
+        The data to be phased
+    range_hz: tuple (low, high)
+        The frequency range in Hertz over which to compare the spectra
+    range_ppm: tuple (low, high)
+        The frequency range in PPM over which to compare the spectra. range_hz
+        and range_ppm cannot both be defined.
+    Returns
+    -------
+    phi0 : float
+        The estimated zero order phase correction
+    phi1 : float
+        The estimated first order phase correction
+    """
+    if range_hz is not None and range_ppm is not None:
+        raise KeyError("Cannot specify both range_hz and range_ppm")
+
+    if range_hz is not None:
+        frequency_slice = data.slice_hz(*range_hz)
+    elif range_hz is not None:
+        frequency_slice = data.slice_ppm(*range_ppm)
+    else:
+        frequency_slice = slice(0, data.np)
+
+    def single_spectrum_version(spectrum):
+        def residual(pars):
+            par_vals = pars.valuesdict()
+            phased_data = spectrum.adjust_phase(par_vals['phi0'],)
+                                                #par_vals['phi1'])
+
+            diff = np.real(phased_data) - np.abs(spectrum)
+
+            return diff[frequency_slice]
+
+        params = lmfit.Parameters()
+        params.add('phi0', value=0, min=-np.pi, max=np.pi)
+        #params.add('phi1', value=0.0, min=-0.01, max=0.25)
+
+        result = lmfit.minimize(residual, params)
+        #return result.params['phi0'].value, result.params['phi1'].value
+        return result.params['phi0'].value, 0
+
+    return np.apply_along_axis(single_spectrum_version,
+                               axis=-1,
+                               arr=data.spectrum())
+
+
+def ernst(data):
+    """
+    Estimates the zero and first order phase using the ACME algorithm, which
+    minimises the integral of the imaginary part of the spectrum. Note that
+    these are the phase correction terms, designed to be used directly in the
+    adjust_phase() function without negation.
+
+    Parameters
+    ----------
+    data: MRSBase
+        The data to be phased
+    range_hz: tuple (low, high)
+        The frequency range in Hertz over which to compare the spectra
+    range_ppm: tuple (low, high)
+        The frequency range in PPM over which to compare the spectra. range_hz
+        and range_ppm cannot both be defined.
+    Returns
+    -------
+    phi0 : float
+        The estimated zero order phase correction
+    phi1 : float
+        The estimated first order phase correction
+    """
+    def residual(pars):
+        par_vals = pars.valuesdict()
+        phased_data = data.adjust_phase(par_vals['phi0'],
+                                        par_vals['phi1'])
+        return np.sum(phased_data.spectrum().imag)
+
+    params = lmfit.Parameters()
+    params.add('phi0', value=0, min=-np.pi, max=np.pi)
+    params.add('phi1', value=0.0, min=-0.005, max=0.1)
+
+    result = lmfit.minimize(residual, params, method='simplex')
+    return result.params['phi0'].value, result.params['phi1'].value
+
+
+def acme(data, *args, range_hz=None, range_ppm=None):
+    """
+    Estimates the zero and first order phase using the ACME algorithm, which
+    minimises the entropy of the real part of the spectrum. Note that these
+    are the phase correction terms, designed to be used directly in the
+    adjust_phase() function without negation.
+    
+    Parameters
+    ----------
+    data: MRSBase
+        The data to be phased
+    range_hz: tuple (low, high)
+        The frequency range in Hertz over which to compare the spectra
+    range_ppm: tuple (low, high)
+        The frequency range in PPM over which to compare the spectra. range_hz
+        and range_ppm cannot both be defined.
+    Returns
+    -------
+    phi0 : float
+        The estimated zero order phase correction
+    phi1 : float
+        The estimated first order phase correction
+    """
+    if range_hz is not None and range_ppm is not None:
+        raise KeyError("Cannot specify both range_hz and range_ppm")
+
+    if range_hz is not None:
+        frequency_slice = data.slice_hz(*range_hz)
+    elif range_hz is not None:
+        frequency_slice = data.slice_ppm(*range_ppm)
+    else:
+        frequency_slice = slice(0, data.np)
+
+    def single_spectrum_version(spectrum):
+        def residual(pars):
+            par_vals = pars.valuesdict()
+            phased_data = spectrum.adjust_phase(par_vals['phi0'],
+                                            par_vals['phi1'])
+
+            r = phased_data.real[frequency_slice]
+            derivative = np.abs((r[1:] - r[:-1]))
+            derivative_norm = derivative / np.sum(derivative)
+
+            # make sure the entropy doesn't blow up by removing 0 values
+            derivative_norm[derivative_norm == 0] = 1
+
+            entropy = -np.sum(derivative_norm * np.log(derivative_norm))
+
+            # penalty function
+            p = np.sum(r[r < 0] ** 2)
+            gamma = 1000
+
+            return entropy + gamma * p
+
+        params = lmfit.Parameters()
+        params.add('phi0', value=0.0, min=-np.pi, max=np.pi)
+        params.add('phi1', value=0.0, min=-0.005, max=0.01)
+
+        result = lmfit.minimize(residual, params, method='simplex')
+        return result.params['phi0'].value, result.params['phi1'].value
+
+    return np.apply_along_axis(single_spectrum_version,
+                               -1,
+                               data.spectrum())


Test patch:
diff --git a/tests/test_mrs/test_processing/test_phasing.py b/tests/test_mrs/test_processing/test_phasing.py
new file mode 100644
index 0000000..b7ae7da
--- /dev/null
+++ b/tests/test_mrs/test_processing/test_phasing.py
@@ -0,0 +1,61 @@
+import suspect
+import numpy as np
+
+
+def test_mag_real_zero():
+    time_axis = np.arange(0, 1.024, 2.5e-4)
+    sample_data = (6 * suspect.basis.gaussian(time_axis, 0, 0.0, 12)
+                   + suspect.basis.gaussian(time_axis, 250, 0.0, 12)
+                   + suspect.basis.gaussian(time_axis, 500, 0.0, 12))
+    sample_data = sample_data.adjust_phase(0.2, 0)
+    sample_data += np.random.rand(len(sample_data)) * 1e-6
+
+    phi0, phi1 = suspect.processing.phase.mag_real(sample_data)
+
+    np.testing.assert_allclose(phi0, -0.2, rtol=0.05)
+
+
+def test_acme_zero():
+    time_axis = np.arange(0, 1.024, 2.5e-4)
+    sample_data = (6 * suspect.basis.gaussian(time_axis, 0, 0.0, 12)
+                   + suspect.basis.gaussian(time_axis, 50, 0.0, 12)
+                   + suspect.basis.gaussian(time_axis, 200, 0.0, 12))
+    sample_data = sample_data.adjust_phase(0.2, 0)
+    sample_data += np.random.rand(len(sample_data)) * 1e-6
+
+    phi0, phi1 = suspect.processing.phase.acme(sample_data)
+
+    np.testing.assert_allclose(phi0, -0.2, rtol=0.05)
+
+
+def test_acme_first():
+    time_axis = np.arange(0, 1.024, 2.5e-4)
+    sample_data = (6 * suspect.basis.gaussian(time_axis, 0, 0.0, 6)
+                   + suspect.basis.gaussian(time_axis, 150, 0.0, 6))
+    sample_data += np.random.rand(len(sample_data)) * 2e-6
+
+    in_0 = 0.5
+    in_1 = 0.001
+    sample_data = sample_data.adjust_phase(in_0, in_1)
+
+    out_0, out_1 = suspect.processing.phase.acme(sample_data)
+
+    np.testing.assert_allclose(in_0, -out_0, rtol=0.05)
+    np.testing.assert_allclose(in_1, -out_1, rtol=0.05)
+
+
+def test_acme_range_hz():
+    time_axis = np.arange(0, 1.024, 2.5e-4)
+    sample_data = (6 * suspect.basis.gaussian(time_axis, 0, 0.0, 12)
+                   + suspect.basis.gaussian(time_axis, 50, 0.0, 12)
+                   - suspect.basis.gaussian(time_axis, 200, 0.0, 12))
+    sample_data += np.random.rand(len(sample_data)) * 1e-7
+
+    in_0 = 0.2
+    in_1 = 0.001
+    sample_data = sample_data.adjust_phase(in_0, in_1)
+
+    out_0, out_1 = suspect.processing.phase.acme(sample_data, range_hz=(-1000, 75))
+
+    np.testing.assert_allclose(in_0, -out_0, rtol=0.05)
+    np.testing.assert_allclose(in_1, -out_1, rtol=0.05)