Repository: gammapy/gammapy

Issue:
2D Sersic implementation and custom models
As discussed at the previous developer meeting, people that used to work with 2D Sersic profile in Sherpa would like to have this spatial model in gammapy.
I do have a working solution for this using astropy models and a custom gammapy model. However in this implementation the normalization over the sky is computed over a grid which is not standard for gammapy models (I use this because it works for any complex composite models for which the normalisation is not obvious).

I don't think it's a priority to have all requested models in gammapy, but the custom models class may help to simplify the definition of custom models (compatible with serialization).

Here is the code that was working on v0.16,:

```python
class CustomSpatialModel(SpatialModel):
    r"""Generate spatial model from custom function

    Parameters
    ----------
    lon_0 : `~astropy.coordinates.Longitude`
        :math:`\text{lon}_0`
    lat_0 : `~astropy.coordinates.Latitude`
        :math:`\text{lat}_0`
    """
    tag="CustomSpatialModel"
    def __init__(self, fct, args, frame="galactic", normalize=True, reval=5.0, dr=0.02):
        for arg in args:
            name = str(arg)
            if name == "lon_0":
                lon_0 = Longitude(args[name]).wrap_at("180d")
                setattr(self, name, Parameter(name, lon_0, min=-180, max=180))
            elif name == "lat_0":
                lat_0 = Latitude(args[name])
                setattr(self, name, Parameter(name, lat_0, min=-90, max=90))
            else:
                setattr(self, name, Parameter(name=name, factor=args[name]))
        self.fct = fct
        self.normalize = normalize
        self.reval = reval
        self.dr = dr
        self.frame = frame
        super()._init_from_parameters([getattr(self, str(arg)) for arg in args])
        
    def to_dict(self):
        return {
                "type":self.tag,
                "function": self.fct.__name__,
                "options":{"normalize": bool(self.normalize),"reval": float(self.reval),"dr": float(self.dr)},
                "parameters": self.parameters.to_dict()["parameters"]
                }

    @classmethod
    def from_dict(cls, data):
        fct=globals().get(data["function"])
        normalize = data["options"].get("normalize", True)
        reval = data["options"].get("reval", 5.0)
        dr = data["options"].get("dr", 0.02)
        args = {x["name"].split("@")[0]: x["value"] * u.Unit(x["unit"]) for x in data["parameters"]}
        init = cls(fct, args, normalize=normalize, reval=reval, dr=dr)
        init.parameters.update_from_dict(data)
        for parameter in init.parameters:
            setattr(init, parameter.name, parameter)
        return init

    def _compute_norm(self, **kwargs):
        theta = np.arange(-self.reval, self.reval, self.dr)
        lon_grid, lat_grid = np.meshgrid(theta, theta)
        grid = self.fct(lon_grid*u.deg, lat_grid*u.deg, **kwargs)
        return (180.0 / np.pi / self.dr) ** 2 / grid.sum()

    @property
    def evaluation_radius(self):
        r"""Evaluation radius (`~astropy.coordinates.Angle`).

        Set as :math:`5\sigma`.
        """
        return Angle(self.reval * u.deg)

    def evaluate(self, lon, lat, **kwargs):
        """Evaluate the model"""
        grid = self.fct(lon, lat, **kwargs)
        if self.normalize == True:
            grid *= self._compute_norm(**kwargs)
            return u.Quantity(grid.value, u.Unit("sr-1"), copy=False)
        else:
            return  grid

def SersicSpatialModel(lon, lat, lon_0, lat_0, r_eff,n, ellip, theta):
    from astropy.modeling.functional_models import Sersic2D
    from astropy.units.quantity import Quantity
    amplitude=1.
    if lon_0>180*u.deg:
        lon_0-=360*u.deg
    if lon_0<-180*u.deg:
        lon_0+=360*u.deg
    lon[lon>180*u.deg]-=360*u.deg
    lon[lon<-180*u.deg]+=360*u.deg
    if isinstance(n,Quantity):
        n=n.value
    return Sersic2D.evaluate(lon,lat,amplitude,r_eff,n,lon_0,lat_0,ellip,theta)

from gammapy.modeling.models import SPATIAL_MODELS
SPATIAL_MODELS.append(CustomSpatialModel)

params= {"function":SersicSpatialModel.__name__,
         "options":
         {"normalize":True,
         "reval":5.*r0,
         "dr":0.04},
         "parameters":[
         {'name':"lon_0",'value':l0,'min':l0-r0,'max':l0+r0,"unit":u.deg},
         {'name':"lat_0",'value':b0,'min':b0-r0,'max':b0+r0,"unit":u.deg},
         {'name':"r_eff",'value':r0+0.01,'min':0.01,'max':2*r0,"unit":u.deg},
         {'name':"n",'value':0.5,'min':0.01,'max':10.,"unit":"","frozen":False},
         {'name':"ellip",'value':0.,'min':0.,'max':1.,"unit":"","frozen":True},
         {'name':"theta",'value':0.,'min':0, "max":np.pi,"unit":u.rad,"frozen":True}]}
spatial_model=CustomSpatialModel.from_dict(params)
```

