Repository: gammapy/gammapy

Issue:
Coordinate transformation in background estimation uses mid-of-run time
**Gammapy version**

Current master

**Bug description**

https://github.com/gammapy/gammapy/blob/d6c5f847d0fea8b84fdf467d6961b2e4798d32c8/gammapy/makers/utils.py#L140-L144

Most important for longer observations and `FOVALIGN=ALTAZ`.



Discussion:

Resolving patch:
diff --git a/gammapy/data/pointing.py b/gammapy/data/pointing.py
--- a/gammapy/data/pointing.py
+++ b/gammapy/data/pointing.py
@@ -3,7 +3,7 @@
 import numpy as np
 import scipy.interpolate
 import astropy.units as u
-from astropy.coordinates import AltAz, CartesianRepresentation, SkyCoord
+from astropy.coordinates import AltAz, CartesianRepresentation, SkyCoord, UnitSphericalRepresentation
 from astropy.table import Table
 from astropy.units import Quantity
 from astropy.utils import lazyproperty
@@ -11,10 +11,46 @@
 from gammapy.utils.scripts import make_path
 from gammapy.utils.time import time_ref_from_dict
 
+from enum import Enum, auto
+
 
 log = logging.getLogger(__name__)
 
-__all__ = ["FixedPointingInfo", "PointingInfo"]
+__all__ = ["FixedPointingInfo", "PointingInfo", "PointingMode"]
+
+
+class PointingMode(Enum):
+    """
+    Describes the behavior of the pointing during the observation.
+
+    See :ref:`gadf:obs-mode`.
+
+    For ground-based instruments, the most common options will be:
+    * POINTING: The telescope observes a fixed position in the ICRS frame
+    * DRIFT: The telscope observes a fixed position in the AltAz frame
+
+    OGIP also defines RASTER, SLEW and SCAN. These cannot be treated using
+    a fixed pointing position in either frame, so they would require the 
+    pointing table, which is at the moment not supported by gammapy.
+
+    The H.E.S.S. data releases uses the not-defined value "WOBBLE",
+    which we assume to be the same as "POINTING", making the assumption
+    that one observation only contains a single wobble position.
+    """
+    POINTING = auto()
+    DRIFT = auto()
+
+    @staticmethod
+    def from_gadf_string(val):
+        # OBS_MODE is not well-defined in GADF 0.2, HESS and MAGIC
+        # filled some variation of "WOBBLE" for the open crab paper
+        if val.upper() in ("POINTING", "WOBBLE"):
+            return PointingMode.POINTING
+
+        if val.upper() == "DRIFT":
+            return PointingMode.DRIFT
+
+        raise ValueError(f"Unsupported pointing mode: {val}")
 
 
 class FixedPointingInfo:
@@ -85,6 +121,14 @@ def read(cls, filename, hdu="EVENTS"):
         table = Table.read(filename, hdu=hdu)
         return cls(meta=table.meta)
 
+    @lazyproperty
+    def mode(self):
+        """See `PointingMode`, if not present, assume POINTING"""
+        obs_mode = self.meta.get("OBS_MODE")
+        if obs_mode is None:
+            return PointingMode.POINTING
+        return PointingMode.from_gadf_string(obs_mode)
+
     @lazyproperty
     def location(self):
         """Observatory location (`~astropy.coordinates.EarthLocation`)."""
@@ -122,7 +166,12 @@ def duration(self):
 
     @lazyproperty
     def radec(self):
-        """RA/DEC pointing position from table (`~astropy.coordinates.SkyCoord`)."""
+        """
+        RA/DEC pointing position from table (`~astropy.coordinates.SkyCoord`).
+
+        Use `get_icrs` to get the pointing at a specific time, correctly
+        handling different pointing modes.
+        """
         ra = self.meta["RA_PNT"]
         dec = self.meta["DEC_PNT"]
         return SkyCoord(ra, dec, unit="deg", frame="icrs")
@@ -134,7 +183,13 @@ def altaz_frame(self):
 
     @lazyproperty
     def altaz(self):
