Repository: gammapy/gammapy

Issue:
Improve 3FGL lightcurve plot
I'm merging #853 now because I don't have more time to work in this at the moment. This is a reminder issue that a few things should be improved:

- [ ] `LightCurve.plot` should handle upper limits
- [ ] `LightCurve.plot` should optionally be able plot a MPL time axis (at the moment uses MJD)
- [ ] `SourceCatalog3FGLSource.lightcurve` should fill upper limit in the appropriate format (might already be the case, add test)
- [ ] Add `lightcurve` property for other Fermi catalogs that have a lightcurve

There was a working example at https://github.com/gammapy/gammapy/pull/853/files#diff-96fedf1476d1576620b1fce4f88c34e6L113 and example plot output [here](http://nbviewer.jupyter.org/gist/cdeil/63161b7b877bc8040178088a8c05846b).

Help welcome!

This is what it looks like at the moment:
```
    from gammapy.catalog import SourceCatalog3FGL
    source = SourceCatalog3FGL()['3FGL J0349.9-2102']
    lc = source.lightcurve
    lc.table.info()
    import matplotlib.pyplot as plt
    lc.plot()
    plt.show()
```
![screen shot 2017-12-04 at 17 46 05](https://user-images.githubusercontent.com/852409/33564546-0be274c4-d91b-11e7-951a-5a708cdb1ade.png)



Discussion:

Resolving patch:
diff --git a/gammapy/time/lightcurve.py b/gammapy/time/lightcurve.py
--- a/gammapy/time/lightcurve.py
+++ b/gammapy/time/lightcurve.py
@@ -188,62 +188,167 @@ def compute_chisq(self):
         chi2, pval = stats.chisquare(yobs, yexp)
         return chi2, pval
 
-    def plot(self, ax=None):
-        """Plot flux versus time.
+    def plot(self, ax=None, time_format='mjd', flux_unit='cm-2 s-1', **kwargs):
+        """Plot flux points.
 
         Parameters
         ----------
-        ax : `~matplotlib.axes.Axes` or None, optional.
+        ax : `~matplotlib.axes.Axes`, optional.
             The `~matplotlib.axes.Axes` object to be drawn on.
             If None, uses the current `~matplotlib.axes.Axes`.
+        time_format : {'mjd', 'iso'}, optional
+            If 'iso', the x axis will contain Matplotlib dates.
+            For formatting these dates see: https://matplotlib.org/gallery/ticks_and_spines/date_demo_rrule.html
+        flux_unit : str, `~astropy.units.Unit`, optional
+            Unit of the flux axis
+        kwargs : dict
+            Keyword arguments passed to :func:`matplotlib.pyplot.errorbar`
 
         Returns
         -------
-        ax : `~matplotlib.axes.Axes` or None, optional.
-            The `~matplotlib.axes.Axes` object to be drawn on.
-            If None, uses the current `~matplotlib.axes.Axes`.
+        ax : `~matplotlib.axes.Axes`
+            Axis object
         """
         import matplotlib.pyplot as plt
-        ax = plt.gca() if ax is None else ax
+        from matplotlib.dates import DateFormatter
+
+        if ax is None:
+            ax = plt.gca()
+
+        y, yerr = self._get_fluxes_and_errors(flux_unit)
+        is_ul, yul = self._get_flux_uls(flux_unit)
+        x, xerr = self._get_times_and_errors(time_format)
+
+        # length of the ul arrow
+        ul_arr = (np.nanmax(np.concatenate((y[~is_ul], yul[is_ul]))) -
+                  np.nanmin(np.concatenate((y[~is_ul], yul[is_ul])))) * 0.1
 
-        # TODO: Should we plot with normal time axis labels (ISO, not MJD)?
+        # join fluxes and upper limits for the plot
+        y[is_ul] = yul[is_ul]
+        yerr[0][is_ul] = ul_arr
 
-        x, xerr = self._get_plot_x()
-        y, yerr = self._get_plot_y()
+        # set plotting defaults and plot
+        kwargs.setdefault('marker', '+')
+        kwargs.setdefault('ls', 'None')
 
-        ax.errorbar(x=x, y=y, xerr=xerr, yerr=yerr, linestyle="None")
-        ax.scatter(x=x, y=y)
-        ax.set_xlabel("Time (MJD)")
-        ax.set_ylabel("Flux (cm-2 s-1)")
+        ax.errorbar(x=x, y=y, xerr=xerr, yerr=yerr, uplims=is_ul, **kwargs)
+        ax.set_xlabel('Time ({})'.format(time_format.upper()))
+        ax.set_ylabel('Flux ({0:FITS})'.format(u.Unit(flux_unit)))
+        if time_format == 'iso':
+            ax.xaxis.set_major_formatter(DateFormatter('%Y-%m-%d %H:%M:%S'))
+            plt.setp(ax.xaxis.get_majorticklabels(), rotation=30, ha="right",
+                     rotation_mode="anchor")
 
         return ax
 
-    def _get_plot_x(self):
-        try:
-            x = self.time.mjd
-        except KeyError:
-            x = self.time_mid.mjd
+    def _get_fluxes_and_errors(self, unit='cm-2 s-1'):
+        """Extract fluxes and corresponding errors
+
+        Helper function for the plot method.
+
+        Parameters
+        ----------
+        unit : str, `~astropy.units.Unit`, optional
+            Unit of the returned flux and errors values
+
+        Returns
+        -------
+        y : `~numpy.ndarray` of `float`
+            Flux values
+        (yn, yp) : (`~numpy.ndarray` of `float`, `~numpy.ndarray` of `float`)
+            Tuple of flux error values
+        """
+        y = self.table['flux'].quantity.to(unit)
+
+        if all(k in self.table.colnames for k in ['flux_errp', 'flux_errn']):
+            yp = self.table['flux_errp'].quantity.to(unit)
+            yn = self.table['flux_errn'].quantity.to(unit)
+        elif 'flux_err' in self.table.colnames:
+            yp = self.table['flux_err'].quantity.to(unit)
+            yn = self.table['flux_err'].quantity.to(unit)
+        else:
+            yp, yn = np.zeros_like(y), np.zeros_like(y)
+
+        return y.value, (yn.value, yp.value)
+
+    def _get_flux_uls(self, unit='cm-2 s-1'):
+        """Extract flux upper limits
+
+        Helper function for the plot method.
 
+        Parameters
+        ----------
+        unit : str, `~astropy.units.Unit`, optional
+            Unit of the returned flux upper limit values
+
+        Returns
+        -------
+        is_ul : `~numpy.ndarray` of `bool`
+            True if the corresponding flux point is an upper limit
+        yul : `~numpy.ndarray` of `float`
+            Flux upper limit values
+        """
         try:
-            xerr = x - self.time_min.mjd, self.time_max.mjd - x
+            is_ul = self.table['is_ul'].data.astype('bool')
         except KeyError:
-            xerr = None
+            is_ul = np.zeros_like(self.table['flux']).data.astype('bool')
 
-        return x, xerr
+        if is_ul.any():
+            yul = self.table['flux_ul'].quantity.to(unit)
+        else:
+            yul = np.zeros_like(self.table['flux']).quantity
+            yul[:] = np.nan
 
-    def _get_plot_y(self):
-        y = self.table['flux'].quantity.to('cm-2 s-1').value
+        return is_ul, yul.value
 
-        if 'flux_errp' in self.table.colnames:
-            yp = self.table['flux_errp'].quantity.to('cm-2 s-1').value
-            yn = self.table['flux_errn'].quantity.to('cm-2 s-1').value
-            yerr = yn, yp
-        elif 'flux_err' in self.table.colnames:
-            yerr = self.table['flux_err'].quantity.to('cm-2 s-1').value
+    def _get_times_and_errors(self, time_format='mjd'):
+        """Extract times and corresponding errors
+
+        Helper function for the plot method.
+
+        Parameters
+        ----------
+        time_format : {'mjd', 'iso'}, optional
+            Time format of the times. If 'iso', times and errors will be
+            returned as `~datetime.datetime` and `~datetime.timedelta` objects
+
+        Returns
+        -------
+        x : `~numpy.ndarray` of `float` or of `~datetime.datetime`
+            Time values or `~datetime.datetime` instances if 'iso' is chosen
+            as time format
+        (xn, xp) : (`~numpy.ndarray` of `float` or of `~datetime.timedelta`,
+                    `~numpy.ndarray` of `float` or of `~datetime.timedelta`)
+            Tuple of time error values or `~datetime.timedelta` instances if
+            'iso' is chosen as time format
+        """
+        from matplotlib.dates import num2timedelta
+
+        try:
+            x = self.time
+        except KeyError:
+            x = self.time_mid
+
+        try:
+            xn, xp = x - self.time_min, self.time_max - x
+        except KeyError:
+            xn, xp = x - x, x - x
+
+        # convert to given time format
+        if time_format == 'iso':
+            x = x.datetime
+            # TODO: In astropy version >3.1 the TimeDelta.to_datetime() method
+            # should start working, for now i will use num2timedelta.
+            xn, xp = np.asarray(num2timedelta(xn.to('d').value)), \
+                np.asarray(num2timedelta(xp.to('d').value))
+        elif time_format == 'mjd':
+            x = x.mjd
+            xn, xp = xn.to('d').value, xp.to('d').value
         else:
-            yerr = None
+            raise ValueError(
+                "The time format '{}' is not supported.".format(time_format))
 
-        return y, yerr
+        return x, (xn, xp)
 
 
 class LightCurveEstimator(object):
@@ -405,7 +510,7 @@ def in_list(item, L):
         for x in in_list('end', time_holder[istart:i + 1]):
             if tmp == 0:
                 alpha += (1 - obs_properties['deadtime'][n]) * (
-                        float(time_holder[x][0]) - (float(time_holder[xm1][0]) + float(time_holder[xm1 - 1][0])) / 2) * \
+                         float(time_holder[x][0]) - (float(time_holder[xm1][0]) + float(time_holder[xm1 - 1][0])) / 2) * \
                          obs_properties['A_off'][n + tmp]
                 time += (1 - obs_properties['deadtime'][n]) * (
                         float(time_holder[x][0]) - (float(time_holder[xm1][0]) + float(time_holder[xm1 - 1][0])) / 2)


Test patch:
diff --git a/gammapy/time/tests/test_lightcurve.py b/gammapy/time/tests/test_lightcurve.py
--- a/gammapy/time/tests/test_lightcurve.py
+++ b/gammapy/time/tests/test_lightcurve.py
@@ -3,6 +3,7 @@
 import pytest
 import numpy as np
 from numpy.testing import assert_allclose
+from datetime import datetime, timedelta
 from astropy.coordinates import SkyCoord, Angle
 from astropy.time import Time
 import astropy.units as u
@@ -42,6 +43,7 @@ def lc():
         Column([1e-11, 3e-11], 'flux', unit='cm-2 s-1'),
         Column([0.1e-11, 0.3e-11], 'flux_err', unit='cm-2 s-1'),
         Column([np.nan, 3.6e-11], 'flux_ul', unit='cm-2 s-1'),
+        Column([False, True], 'is_ul'),
     ])
 
     return LightCurve(table=table)
@@ -116,6 +118,35 @@ def test_lightcurve_plot(lc):
     mpl_savefig_check()
 
 
+@pytest.mark.parametrize('flux_unit', ['cm-2 s-1'])
+def test_lightcurve_plot_flux(lc, flux_unit):
+    f, ferr = lc._get_fluxes_and_errors(flux_unit)
+    assert_allclose(f, [1e-11, 3e-11])
+    assert_allclose(ferr, ([0.1e-11, 0.3e-11], [0.1e-11, 0.3e-11]))
+
+
+@pytest.mark.parametrize('flux_unit', ['cm-2 s-1'])
+def test_lightcurve_plot_flux_ul(lc, flux_unit):
+    is_ul, ful = lc._get_flux_uls(flux_unit)
+    assert_allclose(is_ul, [False, True])
+    assert_allclose(ful, [np.nan, 3.6e-11])
+
+
+@pytest.mark.parametrize('time_format, output', [
+    ('mjd', (np.array([55198., 55201]), (np.array([1., 2.]), np.array([1., 5.])))),
+    ('iso', (np.array([datetime(2010, 1, 2), datetime(2010, 1, 5)]),
+             (np.array([timedelta(1), timedelta(2)]),
+              np.array([timedelta(1), timedelta(5)])))),
+    ('unsupported', ValueError)])
+def test_lightcurve_plot_time(lc, time_format, output):
+    try:
+        t, terr = lc._get_times_and_errors(time_format)
+    except output:
+        return
+    assert np.array_equal(t, output[0])
+    assert np.array_equal(terr, output[1])
+
+
 # TODO: Reuse fixtures from spectrum tests
 @pytest.fixture(scope='session')
 def spec_extraction():