Similarly for spectral models ones can use something like that (this was compatible with an earlier version before the SmoothBrokenPowerLaw was added to gammapy):
```python
class CustomSpectralModel(SpectralModel):
    r"""Generate spectral model from custom function

    Parameters
    ----------
    """
    tag="CustomSpectralModel"
    def __init__(self, fct, args):
        for arg in args:
            name = str(arg)
            setattr(self, name, Parameter(name=name, factor=args[name]))
        self.fct = fct
        super().__init__([getattr(self, str(arg)) for arg in args])

    def to_dict(self):
        return {
                "type": self.tag,
                "function": self.fct.__name__,
                "parameters": self.parameters.to_dict()["parameters"]
                }
            
    @classmethod
    def from_dict(cls,data):
        fct=globals().get(data["function"])
        args = {x["name"].split("@")[0]: x["value"] * u.Unit(x["unit"]) for x in data["parameters"]}
        init = cls(fct, args)
        init.parameters = Parameters.from_dict(data)
        for parameter in init.parameters.parameters:
            setattr(init, parameter.name, parameter)
        return init

    def evaluate(self, energy, **kwargs):
        """Evaluate the model"""
        return self.fct(energy,**kwargs)

from gammapy.modeling.models import SPECTRAL_MODELS
SPECTRAL_MODELS.append(CustomSpectralModel)

def SBPL(energy, amplitude, E_break, alpha_1, alpha_2, delta=0.1):
    from astropy.modeling.powerlaws import SmoothlyBrokenPowerLaw1D as fct
    return fct.evaluate(energy, amplitude, E_break, alpha_1, alpha_2, delta)

params= {"function":SBPL.__name__,
         "parameters":
        [{'name':"amplitude",'value':amp,'min':0,"unit":u.Unit("cm-2 s-1 TeV-1")},
         {'name':"E_break",'value':1.,'min':0.,"unit":u.TeV},
         {'name':"alpha_1",'value':2.,"unit":""},
         {'name':"alpha_2",'value':2.,"unit":""}]}
spectral_model = CustomSpectralModel.from_dict(params)
```


Discussion:
Thanks @QRemy for this proof of concept of a custom model implementation in Gammapy.

In the past we have tried to keep the implementation for new models as minimal as possible, to make it easy for users to implement new models themselves. I think we mostly succeeded with this, that's why I honestly think there is not much gain in providing a `CustomSpatialModel` and `CustomSpectralModel` model wrapper for users. I would much prefer if users use our implementation scheme (as shown in https://docs.gammapy.org/0.17/notebooks/models.html#Implementing-a-Custom-Model)  and have the full flexibility of default values for parameters, min and max values, evaluation radius etc. and especially provide a docstring for the model as well. The overhead compared to the function only definition of the model is in the explicit declaration of the parameters, which I think is actually a good thing. In the `.evaluate()` function users can also call into any Astropy model themselves.

The automatic assignment of the model function  during the serialisation using `globals()` is certainly problematic, it will only work if the `CustomModel` and model function live in the same namespace, which is basically never the case. The model function will live in a user defined namespace, while the `CustomModel` is only useful if it is defined in Gammapy. I don't see a way to make this work at all. That's also why we introduce our model registry in first place (which is the technical and maintenance-wise superior solution).

On the other hand we can be very open to implement new models in Gammapy, either by request of users or directly by user contributed models. I think there is no issue in having lots of models just declared in Gammapy. So adding a `SersicSpatialModel` and calling into astropy models for the evaluation is just fine. I think long-term the plan is to base Gammapy models on Astropy models again anyway.


@QRemy Will you add the sersic spatial model to `gammapy.modeling.models` might be useful to have...  