-        """ALT/AZ pointing position computed from RA/DEC (`~astropy.coordinates.SkyCoord`)."""
+        """
+        ALT/AZ pointing position computed from RA/DEC (`~astropy.coordinates.SkyCoord`)
+        for the midpoint of the run.
+
+        Use `get_altaz` to get the pointing at a specific time, correctly
+        handling different pointing modes.
+        """
         try:
             frame = self.altaz_frame
         except KeyError:
@@ -151,6 +206,87 @@ def altaz(self):
 
         return self.radec.transform_to(frame)
 
+    @lazyproperty
+    def fixed_altaz(self):
+        """The fixed coordinates in AltAz of the observation.
+
+        None if not a DRIFT observation
+        """
+        if self.mode != PointingMode.DRIFT:
+            return None
+
+        alt = u.Quantity(self.meta["ALT_PNT"], u.deg)
+        az = u.Quantity(self.meta["AZ_PNT"], u.deg)
+        return SkyCoord(alt=alt, az=az, frame=self.altaz_frame)
+
+    @lazyproperty
+    def fixed_icrs(self):
+        """
+        The fixed coordinates in ICRS of the observation.
+
+        None if not a POINTING observation
+        """
+        if self.mode != PointingMode.POINTING:
+            return None
+
+        return self.radec
+
+    def get_icrs(self, obstime):
+        """
+        Get the pointing position in ICRS frame for a given time.
+
+        If the observation was performed tracking a fixed position in ICRS,
+        the icrs pointing is returned with the given obstime attached.
+
+        If the observation was perfomed in drift mode, the fixed altaz coordinates
+        are transformed to ICRS using the observation location and the given time.
+
+
+        Parameters
+        ----------
+        obstime: `astropy.time.Time`
+            Time for which to get the pointing position in ICRS frame
+        """
+        if self.mode == PointingMode.POINTING:
+            icrs = self.fixed_icrs
+            return SkyCoord(ra=icrs.ra, dec=icrs.dec, obstime=obstime, frame='icrs')
+
+        if self.mode == PointingMode.DRIFT:
+            return self.get_altaz(obstime).icrs
+
+        raise ValueError(f"Unsupported pointing mode: {self.mode}.")
+
+    def get_altaz(self, obstime):
+        """
+        Get the pointing position in AltAz frame for a given time.
+
+        If the observation was performed tracking a fixed position in ICRS,
+        the icrs pointing is transformed at the given time using the location
+        of the observation.
+
+        If the observation was performed in drift mode,
+        the fixed altaz coordinate is returned with `obstime` attached.
+
+        Parameters
+        ----------
+        obstime: `astropy.time.Time`
+            Time for which to get the pointing position in AltAz frame
+        """
+        frame = AltAz(location=self.location, obstime=obstime)
+
+        if self.mode == PointingMode.POINTING:
+            return self.fixed_icrs.transform_to(frame)
+
+        if self.mode == PointingMode.DRIFT:
+            # see https://github.com/astropy/astropy/issues/12965
+            return SkyCoord(
+                alt=np.full(obstime.shape, self.fixed_altaz.alt.deg) * u.deg,
+                az=np.full(obstime.shape, self.fixed_altaz.az.deg) * u.deg,
+                frame=frame,
+            )
+
+        raise ValueError(f"Unsupported pointing mode: {self.mode}.")
+
 
 class PointingInfo:
     """IACT array pointing info.
@@ -295,16 +431,61 @@ def altaz_from_table(self):
         lat = self.table["ALT_PNT"]
         return SkyCoord(lon, lat, unit="deg", frame=self.altaz_frame)
 
+    @staticmethod
+    def _interpolate_cartesian(mjd_support, coord_support, mjd):
+        xyz = coord_support.cartesian
+        x_new = scipy.interpolate.interp1d(mjd_support, xyz.x)(mjd)
+        y_new = scipy.interpolate.interp1d(mjd_support, xyz.y)(mjd)
+        z_new = scipy.interpolate.interp1d(mjd_support, xyz.z)(mjd)
+        return CartesianRepresentation(x_new, y_new, z_new).represent_as(UnitSphericalRepresentation)
+
     def altaz_interpolate(self, time):
         """Interpolate pointing for a given time."""
-        t_new = time.mjd
-        t = self.time.mjd
-        xyz = self.altaz.cartesian
-        x_new = scipy.interpolate.interp1d(t, xyz.x)(t_new)
-        y_new = scipy.interpolate.interp1d(t, xyz.y)(t_new)
-        z_new = scipy.interpolate.interp1d(t, xyz.z)(t_new)
-        xyz_new = CartesianRepresentation(x_new, y_new, z_new)
         altaz_frame = AltAz(obstime=time, location=self.location)
         return SkyCoord(
-            xyz_new, frame=altaz_frame, unit="deg", representation_type="unitspherical"
+            self._interpolate_cartesian(self.time.mjd, self.altaz, time.mjd),
+            frame=altaz_frame,
         )
+
+    def get_icrs(self, obstime):
+        """
+        Get the pointing position in ICRS frame for a given time.
+
+        Parameters
+        ----------
+        obstime: `astropy.time.Time`
+            Time for which to get the pointing position in ICRS frame
+        """
+        return SkyCoord(
+            self._interpolate_cartesian(self.time.mjd, self.radec, obstime.mjd),
+            obstime=obstime,
+            frame='icrs',
+        )
+
+    def get_altaz(self, obstime):
+        """
+        Get the pointing position in AltAz frame for a given time.
+
+        If the observation was performed tracking a fixed position in ICRS,
+        the icrs pointing is transformed at the given time using the location
+        of the observation.
+
+        If the observation was performed in drift mode,
+        the fixed altaz coordinate is returned with `obstime` attached.
+
+        Parameters
+        ----------
+        obstime: `astropy.time.Time`
+            Time for which to get the pointing position in AltAz frame
+        """
+        # give precedence to ALT_PNT / AZ_PNT if present
+        if 'ALT_PNT' in self.table and 'AZ_PNT' in self.table:
+            altaz = self.altaz_from_table
+            frame = AltAz(obstime=obstime, location=self.location)
+            return SkyCoord(
+                self._interpolate_cartesian(self.time.mjd, altaz, obstime.mjd),
+                frame=frame,
+            )
+
+        # fallback to transformation from required ICRS if not
+        return self.altaz_interpolate(time=obstime)


Test patch:
diff --git a/gammapy/data/tests/test_pointing.py b/gammapy/data/tests/test_pointing.py
--- a/gammapy/data/tests/test_pointing.py
+++ b/gammapy/data/tests/test_pointing.py
@@ -1,10 +1,19 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
 import logging
+
 import numpy as np
 from numpy.testing import assert_allclose
+
+import astropy.units as u
 from astropy.time import Time
+from astropy.coordinates import SkyCoord, AltAz, ICRS
+
 from gammapy.data import FixedPointingInfo, PointingInfo
+from gammapy.data.pointing import PointingMode
 from gammapy.utils.testing import assert_time_allclose, requires_data
+from gammapy.data.observers import observatory_locations
+from gammapy.utils.fits import earth_location_to_dict
+from gammapy.utils.time import time_ref_to_dict, time_relative_to_ref
 
 
 @requires_data()
@@ -128,3 +137,91 @@ def test_altaz_without_location(caplog):
         altaz = pointing.altaz
         assert np.isnan(altaz.alt.value)
         assert np.isnan(altaz.az.value)
+
+
+def test_fixed_pointing_info_fixed_icrs():
+    location = observatory_locations["cta_south"]
+    start = Time("2020-11-01T03:00:00")
+    stop = Time("2020-11-01T03:15:00")
+    ref = Time("2020-11-01T00:00:00")
+    pointing_icrs = SkyCoord(ra=83.28 * u.deg, dec=21.78 * u.deg)
+
+    meta = time_ref_to_dict(ref)
+    meta['TSTART'] = time_relative_to_ref(start, meta).to_value(u.s)
+    meta['TSTOP'] = time_relative_to_ref(stop, meta).to_value(u.s)
+    meta.update(earth_location_to_dict(location))
+    meta["RA_PNT"] = pointing_icrs.ra.deg
+    meta["DEC_PNT"] = pointing_icrs.dec.deg
+
+    pointing = FixedPointingInfo(meta=meta)
+
+    # not given, but assumed if missing
+    assert pointing.mode == PointingMode.POINTING
+    assert pointing.fixed_icrs == pointing_icrs
+    assert pointing.fixed_altaz is None
+
+    altaz = pointing.get_altaz(start)
+    assert altaz.obstime == start
+    assert isinstance(altaz.frame, AltAz)
+    assert np.all(u.isclose(pointing_icrs.ra, pointing.get_icrs(start).ra))
+
+    back_trafo = altaz.transform_to("icrs")
+    assert u.isclose(back_trafo.ra, pointing_icrs.ra)
+    assert u.isclose(back_trafo.dec, pointing_icrs.dec)
+
+    times = start + np.linspace(0, 1, 50) * (stop - start)
+    altaz = pointing.get_altaz(times)
+    assert len(altaz) == len(times)
+    assert np.all(altaz.obstime == times)
+    assert isinstance(altaz.frame, AltAz)
+
+    back_trafo = altaz.transform_to("icrs")
+    assert u.isclose(back_trafo.ra, pointing_icrs.ra).all()
+    assert u.isclose(back_trafo.dec, pointing_icrs.dec).all()
+    assert np.all(u.isclose(pointing_icrs.ra, pointing.get_icrs(times).ra))
+
+
+def test_fixed_pointing_info_fixed_altaz():
+    location = observatory_locations["cta_south"]
+    start = Time("2020-11-01T03:00:00")
+    stop = Time("2020-11-01T03:15:00")
+    ref = Time("2020-11-01T00:00:00")
+    pointing_icrs = SkyCoord(ra=83.28 * u.deg, dec=21.78 * u.deg)
+    pointing_altaz = pointing_icrs.transform_to(AltAz(obstime=start, location=location))
+
+    meta = time_ref_to_dict(ref)
+    meta['TSTART'] = time_relative_to_ref(start, meta).to_value(u.s)
+    meta['TSTOP'] = time_relative_to_ref(stop, meta).to_value(u.s)
+    meta.update(earth_location_to_dict(location))
+    meta["OBS_MODE"] = "DRIFT"
+    meta["ALT_PNT"] = pointing_altaz.alt.deg
+    meta["AZ_PNT"] = pointing_altaz.az.deg
+
+    pointing = FixedPointingInfo(meta=meta)
+
+    # not given, but assumed if missing
+    assert pointing.mode == PointingMode.DRIFT
+    assert pointing.fixed_icrs is None
+    assert u.isclose(pointing.fixed_altaz.alt, pointing_altaz.alt)
+    assert u.isclose(pointing.fixed_altaz.az, pointing_altaz.az)
+
+    icrs = pointing.get_icrs(start)
+    assert icrs.obstime == start
+    assert isinstance(icrs.frame, ICRS)
+
+    back_trafo = icrs.transform_to(pointing_altaz.frame)
+    assert u.isclose(back_trafo.alt, pointing_altaz.alt)
+    assert u.isclose(back_trafo.az, pointing_altaz.az)
+
+    times = start + np.linspace(0, 1, 50) * (stop - start)
+    icrs = pointing.get_icrs(times)
+    assert len(icrs) == len(times)
+    assert np.all(icrs.obstime == times)
+    assert isinstance(icrs.frame, ICRS)
+
+    back_trafo = icrs.transform_to(AltAz(location=location, obstime=times))
+    assert u.isclose(back_trafo.alt, pointing_altaz.alt).all()
+    assert u.isclose(back_trafo.az, pointing_altaz.az).all()
+
+    assert np.all(u.isclose(pointing_altaz.alt, pointing.get_altaz(times).alt))
+    assert np.all(u.isclose(pointing_altaz.az, pointing.get_altaz(times).az))