Resolving patch:
diff --git a/examples/models/spatial/plot_gen_gauss.py b/examples/models/spatial/plot_gen_gauss.py
new file mode 100644
--- /dev/null
+++ b/examples/models/spatial/plot_gen_gauss.py
@@ -0,0 +1,82 @@
+r"""
+.. _generalized-gaussian-spatial-model:
+
+Generalized Gaussian Spatial Model
+==================================
+
+This is a spatial model parametrising a generalized Gaussian function.
+
+By default, the Generalized Gaussian is defined as :
+
+.. math::
+    \phi(\text{lon}, \text{lat})  = \phi(\text{r}) = N \times \exp \left[ - \left( \frac{r}{r_{\rm eff}} \right)^ \left( 1/\eta \right) \right] \,,
+
+the normalization is expressed as:
+
+.. math::
+    N = \frac{1}{ 2 \pi (1-e) r_{\rm eff}^2 \eta \Gamma(2\eta)}\,,
+where :math:`\Gamma` is the gamma function.
+This analytical norm is approximated so it may not integrate to unity in extremal cases
+if ellipticity tend to one and radius is large or :math:`\eta` much larger than one (outside the default range).
+"""
+
+# %%
+# Example plot
+# ------------
+# Here is an example plot of the model for different shape parameter:
+
+from gammapy.maps import WcsGeom, Map
+from gammapy.modeling.models import (
+    GeneralizedGaussianSpatialModel,
+    Models,
+    PowerLawSpectralModel,
+    SkyModel,
+)
+from astropy import units as u
+import matplotlib.pyplot as plt
+
+lon_0 = 20
+lat_0 = 0
+reval = 3
+dr = 0.02
+geom = WcsGeom.create(
+    skydir=(lon_0, lat_0), binsz=dr, width=(2 * reval, 2 * reval), frame="galactic",
+)
+
+tags = [r"Disk, $\eta=0.01$", r"Gaussian, $\eta=0.5$", r"Laplacian, $\eta=1$"]
+eta_range = [0.01, 0.5, 1]
+r_0 = 1
+e = 0.5
+phi = 45 * u.deg
+fig, axes = plt.subplots(1, 3, figsize=(9, 6))
+for ax, eta, tag in zip(axes, eta_range, tags):
+    model = GeneralizedGaussianSpatialModel(
+        lon_0=lon_0 * u.deg,
+        lat_0=lat_0 * u.deg,
+        eta=eta,
+        r_0=r_0 * u.deg,
+        e=e,
+        phi=phi,
+        frame="galactic",
+    )
+    meval = model.evaluate_geom(geom)
+    Map.from_geom(geom=geom, data=meval.value, unit=meval.unit).plot(ax=ax)
+    pixreg = model.to_region().to_pixel(geom.wcs)
+    pixreg.plot(ax=ax, edgecolor="g", facecolor="none", lw=2)
+    ax.set_title(tag)
+    ax.set_xticks([])
+    ax.set_yticks([])
+plt.tight_layout()
+
+# %%
+# YAML representation
+# -------------------
+# Here is an example YAML file using the model:
+
+pwl = PowerLawSpectralModel()
+gengauss = GeneralizedGaussianSpatialModel()
+
+model = SkyModel(spectral_model=pwl, spatial_model=gengauss, name="pwl-gengauss-model")
+models = Models([model])
+
+print(models.to_yaml())
diff --git a/gammapy/modeling/models/__init__.py b/gammapy/modeling/models/__init__.py
--- a/gammapy/modeling/models/__init__.py
+++ b/gammapy/modeling/models/__init__.py
@@ -15,6 +15,7 @@
         TemplateSpatialModel,
         DiskSpatialModel,
         GaussianSpatialModel,
+        GeneralizedGaussianSpatialModel,
         PointSpatialModel,
         ShellSpatialModel,
     ]
diff --git a/gammapy/modeling/models/spatial.py b/gammapy/modeling/models/spatial.py
--- a/gammapy/modeling/models/spatial.py
+++ b/gammapy/modeling/models/spatial.py
@@ -371,6 +371,66 @@ def to_region(self, **kwargs):
         )
 
 
+class GeneralizedGaussianSpatialModel(SpatialModel):
+    r"""Two-dimensional Generealized Gaussian model.
+
+    For more information see :ref:`generalized-gaussian-spatial-model`.
+
+    Parameters
+    ----------
+    lon_0, lat_0 : `~astropy.coordinates.Angle`
+        Center position
+    r_0 : `~astropy.coordinates.Angle`
+        Length of the major semiaxis, in angular units.
+    eta : `float`
+        Shape parameter whitin (0, 1]. Special cases for disk: ->0, Gaussian: 0.5, Laplacian:1
+    e : `float`
+        Eccentricity (:math:`0< e< 1`).
+    phi : `~astropy.coordinates.Angle`
+        Rotation angle :math:`\phi`: of the major semiaxis.
+        Increases counter-clockwise from the North direction.
+    frame : {"icrs", "galactic"}
+        Center position coordinate frame
+    """
+
+    tag = ["GeneralizedGaussianSpatialModel", "gen-gauss"]
+    lon_0 = Parameter("lon_0", "0 deg")
+    lat_0 = Parameter("lat_0", "0 deg", min=-90, max=90)
+    r_0 = Parameter("r_0", "1 deg")
+    eta = Parameter("eta", 0.5, min=0.01, max=1.0)
+    e = Parameter("e", 0.0, min=0.0, max=1.0, frozen=True)
+    phi = Parameter("phi", "0 deg", frozen=True)
+
+    @staticmethod
+    def evaluate(lon, lat, lon_0, lat_0, r_0, eta, e, phi):
+        sep = angular_separation(lon, lat, lon_0, lat_0)
+        if isinstance(eta, u.Quantity):
+            eta = eta.value  # gamma function does not allow quantities
+        minor_axis, r_eff = compute_sigma_eff(lon_0, lat_0, lon, lat, phi, r_0, e)
+        z = sep / r_eff
+        norm = 1 / (2 * np.pi * minor_axis * r_0 * eta * scipy.special.gamma(2 * eta))
+        return (norm * np.exp(-(z ** (1 / eta)))).to("sr-1")
+
+    @property
+    def evaluation_radius(self):
+        r"""Evaluation radius (`~astropy.coordinates.Angle`).
+
+        Set as :math:`5 r_{\rm eff}`.
+        """
+        return 5 * self.parameters["r_0"].quantity
+
+    def to_region(self, **kwargs):
+        """Model outline (`~regions.EllipseSkyRegion`)."""
+        minor_axis = Angle(self.r_0.quantity * (1 - self.e.quantity))
+        return EllipseSkyRegion(
+            center=self.position,
+            height=2 * self.r_0.quantity,
+            width=2 * minor_axis,
+            angle=self.phi.quantity,
+            **kwargs,
+        )
+
+
 class DiskSpatialModel(SpatialModel):
     r"""Constant disk model.
 


Test patch:
diff --git a/gammapy/modeling/models/tests/test_spatial.py b/gammapy/modeling/models/tests/test_spatial.py
--- a/gammapy/modeling/models/tests/test_spatial.py
+++ b/gammapy/modeling/models/tests/test_spatial.py
@@ -12,12 +12,14 @@
 )
 from gammapy.maps import Map, WcsGeom
 from gammapy.modeling.models import (
+    Models,
     ConstantSpatialModel,
     DiskSpatialModel,
     GaussianSpatialModel,
     PointSpatialModel,
     ShellSpatialModel,
     TemplateSpatialModel,
+    GeneralizedGaussianSpatialModel,
 )
 from gammapy.utils.testing import mpl_plot_check, requires_data, requires_dependency
 
@@ -103,6 +105,30 @@ def test_sky_gaussian():
     assert isinstance(model.to_region(), EllipseSkyRegion)
 
 
+@pytest.mark.parametrize("eta", np.arange(0.1, 1.01, 0.3))
+@pytest.mark.parametrize("r_0", np.arange(0.1, 1.01, 0.3))
+@pytest.mark.parametrize("e", np.arange(0.0, 0.801, 0.4))
+def test_generalized_gaussian(eta, r_0, e):
+    reval = 6
+    dr = 0.01
+    geom = WcsGeom.create(
+        skydir=(0, 0), binsz=dr, width=(2 * reval, 2 * reval), frame="galactic",
+    )
+    # check normalization is robut for a large set of values
+    model = GeneralizedGaussianSpatialModel(
+        eta=eta, r_0=r_0 * u.deg, e=e, frame="galactic"
+    )
+    eval_geom = model.evaluate_geom(geom)
+    integ_geom = model.integrate_geom(geom)
+    assert eval_geom.unit.is_equivalent("sr-1")
+    assert integ_geom.unit.is_equivalent("")
+    assert_allclose(integ_geom.data.sum(), 1.0, atol=3e-2)
+    assert isinstance(model.to_region(), EllipseSkyRegion)
+    new_model = GeneralizedGaussianSpatialModel.from_dict(model.to_dict())
+    assert isinstance(new_model, GeneralizedGaussianSpatialModel)
+    assert_allclose(new_model.integrate_geom(geom).data.sum(), integ_geom.data.sum())
+
+
 def test_sky_disk():
     # Test the disk case (e=0)
     r_0 = 2 * u.deg