files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  """Fermi catalog and source classes."""
  import abc
  import warnings
  import numpy as np
  import astropy.units as u
  from astropy.table import Column, Table
  from astropy.time import Time
  from astropy.wcs import FITSFixedWarning
  from gammapy.estimators import FluxPoints, LightCurve
  from gammapy.modeling.models import (
      DiskSpatialModel,
      GaussianSpatialModel,
      Model,
      PointSpatialModel,
      SkyModel,
      TemplateSpatialModel,
  )
  from gammapy.utils.gauss import Gauss2DPDF
  from gammapy.utils.scripts import make_path
  from gammapy.utils.table import table_standardise_units_inplace
  from .core import SourceCatalog, SourceCatalogObject
  
  __all__ = [
      "SourceCatalogObject4FGL",
      "SourceCatalogObject3FGL",
      "SourceCatalogObject2FHL",
      "SourceCatalogObject3FHL",
      "SourceCatalog4FGL",
      "SourceCatalog3FGL",
      "SourceCatalog2FHL",
      "SourceCatalog3FHL",
  ]
  
  
  def compute_flux_points_ul(quantity, quantity_errp):
      """Compute UL value for fermi flux points.
  
      See https://arxiv.org/pdf/1501.02003.pdf (page 30)
      """
      return 2 * quantity_errp + quantity
  
  
  class SourceCatalogObjectFermiBase(SourceCatalogObject, abc.ABC):
      """Base class for Fermi-LAT catalogs."""
  
      asso = ["ASSOC1", "ASSOC2", "ASSOC_TEV", "ASSOC_GAM1", "ASSOC_GAM2", "ASSOC_GAM3"]
  
      def __str__(self):
          return self.info()
  
      def info(self, info="all"):
          """Summary info string.
  
          Parameters
          ----------
          info : {'all', 'basic', 'more', 'position', 'spectral','lightcurve'}
              Comma separated list of options
          """
          if info == "all":
              info = "basic,more,position,spectral,lightcurve"
  
          ss = ""
          ops = info.split(",")
          if "basic" in ops:
              ss += self._info_basic()
          if "more" in ops:
              ss += self._info_more()
          if "position" in ops:
              ss += self._info_position()
              if not self.is_pointlike:
                  ss += self._info_morphology()
          if "spectral" in ops:
              ss += self._info_spectral_fit()
              ss += self._info_spectral_points()
          if "lightcurve" in ops:
              ss += self._info_lightcurve()
          return ss
  
      def _info_basic(self):
          d = self.data
          keys = self.asso
          ss = "\n*** Basic info ***\n\n"
          ss += "Catalog row index (zero-based) : {}\n".format(self.row_index)
          ss += "{:<20s} : {}\n".format("Source name", self.name)
          if "Extended_Source_Name" in d:
              ss += "{:<20s} : {}\n".format("Extended name", d["Extended_Source_Name"])
  
          def get_nonentry_keys(keys):
              vals = [d[_].strip() for _ in keys]
              return ", ".join([_ for _ in vals if _ != ""])
  
          associations = get_nonentry_keys(keys)
          ss += "{:<16s} : {}\n".format("Associations", associations)
          try:
              ss += "{:<16s} : {:.3f}\n".format("ASSOC_PROB_BAY", d["ASSOC_PROB_BAY"])
              ss += "{:<16s} : {:.3f}\n".format("ASSOC_PROB_LR", d["ASSOC_PROB_LR"])
          except (KeyError):
              pass
          try:
              ss += "{:<16s} : {}\n".format("Class1", d["CLASS1"])
          except (KeyError):
              ss += "{:<16s} : {}\n".format("Class", d["CLASS"])
          try:
              ss += "{:<16s} : {}\n".format("Class2", d["CLASS2"])
          except (KeyError):
              pass
          ss += "{:<16s} : {}\n".format("TeVCat flag", d.get("TEVCAT_FLAG", "N/A"))
          return ss
  
      @abc.abstractmethod
      def _info_more(self):
          pass
  
      def _info_position(self):
          d = self.data
          ss = "\n*** Position info ***\n\n"
          ss += "{:<20s} : {:.3f}\n".format("RA", d["RAJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("DEC", d["DEJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("GLON", d["GLON"])
          ss += "{:<20s} : {:.3f}\n".format("GLAT", d["GLAT"])
  
          ss += "\n"
          ss += "{:<20s} : {:.4f}\n".format("Semimajor (68%)", d["Conf_68_SemiMajor"])
          ss += "{:<20s} : {:.4f}\n".format("Semiminor (68%)", d["Conf_68_SemiMinor"])
          ss += "{:<20s} : {:.2f}\n".format("Position angle (68%)", d["Conf_68_PosAng"])
          ss += "{:<20s} : {:.4f}\n".format("Semimajor (95%)", d["Conf_95_SemiMajor"])
          ss += "{:<20s} : {:.4f}\n".format("Semiminor (95%)", d["Conf_95_SemiMinor"])
          ss += "{:<20s} : {:.2f}\n".format("Position angle (95%)", d["Conf_95_PosAng"])
          ss += "{:<20s} : {:.0f}\n".format("ROI number", d["ROI_num"])
          return ss
  
      def _info_morphology(self):
          e = self.data_extended
          ss = "\n*** Extended source information ***\n\n"
          ss += "{:<16s} : {}\n".format("Model form", e["Model_Form"])
          ss += "{:<16s} : {:.4f}\n".format("Model semimajor", e["Model_SemiMajor"])
          ss += "{:<16s} : {:.4f}\n".format("Model semiminor", e["Model_SemiMinor"])
          ss += "{:<16s} : {:.4f}\n".format("Position angle", e["Model_PosAng"])
          try:
              ss += "{:<16s} : {}\n".format("Spatial function", e["Spatial_Function"])
          except KeyError:
              pass
          ss += "{:<16s} : {}\n\n".format("Spatial filename", e["Spatial_Filename"])
          return ss
  
      def _info_spectral_fit(self):
          return "\n"
  
      def _info_spectral_points(self):
          ss = "\n*** Spectral points ***\n\n"
          lines = self.flux_points.table_formatted.pformat(max_width=-1, max_lines=-1)
          ss += "\n".join(lines)
          return ss
  
      def _info_lightcurve(self):
          return "\n"
  
      @property
      def is_pointlike(self):
          return self.data["Extended_Source_Name"].strip() == ""
  
      # FIXME: this should be renamed `set_position_error`,
      # and `phi_0` isn't filled correctly, other parameters missing
      # see https://github.com/gammapy/gammapy/pull/2533#issuecomment-553329049
      def _set_spatial_errors(self, model):
          d = self.data
  
          if "Pos_err_68" in d:
              percent = 0.68
              semi_minor = d["Pos_err_68"]
              semi_major = d["Pos_err_68"]
              phi_0 = 0.0
          else:
              percent = 0.95
              semi_minor = d["Conf_95_SemiMinor"]
              semi_major = d["Conf_95_SemiMajor"]
              phi_0 = d["Conf_95_PosAng"]
  
          if np.isnan(phi_0):
              phi_0 = 0.0 * u.deg
  
          scale_1sigma = Gauss2DPDF().containment_radius(percent)
          lat_err = semi_major / scale_1sigma
          lon_err = semi_minor / scale_1sigma / np.cos(d["DEJ2000"])
  
          if  "TemplateSpatialModel" not in model.tag:
              model.parameters["lon_0"].error = lon_err
              model.parameters["lat_0"].error = lat_err
              model.phi_0 = phi_0
  
      def sky_model(self):
          """Sky model (`~gammapy.modeling.models.SkyModel`)."""
          return SkyModel(
              spatial_model=self.spatial_model(),
              spectral_model=self.spectral_model(),
              name=self.name,
          )
  
  
  class SourceCatalogObject4FGL(SourceCatalogObjectFermiBase):
      """One source from the Fermi-LAT 4FGL catalog.
  
      Catalog is represented by `~gammapy.catalog.SourceCatalog4FGL`.
      """
  
      asso = [
          "ASSOC1",
          "ASSOC2",
          "ASSOC_TEV",
          "ASSOC_FGL",
          "ASSOC_FHL",
          "ASSOC_GAM1",
          "ASSOC_GAM2",
          "ASSOC_GAM3",
      ]
      _ebounds = u.Quantity([50, 100, 300, 1000, 3000, 10000, 30000, 300000], "MeV")
  
      def _info_more(self):
          d = self.data
          ss = "\n*** Other info ***\n\n"
          fmt = "{:<32s} : {:.3f}\n"
          ss += fmt.format("Significance (100 MeV - 1 TeV)", d["Signif_Avg"])
          ss += "{:<32s} : {:.1f}\n".format("Npred", d["Npred"])
          ss += "\n{:<20s} : {}\n".format("Other flags", d["Flags"])
          return ss
  
      def _info_spectral_fit(self):
          d = self.data
          spec_type = d["SpectrumType"].strip()
  
          ss = "\n*** Spectral info ***\n\n"
  
          ss += "{:<45s} : {}\n".format("Spectrum type", d["SpectrumType"])
          fmt = "{:<45s} : {:.3f}\n"
          ss += fmt.format("Detection significance (100 MeV - 1 TeV)", d["Signif_Avg"])
  
          if spec_type == "PowerLaw":
              tag = "PL"
          elif spec_type == "LogParabola":
              tag = "LP"
              ss += "{:<45s} : {:.4f} +- {:.5f}\n".format(
                  "beta", d["LP_beta"], d["Unc_LP_beta"]
              )
              ss += "{:<45s} : {:.1f}\n".format("Significance curvature", d["LP_SigCurv"])
  
          elif spec_type == "PLSuperExpCutoff":
              tag = "PLEC"
              fmt = "{:<45s} : {} +- {}\n"
              ss += fmt.format(
                  "Exponential factor", d["PLEC_Expfactor"], d["Unc_PLEC_Expfactor"]
              )
              ss += "{:<45s} : {} +- {}\n".format(
                  "Super-exponential cutoff index",
                  d["PLEC_Exp_Index"],
                  d["Unc_PLEC_Exp_Index"],
              )
              ss += "{:<45s} : {:.1f}\n".format(
                  "Significance curvature", d["PLEC_SigCurv"]
              )
  
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          ss += "{:<45s} : {:.0f} {}\n".format(
              "Pivot energy", d["Pivot_Energy"].value, d["Pivot_Energy"].unit
          )
  
          fmt = "{:<45s} : {:.3f} +- {:.3f}\n"
          ss += fmt.format(
              "Spectral index", d[tag + "_Index"], d["Unc_" + tag + "_Index"]
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Flux Density at pivot energy",
              d[tag + "_Flux_Density"].value,
              d["Unc_" + tag + "_Flux_Density"].value,
              "cm-2 MeV-1 s-1",
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Integral flux (1 - 100 GeV)",
              d["Flux1000"].value,
              d["Unc_Flux1000"].value,
              "cm-2 s-1",
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Energy flux (100 MeV - 100 GeV)",
              d["Energy_Flux100"].value,
              d["Unc_Energy_Flux100"].value,
              "erg cm-2 s-1",
          )
  
          return ss
  
      def _info_lightcurve(self):
          d = self.data
          ss = "\n*** Lightcurve info ***\n\n"
          ss += "Lightcurve measured in the energy band: 100 MeV - 100 GeV\n\n"
  
          ss += "{:<15s} : {:.3f}\n".format("Variability index", d["Variability_Index"])
  
          if np.isfinite(d["Flux_Peak"]):
              ss += "{:<40s} : {:.3f}\n".format(
                  "Significance peak (100 MeV - 100 GeV)", d["Signif_Peak"]
              )
  
              fmt = "{:<40s} : {:.3} +- {:.3} cm^-2 s^-1\n"
              ss += fmt.format(
                  "Integral flux peak (100 MeV - 100 GeV)",
                  d["Flux_Peak"].value,
                  d["Unc_Flux_Peak"].value,
              )
  
              # TODO: give time as UTC string, not MET
              ss += "{:<40s} : {:.3} s (Mission elapsed time)\n".format(
                  "Time peak", d["Time_Peak"].value
              )
              peak_interval = d["Peak_Interval"].to_value("day")
              ss += "{:<40s} : {:.3} day\n".format("Peak interval", peak_interval)
          else:
              ss += "\nNo peak measured for this source.\n"
  
          # TODO: Add a lightcurve table with d['Flux_History'] and d['Unc_Flux_History']
  
          return ss
  
      def spatial_model(self):
          """Spatial model (`~gammapy.modeling.models.SpatialModel`)."""
          d = self.data
          ra = d["RAJ2000"]
          dec = d["DEJ2000"]
  
          if self.is_pointlike:
              model = PointSpatialModel(lon_0=ra, lat_0=dec, frame="icrs")
          else:
              de = self.data_extended
              morph_type = de["Model_Form"].strip()
              e = (1 - (de["Model_SemiMinor"] / de["Model_SemiMajor"]) ** 2.0) ** 0.5
              sigma = de["Model_SemiMajor"]
              phi = de["Model_PosAng"]
              if morph_type == "Disk":
                  r_0 = de["Model_SemiMajor"]
                  model = DiskSpatialModel(
                      lon_0=ra, lat_0=dec, r_0=r_0, e=e, phi=phi, frame="icrs"
                  )
              elif morph_type in ["Map", "Ring", "2D Gaussian x2"]:
                  filename = de["Spatial_Filename"].strip()
                  path = make_path(
                      "$GAMMAPY_DATA/catalogs/fermi/LAT_extended_sources_8years/Templates/"
                  )
                  with warnings.catch_warnings():  # ignore FITS units warnings
                      warnings.simplefilter("ignore", FITSFixedWarning)
                      model = TemplateSpatialModel.read(path / filename)
              elif morph_type == "2D Gaussian":
                  model = GaussianSpatialModel(
                      lon_0=ra, lat_0=dec, sigma=sigma, e=e, phi=phi, frame="icrs"
                  )
              else:
                  raise ValueError(f"Invalid spatial model: {morph_type!r}")
          self._set_spatial_errors(model)
          return model
  
      def spectral_model(self):
          """Best fit spectral model (`~gammapy.modeling.models.SpectralModel`)."""
          spec_type = self.data["SpectrumType"].strip()
  
          if spec_type == "PowerLaw":
              tag = "PowerLawSpectralModel"
              pars = {
                  "reference": self.data["Pivot_Energy"],
                  "amplitude": self.data["PL_Flux_Density"],
                  "index": self.data["PL_Index"],
              }
              errs = {
                  "amplitude": self.data["Unc_PL_Flux_Density"],
                  "index": self.data["Unc_PL_Index"],
              }
          elif spec_type == "LogParabola":
              tag = "LogParabolaSpectralModel"
              pars = {
                  "reference": self.data["Pivot_Energy"],
                  "amplitude": self.data["LP_Flux_Density"],
                  "alpha": self.data["LP_Index"],
                  "beta": self.data["LP_beta"],
              }
              errs = {
                  "amplitude": self.data["Unc_LP_Flux_Density"],
                  "alpha": self.data["Unc_LP_Index"],
                  "beta": self.data["Unc_LP_beta"],
              }
          elif spec_type == "PLSuperExpCutoff":
              tag = "SuperExpCutoffPowerLaw4FGLSpectralModel"
              pars = {
                  "reference": self.data["Pivot_Energy"],
                  "amplitude": self.data["PLEC_Flux_Density"],
                  "index_1": self.data["PLEC_Index"],
                  "index_2": self.data["PLEC_Exp_Index"],
                  "expfactor": self.data["PLEC_Expfactor"],
              }
              errs = {
                  "amplitude": self.data["Unc_PLEC_Flux_Density"],
                  "index_1": self.data["Unc_PLEC_Index"],
                  "index_2": np.nan_to_num(self.data["Unc_PLEC_Exp_Index"]),
                  "expfactor": self.data["Unc_PLEC_Expfactor"],
              }
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          model = Model.create(tag, **pars)
  
          for name, value in errs.items():
              model.parameters[name].error = value
  
          return model
  
      @property
      def flux_points(self):
          """Flux points (`~gammapy.spectrum.FluxPoints`)."""
          table = Table()
          table.meta["SED_TYPE"] = "flux"
  
          table["e_min"] = self._ebounds[:-1]
          table["e_max"] = self._ebounds[1:]
  
          flux = self._get_flux_values("Flux_Band")
          flux_err = self._get_flux_values("Unc_Flux_Band")
          table["flux"] = flux
          table["flux_errn"] = np.abs(flux_err[:, 0])
          table["flux_errp"] = flux_err[:, 1]
  
          nuFnu = self._get_flux_values("nuFnu_Band", "erg cm-2 s-1")
          table["e2dnde"] = nuFnu
          table["e2dnde_errn"] = np.abs(nuFnu * flux_err[:, 0] / flux)
          table["e2dnde_errp"] = nuFnu * flux_err[:, 1] / flux
  
          is_ul = np.isnan(table["flux_errn"])
          table["is_ul"] = is_ul
  
          # handle upper limits
          table["flux_ul"] = np.nan * flux_err.unit
          flux_ul = compute_flux_points_ul(table["flux"], table["flux_errp"])
          table["flux_ul"][is_ul] = flux_ul[is_ul]
  
          # handle upper limits
          table["e2dnde_ul"] = np.nan * nuFnu.unit
          e2dnde_ul = compute_flux_points_ul(table["e2dnde"], table["e2dnde_errp"])
          table["e2dnde_ul"][is_ul] = e2dnde_ul[is_ul]
  
          # Square root of test statistic
          table["sqrt_TS"] = self.data["Sqrt_TS_Band"]
          return FluxPoints(table)
  
      def _get_flux_values(self, prefix, unit="cm-2 s-1"):
          values = self.data[prefix]
          return u.Quantity(values, unit)
  
      @property
      def lightcurve(self):
          """Lightcurve (`~gammapy.time.LightCurve`)."""
          flux = self.data["Flux_History"]
  
          # Flux error is given as asymmetric high/low
          flux_errn = -self.data["Unc_Flux_History"][:, 0]
          flux_errp = self.data["Unc_Flux_History"][:, 1]
  
          # Really the time binning is stored in a separate HDU in the FITS
          # catalog file called `Hist_Start`, with a single column `Hist_Start`
          # giving the time binning in MET (mission elapsed time)
          # This is not available here for now.
          # TODO: read that info in `SourceCatalog3FGL` and pass it down to the
          # `SourceCatalogObject3FGL` object somehow.
  
          # For now, we just hard-code the start and stop time and assume
          # equally-spaced time intervals. This is roughly correct,
          # for plotting the difference doesn't matter, only for analysis
          time_start = Time("2008-08-04T15:43:36.0000")
          time_end = Time("2016-08-02T05:44:11.9999")
          n_points = len(flux)
          time_step = (time_end - time_start) / n_points
          time_bounds = time_start + np.arange(n_points + 1) * time_step
  
          table = Table(
              [
                  Column(time_bounds[:-1].utc.mjd, "time_min"),
                  Column(time_bounds[1:].utc.mjd, "time_max"),
                  Column(flux, "flux"),
                  Column(flux_errp, "flux_errp"),
                  Column(flux_errn, "flux_errn"),
              ]
          )
          return LightCurve(table)
  
  
  class SourceCatalogObject3FGL(SourceCatalogObjectFermiBase):
      """One source from the Fermi-LAT 3FGL catalog.
  
      Catalog is represented by `~gammapy.catalog.SourceCatalog3FGL`.
      """
  
      _ebounds = u.Quantity([100, 300, 1000, 3000, 10000, 100000], "MeV")
      _ebounds_suffix = ["100_300", "300_1000", "1000_3000", "3000_10000", "10000_100000"]
      energy_range = u.Quantity([100, 100000], "MeV")
      """Energy range used for the catalog.
  
      Paper says that analysis uses data up to 300 GeV,
      but results are all quoted up to 100 GeV only to
      be consistent with previous catalogs.
      """
  
      def _info_more(self):
          d = self.data
          ss = "\n*** Other info ***\n\n"
          ss += "{:<20s} : {}\n".format("Other flags", d["Flags"])
          return ss
  
      def _info_spectral_fit(self):
          d = self.data
          spec_type = d["SpectrumType"].strip()
  
          ss = "\n*** Spectral info ***\n\n"
  
          ss += "{:<45s} : {}\n".format("Spectrum type", d["SpectrumType"])
          fmt = "{:<45s} : {:.3f}\n"
          ss += fmt.format("Detection significance (100 MeV - 300 GeV)", d["Signif_Avg"])
          ss += "{:<45s} : {:.1f}\n".format("Significance curvature", d["Signif_Curve"])
  
          if spec_type == "PowerLaw":
              pass
          elif spec_type == "LogParabola":
              ss += "{:<45s} : {} +- {}\n".format("beta", d["beta"], d["Unc_beta"])
          elif spec_type in ["PLExpCutoff", "PlSuperExpCutoff"]:
              fmt = "{:<45s} : {:.0f} +- {:.0f} {}\n"
              ss += fmt.format(
                  "Cutoff energy",
                  d["Cutoff"].value,
                  d["Unc_Cutoff"].value,
                  d["Cutoff"].unit,
              )
          elif spec_type == "PLSuperExpCutoff":
              ss += "{:<45s} : {} +- {}\n".format(
                  "Super-exponential cutoff index", d["Exp_Index"], d["Unc_Exp_Index"]
              )
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          ss += "{:<45s} : {:.0f} {}\n".format(
              "Pivot energy", d["Pivot_Energy"].value, d["Pivot_Energy"].unit
          )
  
          ss += "{:<45s} : {:.3f}\n".format(
              "Power law spectral index", d["PowerLaw_Index"]
          )
  
          fmt = "{:<45s} : {:.3f} +- {:.3f}\n"
          ss += fmt.format("Spectral index", d["Spectral_Index"], d["Unc_Spectral_Index"])
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Flux Density at pivot energy",
              d["Flux_Density"].value,
              d["Unc_Flux_Density"].value,
              "cm-2 MeV-1 s-1",
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Integral flux (1 - 100 GeV)",
              d["Flux1000"].value,
              d["Unc_Flux1000"].value,
              "cm-2 s-1",
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Energy flux (100 MeV - 100 GeV)",
              d["Energy_Flux100"].value,
              d["Unc_Energy_Flux100"].value,
              "erg cm-2 s-1",
          )
  
          return ss
  
      def _info_lightcurve(self):
          d = self.data
          ss = "\n*** Lightcurve info ***\n\n"
          ss += "Lightcurve measured in the energy band: 100 MeV - 100 GeV\n\n"
  
          ss += "{:<15s} : {:.3f}\n".format("Variability index", d["Variability_Index"])
  
          if np.isfinite(d["Flux_Peak"]):
              ss += "{:<40s} : {:.3f}\n".format(
                  "Significance peak (100 MeV - 100 GeV)", d["Signif_Peak"]
              )
  
              fmt = "{:<40s} : {:.3} +- {:.3} cm^-2 s^-1\n"
              ss += fmt.format(
                  "Integral flux peak (100 MeV - 100 GeV)",
                  d["Flux_Peak"].value,
                  d["Unc_Flux_Peak"].value,
              )
  
              # TODO: give time as UTC string, not MET
              ss += "{:<40s} : {:.3} s (Mission elapsed time)\n".format(
                  "Time peak", d["Time_Peak"].value
              )
              peak_interval = d["Peak_Interval"].to_value("day")
              ss += "{:<40s} : {:.3} day\n".format("Peak interval", peak_interval)
          else:
              ss += "\nNo peak measured for this source.\n"
  
          # TODO: Add a lightcurve table with d['Flux_History'] and d['Unc_Flux_History']
  
          return ss
  
      def spectral_model(self):
          """Best fit spectral model (`~gammapy.modeling.models.SpectralModel`)."""
          spec_type = self.data["SpectrumType"].strip()
  
          if spec_type == "PowerLaw":
              tag = "PowerLawSpectralModel"
              pars = {
                  "amplitude": self.data["Flux_Density"],
                  "reference": self.data["Pivot_Energy"],
                  "index": self.data["Spectral_Index"],
              }
              errs = {
                  "amplitude": self.data["Unc_Flux_Density"],
                  "index": self.data["Unc_Spectral_Index"],
              }
          elif spec_type == "PLExpCutoff":
              tag = "ExpCutoffPowerLaw3FGLSpectralModel"
              pars = {
                  "amplitude": self.data["Flux_Density"],
                  "reference": self.data["Pivot_Energy"],
                  "index": self.data["Spectral_Index"],
                  "ecut": self.data["Cutoff"],
              }
              errs = {
                  "amplitude": self.data["Unc_Flux_Density"],
                  "index": self.data["Unc_Spectral_Index"],
                  "ecut": self.data["Unc_Cutoff"],
              }
          elif spec_type == "LogParabola":
              tag = "LogParabolaSpectralModel"
              pars = {
                  "amplitude": self.data["Flux_Density"],
                  "reference": self.data["Pivot_Energy"],
                  "alpha": self.data["Spectral_Index"],
                  "beta": self.data["beta"],
              }
              errs = {
                  "amplitude": self.data["Unc_Flux_Density"],
                  "alpha": self.data["Unc_Spectral_Index"],
                  "beta": self.data["Unc_beta"],
              }
          elif spec_type == "PLSuperExpCutoff":
              tag = "SuperExpCutoffPowerLaw3FGLSpectralModel"
              pars = {
                  "amplitude": self.data["Flux_Density"],
                  "reference": self.data["Pivot_Energy"],
                  "index_1": self.data["Spectral_Index"],
                  "index_2": self.data["Exp_Index"],
                  "ecut": self.data["Cutoff"],
              }
              errs = {
                  "amplitude": self.data["Unc_Flux_Density"],
                  "index_1": self.data["Unc_Spectral_Index"],
                  "index_2": self.data["Unc_Exp_Index"],
                  "ecut": self.data["Unc_Cutoff"],
              }
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          model = Model.create(tag, **pars)
  
          for name, value in errs.items():
              model.parameters[name].error = value
  
          return model
  
      def spatial_model(self):
          """Spatial model (`~gammapy.modeling.models.SpatialModel`)."""
          d = self.data
          ra = d["RAJ2000"]
          dec = d["DEJ2000"]
  
          if self.is_pointlike:
              model = PointSpatialModel(lon_0=ra, lat_0=dec, frame="icrs")
          else:
              de = self.data_extended
              morph_type = de["Model_Form"].strip()
              e = (1 - (de["Model_SemiMinor"] / de["Model_SemiMajor"]) ** 2.0) ** 0.5
              sigma = de["Model_SemiMajor"]
              phi = de["Model_PosAng"]
              if morph_type == "Disk":
                  r_0 = de["Model_SemiMajor"]
                  model = DiskSpatialModel(
                      lon_0=ra, lat_0=dec, r_0=r_0, e=e, phi=phi, frame="icrs"
                  )
              elif morph_type in ["Map", "Ring", "2D Gaussian x2"]:
                  filename = de["Spatial_Filename"].strip()
                  path = make_path(
                      "$GAMMAPY_DATA/catalogs/fermi/Extended_archive_v15/Templates/"
                  )
                  model = TemplateSpatialModel.read(path / filename)
              elif morph_type == "2D Gaussian":
                  model = GaussianSpatialModel(
                      lon_0=ra, lat_0=dec, sigma=sigma, e=e, phi=phi, frame="icrs"
                  )
              else:
                  raise ValueError(f"Invalid spatial model: {morph_type!r}")
          self._set_spatial_errors(model)
          return model
  
      @property
      def flux_points(self):
          """Flux points (`~gammapy.spectrum.FluxPoints`)."""
          table = Table()
          table.meta["SED_TYPE"] = "flux"
  
          table["e_min"] = self._ebounds[:-1]
          table["e_max"] = self._ebounds[1:]
  
          flux = self._get_flux_values("Flux")
          flux_err = self._get_flux_values("Unc_Flux")
          table["flux"] = flux
          table["flux_errn"] = np.abs(flux_err[:, 0])
          table["flux_errp"] = flux_err[:, 1]
  
          nuFnu = self._get_flux_values("nuFnu", "erg cm-2 s-1")
          table["e2dnde"] = nuFnu
          table["e2dnde_errn"] = np.abs(nuFnu * flux_err[:, 0] / flux)
          table["e2dnde_errp"] = nuFnu * flux_err[:, 1] / flux
  
          is_ul = np.isnan(table["flux_errn"])
          table["is_ul"] = is_ul
  
          # handle upper limits
          table["flux_ul"] = np.nan * flux_err.unit
          flux_ul = compute_flux_points_ul(table["flux"], table["flux_errp"])
          table["flux_ul"][is_ul] = flux_ul[is_ul]
  
          # handle upper limits
          table["e2dnde_ul"] = np.nan * nuFnu.unit
          e2dnde_ul = compute_flux_points_ul(table["e2dnde"], table["e2dnde_errp"])
          table["e2dnde_ul"][is_ul] = e2dnde_ul[is_ul]
  
          # Square root of test statistic
          table["sqrt_TS"] = [self.data["Sqrt_TS" + _] for _ in self._ebounds_suffix]
          return FluxPoints(table)
  
      def _get_flux_values(self, prefix, unit="cm-2 s-1"):
          values = [self.data[prefix + _] for _ in self._ebounds_suffix]
          return u.Quantity(values, unit)
  
      @property
      def lightcurve(self):
          """Lightcurve (`~gammapy.time.LightCurve`)."""
          flux = self.data["Flux_History"]
  
          # Flux error is given as asymmetric high/low
          flux_errn = -self.data["Unc_Flux_History"][:, 0]
          flux_errp = self.data["Unc_Flux_History"][:, 1]
  
          # Really the time binning is stored in a separate HDU in the FITS
          # catalog file called `Hist_Start`, with a single column `Hist_Start`
          # giving the time binning in MET (mission elapsed time)
          # This is not available here for now.
          # TODO: read that info in `SourceCatalog3FGL` and pass it down to the
          # `SourceCatalogObject3FGL` object somehow.
  
          # For now, we just hard-code the start and stop time and assume
          # equally-spaced time intervals. This is roughly correct,
          # for plotting the difference doesn't matter, only for analysis
          time_start = Time("2008-08-02T00:33:19")
          time_end = Time("2012-07-31T22:45:47")
          n_points = len(flux)
          time_step = (time_end - time_start) / n_points
          time_bounds = time_start + np.arange(n_points + 1) * time_step
  
          table = Table(
              [
                  Column(time_bounds[:-1].utc.mjd, "time_min"),
                  Column(time_bounds[1:].utc.mjd, "time_max"),
                  Column(flux, "flux"),
                  Column(flux_errp, "flux_errp"),
                  Column(flux_errn, "flux_errn"),
              ]
          )
          return LightCurve(table)
  
  
  class SourceCatalogObject2FHL(SourceCatalogObjectFermiBase):
      """One source from the Fermi-LAT 2FHL catalog.
  
      Catalog is represented by `~gammapy.catalog.SourceCatalog2FHL`.
      """
  
      asso = ["ASSOC", "3FGL_Name", "1FHL_Name", "TeVCat_Name"]
      _ebounds = u.Quantity([50, 171, 585, 2000], "GeV")
      _ebounds_suffix = ["50_171", "171_585", "585_2000"]
      energy_range = u.Quantity([0.05, 2], "TeV")
      """Energy range used for the catalog."""
  
      def _info_more(self):
          d = self.data
          ss = "\n*** Other info ***\n\n"
          fmt = "{:<32s} : {:.3f}\n"
          ss += fmt.format("Test statistic (50 GeV - 2 TeV)", d["TS"])
          return ss
  
      def _info_position(self):
          d = self.data
          ss = "\n*** Position info ***\n\n"
          ss += "{:<20s} : {:.3f}\n".format("RA", d["RAJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("DEC", d["DEJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("GLON", d["GLON"])
          ss += "{:<20s} : {:.3f}\n".format("GLAT", d["GLAT"])
  
          ss += "\n"
          ss += "{:<20s} : {:.4f}\n".format("Error on position (68%)", d["Pos_err_68"])
          ss += "{:<20s} : {:.0f}\n".format("ROI number", d["ROI"])
          return ss
  
      def _info_spectral_fit(self):
          d = self.data
  
          ss = "\n*** Spectral fit info ***\n\n"
  
          fmt = "{:<32s} : {:.3f} +- {:.3f}\n"
          ss += fmt.format(
              "Power-law spectral index", d["Spectral_Index"], d["Unc_Spectral_Index"]
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Integral flux (50 GeV - 2 TeV)",
              d["Flux50"].value,
              d["Unc_Flux50"].value,
              "cm-2 s-1",
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Energy flux (50 GeV - 2 TeV)",
              d["Energy_Flux50"].value,
              d["Unc_Energy_Flux50"].value,
              "erg cm-2 s-1",
          )
  
          return ss
  
      @property
      def is_pointlike(self):
          return self.data["Source_Name"].strip()[-1] != "e"
  
      def spatial_model(self):
          """Spatial model (`~gammapy.modeling.models.SpatialModel`)."""
          d = self.data
          ra = d["RAJ2000"]
          dec = d["DEJ2000"]
  
          if self.is_pointlike:
              model = PointSpatialModel(lon_0=ra, lat_0=dec, frame="icrs")
          else:
              de = self.data_extended
              morph_type = de["Model_Form"].strip()
              e = (1 - (de["Model_SemiMinor"] / de["Model_SemiMajor"]) ** 2.0) ** 0.5
              sigma = de["Model_SemiMajor"]
              phi = de["Model_PosAng"]
              if morph_type in ["Disk", "Elliptical Disk"]:
                  r_0 = de["Model_SemiMajor"]
                  model = DiskSpatialModel(
                      lon_0=ra, lat_0=dec, r_0=r_0, e=e, phi=phi, frame="icrs"
                  )
              elif morph_type in ["Map", "Ring", "2D Gaussian x2"]:
                  filename = de["Spatial_Filename"].strip()
                  path = make_path(
                      "$GAMMAPY_DATA/catalogs/fermi/Extended_archive_v15/Templates/"
                  )
                  return TemplateSpatialModel.read(path / filename)
              elif morph_type in ["2D Gaussian", "Elliptical 2D Gaussian"]:
                  model = GaussianSpatialModel(
                      lon_0=ra, lat_0=dec, sigma=sigma, e=e, phi=phi, frame="icrs"
                  )
              else:
                  raise ValueError(f"Invalid spatial model: {morph_type!r}")
  
          self._set_spatial_errors(model)
          return model
  
      def spectral_model(self):
          """Best fit spectral model (`~gammapy.modeling.models.SpectralModel`)."""
          tag = "PowerLaw2SpectralModel"
          pars = {
              "amplitude": self.data["Flux50"],
              "emin": self.energy_range[0],
              "emax": self.energy_range[1],
              "index": self.data["Spectral_Index"],
          }
          errs = {
              "amplitude": self.data["Unc_Flux50"],
              "index": self.data["Unc_Spectral_Index"],
          }
  
          model = Model.create(tag, **pars)
  
          for name, value in errs.items():
              model.parameters[name].error = value
  
          return model
  
      @property
      def flux_points(self):
          """Integral flux points (`~gammapy.spectrum.FluxPoints`)."""
          table = Table()
          table.meta["SED_TYPE"] = "flux"
          table["e_min"] = self._ebounds[:-1]
          table["e_max"] = self._ebounds[1:]
          table["flux"] = self._get_flux_values("Flux")
          flux_err = self._get_flux_values("Unc_Flux")
          table["flux_errn"] = np.abs(flux_err[:, 0])
          table["flux_errp"] = flux_err[:, 1]
  
          # handle upper limits
          is_ul = np.isnan(table["flux_errn"])
          table["is_ul"] = is_ul
          table["flux_ul"] = np.nan * flux_err.unit
          flux_ul = compute_flux_points_ul(table["flux"], table["flux_errp"])
          table["flux_ul"][is_ul] = flux_ul[is_ul]
          return FluxPoints(table)
  
      def _get_flux_values(self, prefix, unit="cm-2 s-1"):
          values = [self.data[prefix + _ + "GeV"] for _ in self._ebounds_suffix]
          return u.Quantity(values, unit)
  
  
  class SourceCatalogObject3FHL(SourceCatalogObjectFermiBase):
      """One source from the Fermi-LAT 3FHL catalog.
  
      Catalog is represented by `~gammapy.catalog.SourceCatalog3FHL`.
      """
  
      asso = ["ASSOC1", "ASSOC2", "ASSOC_TEV", "ASSOC_GAM"]
      energy_range = u.Quantity([0.01, 2], "TeV")
      """Energy range used for the catalog."""
  
      _ebounds = u.Quantity([10, 20, 50, 150, 500, 2000], "GeV")
  
      def _info_position(self):
          d = self.data
          ss = "\n*** Position info ***\n\n"
          ss += "{:<20s} : {:.3f}\n".format("RA", d["RAJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("DEC", d["DEJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("GLON", d["GLON"])
          ss += "{:<20s} : {:.3f}\n".format("GLAT", d["GLAT"])
  
          # TODO: All sources are non-elliptical; just give one number for radius?
          ss += "\n"
          ss += "{:<20s} : {:.4f}\n".format("Semimajor (95%)", d["Conf_95_SemiMajor"])
          ss += "{:<20s} : {:.4f}\n".format("Semiminor (95%)", d["Conf_95_SemiMinor"])
          ss += "{:<20s} : {:.2f}\n".format("Position angle (95%)", d["Conf_95_PosAng"])
          ss += "{:<20s} : {:.0f}\n".format("ROI number", d["ROI_num"])
  
          return ss
  
      def _info_spectral_fit(self):
          d = self.data
          spec_type = d["SpectrumType"].strip()
  
          ss = "\n*** Spectral fit info ***\n\n"
  
          ss += "{:<32s} : {}\n".format("Spectrum type", d["SpectrumType"])
          ss += "{:<32s} : {:.1f}\n".format("Significance curvature", d["Signif_Curve"])
  
          # Power-law parameters are always given; give in any case
          fmt = "{:<32s} : {:.3f} +- {:.3f}\n"
          ss += fmt.format(
              "Power-law spectral index", d["PowerLaw_Index"], d["Unc_PowerLaw_Index"]
          )
  
          if spec_type == "PowerLaw":
              pass
          elif spec_type == "LogParabola":
              fmt = "{:<32s} : {:.3f} +- {:.3f}\n"
              ss += fmt.format(
                  "LogParabolaSpectralModel spectral index",
                  d["Spectral_Index"],
                  d["Unc_Spectral_Index"],
              )
  
              ss += "{:<32s} : {:.3f} +- {:.3f}\n".format(
                  "LogParabolaSpectralModel beta", d["beta"], d["Unc_beta"]
              )
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          ss += "{:<32s} : {:.1f} {}\n".format(
              "Pivot energy", d["Pivot_Energy"].value, d["Pivot_Energy"].unit
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Flux Density at pivot energy",
              d["Flux_Density"].value,
              d["Unc_Flux_Density"].value,
              "cm-2 GeV-1 s-1",
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Integral flux (10 GeV - 1 TeV)",
              d["Flux"].value,
              d["Unc_Flux"].value,
              "cm-2 s-1",
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Energy flux (10 GeV - TeV)",
              d["Energy_Flux"].value,
              d["Unc_Energy_Flux"].value,
              "erg cm-2 s-1",
          )
  
          return ss
  
      def _info_more(self):
          d = self.data
          ss = "\n*** Other info ***\n\n"
  
          fmt = "{:<32s} : {:.3f}\n"
          ss += fmt.format("Significance (10 GeV - 2 TeV)", d["Signif_Avg"])
          ss += "{:<32s} : {:.1f}\n".format("Npred", d["Npred"])
  
          ss += "\n{:<16s} : {:.3f} {}\n".format(
              "HEP Energy", d["HEP_Energy"].value, d["HEP_Energy"].unit
          )
          ss += "{:<16s} : {:.3f}\n".format("HEP Probability", d["HEP_Prob"])
  
          ss += "{:<16s} : {}\n".format("Bayesian Blocks", d["Variability_BayesBlocks"])
  
          ss += "{:<16s} : {:.3f}\n".format("Redshift", d["Redshift"])
          ss += "{:<16s} : {:.3} {}\n".format(
              "NuPeak_obs", d["NuPeak_obs"].value, d["NuPeak_obs"].unit
          )
  
          return ss
  
      def spectral_model(self):
          """Best fit spectral model (`~gammapy.modeling.models.SpectralModel`)."""
          d = self.data
          spec_type = self.data["SpectrumType"].strip()
  
          if spec_type == "PowerLaw":
              tag = "PowerLawSpectralModel"
              pars = {
                  "reference": d["Pivot_Energy"],
                  "amplitude": d["Flux_Density"],
                  "index": d["PowerLaw_Index"],
              }
              errs = {
                  "amplitude": d["Unc_Flux_Density"],
                  "index": d["Unc_PowerLaw_Index"],
              }
          elif spec_type == "LogParabola":
              tag = "LogParabolaSpectralModel"
              pars = {
                  "reference": d["Pivot_Energy"],
                  "amplitude": d["Flux_Density"],
                  "alpha": d["Spectral_Index"],
                  "beta": d["beta"],
              }
              errs = {
                  "amplitude": d["Unc_Flux_Density"],
                  "alpha": d["Unc_Spectral_Index"],
                  "beta": d["Unc_beta"],
              }
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          model = Model.create(tag, **pars)
  
          for name, value in errs.items():
              model.parameters[name].error = value
  
          return model
  
      @property
      def flux_points(self):
          """Flux points (`~gammapy.spectrum.FluxPoints`)."""
          table = Table()
          table.meta["SED_TYPE"] = "flux"
          table["e_min"] = self._ebounds[:-1]
          table["e_max"] = self._ebounds[1:]
  
          flux = self.data["Flux_Band"]
          flux_err = self.data["Unc_Flux_Band"]
          e2dnde = self.data["nuFnu"]
  
          table["flux"] = flux
          table["flux_errn"] = np.abs(flux_err[:, 0])
          table["flux_errp"] = flux_err[:, 1]
  
          table["e2dnde"] = e2dnde
          table["e2dnde_errn"] = np.abs(e2dnde * flux_err[:, 0] / flux)
          table["e2dnde_errp"] = e2dnde * flux_err[:, 1] / flux
  
          is_ul = np.isnan(table["flux_errn"])
          table["is_ul"] = is_ul
  
          # handle upper limits
          table["flux_ul"] = np.nan * flux_err.unit
          flux_ul = compute_flux_points_ul(table["flux"], table["flux_errp"])
          table["flux_ul"][is_ul] = flux_ul[is_ul]
  
          table["e2dnde_ul"] = np.nan * e2dnde.unit
          e2dnde_ul = compute_flux_points_ul(table["e2dnde"], table["e2dnde_errp"])
          table["e2dnde_ul"][is_ul] = e2dnde_ul[is_ul]
  
          # Square root of test statistic
          table["sqrt_ts"] = self.data["Sqrt_TS_Band"]
          return FluxPoints(table)
  
      def spatial_model(self):
          """Source spatial model (`~gammapy.modeling.models.SpatialModel`)."""
          d = self.data
          ra = d["RAJ2000"]
          dec = d["DEJ2000"]
  
          if self.is_pointlike:
              model = PointSpatialModel(lon_0=ra, lat_0=dec, frame="icrs")
          else:
              de = self.data_extended
              morph_type = de["Spatial_Function"].strip()
              e = (1 - (de["Model_SemiMinor"] / de["Model_SemiMajor"]) ** 2.0) ** 0.5
              sigma = de["Model_SemiMajor"]
              phi = de["Model_PosAng"]
              if morph_type == "RadialDisk":
                  r_0 = de["Model_SemiMajor"]
                  model = DiskSpatialModel(
                      lon_0=ra, lat_0=dec, r_0=r_0, e=e, phi=phi, frame="icrs"
                  )
              elif morph_type in ["SpatialMap"]:
                  filename = de["Spatial_Filename"].strip()
                  path = make_path(
                      "$GAMMAPY_DATA/catalogs/fermi/Extended_archive_v18/Templates/"
                  )
                  model = TemplateSpatialModel.read(path / filename)
              elif morph_type == "RadialGauss":
                  model = GaussianSpatialModel(
                      lon_0=ra, lat_0=dec, sigma=sigma, e=e, phi=phi, frame="icrs"
                  )
              else:
                  raise ValueError(f"Invalid morph_type: {morph_type!r}")
          self._set_spatial_errors(model)
          return model
  
  
  class SourceCatalog3FGL(SourceCatalog):
      """Fermi-LAT 3FGL source catalog.
  
      - https://ui.adsabs.harvard.edu/#abs/2015ApJS..218...23A
      - https://fermi.gsfc.nasa.gov/ssc/data/access/lat/4yr_catalog/
  
      One source is represented by `~gammapy.catalog.SourceCatalogObject3FGL`.
      """
  
      tag = "3fgl"
      description = "LAT 4-year point source catalog"
      source_object_class = SourceCatalogObject3FGL
  
      def __init__(self, filename="$GAMMAPY_DATA/catalogs/fermi/gll_psc_v16.fit.gz"):
          filename = make_path(filename)
  
          with warnings.catch_warnings():  # ignore FITS units warnings
              warnings.simplefilter("ignore", u.UnitsWarning)
              table = Table.read(filename, hdu="LAT_Point_Source_Catalog")
  
          table_standardise_units_inplace(table)
  
          source_name_key = "Source_Name"
          source_name_alias = (
              "Extended_Source_Name",
              "0FGL_Name",
              "1FGL_Name",
              "2FGL_Name",
              "1FHL_Name",
              "ASSOC_TEV",
              "ASSOC1",
              "ASSOC2",
          )
          super().__init__(
              table=table,
              source_name_key=source_name_key,
              source_name_alias=source_name_alias,
          )
  
          self.extended_sources_table = Table.read(filename, hdu="ExtendedSources")
  
  
  class SourceCatalog4FGL(SourceCatalog):
      """Fermi-LAT 4FGL source catalog.
  
      - https://arxiv.org/abs/1902.10045
      - https://fermi.gsfc.nasa.gov/ssc/data/access/lat/8yr_catalog/
  
      One source is represented by `~gammapy.catalog.SourceCatalogObject4FGL`.
      """
  
      tag = "4fgl"
      description = "LAT 8-year point source catalog"
      source_object_class = SourceCatalogObject4FGL
  
      def __init__(self, filename="$GAMMAPY_DATA/catalogs/fermi/gll_psc_v20.fit.gz"):
          filename = make_path(filename)
          table = Table.read(filename, hdu="LAT_Point_Source_Catalog")
          table_standardise_units_inplace(table)
  
          source_name_key = "Source_Name"
          source_name_alias = (
              "Extended_Source_Name",
              "ASSOC_FGL",
              "ASSOC_FHL",
              "ASSOC_GAM1",
              "ASSOC_GAM2",
              "ASSOC_GAM3",
              "ASSOC_TEV",
              "ASSOC1",
              "ASSOC2",
          )
          super().__init__(
              table=table,
              source_name_key=source_name_key,
              source_name_alias=source_name_alias,
          )
  
          self.extended_sources_table = Table.read(filename, hdu="ExtendedSources")
  
  
  class SourceCatalog2FHL(SourceCatalog):
      """Fermi-LAT 2FHL source catalog.
  
      - https://ui.adsabs.harvard.edu/abs/2016ApJS..222....5A
      - https://fermi.gsfc.nasa.gov/ssc/data/access/lat/2FHL/
  
      One source is represented by `~gammapy.catalog.SourceCatalogObject2FHL`.
      """
  
      tag = "2fhl"
      description = "LAT second high-energy source catalog"
      source_object_class = SourceCatalogObject2FHL
  
      def __init__(self, filename="$GAMMAPY_DATA/catalogs/fermi/gll_psch_v09.fit.gz"):
          filename = make_path(filename)
  
          with warnings.catch_warnings():  # ignore FITS units warnings
              warnings.simplefilter("ignore", u.UnitsWarning)
              table = Table.read(filename, hdu="2FHL Source Catalog")
  
          table_standardise_units_inplace(table)
  
          source_name_key = "Source_Name"
          source_name_alias = ("ASSOC", "3FGL_Name", "1FHL_Name", "TeVCat_Name")
          super().__init__(
              table=table,
              source_name_key=source_name_key,
              source_name_alias=source_name_alias,
          )
  
          self.extended_sources_table = Table.read(filename, hdu="Extended Sources")
          self.rois = Table.read(filename, hdu="ROIs")
  
  
  class SourceCatalog3FHL(SourceCatalog):
      """Fermi-LAT 3FHL source catalog.
  
      - https://ui.adsabs.harvard.edu/abs/2017ApJS..232...18A
      - https://fermi.gsfc.nasa.gov/ssc/data/access/lat/3FHL/
  
      One source is represented by `~gammapy.catalog.SourceCatalogObject3FHL`.
      """
  
      tag = "3fhl"
      description = "LAT third high-energy source catalog"
      source_object_class = SourceCatalogObject3FHL
  
      def __init__(self, filename="$GAMMAPY_DATA/catalogs/fermi/gll_psch_v13.fit.gz"):
          filename = make_path(filename)
  
          with warnings.catch_warnings():  # ignore FITS units warnings
              warnings.simplefilter("ignore", u.UnitsWarning)
              table = Table.read(filename, hdu="LAT_Point_Source_Catalog")
  
          table_standardise_units_inplace(table)
  
          source_name_key = "Source_Name"
          source_name_alias = ("ASSOC1", "ASSOC2", "ASSOC_TEV", "ASSOC_GAM")
          super().__init__(
              table=table,
              source_name_key=source_name_key,
              source_name_alias=source_name_alias,
          )
  
          self.extended_sources_table = Table.read(filename, hdu="ExtendedSources")
          self.rois = Table.read(filename, hdu="ROIs")
          self.energy_bounds_table = Table.read(filename, hdu="EnergyBounds")
  
files: gammapy/catalog/fermi.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  """Fermi catalog and source classes."""
  import abc
  import warnings
  import numpy as np
  import astropy.units as u
  from astropy.table import Column, Table
  from astropy.time import Time
  from astropy.wcs import FITSFixedWarning
  from gammapy.estimators import FluxPoints, LightCurve
  from gammapy.modeling.models import (
      DiskSpatialModel,
      GaussianSpatialModel,
      Model,
      PointSpatialModel,
      SkyModel,
      TemplateSpatialModel,
  )
  from gammapy.utils.gauss import Gauss2DPDF
  from gammapy.utils.scripts import make_path
  from gammapy.utils.table import table_standardise_units_inplace
  from .core import SourceCatalog, SourceCatalogObject
  
  __all__ = [
      "SourceCatalogObject4FGL",
      "SourceCatalogObject3FGL",
      "SourceCatalogObject2FHL",
      "SourceCatalogObject3FHL",
      "SourceCatalog4FGL",
      "SourceCatalog3FGL",
      "SourceCatalog2FHL",
      "SourceCatalog3FHL",
  ]
  
  
  def compute_flux_points_ul(quantity, quantity_errp):
      """Compute UL value for fermi flux points.
  
      See https://arxiv.org/pdf/1501.02003.pdf (page 30)
      """
      return 2 * quantity_errp + quantity
  
  
  class SourceCatalogObjectFermiBase(SourceCatalogObject, abc.ABC):
      """Base class for Fermi-LAT catalogs."""
  
      asso = ["ASSOC1", "ASSOC2", "ASSOC_TEV", "ASSOC_GAM1", "ASSOC_GAM2", "ASSOC_GAM3"]
  
      def __str__(self):
          return self.info()
  
      def info(self, info="all"):
          """Summary info string.
  
          Parameters
          ----------
          info : {'all', 'basic', 'more', 'position', 'spectral','lightcurve'}
              Comma separated list of options
          """
          if info == "all":
              info = "basic,more,position,spectral,lightcurve"
  
          ss = ""
          ops = info.split(",")
          if "basic" in ops:
              ss += self._info_basic()
          if "more" in ops:
              ss += self._info_more()
          if "position" in ops:
              ss += self._info_position()
              if not self.is_pointlike:
                  ss += self._info_morphology()
          if "spectral" in ops:
              ss += self._info_spectral_fit()
              ss += self._info_spectral_points()
          if "lightcurve" in ops:
              ss += self._info_lightcurve()
          return ss
  
      def _info_basic(self):
          d = self.data
          keys = self.asso
          ss = "\n*** Basic info ***\n\n"
          ss += "Catalog row index (zero-based) : {}\n".format(self.row_index)
          ss += "{:<20s} : {}\n".format("Source name", self.name)
          if "Extended_Source_Name" in d:
              ss += "{:<20s} : {}\n".format("Extended name", d["Extended_Source_Name"])
  
          def get_nonentry_keys(keys):
              vals = [d[_].strip() for _ in keys]
              return ", ".join([_ for _ in vals if _ != ""])
  
          associations = get_nonentry_keys(keys)
          ss += "{:<16s} : {}\n".format("Associations", associations)
          try:
              ss += "{:<16s} : {:.3f}\n".format("ASSOC_PROB_BAY", d["ASSOC_PROB_BAY"])
              ss += "{:<16s} : {:.3f}\n".format("ASSOC_PROB_LR", d["ASSOC_PROB_LR"])
          except (KeyError):
              pass
          try:
              ss += "{:<16s} : {}\n".format("Class1", d["CLASS1"])
          except (KeyError):
              ss += "{:<16s} : {}\n".format("Class", d["CLASS"])
          try:
              ss += "{:<16s} : {}\n".format("Class2", d["CLASS2"])
          except (KeyError):
              pass
          ss += "{:<16s} : {}\n".format("TeVCat flag", d.get("TEVCAT_FLAG", "N/A"))
          return ss
  
      @abc.abstractmethod
      def _info_more(self):
          pass
  
      def _info_position(self):
          d = self.data
          ss = "\n*** Position info ***\n\n"
          ss += "{:<20s} : {:.3f}\n".format("RA", d["RAJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("DEC", d["DEJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("GLON", d["GLON"])
          ss += "{:<20s} : {:.3f}\n".format("GLAT", d["GLAT"])
  
          ss += "\n"
          ss += "{:<20s} : {:.4f}\n".format("Semimajor (68%)", d["Conf_68_SemiMajor"])
          ss += "{:<20s} : {:.4f}\n".format("Semiminor (68%)", d["Conf_68_SemiMinor"])
          ss += "{:<20s} : {:.2f}\n".format("Position angle (68%)", d["Conf_68_PosAng"])
          ss += "{:<20s} : {:.4f}\n".format("Semimajor (95%)", d["Conf_95_SemiMajor"])
          ss += "{:<20s} : {:.4f}\n".format("Semiminor (95%)", d["Conf_95_SemiMinor"])
          ss += "{:<20s} : {:.2f}\n".format("Position angle (95%)", d["Conf_95_PosAng"])
          ss += "{:<20s} : {:.0f}\n".format("ROI number", d["ROI_num"])
          return ss
  
      def _info_morphology(self):
          e = self.data_extended
          ss = "\n*** Extended source information ***\n\n"
          ss += "{:<16s} : {}\n".format("Model form", e["Model_Form"])
          ss += "{:<16s} : {:.4f}\n".format("Model semimajor", e["Model_SemiMajor"])
          ss += "{:<16s} : {:.4f}\n".format("Model semiminor", e["Model_SemiMinor"])
          ss += "{:<16s} : {:.4f}\n".format("Position angle", e["Model_PosAng"])
          try:
              ss += "{:<16s} : {}\n".format("Spatial function", e["Spatial_Function"])
          except KeyError:
              pass
          ss += "{:<16s} : {}\n\n".format("Spatial filename", e["Spatial_Filename"])
          return ss
  
      def _info_spectral_fit(self):
          return "\n"
  
      def _info_spectral_points(self):
          ss = "\n*** Spectral points ***\n\n"
          lines = self.flux_points.table_formatted.pformat(max_width=-1, max_lines=-1)
          ss += "\n".join(lines)
          return ss
  
      def _info_lightcurve(self):
          return "\n"
  
      @property
      def is_pointlike(self):
          return self.data["Extended_Source_Name"].strip() == ""
  
      # FIXME: this should be renamed `set_position_error`,
      # and `phi_0` isn't filled correctly, other parameters missing
      # see https://github.com/gammapy/gammapy/pull/2533#issuecomment-553329049
      def _set_spatial_errors(self, model):
          d = self.data
  
          if "Pos_err_68" in d:
              percent = 0.68
              semi_minor = d["Pos_err_68"]
              semi_major = d["Pos_err_68"]
              phi_0 = 0.0
          else:
              percent = 0.95
              semi_minor = d["Conf_95_SemiMinor"]
              semi_major = d["Conf_95_SemiMajor"]
              phi_0 = d["Conf_95_PosAng"]
  
          if np.isnan(phi_0):
              phi_0 = 0.0 * u.deg
  
          scale_1sigma = Gauss2DPDF().containment_radius(percent)
          lat_err = semi_major / scale_1sigma
          lon_err = semi_minor / scale_1sigma / np.cos(d["DEJ2000"])
  
          if model.tag != "TemplateSpatialModel":
              model.parameters["lon_0"].error = lon_err
              model.parameters["lat_0"].error = lat_err
              model.phi_0 = phi_0
  
      def sky_model(self):
          """Sky model (`~gammapy.modeling.models.SkyModel`)."""
          return SkyModel(
              spatial_model=self.spatial_model(),
              spectral_model=self.spectral_model(),
              name=self.name,
          )
  
  
  class SourceCatalogObject4FGL(SourceCatalogObjectFermiBase):
      """One source from the Fermi-LAT 4FGL catalog.
  
      Catalog is represented by `~gammapy.catalog.SourceCatalog4FGL`.
      """
  
      asso = [
          "ASSOC1",
          "ASSOC2",
          "ASSOC_TEV",
          "ASSOC_FGL",
          "ASSOC_FHL",
          "ASSOC_GAM1",
          "ASSOC_GAM2",
          "ASSOC_GAM3",
      ]
      _ebounds = u.Quantity([50, 100, 300, 1000, 3000, 10000, 30000, 300000], "MeV")
  
      def _info_more(self):
          d = self.data
          ss = "\n*** Other info ***\n\n"
          fmt = "{:<32s} : {:.3f}\n"
          ss += fmt.format("Significance (100 MeV - 1 TeV)", d["Signif_Avg"])
          ss += "{:<32s} : {:.1f}\n".format("Npred", d["Npred"])
          ss += "\n{:<20s} : {}\n".format("Other flags", d["Flags"])
          return ss
  
      def _info_spectral_fit(self):
          d = self.data
          spec_type = d["SpectrumType"].strip()
  
          ss = "\n*** Spectral info ***\n\n"
  
          ss += "{:<45s} : {}\n".format("Spectrum type", d["SpectrumType"])
          fmt = "{:<45s} : {:.3f}\n"
          ss += fmt.format("Detection significance (100 MeV - 1 TeV)", d["Signif_Avg"])
  
          if spec_type == "PowerLaw":
              tag = "PL"
          elif spec_type == "LogParabola":
              tag = "LP"
              ss += "{:<45s} : {:.4f} +- {:.5f}\n".format(
                  "beta", d["LP_beta"], d["Unc_LP_beta"]
              )
              ss += "{:<45s} : {:.1f}\n".format("Significance curvature", d["LP_SigCurv"])
  
          elif spec_type == "PLSuperExpCutoff":
              tag = "PLEC"
              fmt = "{:<45s} : {} +- {}\n"
              ss += fmt.format(
                  "Exponential factor", d["PLEC_Expfactor"], d["Unc_PLEC_Expfactor"]
              )
              ss += "{:<45s} : {} +- {}\n".format(
                  "Super-exponential cutoff index",
                  d["PLEC_Exp_Index"],
                  d["Unc_PLEC_Exp_Index"],
              )
              ss += "{:<45s} : {:.1f}\n".format(
                  "Significance curvature", d["PLEC_SigCurv"]
              )
  
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          ss += "{:<45s} : {:.0f} {}\n".format(
              "Pivot energy", d["Pivot_Energy"].value, d["Pivot_Energy"].unit
          )
  
          fmt = "{:<45s} : {:.3f} +- {:.3f}\n"
          ss += fmt.format(
              "Spectral index", d[tag + "_Index"], d["Unc_" + tag + "_Index"]
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Flux Density at pivot energy",
              d[tag + "_Flux_Density"].value,
              d["Unc_" + tag + "_Flux_Density"].value,
              "cm-2 MeV-1 s-1",
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Integral flux (1 - 100 GeV)",
              d["Flux1000"].value,
              d["Unc_Flux1000"].value,
              "cm-2 s-1",
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Energy flux (100 MeV - 100 GeV)",
              d["Energy_Flux100"].value,
              d["Unc_Energy_Flux100"].value,
              "erg cm-2 s-1",
          )
  
          return ss
  
      def _info_lightcurve(self):
          d = self.data
          ss = "\n*** Lightcurve info ***\n\n"
          ss += "Lightcurve measured in the energy band: 100 MeV - 100 GeV\n\n"
  
          ss += "{:<15s} : {:.3f}\n".format("Variability index", d["Variability_Index"])
  
          if np.isfinite(d["Flux_Peak"]):
              ss += "{:<40s} : {:.3f}\n".format(
                  "Significance peak (100 MeV - 100 GeV)", d["Signif_Peak"]
              )
  
              fmt = "{:<40s} : {:.3} +- {:.3} cm^-2 s^-1\n"
              ss += fmt.format(
                  "Integral flux peak (100 MeV - 100 GeV)",
                  d["Flux_Peak"].value,
                  d["Unc_Flux_Peak"].value,
              )
  
              # TODO: give time as UTC string, not MET
              ss += "{:<40s} : {:.3} s (Mission elapsed time)\n".format(
                  "Time peak", d["Time_Peak"].value
              )
              peak_interval = d["Peak_Interval"].to_value("day")
              ss += "{:<40s} : {:.3} day\n".format("Peak interval", peak_interval)
          else:
              ss += "\nNo peak measured for this source.\n"
  
          # TODO: Add a lightcurve table with d['Flux_History'] and d['Unc_Flux_History']
  
          return ss
  
      def spatial_model(self):
          """Spatial model (`~gammapy.modeling.models.SpatialModel`)."""
          d = self.data
          ra = d["RAJ2000"]
          dec = d["DEJ2000"]
  
          if self.is_pointlike:
              model = PointSpatialModel(lon_0=ra, lat_0=dec, frame="icrs")
          else:
              de = self.data_extended
              morph_type = de["Model_Form"].strip()
              e = (1 - (de["Model_SemiMinor"] / de["Model_SemiMajor"]) ** 2.0) ** 0.5
              sigma = de["Model_SemiMajor"]
              phi = de["Model_PosAng"]
              if morph_type == "Disk":
                  r_0 = de["Model_SemiMajor"]
                  model = DiskSpatialModel(
                      lon_0=ra, lat_0=dec, r_0=r_0, e=e, phi=phi, frame="icrs"
                  )
              elif morph_type in ["Map", "Ring", "2D Gaussian x2"]:
                  filename = de["Spatial_Filename"].strip()
                  path = make_path(
                      "$GAMMAPY_DATA/catalogs/fermi/LAT_extended_sources_8years/Templates/"
                  )
                  with warnings.catch_warnings():  # ignore FITS units warnings
                      warnings.simplefilter("ignore", FITSFixedWarning)
                      model = TemplateSpatialModel.read(path / filename)
              elif morph_type == "2D Gaussian":
                  model = GaussianSpatialModel(
                      lon_0=ra, lat_0=dec, sigma=sigma, e=e, phi=phi, frame="icrs"
                  )
              else:
                  raise ValueError(f"Invalid spatial model: {morph_type!r}")
          self._set_spatial_errors(model)
          return model
  
      def spectral_model(self):
          """Best fit spectral model (`~gammapy.modeling.models.SpectralModel`)."""
          spec_type = self.data["SpectrumType"].strip()
  
          if spec_type == "PowerLaw":
              tag = "PowerLawSpectralModel"
              pars = {
                  "reference": self.data["Pivot_Energy"],
                  "amplitude": self.data["PL_Flux_Density"],
                  "index": self.data["PL_Index"],
              }
              errs = {
                  "amplitude": self.data["Unc_PL_Flux_Density"],
                  "index": self.data["Unc_PL_Index"],
              }
          elif spec_type == "LogParabola":
              tag = "LogParabolaSpectralModel"
              pars = {
                  "reference": self.data["Pivot_Energy"],
                  "amplitude": self.data["LP_Flux_Density"],
                  "alpha": self.data["LP_Index"],
                  "beta": self.data["LP_beta"],
              }
              errs = {
                  "amplitude": self.data["Unc_LP_Flux_Density"],
                  "alpha": self.data["Unc_LP_Index"],
                  "beta": self.data["Unc_LP_beta"],
              }
          elif spec_type == "PLSuperExpCutoff":
              tag = "SuperExpCutoffPowerLaw4FGLSpectralModel"
              pars = {
                  "reference": self.data["Pivot_Energy"],
                  "amplitude": self.data["PLEC_Flux_Density"],
                  "index_1": self.data["PLEC_Index"],
                  "index_2": self.data["PLEC_Exp_Index"],
                  "expfactor": self.data["PLEC_Expfactor"],
              }
              errs = {
                  "amplitude": self.data["Unc_PLEC_Flux_Density"],
                  "index_1": self.data["Unc_PLEC_Index"],
                  "index_2": np.nan_to_num(self.data["Unc_PLEC_Exp_Index"]),
                  "expfactor": self.data["Unc_PLEC_Expfactor"],
              }
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          model = Model.create(tag, **pars)
  
          for name, value in errs.items():
              model.parameters[name].error = value
  
          return model
  
      @property
      def flux_points(self):
          """Flux points (`~gammapy.spectrum.FluxPoints`)."""
          table = Table()
          table.meta["SED_TYPE"] = "flux"
  
          table["e_min"] = self._ebounds[:-1]
          table["e_max"] = self._ebounds[1:]
  
          flux = self._get_flux_values("Flux_Band")
          flux_err = self._get_flux_values("Unc_Flux_Band")
          table["flux"] = flux
          table["flux_errn"] = np.abs(flux_err[:, 0])
          table["flux_errp"] = flux_err[:, 1]
  
          nuFnu = self._get_flux_values("nuFnu_Band", "erg cm-2 s-1")
          table["e2dnde"] = nuFnu
          table["e2dnde_errn"] = np.abs(nuFnu * flux_err[:, 0] / flux)
          table["e2dnde_errp"] = nuFnu * flux_err[:, 1] / flux
  
          is_ul = np.isnan(table["flux_errn"])
          table["is_ul"] = is_ul
  
          # handle upper limits
          table["flux_ul"] = np.nan * flux_err.unit
          flux_ul = compute_flux_points_ul(table["flux"], table["flux_errp"])
          table["flux_ul"][is_ul] = flux_ul[is_ul]
  
          # handle upper limits
          table["e2dnde_ul"] = np.nan * nuFnu.unit
          e2dnde_ul = compute_flux_points_ul(table["e2dnde"], table["e2dnde_errp"])
          table["e2dnde_ul"][is_ul] = e2dnde_ul[is_ul]
  
          # Square root of test statistic
          table["sqrt_TS"] = self.data["Sqrt_TS_Band"]
          return FluxPoints(table)
  
      def _get_flux_values(self, prefix, unit="cm-2 s-1"):
          values = self.data[prefix]
          return u.Quantity(values, unit)
  
      @property
      def lightcurve(self):
          """Lightcurve (`~gammapy.time.LightCurve`)."""
          flux = self.data["Flux_History"]
  
          # Flux error is given as asymmetric high/low
          flux_errn = -self.data["Unc_Flux_History"][:, 0]
          flux_errp = self.data["Unc_Flux_History"][:, 1]
  
          # Really the time binning is stored in a separate HDU in the FITS
          # catalog file called `Hist_Start`, with a single column `Hist_Start`
          # giving the time binning in MET (mission elapsed time)
          # This is not available here for now.
          # TODO: read that info in `SourceCatalog3FGL` and pass it down to the
          # `SourceCatalogObject3FGL` object somehow.
  
          # For now, we just hard-code the start and stop time and assume
          # equally-spaced time intervals. This is roughly correct,
          # for plotting the difference doesn't matter, only for analysis
          time_start = Time("2008-08-04T15:43:36.0000")
          time_end = Time("2016-08-02T05:44:11.9999")
          n_points = len(flux)
          time_step = (time_end - time_start) / n_points
          time_bounds = time_start + np.arange(n_points + 1) * time_step
  
          table = Table(
              [
                  Column(time_bounds[:-1].utc.mjd, "time_min"),
                  Column(time_bounds[1:].utc.mjd, "time_max"),
                  Column(flux, "flux"),
                  Column(flux_errp, "flux_errp"),
                  Column(flux_errn, "flux_errn"),
              ]
          )
          return LightCurve(table)
  
  
  class SourceCatalogObject3FGL(SourceCatalogObjectFermiBase):
      """One source from the Fermi-LAT 3FGL catalog.
  
      Catalog is represented by `~gammapy.catalog.SourceCatalog3FGL`.
      """
  
      _ebounds = u.Quantity([100, 300, 1000, 3000, 10000, 100000], "MeV")
      _ebounds_suffix = ["100_300", "300_1000", "1000_3000", "3000_10000", "10000_100000"]
      energy_range = u.Quantity([100, 100000], "MeV")
      """Energy range used for the catalog.
  
      Paper says that analysis uses data up to 300 GeV,
      but results are all quoted up to 100 GeV only to
      be consistent with previous catalogs.
      """
  
      def _info_more(self):
          d = self.data
          ss = "\n*** Other info ***\n\n"
          ss += "{:<20s} : {}\n".format("Other flags", d["Flags"])
          return ss
  
      def _info_spectral_fit(self):
          d = self.data
          spec_type = d["SpectrumType"].strip()
  
          ss = "\n*** Spectral info ***\n\n"
  
          ss += "{:<45s} : {}\n".format("Spectrum type", d["SpectrumType"])
          fmt = "{:<45s} : {:.3f}\n"
          ss += fmt.format("Detection significance (100 MeV - 300 GeV)", d["Signif_Avg"])
          ss += "{:<45s} : {:.1f}\n".format("Significance curvature", d["Signif_Curve"])
  
          if spec_type == "PowerLaw":
              pass
          elif spec_type == "LogParabola":
              ss += "{:<45s} : {} +- {}\n".format("beta", d["beta"], d["Unc_beta"])
          elif spec_type in ["PLExpCutoff", "PlSuperExpCutoff"]:
              fmt = "{:<45s} : {:.0f} +- {:.0f} {}\n"
              ss += fmt.format(
                  "Cutoff energy",
                  d["Cutoff"].value,
                  d["Unc_Cutoff"].value,
                  d["Cutoff"].unit,
              )
          elif spec_type == "PLSuperExpCutoff":
              ss += "{:<45s} : {} +- {}\n".format(
                  "Super-exponential cutoff index", d["Exp_Index"], d["Unc_Exp_Index"]
              )
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          ss += "{:<45s} : {:.0f} {}\n".format(
              "Pivot energy", d["Pivot_Energy"].value, d["Pivot_Energy"].unit
          )
  
          ss += "{:<45s} : {:.3f}\n".format(
              "Power law spectral index", d["PowerLaw_Index"]
          )
  
          fmt = "{:<45s} : {:.3f} +- {:.3f}\n"
          ss += fmt.format("Spectral index", d["Spectral_Index"], d["Unc_Spectral_Index"])
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Flux Density at pivot energy",
              d["Flux_Density"].value,
              d["Unc_Flux_Density"].value,
              "cm-2 MeV-1 s-1",
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Integral flux (1 - 100 GeV)",
              d["Flux1000"].value,
              d["Unc_Flux1000"].value,
              "cm-2 s-1",
          )
  
          fmt = "{:<45s} : {:.3} +- {:.3} {}\n"
          ss += fmt.format(
              "Energy flux (100 MeV - 100 GeV)",
              d["Energy_Flux100"].value,
              d["Unc_Energy_Flux100"].value,
              "erg cm-2 s-1",
          )
  
          return ss
  
      def _info_lightcurve(self):
          d = self.data
          ss = "\n*** Lightcurve info ***\n\n"
          ss += "Lightcurve measured in the energy band: 100 MeV - 100 GeV\n\n"
  
          ss += "{:<15s} : {:.3f}\n".format("Variability index", d["Variability_Index"])
  
          if np.isfinite(d["Flux_Peak"]):
              ss += "{:<40s} : {:.3f}\n".format(
                  "Significance peak (100 MeV - 100 GeV)", d["Signif_Peak"]
              )
  
              fmt = "{:<40s} : {:.3} +- {:.3} cm^-2 s^-1\n"
              ss += fmt.format(
                  "Integral flux peak (100 MeV - 100 GeV)",
                  d["Flux_Peak"].value,
                  d["Unc_Flux_Peak"].value,
              )
  
              # TODO: give time as UTC string, not MET
              ss += "{:<40s} : {:.3} s (Mission elapsed time)\n".format(
                  "Time peak", d["Time_Peak"].value
              )
              peak_interval = d["Peak_Interval"].to_value("day")
              ss += "{:<40s} : {:.3} day\n".format("Peak interval", peak_interval)
          else:
              ss += "\nNo peak measured for this source.\n"
  
          # TODO: Add a lightcurve table with d['Flux_History'] and d['Unc_Flux_History']
  
          return ss
  
      def spectral_model(self):
          """Best fit spectral model (`~gammapy.modeling.models.SpectralModel`)."""
          spec_type = self.data["SpectrumType"].strip()
  
          if spec_type == "PowerLaw":
              tag = "PowerLawSpectralModel"
              pars = {
                  "amplitude": self.data["Flux_Density"],
                  "reference": self.data["Pivot_Energy"],
                  "index": self.data["Spectral_Index"],
              }
              errs = {
                  "amplitude": self.data["Unc_Flux_Density"],
                  "index": self.data["Unc_Spectral_Index"],
              }
          elif spec_type == "PLExpCutoff":
              tag = "ExpCutoffPowerLaw3FGLSpectralModel"
              pars = {
                  "amplitude": self.data["Flux_Density"],
                  "reference": self.data["Pivot_Energy"],
                  "index": self.data["Spectral_Index"],
                  "ecut": self.data["Cutoff"],
              }
              errs = {
                  "amplitude": self.data["Unc_Flux_Density"],
                  "index": self.data["Unc_Spectral_Index"],
                  "ecut": self.data["Unc_Cutoff"],
              }
          elif spec_type == "LogParabola":
              tag = "LogParabolaSpectralModel"
              pars = {
                  "amplitude": self.data["Flux_Density"],
                  "reference": self.data["Pivot_Energy"],
                  "alpha": self.data["Spectral_Index"],
                  "beta": self.data["beta"],
              }
              errs = {
                  "amplitude": self.data["Unc_Flux_Density"],
                  "alpha": self.data["Unc_Spectral_Index"],
                  "beta": self.data["Unc_beta"],
              }
          elif spec_type == "PLSuperExpCutoff":
              tag = "SuperExpCutoffPowerLaw3FGLSpectralModel"
              pars = {
                  "amplitude": self.data["Flux_Density"],
                  "reference": self.data["Pivot_Energy"],
                  "index_1": self.data["Spectral_Index"],
                  "index_2": self.data["Exp_Index"],
                  "ecut": self.data["Cutoff"],
              }
              errs = {
                  "amplitude": self.data["Unc_Flux_Density"],
                  "index_1": self.data["Unc_Spectral_Index"],
                  "index_2": self.data["Unc_Exp_Index"],
                  "ecut": self.data["Unc_Cutoff"],
              }
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          model = Model.create(tag, **pars)
  
          for name, value in errs.items():
              model.parameters[name].error = value
  
          return model
  
      def spatial_model(self):
          """Spatial model (`~gammapy.modeling.models.SpatialModel`)."""
          d = self.data
          ra = d["RAJ2000"]
          dec = d["DEJ2000"]
  
          if self.is_pointlike:
              model = PointSpatialModel(lon_0=ra, lat_0=dec, frame="icrs")
          else:
              de = self.data_extended
              morph_type = de["Model_Form"].strip()
              e = (1 - (de["Model_SemiMinor"] / de["Model_SemiMajor"]) ** 2.0) ** 0.5
              sigma = de["Model_SemiMajor"]
              phi = de["Model_PosAng"]
              if morph_type == "Disk":
                  r_0 = de["Model_SemiMajor"]
                  model = DiskSpatialModel(
                      lon_0=ra, lat_0=dec, r_0=r_0, e=e, phi=phi, frame="icrs"
                  )
              elif morph_type in ["Map", "Ring", "2D Gaussian x2"]:
                  filename = de["Spatial_Filename"].strip()
                  path = make_path(
                      "$GAMMAPY_DATA/catalogs/fermi/Extended_archive_v15/Templates/"
                  )
                  model = TemplateSpatialModel.read(path / filename)
              elif morph_type == "2D Gaussian":
                  model = GaussianSpatialModel(
                      lon_0=ra, lat_0=dec, sigma=sigma, e=e, phi=phi, frame="icrs"
                  )
              else:
                  raise ValueError(f"Invalid spatial model: {morph_type!r}")
          self._set_spatial_errors(model)
          return model
  
      @property
      def flux_points(self):
          """Flux points (`~gammapy.spectrum.FluxPoints`)."""
          table = Table()
          table.meta["SED_TYPE"] = "flux"
  
          table["e_min"] = self._ebounds[:-1]
          table["e_max"] = self._ebounds[1:]
  
          flux = self._get_flux_values("Flux")
          flux_err = self._get_flux_values("Unc_Flux")
          table["flux"] = flux
          table["flux_errn"] = np.abs(flux_err[:, 0])
          table["flux_errp"] = flux_err[:, 1]
  
          nuFnu = self._get_flux_values("nuFnu", "erg cm-2 s-1")
          table["e2dnde"] = nuFnu
          table["e2dnde_errn"] = np.abs(nuFnu * flux_err[:, 0] / flux)
          table["e2dnde_errp"] = nuFnu * flux_err[:, 1] / flux
  
          is_ul = np.isnan(table["flux_errn"])
          table["is_ul"] = is_ul
  
          # handle upper limits
          table["flux_ul"] = np.nan * flux_err.unit
          flux_ul = compute_flux_points_ul(table["flux"], table["flux_errp"])
          table["flux_ul"][is_ul] = flux_ul[is_ul]
  
          # handle upper limits
          table["e2dnde_ul"] = np.nan * nuFnu.unit
          e2dnde_ul = compute_flux_points_ul(table["e2dnde"], table["e2dnde_errp"])
          table["e2dnde_ul"][is_ul] = e2dnde_ul[is_ul]
  
          # Square root of test statistic
          table["sqrt_TS"] = [self.data["Sqrt_TS" + _] for _ in self._ebounds_suffix]
          return FluxPoints(table)
  
      def _get_flux_values(self, prefix, unit="cm-2 s-1"):
          values = [self.data[prefix + _] for _ in self._ebounds_suffix]
          return u.Quantity(values, unit)
  
      @property
      def lightcurve(self):
          """Lightcurve (`~gammapy.time.LightCurve`)."""
          flux = self.data["Flux_History"]
  
          # Flux error is given as asymmetric high/low
          flux_errn = -self.data["Unc_Flux_History"][:, 0]
          flux_errp = self.data["Unc_Flux_History"][:, 1]
  
          # Really the time binning is stored in a separate HDU in the FITS
          # catalog file called `Hist_Start`, with a single column `Hist_Start`
          # giving the time binning in MET (mission elapsed time)
          # This is not available here for now.
          # TODO: read that info in `SourceCatalog3FGL` and pass it down to the
          # `SourceCatalogObject3FGL` object somehow.
  
          # For now, we just hard-code the start and stop time and assume
          # equally-spaced time intervals. This is roughly correct,
          # for plotting the difference doesn't matter, only for analysis
          time_start = Time("2008-08-02T00:33:19")
          time_end = Time("2012-07-31T22:45:47")
          n_points = len(flux)
          time_step = (time_end - time_start) / n_points
          time_bounds = time_start + np.arange(n_points + 1) * time_step
  
          table = Table(
              [
                  Column(time_bounds[:-1].utc.mjd, "time_min"),
                  Column(time_bounds[1:].utc.mjd, "time_max"),
                  Column(flux, "flux"),
                  Column(flux_errp, "flux_errp"),
                  Column(flux_errn, "flux_errn"),
              ]
          )
          return LightCurve(table)
  
  
  class SourceCatalogObject2FHL(SourceCatalogObjectFermiBase):
      """One source from the Fermi-LAT 2FHL catalog.
  
      Catalog is represented by `~gammapy.catalog.SourceCatalog2FHL`.
      """
  
      asso = ["ASSOC", "3FGL_Name", "1FHL_Name", "TeVCat_Name"]
      _ebounds = u.Quantity([50, 171, 585, 2000], "GeV")
      _ebounds_suffix = ["50_171", "171_585", "585_2000"]
      energy_range = u.Quantity([0.05, 2], "TeV")
      """Energy range used for the catalog."""
  
      def _info_more(self):
          d = self.data
          ss = "\n*** Other info ***\n\n"
          fmt = "{:<32s} : {:.3f}\n"
          ss += fmt.format("Test statistic (50 GeV - 2 TeV)", d["TS"])
          return ss
  
      def _info_position(self):
          d = self.data
          ss = "\n*** Position info ***\n\n"
          ss += "{:<20s} : {:.3f}\n".format("RA", d["RAJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("DEC", d["DEJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("GLON", d["GLON"])
          ss += "{:<20s} : {:.3f}\n".format("GLAT", d["GLAT"])
  
          ss += "\n"
          ss += "{:<20s} : {:.4f}\n".format("Error on position (68%)", d["Pos_err_68"])
          ss += "{:<20s} : {:.0f}\n".format("ROI number", d["ROI"])
          return ss
  
      def _info_spectral_fit(self):
          d = self.data
  
          ss = "\n*** Spectral fit info ***\n\n"
  
          fmt = "{:<32s} : {:.3f} +- {:.3f}\n"
          ss += fmt.format(
              "Power-law spectral index", d["Spectral_Index"], d["Unc_Spectral_Index"]
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Integral flux (50 GeV - 2 TeV)",
              d["Flux50"].value,
              d["Unc_Flux50"].value,
              "cm-2 s-1",
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Energy flux (50 GeV - 2 TeV)",
              d["Energy_Flux50"].value,
              d["Unc_Energy_Flux50"].value,
              "erg cm-2 s-1",
          )
  
          return ss
  
      @property
      def is_pointlike(self):
          return self.data["Source_Name"].strip()[-1] != "e"
  
      def spatial_model(self):
          """Spatial model (`~gammapy.modeling.models.SpatialModel`)."""
          d = self.data
          ra = d["RAJ2000"]
          dec = d["DEJ2000"]
  
          if self.is_pointlike:
              model = PointSpatialModel(lon_0=ra, lat_0=dec, frame="icrs")
          else:
              de = self.data_extended
              morph_type = de["Model_Form"].strip()
              e = (1 - (de["Model_SemiMinor"] / de["Model_SemiMajor"]) ** 2.0) ** 0.5
              sigma = de["Model_SemiMajor"]
              phi = de["Model_PosAng"]
              if morph_type in ["Disk", "Elliptical Disk"]:
                  r_0 = de["Model_SemiMajor"]
                  model = DiskSpatialModel(
                      lon_0=ra, lat_0=dec, r_0=r_0, e=e, phi=phi, frame="icrs"
                  )
              elif morph_type in ["Map", "Ring", "2D Gaussian x2"]:
                  filename = de["Spatial_Filename"].strip()
                  path = make_path(
                      "$GAMMAPY_DATA/catalogs/fermi/Extended_archive_v15/Templates/"
                  )
                  return TemplateSpatialModel.read(path / filename)
              elif morph_type in ["2D Gaussian", "Elliptical 2D Gaussian"]:
                  model = GaussianSpatialModel(
                      lon_0=ra, lat_0=dec, sigma=sigma, e=e, phi=phi, frame="icrs"
                  )
              else:
                  raise ValueError(f"Invalid spatial model: {morph_type!r}")
  
          self._set_spatial_errors(model)
          return model
  
      def spectral_model(self):
          """Best fit spectral model (`~gammapy.modeling.models.SpectralModel`)."""
          tag = "PowerLaw2SpectralModel"
          pars = {
              "amplitude": self.data["Flux50"],
              "emin": self.energy_range[0],
              "emax": self.energy_range[1],
              "index": self.data["Spectral_Index"],
          }
          errs = {
              "amplitude": self.data["Unc_Flux50"],
              "index": self.data["Unc_Spectral_Index"],
          }
  
          model = Model.create(tag, **pars)
  
          for name, value in errs.items():
              model.parameters[name].error = value
  
          return model
  
      @property
      def flux_points(self):
          """Integral flux points (`~gammapy.spectrum.FluxPoints`)."""
          table = Table()
          table.meta["SED_TYPE"] = "flux"
          table["e_min"] = self._ebounds[:-1]
          table["e_max"] = self._ebounds[1:]
          table["flux"] = self._get_flux_values("Flux")
          flux_err = self._get_flux_values("Unc_Flux")
          table["flux_errn"] = np.abs(flux_err[:, 0])
          table["flux_errp"] = flux_err[:, 1]
  
          # handle upper limits
          is_ul = np.isnan(table["flux_errn"])
          table["is_ul"] = is_ul
          table["flux_ul"] = np.nan * flux_err.unit
          flux_ul = compute_flux_points_ul(table["flux"], table["flux_errp"])
          table["flux_ul"][is_ul] = flux_ul[is_ul]
          return FluxPoints(table)
  
      def _get_flux_values(self, prefix, unit="cm-2 s-1"):
          values = [self.data[prefix + _ + "GeV"] for _ in self._ebounds_suffix]
          return u.Quantity(values, unit)
  
  
  class SourceCatalogObject3FHL(SourceCatalogObjectFermiBase):
      """One source from the Fermi-LAT 3FHL catalog.
  
      Catalog is represented by `~gammapy.catalog.SourceCatalog3FHL`.
      """
  
      asso = ["ASSOC1", "ASSOC2", "ASSOC_TEV", "ASSOC_GAM"]
      energy_range = u.Quantity([0.01, 2], "TeV")
      """Energy range used for the catalog."""
  
      _ebounds = u.Quantity([10, 20, 50, 150, 500, 2000], "GeV")
  
      def _info_position(self):
          d = self.data
          ss = "\n*** Position info ***\n\n"
          ss += "{:<20s} : {:.3f}\n".format("RA", d["RAJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("DEC", d["DEJ2000"])
          ss += "{:<20s} : {:.3f}\n".format("GLON", d["GLON"])
          ss += "{:<20s} : {:.3f}\n".format("GLAT", d["GLAT"])
  
          # TODO: All sources are non-elliptical; just give one number for radius?
          ss += "\n"
          ss += "{:<20s} : {:.4f}\n".format("Semimajor (95%)", d["Conf_95_SemiMajor"])
          ss += "{:<20s} : {:.4f}\n".format("Semiminor (95%)", d["Conf_95_SemiMinor"])
          ss += "{:<20s} : {:.2f}\n".format("Position angle (95%)", d["Conf_95_PosAng"])
          ss += "{:<20s} : {:.0f}\n".format("ROI number", d["ROI_num"])
  
          return ss
  
      def _info_spectral_fit(self):
          d = self.data
          spec_type = d["SpectrumType"].strip()
  
          ss = "\n*** Spectral fit info ***\n\n"
  
          ss += "{:<32s} : {}\n".format("Spectrum type", d["SpectrumType"])
          ss += "{:<32s} : {:.1f}\n".format("Significance curvature", d["Signif_Curve"])
  
          # Power-law parameters are always given; give in any case
          fmt = "{:<32s} : {:.3f} +- {:.3f}\n"
          ss += fmt.format(
              "Power-law spectral index", d["PowerLaw_Index"], d["Unc_PowerLaw_Index"]
          )
  
          if spec_type == "PowerLaw":
              pass
          elif spec_type == "LogParabola":
              fmt = "{:<32s} : {:.3f} +- {:.3f}\n"
              ss += fmt.format(
                  "LogParabolaSpectralModel spectral index",
                  d["Spectral_Index"],
                  d["Unc_Spectral_Index"],
              )
  
              ss += "{:<32s} : {:.3f} +- {:.3f}\n".format(
                  "LogParabolaSpectralModel beta", d["beta"], d["Unc_beta"]
              )
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          ss += "{:<32s} : {:.1f} {}\n".format(
              "Pivot energy", d["Pivot_Energy"].value, d["Pivot_Energy"].unit
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Flux Density at pivot energy",
              d["Flux_Density"].value,
              d["Unc_Flux_Density"].value,
              "cm-2 GeV-1 s-1",
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Integral flux (10 GeV - 1 TeV)",
              d["Flux"].value,
              d["Unc_Flux"].value,
              "cm-2 s-1",
          )
  
          ss += "{:<32s} : {:.3} +- {:.3} {}\n".format(
              "Energy flux (10 GeV - TeV)",
              d["Energy_Flux"].value,
              d["Unc_Energy_Flux"].value,
              "erg cm-2 s-1",
          )
  
          return ss
  
      def _info_more(self):
          d = self.data
          ss = "\n*** Other info ***\n\n"
  
          fmt = "{:<32s} : {:.3f}\n"
          ss += fmt.format("Significance (10 GeV - 2 TeV)", d["Signif_Avg"])
          ss += "{:<32s} : {:.1f}\n".format("Npred", d["Npred"])
  
          ss += "\n{:<16s} : {:.3f} {}\n".format(
              "HEP Energy", d["HEP_Energy"].value, d["HEP_Energy"].unit
          )
          ss += "{:<16s} : {:.3f}\n".format("HEP Probability", d["HEP_Prob"])
  
          ss += "{:<16s} : {}\n".format("Bayesian Blocks", d["Variability_BayesBlocks"])
  
          ss += "{:<16s} : {:.3f}\n".format("Redshift", d["Redshift"])
          ss += "{:<16s} : {:.3} {}\n".format(
              "NuPeak_obs", d["NuPeak_obs"].value, d["NuPeak_obs"].unit
          )
  
          return ss
  
      def spectral_model(self):
          """Best fit spectral model (`~gammapy.modeling.models.SpectralModel`)."""
          d = self.data
          spec_type = self.data["SpectrumType"].strip()
  
          if spec_type == "PowerLaw":
              tag = "PowerLawSpectralModel"
              pars = {
                  "reference": d["Pivot_Energy"],
                  "amplitude": d["Flux_Density"],
                  "index": d["PowerLaw_Index"],
              }
              errs = {
                  "amplitude": d["Unc_Flux_Density"],
                  "index": d["Unc_PowerLaw_Index"],
              }
          elif spec_type == "LogParabola":
              tag = "LogParabolaSpectralModel"
              pars = {
                  "reference": d["Pivot_Energy"],
                  "amplitude": d["Flux_Density"],
                  "alpha": d["Spectral_Index"],
                  "beta": d["beta"],
              }
              errs = {
                  "amplitude": d["Unc_Flux_Density"],
                  "alpha": d["Unc_Spectral_Index"],
                  "beta": d["Unc_beta"],
              }
          else:
              raise ValueError(f"Invalid spec_type: {spec_type!r}")
  
          model = Model.create(tag, **pars)
  
          for name, value in errs.items():
              model.parameters[name].error = value
  
          return model
  
      @property
      def flux_points(self):
          """Flux points (`~gammapy.spectrum.FluxPoints`)."""
          table = Table()
          table.meta["SED_TYPE"] = "flux"
          table["e_min"] = self._ebounds[:-1]
          table["e_max"] = self._ebounds[1:]
  
          flux = self.data["Flux_Band"]
          flux_err = self.data["Unc_Flux_Band"]
          e2dnde = self.data["nuFnu"]
  
          table["flux"] = flux
          table["flux_errn"] = np.abs(flux_err[:, 0])
          table["flux_errp"] = flux_err[:, 1]
  
          table["e2dnde"] = e2dnde
          table["e2dnde_errn"] = np.abs(e2dnde * flux_err[:, 0] / flux)
          table["e2dnde_errp"] = e2dnde * flux_err[:, 1] / flux
  
          is_ul = np.isnan(table["flux_errn"])
          table["is_ul"] = is_ul
  
          # handle upper limits
          table["flux_ul"] = np.nan * flux_err.unit
          flux_ul = compute_flux_points_ul(table["flux"], table["flux_errp"])
          table["flux_ul"][is_ul] = flux_ul[is_ul]
  
          table["e2dnde_ul"] = np.nan * e2dnde.unit
          e2dnde_ul = compute_flux_points_ul(table["e2dnde"], table["e2dnde_errp"])
          table["e2dnde_ul"][is_ul] = e2dnde_ul[is_ul]
  
          # Square root of test statistic
          table["sqrt_ts"] = self.data["Sqrt_TS_Band"]
          return FluxPoints(table)
  
      def spatial_model(self):
          """Source spatial model (`~gammapy.modeling.models.SpatialModel`)."""
          d = self.data
          ra = d["RAJ2000"]
          dec = d["DEJ2000"]
  
          if self.is_pointlike:
              model = PointSpatialModel(lon_0=ra, lat_0=dec, frame="icrs")
          else:
              de = self.data_extended
              morph_type = de["Spatial_Function"].strip()
              e = (1 - (de["Model_SemiMinor"] / de["Model_SemiMajor"]) ** 2.0) ** 0.5
              sigma = de["Model_SemiMajor"]
              phi = de["Model_PosAng"]
              if morph_type == "RadialDisk":
                  r_0 = de["Model_SemiMajor"]
                  model = DiskSpatialModel(
                      lon_0=ra, lat_0=dec, r_0=r_0, e=e, phi=phi, frame="icrs"
                  )
              elif morph_type in ["SpatialMap"]:
                  filename = de["Spatial_Filename"].strip()
                  path = make_path(
                      "$GAMMAPY_DATA/catalogs/fermi/Extended_archive_v18/Templates/"
                  )
                  model = TemplateSpatialModel.read(path / filename)
              elif morph_type == "RadialGauss":
                  model = GaussianSpatialModel(
                      lon_0=ra, lat_0=dec, sigma=sigma, e=e, phi=phi, frame="icrs"
                  )
              else:
                  raise ValueError(f"Invalid morph_type: {morph_type!r}")
          self._set_spatial_errors(model)
          return model
  
  
  class SourceCatalog3FGL(SourceCatalog):
      """Fermi-LAT 3FGL source catalog.
  
      - https://ui.adsabs.harvard.edu/#abs/2015ApJS..218...23A
      - https://fermi.gsfc.nasa.gov/ssc/data/access/lat/4yr_catalog/
  
      One source is represented by `~gammapy.catalog.SourceCatalogObject3FGL`.
      """
  
      tag = "3fgl"
      description = "LAT 4-year point source catalog"
      source_object_class = SourceCatalogObject3FGL
  
      def __init__(self, filename="$GAMMAPY_DATA/catalogs/fermi/gll_psc_v16.fit.gz"):
          filename = make_path(filename)
  
          with warnings.catch_warnings():  # ignore FITS units warnings
              warnings.simplefilter("ignore", u.UnitsWarning)
              table = Table.read(filename, hdu="LAT_Point_Source_Catalog")
  
          table_standardise_units_inplace(table)
  
          source_name_key = "Source_Name"
          source_name_alias = (
              "Extended_Source_Name",
              "0FGL_Name",
              "1FGL_Name",
              "2FGL_Name",
              "1FHL_Name",
              "ASSOC_TEV",
              "ASSOC1",
              "ASSOC2",
          )
          super().__init__(
              table=table,
              source_name_key=source_name_key,
              source_name_alias=source_name_alias,
          )
  
          self.extended_sources_table = Table.read(filename, hdu="ExtendedSources")
  
  
  class SourceCatalog4FGL(SourceCatalog):
      """Fermi-LAT 4FGL source catalog.
  
      - https://arxiv.org/abs/1902.10045
      - https://fermi.gsfc.nasa.gov/ssc/data/access/lat/8yr_catalog/
  
      One source is represented by `~gammapy.catalog.SourceCatalogObject4FGL`.
      """
  
      tag = "4fgl"
      description = "LAT 8-year point source catalog"
      source_object_class = SourceCatalogObject4FGL
  
      def __init__(self, filename="$GAMMAPY_DATA/catalogs/fermi/gll_psc_v20.fit.gz"):
          filename = make_path(filename)
          table = Table.read(filename, hdu="LAT_Point_Source_Catalog")
          table_standardise_units_inplace(table)
  
          source_name_key = "Source_Name"
          source_name_alias = (
              "Extended_Source_Name",
              "ASSOC_FGL",
              "ASSOC_FHL",
              "ASSOC_GAM1",
              "ASSOC_GAM2",
              "ASSOC_GAM3",
              "ASSOC_TEV",
              "ASSOC1",
              "ASSOC2",
          )
          super().__init__(
              table=table,
              source_name_key=source_name_key,
              source_name_alias=source_name_alias,
          )
  
          self.extended_sources_table = Table.read(filename, hdu="ExtendedSources")
  
  
  class SourceCatalog2FHL(SourceCatalog):
      """Fermi-LAT 2FHL source catalog.
  
      - https://ui.adsabs.harvard.edu/abs/2016ApJS..222....5A
      - https://fermi.gsfc.nasa.gov/ssc/data/access/lat/2FHL/
  
      One source is represented by `~gammapy.catalog.SourceCatalogObject2FHL`.
      """
  
      tag = "2fhl"
      description = "LAT second high-energy source catalog"
      source_object_class = SourceCatalogObject2FHL
  
      def __init__(self, filename="$GAMMAPY_DATA/catalogs/fermi/gll_psch_v09.fit.gz"):
          filename = make_path(filename)
  
          with warnings.catch_warnings():  # ignore FITS units warnings
              warnings.simplefilter("ignore", u.UnitsWarning)
              table = Table.read(filename, hdu="2FHL Source Catalog")
  
          table_standardise_units_inplace(table)
  
          source_name_key = "Source_Name"
          source_name_alias = ("ASSOC", "3FGL_Name", "1FHL_Name", "TeVCat_Name")
          super().__init__(
              table=table,
              source_name_key=source_name_key,
              source_name_alias=source_name_alias,
          )
  
          self.extended_sources_table = Table.read(filename, hdu="Extended Sources")
          self.rois = Table.read(filename, hdu="ROIs")
  
  
  class SourceCatalog3FHL(SourceCatalog):
      """Fermi-LAT 3FHL source catalog.
  
      - https://ui.adsabs.harvard.edu/abs/2017ApJS..232...18A
      - https://fermi.gsfc.nasa.gov/ssc/data/access/lat/3FHL/
  
      One source is represented by `~gammapy.catalog.SourceCatalogObject3FHL`.
      """
  
      tag = "3fhl"
      description = "LAT third high-energy source catalog"
      source_object_class = SourceCatalogObject3FHL
  
      def __init__(self, filename="$GAMMAPY_DATA/catalogs/fermi/gll_psch_v13.fit.gz"):
          filename = make_path(filename)
  
          with warnings.catch_warnings():  # ignore FITS units warnings
              warnings.simplefilter("ignore", u.UnitsWarning)
              table = Table.read(filename, hdu="LAT_Point_Source_Catalog")
  
          table_standardise_units_inplace(table)
  
          source_name_key = "Source_Name"
          source_name_alias = ("ASSOC1", "ASSOC2", "ASSOC_TEV", "ASSOC_GAM")
          super().__init__(
              table=table,
              source_name_key=source_name_key,
              source_name_alias=source_name_alias,
          )
  
          self.extended_sources_table = Table.read(filename, hdu="ExtendedSources")
          self.rois = Table.read(filename, hdu="ROIs")
          self.energy_bounds_table = Table.read(filename, hdu="EnergyBounds")
  
files: gammapy/catalog/fermi.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  import pytest
  import numpy as np
  from numpy.testing import assert_allclose
  from astropy import units as u
  from astropy.time import Time
  from astropy.utils.data import get_pkg_data_filename
  from gammapy.catalog import (
      SourceCatalog2FHL,
      SourceCatalog3FGL,
      SourceCatalog3FHL,
      SourceCatalog4FGL,
  )
  from gammapy.modeling.models import (
      ExpCutoffPowerLaw3FGLSpectralModel,
      LogParabolaSpectralModel,
      PowerLaw2SpectralModel,
      PowerLawSpectralModel,
      SuperExpCutoffPowerLaw3FGLSpectralModel,
      SuperExpCutoffPowerLaw4FGLSpectralModel,
  )
  from gammapy.utils.gauss import Gauss2DPDF
  from gammapy.utils.testing import (
      assert_quantity_allclose,
      assert_time_allclose,
      requires_data,
  )
  
  SOURCES_4FGL = [
      dict(
          idx=0,
          name="4FGL J0000.3-7355",
          str_ref_file="data/4fgl_J0000.3-7355.txt",
          spec_type=PowerLawSpectralModel,
          dnde=u.Quantity(2.9476e-11, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(5.3318e-12, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=3,
          name="4FGL J0001.5+2113",
          str_ref_file="data/4fgl_J0001.5+2113.txt",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(2.8545e-8, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(1.3324e-9, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=7,
          name="4FGL J0002.8+6217",
          str_ref_file="data/4fgl_J0002.8+6217.txt",
          spec_type=SuperExpCutoffPowerLaw4FGLSpectralModel,
          dnde=u.Quantity(2.084e-09, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(1.0885e-10, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=2718,
          name="4FGL J1409.1-6121e",
          str_ref_file="data/4fgl_J1409.1-6121e.txt",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(1.3237202133031811e-12, "cm-2 s-1 MeV-1"),
          dnde_err=u.Quantity(4.513233455580648e-14, "cm-2 s-1 MeV-1"),
      ),
  ]
  
  SOURCES_3FGL = [
      dict(
          idx=0,
          name="3FGL J0000.1+6545",
          str_ref_file="data/3fgl_J0000.1+6545.txt",
          spec_type=PowerLawSpectralModel,
          dnde=u.Quantity(1.4351261e-9, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(2.1356270e-10, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=4,
          name="3FGL J0001.4+2120",
          str_ref_file="data/3fgl_J0001.4+2120.txt",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(8.3828599e-10, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(2.6713238e-10, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=55,
          name="3FGL J0023.4+0923",
          str_ref_file="data/3fgl_J0023.4+0923.txt",
          spec_type=ExpCutoffPowerLaw3FGLSpectralModel,
          dnde=u.Quantity(1.8666925e-09, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(2.2068837e-10, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=960,
          name="3FGL J0835.3-4510",
          str_ref_file="data/3fgl_J0835.3-4510.txt",
          spec_type=SuperExpCutoffPowerLaw3FGLSpectralModel,
          dnde=u.Quantity(1.6547128794756733e-06, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(1.6621504e-11, "cm-2 s-1 MeV-1"),
      ),
  ]
  
  SOURCES_2FHL = [
      dict(
          idx=221,
          name="2FHL J1445.1-0329",
          str_ref_file="data/2fhl_j1445.1-0329.txt",
          spec_type=PowerLaw2SpectralModel,
          dnde=u.Quantity(1.065463448091757e-10, "cm-2 s-1 TeV-1"),
          dnde_err=u.Quantity(4.9691205387540815e-11, "cm-2 s-1 TeV-1"),
      ),
      dict(
          idx=134,
          name="2FHL J0822.6-4250e",
          str_ref_file="data/2fhl_j0822.6-4250e.txt",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(2.46548351696472e-10, "cm-2 s-1 TeV-1"),
          dnde_err=u.Quantity(9.771755529198772e-11, "cm-2 s-1 TeV-1"),
      ),
  ]
  
  SOURCES_3FHL = [
      dict(
          idx=352,
          name="3FHL J0534.5+2201",
          spec_type=PowerLawSpectralModel,
          dnde=u.Quantity(6.3848912826152664e-12, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(2.679593524691324e-13, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=1442,
          name="3FHL J2158.8-3013",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(2.056998292908196e-12, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(4.219030630302381e-13, "cm-2 s-1 GeV-1"),
      ),
  ]
  
  
  @requires_data()
  class TestFermi4FGLObject:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog4FGL()
          cls.source_name = "4FGL J0534.5+2200"
          cls.source = cls.cat[cls.source_name]
  
      def test_name(self):
          assert self.source.name == self.source_name
  
      def test_row_index(self):
          assert self.source.row_index == 995
  
      @pytest.mark.parametrize("ref", SOURCES_4FGL, ids=lambda _: _["name"])
      def test_str(self, ref):
          actual = str(self.cat[ref["idx"]])
          expected = open(get_pkg_data_filename(ref["str_ref_file"])).read()
          assert actual == expected
  
      @pytest.mark.parametrize("ref", SOURCES_4FGL, ids=lambda _: _["name"])
      def test_spectral_model(self, ref):
          model = self.cat[ref["idx"]].spectral_model()
  
          e_ref = model.reference.quantity
          dnde, dnde_err = model.evaluate_error(e_ref)
          assert isinstance(model, ref["spec_type"])
          assert_quantity_allclose(dnde, ref["dnde"], rtol=1e-4)
          assert_quantity_allclose(dnde_err, ref["dnde_err"], rtol=1e-4)
  
      def test_spatial_model(self):
          model = self.cat["4FGL J0000.3-7355"].spatial_model()
          assert "PointSpatialModel" in model.tag
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 0.0983)
          assert_allclose(p["lat_0"].value, -73.921997)
          pos_err = model.position_error
          assert_allclose(pos_err.angle.value, -62.7)
          assert_allclose(0.5 * pos_err.height.value, 0.0525, rtol=1e-4)
          assert_allclose(0.5 * pos_err.width.value, 0.051, rtol=1e-4)
          assert_allclose(model.position.ra.value, pos_err.center.ra.value)
          assert_allclose(model.position.dec.value, pos_err.center.dec.value)
  
          model = self.cat["4FGL J1409.1-6121e"].spatial_model()
          assert "DiskSpatialModel" in model.tag
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 212.294006)
          assert_allclose(p["lat_0"].value, -61.353001)
          assert_allclose(p["r_0"].value, 0.7331369519233704)
  
          model = self.cat["4FGL J0617.2+2234e"].spatial_model()
          assert "GaussianSpatialModel" in model.tag
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 94.309998)
          assert_allclose(p["lat_0"].value, 22.58)
          assert_allclose(p["sigma"].value, 0.27)
  
          model = self.cat["4FGL J1443.0-6227e"].spatial_model()
          assert "TemplateSpatialModel" in model.tag
          assert model.frame == "fk5"
          assert model.normalize is True
  
      @pytest.mark.parametrize("ref", SOURCES_4FGL, ids=lambda _: _["name"])
      def test_sky_model(self, ref):
          self.cat[ref["idx"]].sky_model
  
      def test_flux_points(self):
          flux_points = self.source.flux_points
  
          assert len(flux_points.table) == 7
          assert "flux_ul" in flux_points.table.colnames
          assert flux_points.sed_type == "flux"
  
          desired = [
              2.2378458e-06,
              1.4318283e-06,
              5.4776939e-07,
              1.2769708e-07,
              2.5820052e-08,
              2.3897000e-09,
              7.1766204e-11,
          ]
          assert_allclose(flux_points.table["flux"].data, desired, rtol=1e-5)
  
      def test_flux_points_ul(self):
          source = self.cat["4FGL J0000.3-7355"]
          flux_points = source.flux_points
  
          desired = [
              4.13504750e-08,
              3.80519616e-09,
              np.nan,
              np.nan,
              np.nan,
              np.nan,
              7.99699456e-12,
          ]
          assert_allclose(flux_points.table["flux_ul"].data, desired, rtol=1e-5)
  
      def test_lightcurve(self):
          lc = self.source.lightcurve
          table = lc.table
  
          assert len(table) == 8
          assert table.colnames == [
              "time_min",
              "time_max",
              "flux",
              "flux_errp",
              "flux_errn",
          ]
  
          expected = Time(54682.655277777776, format="mjd", scale="utc")
          assert_time_allclose(lc.time_min[0], expected)
  
          expected = Time(55047.603239293836, format="mjd", scale="utc")
          assert_time_allclose(lc.time_max[0], expected)
  
          assert table["flux"].unit == "cm-2 s-1"
          assert_allclose(table["flux"][0], 2.2122326e-06, rtol=1e-3)
  
          assert table["flux_errp"].unit == "cm-2 s-1"
          assert_allclose(table["flux_errp"][0], 2.3099371e-08, rtol=1e-3)
  
          assert table["flux_errn"].unit == "cm-2 s-1"
          assert_allclose(table["flux_errn"][0], 2.3099371e-08, rtol=1e-3)
  
  
  @requires_data()
  class TestFermi3FGLObject:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog3FGL()
          # Use 3FGL J0534.5+2201 (Crab) as a test source
          cls.source_name = "3FGL J0534.5+2201"
          cls.source = cls.cat[cls.source_name]
  
      def test_name(self):
          assert self.source.name == self.source_name
  
      def test_row_index(self):
          assert self.source.row_index == 621
  
      def test_data(self):
          assert_allclose(self.source.data["Signif_Avg"], 30.669872283935547)
  
      def test_position(self):
          position = self.source.position
          assert_allclose(position.ra.deg, 83.637199, atol=1e-3)
          assert_allclose(position.dec.deg, 22.024099, atol=1e-3)
  
      @pytest.mark.parametrize("ref", SOURCES_3FGL, ids=lambda _: _["name"])
      def test_str(self, ref):
          actual = str(self.cat[ref["idx"]])
          expected = open(get_pkg_data_filename(ref["str_ref_file"])).read()
          assert actual == expected
  
      @pytest.mark.parametrize("ref", SOURCES_3FGL, ids=lambda _: _["name"])
      def test_spectral_model(self, ref):
          model = self.cat[ref["idx"]].spectral_model()
  
          dnde, dnde_err = model.evaluate_error(1 * u.GeV)
  
          assert isinstance(model, ref["spec_type"])
          assert_quantity_allclose(dnde, ref["dnde"])
          assert_quantity_allclose(dnde_err, ref["dnde_err"], rtol=1e-3)
  
      def test_spatial_model(self):
          model = self.cat[0].spatial_model()
          assert "PointSpatialModel" in model.tag
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 0.0377)
          assert_allclose(p["lat_0"].value, 65.751701)
  
          model = self.cat[122].spatial_model()
          assert "GaussianSpatialModel" in model.tag
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 14.75)
          assert_allclose(p["lat_0"].value, -72.699997)
          assert_allclose(p["sigma"].value, 1.35)
  
          model = self.cat[955].spatial_model()
          assert "DiskSpatialModel" in model.tag
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 128.287201)
          assert_allclose(p["lat_0"].value, -45.190102)
          assert_allclose(p["r_0"].value, 0.91)
  
          model = self.cat[602].spatial_model()
          assert "TemplateSpatialModel" in model.tag
          assert model.frame == "fk5"
          assert model.normalize is True
  
          model = self.cat["3FGL J0000.2-3738"].spatial_model()
          pos_err = model.position_error
          assert_allclose(pos_err.angle.value, -88.55)
          assert_allclose(0.5 * pos_err.height.value, 0.0731, rtol=1e-4)
          assert_allclose(0.5 * pos_err.width.value, 0.0676, rtol=1e-4)
          assert_allclose(model.position.ra.value, pos_err.center.ra.value)
          assert_allclose(model.position.dec.value, pos_err.center.dec.value)
  
      @pytest.mark.parametrize("ref", SOURCES_3FGL, ids=lambda _: _["name"])
      def test_sky_model(self, ref):
          self.cat[ref["idx"]].sky_model()
  
      def test_flux_points(self):
          flux_points = self.source.flux_points
  
          assert len(flux_points.table) == 5
          assert "flux_ul" in flux_points.table.colnames
          assert flux_points.sed_type == "flux"
  
          desired = [1.645888e-06, 5.445407e-07, 1.255338e-07, 2.545524e-08, 2.263189e-09]
          assert_allclose(flux_points.table["flux"].data, desired, rtol=1e-5)
  
      def test_flux_points_ul(self):
          source = self.cat["3FGL J0000.2-3738"]
          flux_points = source.flux_points
  
          desired = [4.096391e-09, 6.680059e-10, np.nan, np.nan, np.nan]
          assert_allclose(flux_points.table["flux_ul"].data, desired, rtol=1e-5)
  
      def test_lightcurve(self):
          lc = self.source.lightcurve
          table = lc.table
  
          assert len(table) == 48
          assert table.colnames == [
              "time_min",
              "time_max",
              "flux",
              "flux_errp",
              "flux_errn",
          ]
  
          expected = Time(54680.02313657408, format="mjd", scale="utc")
          assert_time_allclose(lc.time_min[0], expected)
  
          expected = Time(54710.43824797454, format="mjd", scale="utc")
          assert_time_allclose(lc.time_max[0], expected)
  
          assert table["flux"].unit == "cm-2 s-1"
          assert_allclose(table["flux"][0], 2.384e-06, rtol=1e-3)
  
          assert table["flux_errp"].unit == "cm-2 s-1"
          assert_allclose(table["flux_errp"][0], 8.071e-08, rtol=1e-3)
  
          assert table["flux_errn"].unit == "cm-2 s-1"
          assert_allclose(table["flux_errn"][0], 8.071e-08, rtol=1e-3)
  
      def test_crab_alias(self):
          for name in [
              "Crab",
              "3FGL J0534.5+2201",
              "1FHL J0534.5+2201",
              "PSR J0534+2200",
          ]:
              assert self.cat[name].row_index == 621
  
  
  @requires_data()
  class TestFermi2FHLObject:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog2FHL()
          # Use 2FHL J0534.5+2201 (Crab) as a test source
          cls.source_name = "2FHL J0534.5+2201"
          cls.source = cls.cat[cls.source_name]
  
      def test_name(self):
          assert self.source.name == self.source_name
  
      def test_position(self):
          position = self.source.position
          assert_allclose(position.ra.deg, 83.634102, atol=1e-3)
          assert_allclose(position.dec.deg, 22.0215, atol=1e-3)
  
      @pytest.mark.parametrize("ref", SOURCES_2FHL, ids=lambda _: _["name"])
      def test_str(self, ref):
          actual = str(self.cat[ref["idx"]])
          expected = open(get_pkg_data_filename(ref["str_ref_file"])).read()
          assert actual == expected
  
      def test_spectral_model(self):
          model = self.source.spectral_model()
          energy = u.Quantity(100, "GeV")
          desired = u.Quantity(6.8700477298e-12, "cm-2 GeV-1 s-1")
          assert_quantity_allclose(model(energy), desired)
  
      def test_flux_points(self):
          # test flux point on  PKS 2155-304
          src = self.cat["PKS 2155-304"]
          flux_points = src.flux_points
          actual = flux_points.table["flux"]
          desired = [2.866363e-10, 6.118736e-11, 3.257970e-16] * u.Unit("cm-2 s-1")
          assert_quantity_allclose(actual, desired)
  
          actual = flux_points.table["flux_ul"]
          desired = [np.nan, np.nan, 1.294092e-11] * u.Unit("cm-2 s-1")
          assert_quantity_allclose(actual, desired, rtol=1e-3)
  
      def test_spatial_model(self):
          model = self.cat[221].spatial_model()
          assert "PointSpatialModel" in model.tag
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 221.281998, rtol=1e-5)
          assert_allclose(p["lat_0"].value, -3.4943, rtol=1e-5)
  
          model = self.cat["2FHL J1304.5-4353"].spatial_model()
          pos_err = model.position_error
          scale = Gauss2DPDF().containment_radius(0.95) / Gauss2DPDF().containment_radius(
              0.68
          )
          assert_allclose(pos_err.height.value, 2 * 0.041987 * scale, rtol=1e-4)
          assert_allclose(pos_err.width.value, 2 * 0.041987 * scale, rtol=1e-4)
          assert_allclose(model.position.ra.value, pos_err.center.ra.value)
          assert_allclose(model.position.dec.value, pos_err.center.dec.value)
  
          model = self.cat[97].spatial_model()
          assert "GaussianSpatialModel" in model.tag
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 94.309998, rtol=1e-5)
          assert_allclose(p["lat_0"].value, 22.58, rtol=1e-5)
          assert_allclose(p["sigma"].value, 0.27)
  
          model = self.cat[134].spatial_model()
          assert "DiskSpatialModel" in model.tag
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 125.660004, rtol=1e-5)
          assert_allclose(p["lat_0"].value, -42.84, rtol=1e-5)
          assert_allclose(p["r_0"].value, 0.37)
  
          model = self.cat[256].spatial_model()
          assert "TemplateSpatialModel" in model.tag
          assert model.frame == "fk5"
          assert model.normalize is True
          # TODO: have to check the extended template used for RX J1713,
          # for now I guess it's the same than for 3FGL
          # and added a copy with the name given by 2FHL in gammapy-extra
  
  
  @requires_data()
  class TestFermi3FHLObject:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog3FHL()
          # Use 3FHL J0534.5+2201 (Crab) as a test source
          cls.source_name = "3FHL J0534.5+2201"
          cls.source = cls.cat[cls.source_name]
  
      def test_name(self):
          assert self.source.name == self.source_name
  
      def test_row_index(self):
          assert self.source.row_index == 352
  
      def test_data(self):
          assert_allclose(self.source.data["Signif_Avg"], 168.64082)
  
      def test_str(self):
          actual = str(self.cat["3FHL J2301.9+5855e"])  # an extended source
          expected = open(get_pkg_data_filename("data/3fhl_j2301.9+5855e.txt")).read()
          assert actual == expected
  
      def test_position(self):
          position = self.source.position
          assert_allclose(position.ra.deg, 83.634834, atol=1e-3)
          assert_allclose(position.dec.deg, 22.019203, atol=1e-3)
  
      @pytest.mark.parametrize("ref", SOURCES_3FHL, ids=lambda _: _["name"])
      def test_spectral_model(self, ref):
          model = self.cat[ref["idx"]].spectral_model()
  
          dnde, dnde_err = model.evaluate_error(100 * u.GeV)
  
          assert isinstance(model, ref["spec_type"])
          assert_quantity_allclose(dnde, ref["dnde"])
          assert_quantity_allclose(dnde_err, ref["dnde_err"], rtol=1e-3)
  
      @pytest.mark.parametrize("ref", SOURCES_3FHL, ids=lambda _: _["name"])
      def test_spatial_model(self, ref):
          model = self.cat[ref["idx"]].spatial_model()
          assert model.frame == "icrs"
  
          model = self.cat["3FHL J0002.1-6728"].spatial_model()
          pos_err = model.position_error
          assert_allclose(0.5 * pos_err.height.value, 0.035713, rtol=1e-4)
          assert_allclose(0.5 * pos_err.width.value, 0.035713, rtol=1e-4)
          assert_allclose(model.position.ra.value, pos_err.center.ra.value)
          assert_allclose(model.position.dec.value, pos_err.center.dec.value)
  
      @pytest.mark.parametrize("ref", SOURCES_3FHL, ids=lambda _: _["name"])
      def test_sky_model(self, ref):
          self.cat[ref["idx"]].sky_model()
  
      def test_flux_points(self):
          flux_points = self.source.flux_points
  
          assert len(flux_points.table) == 5
          assert "flux_ul" in flux_points.table.colnames
  
          desired = [5.169889e-09, 2.245024e-09, 9.243175e-10, 2.758956e-10, 6.684021e-11]
          assert_allclose(flux_points.table["flux"].data, desired, rtol=1e-3)
  
      def test_crab_alias(self):
          for name in ["Crab Nebula", "3FHL J0534.5+2201", "3FGL J0534.5+2201i"]:
              assert self.cat[name].row_index == 352
  
  
  @requires_data()
  class TestSourceCatalog3FGL:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog3FGL()
  
      def test_main_table(self):
          assert len(self.cat.table) == 3034
  
      def test_extended_sources(self):
          table = self.cat.extended_sources_table
          assert len(table) == 25
  
  
  @requires_data()
  class TestSourceCatalog2FHL:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog2FHL()
  
      def test_main_table(self):
          assert len(self.cat.table) == 360
  
      def test_extended_sources(self):
          table = self.cat.extended_sources_table
          assert len(table) == 25
  
      def test_crab_alias(self):
          for name in ["Crab", "3FGL J0534.5+2201i", "1FHL J0534.5+2201"]:
              assert self.cat[name].row_index == 85
  
  
  @requires_data()
  class TestSourceCatalog3FHL:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog3FHL()
  
      def test_main_table(self):
          assert len(self.cat.table) == 1556
  
      def test_extended_sources(self):
          table = self.cat.extended_sources_table
          assert len(table) == 55
  
files: gammapy/catalog/tests/test_fermi.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  import pytest
  import numpy as np
  from numpy.testing import assert_allclose
  from astropy import units as u
  from astropy.time import Time
  from astropy.utils.data import get_pkg_data_filename
  from gammapy.catalog import (
      SourceCatalog2FHL,
      SourceCatalog3FGL,
      SourceCatalog3FHL,
      SourceCatalog4FGL,
  )
  from gammapy.modeling.models import (
      ExpCutoffPowerLaw3FGLSpectralModel,
      LogParabolaSpectralModel,
      PowerLaw2SpectralModel,
      PowerLawSpectralModel,
      SuperExpCutoffPowerLaw3FGLSpectralModel,
      SuperExpCutoffPowerLaw4FGLSpectralModel,
  )
  from gammapy.utils.gauss import Gauss2DPDF
  from gammapy.utils.testing import (
      assert_quantity_allclose,
      assert_time_allclose,
      requires_data,
  )
  
  SOURCES_4FGL = [
      dict(
          idx=0,
          name="4FGL J0000.3-7355",
          str_ref_file="data/4fgl_J0000.3-7355.txt",
          spec_type=PowerLawSpectralModel,
          dnde=u.Quantity(2.9476e-11, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(5.3318e-12, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=3,
          name="4FGL J0001.5+2113",
          str_ref_file="data/4fgl_J0001.5+2113.txt",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(2.8545e-8, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(1.3324e-9, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=7,
          name="4FGL J0002.8+6217",
          str_ref_file="data/4fgl_J0002.8+6217.txt",
          spec_type=SuperExpCutoffPowerLaw4FGLSpectralModel,
          dnde=u.Quantity(2.084e-09, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(1.0885e-10, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=2718,
          name="4FGL J1409.1-6121e",
          str_ref_file="data/4fgl_J1409.1-6121e.txt",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(1.3237202133031811e-12, "cm-2 s-1 MeV-1"),
          dnde_err=u.Quantity(4.513233455580648e-14, "cm-2 s-1 MeV-1"),
      ),
  ]
  
  SOURCES_3FGL = [
      dict(
          idx=0,
          name="3FGL J0000.1+6545",
          str_ref_file="data/3fgl_J0000.1+6545.txt",
          spec_type=PowerLawSpectralModel,
          dnde=u.Quantity(1.4351261e-9, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(2.1356270e-10, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=4,
          name="3FGL J0001.4+2120",
          str_ref_file="data/3fgl_J0001.4+2120.txt",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(8.3828599e-10, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(2.6713238e-10, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=55,
          name="3FGL J0023.4+0923",
          str_ref_file="data/3fgl_J0023.4+0923.txt",
          spec_type=ExpCutoffPowerLaw3FGLSpectralModel,
          dnde=u.Quantity(1.8666925e-09, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(2.2068837e-10, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=960,
          name="3FGL J0835.3-4510",
          str_ref_file="data/3fgl_J0835.3-4510.txt",
          spec_type=SuperExpCutoffPowerLaw3FGLSpectralModel,
          dnde=u.Quantity(1.6547128794756733e-06, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(1.6621504e-11, "cm-2 s-1 MeV-1"),
      ),
  ]
  
  SOURCES_2FHL = [
      dict(
          idx=221,
          name="2FHL J1445.1-0329",
          str_ref_file="data/2fhl_j1445.1-0329.txt",
          spec_type=PowerLaw2SpectralModel,
          dnde=u.Quantity(1.065463448091757e-10, "cm-2 s-1 TeV-1"),
          dnde_err=u.Quantity(4.9691205387540815e-11, "cm-2 s-1 TeV-1"),
      ),
      dict(
          idx=134,
          name="2FHL J0822.6-4250e",
          str_ref_file="data/2fhl_j0822.6-4250e.txt",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(2.46548351696472e-10, "cm-2 s-1 TeV-1"),
          dnde_err=u.Quantity(9.771755529198772e-11, "cm-2 s-1 TeV-1"),
      ),
  ]
  
  SOURCES_3FHL = [
      dict(
          idx=352,
          name="3FHL J0534.5+2201",
          spec_type=PowerLawSpectralModel,
          dnde=u.Quantity(6.3848912826152664e-12, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(2.679593524691324e-13, "cm-2 s-1 GeV-1"),
      ),
      dict(
          idx=1442,
          name="3FHL J2158.8-3013",
          spec_type=LogParabolaSpectralModel,
          dnde=u.Quantity(2.056998292908196e-12, "cm-2 s-1 GeV-1"),
          dnde_err=u.Quantity(4.219030630302381e-13, "cm-2 s-1 GeV-1"),
      ),
  ]
  
  
  @requires_data()
  class TestFermi4FGLObject:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog4FGL()
          cls.source_name = "4FGL J0534.5+2200"
          cls.source = cls.cat[cls.source_name]
  
      def test_name(self):
          assert self.source.name == self.source_name
  
      def test_row_index(self):
          assert self.source.row_index == 995
  
      @pytest.mark.parametrize("ref", SOURCES_4FGL, ids=lambda _: _["name"])
      def test_str(self, ref):
          actual = str(self.cat[ref["idx"]])
          expected = open(get_pkg_data_filename(ref["str_ref_file"])).read()
          assert actual == expected
  
      @pytest.mark.parametrize("ref", SOURCES_4FGL, ids=lambda _: _["name"])
      def test_spectral_model(self, ref):
          model = self.cat[ref["idx"]].spectral_model()
  
          e_ref = model.reference.quantity
          dnde, dnde_err = model.evaluate_error(e_ref)
          assert isinstance(model, ref["spec_type"])
          assert_quantity_allclose(dnde, ref["dnde"], rtol=1e-4)
          assert_quantity_allclose(dnde_err, ref["dnde_err"], rtol=1e-4)
  
      def test_spatial_model(self):
          model = self.cat["4FGL J0000.3-7355"].spatial_model()
          assert model.tag == "PointSpatialModel"
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 0.0983)
          assert_allclose(p["lat_0"].value, -73.921997)
          pos_err = model.position_error
          assert_allclose(pos_err.angle.value, -62.7)
          assert_allclose(0.5 * pos_err.height.value, 0.0525, rtol=1e-4)
          assert_allclose(0.5 * pos_err.width.value, 0.051, rtol=1e-4)
          assert_allclose(model.position.ra.value, pos_err.center.ra.value)
          assert_allclose(model.position.dec.value, pos_err.center.dec.value)
  
          model = self.cat["4FGL J1409.1-6121e"].spatial_model()
          assert model.tag == "DiskSpatialModel"
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 212.294006)
          assert_allclose(p["lat_0"].value, -61.353001)
          assert_allclose(p["r_0"].value, 0.7331369519233704)
  
          model = self.cat["4FGL J0617.2+2234e"].spatial_model()
          assert model.tag == "GaussianSpatialModel"
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 94.309998)
          assert_allclose(p["lat_0"].value, 22.58)
          assert_allclose(p["sigma"].value, 0.27)
  
          model = self.cat["4FGL J1443.0-6227e"].spatial_model()
          assert model.tag == "TemplateSpatialModel"
          assert model.frame == "fk5"
          assert model.normalize is True
  
      @pytest.mark.parametrize("ref", SOURCES_4FGL, ids=lambda _: _["name"])
      def test_sky_model(self, ref):
          self.cat[ref["idx"]].sky_model
  
      def test_flux_points(self):
          flux_points = self.source.flux_points
  
          assert len(flux_points.table) == 7
          assert "flux_ul" in flux_points.table.colnames
          assert flux_points.sed_type == "flux"
  
          desired = [
              2.2378458e-06,
              1.4318283e-06,
              5.4776939e-07,
              1.2769708e-07,
              2.5820052e-08,
              2.3897000e-09,
              7.1766204e-11,
          ]
          assert_allclose(flux_points.table["flux"].data, desired, rtol=1e-5)
  
      def test_flux_points_ul(self):
          source = self.cat["4FGL J0000.3-7355"]
          flux_points = source.flux_points
  
          desired = [
              4.13504750e-08,
              3.80519616e-09,
              np.nan,
              np.nan,
              np.nan,
              np.nan,
              7.99699456e-12,
          ]
          assert_allclose(flux_points.table["flux_ul"].data, desired, rtol=1e-5)
  
      def test_lightcurve(self):
          lc = self.source.lightcurve
          table = lc.table
  
          assert len(table) == 8
          assert table.colnames == [
              "time_min",
              "time_max",
              "flux",
              "flux_errp",
              "flux_errn",
          ]
  
          expected = Time(54682.655277777776, format="mjd", scale="utc")
          assert_time_allclose(lc.time_min[0], expected)
  
          expected = Time(55047.603239293836, format="mjd", scale="utc")
          assert_time_allclose(lc.time_max[0], expected)
  
          assert table["flux"].unit == "cm-2 s-1"
          assert_allclose(table["flux"][0], 2.2122326e-06, rtol=1e-3)
  
          assert table["flux_errp"].unit == "cm-2 s-1"
          assert_allclose(table["flux_errp"][0], 2.3099371e-08, rtol=1e-3)
  
          assert table["flux_errn"].unit == "cm-2 s-1"
          assert_allclose(table["flux_errn"][0], 2.3099371e-08, rtol=1e-3)
  
  
  @requires_data()
  class TestFermi3FGLObject:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog3FGL()
          # Use 3FGL J0534.5+2201 (Crab) as a test source
          cls.source_name = "3FGL J0534.5+2201"
          cls.source = cls.cat[cls.source_name]
  
      def test_name(self):
          assert self.source.name == self.source_name
  
      def test_row_index(self):
          assert self.source.row_index == 621
  
      def test_data(self):
          assert_allclose(self.source.data["Signif_Avg"], 30.669872283935547)
  
      def test_position(self):
          position = self.source.position
          assert_allclose(position.ra.deg, 83.637199, atol=1e-3)
          assert_allclose(position.dec.deg, 22.024099, atol=1e-3)
  
      @pytest.mark.parametrize("ref", SOURCES_3FGL, ids=lambda _: _["name"])
      def test_str(self, ref):
          actual = str(self.cat[ref["idx"]])
          expected = open(get_pkg_data_filename(ref["str_ref_file"])).read()
          assert actual == expected
  
      @pytest.mark.parametrize("ref", SOURCES_3FGL, ids=lambda _: _["name"])
      def test_spectral_model(self, ref):
          model = self.cat[ref["idx"]].spectral_model()
  
          dnde, dnde_err = model.evaluate_error(1 * u.GeV)
  
          assert isinstance(model, ref["spec_type"])
          assert_quantity_allclose(dnde, ref["dnde"])
          assert_quantity_allclose(dnde_err, ref["dnde_err"], rtol=1e-3)
  
      def test_spatial_model(self):
          model = self.cat[0].spatial_model()
          assert model.tag == "PointSpatialModel"
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 0.0377)
          assert_allclose(p["lat_0"].value, 65.751701)
  
          model = self.cat[122].spatial_model()
          assert model.tag == "GaussianSpatialModel"
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 14.75)
          assert_allclose(p["lat_0"].value, -72.699997)
          assert_allclose(p["sigma"].value, 1.35)
  
          model = self.cat[955].spatial_model()
          assert model.tag == "DiskSpatialModel"
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 128.287201)
          assert_allclose(p["lat_0"].value, -45.190102)
          assert_allclose(p["r_0"].value, 0.91)
  
          model = self.cat[602].spatial_model()
          assert model.tag == "TemplateSpatialModel"
          assert model.frame == "fk5"
          assert model.normalize is True
  
          model = self.cat["3FGL J0000.2-3738"].spatial_model()
          pos_err = model.position_error
          assert_allclose(pos_err.angle.value, -88.55)
          assert_allclose(0.5 * pos_err.height.value, 0.0731, rtol=1e-4)
          assert_allclose(0.5 * pos_err.width.value, 0.0676, rtol=1e-4)
          assert_allclose(model.position.ra.value, pos_err.center.ra.value)
          assert_allclose(model.position.dec.value, pos_err.center.dec.value)
  
      @pytest.mark.parametrize("ref", SOURCES_3FGL, ids=lambda _: _["name"])
      def test_sky_model(self, ref):
          self.cat[ref["idx"]].sky_model()
  
      def test_flux_points(self):
          flux_points = self.source.flux_points
  
          assert len(flux_points.table) == 5
          assert "flux_ul" in flux_points.table.colnames
          assert flux_points.sed_type == "flux"
  
          desired = [1.645888e-06, 5.445407e-07, 1.255338e-07, 2.545524e-08, 2.263189e-09]
          assert_allclose(flux_points.table["flux"].data, desired, rtol=1e-5)
  
      def test_flux_points_ul(self):
          source = self.cat["3FGL J0000.2-3738"]
          flux_points = source.flux_points
  
          desired = [4.096391e-09, 6.680059e-10, np.nan, np.nan, np.nan]
          assert_allclose(flux_points.table["flux_ul"].data, desired, rtol=1e-5)
  
      def test_lightcurve(self):
          lc = self.source.lightcurve
          table = lc.table
  
          assert len(table) == 48
          assert table.colnames == [
              "time_min",
              "time_max",
              "flux",
              "flux_errp",
              "flux_errn",
          ]
  
          expected = Time(54680.02313657408, format="mjd", scale="utc")
          assert_time_allclose(lc.time_min[0], expected)
  
          expected = Time(54710.43824797454, format="mjd", scale="utc")
          assert_time_allclose(lc.time_max[0], expected)
  
          assert table["flux"].unit == "cm-2 s-1"
          assert_allclose(table["flux"][0], 2.384e-06, rtol=1e-3)
  
          assert table["flux_errp"].unit == "cm-2 s-1"
          assert_allclose(table["flux_errp"][0], 8.071e-08, rtol=1e-3)
  
          assert table["flux_errn"].unit == "cm-2 s-1"
          assert_allclose(table["flux_errn"][0], 8.071e-08, rtol=1e-3)
  
      def test_crab_alias(self):
          for name in [
              "Crab",
              "3FGL J0534.5+2201",
              "1FHL J0534.5+2201",
              "PSR J0534+2200",
          ]:
              assert self.cat[name].row_index == 621
  
  
  @requires_data()
  class TestFermi2FHLObject:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog2FHL()
          # Use 2FHL J0534.5+2201 (Crab) as a test source
          cls.source_name = "2FHL J0534.5+2201"
          cls.source = cls.cat[cls.source_name]
  
      def test_name(self):
          assert self.source.name == self.source_name
  
      def test_position(self):
          position = self.source.position
          assert_allclose(position.ra.deg, 83.634102, atol=1e-3)
          assert_allclose(position.dec.deg, 22.0215, atol=1e-3)
  
      @pytest.mark.parametrize("ref", SOURCES_2FHL, ids=lambda _: _["name"])
      def test_str(self, ref):
          actual = str(self.cat[ref["idx"]])
          expected = open(get_pkg_data_filename(ref["str_ref_file"])).read()
          assert actual == expected
  
      def test_spectral_model(self):
          model = self.source.spectral_model()
          energy = u.Quantity(100, "GeV")
          desired = u.Quantity(6.8700477298e-12, "cm-2 GeV-1 s-1")
          assert_quantity_allclose(model(energy), desired)
  
      def test_flux_points(self):
          # test flux point on  PKS 2155-304
          src = self.cat["PKS 2155-304"]
          flux_points = src.flux_points
          actual = flux_points.table["flux"]
          desired = [2.866363e-10, 6.118736e-11, 3.257970e-16] * u.Unit("cm-2 s-1")
          assert_quantity_allclose(actual, desired)
  
          actual = flux_points.table["flux_ul"]
          desired = [np.nan, np.nan, 1.294092e-11] * u.Unit("cm-2 s-1")
          assert_quantity_allclose(actual, desired, rtol=1e-3)
  
      def test_spatial_model(self):
          model = self.cat[221].spatial_model()
          assert model.tag == "PointSpatialModel"
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 221.281998, rtol=1e-5)
          assert_allclose(p["lat_0"].value, -3.4943, rtol=1e-5)
  
          model = self.cat["2FHL J1304.5-4353"].spatial_model()
          pos_err = model.position_error
          scale = Gauss2DPDF().containment_radius(0.95) / Gauss2DPDF().containment_radius(
              0.68
          )
          assert_allclose(pos_err.height.value, 2 * 0.041987 * scale, rtol=1e-4)
          assert_allclose(pos_err.width.value, 2 * 0.041987 * scale, rtol=1e-4)
          assert_allclose(model.position.ra.value, pos_err.center.ra.value)
          assert_allclose(model.position.dec.value, pos_err.center.dec.value)
  
          model = self.cat[97].spatial_model()
          assert model.tag == "GaussianSpatialModel"
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 94.309998, rtol=1e-5)
          assert_allclose(p["lat_0"].value, 22.58, rtol=1e-5)
          assert_allclose(p["sigma"].value, 0.27)
  
          model = self.cat[134].spatial_model()
          assert model.tag == "DiskSpatialModel"
          assert model.frame == "icrs"
          p = model.parameters
          assert_allclose(p["lon_0"].value, 125.660004, rtol=1e-5)
          assert_allclose(p["lat_0"].value, -42.84, rtol=1e-5)
          assert_allclose(p["r_0"].value, 0.37)
  
          model = self.cat[256].spatial_model()
          assert model.tag == "TemplateSpatialModel"
          assert model.frame == "fk5"
          assert model.normalize is True
          # TODO: have to check the extended template used for RX J1713,
          # for now I guess it's the same than for 3FGL
          # and added a copy with the name given by 2FHL in gammapy-extra
  
  
  @requires_data()
  class TestFermi3FHLObject:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog3FHL()
          # Use 3FHL J0534.5+2201 (Crab) as a test source
          cls.source_name = "3FHL J0534.5+2201"
          cls.source = cls.cat[cls.source_name]
  
      def test_name(self):
          assert self.source.name == self.source_name
  
      def test_row_index(self):
          assert self.source.row_index == 352
  
      def test_data(self):
          assert_allclose(self.source.data["Signif_Avg"], 168.64082)
  
      def test_str(self):
          actual = str(self.cat["3FHL J2301.9+5855e"])  # an extended source
          expected = open(get_pkg_data_filename("data/3fhl_j2301.9+5855e.txt")).read()
          assert actual == expected
  
      def test_position(self):
          position = self.source.position
          assert_allclose(position.ra.deg, 83.634834, atol=1e-3)
          assert_allclose(position.dec.deg, 22.019203, atol=1e-3)
  
      @pytest.mark.parametrize("ref", SOURCES_3FHL, ids=lambda _: _["name"])
      def test_spectral_model(self, ref):
          model = self.cat[ref["idx"]].spectral_model()
  
          dnde, dnde_err = model.evaluate_error(100 * u.GeV)
  
          assert isinstance(model, ref["spec_type"])
          assert_quantity_allclose(dnde, ref["dnde"])
          assert_quantity_allclose(dnde_err, ref["dnde_err"], rtol=1e-3)
  
      @pytest.mark.parametrize("ref", SOURCES_3FHL, ids=lambda _: _["name"])
      def test_spatial_model(self, ref):
          model = self.cat[ref["idx"]].spatial_model()
          assert model.frame == "icrs"
  
          model = self.cat["3FHL J0002.1-6728"].spatial_model()
          pos_err = model.position_error
          assert_allclose(0.5 * pos_err.height.value, 0.035713, rtol=1e-4)
          assert_allclose(0.5 * pos_err.width.value, 0.035713, rtol=1e-4)
          assert_allclose(model.position.ra.value, pos_err.center.ra.value)
          assert_allclose(model.position.dec.value, pos_err.center.dec.value)
  
      @pytest.mark.parametrize("ref", SOURCES_3FHL, ids=lambda _: _["name"])
      def test_sky_model(self, ref):
          self.cat[ref["idx"]].sky_model()
  
      def test_flux_points(self):
          flux_points = self.source.flux_points
  
          assert len(flux_points.table) == 5
          assert "flux_ul" in flux_points.table.colnames
  
          desired = [5.169889e-09, 2.245024e-09, 9.243175e-10, 2.758956e-10, 6.684021e-11]
          assert_allclose(flux_points.table["flux"].data, desired, rtol=1e-3)
  
      def test_crab_alias(self):
          for name in ["Crab Nebula", "3FHL J0534.5+2201", "3FGL J0534.5+2201i"]:
              assert self.cat[name].row_index == 352
  
  
  @requires_data()
  class TestSourceCatalog3FGL:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog3FGL()
  
      def test_main_table(self):
          assert len(self.cat.table) == 3034
  
      def test_extended_sources(self):
          table = self.cat.extended_sources_table
          assert len(table) == 25
  
  
  @requires_data()
  class TestSourceCatalog2FHL:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog2FHL()
  
      def test_main_table(self):
          assert len(self.cat.table) == 360
  
      def test_extended_sources(self):
          table = self.cat.extended_sources_table
          assert len(table) == 25
  
      def test_crab_alias(self):
          for name in ["Crab", "3FGL J0534.5+2201i", "1FHL J0534.5+2201"]:
              assert self.cat[name].row_index == 85
  
  
  @requires_data()
  class TestSourceCatalog3FHL:
      @classmethod
      def setup_class(cls):
          cls.cat = SourceCatalog3FHL()
  
      def test_main_table(self):
          assert len(self.cat.table) == 1556
  
      def test_extended_sources(self):
          table = self.cat.extended_sources_table
          assert len(table) == 55
  
files: gammapy/catalog/tests/test_fermi.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  import collections.abc
  import copy
  from os.path import split
  import numpy as np
  import astropy.units as u
  from astropy.table import Table
  import yaml
  from gammapy.modeling import Covariance, Parameter, Parameters
  from gammapy.utils.scripts import make_name, make_path
  
  
  def _set_link(shared_register, model):
      for param in model.parameters:
          name = param.name
          link_label = param._link_label_io
          if link_label is not None:
              if link_label in shared_register:
                  new_param = shared_register[link_label]
                  setattr(model, name, new_param)
              else:
                  shared_register[link_label] = param
      return shared_register
  
  
  __all__ = ["Model", "Models", "ProperModels"]
  
  
  class Model:
      """Model base class."""
  
      def __init__(self, **kwargs):
          # Copy default parameters from the class to the instance
          default_parameters = self.default_parameters.copy()
  
          for par in default_parameters:
              value = kwargs.get(par.name, par)
  
              if not isinstance(value, Parameter):
                  par.quantity = u.Quantity(value)
              else:
                  par = value
  
              setattr(self, par.name, par)
  
          self._covariance = Covariance(self.parameters)
  
      def __init_subclass__(cls, **kwargs):
          # Add parameters list on the model sub-class (not instances)
          cls.default_parameters = Parameters(
              [_ for _ in cls.__dict__.values() if isinstance(_, Parameter)]
          )
  
      @classmethod
      def from_parameters(cls, parameters, **kwargs):
          """Create model from parameter list
  
          Parameters
          ----------
          parameters : `Parameters`
              Parameters for init
  
          Returns
          -------
          model : `Model`
              Model instance
          """
          for par in parameters:
              kwargs[par.name] = par
          return cls(**kwargs)
  
      def _check_covariance(self):
          if not self.parameters == self._covariance.parameters:
              self._covariance = Covariance(self.parameters)
  
      @property
      def covariance(self):
          self._check_covariance()
          for par in self.parameters:
              pars = Parameters([par])
              covar = Covariance(pars, data=[[par.error ** 2]])
              self._covariance.set_subcovariance(covar)
  
          return self._covariance
  
      @covariance.setter
      def covariance(self, covariance):
          self._check_covariance()
          self._covariance.data = covariance
  
          for par in self.parameters:
              pars = Parameters([par])
              variance = self._covariance.get_subcovariance(pars)
              par.error = np.sqrt(variance)
  
      @property
      def parameters(self):
          """Parameters (`~gammapy.modeling.Parameters`)"""
          return Parameters(
              [getattr(self, name) for name in self.default_parameters.names]
          )
  
      def copy(self):
          """A deep copy."""
          return copy.deepcopy(self)
  
      def to_dict(self):
          """Create dict for YAML serialisation"""
          tag = self.tag[0] if isinstance(self.tag, list) else self.tag
          return {"type": tag, "parameters": self.parameters.to_dict()}
  
      @classmethod
      def from_dict(cls, data):
          kwargs = {}
          parameters = Parameters.from_dict(data["parameters"])
  
          # TODO: this is a special case for spatial models, maybe better move to `SpatialModel` base class
          if "frame" in data:
              kwargs["frame"] = data["frame"]
  
          return cls.from_parameters(parameters, **kwargs)
  
      @staticmethod
      def create(tag, *args, **kwargs):
          """Create a model instance.
  
          Examples
          --------
          >>> from gammapy.modeling.models import Model
          >>> spectral_model = Model.create("PowerLaw2SpectralModel", amplitude="1e-10 cm-2 s-1", index=3)
          >>> type(spectral_model)
          gammapy.modeling.models.spectral.PowerLaw2SpectralModel
          """
          from . import MODEL_REGISTRY
  
          cls = MODEL_REGISTRY.get_cls(tag)
          return cls(*args, **kwargs)
  
      def __str__(self):
          return f"{self.__class__.__name__}\n\n{self.parameters.to_table()}"
  
  
  class Models(collections.abc.MutableSequence):
      """Sky model collection.
  
      Parameters
      ----------
      models : `SkyModel`, list of `SkyModel` or `Models`
          Sky models
      """
  
      def __init__(self, models=None):
          if models is None:
              models = []
  
          if isinstance(models, Models):
              models = models._models
          elif isinstance(models, Model):
              models = [models]
          elif not isinstance(models, list):
              raise TypeError(f"Invalid type: {models!r}")
  
          unique_names = []
          for model in models:
              if model.name in unique_names:
                  raise (ValueError("Model names must be unique"))
              unique_names.append(model.name)
  
          self._models = models
          self._covar_file = None
          self._covariance = Covariance(self.parameters)
  
      def _check_covariance(self):
          if not self.parameters == self._covariance.parameters:
              self._covariance = Covariance.from_stack(
                  [model.covariance for model in self._models]
              )
  
      @property
      def covariance(self):
          self._check_covariance()
  
          for model in self._models:
              self._covariance.set_subcovariance(model.covariance)
  
          return self._covariance
  
      @covariance.setter
      def covariance(self, covariance):
          self._check_covariance()
          self._covariance.data = covariance
  
          for model in self._models:
              subcovar = self._covariance.get_subcovariance(model.covariance.parameters)
              model.covariance = subcovar
  
      @property
      def parameters(self):
          return Parameters.from_stack([_.parameters for _ in self._models])
  
      @property
      def parameters_unique_names(self):
          from gammapy.modeling.models import SkyModel
  
          param_names = []
          for m in self._models:
              if isinstance(m, SkyModel):
                  for p in m.parameters:
                      if (
                          m.spectral_model is not None
                          and p in m.spectral_model.parameters
                      ):
                          tag = ".spectral."
                      elif (
                          m.spatial_model is not None and p in m.spatial_model.parameters
                      ):
                          tag = ".spatial."
                      elif (
                          m.temporal_model is not None
                          and p in m.temporal_model.parameters
                      ):
                          tag = ".temporal."
                      param_names.append(m.name + tag + p.name)
              else:
                  for p in m.parameters:
                      param_names.append(m.name + "." + p.name)
          return param_names
  
      @property
      def names(self):
          return [m.name for m in self._models]
  
      @classmethod
      def read(cls, filename):
          """Read from YAML file."""
          yaml_str = make_path(filename).read_text()
          path, filename = split(filename)
          return cls.from_yaml(yaml_str, path=path)
  
      @classmethod
      def from_yaml(cls, yaml_str, path=""):
          """Create from YAML string."""
          data = yaml.safe_load(yaml_str)
          return cls.from_dict(data, path=path)
  
      @classmethod
      def from_dict(cls, data, path=""):
          """Create from dict."""
          from . import MODEL_REGISTRY, SkyModel
  
          models = []
  
          for component in data["components"]:
              model = MODEL_REGISTRY.get_cls(component["type"]).from_dict(component)
              models.append(model)
  
          models = cls(models)
  
          if "covariance" in data:
              filename = data["covariance"]
              path = make_path(path)
              if not (path / filename).exists():
                  path, filename = split(filename)
  
              models.read_covariance(path, filename, format="ascii.fixed_width")
  
          shared_register = {}
          for model in models:
              if isinstance(model, SkyModel):
                  submodels = [
                      model.spectral_model,
                      model.spatial_model,
                      model.temporal_model,
                  ]
                  for submodel in submodels:
                      if submodel is not None:
                          shared_register = _set_link(shared_register, submodel)
              else:
                  shared_register = _set_link(shared_register, model)
          return models
  
      def write(self, path, overwrite=False, write_covariance=True):
          """Write to YAML file.
  
          Parameters
          ----------
          path : `pathlib.Path`
              path to write files
          overwrite : bool
              overwrite files
          write_covariance : bool
              save covariance or not
          """
  
          base_path, _ = split(path)
          path = make_path(path)
          base_path = make_path(base_path)
  
          if path.exists() and not overwrite:
              raise IOError(f"File exists already: {path}")
  
          if (
              write_covariance
              and self.covariance is not None
              and len(self.parameters) != 0
          ):
              filecovar = path.stem + "_covariance.dat"
              kwargs = dict(
                  format="ascii.fixed_width", delimiter="|", overwrite=overwrite
              )
              self.write_covariance(base_path / filecovar, **kwargs)
              self._covar_file = filecovar
  
          path.write_text(self.to_yaml())
  
      def to_yaml(self):
          """Convert to YAML string."""
          data = self.to_dict()
          return yaml.dump(
              data, sort_keys=False, indent=4, width=80, default_flow_style=None
          )
  
      def to_dict(self):
          """Convert to dict."""
          # update linked parameters labels
          params_list = []
          params_shared = []
          for param in self.parameters:
              if param not in params_list:
                  params_list.append(param)
              elif param not in params_shared:
                  params_shared.append(param)
          for param in params_shared:
              param._link_label_io = param.name + "@" + make_name()
  
          models_data = []
          for model in self._models:
              model_data = model.to_dict()
              models_data.append(model_data)
          if self._covar_file is not None:
              return {
                  "components": models_data,
                  "covariance": str(self._covar_file),
              }
          else:
              return {"components": models_data}
  
      def read_covariance(self, path, filename="_covariance.dat", **kwargs):
          """Read covariance data from file
  
          Parameters
          ----------
          filename : str
              Filename
          **kwargs : dict
              Keyword arguments passed to `~astropy.table.Table.read`
  
          """
          path = make_path(path)
          filepath = str(path / filename)
          t = Table.read(filepath, **kwargs)
          t.remove_column("Parameters")
          arr = np.array(t)
          data = arr.view(float).reshape(arr.shape + (-1,))
          self.covariance = data
          self._covar_file = filename
  
      def write_covariance(self, filename, **kwargs):
          """Write covariance to file
  
          Parameters
          ----------
          filename : str
              Filename
          **kwargs : dict
              Keyword arguments passed to `~astropy.table.Table.write`
  
          """
          names = self.parameters_unique_names
          table = Table()
          table["Parameters"] = names
  
          for idx, name in enumerate(names):
              values = self.covariance.data[idx]
              table[name] = values
  
          table.write(make_path(filename), **kwargs)
  
      def __str__(self):
          str_ = f"{self.__class__.__name__}\n\n"
  
          for idx, model in enumerate(self):
              str_ += f"Component {idx}: "
              str_ += str(model)
  
          return str_.expandtabs(tabsize=2)
  
      def __add__(self, other):
          if isinstance(other, (Models, list)):
              return Models([*self, *other])
          elif isinstance(other, Model):
              if other.name in self.names:
                  raise (ValueError("Model names must be unique"))
              return Models([*self, other])
          else:
              raise TypeError(f"Invalid type: {other!r}")
  
      def __getitem__(self, key):
          return self._models[self.index(key)]
  
      def __delitem__(self, key):
          del self._models[self.index(key)]
  
      def __setitem__(self, key, model):
          from gammapy.modeling.models import SkyModel, SkyDiffuseCube
  
          if isinstance(model, (SkyModel, SkyDiffuseCube)):
              self._models[self.index(key)] = model
          else:
              raise TypeError(f"Invalid type: {model!r}")
  
      def insert(self, idx, model):
          if model.name in self.names:
              raise (ValueError("Model names must be unique"))
  
          self._models.insert(idx, model)
  
      def index(self, key):
          if isinstance(key, (int, slice)):
              return key
          elif isinstance(key, str):
              return self.names.index(key)
          elif isinstance(key, Model):
              return self._models.index(key)
          else:
              raise TypeError(f"Invalid type: {type(key)!r}")
  
      def __len__(self):
          return len(self._models)
  
      def _ipython_key_completions_(self):
          return self.names
  
      def copy(self):
          """A deep copy."""
          return copy.deepcopy(self)
  
  
  class ProperModels(Models):
      """ Proper Models of a Dataset or Datasets."""
  
      def __init__(self, parent):
          from gammapy.datasets import Dataset, Datasets
  
          if isinstance(parent, Dataset):
              self._datasets = [parent]
              self._is_dataset = True
          elif isinstance(parent, Datasets):
              self._datasets = parent._datasets
              self._is_dataset = False
          else:
              raise TypeError(f"Invalid type: {type(parent)!r}")
  
          unique_models = []
          for d in self._datasets:
              if d._models is not None:
                  for model in d._models:
                      if model not in unique_models:
                          if (
                              model.datasets_names is None
                              or d.name in model.datasets_names
                          ):
                              unique_models.append(model)
              else:
                  d._models = Models([])
              self._models = unique_models
  
          if self._is_dataset == False:
              self.force_models_consistency()
  
          self._covar_file = None
          self._covariance = Covariance(self.parameters)
  
      def force_models_consistency(self):
          """Force consistency between models listed in each dataset of a datasets
          and each model.datasets_names. For example if a model with
          datasets_names = None appears in only one dataset,
          it will be automatically added to the others.
          """
          for d in self._datasets:
              for m in self._models:
                  if (
                      m.datasets_names is None or d.name in m.datasets_names
                  ) and m not in d.models:
                      d.models.append(m)
  
      def __add__(self, other):
          if isinstance(other, (Models, list)):
              pass
          elif isinstance(other, Model):
              other = [other]
          else:
              raise TypeError(f"Invalid type: {other!r}")
          for d in self._datasets:
              for m in other:
                  if m not in d._models:
                      d._models.append(m)
                  if (
                      m.datasets_names is not None
                      and d.name not in m.datasets_names
                      and self._is_dataset
                  ):
                      m.datasets_names.append(d.name)
  
      def __delitem__(self, key):
          for d in self._datasets:
              if key in d.models.names:
                  datasets_names = d.models[key].datasets_names
                  if datasets_names is None or d.name in datasets_names:
                      d._models.remove(key)
  
      def __setitem__(self, key, model):
          from gammapy.modeling.models import SkyModel, SkyDiffuseCube
  
          for d in self._datasets:
              if model not in d._models:
                  if isinstance(model, (SkyModel, SkyDiffuseCube)):
                      d._models[key] = model
  
                  else:
                      raise TypeError(f"Invalid type: {model!r}")
              if (
                  model.datasets_names is not None
                  and d.name not in model.datasets_names
                  and self._is_dataset
              ):
                  model.datasets_names.append(d.name)
  
      def insert(self, idx, model):
          from gammapy.modeling.models import SkyModel, SkyDiffuseCube
  
          for d in self._datasets:
              if model not in d._models:
                  if isinstance(model, (SkyModel, SkyDiffuseCube)):
                      if idx == len(self):
                          index = len(d._models)
                      else:
                          index = idx
                      d._models.insert(index, model)
                  else:
                      raise TypeError(f"Invalid type: {model!r}")
              if (
                  model.datasets_names is not None
                  and d.name not in model.datasets_names
                  and self._is_dataset
              ):
                  model.datasets_names.append(d.name)
  
      def remove(self, value):
          key = value.name
          del self[key]
  
files: gammapy/modeling/models/core.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  import collections.abc
  import copy
  from os.path import split
  import numpy as np
  import astropy.units as u
  from astropy.table import Table
  import yaml
  from gammapy.modeling import Covariance, Parameter, Parameters
  from gammapy.utils.scripts import make_name, make_path
  
  
  def _set_link(shared_register, model):
      for param in model.parameters:
          name = param.name
          link_label = param._link_label_io
          if link_label is not None:
              if link_label in shared_register:
                  new_param = shared_register[link_label]
                  setattr(model, name, new_param)
              else:
                  shared_register[link_label] = param
      return shared_register
  
  
  __all__ = ["Model", "Models", "ProperModels"]
  
  
  class Model:
      """Model base class."""
  
      def __init__(self, **kwargs):
          # Copy default parameters from the class to the instance
          default_parameters = self.default_parameters.copy()
  
          for par in default_parameters:
              value = kwargs.get(par.name, par)
  
              if not isinstance(value, Parameter):
                  par.quantity = u.Quantity(value)
              else:
                  par = value
  
              setattr(self, par.name, par)
  
          self._covariance = Covariance(self.parameters)
  
      def __init_subclass__(cls, **kwargs):
          # Add parameters list on the model sub-class (not instances)
          cls.default_parameters = Parameters(
              [_ for _ in cls.__dict__.values() if isinstance(_, Parameter)]
          )
  
      @classmethod
      def from_parameters(cls, parameters, **kwargs):
          """Create model from parameter list
  
          Parameters
          ----------
          parameters : `Parameters`
              Parameters for init
  
          Returns
          -------
          model : `Model`
              Model instance
          """
          for par in parameters:
              kwargs[par.name] = par
          return cls(**kwargs)
  
      def _check_covariance(self):
          if not self.parameters == self._covariance.parameters:
              self._covariance = Covariance(self.parameters)
  
      @property
      def covariance(self):
          self._check_covariance()
          for par in self.parameters:
              pars = Parameters([par])
              covar = Covariance(pars, data=[[par.error ** 2]])
              self._covariance.set_subcovariance(covar)
  
          return self._covariance
  
      @covariance.setter
      def covariance(self, covariance):
          self._check_covariance()
          self._covariance.data = covariance
  
          for par in self.parameters:
              pars = Parameters([par])
              variance = self._covariance.get_subcovariance(pars)
              par.error = np.sqrt(variance)
  
      @property
      def parameters(self):
          """Parameters (`~gammapy.modeling.Parameters`)"""
          return Parameters(
              [getattr(self, name) for name in self.default_parameters.names]
          )
  
      def copy(self):
          """A deep copy."""
          return copy.deepcopy(self)
  
      def to_dict(self):
          """Create dict for YAML serialisation"""
          return {"type": self.tag, "parameters": self.parameters.to_dict()}
  
      @classmethod
      def from_dict(cls, data):
          kwargs = {}
          parameters = Parameters.from_dict(data["parameters"])
  
          # TODO: this is a special case for spatial models, maybe better move to `SpatialModel` base class
          if "frame" in data:
              kwargs["frame"] = data["frame"]
  
          return cls.from_parameters(parameters, **kwargs)
  
      @staticmethod
      def create(tag, *args, **kwargs):
          """Create a model instance.
  
          Examples
          --------
          >>> from gammapy.modeling.models import Model
          >>> spectral_model = Model.create("PowerLaw2SpectralModel", amplitude="1e-10 cm-2 s-1", index=3)
          >>> type(spectral_model)
          gammapy.modeling.models.spectral.PowerLaw2SpectralModel
          """
          from . import MODEL_REGISTRY
  
          cls = MODEL_REGISTRY.get_cls(tag)
          return cls(*args, **kwargs)
  
      def __str__(self):
          return f"{self.__class__.__name__}\n\n{self.parameters.to_table()}"
  
  
  class Models(collections.abc.MutableSequence):
      """Sky model collection.
  
      Parameters
      ----------
      models : `SkyModel`, list of `SkyModel` or `Models`
          Sky models
      """
  
      def __init__(self, models=None):
          if models is None:
              models = []
  
          if isinstance(models, Models):
              models = models._models
          elif isinstance(models, Model):
              models = [models]
          elif not isinstance(models, list):
              raise TypeError(f"Invalid type: {models!r}")
  
          unique_names = []
          for model in models:
              if model.name in unique_names:
                  raise (ValueError("Model names must be unique"))
              unique_names.append(model.name)
  
          self._models = models
          self._covar_file = None
          self._covariance = Covariance(self.parameters)
  
      def _check_covariance(self):
          if not self.parameters == self._covariance.parameters:
              self._covariance = Covariance.from_stack(
                  [model.covariance for model in self._models]
              )
  
      @property
      def covariance(self):
          self._check_covariance()
  
          for model in self._models:
              self._covariance.set_subcovariance(model.covariance)
  
          return self._covariance
  
      @covariance.setter
      def covariance(self, covariance):
          self._check_covariance()
          self._covariance.data = covariance
  
          for model in self._models:
              subcovar = self._covariance.get_subcovariance(model.covariance.parameters)
              model.covariance = subcovar
  
      @property
      def parameters(self):
          return Parameters.from_stack([_.parameters for _ in self._models])
  
      @property
      def parameters_unique_names(self):
          from gammapy.modeling.models import SkyModel
  
          param_names = []
          for m in self._models:
              if isinstance(m, SkyModel):
                  for p in m.parameters:
                      if (
                          m.spectral_model is not None
                          and p in m.spectral_model.parameters
                      ):
                          tag = ".spectral."
                      elif (
                          m.spatial_model is not None and p in m.spatial_model.parameters
                      ):
                          tag = ".spatial."
                      elif (
                          m.temporal_model is not None
                          and p in m.temporal_model.parameters
                      ):
                          tag = ".temporal."
                      param_names.append(m.name + tag + p.name)
              else:
                  for p in m.parameters:
                      param_names.append(m.name + "." + p.name)
          return param_names
  
      @property
      def names(self):
          return [m.name for m in self._models]
  
      @classmethod
      def read(cls, filename):
          """Read from YAML file."""
          yaml_str = make_path(filename).read_text()
          path, filename = split(filename)
          return cls.from_yaml(yaml_str, path=path)
  
      @classmethod
      def from_yaml(cls, yaml_str, path=""):
          """Create from YAML string."""
          data = yaml.safe_load(yaml_str)
          return cls.from_dict(data, path=path)
  
      @classmethod
      def from_dict(cls, data, path=""):
          """Create from dict."""
          from . import MODEL_REGISTRY, SkyModel
  
          models = []
  
          for component in data["components"]:
              model = MODEL_REGISTRY.get_cls(component["type"]).from_dict(component)
              models.append(model)
  
          models = cls(models)
  
          if "covariance" in data:
              filename = data["covariance"]
              path = make_path(path)
              if not (path / filename).exists():
                  path, filename = split(filename)
  
              models.read_covariance(path, filename, format="ascii.fixed_width")
  
          shared_register = {}
          for model in models:
              if isinstance(model, SkyModel):
                  submodels = [
                      model.spectral_model,
                      model.spatial_model,
                      model.temporal_model,
                  ]
                  for submodel in submodels:
                      if submodel is not None:
                          shared_register = _set_link(shared_register, submodel)
              else:
                  shared_register = _set_link(shared_register, model)
          return models
  
      def write(self, path, overwrite=False, write_covariance=True):
          """Write to YAML file.
  
          Parameters
          ----------
          path : `pathlib.Path`
              path to write files
          overwrite : bool
              overwrite files
          write_covariance : bool
              save covariance or not
          """
  
          base_path, _ = split(path)
          path = make_path(path)
          base_path = make_path(base_path)
  
          if path.exists() and not overwrite:
              raise IOError(f"File exists already: {path}")
  
          if (
              write_covariance
              and self.covariance is not None
              and len(self.parameters) != 0
          ):
              filecovar = path.stem + "_covariance.dat"
              kwargs = dict(
                  format="ascii.fixed_width", delimiter="|", overwrite=overwrite
              )
              self.write_covariance(base_path / filecovar, **kwargs)
              self._covar_file = filecovar
  
          path.write_text(self.to_yaml())
  
      def to_yaml(self):
          """Convert to YAML string."""
          data = self.to_dict()
          return yaml.dump(
              data, sort_keys=False, indent=4, width=80, default_flow_style=None
          )
  
      def to_dict(self):
          """Convert to dict."""
          # update linked parameters labels
          params_list = []
          params_shared = []
          for param in self.parameters:
              if param not in params_list:
                  params_list.append(param)
              elif param not in params_shared:
                  params_shared.append(param)
          for param in params_shared:
              param._link_label_io = param.name + "@" + make_name()
  
          models_data = []
          for model in self._models:
              model_data = model.to_dict()
              models_data.append(model_data)
          if self._covar_file is not None:
              return {
                  "components": models_data,
                  "covariance": str(self._covar_file),
              }
          else:
              return {"components": models_data}
  
      def read_covariance(self, path, filename="_covariance.dat", **kwargs):
          """Read covariance data from file
  
          Parameters
          ----------
          filename : str
              Filename
          **kwargs : dict
              Keyword arguments passed to `~astropy.table.Table.read`
  
          """
          path = make_path(path)
          filepath = str(path / filename)
          t = Table.read(filepath, **kwargs)
          t.remove_column("Parameters")
          arr = np.array(t)
          data = arr.view(float).reshape(arr.shape + (-1,))
          self.covariance = data
          self._covar_file = filename
  
      def write_covariance(self, filename, **kwargs):
          """Write covariance to file
  
          Parameters
          ----------
          filename : str
              Filename
          **kwargs : dict
              Keyword arguments passed to `~astropy.table.Table.write`
  
          """
          names = self.parameters_unique_names
          table = Table()
          table["Parameters"] = names
  
          for idx, name in enumerate(names):
              values = self.covariance.data[idx]
              table[name] = values
  
          table.write(make_path(filename), **kwargs)
  
      def __str__(self):
          str_ = f"{self.__class__.__name__}\n\n"
  
          for idx, model in enumerate(self):
              str_ += f"Component {idx}: "
              str_ += str(model)
  
          return str_.expandtabs(tabsize=2)
  
      def __add__(self, other):
          if isinstance(other, (Models, list)):
              return Models([*self, *other])
          elif isinstance(other, Model):
              if other.name in self.names:
                  raise (ValueError("Model names must be unique"))
              return Models([*self, other])
          else:
              raise TypeError(f"Invalid type: {other!r}")
  
      def __getitem__(self, key):
          return self._models[self.index(key)]
  
      def __delitem__(self, key):
          del self._models[self.index(key)]
  
      def __setitem__(self, key, model):
          from gammapy.modeling.models import SkyModel, SkyDiffuseCube
  
          if isinstance(model, (SkyModel, SkyDiffuseCube)):
              self._models[self.index(key)] = model
          else:
              raise TypeError(f"Invalid type: {model!r}")
  
      def insert(self, idx, model):
          if model.name in self.names:
              raise (ValueError("Model names must be unique"))
  
          self._models.insert(idx, model)
  
      def index(self, key):
          if isinstance(key, (int, slice)):
              return key
          elif isinstance(key, str):
              return self.names.index(key)
          elif isinstance(key, Model):
              return self._models.index(key)
          else:
              raise TypeError(f"Invalid type: {type(key)!r}")
  
      def __len__(self):
          return len(self._models)
  
      def _ipython_key_completions_(self):
          return self.names
  
      def copy(self):
          """A deep copy."""
          return copy.deepcopy(self)
  
  
  class ProperModels(Models):
      """ Proper Models of a Dataset or Datasets."""
  
      def __init__(self, parent):
          from gammapy.datasets import Dataset, Datasets
  
          if isinstance(parent, Dataset):
              self._datasets = [parent]
              self._is_dataset = True
          elif isinstance(parent, Datasets):
              self._datasets = parent._datasets
              self._is_dataset = False
          else:
              raise TypeError(f"Invalid type: {type(parent)!r}")
  
          unique_models = []
          for d in self._datasets:
              if d._models is not None:
                  for model in d._models:
                      if model not in unique_models:
                          if (
                              model.datasets_names is None
                              or d.name in model.datasets_names
                          ):
                              unique_models.append(model)
              else:
                  d._models = Models([])
              self._models = unique_models
  
          if self._is_dataset == False:
              self.force_models_consistency()
  
          self._covar_file = None
          self._covariance = Covariance(self.parameters)
  
      def force_models_consistency(self):
          """Force consistency between models listed in each dataset of a datasets
          and each model.datasets_names. For example if a model with
          datasets_names = None appears in only one dataset,
          it will be automatically added to the others.
          """
          for d in self._datasets:
              for m in self._models:
                  if (
                      m.datasets_names is None or d.name in m.datasets_names
                  ) and m not in d.models:
                      d.models.append(m)
  
      def __add__(self, other):
          if isinstance(other, (Models, list)):
              pass
          elif isinstance(other, Model):
              other = [other]
          else:
              raise TypeError(f"Invalid type: {other!r}")
          for d in self._datasets:
              for m in other:
                  if m not in d._models:
                      d._models.append(m)
                  if (
                      m.datasets_names is not None
                      and d.name not in m.datasets_names
                      and self._is_dataset
                  ):
                      m.datasets_names.append(d.name)
  
      def __delitem__(self, key):
          for d in self._datasets:
              if key in d.models.names:
                  datasets_names = d.models[key].datasets_names
                  if datasets_names is None or d.name in datasets_names:
                      d._models.remove(key)
  
      def __setitem__(self, key, model):
          from gammapy.modeling.models import SkyModel, SkyDiffuseCube
  
          for d in self._datasets:
              if model not in d._models:
                  if isinstance(model, (SkyModel, SkyDiffuseCube)):
                      d._models[key] = model
  
                  else:
                      raise TypeError(f"Invalid type: {model!r}")
              if (
                  model.datasets_names is not None
                  and d.name not in model.datasets_names
                  and self._is_dataset
              ):
                  model.datasets_names.append(d.name)
  
      def insert(self, idx, model):
          from gammapy.modeling.models import SkyModel, SkyDiffuseCube
  
          for d in self._datasets:
              if model not in d._models:
                  if isinstance(model, (SkyModel, SkyDiffuseCube)):
                      if idx == len(self):
                          index = len(d._models)
                      else:
                          index = idx
                      d._models.insert(index, model)
                  else:
                      raise TypeError(f"Invalid type: {model!r}")
              if (
                  model.datasets_names is not None
                  and d.name not in model.datasets_names
                  and self._is_dataset
              ):
                  model.datasets_names.append(d.name)
  
      def remove(self, value):
          key = value.name
          del self[key]
  
files: gammapy/modeling/models/core.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  """Spatial models."""
  import logging
  import numpy as np
  import scipy.integrate
  import scipy.special
  import astropy.units as u
  from astropy.coordinates import Angle, SkyCoord
  from astropy.coordinates.angle_utilities import angular_separation, position_angle
  from regions import (
      CircleAnnulusSkyRegion,
      EllipseSkyRegion,
      PointSkyRegion,
      PolygonSkyRegion,
  )
  from gammapy.maps import Map, WcsGeom
  from gammapy.modeling import Parameter, Parameters
  from gammapy.utils.gauss import Gauss2DPDF
  from gammapy.utils.scripts import make_path
  from .core import Model
  
  log = logging.getLogger(__name__)
  
  
  def compute_sigma_eff(lon_0, lat_0, lon, lat, phi, major_axis, e):
      """Effective radius, used for the evaluation of elongated models"""
      phi_0 = position_angle(lon_0, lat_0, lon, lat)
      d_phi = phi - phi_0
      minor_axis = Angle(major_axis * np.sqrt(1 - e ** 2))
  
      a2 = (major_axis * np.sin(d_phi)) ** 2
      b2 = (minor_axis * np.cos(d_phi)) ** 2
      denominator = np.sqrt(a2 + b2)
      sigma_eff = major_axis * minor_axis / denominator
      return minor_axis, sigma_eff
  
  
  class SpatialModel(Model):
      """Spatial model base class."""
  
      def __init__(self, **kwargs):
          frame = kwargs.pop("frame", "icrs")
          super().__init__(**kwargs)
          if not hasattr(self, "frame"):
              self.frame = frame
  
      def __call__(self, lon, lat):
          """Call evaluate method"""
          kwargs = {par.name: par.quantity for par in self.parameters}
          return self.evaluate(lon, lat, **kwargs)
  
      @property
      def position(self):
          """Spatial model center position"""
          lon = self.lon_0.quantity
          lat = self.lat_0.quantity
          return SkyCoord(lon, lat, frame=self.frame)
  
      @position.setter
      def position(self, skycoord):
          """Spatial model center position"""
          coord = skycoord.transform_to(self.frame)
          self.lon_0.quantity = coord.data.lon
          self.lat_0.quantity = coord.data.lat
  
      # TODO: get rid of this!
      _phi_0 = 0.0
  
      @property
      def phi_0(self):
          return self._phi_0
  
      @phi_0.setter
      def phi_0(self, phi_0=0.0):
          self._phi_0 = phi_0
  
      @property
      def position_error(self):
          """Get 95% containment position error as (`~regions.EllipseSkyRegion`)"""
          if self.covariance is None:
              return EllipseSkyRegion(
                  center=self.position,
                  height=np.nan * u.deg,
                  width=np.nan * u.deg,
                  angle=np.nan * u.deg,
              )
          pars = self.parameters
          sub_covar = self.covariance.get_subcovariance(["lon_0", "lat_0"]).data.copy()
          cos_lat = np.cos(self.lat_0.quantity.to_value("rad"))
          sub_covar[0, 0] *= cos_lat ** 2.0
          sub_covar[0, 1] *= cos_lat
          sub_covar[1, 0] *= cos_lat
          eig_vals, eig_vecs = np.linalg.eig(sub_covar)
          lon_err, lat_err = np.sqrt(eig_vals)
          y_vec = eig_vecs[:, 0]
          phi = (np.arctan2(y_vec[1], y_vec[0]) * u.rad).to("deg") + self.phi_0
          err = np.sort([lon_err, lat_err])
          scale_r95 = Gauss2DPDF().containment_radius(0.95)
          err *= scale_r95
          if err[1] == lon_err * scale_r95:
              phi += 90 * u.deg
              height = 2 * err[1] * pars["lon_0"].unit
              width = 2 * err[0] * pars["lat_0"].unit
          else:
              height = 2 * err[1] * pars["lat_0"].unit
              width = 2 * err[0] * pars["lon_0"].unit
          return EllipseSkyRegion(
              center=self.position, height=height, width=width, angle=phi
          )
  
      def evaluate_geom(self, geom):
          """Evaluate model on `~gammapy.maps.Geom`."""
          coords = geom.get_coord(frame=self.frame)
          return self(coords.lon, coords.lat)
  
      def integrate_geom(self, geom):
          """Integrate model on `~gammapy.maps.Geom`."""
          values = self.evaluate_geom(geom)
          data = values * geom.solid_angle()
          return Map.from_geom(geom=geom, data=data.value, unit=data.unit)
  
      def to_dict(self):
          """Create dict for YAML serilisation"""
          data = super().to_dict()
          data["frame"] = self.frame
          data["parameters"] = data.pop("parameters")
          return data
  
      def plot(self, ax=None, geom=None, **kwargs):
          """Plot spatial model.
  
          Parameters
          ----------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          geom : `~gammapy.maps.WcsGeom`, optional
              Geom to use for plotting.
          **kwargs : dict
              Keyword arguments passed to `~gammapy.maps.WcsMap.plot()`
  
          Returns
          -------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          """
          if self.evaluation_radius is None and geom is None:
              raise ValueError(
                  f"{self.__class__.__name__} requires geom to be defined for plotting."
              )
  
          if geom is None:
              width = 2 * max(self.evaluation_radius, 0.1 * u.deg)
              geom = WcsGeom.create(
                  skydir=self.position, frame=self.frame, width=width, binsz=0.02
              )
  
          data = self.evaluate_geom(geom)
          m = Map.from_geom(geom, data=data.value, unit=data.unit)
          _, ax, _ = m.plot(ax=ax, **kwargs)
  
          return ax
  
      def plot_error(self, ax=None, **kwargs):
          """Plot position error
  
          Parameters
          ----------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          **kwargs : dict
              Keyword arguments passed to `~gammapy.maps.WcsMap.plot()`
  
          Returns
          -------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          """
          import matplotlib.pyplot as plt
  
          # plot center position
          lon, lat = self.lon_0.value, self.lat_0.value
  
          ax = plt.gca() if ax is None else ax
  
          kwargs.setdefault("marker", "x")
          kwargs.setdefault("color", "red")
          kwargs.setdefault("label", "position")
  
          ax.scatter(lon, lat, transform=ax.get_transform(self.frame), **kwargs)
  
          # plot position error
          if not np.all(self.covariance.data == 0):
              region = self.position_error.to_pixel(ax.wcs)
              artist = region.as_artist(facecolor="none", edgecolor=kwargs["color"])
              ax.add_artist(artist)
  
          return ax
  
  
  class PointSpatialModel(SpatialModel):
      r"""Point Source.
  
      For more information see :ref:`point-spatial-model`.
  
      Parameters
      ----------
      lon_0, lat_0 : `~astropy.coordinates.Angle`
          Center position
      frame : {"icrs", "galactic"}
          Center position coordinate frame
      """
  
      tag = ["PointSpatialModel", "PS"]
      lon_0 = Parameter("lon_0", "0 deg")
      lat_0 = Parameter("lat_0", "0 deg", min=-90, max=90)
  
      @property
      def evaluation_radius(self):
          """Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set as zero degrees.
          """
          return 0 * u.deg
  
      @staticmethod
      def _grid_weights(x, y, x0, y0):
          """Compute 4-pixel weights such that centroid is preserved."""
          dx = np.abs(x - x0)
          dx = np.where(dx < 1, 1 - dx, 0)
  
          dy = np.abs(y - y0)
          dy = np.where(dy < 1, 1 - dy, 0)
  
          return dx * dy
  
      def evaluate_geom(self, geom):
          """Evaluate model on `~gammapy.maps.Geom`."""
          values = self.integrate_geom(geom).data
          return values / geom.solid_angle()
  
      def integrate_geom(self, geom):
          """Integrate model on `~gammapy.maps.Geom`
  
          Parameters
          ----------
          geom : `Geom`
              Map geometry
  
          Returns
          -------
          flux : `Map`
              Predicted flux map
          """
          x, y = geom.get_pix()[0:2]
          x0, y0 = self.position.to_pixel(geom.wcs)
          data = self._grid_weights(x, y, x0, y0)
          return Map.from_geom(geom=geom, data=data, unit="")
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.PointSkyRegion`)."""
          return PointSkyRegion(center=self.position, **kwargs)
  
  
  class GaussianSpatialModel(SpatialModel):
      r"""Two-dimensional Gaussian model.
  
      For more information see :ref:`gaussian-spatial-model`.
  
      Parameters
      ----------
      lon_0, lat_0 : `~astropy.coordinates.Angle`
          Center position
      sigma : `~astropy.coordinates.Angle`
          Length of the major semiaxis of the Gaussian, in angular units.
      e : `float`
          Eccentricity of the Gaussian (: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 = ["GaussianSpatialModel", "GaussianSpatial"]
  
      lon_0 = Parameter("lon_0", "0 deg")
      lat_0 = Parameter("lat_0", "0 deg", min=-90, max=90)
      sigma = Parameter("sigma", "1 deg", min=0)
      e = Parameter("e", 0, min=0, max=1, frozen=True)
      phi = Parameter("phi", "0 deg", frozen=True)
  
      @property
      def evaluation_radius(self):
          r"""Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set as :math:`5\sigma`.
          """
          return 5 * self.parameters["sigma"].quantity
  
      @staticmethod
      def evaluate(lon, lat, lon_0, lat_0, sigma, e, phi):
          """Evaluate model."""
          sep = angular_separation(lon, lat, lon_0, lat_0)
  
          if e == 0:
              a = 1.0 - np.cos(sigma)
              norm = (1 / (4 * np.pi * a * (1.0 - np.exp(-1.0 / a)))).value
          else:
              minor_axis, sigma_eff = compute_sigma_eff(
                  lon_0, lat_0, lon, lat, phi, sigma, e
              )
              a = 1.0 - np.cos(sigma_eff)
              norm = (1 / (2 * np.pi * sigma * minor_axis)).to_value("sr-1")
  
          exponent = -0.5 * ((1 - np.cos(sep)) / a)
          return u.Quantity(norm * np.exp(exponent).value, "sr-1", copy=False)
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.EllipseSkyRegion`)."""
          minor_axis = Angle(self.sigma.quantity * np.sqrt(1 - self.e.quantity ** 2))
          return EllipseSkyRegion(
              center=self.position,
              height=2 * self.sigma.quantity,
              width=2 * minor_axis,
              angle=self.phi.quantity,
              **kwargs,
          )
  
  
  class DiskSpatialModel(SpatialModel):
      r"""Constant disk model.
  
      For more information see :ref:`disk-spatial-model`.
  
      Parameters
      ----------
      lon_0, lat_0 : `~astropy.coordinates.Angle`
          Center position
      r_0 : `~astropy.coordinates.Angle`
          :math:`a`: length of the major semiaxis, in angular units.
      e : `float`
          Eccentricity of the ellipse (:math:`0< e< 1`).
      phi : `~astropy.coordinates.Angle`
          Rotation angle :math:`\phi`: of the major semiaxis.
          Increases counter-clockwise from the North direction.
      edge : `~astropy.coordinates.Angle`
          Width of the edge. The width is defined as the range within the
          smooth edges of the model drops from 95% to 5% of its amplitude.
      frame : {"icrs", "galactic"}
          Center position coordinate frame
      """
  
      tag = ["DiskSpatialModel", "disk"]
      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", min=0)
      e = Parameter("e", 0, min=0, max=1, frozen=True)
      phi = Parameter("phi", "0 deg", frozen=True)
      edge = Parameter("edge", "0.01 deg", frozen=True, min=0.01)
  
      @property
      def evaluation_radius(self):
          """Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set to the length of the semi-major axis.
          """
          return self.r_0.quantity
  
      @staticmethod
      def _evaluate_norm_factor(r_0, e):
          """Compute the normalization factor."""
          semi_minor = r_0 * np.sqrt(1 - e ** 2)
  
          def integral_fcn(x, a, b):
              A = 1 / np.sin(a) ** 2
              B = 1 / np.sin(b) ** 2
              C = A - B
              cs2 = np.cos(x) ** 2
  
              return 1 - np.sqrt(1 - 1 / (B + C * cs2))
  
          return (
              2
              * scipy.integrate.quad(
                  lambda x: integral_fcn(x, r_0, semi_minor), 0, np.pi
              )[0]
          ) ** -1
  
      @staticmethod
      def _evaluate_smooth_edge(x, width):
          value = (x / width).to_value("")
          edge_width_95 = 2.326174307353347
          return 0.5 * (1 - scipy.special.erf(value * edge_width_95))
  
      @staticmethod
      def evaluate(lon, lat, lon_0, lat_0, r_0, e, phi, edge):
          """Evaluate model."""
          sep = angular_separation(lon, lat, lon_0, lat_0)
  
          if e == 0:
              sigma_eff = r_0
          else:
              sigma_eff = compute_sigma_eff(lon_0, lat_0, lon, lat, phi, r_0, e)[1]
  
          norm = DiskSpatialModel._evaluate_norm_factor(r_0, e)
  
          in_ellipse = DiskSpatialModel._evaluate_smooth_edge(sep - sigma_eff, edge)
          return u.Quantity(norm * in_ellipse, "sr-1", copy=False)
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.EllipseSkyRegion`)."""
          minor_axis = Angle(self.r_0.quantity * np.sqrt(1 - self.e.quantity ** 2))
          return EllipseSkyRegion(
              center=self.position,
              height=2 * self.r_0.quantity,
              width=2 * minor_axis,
              angle=self.phi.quantity,
              **kwargs,
          )
  
  
  class ShellSpatialModel(SpatialModel):
      r"""Shell model.
  
      For more information see :ref:`shell-spatial-model`.
  
      Parameters
      ----------
      lon_0, lat_0 : `~astropy.coordinates.Angle`
          Center position
      radius : `~astropy.coordinates.Angle`
          Inner radius, :math:`r_{in}`
      width : `~astropy.coordinates.Angle`
          Shell width
      frame : {"icrs", "galactic"}
          Center position coordinate frame
      """
  
      tag = ["ShellSpatialModel", "shell"]
      lon_0 = Parameter("lon_0", "0 deg")
      lat_0 = Parameter("lat_0", "0 deg", min=-90, max=90)
      radius = Parameter("radius", "1 deg")
      width = Parameter("width", "0.2 deg")
  
      @property
      def evaluation_radius(self):
          r"""Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set to :math:`r_\text{out}`.
          """
          return self.radius.quantity + self.width.quantity
  
      @staticmethod
      def evaluate(lon, lat, lon_0, lat_0, radius, width):
          """Evaluate model."""
          sep = angular_separation(lon, lat, lon_0, lat_0)
          radius_out = radius + width
  
          norm = 3 / (2 * np.pi * (radius_out ** 3 - radius ** 3))
  
          with np.errstate(invalid="ignore"):
              # np.where and np.select do not work with quantities, so we use the
              # workaround with indexing
              value = np.sqrt(radius_out ** 2 - sep ** 2)
              mask = [sep < radius]
              value[mask] = (value - np.sqrt(radius ** 2 - sep ** 2))[mask]
              value[sep > radius_out] = 0
  
          return norm * value
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.CircleAnnulusSkyRegion`)."""
          return CircleAnnulusSkyRegion(
              center=self.position,
              inner_radius=self.radius.quantity,
              outer_radius=self.radius.quantity + self.width.quantity,
              **kwargs,
          )
  
  
  class ConstantSpatialModel(SpatialModel):
      """Spatially constant (isotropic) spatial model.
  
      For more information see :ref:`constant-spatial-model`.
  
      Parameters
      ----------
      value : `~astropy.units.Quantity`
          Value
      """
  
      tag = ["ConstantSpatialModel", "ConstantSpatial"]
      value = Parameter("value", "1 sr-1", frozen=True)
  
      frame = "icrs"
      evaluation_radius = None
      position = SkyCoord("0 deg", "0 deg", frame=frame)
  
      def to_dict(self):
          """Create dict for YAML serilisation"""
          # redefined to ignore frame attribute from parent class
          data = super().to_dict()
          data.pop("frame")
          data["parameters"] = data.pop("parameters")
          return data
  
      @staticmethod
      def evaluate(lon, lat, value):
          """Evaluate model."""
          return value
  
      @staticmethod
      def to_region(**kwargs):
          """Model outline (`~regions.EllipseSkyRegion`)."""
          return EllipseSkyRegion(
              center=SkyCoord(np.nan * u.deg, np.nan * u.deg),
              height=np.nan * u.deg,
              width=np.nan * u.deg,
              angle=np.nan * u.deg,
              **kwargs,
          )
  
  
  class TemplateSpatialModel(SpatialModel):
      """Spatial sky map template model (2D).
  
      This is for a 2D image. The unit of the map has to be equivalent to ``sr-1``.
  
      Use `~gammapy.modeling.models.SkyDiffuseCube` for 3D cubes with
      an energy axis.
  
      For more information see :ref:`template-spatial-model`.
  
      Parameters
      ----------
      map : `~gammapy.maps.Map`
          Map template. The unit has to be equivalent to ``sr-1``.
      norm : float
          Norm parameter (multiplied with map values)
      meta : dict, optional
          Meta information, meta['filename'] will be used for serialization
      normalize : bool
          Normalize the input map so that it integrates to unity.
      interp_kwargs : dict
          Interpolation keyword arguments passed to `gammapy.maps.Map.interp_by_coord`.
          Default arguments are {'interp': 'linear', 'fill_value': 0}.
      """
  
      tag = ["TemplateSpatialModel", "TemplateSpatial"]
      norm = Parameter("norm", 1)
  
      def __init__(
          self,
          map,
          norm=norm.quantity,
          meta=None,
          normalize=True,
          interp_kwargs=None,
          filename=None,
      ):
          if (map.data < 0).any():
              log.warning("Diffuse map has negative values. Check and fix this!")
  
          if filename is not None:
              filename = str(make_path(filename))
  
          self.map = map
          if not self.map.unit.is_equivalent("sr-1"):
              raise ValueError("The map unit should be equivalent to sr-1")
          self.normalize = normalize
          if normalize:
              # Normalize the diffuse map model so that it integrates to unity."""
              data = self.map.data / self.map.data.sum()
              data /= self.map.geom.solid_angle().to_value("sr")
              self.map = self.map.copy(data=data, unit="sr-1")
  
          self.meta = dict() if meta is None else meta
          interp_kwargs = {} if interp_kwargs is None else interp_kwargs
          interp_kwargs.setdefault("interp", "linear")
          interp_kwargs.setdefault("fill_value", 0)
          self._interp_kwargs = interp_kwargs
          self.filename = filename
          super().__init__(norm=norm)
  
      @property
      def evaluation_radius(self):
          """Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set to half of the maximal dimension of the map.
          """
          return np.max(self.map.geom.width) / 2.0
  
      @classmethod
      def read(cls, filename, normalize=True, **kwargs):
          """Read spatial template model from FITS image.
  
          The unit of the map has to be equivalent to ``sr-1``. If no unit the default is ``sr-1``.
  
          Parameters
          ----------
          filename : str
              FITS image filename.
          normalize : bool
              Normalize the input map so that it integrates to unity.
          kwargs : dict
              Keyword arguments passed to `Map.read()`.
          """
          m = Map.read(filename, **kwargs)
  
          if not m.unit.is_equivalent("sr-1"):
              m.unit = "sr-1"
              log.warning(
                  "Spatial template unit is not equivalent to sr^-1, unit changed to sr^-1"
              )
  
          return cls(m, normalize=normalize, filename=filename)
  
      def evaluate(self, lon, lat, norm):
          """Evaluate model."""
          coord = {"lon": lon.to_value("deg"), "lat": lat.to_value("deg")}
          val = self.map.interp_by_coord(coord, **self._interp_kwargs)
          return u.Quantity(norm.value * val, self.map.unit, copy=False)
  
      @property
      def position(self):
          """`~astropy.coordinates.SkyCoord`"""
          return self.map.geom.center_skydir
  
      @property
      def frame(self):
          return self.position.frame.name
  
      @classmethod
      def from_dict(cls, data):
          m = Map.read(data["filename"])
  
          if not m.unit.is_equivalent("sr-1"):
              m.unit = "sr-1"
              log.warning(
                  "Spatial template unit is not equivalent to sr^-1, unit changed to sr^-1"
              )
  
          parameters = Parameters.from_dict(data["parameters"])
          return cls.from_parameters(
              parameters=parameters,
              map=m,
              filename=data["filename"],
              normalize=data.get("normalize", True),
          )
  
      def to_dict(self):
          """Create dict for YAML serilisation"""
          data = super().to_dict()
          data["filename"] = self.filename
          data["normalize"] = self.normalize
          return data
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.PolygonSkyRegion`)."""
          footprint = self.map.geom.wcs.calc_footprint()
          return PolygonSkyRegion(
              vertices=SkyCoord(footprint, unit="deg", frame=self.frame, **kwargs)
          )
  
files: gammapy/modeling/models/spatial.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  """Spatial models."""
  import logging
  import numpy as np
  import scipy.integrate
  import scipy.special
  import astropy.units as u
  from astropy.coordinates import Angle, SkyCoord
  from astropy.coordinates.angle_utilities import angular_separation, position_angle
  from regions import (
      CircleAnnulusSkyRegion,
      EllipseSkyRegion,
      PointSkyRegion,
      PolygonSkyRegion,
  )
  from gammapy.maps import Map, WcsGeom
  from gammapy.modeling import Parameter, Parameters
  from gammapy.utils.gauss import Gauss2DPDF
  from gammapy.utils.scripts import make_path
  from .core import Model
  
  log = logging.getLogger(__name__)
  
  
  def compute_sigma_eff(lon_0, lat_0, lon, lat, phi, major_axis, e):
      """Effective radius, used for the evaluation of elongated models"""
      phi_0 = position_angle(lon_0, lat_0, lon, lat)
      d_phi = phi - phi_0
      minor_axis = Angle(major_axis * np.sqrt(1 - e ** 2))
  
      a2 = (major_axis * np.sin(d_phi)) ** 2
      b2 = (minor_axis * np.cos(d_phi)) ** 2
      denominator = np.sqrt(a2 + b2)
      sigma_eff = major_axis * minor_axis / denominator
      return minor_axis, sigma_eff
  
  
  class SpatialModel(Model):
      """Spatial model base class."""
  
      def __init__(self, **kwargs):
          frame = kwargs.pop("frame", "icrs")
          super().__init__(**kwargs)
          if not hasattr(self, "frame"):
              self.frame = frame
  
      def __call__(self, lon, lat):
          """Call evaluate method"""
          kwargs = {par.name: par.quantity for par in self.parameters}
          return self.evaluate(lon, lat, **kwargs)
  
      @property
      def position(self):
          """Spatial model center position"""
          lon = self.lon_0.quantity
          lat = self.lat_0.quantity
          return SkyCoord(lon, lat, frame=self.frame)
  
      @position.setter
      def position(self, skycoord):
          """Spatial model center position"""
          coord = skycoord.transform_to(self.frame)
          self.lon_0.quantity = coord.data.lon
          self.lat_0.quantity = coord.data.lat
  
      # TODO: get rid of this!
      _phi_0 = 0.0
  
      @property
      def phi_0(self):
          return self._phi_0
  
      @phi_0.setter
      def phi_0(self, phi_0=0.0):
          self._phi_0 = phi_0
  
      @property
      def position_error(self):
          """Get 95% containment position error as (`~regions.EllipseSkyRegion`)"""
          if self.covariance is None:
              return EllipseSkyRegion(
                  center=self.position,
                  height=np.nan * u.deg,
                  width=np.nan * u.deg,
                  angle=np.nan * u.deg,
              )
          pars = self.parameters
          sub_covar = self.covariance.get_subcovariance(["lon_0", "lat_0"]).data.copy()
          cos_lat = np.cos(self.lat_0.quantity.to_value("rad"))
          sub_covar[0, 0] *= cos_lat ** 2.0
          sub_covar[0, 1] *= cos_lat
          sub_covar[1, 0] *= cos_lat
          eig_vals, eig_vecs = np.linalg.eig(sub_covar)
          lon_err, lat_err = np.sqrt(eig_vals)
          y_vec = eig_vecs[:, 0]
          phi = (np.arctan2(y_vec[1], y_vec[0]) * u.rad).to("deg") + self.phi_0
          err = np.sort([lon_err, lat_err])
          scale_r95 = Gauss2DPDF().containment_radius(0.95)
          err *= scale_r95
          if err[1] == lon_err * scale_r95:
              phi += 90 * u.deg
              height = 2 * err[1] * pars["lon_0"].unit
              width = 2 * err[0] * pars["lat_0"].unit
          else:
              height = 2 * err[1] * pars["lat_0"].unit
              width = 2 * err[0] * pars["lon_0"].unit
          return EllipseSkyRegion(
              center=self.position, height=height, width=width, angle=phi
          )
  
      def evaluate_geom(self, geom):
          """Evaluate model on `~gammapy.maps.Geom`."""
          coords = geom.get_coord(frame=self.frame)
          return self(coords.lon, coords.lat)
  
      def integrate_geom(self, geom):
          """Integrate model on `~gammapy.maps.Geom`."""
          values = self.evaluate_geom(geom)
          data = values * geom.solid_angle()
          return Map.from_geom(geom=geom, data=data.value, unit=data.unit)
  
      def to_dict(self):
          """Create dict for YAML serilisation"""
          data = super().to_dict()
          data["frame"] = self.frame
          data["parameters"] = data.pop("parameters")
          return data
  
      def plot(self, ax=None, geom=None, **kwargs):
          """Plot spatial model.
  
          Parameters
          ----------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          geom : `~gammapy.maps.WcsGeom`, optional
              Geom to use for plotting.
          **kwargs : dict
              Keyword arguments passed to `~gammapy.maps.WcsMap.plot()`
  
          Returns
          -------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          """
          if self.evaluation_radius is None and geom is None:
              raise ValueError(
                  f"{self.__class__.__name__} requires geom to be defined for plotting."
              )
  
          if geom is None:
              width = 2 * max(self.evaluation_radius, 0.1 * u.deg)
              geom = WcsGeom.create(
                  skydir=self.position, frame=self.frame, width=width, binsz=0.02
              )
  
          data = self.evaluate_geom(geom)
          m = Map.from_geom(geom, data=data.value, unit=data.unit)
          _, ax, _ = m.plot(ax=ax, **kwargs)
  
          return ax
  
      def plot_error(self, ax=None, **kwargs):
          """Plot position error
  
          Parameters
          ----------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          **kwargs : dict
              Keyword arguments passed to `~gammapy.maps.WcsMap.plot()`
  
          Returns
          -------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          """
          import matplotlib.pyplot as plt
  
          # plot center position
          lon, lat = self.lon_0.value, self.lat_0.value
  
          ax = plt.gca() if ax is None else ax
  
          kwargs.setdefault("marker", "x")
          kwargs.setdefault("color", "red")
          kwargs.setdefault("label", "position")
  
          ax.scatter(lon, lat, transform=ax.get_transform(self.frame), **kwargs)
  
          # plot position error
          if not np.all(self.covariance.data == 0):
              region = self.position_error.to_pixel(ax.wcs)
              artist = region.as_artist(facecolor="none", edgecolor=kwargs["color"])
              ax.add_artist(artist)
  
          return ax
  
  
  class PointSpatialModel(SpatialModel):
      r"""Point Source.
  
      For more information see :ref:`point-spatial-model`.
  
      Parameters
      ----------
      lon_0, lat_0 : `~astropy.coordinates.Angle`
          Center position
      frame : {"icrs", "galactic"}
          Center position coordinate frame
      """
  
      tag = "PointSpatialModel"
      lon_0 = Parameter("lon_0", "0 deg")
      lat_0 = Parameter("lat_0", "0 deg", min=-90, max=90)
  
      @property
      def evaluation_radius(self):
          """Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set as zero degrees.
          """
          return 0 * u.deg
  
      @staticmethod
      def _grid_weights(x, y, x0, y0):
          """Compute 4-pixel weights such that centroid is preserved."""
          dx = np.abs(x - x0)
          dx = np.where(dx < 1, 1 - dx, 0)
  
          dy = np.abs(y - y0)
          dy = np.where(dy < 1, 1 - dy, 0)
  
          return dx * dy
  
      def evaluate_geom(self, geom):
          """Evaluate model on `~gammapy.maps.Geom`."""
          values = self.integrate_geom(geom).data
          return values / geom.solid_angle()
  
      def integrate_geom(self, geom):
          """Integrate model on `~gammapy.maps.Geom`
  
          Parameters
          ----------
          geom : `Geom`
              Map geometry
  
          Returns
          -------
          flux : `Map`
              Predicted flux map
          """
          x, y = geom.get_pix()[0:2]
          x0, y0 = self.position.to_pixel(geom.wcs)
          data = self._grid_weights(x, y, x0, y0)
          return Map.from_geom(geom=geom, data=data, unit="")
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.PointSkyRegion`)."""
          return PointSkyRegion(center=self.position, **kwargs)
  
  
  class GaussianSpatialModel(SpatialModel):
      r"""Two-dimensional Gaussian model.
  
      For more information see :ref:`gaussian-spatial-model`.
  
      Parameters
      ----------
      lon_0, lat_0 : `~astropy.coordinates.Angle`
          Center position
      sigma : `~astropy.coordinates.Angle`
          Length of the major semiaxis of the Gaussian, in angular units.
      e : `float`
          Eccentricity of the Gaussian (: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 = "GaussianSpatialModel"
  
      lon_0 = Parameter("lon_0", "0 deg")
      lat_0 = Parameter("lat_0", "0 deg", min=-90, max=90)
      sigma = Parameter("sigma", "1 deg", min=0)
      e = Parameter("e", 0, min=0, max=1, frozen=True)
      phi = Parameter("phi", "0 deg", frozen=True)
  
      @property
      def evaluation_radius(self):
          r"""Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set as :math:`5\sigma`.
          """
          return 5 * self.parameters["sigma"].quantity
  
      @staticmethod
      def evaluate(lon, lat, lon_0, lat_0, sigma, e, phi):
          """Evaluate model."""
          sep = angular_separation(lon, lat, lon_0, lat_0)
  
          if e == 0:
              a = 1.0 - np.cos(sigma)
              norm = (1 / (4 * np.pi * a * (1.0 - np.exp(-1.0 / a)))).value
          else:
              minor_axis, sigma_eff = compute_sigma_eff(
                  lon_0, lat_0, lon, lat, phi, sigma, e
              )
              a = 1.0 - np.cos(sigma_eff)
              norm = (1 / (2 * np.pi * sigma * minor_axis)).to_value("sr-1")
  
          exponent = -0.5 * ((1 - np.cos(sep)) / a)
          return u.Quantity(norm * np.exp(exponent).value, "sr-1", copy=False)
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.EllipseSkyRegion`)."""
          minor_axis = Angle(self.sigma.quantity * np.sqrt(1 - self.e.quantity ** 2))
          return EllipseSkyRegion(
              center=self.position,
              height=2 * self.sigma.quantity,
              width=2 * minor_axis,
              angle=self.phi.quantity,
              **kwargs,
          )
  
  
  class DiskSpatialModel(SpatialModel):
      r"""Constant disk model.
  
      For more information see :ref:`disk-spatial-model`.
  
      Parameters
      ----------
      lon_0, lat_0 : `~astropy.coordinates.Angle`
          Center position
      r_0 : `~astropy.coordinates.Angle`
          :math:`a`: length of the major semiaxis, in angular units.
      e : `float`
          Eccentricity of the ellipse (:math:`0< e< 1`).
      phi : `~astropy.coordinates.Angle`
          Rotation angle :math:`\phi`: of the major semiaxis.
          Increases counter-clockwise from the North direction.
      edge : `~astropy.coordinates.Angle`
          Width of the edge. The width is defined as the range within the
          smooth edges of the model drops from 95% to 5% of its amplitude.
      frame : {"icrs", "galactic"}
          Center position coordinate frame
      """
  
      tag = "DiskSpatialModel"
      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", min=0)
      e = Parameter("e", 0, min=0, max=1, frozen=True)
      phi = Parameter("phi", "0 deg", frozen=True)
      edge = Parameter("edge", "0.01 deg", frozen=True, min=0.01)
  
      @property
      def evaluation_radius(self):
          """Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set to the length of the semi-major axis.
          """
          return self.r_0.quantity
  
      @staticmethod
      def _evaluate_norm_factor(r_0, e):
          """Compute the normalization factor."""
          semi_minor = r_0 * np.sqrt(1 - e ** 2)
  
          def integral_fcn(x, a, b):
              A = 1 / np.sin(a) ** 2
              B = 1 / np.sin(b) ** 2
              C = A - B
              cs2 = np.cos(x) ** 2
  
              return 1 - np.sqrt(1 - 1 / (B + C * cs2))
  
          return (
              2
              * scipy.integrate.quad(
                  lambda x: integral_fcn(x, r_0, semi_minor), 0, np.pi
              )[0]
          ) ** -1
  
      @staticmethod
      def _evaluate_smooth_edge(x, width):
          value = (x / width).to_value("")
          edge_width_95 = 2.326174307353347
          return 0.5 * (1 - scipy.special.erf(value * edge_width_95))
  
      @staticmethod
      def evaluate(lon, lat, lon_0, lat_0, r_0, e, phi, edge):
          """Evaluate model."""
          sep = angular_separation(lon, lat, lon_0, lat_0)
  
          if e == 0:
              sigma_eff = r_0
          else:
              sigma_eff = compute_sigma_eff(lon_0, lat_0, lon, lat, phi, r_0, e)[1]
  
          norm = DiskSpatialModel._evaluate_norm_factor(r_0, e)
  
          in_ellipse = DiskSpatialModel._evaluate_smooth_edge(sep - sigma_eff, edge)
          return u.Quantity(norm * in_ellipse, "sr-1", copy=False)
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.EllipseSkyRegion`)."""
          minor_axis = Angle(self.r_0.quantity * np.sqrt(1 - self.e.quantity ** 2))
          return EllipseSkyRegion(
              center=self.position,
              height=2 * self.r_0.quantity,
              width=2 * minor_axis,
              angle=self.phi.quantity,
              **kwargs,
          )
  
  
  class ShellSpatialModel(SpatialModel):
      r"""Shell model.
  
      For more information see :ref:`shell-spatial-model`.
  
      Parameters
      ----------
      lon_0, lat_0 : `~astropy.coordinates.Angle`
          Center position
      radius : `~astropy.coordinates.Angle`
          Inner radius, :math:`r_{in}`
      width : `~astropy.coordinates.Angle`
          Shell width
      frame : {"icrs", "galactic"}
          Center position coordinate frame
      """
  
      tag = "ShellSpatialModel"
      lon_0 = Parameter("lon_0", "0 deg")
      lat_0 = Parameter("lat_0", "0 deg", min=-90, max=90)
      radius = Parameter("radius", "1 deg")
      width = Parameter("width", "0.2 deg")
  
      @property
      def evaluation_radius(self):
          r"""Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set to :math:`r_\text{out}`.
          """
          return self.radius.quantity + self.width.quantity
  
      @staticmethod
      def evaluate(lon, lat, lon_0, lat_0, radius, width):
          """Evaluate model."""
          sep = angular_separation(lon, lat, lon_0, lat_0)
          radius_out = radius + width
  
          norm = 3 / (2 * np.pi * (radius_out ** 3 - radius ** 3))
  
          with np.errstate(invalid="ignore"):
              # np.where and np.select do not work with quantities, so we use the
              # workaround with indexing
              value = np.sqrt(radius_out ** 2 - sep ** 2)
              mask = [sep < radius]
              value[mask] = (value - np.sqrt(radius ** 2 - sep ** 2))[mask]
              value[sep > radius_out] = 0
  
          return norm * value
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.CircleAnnulusSkyRegion`)."""
          return CircleAnnulusSkyRegion(
              center=self.position,
              inner_radius=self.radius.quantity,
              outer_radius=self.radius.quantity + self.width.quantity,
              **kwargs,
          )
  
  
  class ConstantSpatialModel(SpatialModel):
      """Spatially constant (isotropic) spatial model.
  
      For more information see :ref:`constant-spatial-model`.
  
      Parameters
      ----------
      value : `~astropy.units.Quantity`
          Value
      """
  
      tag = "ConstantSpatialModel"
      value = Parameter("value", "1 sr-1", frozen=True)
  
      frame = "icrs"
      evaluation_radius = None
      position = SkyCoord("0 deg", "0 deg", frame=frame)
  
      def to_dict(self):
          """Create dict for YAML serilisation"""
          # redefined to ignore frame attribute from parent class
          data = super().to_dict()
          data.pop("frame")
          data["parameters"] = data.pop("parameters")
          return data
  
      @staticmethod
      def evaluate(lon, lat, value):
          """Evaluate model."""
          return value
  
      @staticmethod
      def to_region(**kwargs):
          """Model outline (`~regions.EllipseSkyRegion`)."""
          return EllipseSkyRegion(
              center=SkyCoord(np.nan * u.deg, np.nan * u.deg),
              height=np.nan * u.deg,
              width=np.nan * u.deg,
              angle=np.nan * u.deg,
              **kwargs,
          )
  
  
  class TemplateSpatialModel(SpatialModel):
      """Spatial sky map template model (2D).
  
      This is for a 2D image. The unit of the map has to be equivalent to ``sr-1``.
  
      Use `~gammapy.modeling.models.SkyDiffuseCube` for 3D cubes with
      an energy axis.
  
      For more information see :ref:`template-spatial-model`.
  
      Parameters
      ----------
      map : `~gammapy.maps.Map`
          Map template. The unit has to be equivalent to ``sr-1``.
      norm : float
          Norm parameter (multiplied with map values)
      meta : dict, optional
          Meta information, meta['filename'] will be used for serialization
      normalize : bool
          Normalize the input map so that it integrates to unity.
      interp_kwargs : dict
          Interpolation keyword arguments passed to `gammapy.maps.Map.interp_by_coord`.
          Default arguments are {'interp': 'linear', 'fill_value': 0}.
      """
  
      tag = "TemplateSpatialModel"
      norm = Parameter("norm", 1)
  
      def __init__(
          self,
          map,
          norm=norm.quantity,
          meta=None,
          normalize=True,
          interp_kwargs=None,
          filename=None,
      ):
          if (map.data < 0).any():
              log.warning("Diffuse map has negative values. Check and fix this!")
  
          if filename is not None:
              filename = str(make_path(filename))
  
          self.map = map
          if not self.map.unit.is_equivalent("sr-1"):
              raise ValueError("The map unit should be equivalent to sr-1")
          self.normalize = normalize
          if normalize:
              # Normalize the diffuse map model so that it integrates to unity."""
              data = self.map.data / self.map.data.sum()
              data /= self.map.geom.solid_angle().to_value("sr")
              self.map = self.map.copy(data=data, unit="sr-1")
  
          self.meta = dict() if meta is None else meta
          interp_kwargs = {} if interp_kwargs is None else interp_kwargs
          interp_kwargs.setdefault("interp", "linear")
          interp_kwargs.setdefault("fill_value", 0)
          self._interp_kwargs = interp_kwargs
          self.filename = filename
          super().__init__(norm=norm)
  
      @property
      def evaluation_radius(self):
          """Evaluation radius (`~astropy.coordinates.Angle`).
  
          Set to half of the maximal dimension of the map.
          """
          return np.max(self.map.geom.width) / 2.0
  
      @classmethod
      def read(cls, filename, normalize=True, **kwargs):
          """Read spatial template model from FITS image.
  
          The unit of the map has to be equivalent to ``sr-1``. If no unit the default is ``sr-1``.
  
          Parameters
          ----------
          filename : str
              FITS image filename.
          normalize : bool
              Normalize the input map so that it integrates to unity.
          kwargs : dict
              Keyword arguments passed to `Map.read()`.
          """
          m = Map.read(filename, **kwargs)
  
          if not m.unit.is_equivalent("sr-1"):
              m.unit = "sr-1"
              log.warning(
                  "Spatial template unit is not equivalent to sr^-1, unit changed to sr^-1"
              )
  
          return cls(m, normalize=normalize, filename=filename)
  
      def evaluate(self, lon, lat, norm):
          """Evaluate model."""
          coord = {"lon": lon.to_value("deg"), "lat": lat.to_value("deg")}
          val = self.map.interp_by_coord(coord, **self._interp_kwargs)
          return u.Quantity(norm.value * val, self.map.unit, copy=False)
  
      @property
      def position(self):
          """`~astropy.coordinates.SkyCoord`"""
          return self.map.geom.center_skydir
  
      @property
      def frame(self):
          return self.position.frame.name
  
      @classmethod
      def from_dict(cls, data):
          m = Map.read(data["filename"])
  
          if not m.unit.is_equivalent("sr-1"):
              m.unit = "sr-1"
              log.warning(
                  "Spatial template unit is not equivalent to sr^-1, unit changed to sr^-1"
              )
  
          parameters = Parameters.from_dict(data["parameters"])
          return cls.from_parameters(
              parameters=parameters,
              map=m,
              filename=data["filename"],
              normalize=data.get("normalize", True),
          )
  
      def to_dict(self):
          """Create dict for YAML serilisation"""
          data = super().to_dict()
          data["filename"] = self.filename
          data["normalize"] = self.normalize
          return data
  
      def to_region(self, **kwargs):
          """Model outline (`~regions.PolygonSkyRegion`)."""
          footprint = self.map.geom.wcs.calc_footprint()
          return PolygonSkyRegion(
              vertices=SkyCoord(footprint, unit="deg", frame=self.frame, **kwargs)
          )
  
files: gammapy/modeling/models/spatial.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  """Spectral models for Gammapy."""
  import operator
  import numpy as np
  import scipy.optimize
  import scipy.special
  import astropy.units as u
  from astropy import constants as const
  from astropy.table import Table
  from gammapy.maps import MapAxis
  from gammapy.maps.utils import edges_from_lo_hi
  from gammapy.modeling import Covariance, Parameter, Parameters
  from gammapy.utils.integrate import evaluate_integral_pwl, trapz_loglog
  from gammapy.utils.interpolation import ScaledRegularGridInterpolator
  from gammapy.utils.scripts import make_path
  from .core import Model
  
  
  def integrate_spectrum(func, emin, emax, ndecade=100, intervals=False):
      """Integrate 1d function using the log-log trapezoidal rule.
  
      If scalar values for xmin and xmax are passed an oversampled grid is generated using the
      ``ndecade`` keyword argument. If xmin and xmax arrays are passed, no
      oversampling is performed and the integral is computed in the provided
      grid.
  
      Parameters
      ----------
      func : callable
          Function to integrate.
      emin : `~astropy.units.Quantity`
          Integration range minimum
      emax : `~astropy.units.Quantity`
          Integration range minimum
      ndecade : int, optional
          Number of grid points per decade used for the integration.
          Default : 100.
      intervals : bool, optional
          Return integrals in the grid not the sum, default: False
      """
      if emin.isscalar and emax.isscalar:
          energies = MapAxis.from_energy_bounds(
              emin=emin, emax=emax, nbin=ndecade, per_decade=True
          ).edges
      else:
          energies = edges_from_lo_hi(emin, emax)
  
      values = func(energies)
  
      integral = trapz_loglog(values, energies)
  
      if intervals:
          return integral
  
      return integral.sum()
  
  
  class SpectralModel(Model):
      """Spectral model base class."""
  
      def __call__(self, energy):
          kwargs = {par.name: par.quantity for par in self.parameters}
          kwargs = self._convert_evaluate_unit(kwargs, energy)
          return self.evaluate(energy, **kwargs)
  
      @staticmethod
      def _convert_evaluate_unit(kwargs_ref, energy):
          kwargs = {}
          for name, quantity in kwargs_ref.items():
              if quantity.unit.physical_type == "energy":
                  quantity = quantity.to(energy.unit)
              kwargs[name] = quantity
          return kwargs
  
      def __add__(self, model):
          if not isinstance(model, SpectralModel):
              model = ConstantSpectralModel(const=model)
          return CompoundSpectralModel(self, model, operator.add)
  
      def __radd__(self, model):
          return self.__add__(model)
  
      def __sub__(self, model):
          if not isinstance(model, SpectralModel):
              model = ConstantSpectralModel(const=model)
          return CompoundSpectralModel(self, model, operator.sub)
  
      def __rsub__(self, model):
          return self.__sub__(model)
  
      def _evaluate_gradient(self, energy, eps):
          n = len(self.parameters)
          f = self(energy)
          shape = (n, len(np.atleast_1d(energy)))
          df_dp = np.zeros(shape)
  
          for idx, parameter in enumerate(self.parameters):
              if parameter.frozen or eps[idx] == 0:
                  continue
  
              parameter.value += eps[idx]
              df = self(energy) - f
              df_dp[idx] = df.value / eps[idx]
  
              # Reset model to original parameter
              parameter.value -= eps[idx]
  
          return df_dp
  
      def evaluate_error(self, energy, epsilon=1e-4):
          """Evaluate spectral model with error propagation.
  
          Parameters
          ----------
          energy : `~astropy.units.Quantity`
              Energy at which to evaluate
          epsilon : float
              Step size of the gradient evaluation. Given as a
              fraction of the parameter error.
  
          Returns
          -------
          dnde, dnde_error : tuple of `~astropy.units.Quantity`
              Tuple of flux and flux error.
          """
          p_cov = self.covariance
          eps = np.sqrt(np.diag(p_cov)) * epsilon
  
          df_dp = self._evaluate_gradient(energy, eps)
          f_cov = df_dp.T @ p_cov @ df_dp
          f_err = np.sqrt(np.diagonal(f_cov))
  
          q = self(energy)
          return u.Quantity([q.value, f_err], unit=q.unit)
  
      def integral(self, emin, emax, **kwargs):
          r"""Integrate spectral model numerically.
  
          .. math::
              F(E_{min}, E_{max}) = \int_{E_{min}}^{E_{max}} \phi(E) dE
  
          If array input for ``emin`` and ``emax`` is given you have to set
          ``intervals=True`` if you want the integral in each energy bin.
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          **kwargs : dict
              Keyword arguments passed to :func:`~gammapy.utils.integrate.integrate_spectrum`
          """
          return integrate_spectrum(self, emin, emax, **kwargs)
  
      def energy_flux(self, emin, emax, **kwargs):
          r"""Compute energy flux in given energy range.
  
          .. math::
              G(E_{min}, E_{max}) = \int_{E_{min}}^{E_{max}} E \phi(E) dE
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          **kwargs : dict
              Keyword arguments passed to func:`~gammapy.utils.integrate.integrate_spectrum`
          """
  
          def f(x):
              return x * self(x)
  
          return integrate_spectrum(f, emin, emax, **kwargs)
  
      def plot(
          self,
          energy_range,
          ax=None,
          energy_unit="TeV",
          flux_unit="cm-2 s-1 TeV-1",
          energy_power=0,
          n_points=100,
          **kwargs,
      ):
          """Plot spectral model curve.
  
          kwargs are forwarded to `matplotlib.pyplot.plot`
  
          By default a log-log scaling of the axes is used, if you want to change
          the y axis scaling to linear you can use::
  
              from gammapy.modeling.models import ExpCutoffPowerLawSpectralModel
              from astropy import units as u
  
              pwl = ExpCutoffPowerLawSpectralModel()
              ax = pwl.plot(energy_range=(0.1, 100) * u.TeV)
              ax.set_yscale('linear')
  
          Parameters
          ----------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          energy_range : `~astropy.units.Quantity`
              Plot range
          energy_unit : str, `~astropy.units.Unit`, optional
              Unit of the energy axis
          flux_unit : str, `~astropy.units.Unit`, optional
              Unit of the flux axis
          energy_power : int, optional
              Power of energy to multiply flux axis with
          n_points : int, optional
              Number of evaluation nodes
  
          Returns
          -------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          """
          import matplotlib.pyplot as plt
  
          ax = plt.gca() if ax is None else ax
  
          emin, emax = energy_range
          energy = MapAxis.from_energy_bounds(emin, emax, n_points, energy_unit).edges
  
          # evaluate model
          flux = self(energy).to(flux_unit)
  
          y = self._plot_scale_flux(energy, flux, energy_power)
  
          ax.plot(energy.value, y.value, **kwargs)
  
          self._plot_format_ax(ax, energy, y, energy_power)
          return ax
  
      def plot_error(
          self,
          energy_range,
          ax=None,
          energy_unit="TeV",
          flux_unit="cm-2 s-1 TeV-1",
          energy_power=0,
          n_points=100,
          **kwargs,
      ):
          """Plot spectral model error band.
  
          .. note::
  
              This method calls ``ax.set_yscale("log", nonposy='clip')`` and
              ``ax.set_xscale("log", nonposx='clip')`` to create a log-log representation.
              The additional argument ``nonposx='clip'`` avoids artefacts in the plot,
              when the error band extends to negative values (see also
              https://github.com/matplotlib/matplotlib/issues/8623).
  
              When you call ``plt.loglog()`` or ``plt.semilogy()`` explicitely in your
              plotting code and the error band extends to negative values, it is not
              shown correctly. To circumvent this issue also use
              ``plt.loglog(nonposx='clip', nonposy='clip')``
              or ``plt.semilogy(nonposy='clip')``.
  
          Parameters
          ----------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          energy_range : `~astropy.units.Quantity`
              Plot range
          energy_unit : str, `~astropy.units.Unit`, optional
              Unit of the energy axis
          flux_unit : str, `~astropy.units.Unit`, optional
              Unit of the flux axis
          energy_power : int, optional
              Power of energy to multiply flux axis with
          n_points : int, optional
              Number of evaluation nodes
          **kwargs : dict
              Keyword arguments forwarded to `matplotlib.pyplot.fill_between`
  
          Returns
          -------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          """
          import matplotlib.pyplot as plt
  
          ax = plt.gca() if ax is None else ax
  
          kwargs.setdefault("facecolor", "black")
          kwargs.setdefault("alpha", 0.2)
          kwargs.setdefault("linewidth", 0)
  
          emin, emax = energy_range
          energy = MapAxis.from_energy_bounds(emin, emax, n_points, energy_unit).edges
  
          flux, flux_err = self.evaluate_error(energy).to(flux_unit)
  
          y_lo = self._plot_scale_flux(energy, flux - flux_err, energy_power)
          y_hi = self._plot_scale_flux(energy, flux + flux_err, energy_power)
  
          where = (energy >= energy_range[0]) & (energy <= energy_range[1])
          ax.fill_between(energy.value, y_lo.value, y_hi.value, where=where, **kwargs)
  
          self._plot_format_ax(ax, energy, y_lo, energy_power)
          return ax
  
      @staticmethod
      def _plot_format_ax(ax, energy, y, energy_power):
          ax.set_xlabel(f"Energy [{energy.unit}]")
          if energy_power > 0:
              ax.set_ylabel(f"E{energy_power} * Flux [{y.unit}]")
          else:
              ax.set_ylabel(f"Flux [{y.unit}]")
  
          ax.set_xscale("log", nonposx="clip")
          ax.set_yscale("log", nonposy="clip")
  
      @staticmethod
      def _plot_scale_flux(energy, flux, energy_power):
          try:
              eunit = [_ for _ in flux.unit.bases if _.physical_type == "energy"][0]
          except IndexError:
              eunit = energy.unit
          y = flux * np.power(energy, energy_power)
          return y.to(flux.unit * eunit ** energy_power)
  
      def spectral_index(self, energy, epsilon=1e-5):
          """Compute spectral index at given energy.
  
          Parameters
          ----------
          energy : `~astropy.units.Quantity`
              Energy at which to estimate the index
          epsilon : float
              Fractional energy increment to use for determining the spectral index.
  
          Returns
          -------
          index : float
              Estimated spectral index.
          """
          f1 = self(energy)
          f2 = self(energy * (1 + epsilon))
          return np.log(f1 / f2) / np.log(1 + epsilon)
  
      def inverse(self, value, emin=0.1 * u.TeV, emax=100 * u.TeV):
          """Return energy for a given function value of the spectral model.
  
          Calls the `scipy.optimize.brentq` numerical root finding method.
  
          Parameters
          ----------
          value : `~astropy.units.Quantity`
              Function value of the spectral model.
          emin : `~astropy.units.Quantity`
              Lower bracket value in case solution is not unique.
          emax : `~astropy.units.Quantity`
              Upper bracket value in case solution is not unique.
  
          Returns
          -------
          energy : `~astropy.units.Quantity`
              Energies at which the model has the given ``value``.
          """
          eunit = "TeV"
  
          energies = []
          for val in np.atleast_1d(value):
  
              def f(x):
                  # scale by 1e12 to achieve better precision
                  energy = u.Quantity(x, eunit, copy=False)
                  y = self(energy).to_value(value.unit)
                  return 1e12 * (y - val.value)
  
              energy = scipy.optimize.brentq(
                  f, emin.to_value(eunit), emax.to_value(eunit)
              )
              energies.append(energy)
  
          return u.Quantity(energies, eunit, copy=False)
  
  
  class ConstantSpectralModel(SpectralModel):
      r"""Constant model.
  
      For more information see :ref:`constant-spectral-model`.
  
      Parameters
      ----------
      const : `~astropy.units.Quantity`
          :math:`k`
      """
  
      tag = ["ConstantSpectralModel", "ConstantSpectral"]
      const = Parameter("const", "1e-12 cm-2 s-1 TeV-1")
  
      @staticmethod
      def evaluate(energy, const):
          """Evaluate the model (static function)."""
          return np.ones(np.atleast_1d(energy).shape) * const
  
  
  class CompoundSpectralModel(SpectralModel):
      """Arithmetic combination of two spectral models.
  
      For more information see :ref:`compound-spectral-model`.
      """
  
      tag = ["CompoundSpectralModel", "CompoundSpectral"]
  
      def __init__(self, model1, model2, operator):
          self.model1 = model1
          self.model2 = model2
          self.operator = operator
          super().__init__()
  
      @property
      def parameters(self):
          return self.model1.parameters + self.model2.parameters
  
      def __str__(self):
          return (
              f"{self.__class__.__name__}\n"
              f"    Component 1 : {self.model1}\n"
              f"    Component 2 : {self.model2}\n"
              f"    Operator : {self.operator}\n"
          )
  
      def __call__(self, energy):
          val1 = self.model1(energy)
          val2 = self.model2(energy)
          return self.operator(val1, val2)
  
      def to_dict(self):
          return {
              "model1": self.model1.to_dict(),
              "model2": self.model2.to_dict(),
              "operator": self.operator,
          }
  
  
  class PowerLawSpectralModel(SpectralModel):
      r"""Spectral power-law model.
  
      For more information see :ref:`powerlaw-spectral-model`.
  
      Parameters
      ----------
      index : `~astropy.units.Quantity`
          :math:`\Gamma`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      """
  
      tag = ["PowerLawSpectralModel", "PL"]
      index = Parameter("index", 2.0)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      evaluate_integral = staticmethod(evaluate_integral_pwl)
  
      @staticmethod
      def evaluate(energy, index, amplitude, reference):
          """Evaluate the model (static function)."""
          return amplitude * np.power((energy / reference), -index)
  
      @staticmethod
      def evaluate_energy_flux(emin, emax, index, amplitude, reference):
          """Evaluate the energy flux (static function)"""
          val = -1 * index + 2
  
          prefactor = amplitude * reference ** 2 / val
          upper = (emax / reference) ** val
          lower = (emin / reference) ** val
          energy_flux = prefactor * (upper - lower)
  
          mask = np.isclose(val, 0)
  
          if mask.any():
              # see https://www.wolframalpha.com/input/?i=a+*+x+*+(x%2Fb)+%5E+(-2)
              # for reference
              energy_flux[mask] = amplitude * reference ** 2 * np.log(emax / emin)[mask]
  
          return energy_flux
  
      def integral(self, emin, emax, **kwargs):
          r"""Integrate power law analytically.
  
          .. math::
              F(E_{min}, E_{max}) = \int_{E_{min}}^{E_{max}}\phi(E)dE = \left.
              \phi_0 \frac{E_0}{-\Gamma + 1} \left( \frac{E}{E_0} \right)^{-\Gamma + 1}
              \right \vert _{E_{min}}^{E_{max}}
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range
          """
          kwargs = {par.name: par.quantity for par in self.parameters}
          kwargs = self._convert_evaluate_unit(kwargs, emin)
          return self.evaluate_integral(emin=emin, emax=emax, **kwargs)
  
      def energy_flux(self, emin, emax):
          r"""Compute energy flux in given energy range analytically.
  
          .. math::
              G(E_{min}, E_{max}) = \int_{E_{min}}^{E_{max}}E \phi(E)dE = \left.
              \phi_0 \frac{E_0^2}{-\Gamma + 2} \left( \frac{E}{E_0} \right)^{-\Gamma + 2}
              \right \vert _{E_{min}}^{E_{max}}
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          """
          kwargs = {par.name: par.quantity for par in self.parameters}
          kwargs = self._convert_evaluate_unit(kwargs, emin)
          return self.evaluate_energy_flux(emin=emin, emax=emax, **kwargs)
  
      def inverse(self, value):
          """Return energy for a given function value of the spectral model.
  
          Parameters
          ----------
          value : `~astropy.units.Quantity`
              Function value of the spectral model.
          """
          base = value / self.amplitude.quantity
          return self.reference.quantity * np.power(base, -1.0 / self.index.value)
  
      @property
      def pivot_energy(self):
          r"""The decorrelation energy is defined as:
  
          .. math::
  
              E_D = E_0 * \exp{cov(\phi_0, \Gamma) / (\phi_0 \Delta \Gamma^2)}
  
          Formula (1) in https://arxiv.org/pdf/0910.4881.pdf
          """
          index_err = self.index.error
          reference = self.reference.quantity
          amplitude = self.amplitude.quantity
          cov_index_ampl = self.covariance.data[0, 1] * amplitude.unit
          return reference * np.exp(cov_index_ampl / (amplitude * index_err ** 2))
  
  
  class PowerLaw2SpectralModel(SpectralModel):
      r"""Spectral power-law model with integral as amplitude parameter.
  
      For more information see :ref:`powerlaw2-spectral-model`.
  
      Parameters
      ----------
      index : `~astropy.units.Quantity`
          Spectral index :math:`\Gamma`
      amplitude : `~astropy.units.Quantity`
          Integral flux :math:`F_0`.
      emin : `~astropy.units.Quantity`
          Lower energy limit :math:`E_{0, min}`.
      emax : `~astropy.units.Quantity`
          Upper energy limit :math:`E_{0, max}`.
      """
  
      tag = ["PowerLaw2SpectralModel", "PL2"]
  
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1")
      index = Parameter("index", 2)
      emin = Parameter("emin", "0.1 TeV", frozen=True)
      emax = Parameter("emax", "100 TeV", frozen=True)
  
      @staticmethod
      def evaluate(energy, amplitude, index, emin, emax):
          """Evaluate the model (static function)."""
          top = -index + 1
  
          # to get the energies dimensionless we use a modified formula
          bottom = emax - emin * (emin / emax) ** (-index)
          return amplitude * (top / bottom) * np.power(energy / emax, -index)
  
      def integral(self, emin, emax, **kwargs):
          r"""Integrate power law analytically.
  
          .. math::
              F(E_{min}, E_{max}) = F_0 \cdot \frac{E_{max}^{\Gamma + 1} \
                                  - E_{min}^{\Gamma + 1}}{E_{0, max}^{\Gamma + 1} \
                                  - E_{0, min}^{\Gamma + 1}}
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          """
          temp1 = np.power(emax, -self.index.value + 1)
          temp2 = np.power(emin, -self.index.value + 1)
          top = temp1 - temp2
  
          temp1 = np.power(self.emax.quantity, -self.index.value + 1)
          temp2 = np.power(self.emin.quantity, -self.index.value + 1)
          bottom = temp1 - temp2
  
          return self.amplitude.quantity * top / bottom
  
      def inverse(self, value):
          """Return energy for a given function value of the spectral model.
  
          Parameters
          ----------
          value : `~astropy.units.Quantity`
              Function value of the spectral model.
          """
          amplitude = self.amplitude.quantity
          index = self.index.value
          emin = self.emin.quantity
          emax = self.emax.quantity
  
          # to get the energies dimensionless we use a modified formula
          top = -index + 1
          bottom = emax - emin * (emin / emax) ** (-index)
          term = (bottom / top) * (value / amplitude)
          return np.power(term.to_value(""), -1.0 / index) * emax
  
  
  class BrokenPowerLawSpectralModel(SpectralModel):
      r"""Spectral broken power-law model.
  
      For more information see :ref:`broken-powerlaw-spectral-model`.
  
      Parameters
      ----------
      index1 : `~astropy.units.Quantity`
          :math:`\Gamma1`
      index2 : `~astropy.units.Quantity`
          :math:`\Gamma2`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      ebreak : `~astropy.units.Quantity`
          :math:`E_{break}`
      """
  
      tag = "BrokenPowerLawSpectralModel"
      index1 = Parameter("index1", 2.0)
      index2 = Parameter("index2", 2.0)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      ebreak = Parameter("ebreak", "1 TeV")
  
      @staticmethod
      def evaluate(energy, index1, index2, amplitude, ebreak):
          """Evaluate the model (static function)."""
          energy = np.atleast_1d(energy)
          cond = energy < ebreak
          bpwl = amplitude * np.ones(energy.shape)
          bpwl[cond] *= (energy[cond] / ebreak) ** (-index1)
          bpwl[~cond] *= (energy[~cond] / ebreak) ** (-index2)
          return bpwl
  
  
  class SmoothBrokenPowerLawSpectralModel(SpectralModel):
      r"""Spectral smooth broken power-law model.
  
      For more information see :ref:`smooth-broken-powerlaw-spectral-model`.
  
      Parameters
      ----------
      index1 : `~astropy.units.Quantity`
          :math:`\Gamma1`
      index2 : `~astropy.units.Quantity`
          :math:`\Gamma2`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      ebreak : `~astropy.units.Quantity`
          :math:`E_{break}`
      beta : `~astropy.units.Quantity`
          :math:`\beta`
      """
  
      tag = ["SmoothBrokenPowerLawSpectralModel", "SBPL"]
      index1 = Parameter("index1", 2.0)
      index2 = Parameter("index2", 2.0)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      ebreak = Parameter("ebreak", "1 TeV")
      reference = Parameter("reference", "1 TeV", frozen=True)
      beta = Parameter("beta", 1, frozen=True)
  
      @staticmethod
      def evaluate(energy, index1, index2, amplitude, ebreak, reference, beta):
          """Evaluate the model (static function)."""
          beta *= np.sign(index2 - index1)
          pwl = amplitude * (energy / reference) ** (-index1)
          brk = (1 + (energy / ebreak) ** ((index2 - index1) / beta)) ** (-beta)
          return pwl * brk
  
  
  class ExpCutoffPowerLawSpectralModel(SpectralModel):
      r"""Spectral exponential cutoff power-law model.
  
      For more information see :ref:`exp-cutoff-powerlaw-spectral-model`.
  
      Parameters
      ----------
      index : `~astropy.units.Quantity`
          :math:`\Gamma`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      lambda_ : `~astropy.units.Quantity`
          :math:`\lambda`
      alpha : `~astropy.units.Quantity`
          :math:`\alpha`
      """
  
      tag = ["ExpCutoffPowerLawSpectralModel", "ECPL"]
  
      index = Parameter("index", 1.5)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      lambda_ = Parameter("lambda_", "0.1 TeV-1")
      alpha = Parameter("alpha", "1.0", frozen=True)
  
      @staticmethod
      def evaluate(energy, index, amplitude, reference, lambda_, alpha):
          """Evaluate the model (static function)."""
          pwl = amplitude * (energy / reference) ** (-index)
          cutoff = np.exp(-np.power(energy * lambda_, alpha))
  
          return pwl * cutoff
  
      @property
      def e_peak(self):
          r"""Spectral energy distribution peak energy (`~astropy.units.Quantity`).
  
          This is the peak in E^2 x dN/dE and is given by:
  
          .. math::
              E_{Peak} =  \left(\frac{2 - \Gamma}{\alpha}\right)^{1/\alpha} / \lambda
          """
          reference = self.reference.quantity
          index = self.index.quantity
          lambda_ = self.lambda_.quantity
          alpha = self.alpha.quantity
  
          if index >= 2 or lambda_ == 0.0 or alpha == 0.0:
              return np.nan * reference.unit
          else:
              return np.power((2 - index) / alpha, 1 / alpha) / lambda_
  
  
  class ExpCutoffPowerLaw3FGLSpectralModel(SpectralModel):
      r"""Spectral exponential cutoff power-law model used for 3FGL.
  
      For more information see :ref:`exp-cutoff-powerlaw-3fgl-spectral-model`.
  
      Parameters
      ----------
      index : `~astropy.units.Quantity`
          :math:`\Gamma`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      ecut : `~astropy.units.Quantity`
          :math:`E_{C}`
      """
  
      tag = ["ExpCutoffPowerLaw3FGLSpectralModel", "ECPL3FGL"]
      index = Parameter("index", 1.5)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      ecut = Parameter("ecut", "10 TeV")
  
      @staticmethod
      def evaluate(energy, index, amplitude, reference, ecut):
          """Evaluate the model (static function)."""
          pwl = amplitude * (energy / reference) ** (-index)
          cutoff = np.exp((reference - energy) / ecut)
          return pwl * cutoff
  
  
  class SuperExpCutoffPowerLaw3FGLSpectralModel(SpectralModel):
      r"""Spectral super exponential cutoff power-law model used for 3FGL.
  
      For more information see :ref:`super-exp-cutoff-powerlaw-3fgl-spectral-model`.
  
      .. math::
          \phi(E) = \phi_0 \cdot \left(\frac{E}{E_0}\right)^{-\Gamma_1}
                    \exp \left( \left(\frac{E_0}{E_{C}} \right)^{\Gamma_2} -
                                \left(\frac{E}{E_{C}} \right)^{\Gamma_2}
                                \right)
  
      Parameters
      ----------
      index_1 : `~astropy.units.Quantity`
          :math:`\Gamma_1`
      index_2 : `~astropy.units.Quantity`
          :math:`\Gamma_2`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      ecut : `~astropy.units.Quantity`
          :math:`E_{C}`
      """
  
      tag = ["SuperExpCutoffPowerLaw3FGLSpectralModel", "SECPL3FGL"]
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      ecut = Parameter("ecut", "10 TeV")
      index_1 = Parameter("index_1", 1.5)
      index_2 = Parameter("index_2", 2)
  
      @staticmethod
      def evaluate(energy, amplitude, reference, ecut, index_1, index_2):
          """Evaluate the model (static function)."""
          pwl = amplitude * (energy / reference) ** (-index_1)
          cutoff = np.exp((reference / ecut) ** index_2 - (energy / ecut) ** index_2)
          return pwl * cutoff
  
  
  class SuperExpCutoffPowerLaw4FGLSpectralModel(SpectralModel):
      r"""Spectral super exponential cutoff power-law model used for 4FGL.
  
      For more information see :ref:`super-exp-cutoff-powerlaw-4fgl-spectral-model`.
  
      Parameters
      ----------
      index_1 : `~astropy.units.Quantity`
          :math:`\Gamma_1`
      index_2 : `~astropy.units.Quantity`
          :math:`\Gamma_2`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      expfactor : `~astropy.units.Quantity`
          :math:`a`, given as dimensionless value but
          internally assumes unit of :math:`[E_0]` power :math:`-\Gamma_2`
      """
  
      tag = ["SuperExpCutoffPowerLaw4FGLSpectralModel", "SECPL4FGL"]
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      expfactor = Parameter("expfactor", "1e-2")
      index_1 = Parameter("index_1", 1.5)
      index_2 = Parameter("index_2", 2)
  
      @staticmethod
      def evaluate(energy, amplitude, reference, expfactor, index_1, index_2):
          """Evaluate the model (static function)."""
          pwl = amplitude * (energy / reference) ** (-index_1)
          cutoff = np.exp(
              expfactor
              / reference.unit ** index_2
              * (reference ** index_2 - energy ** index_2)
          )
          return pwl * cutoff
  
  
  class LogParabolaSpectralModel(SpectralModel):
      r"""Spectral log parabola model.
  
      For more information see :ref:`logparabola-spectral-model`.
  
      Parameters
      ----------
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      alpha : `~astropy.units.Quantity`
          :math:`\alpha`
      beta : `~astropy.units.Quantity`
          :math:`\beta`
      """
  
      tag = ["LogParabolaSpectralModel", "LP", "logpar"]
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "10 TeV", frozen=True)
      alpha = Parameter("alpha", 2)
      beta = Parameter("beta", 1)
  
      @classmethod
      def from_log10(cls, amplitude, reference, alpha, beta):
          """Construct from :math:`log_{10}` parametrization."""
          beta_ = beta / np.log(10)
          return cls(amplitude=amplitude, reference=reference, alpha=alpha, beta=beta_)
  
      @staticmethod
      def evaluate(energy, amplitude, reference, alpha, beta):
          """Evaluate the model (static function)."""
          xx = energy / reference
          exponent = -alpha - beta * np.log(xx)
          return amplitude * np.power(xx, exponent)
  
      @property
      def e_peak(self):
          r"""Spectral energy distribution peak energy (`~astropy.units.Quantity`).
  
          This is the peak in E^2 x dN/dE and is given by:
  
          .. math::
              E_{Peak} = E_{0} \exp{ (2 - \alpha) / (2 * \beta)}
          """
          reference = self.reference.quantity
          alpha = self.alpha.quantity
          beta = self.beta.quantity
          return reference * np.exp((2 - alpha) / (2 * beta))
  
  
  class TemplateSpectralModel(SpectralModel):
      """A model generated from a table of energy and value arrays.
  
      For more information see :ref:`template-spectral-model`.
  
      Parameters
      ----------
      energy : `~astropy.units.Quantity`
          Array of energies at which the model values are given
      values : array
          Array with the values of the model at energies ``energy``.
      norm : float
          Model scale that is multiplied to the supplied arrays. Defaults to 1.
      interp_kwargs : dict
          Interpolation keyword arguments pass to `scipy.interpolate.interp1d`.
          By default all values outside the interpolation range are set to zero.
          If you want to apply linear extrapolation you can pass `interp_kwargs={'fill_value':
          'extrapolate', 'kind': 'linear'}`. If you want to choose the interpolation
          scaling applied to values, you can use `interp_kwargs={"values_scale": "log"}`.
      meta : dict, optional
          Meta information, meta['filename'] will be used for serialization
      """
  
      tag = ["TemplateSpectralModel", "TemplateSpectral"]
      norm = Parameter("norm", 1, unit="")
      tilt = Parameter("tilt", 0, unit="", frozen=True)
      reference = Parameter("reference", "1 TeV", frozen=True)
  
      def __init__(
          self,
          energy,
          values,
          norm=norm.quantity,
          tilt=tilt.quantity,
          reference=reference.quantity,
          interp_kwargs=None,
          meta=None,
      ):
          self.energy = energy
          self.values = u.Quantity(values, copy=False)
          self.meta = dict() if meta is None else meta
          interp_kwargs = interp_kwargs or {}
          interp_kwargs.setdefault("values_scale", "log")
          interp_kwargs.setdefault("points_scale", ("log",))
  
          self._evaluate = ScaledRegularGridInterpolator(
              points=(energy,), values=values, **interp_kwargs
          )
  
          super().__init__(norm=norm, tilt=tilt, reference=reference)
  
      @classmethod
      def read_xspec_model(cls, filename, param, **kwargs):
          """Read XSPEC table model.
  
          The input is a table containing absorbed values from a XSPEC model as a
          function of energy.
  
          TODO: Format of the file should be described and discussed in
          https://gamma-astro-data-formats.readthedocs.io/en/latest/index.html
  
          Parameters
          ----------
          filename : str
              File containing the XSPEC model
          param : float
              Model parameter value
  
          Examples
          --------
          Fill table from an EBL model (Franceschini, 2008)
  
          >>> from gammapy.modeling.models import TemplateSpectralModel
          >>> filename = '$GAMMAPY_DATA/ebl/ebl_franceschini.fits.gz'
          >>> table_model = TemplateSpectralModel.read_xspec_model(filename=filename, param=0.3)
          """
          filename = make_path(filename)
  
          # Check if parameter value is in range
          table_param = Table.read(filename, hdu="PARAMETERS")
          pmin = table_param["MINIMUM"]
          pmax = table_param["MAXIMUM"]
          if param < pmin or param > pmax:
              raise ValueError(f"Out of range: param={param}, min={pmin}, max={pmax}")
  
          # Get energy values
          table_energy = Table.read(filename, hdu="ENERGIES")
          energy_lo = table_energy["ENERG_LO"]
          energy_hi = table_energy["ENERG_HI"]
  
          # set energy to log-centers
          energy = np.sqrt(energy_lo * energy_hi)
  
          # Get spectrum values (no interpolation, take closest value for param)
          table_spectra = Table.read(filename, hdu="SPECTRA")
          idx = np.abs(table_spectra["PARAMVAL"] - param).argmin()
          values = u.Quantity(table_spectra[idx][1], "", copy=False)  # no dimension
  
          kwargs.setdefault("interp_kwargs", {"values_scale": "lin"})
          return cls(energy=energy, values=values, **kwargs)
  
      def evaluate(self, energy, norm, tilt, reference):
          """Evaluate the model (static function)."""
          values = self._evaluate((energy,), clip=True)
          tilt_factor = np.power(energy / reference, -tilt)
          return norm * values * tilt_factor
  
      def to_dict(self):
          return {
              "type": self.tag[0],
              "parameters": self.parameters.to_dict(),
              "energy": {
                  "data": self.energy.data.tolist(),
                  "unit": str(self.energy.unit),
              },
              "values": {
                  "data": self.values.data.tolist(),
                  "unit": str(self.values.unit),
              },
          }
  
      @classmethod
      def from_dict(cls, data):
          parameters = Parameters.from_dict(data["parameters"])
          energy = u.Quantity(data["energy"]["data"], data["energy"]["unit"])
          values = u.Quantity(data["values"]["data"], data["values"]["unit"])
          return cls.from_parameters(parameters, energy=energy, values=values)
  
  
  class ScaleSpectralModel(SpectralModel):
      """Wrapper to scale another spectral model by a norm factor.
  
      Parameters
      ----------
      model : `SpectralModel`
          Spectral model to wrap.
      norm : float
          Multiplicative norm factor for the model value.
      """
  
      tag = ["ScaleSpectralModel", "ScaleSpectral"]
      norm = Parameter("norm", 1, unit="")
  
      def __init__(self, model, norm=norm.quantity):
          self.model = model
          self._covariance = None
          super().__init__(norm=norm)
  
      def evaluate(self, energy, norm):
          return norm * self.model(energy)
  
      def integral(self, emin, emax, **kwargs):
          return self.norm.value * self.model.integral(emin, emax, **kwargs)
  
  
  class Absorption:
      r"""Gamma-ray absorption models.
  
      For more information see :ref:`absorption-spectral-model`.
  
      Parameters
      ----------
      energy : `~astropy.units.Quantity`
          Energy node values
      param : `~astropy.units.Quantity`
          Parameter node values
      data : `~astropy.units.Quantity`
          Model value
      filename : str
          Filename of the absorption model used for serialisation.
      interp_kwargs : dict
          Interpolation option passed to `ScaledRegularGridInterpolator`.
          By default the models are extrapolated outside the range. To prevent
          this and raise an error instead use interp_kwargs = {"extrapolate": False}
      """
  
      tag = "Absorption"
  
      def __init__(self, energy, param, data, filename=None, interp_kwargs=None):
          self.data = data
          self.filename = filename
          # set values log centers
          self.param = param
          self.energy = energy
  
          interp_kwargs = interp_kwargs or {}
          interp_kwargs.setdefault("points_scale", ("log", "lin"))
          interp_kwargs.setdefault("extrapolate", True)
  
          self._evaluate = ScaledRegularGridInterpolator(
              points=(self.param, self.energy), values=data, **interp_kwargs
          )
  
      def to_dict(self):
          if self.filename is None:
              return {
                  "type": self.tag,
                  "energy": {
                      "data": self.energy.data.tolist(),
                      "unit": str(self.energy.unit),
                  },
                  "param": {
                      "data": self.param.data.tolist(),
                      "unit": str(self.param.unit),
                  },
                  "values": {
                      "data": self.data.data.tolist(),
                      "unit": str(self.data.unit),
                  },
              }
          else:
              return {"type": self.tag, "filename": self.filename}
  
      @classmethod
      def from_dict(cls, data):
  
          if "filename" in data:
              return cls.read(data["filename"])
          else:
              energy = u.Quantity(data["energy"]["data"], data["energy"]["unit"])
              param = u.Quantity(data["param"]["data"], data["param"]["unit"])
              values = u.Quantity(data["values"]["data"], data["values"]["unit"])
              return cls(energy=energy, param=param, data=values)
  
      @classmethod
      def read(cls, filename, interp_kwargs=None):
          """Build object from an XSPEC model.
  
          Todo: Format of XSPEC binary files should be referenced at https://gamma-astro-data-formats.readthedocs.io/en/latest/
  
          Parameters
          ----------
          filename : str
              File containing the model.
          interp_kwargs : dict
              Interpolation option passed to `ScaledRegularGridInterpolator`.
  
          Returns
          -------
          absorption : `Absorption`
              Absorption model.
          """
          # Create EBL data array
          filename = make_path(filename)
          table_param = Table.read(filename, hdu="PARAMETERS")
  
          # TODO: for some reason the table contain duplicated values
          param, idx = np.unique(table_param[0]["VALUE"], return_index=True)
  
          # Get energy values
          table_energy = Table.read(filename, hdu="ENERGIES")
          energy_lo = u.Quantity(
              table_energy["ENERG_LO"], "keV", copy=False
          )  # unit not stored in file
          energy_hi = u.Quantity(
              table_energy["ENERG_HI"], "keV", copy=False
          )  # unit not stored in file
          energy = np.sqrt(energy_lo * energy_hi)
  
          # Get spectrum values
          table_spectra = Table.read(filename, hdu="SPECTRA")
          data = table_spectra["INTPSPEC"].data[idx, :]
          return cls(
              energy=energy,
              param=param,
              data=data,
              filename=filename,
              interp_kwargs=interp_kwargs,
          )
  
      @classmethod
      def read_builtin(cls, name, interp_kwargs=None):
          """Read one of the built-in absorption models.
  
          Parameters
          ----------
          name : {'franceschini', 'dominguez', 'finke'}
              name of one of the available model in gammapy-data
  
          References
          ----------
          .. [1] Franceschini et al., "Extragalactic optical-infrared background radiation, its time evolution and the cosmic photon-photon opacity",
              `Link <https://ui.adsabs.harvard.edu/abs/2008A%26A...487..837F>`__
          .. [2] Dominguez et al., " Extragalactic background light inferred from AEGIS galaxy-SED-type fractions"
              `Link <https://ui.adsabs.harvard.edu/abs/2011MNRAS.410.2556D>`__
          .. [3] Finke et al., "Modeling the Extragalactic Background Light from Stars and Dust"
              `Link <https://ui.adsabs.harvard.edu/abs/2010ApJ...712..238F>`__
  
          Returns
          -------
          absorption : `Absorption`
              Absorption model.
  
          """
          models = dict()
          models["franceschini"] = "$GAMMAPY_DATA/ebl/ebl_franceschini.fits.gz"
          models["dominguez"] = "$GAMMAPY_DATA/ebl/ebl_dominguez11.fits.gz"
          models["finke"] = "$GAMMAPY_DATA/ebl/frd_abs.fits.gz"
  
          return cls.read(models[name], interp_kwargs=interp_kwargs)
  
      def table_model(self, parameter):
          """Table model for a given parameter value.
  
          Parameters
          ----------
          parameter : float
              Parameter value.
  
          Returns
          -------
          template_model : `TemplateSpectralModel`
              Template spectral model.
          """
          energy = self.energy
          values = self.evaluate(energy=energy, parameter=parameter)
          return TemplateSpectralModel(
              energy=energy, values=values, interp_kwargs={"values_scale": "log"}
          )
  
      def evaluate(self, energy, parameter):
          """Evaluate model for energy and parameter value."""
          return np.clip(self._evaluate((parameter, energy)), 0, 1)
  
  
  class AbsorbedSpectralModel(SpectralModel):
      r"""Spectral model with EBL absorption.
  
      For more information see :ref:`absorbed-spectral-model`.
  
      Parameters
      ----------
      spectral_model : `SpectralModel`
          Spectral model.
      absorption : `Absorption`
          Absorption model.
      redshift : float
          Redshift of the absorption model
      alpha_norm: float
          Norm of the EBL model
      """
  
      tag = "AbsorbedSpectralModel"
      alpha_norm = Parameter("alpha_norm", 1.0, frozen=True)
      redshift = Parameter("redshift", 0.1, frozen=True)
  
      def __init__(
          self, spectral_model, absorption, redshift, alpha_norm=alpha_norm.quantity,
      ):
          self.spectral_model = spectral_model
          self.absorption = absorption
  
          min_ = self.absorption.param.min()
          max_ = self.absorption.param.max()
  
          redshift = Parameter("redshift", redshift, frozen=True, min=min_, max=max_)
          super().__init__(redshift=redshift, alpha_norm=alpha_norm)
  
      def _check_covariance(self):
          if not self.parameters == self._covariance.parameters:
              self._covariance = Covariance(self.parameters)
  
      @property
      def covariance(self):
          self._check_covariance()
          self._covariance.set_subcovariance(self.spectral_model.covariance)
          return self._covariance
  
      @covariance.setter
      def covariance(self, covariance):
          self._check_covariance()
          self._covariance.data = covariance
  
          subcovar = self._covariance.get_subcovariance(
              self.spectral_model.covariance.parameters
          )
          self.spectral_model.covariance = subcovar
  
      @property
      def parameters(self):
          return (
              Parameters([self.redshift, self.alpha_norm])
              + self.spectral_model.parameters
          )
  
      def evaluate(self, energy, **kwargs):
          """Evaluate the model at a given energy."""
          # assign redshift value and remove it from dictionary
          # since it does not belong to the spectral model
          parameter = kwargs.pop("redshift")
          alpha_norm = kwargs.pop("alpha_norm")
  
          dnde = self.spectral_model.evaluate(energy=energy, **kwargs)
          absorption = self.absorption.evaluate(energy=energy, parameter=parameter)
          # Power rule: (e ^ a) ^ b = e ^ (a * b)
          absorption = np.power(absorption, alpha_norm)
          return dnde * absorption
  
      def to_dict(self):
          return {
              "type": self.tag,
              "base_model": self.spectral_model.to_dict(),
              "absorption": self.absorption.to_dict(),
              "absorption_parameter": {"name": "redshift", "value": self.redshift.value,},
              "parameters": Parameters([self.redshift, self.alpha_norm]).to_dict(),
          }
  
      @classmethod
      def from_dict(cls, data):
          from gammapy.modeling.models import SPECTRAL_MODEL_REGISTRY
  
          model_class = SPECTRAL_MODEL_REGISTRY.get_cls(data["base_model"]["type"])
  
          model = cls(
              spectral_model=model_class.from_dict(data["base_model"]),
              absorption=Absorption.from_dict(data["absorption"]),
              redshift=data["absorption_parameter"]["value"],
          )
          return model
  
  
  class NaimaSpectralModel(SpectralModel):
      r"""A wrapper for Naima models.
  
      For more information see :ref:`naima-spectral-model`.
  
      Parameters
      ----------
      radiative_model : `~naima.models.BaseRadiative`
          An instance of a radiative model defined in `~naima.models`
      distance : `~astropy.units.Quantity`, optional
          Distance to the source. If set to 0, the intrinsic differential
          luminosity will be returned. Default is 1 kpc
      seed : str or list of str, optional
          Seed photon field(s) to be considered for the `radiative_model` flux computation,
          in case of a `~naima.models.InverseCompton` model. It can be a subset of the
          `seed_photon_fields` list defining the `radiative_model`. Default is the whole list
          of photon fields
      nested_models : dict
          Additionnal parameters for nested models not supplied by the radiative model,
          for now this is used  only for synchrotron self-compton model
      """
  
      tag = ["NaimaSpectralModel", "NaimaSpectral"]
  
      def __init__(
          self, radiative_model, distance=1.0 * u.kpc, seed=None, nested_models=None
      ):
          import naima
  
          self.radiative_model = radiative_model
          self._particle_distribution = self.radiative_model.particle_distribution
          self.distance = u.Quantity(distance)
          self.seed = seed
  
          if nested_models is None:
              nested_models = {}
  
          self.nested_models = nested_models
  
          if isinstance(self._particle_distribution, naima.models.TableModel):
              param_names = ["amplitude"]
          else:
              param_names = self._particle_distribution.param_names
  
          parameters = []
  
          for name in param_names:
              value = getattr(self._particle_distribution, name)
              parameter = Parameter(name, value)
              parameters.append(parameter)
  
          # In case of a synchrotron radiative model, append B to the fittable parameters
          if "B" in self.radiative_model.param_names:
              value = getattr(self.radiative_model, "B")
              parameter = Parameter("B", value)
              parameters.append(parameter)
  
          # In case of a synchrotron self compton model, append B and Rpwn to the fittable parameters
          if (
              isinstance(self.radiative_model, naima.models.InverseCompton)
              and "SSC" in self.nested_models
          ):
              B = self.nested_models["SSC"]["B"]
              radius = self.nested_models["SSC"]["radius"]
              parameters.append(Parameter("B", B))
              parameters.append(Parameter("radius", radius, frozen=True))
  
          self.default_parameters = Parameters(parameters)
          super().__init__()
  
      def _evaluate_ssc(
          self, energy,
      ):
          """
          Compute photon density spectrum from synchrotron emission for synchrotron self-compton model,
          assuming uniform synchrotron emissivity inside a sphere of radius R
          (see Section 4.1 of Atoyan & Aharonian 1996)
  
          based on :
          "https://naima.readthedocs.io/en/latest/examples.html#crab-nebula-ssc-model"
  
          """
          import naima
  
          SYN = naima.models.Synchrotron(
              self._particle_distribution,
              B=self.B.quantity,
              Eemax=self.radiative_model.Eemax,
              Eemin=self.radiative_model.Eemin,
          )
  
          Esy = np.logspace(-7, 9, 100) * u.eV
          Lsy = SYN.flux(Esy, distance=0 * u.cm)  # use distance 0 to get luminosity
          phn_sy = Lsy / (4 * np.pi * self.radius.quantity ** 2 * const.c) * 2.24
          # The factor 2.24 comes from the assumption on uniform synchrotron
          # emissivity inside a sphere
  
          if "SSC" not in self.radiative_model.seed_photon_fields:
              self.radiative_model.seed_photon_fields["SSC"] = {
                  "isotropic": True,
                  "type": "array",
                  "energy": Esy,
                  "photon_density": phn_sy,
              }
          else:
              self.radiative_model.seed_photon_fields["SSC"]["photon_density"] = phn_sy
  
          dnde = self.radiative_model.flux(
              energy, seed=self.seed, distance=self.distance
          ) + SYN.flux(energy, distance=self.distance)
          return dnde
  
      def evaluate(self, energy, **kwargs):
          """Evaluate the model."""
          import naima
  
          for name, value in kwargs.items():
              setattr(self._particle_distribution, name, value)
  
          if "B" in self.radiative_model.param_names:
              self.radiative_model.B = self.B.quantity
  
          if (
              isinstance(self.radiative_model, naima.models.InverseCompton)
              and "SSC" in self.nested_models
          ):
              dnde = self._evaluate_ssc(energy.flatten())
          elif self.seed is not None:
              dnde = self.radiative_model.flux(
                  energy.flatten(), seed=self.seed, distance=self.distance
              )
          else:
              dnde = self.radiative_model.flux(energy.flatten(), distance=self.distance)
  
          dnde = dnde.reshape(energy.shape)
          unit = 1 / (energy.unit * u.cm ** 2 * u.s)
          return dnde.to(unit)
  
      @classmethod
      def from_dict(cls, data):
          raise NotImplementedError(
              "Currently the NaimaSpectralModel cannot be read from YAML"
          )
  
      @classmethod
      def from_parameters(cls, parameters, **kwargs):
          raise NotImplementedError(
              "Currently the NaimaSpectralModel cannot be built from a list of parameters."
          )
  
  
  class GaussianSpectralModel(SpectralModel):
      r"""Gaussian spectral model.
  
      For more information see :ref:`gaussian-spectral-model`.
  
      Parameters
      ----------
      norm : `~astropy.units.Quantity`
          :math:`N_0`
      mean : `~astropy.units.Quantity`
          :math:`\bar{E}`
      sigma : `~astropy.units.Quantity`
          :math:`\sigma`
      """
  
      tag = ["GaussianSpectralModel", "GaussianSpectral"]
      norm = Parameter("norm", 1e-12 * u.Unit("cm-2 s-1"))
      mean = Parameter("mean", 1 * u.TeV)
      sigma = Parameter("sigma", 2 * u.TeV)
  
      @staticmethod
      def evaluate(energy, norm, mean, sigma):
          return (
              norm
              / (sigma * np.sqrt(2 * np.pi))
              * np.exp(-((energy - mean) ** 2) / (2 * sigma ** 2))
          )
  
      def integral(self, emin, emax, **kwargs):
          r"""Integrate Gaussian analytically.
  
          .. math::
              F(E_{min}, E_{max}) = \frac{N_0}{2} \left[ erf(\frac{E - \bar{E}}{\sqrt{2} \sigma})\right]_{E_{min}}^{E_{max}}
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range
          """
          # kwargs are passed to this function but not used
          # this is to get a consistent API with SpectralModel.integral()
          u_min = (
              (emin - self.mean.quantity) / (np.sqrt(2) * self.sigma.quantity)
          ).to_value("")
          u_max = (
              (emax - self.mean.quantity) / (np.sqrt(2) * self.sigma.quantity)
          ).to_value("")
  
          return (
              self.norm.quantity
              / 2
              * (scipy.special.erf(u_max) - scipy.special.erf(u_min))
          )
  
      def energy_flux(self, emin, emax):
          r"""Compute energy flux in given energy range analytically.
  
          .. math::
              G(E_{min}, E_{max}) =  \frac{N_0 \sigma}{\sqrt{2*\pi}}* \left[ - \exp(\frac{E_{min}-\bar{E}}{\sqrt{2} \sigma})
              \right]_{E_{min}}^{E_{max}} + \frac{N_0 * \bar{E}}{2} \left[ erf(\frac{E - \bar{E}}{\sqrt{2} \sigma})
               \right]_{E_{min}}^{E_{max}}
  
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          """
          u_min = (
              (emin - self.mean.quantity) / (np.sqrt(2) * self.sigma.quantity)
          ).to_value("")
          u_max = (
              (emax - self.mean.quantity) / (np.sqrt(2) * self.sigma.quantity)
          ).to_value("")
          a = self.norm.quantity * self.sigma.quantity / np.sqrt(2 * np.pi)
          b = self.norm.quantity * self.mean.quantity / 2
          return a * (np.exp(-(u_min ** 2)) - np.exp(-(u_max ** 2))) + b * (
              scipy.special.erf(u_max) - scipy.special.erf(u_min)
          )
  
files: gammapy/modeling/models/spectral.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  """Spectral models for Gammapy."""
  import operator
  import numpy as np
  import scipy.optimize
  import scipy.special
  import astropy.units as u
  from astropy import constants as const
  from astropy.table import Table
  from gammapy.maps import MapAxis
  from gammapy.maps.utils import edges_from_lo_hi
  from gammapy.modeling import Covariance, Parameter, Parameters
  from gammapy.utils.integrate import evaluate_integral_pwl, trapz_loglog
  from gammapy.utils.interpolation import ScaledRegularGridInterpolator
  from gammapy.utils.scripts import make_path
  from .core import Model
  
  
  def integrate_spectrum(func, emin, emax, ndecade=100, intervals=False):
      """Integrate 1d function using the log-log trapezoidal rule.
  
      If scalar values for xmin and xmax are passed an oversampled grid is generated using the
      ``ndecade`` keyword argument. If xmin and xmax arrays are passed, no
      oversampling is performed and the integral is computed in the provided
      grid.
  
      Parameters
      ----------
      func : callable
          Function to integrate.
      emin : `~astropy.units.Quantity`
          Integration range minimum
      emax : `~astropy.units.Quantity`
          Integration range minimum
      ndecade : int, optional
          Number of grid points per decade used for the integration.
          Default : 100.
      intervals : bool, optional
          Return integrals in the grid not the sum, default: False
      """
      if emin.isscalar and emax.isscalar:
          energies = MapAxis.from_energy_bounds(
              emin=emin, emax=emax, nbin=ndecade, per_decade=True
          ).edges
      else:
          energies = edges_from_lo_hi(emin, emax)
  
      values = func(energies)
  
      integral = trapz_loglog(values, energies)
  
      if intervals:
          return integral
  
      return integral.sum()
  
  
  class SpectralModel(Model):
      """Spectral model base class."""
  
      def __call__(self, energy):
          kwargs = {par.name: par.quantity for par in self.parameters}
          kwargs = self._convert_evaluate_unit(kwargs, energy)
          return self.evaluate(energy, **kwargs)
  
      @staticmethod
      def _convert_evaluate_unit(kwargs_ref, energy):
          kwargs = {}
          for name, quantity in kwargs_ref.items():
              if quantity.unit.physical_type == "energy":
                  quantity = quantity.to(energy.unit)
              kwargs[name] = quantity
          return kwargs
  
      def __add__(self, model):
          if not isinstance(model, SpectralModel):
              model = ConstantSpectralModel(const=model)
          return CompoundSpectralModel(self, model, operator.add)
  
      def __radd__(self, model):
          return self.__add__(model)
  
      def __sub__(self, model):
          if not isinstance(model, SpectralModel):
              model = ConstantSpectralModel(const=model)
          return CompoundSpectralModel(self, model, operator.sub)
  
      def __rsub__(self, model):
          return self.__sub__(model)
  
      def _evaluate_gradient(self, energy, eps):
          n = len(self.parameters)
          f = self(energy)
          shape = (n, len(np.atleast_1d(energy)))
          df_dp = np.zeros(shape)
  
          for idx, parameter in enumerate(self.parameters):
              if parameter.frozen or eps[idx] == 0:
                  continue
  
              parameter.value += eps[idx]
              df = self(energy) - f
              df_dp[idx] = df.value / eps[idx]
  
              # Reset model to original parameter
              parameter.value -= eps[idx]
  
          return df_dp
  
      def evaluate_error(self, energy, epsilon=1e-4):
          """Evaluate spectral model with error propagation.
  
          Parameters
          ----------
          energy : `~astropy.units.Quantity`
              Energy at which to evaluate
          epsilon : float
              Step size of the gradient evaluation. Given as a
              fraction of the parameter error.
  
          Returns
          -------
          dnde, dnde_error : tuple of `~astropy.units.Quantity`
              Tuple of flux and flux error.
          """
          p_cov = self.covariance
          eps = np.sqrt(np.diag(p_cov)) * epsilon
  
          df_dp = self._evaluate_gradient(energy, eps)
          f_cov = df_dp.T @ p_cov @ df_dp
          f_err = np.sqrt(np.diagonal(f_cov))
  
          q = self(energy)
          return u.Quantity([q.value, f_err], unit=q.unit)
  
      def integral(self, emin, emax, **kwargs):
          r"""Integrate spectral model numerically.
  
          .. math::
              F(E_{min}, E_{max}) = \int_{E_{min}}^{E_{max}} \phi(E) dE
  
          If array input for ``emin`` and ``emax`` is given you have to set
          ``intervals=True`` if you want the integral in each energy bin.
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          **kwargs : dict
              Keyword arguments passed to :func:`~gammapy.utils.integrate.integrate_spectrum`
          """
          return integrate_spectrum(self, emin, emax, **kwargs)
  
      def energy_flux(self, emin, emax, **kwargs):
          r"""Compute energy flux in given energy range.
  
          .. math::
              G(E_{min}, E_{max}) = \int_{E_{min}}^{E_{max}} E \phi(E) dE
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          **kwargs : dict
              Keyword arguments passed to func:`~gammapy.utils.integrate.integrate_spectrum`
          """
  
          def f(x):
              return x * self(x)
  
          return integrate_spectrum(f, emin, emax, **kwargs)
  
      def plot(
          self,
          energy_range,
          ax=None,
          energy_unit="TeV",
          flux_unit="cm-2 s-1 TeV-1",
          energy_power=0,
          n_points=100,
          **kwargs,
      ):
          """Plot spectral model curve.
  
          kwargs are forwarded to `matplotlib.pyplot.plot`
  
          By default a log-log scaling of the axes is used, if you want to change
          the y axis scaling to linear you can use::
  
              from gammapy.modeling.models import ExpCutoffPowerLawSpectralModel
              from astropy import units as u
  
              pwl = ExpCutoffPowerLawSpectralModel()
              ax = pwl.plot(energy_range=(0.1, 100) * u.TeV)
              ax.set_yscale('linear')
  
          Parameters
          ----------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          energy_range : `~astropy.units.Quantity`
              Plot range
          energy_unit : str, `~astropy.units.Unit`, optional
              Unit of the energy axis
          flux_unit : str, `~astropy.units.Unit`, optional
              Unit of the flux axis
          energy_power : int, optional
              Power of energy to multiply flux axis with
          n_points : int, optional
              Number of evaluation nodes
  
          Returns
          -------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          """
          import matplotlib.pyplot as plt
  
          ax = plt.gca() if ax is None else ax
  
          emin, emax = energy_range
          energy = MapAxis.from_energy_bounds(emin, emax, n_points, energy_unit).edges
  
          # evaluate model
          flux = self(energy).to(flux_unit)
  
          y = self._plot_scale_flux(energy, flux, energy_power)
  
          ax.plot(energy.value, y.value, **kwargs)
  
          self._plot_format_ax(ax, energy, y, energy_power)
          return ax
  
      def plot_error(
          self,
          energy_range,
          ax=None,
          energy_unit="TeV",
          flux_unit="cm-2 s-1 TeV-1",
          energy_power=0,
          n_points=100,
          **kwargs,
      ):
          """Plot spectral model error band.
  
          .. note::
  
              This method calls ``ax.set_yscale("log", nonposy='clip')`` and
              ``ax.set_xscale("log", nonposx='clip')`` to create a log-log representation.
              The additional argument ``nonposx='clip'`` avoids artefacts in the plot,
              when the error band extends to negative values (see also
              https://github.com/matplotlib/matplotlib/issues/8623).
  
              When you call ``plt.loglog()`` or ``plt.semilogy()`` explicitely in your
              plotting code and the error band extends to negative values, it is not
              shown correctly. To circumvent this issue also use
              ``plt.loglog(nonposx='clip', nonposy='clip')``
              or ``plt.semilogy(nonposy='clip')``.
  
          Parameters
          ----------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          energy_range : `~astropy.units.Quantity`
              Plot range
          energy_unit : str, `~astropy.units.Unit`, optional
              Unit of the energy axis
          flux_unit : str, `~astropy.units.Unit`, optional
              Unit of the flux axis
          energy_power : int, optional
              Power of energy to multiply flux axis with
          n_points : int, optional
              Number of evaluation nodes
          **kwargs : dict
              Keyword arguments forwarded to `matplotlib.pyplot.fill_between`
  
          Returns
          -------
          ax : `~matplotlib.axes.Axes`, optional
              Axis
          """
          import matplotlib.pyplot as plt
  
          ax = plt.gca() if ax is None else ax
  
          kwargs.setdefault("facecolor", "black")
          kwargs.setdefault("alpha", 0.2)
          kwargs.setdefault("linewidth", 0)
  
          emin, emax = energy_range
          energy = MapAxis.from_energy_bounds(emin, emax, n_points, energy_unit).edges
  
          flux, flux_err = self.evaluate_error(energy).to(flux_unit)
  
          y_lo = self._plot_scale_flux(energy, flux - flux_err, energy_power)
          y_hi = self._plot_scale_flux(energy, flux + flux_err, energy_power)
  
          where = (energy >= energy_range[0]) & (energy <= energy_range[1])
          ax.fill_between(energy.value, y_lo.value, y_hi.value, where=where, **kwargs)
  
          self._plot_format_ax(ax, energy, y_lo, energy_power)
          return ax
  
      @staticmethod
      def _plot_format_ax(ax, energy, y, energy_power):
          ax.set_xlabel(f"Energy [{energy.unit}]")
          if energy_power > 0:
              ax.set_ylabel(f"E{energy_power} * Flux [{y.unit}]")
          else:
              ax.set_ylabel(f"Flux [{y.unit}]")
  
          ax.set_xscale("log", nonposx="clip")
          ax.set_yscale("log", nonposy="clip")
  
      @staticmethod
      def _plot_scale_flux(energy, flux, energy_power):
          try:
              eunit = [_ for _ in flux.unit.bases if _.physical_type == "energy"][0]
          except IndexError:
              eunit = energy.unit
          y = flux * np.power(energy, energy_power)
          return y.to(flux.unit * eunit ** energy_power)
  
      def spectral_index(self, energy, epsilon=1e-5):
          """Compute spectral index at given energy.
  
          Parameters
          ----------
          energy : `~astropy.units.Quantity`
              Energy at which to estimate the index
          epsilon : float
              Fractional energy increment to use for determining the spectral index.
  
          Returns
          -------
          index : float
              Estimated spectral index.
          """
          f1 = self(energy)
          f2 = self(energy * (1 + epsilon))
          return np.log(f1 / f2) / np.log(1 + epsilon)
  
      def inverse(self, value, emin=0.1 * u.TeV, emax=100 * u.TeV):
          """Return energy for a given function value of the spectral model.
  
          Calls the `scipy.optimize.brentq` numerical root finding method.
  
          Parameters
          ----------
          value : `~astropy.units.Quantity`
              Function value of the spectral model.
          emin : `~astropy.units.Quantity`
              Lower bracket value in case solution is not unique.
          emax : `~astropy.units.Quantity`
              Upper bracket value in case solution is not unique.
  
          Returns
          -------
          energy : `~astropy.units.Quantity`
              Energies at which the model has the given ``value``.
          """
          eunit = "TeV"
  
          energies = []
          for val in np.atleast_1d(value):
  
              def f(x):
                  # scale by 1e12 to achieve better precision
                  energy = u.Quantity(x, eunit, copy=False)
                  y = self(energy).to_value(value.unit)
                  return 1e12 * (y - val.value)
  
              energy = scipy.optimize.brentq(
                  f, emin.to_value(eunit), emax.to_value(eunit)
              )
              energies.append(energy)
  
          return u.Quantity(energies, eunit, copy=False)
  
  
  class ConstantSpectralModel(SpectralModel):
      r"""Constant model.
  
      For more information see :ref:`constant-spectral-model`.
  
      Parameters
      ----------
      const : `~astropy.units.Quantity`
          :math:`k`
      """
  
      tag = "ConstantSpectralModel"
      const = Parameter("const", "1e-12 cm-2 s-1 TeV-1")
  
      @staticmethod
      def evaluate(energy, const):
          """Evaluate the model (static function)."""
          return np.ones(np.atleast_1d(energy).shape) * const
  
  
  class CompoundSpectralModel(SpectralModel):
      """Arithmetic combination of two spectral models.
  
      For more information see :ref:`compound-spectral-model`.
      """
  
      tag = "CompoundSpectralModel"
  
      def __init__(self, model1, model2, operator):
          self.model1 = model1
          self.model2 = model2
          self.operator = operator
          super().__init__()
  
      @property
      def parameters(self):
          return self.model1.parameters + self.model2.parameters
  
      def __str__(self):
          return (
              f"{self.__class__.__name__}\n"
              f"    Component 1 : {self.model1}\n"
              f"    Component 2 : {self.model2}\n"
              f"    Operator : {self.operator}\n"
          )
  
      def __call__(self, energy):
          val1 = self.model1(energy)
          val2 = self.model2(energy)
          return self.operator(val1, val2)
  
      def to_dict(self):
          return {
              "model1": self.model1.to_dict(),
              "model2": self.model2.to_dict(),
              "operator": self.operator,
          }
  
  
  class PowerLawSpectralModel(SpectralModel):
      r"""Spectral power-law model.
  
      For more information see :ref:`powerlaw-spectral-model`.
  
      Parameters
      ----------
      index : `~astropy.units.Quantity`
          :math:`\Gamma`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      """
  
      tag = "PowerLawSpectralModel"
      index = Parameter("index", 2.0)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      evaluate_integral = staticmethod(evaluate_integral_pwl)
  
      @staticmethod
      def evaluate(energy, index, amplitude, reference):
          """Evaluate the model (static function)."""
          return amplitude * np.power((energy / reference), -index)
  
      @staticmethod
      def evaluate_energy_flux(emin, emax, index, amplitude, reference):
          """Evaluate the energy flux (static function)"""
          val = -1 * index + 2
  
          prefactor = amplitude * reference ** 2 / val
          upper = (emax / reference) ** val
          lower = (emin / reference) ** val
          energy_flux = prefactor * (upper - lower)
  
          mask = np.isclose(val, 0)
  
          if mask.any():
              # see https://www.wolframalpha.com/input/?i=a+*+x+*+(x%2Fb)+%5E+(-2)
              # for reference
              energy_flux[mask] = amplitude * reference ** 2 * np.log(emax / emin)[mask]
  
          return energy_flux
  
      def integral(self, emin, emax, **kwargs):
          r"""Integrate power law analytically.
  
          .. math::
              F(E_{min}, E_{max}) = \int_{E_{min}}^{E_{max}}\phi(E)dE = \left.
              \phi_0 \frac{E_0}{-\Gamma + 1} \left( \frac{E}{E_0} \right)^{-\Gamma + 1}
              \right \vert _{E_{min}}^{E_{max}}
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range
          """
          kwargs = {par.name: par.quantity for par in self.parameters}
          kwargs = self._convert_evaluate_unit(kwargs, emin)
          return self.evaluate_integral(emin=emin, emax=emax, **kwargs)
  
      def energy_flux(self, emin, emax):
          r"""Compute energy flux in given energy range analytically.
  
          .. math::
              G(E_{min}, E_{max}) = \int_{E_{min}}^{E_{max}}E \phi(E)dE = \left.
              \phi_0 \frac{E_0^2}{-\Gamma + 2} \left( \frac{E}{E_0} \right)^{-\Gamma + 2}
              \right \vert _{E_{min}}^{E_{max}}
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          """
          kwargs = {par.name: par.quantity for par in self.parameters}
          kwargs = self._convert_evaluate_unit(kwargs, emin)
          return self.evaluate_energy_flux(emin=emin, emax=emax, **kwargs)
  
      def inverse(self, value):
          """Return energy for a given function value of the spectral model.
  
          Parameters
          ----------
          value : `~astropy.units.Quantity`
              Function value of the spectral model.
          """
          base = value / self.amplitude.quantity
          return self.reference.quantity * np.power(base, -1.0 / self.index.value)
  
      @property
      def pivot_energy(self):
          r"""The decorrelation energy is defined as:
  
          .. math::
  
              E_D = E_0 * \exp{cov(\phi_0, \Gamma) / (\phi_0 \Delta \Gamma^2)}
  
          Formula (1) in https://arxiv.org/pdf/0910.4881.pdf
          """
          index_err = self.index.error
          reference = self.reference.quantity
          amplitude = self.amplitude.quantity
          cov_index_ampl = self.covariance.data[0, 1] * amplitude.unit
          return reference * np.exp(cov_index_ampl / (amplitude * index_err ** 2))
  
  
  class PowerLaw2SpectralModel(SpectralModel):
      r"""Spectral power-law model with integral as amplitude parameter.
  
      For more information see :ref:`powerlaw2-spectral-model`.
  
      Parameters
      ----------
      index : `~astropy.units.Quantity`
          Spectral index :math:`\Gamma`
      amplitude : `~astropy.units.Quantity`
          Integral flux :math:`F_0`.
      emin : `~astropy.units.Quantity`
          Lower energy limit :math:`E_{0, min}`.
      emax : `~astropy.units.Quantity`
          Upper energy limit :math:`E_{0, max}`.
      """
  
      tag = "PowerLaw2SpectralModel"
  
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1")
      index = Parameter("index", 2)
      emin = Parameter("emin", "0.1 TeV", frozen=True)
      emax = Parameter("emax", "100 TeV", frozen=True)
  
      @staticmethod
      def evaluate(energy, amplitude, index, emin, emax):
          """Evaluate the model (static function)."""
          top = -index + 1
  
          # to get the energies dimensionless we use a modified formula
          bottom = emax - emin * (emin / emax) ** (-index)
          return amplitude * (top / bottom) * np.power(energy / emax, -index)
  
      def integral(self, emin, emax, **kwargs):
          r"""Integrate power law analytically.
  
          .. math::
              F(E_{min}, E_{max}) = F_0 \cdot \frac{E_{max}^{\Gamma + 1} \
                                  - E_{min}^{\Gamma + 1}}{E_{0, max}^{\Gamma + 1} \
                                  - E_{0, min}^{\Gamma + 1}}
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          """
          temp1 = np.power(emax, -self.index.value + 1)
          temp2 = np.power(emin, -self.index.value + 1)
          top = temp1 - temp2
  
          temp1 = np.power(self.emax.quantity, -self.index.value + 1)
          temp2 = np.power(self.emin.quantity, -self.index.value + 1)
          bottom = temp1 - temp2
  
          return self.amplitude.quantity * top / bottom
  
      def inverse(self, value):
          """Return energy for a given function value of the spectral model.
  
          Parameters
          ----------
          value : `~astropy.units.Quantity`
              Function value of the spectral model.
          """
          amplitude = self.amplitude.quantity
          index = self.index.value
          emin = self.emin.quantity
          emax = self.emax.quantity
  
          # to get the energies dimensionless we use a modified formula
          top = -index + 1
          bottom = emax - emin * (emin / emax) ** (-index)
          term = (bottom / top) * (value / amplitude)
          return np.power(term.to_value(""), -1.0 / index) * emax
  
  
  class BrokenPowerLawSpectralModel(SpectralModel):
      r"""Spectral broken power-law model.
  
      For more information see :ref:`broken-powerlaw-spectral-model`.
  
      Parameters
      ----------
      index1 : `~astropy.units.Quantity`
          :math:`\Gamma1`
      index2 : `~astropy.units.Quantity`
          :math:`\Gamma2`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      ebreak : `~astropy.units.Quantity`
          :math:`E_{break}`
      """
  
      tag = "BrokenPowerLawSpectralModel"
      index1 = Parameter("index1", 2.0)
      index2 = Parameter("index2", 2.0)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      ebreak = Parameter("ebreak", "1 TeV")
  
      @staticmethod
      def evaluate(energy, index1, index2, amplitude, ebreak):
          """Evaluate the model (static function)."""
          energy = np.atleast_1d(energy)
          cond = energy < ebreak
          bpwl = amplitude * np.ones(energy.shape)
          bpwl[cond] *= (energy[cond] / ebreak) ** (-index1)
          bpwl[~cond] *= (energy[~cond] / ebreak) ** (-index2)
          return bpwl
  
  
  class SmoothBrokenPowerLawSpectralModel(SpectralModel):
      r"""Spectral smooth broken power-law model.
  
      For more information see :ref:`smooth-broken-powerlaw-spectral-model`.
  
      Parameters
      ----------
      index1 : `~astropy.units.Quantity`
          :math:`\Gamma1`
      index2 : `~astropy.units.Quantity`
          :math:`\Gamma2`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      ebreak : `~astropy.units.Quantity`
          :math:`E_{break}`
      beta : `~astropy.units.Quantity`
          :math:`\beta`
      """
  
      tag = "SmoothBrokenPowerLawSpectralModel"
      index1 = Parameter("index1", 2.0)
      index2 = Parameter("index2", 2.0)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      ebreak = Parameter("ebreak", "1 TeV")
      reference = Parameter("reference", "1 TeV", frozen=True)
      beta = Parameter("beta", 1, frozen=True)
  
      @staticmethod
      def evaluate(energy, index1, index2, amplitude, ebreak, reference, beta):
          """Evaluate the model (static function)."""
          beta *= np.sign(index2 - index1)
          pwl = amplitude * (energy / reference) ** (-index1)
          brk = (1 + (energy / ebreak) ** ((index2 - index1) / beta)) ** (-beta)
          return pwl * brk
  
  
  class ExpCutoffPowerLawSpectralModel(SpectralModel):
      r"""Spectral exponential cutoff power-law model.
  
      For more information see :ref:`exp-cutoff-powerlaw-spectral-model`.
  
      Parameters
      ----------
      index : `~astropy.units.Quantity`
          :math:`\Gamma`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      lambda_ : `~astropy.units.Quantity`
          :math:`\lambda`
      alpha : `~astropy.units.Quantity`
          :math:`\alpha`
      """
  
      tag = "ExpCutoffPowerLawSpectralModel"
  
      index = Parameter("index", 1.5)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      lambda_ = Parameter("lambda_", "0.1 TeV-1")
      alpha = Parameter("alpha", "1.0", frozen=True)
  
      @staticmethod
      def evaluate(energy, index, amplitude, reference, lambda_, alpha):
          """Evaluate the model (static function)."""
          pwl = amplitude * (energy / reference) ** (-index)
          cutoff = np.exp(-np.power(energy * lambda_, alpha))
  
          return pwl * cutoff
  
      @property
      def e_peak(self):
          r"""Spectral energy distribution peak energy (`~astropy.units.Quantity`).
  
          This is the peak in E^2 x dN/dE and is given by:
  
          .. math::
              E_{Peak} =  \left(\frac{2 - \Gamma}{\alpha}\right)^{1/\alpha} / \lambda
          """
          reference = self.reference.quantity
          index = self.index.quantity
          lambda_ = self.lambda_.quantity
          alpha = self.alpha.quantity
  
          if index >= 2 or lambda_ == 0.0 or alpha == 0.0:
              return np.nan * reference.unit
          else:
              return np.power((2 - index) / alpha, 1 / alpha) / lambda_
  
  
  class ExpCutoffPowerLaw3FGLSpectralModel(SpectralModel):
      r"""Spectral exponential cutoff power-law model used for 3FGL.
  
      For more information see :ref:`exp-cutoff-powerlaw-3fgl-spectral-model`.
  
      Parameters
      ----------
      index : `~astropy.units.Quantity`
          :math:`\Gamma`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      ecut : `~astropy.units.Quantity`
          :math:`E_{C}`
      """
  
      tag = "ExpCutoffPowerLaw3FGLSpectralModel"
      index = Parameter("index", 1.5)
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      ecut = Parameter("ecut", "10 TeV")
  
      @staticmethod
      def evaluate(energy, index, amplitude, reference, ecut):
          """Evaluate the model (static function)."""
          pwl = amplitude * (energy / reference) ** (-index)
          cutoff = np.exp((reference - energy) / ecut)
          return pwl * cutoff
  
  
  class SuperExpCutoffPowerLaw3FGLSpectralModel(SpectralModel):
      r"""Spectral super exponential cutoff power-law model used for 3FGL.
  
      For more information see :ref:`super-exp-cutoff-powerlaw-3fgl-spectral-model`.
  
      .. math::
          \phi(E) = \phi_0 \cdot \left(\frac{E}{E_0}\right)^{-\Gamma_1}
                    \exp \left( \left(\frac{E_0}{E_{C}} \right)^{\Gamma_2} -
                                \left(\frac{E}{E_{C}} \right)^{\Gamma_2}
                                \right)
  
      Parameters
      ----------
      index_1 : `~astropy.units.Quantity`
          :math:`\Gamma_1`
      index_2 : `~astropy.units.Quantity`
          :math:`\Gamma_2`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      ecut : `~astropy.units.Quantity`
          :math:`E_{C}`
      """
  
      tag = "SuperExpCutoffPowerLaw3FGLSpectralModel"
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      ecut = Parameter("ecut", "10 TeV")
      index_1 = Parameter("index_1", 1.5)
      index_2 = Parameter("index_2", 2)
  
      @staticmethod
      def evaluate(energy, amplitude, reference, ecut, index_1, index_2):
          """Evaluate the model (static function)."""
          pwl = amplitude * (energy / reference) ** (-index_1)
          cutoff = np.exp((reference / ecut) ** index_2 - (energy / ecut) ** index_2)
          return pwl * cutoff
  
  
  class SuperExpCutoffPowerLaw4FGLSpectralModel(SpectralModel):
      r"""Spectral super exponential cutoff power-law model used for 4FGL.
  
      For more information see :ref:`super-exp-cutoff-powerlaw-4fgl-spectral-model`.
  
      Parameters
      ----------
      index_1 : `~astropy.units.Quantity`
          :math:`\Gamma_1`
      index_2 : `~astropy.units.Quantity`
          :math:`\Gamma_2`
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      expfactor : `~astropy.units.Quantity`
          :math:`a`, given as dimensionless value but
          internally assumes unit of :math:`[E_0]` power :math:`-\Gamma_2`
      """
  
      tag = "SuperExpCutoffPowerLaw4FGLSpectralModel"
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "1 TeV", frozen=True)
      expfactor = Parameter("expfactor", "1e-2")
      index_1 = Parameter("index_1", 1.5)
      index_2 = Parameter("index_2", 2)
  
      @staticmethod
      def evaluate(energy, amplitude, reference, expfactor, index_1, index_2):
          """Evaluate the model (static function)."""
          pwl = amplitude * (energy / reference) ** (-index_1)
          cutoff = np.exp(
              expfactor
              / reference.unit ** index_2
              * (reference ** index_2 - energy ** index_2)
          )
          return pwl * cutoff
  
  
  class LogParabolaSpectralModel(SpectralModel):
      r"""Spectral log parabola model.
  
      For more information see :ref:`logparabola-spectral-model`.
  
      Parameters
      ----------
      amplitude : `~astropy.units.Quantity`
          :math:`\phi_0`
      reference : `~astropy.units.Quantity`
          :math:`E_0`
      alpha : `~astropy.units.Quantity`
          :math:`\alpha`
      beta : `~astropy.units.Quantity`
          :math:`\beta`
      """
  
      tag = "LogParabolaSpectralModel"
      amplitude = Parameter("amplitude", "1e-12 cm-2 s-1 TeV-1")
      reference = Parameter("reference", "10 TeV", frozen=True)
      alpha = Parameter("alpha", 2)
      beta = Parameter("beta", 1)
  
      @classmethod
      def from_log10(cls, amplitude, reference, alpha, beta):
          """Construct from :math:`log_{10}` parametrization."""
          beta_ = beta / np.log(10)
          return cls(amplitude=amplitude, reference=reference, alpha=alpha, beta=beta_)
  
      @staticmethod
      def evaluate(energy, amplitude, reference, alpha, beta):
          """Evaluate the model (static function)."""
          xx = energy / reference
          exponent = -alpha - beta * np.log(xx)
          return amplitude * np.power(xx, exponent)
  
      @property
      def e_peak(self):
          r"""Spectral energy distribution peak energy (`~astropy.units.Quantity`).
  
          This is the peak in E^2 x dN/dE and is given by:
  
          .. math::
              E_{Peak} = E_{0} \exp{ (2 - \alpha) / (2 * \beta)}
          """
          reference = self.reference.quantity
          alpha = self.alpha.quantity
          beta = self.beta.quantity
          return reference * np.exp((2 - alpha) / (2 * beta))
  
  
  class TemplateSpectralModel(SpectralModel):
      """A model generated from a table of energy and value arrays.
  
      For more information see :ref:`template-spectral-model`.
  
      Parameters
      ----------
      energy : `~astropy.units.Quantity`
          Array of energies at which the model values are given
      values : array
          Array with the values of the model at energies ``energy``.
      norm : float
          Model scale that is multiplied to the supplied arrays. Defaults to 1.
      interp_kwargs : dict
          Interpolation keyword arguments pass to `scipy.interpolate.interp1d`.
          By default all values outside the interpolation range are set to zero.
          If you want to apply linear extrapolation you can pass `interp_kwargs={'fill_value':
          'extrapolate', 'kind': 'linear'}`. If you want to choose the interpolation
          scaling applied to values, you can use `interp_kwargs={"values_scale": "log"}`.
      meta : dict, optional
          Meta information, meta['filename'] will be used for serialization
      """
  
      tag = "TemplateSpectralModel"
      norm = Parameter("norm", 1, unit="")
      tilt = Parameter("tilt", 0, unit="", frozen=True)
      reference = Parameter("reference", "1 TeV", frozen=True)
  
      def __init__(
          self,
          energy,
          values,
          norm=norm.quantity,
          tilt=tilt.quantity,
          reference=reference.quantity,
          interp_kwargs=None,
          meta=None,
      ):
          self.energy = energy
          self.values = u.Quantity(values, copy=False)
          self.meta = dict() if meta is None else meta
          interp_kwargs = interp_kwargs or {}
          interp_kwargs.setdefault("values_scale", "log")
          interp_kwargs.setdefault("points_scale", ("log",))
  
          self._evaluate = ScaledRegularGridInterpolator(
              points=(energy,), values=values, **interp_kwargs
          )
  
          super().__init__(norm=norm, tilt=tilt, reference=reference)
  
      @classmethod
      def read_xspec_model(cls, filename, param, **kwargs):
          """Read XSPEC table model.
  
          The input is a table containing absorbed values from a XSPEC model as a
          function of energy.
  
          TODO: Format of the file should be described and discussed in
          https://gamma-astro-data-formats.readthedocs.io/en/latest/index.html
  
          Parameters
          ----------
          filename : str
              File containing the XSPEC model
          param : float
              Model parameter value
  
          Examples
          --------
          Fill table from an EBL model (Franceschini, 2008)
  
          >>> from gammapy.modeling.models import TemplateSpectralModel
          >>> filename = '$GAMMAPY_DATA/ebl/ebl_franceschini.fits.gz'
          >>> table_model = TemplateSpectralModel.read_xspec_model(filename=filename, param=0.3)
          """
          filename = make_path(filename)
  
          # Check if parameter value is in range
          table_param = Table.read(filename, hdu="PARAMETERS")
          pmin = table_param["MINIMUM"]
          pmax = table_param["MAXIMUM"]
          if param < pmin or param > pmax:
              raise ValueError(f"Out of range: param={param}, min={pmin}, max={pmax}")
  
          # Get energy values
          table_energy = Table.read(filename, hdu="ENERGIES")
          energy_lo = table_energy["ENERG_LO"]
          energy_hi = table_energy["ENERG_HI"]
  
          # set energy to log-centers
          energy = np.sqrt(energy_lo * energy_hi)
  
          # Get spectrum values (no interpolation, take closest value for param)
          table_spectra = Table.read(filename, hdu="SPECTRA")
          idx = np.abs(table_spectra["PARAMVAL"] - param).argmin()
          values = u.Quantity(table_spectra[idx][1], "", copy=False)  # no dimension
  
          kwargs.setdefault("interp_kwargs", {"values_scale": "lin"})
          return cls(energy=energy, values=values, **kwargs)
  
      def evaluate(self, energy, norm, tilt, reference):
          """Evaluate the model (static function)."""
          values = self._evaluate((energy,), clip=True)
          tilt_factor = np.power(energy / reference, -tilt)
          return norm * values * tilt_factor
  
      def to_dict(self):
          return {
              "type": self.tag,
              "parameters": self.parameters.to_dict(),
              "energy": {
                  "data": self.energy.data.tolist(),
                  "unit": str(self.energy.unit),
              },
              "values": {
                  "data": self.values.data.tolist(),
                  "unit": str(self.values.unit),
              },
          }
  
      @classmethod
      def from_dict(cls, data):
          parameters = Parameters.from_dict(data["parameters"])
          energy = u.Quantity(data["energy"]["data"], data["energy"]["unit"])
          values = u.Quantity(data["values"]["data"], data["values"]["unit"])
          return cls.from_parameters(parameters, energy=energy, values=values)
  
  
  class ScaleSpectralModel(SpectralModel):
      """Wrapper to scale another spectral model by a norm factor.
  
      Parameters
      ----------
      model : `SpectralModel`
          Spectral model to wrap.
      norm : float
          Multiplicative norm factor for the model value.
      """
  
      tag = "ScaleSpectralModel"
      norm = Parameter("norm", 1, unit="")
  
      def __init__(self, model, norm=norm.quantity):
          self.model = model
          self._covariance = None
          super().__init__(norm=norm)
  
      def evaluate(self, energy, norm):
          return norm * self.model(energy)
  
      def integral(self, emin, emax, **kwargs):
          return self.norm.value * self.model.integral(emin, emax, **kwargs)
  
  
  class Absorption:
      r"""Gamma-ray absorption models.
  
      For more information see :ref:`absorption-spectral-model`.
  
      Parameters
      ----------
      energy : `~astropy.units.Quantity`
          Energy node values
      param : `~astropy.units.Quantity`
          Parameter node values
      data : `~astropy.units.Quantity`
          Model value
      filename : str
          Filename of the absorption model used for serialisation.
      interp_kwargs : dict
          Interpolation option passed to `ScaledRegularGridInterpolator`.
          By default the models are extrapolated outside the range. To prevent
          this and raise an error instead use interp_kwargs = {"extrapolate": False}
      """
  
      tag = "Absorption"
  
      def __init__(self, energy, param, data, filename=None, interp_kwargs=None):
          self.data = data
          self.filename = filename
          # set values log centers
          self.param = param
          self.energy = energy
  
          interp_kwargs = interp_kwargs or {}
          interp_kwargs.setdefault("points_scale", ("log", "lin"))
          interp_kwargs.setdefault("extrapolate", True)
  
          self._evaluate = ScaledRegularGridInterpolator(
              points=(self.param, self.energy), values=data, **interp_kwargs
          )
  
      def to_dict(self):
          if self.filename is None:
              return {
                  "type": self.tag,
                  "energy": {
                      "data": self.energy.data.tolist(),
                      "unit": str(self.energy.unit),
                  },
                  "param": {
                      "data": self.param.data.tolist(),
                      "unit": str(self.param.unit),
                  },
                  "values": {
                      "data": self.data.data.tolist(),
                      "unit": str(self.data.unit),
                  },
              }
          else:
              return {"type": self.tag, "filename": self.filename}
  
      @classmethod
      def from_dict(cls, data):
  
          if "filename" in data:
              return cls.read(data["filename"])
          else:
              energy = u.Quantity(data["energy"]["data"], data["energy"]["unit"])
              param = u.Quantity(data["param"]["data"], data["param"]["unit"])
              values = u.Quantity(data["values"]["data"], data["values"]["unit"])
              return cls(energy=energy, param=param, data=values)
  
      @classmethod
      def read(cls, filename, interp_kwargs=None):
          """Build object from an XSPEC model.
  
          Todo: Format of XSPEC binary files should be referenced at https://gamma-astro-data-formats.readthedocs.io/en/latest/
  
          Parameters
          ----------
          filename : str
              File containing the model.
          interp_kwargs : dict
              Interpolation option passed to `ScaledRegularGridInterpolator`.
  
          Returns
          -------
          absorption : `Absorption`
              Absorption model.
          """
          # Create EBL data array
          filename = make_path(filename)
          table_param = Table.read(filename, hdu="PARAMETERS")
  
          # TODO: for some reason the table contain duplicated values
          param, idx = np.unique(table_param[0]["VALUE"], return_index=True)
  
          # Get energy values
          table_energy = Table.read(filename, hdu="ENERGIES")
          energy_lo = u.Quantity(
              table_energy["ENERG_LO"], "keV", copy=False
          )  # unit not stored in file
          energy_hi = u.Quantity(
              table_energy["ENERG_HI"], "keV", copy=False
          )  # unit not stored in file
          energy = np.sqrt(energy_lo * energy_hi)
  
          # Get spectrum values
          table_spectra = Table.read(filename, hdu="SPECTRA")
          data = table_spectra["INTPSPEC"].data[idx, :]
          return cls(
              energy=energy,
              param=param,
              data=data,
              filename=filename,
              interp_kwargs=interp_kwargs,
          )
  
      @classmethod
      def read_builtin(cls, name, interp_kwargs=None):
          """Read one of the built-in absorption models.
  
          Parameters
          ----------
          name : {'franceschini', 'dominguez', 'finke'}
              name of one of the available model in gammapy-data
  
          References
          ----------
          .. [1] Franceschini et al., "Extragalactic optical-infrared background radiation, its time evolution and the cosmic photon-photon opacity",
              `Link <https://ui.adsabs.harvard.edu/abs/2008A%26A...487..837F>`__
          .. [2] Dominguez et al., " Extragalactic background light inferred from AEGIS galaxy-SED-type fractions"
              `Link <https://ui.adsabs.harvard.edu/abs/2011MNRAS.410.2556D>`__
          .. [3] Finke et al., "Modeling the Extragalactic Background Light from Stars and Dust"
              `Link <https://ui.adsabs.harvard.edu/abs/2010ApJ...712..238F>`__
  
          Returns
          -------
          absorption : `Absorption`
              Absorption model.
  
          """
          models = dict()
          models["franceschini"] = "$GAMMAPY_DATA/ebl/ebl_franceschini.fits.gz"
          models["dominguez"] = "$GAMMAPY_DATA/ebl/ebl_dominguez11.fits.gz"
          models["finke"] = "$GAMMAPY_DATA/ebl/frd_abs.fits.gz"
  
          return cls.read(models[name], interp_kwargs=interp_kwargs)
  
      def table_model(self, parameter):
          """Table model for a given parameter value.
  
          Parameters
          ----------
          parameter : float
              Parameter value.
  
          Returns
          -------
          template_model : `TemplateSpectralModel`
              Template spectral model.
          """
          energy = self.energy
          values = self.evaluate(energy=energy, parameter=parameter)
          return TemplateSpectralModel(
              energy=energy, values=values, interp_kwargs={"values_scale": "log"}
          )
  
      def evaluate(self, energy, parameter):
          """Evaluate model for energy and parameter value."""
          return np.clip(self._evaluate((parameter, energy)), 0, 1)
  
  
  class AbsorbedSpectralModel(SpectralModel):
      r"""Spectral model with EBL absorption.
  
      For more information see :ref:`absorbed-spectral-model`.
  
      Parameters
      ----------
      spectral_model : `SpectralModel`
          Spectral model.
      absorption : `Absorption`
          Absorption model.
      redshift : float
          Redshift of the absorption model
      alpha_norm: float
          Norm of the EBL model
      """
  
      tag = "AbsorbedSpectralModel"
      alpha_norm = Parameter("alpha_norm", 1.0, frozen=True)
      redshift = Parameter("redshift", 0.1, frozen=True)
  
      def __init__(
          self, spectral_model, absorption, redshift, alpha_norm=alpha_norm.quantity,
      ):
          self.spectral_model = spectral_model
          self.absorption = absorption
  
          min_ = self.absorption.param.min()
          max_ = self.absorption.param.max()
  
          redshift = Parameter("redshift", redshift, frozen=True, min=min_, max=max_)
          super().__init__(redshift=redshift, alpha_norm=alpha_norm)
  
      def _check_covariance(self):
          if not self.parameters == self._covariance.parameters:
              self._covariance = Covariance(self.parameters)
  
      @property
      def covariance(self):
          self._check_covariance()
          self._covariance.set_subcovariance(self.spectral_model.covariance)
          return self._covariance
  
      @covariance.setter
      def covariance(self, covariance):
          self._check_covariance()
          self._covariance.data = covariance
  
          subcovar = self._covariance.get_subcovariance(
              self.spectral_model.covariance.parameters
          )
          self.spectral_model.covariance = subcovar
  
      @property
      def parameters(self):
          return (
              Parameters([self.redshift, self.alpha_norm])
              + self.spectral_model.parameters
          )
  
      def evaluate(self, energy, **kwargs):
          """Evaluate the model at a given energy."""
          # assign redshift value and remove it from dictionary
          # since it does not belong to the spectral model
          parameter = kwargs.pop("redshift")
          alpha_norm = kwargs.pop("alpha_norm")
  
          dnde = self.spectral_model.evaluate(energy=energy, **kwargs)
          absorption = self.absorption.evaluate(energy=energy, parameter=parameter)
          # Power rule: (e ^ a) ^ b = e ^ (a * b)
          absorption = np.power(absorption, alpha_norm)
          return dnde * absorption
  
      def to_dict(self):
          return {
              "type": self.tag,
              "base_model": self.spectral_model.to_dict(),
              "absorption": self.absorption.to_dict(),
              "absorption_parameter": {"name": "redshift", "value": self.redshift.value,},
              "parameters": Parameters([self.redshift, self.alpha_norm]).to_dict(),
          }
  
      @classmethod
      def from_dict(cls, data):
          from gammapy.modeling.models import SPECTRAL_MODEL_REGISTRY
  
          model_class = SPECTRAL_MODEL_REGISTRY.get_cls(data["base_model"]["type"])
  
          model = cls(
              spectral_model=model_class.from_dict(data["base_model"]),
              absorption=Absorption.from_dict(data["absorption"]),
              redshift=data["absorption_parameter"]["value"],
          )
          return model
  
  
  class NaimaSpectralModel(SpectralModel):
      r"""A wrapper for Naima models.
  
      For more information see :ref:`naima-spectral-model`.
  
      Parameters
      ----------
      radiative_model : `~naima.models.BaseRadiative`
          An instance of a radiative model defined in `~naima.models`
      distance : `~astropy.units.Quantity`, optional
          Distance to the source. If set to 0, the intrinsic differential
          luminosity will be returned. Default is 1 kpc
      seed : str or list of str, optional
          Seed photon field(s) to be considered for the `radiative_model` flux computation,
          in case of a `~naima.models.InverseCompton` model. It can be a subset of the
          `seed_photon_fields` list defining the `radiative_model`. Default is the whole list
          of photon fields
      nested_models : dict
          Additionnal parameters for nested models not supplied by the radiative model,
          for now this is used  only for synchrotron self-compton model
      """
  
      tag = "NaimaSpectralModel"
  
      def __init__(
          self, radiative_model, distance=1.0 * u.kpc, seed=None, nested_models=None
      ):
          import naima
  
          self.radiative_model = radiative_model
          self._particle_distribution = self.radiative_model.particle_distribution
          self.distance = u.Quantity(distance)
          self.seed = seed
  
          if nested_models is None:
              nested_models = {}
  
          self.nested_models = nested_models
  
          if isinstance(self._particle_distribution, naima.models.TableModel):
              param_names = ["amplitude"]
          else:
              param_names = self._particle_distribution.param_names
  
          parameters = []
  
          for name in param_names:
              value = getattr(self._particle_distribution, name)
              parameter = Parameter(name, value)
              parameters.append(parameter)
  
          # In case of a synchrotron radiative model, append B to the fittable parameters
          if "B" in self.radiative_model.param_names:
              value = getattr(self.radiative_model, "B")
              parameter = Parameter("B", value)
              parameters.append(parameter)
  
          # In case of a synchrotron self compton model, append B and Rpwn to the fittable parameters
          if (
              isinstance(self.radiative_model, naima.models.InverseCompton)
              and "SSC" in self.nested_models
          ):
              B = self.nested_models["SSC"]["B"]
              radius = self.nested_models["SSC"]["radius"]
              parameters.append(Parameter("B", B))
              parameters.append(Parameter("radius", radius, frozen=True))
  
          self.default_parameters = Parameters(parameters)
          super().__init__()
  
      def _evaluate_ssc(
          self, energy,
      ):
          """
          Compute photon density spectrum from synchrotron emission for synchrotron self-compton model,
          assuming uniform synchrotron emissivity inside a sphere of radius R
          (see Section 4.1 of Atoyan & Aharonian 1996)
  
          based on :
          "https://naima.readthedocs.io/en/latest/examples.html#crab-nebula-ssc-model"
  
          """
          import naima
  
          SYN = naima.models.Synchrotron(
              self._particle_distribution,
              B=self.B.quantity,
              Eemax=self.radiative_model.Eemax,
              Eemin=self.radiative_model.Eemin,
          )
  
          Esy = np.logspace(-7, 9, 100) * u.eV
          Lsy = SYN.flux(Esy, distance=0 * u.cm)  # use distance 0 to get luminosity
          phn_sy = Lsy / (4 * np.pi * self.radius.quantity ** 2 * const.c) * 2.24
          # The factor 2.24 comes from the assumption on uniform synchrotron
          # emissivity inside a sphere
  
          if "SSC" not in self.radiative_model.seed_photon_fields:
              self.radiative_model.seed_photon_fields["SSC"] = {
                  "isotropic": True,
                  "type": "array",
                  "energy": Esy,
                  "photon_density": phn_sy,
              }
          else:
              self.radiative_model.seed_photon_fields["SSC"]["photon_density"] = phn_sy
  
          dnde = self.radiative_model.flux(
              energy, seed=self.seed, distance=self.distance
          ) + SYN.flux(energy, distance=self.distance)
          return dnde
  
      def evaluate(self, energy, **kwargs):
          """Evaluate the model."""
          import naima
  
          for name, value in kwargs.items():
              setattr(self._particle_distribution, name, value)
  
          if "B" in self.radiative_model.param_names:
              self.radiative_model.B = self.B.quantity
  
          if (
              isinstance(self.radiative_model, naima.models.InverseCompton)
              and "SSC" in self.nested_models
          ):
              dnde = self._evaluate_ssc(energy.flatten())
          elif self.seed is not None:
              dnde = self.radiative_model.flux(
                  energy.flatten(), seed=self.seed, distance=self.distance
              )
          else:
              dnde = self.radiative_model.flux(energy.flatten(), distance=self.distance)
  
          dnde = dnde.reshape(energy.shape)
          unit = 1 / (energy.unit * u.cm ** 2 * u.s)
          return dnde.to(unit)
  
      @classmethod
      def from_dict(cls, data):
          raise NotImplementedError(
              "Currently the NaimaSpectralModel cannot be read from YAML"
          )
  
      @classmethod
      def from_parameters(cls, parameters, **kwargs):
          raise NotImplementedError(
              "Currently the NaimaSpectralModel cannot be built from a list of parameters."
          )
  
  
  class GaussianSpectralModel(SpectralModel):
      r"""Gaussian spectral model.
  
      For more information see :ref:`gaussian-spectral-model`.
  
      Parameters
      ----------
      norm : `~astropy.units.Quantity`
          :math:`N_0`
      mean : `~astropy.units.Quantity`
          :math:`\bar{E}`
      sigma : `~astropy.units.Quantity`
          :math:`\sigma`
      """
  
      tag = "GaussianSpectralModel"
      norm = Parameter("norm", 1e-12 * u.Unit("cm-2 s-1"))
      mean = Parameter("mean", 1 * u.TeV)
      sigma = Parameter("sigma", 2 * u.TeV)
  
      @staticmethod
      def evaluate(energy, norm, mean, sigma):
          return (
              norm
              / (sigma * np.sqrt(2 * np.pi))
              * np.exp(-((energy - mean) ** 2) / (2 * sigma ** 2))
          )
  
      def integral(self, emin, emax, **kwargs):
          r"""Integrate Gaussian analytically.
  
          .. math::
              F(E_{min}, E_{max}) = \frac{N_0}{2} \left[ erf(\frac{E - \bar{E}}{\sqrt{2} \sigma})\right]_{E_{min}}^{E_{max}}
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range
          """
          # kwargs are passed to this function but not used
          # this is to get a consistent API with SpectralModel.integral()
          u_min = (
              (emin - self.mean.quantity) / (np.sqrt(2) * self.sigma.quantity)
          ).to_value("")
          u_max = (
              (emax - self.mean.quantity) / (np.sqrt(2) * self.sigma.quantity)
          ).to_value("")
  
          return (
              self.norm.quantity
              / 2
              * (scipy.special.erf(u_max) - scipy.special.erf(u_min))
          )
  
      def energy_flux(self, emin, emax):
          r"""Compute energy flux in given energy range analytically.
  
          .. math::
              G(E_{min}, E_{max}) =  \frac{N_0 \sigma}{\sqrt{2*\pi}}* \left[ - \exp(\frac{E_{min}-\bar{E}}{\sqrt{2} \sigma})
              \right]_{E_{min}}^{E_{max}} + \frac{N_0 * \bar{E}}{2} \left[ erf(\frac{E - \bar{E}}{\sqrt{2} \sigma})
               \right]_{E_{min}}^{E_{max}}
  
  
          Parameters
          ----------
          emin, emax : `~astropy.units.Quantity`
              Lower and upper bound of integration range.
          """
          u_min = (
              (emin - self.mean.quantity) / (np.sqrt(2) * self.sigma.quantity)
          ).to_value("")
          u_max = (
              (emax - self.mean.quantity) / (np.sqrt(2) * self.sigma.quantity)
          ).to_value("")
          a = self.norm.quantity * self.sigma.quantity / np.sqrt(2 * np.pi)
          b = self.norm.quantity * self.mean.quantity / 2
          return a * (np.exp(-(u_min ** 2)) - np.exp(-(u_max ** 2))) + b * (
              scipy.special.erf(u_max) - scipy.special.erf(u_min)
          )
  
files: gammapy/modeling/models/spectral.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  import pytest
  from numpy.testing import assert_allclose
  import astropy.units as u
  from gammapy.modeling.models import Model, Parameter, Parameters
  from gammapy.datasets import Datasets
  from gammapy.utils.testing import requires_data
  
  
  class MyModel(Model):
      """Simple model example"""
  
      x = Parameter("x", 1, "cm")
      y = Parameter("y", 2)
  
  
  class CoModel(Model):
      """Compound model example"""
  
      norm = Parameter("norm", 42, "cm")
  
      def __init__(self, m1, m2, norm=norm.quantity):
          self.m1 = m1
          self.m2 = m2
          super().__init__(norm=norm)
  
      @property
      def parameters(self):
          return Parameters([self.norm]) + self.m1.parameters + self.m2.parameters
  
  
  class WrapperModel(Model):
      """Wrapper compound model.
  
      Dynamically generated parameters in `__init__`,
      and a parameter name conflict with the wrapped
      model, both have a parameter called "y".
      """
  
      def __init__(self, m1, a=1, y=99):
          self.m1 = m1
          a = Parameter("a", a)
          y = Parameter("y", y)
          self.default_parameters = Parameters([a, y])
          super().__init__(a=a, y=y)
  
      @property
      def parameters(self):
          return Parameters([self.a, self.y]) + self.m1.parameters
  
  
  def test_model_class():
      assert isinstance(MyModel.parameters, property)
      assert MyModel.x.name == "x"
      assert MyModel.default_parameters["x"] is MyModel.x
  
  
  def test_model_class_par_init():
      x = Parameter("x", 4, "cm")
      y = Parameter("y", 10)
  
      model = MyModel(x=x, y=y)
  
      assert x is model.x
      assert y is model.y
  
  
  def test_model_init():
      m = MyModel()
      assert m.x.name == "x"
      assert m.x.value == 1
      assert m.x is m.parameters[0]
      assert m.y is m.parameters[1]
      assert m.parameters is not MyModel.default_parameters
  
      m = MyModel(x="99 cm")
      assert m.x.value == 99
      assert m.y.value == 2
  
      # Currently we always convert to the default unit of a parameter
      # TODO: discuss if this is the behaviour we want, or if we instead
      # should change to the user-set unit, as long as it's compatible
      m = MyModel(x=99 * u.m)
      assert_allclose(m.x.value, 9900)
      assert m.x.unit == "cm"
  
      with pytest.raises(u.UnitConversionError):
          MyModel(x=99)
  
      with pytest.raises(u.UnitConversionError):
          MyModel(x=99 * u.s)
  
  
  def test_wrapper_model():
      outer = MyModel()
      m = WrapperModel(outer)
  
      assert isinstance(m.a, Parameter)
      assert m.y.value == 99
  
      assert m.parameters.names == ["a", "y", "x", "y"]
  
  
  def test_model_parameter():
      m = MyModel(x="99 cm")
      assert isinstance(m.x, Parameter)
      assert m.x.value == 99
      assert m.x.unit == "cm"
  
      with pytest.raises(TypeError):
          m.x = 99
  
      with pytest.raises(TypeError):
          m.x = 99 * u.cm
  
  
  # TODO: implement parameter linking. Not working ATM!
  @pytest.mark.xfail()
  def test_model_parameter_link():
      # Assigning a parameter should create a link
      m = MyModel()
      par = MyModel.x.copy()
      m.x = par
      assert isinstance(m.x, Parameter)
      assert m.x is par
      # model.parameters should be in sync with attributes
      assert m.x is m.parameters["x"]
  
  
  def test_model_copy():
      m = MyModel()
  
      m2 = m.copy()
  
      # Models should be independent
      assert m.parameters is not m2.parameters
      assert m.parameters[0] is not m2.parameters[0]
  
  
  def test_model_create():
      spectral_model = Model.create(
          "PowerLaw2SpectralModel", amplitude="1e-10 cm-2 s-1", index=3
      )
      assert "PowerLaw2SpectralModel" in spectral_model.tag
      assert_allclose(spectral_model.index.value, 3)
  
  
  def test_compound_model():
      m1 = MyModel()
      m2 = MyModel(x=10 * u.cm, y=20)
      m = CoModel(m1, m2)
      assert len(m.parameters) == 5
      assert m.parameters.names == ["norm", "x", "y", "x", "y"]
      assert_allclose(m.parameters.values, [42, 1, 2, 10, 20])
  
  
  def test_parameter_link_init():
      m1 = MyModel()
      m2 = MyModel(y=m1.y)
  
      assert m1.y is m2.y
  
      m1.y.value = 100
      assert_allclose(m2.y.value, 100)
  
  
  def test_parameter_link():
      m1 = MyModel()
      m2 = MyModel()
  
      m2.y = m1.y
  
      m1.y.value = 100
      assert_allclose(m2.y.value, 100)
  
  
  @requires_data()
  def test_models_management(tmp_path):
      path = "$GAMMAPY_DATA/tests/models"
      filedata = "gc_example_datasets.yaml"
      filemodel = "gc_example_models.yaml"
  
      datasets = Datasets.read(path, filedata, filemodel)
  
      model1 = datasets.models[0].copy(name="model1", datasets_names=None)
      model2 = datasets.models[0].copy(name="model2", datasets_names=[datasets[1].name])
      model3 = datasets.models[0].copy(name="model3", datasets_names=[datasets[0].name])
  
      model1b = datasets.models[0].copy(name="model1", datasets_names=None)
      model1b.spectral_model.amplitude.value *= 2
  
      names0 = datasets[0].models.names
      names1 = datasets[1].models.names
  
      datasets[0].models.append(model1)
      _ = datasets[0].models + model2
      assert datasets[0].models.names == names0 + ["model1", "model2"]
      assert datasets[0].models["model1"].datasets_names == None
      assert datasets[0].models["model2"].datasets_names == [
          datasets[1].name,
          datasets[0].name,
      ]
      assert datasets[1].models.names == names1 + ["model1", "model2"]
  
      # TODO consistency check at datasets level ?
      # or force same Models for each dataset._models on datasets init ?
      # here we have the right behavior: model1 and model2 are also added to dataset1
      # because serialization create a global model object shared by all datasets
      # if that was not the case we could have inconsistancies
      # such as model1.datasets_names == None added only to dataset1
      # user can still create such inconsistancies if they define datasets
      # with diferent Models objects for each dataset.
  
      del datasets[0].models["model1"]
      assert datasets[0].models.names == names0 + ["model2"]
  
      datasets[0].models.remove(model2)
      assert datasets[0].models.names == names0
  
      datasets.models.append(model2)
      assert model2 in datasets.models
      assert model2 in datasets[1].models
      assert datasets[0].models.names == names0 + ["model2"]
  
      datasets[0].models.extend([model1, model3])
      assert datasets[0].models.names == names0 + ["model2", "model1", "model3"]
  
      for m in [model1, model2, model3]:
          datasets.models.remove(m)
      assert datasets[0].models.names == names0
      assert datasets[1].models.names == names1
      datasets.models.extend([model1, model2, model3])
      assert datasets[0].models.names == names0 + ["model1", "model2", "model3"]
      assert datasets[1].models.names == names1 + ["model1", "model2"]
  
      for m in [model1, model2, model3]:
          datasets.models.remove(m)
      _ = datasets.models + [model1, model2]
      assert datasets[0].models.names == names0 + ["model1", "model2"]
      assert datasets[1].models.names == names1 + ["model1", "model2"]
  
      datasets[0].models["model2"] = model3
      assert datasets[0].models.names == names0 + ["model1", "model3"]
      assert datasets[1].models.names == names1 + ["model1"]
  
      datasets.models.remove(model1)
      datasets[0].models = model1
      _ = datasets.models  # auto-update models
      assert datasets[0].models.names == ["model1", "gll_iem_v06_cutout"]
      # the consistency check added diffuse model contained in the global model
  
      npred1 = datasets[0].npred().data.sum()
      datasets.models.remove(model1)
      npred0 = datasets[0].npred().data.sum()
      datasets.models.append(model1b)
      npred1b = datasets[0].npred().data.sum()
      assert npred1b != npred1
      assert npred1b != npred0
      assert_allclose(npred1b, 2147.407023024028)
  
      datasets.models.remove(model1b)
      _ = datasets.models  # auto-update models
      newmodels = [datasets.models[0].copy() for k in range(48)]
      datasets.models.extend(newmodels)
  
      datasets[0].use_cache = False
      nocache = datasets[0].npred().data.sum()
      datasets[0].use_cache = True
      assert_allclose(datasets[0].npred().data.sum(), nocache)
  
files: gammapy/modeling/models/tests/test_core.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  import pytest
  from numpy.testing import assert_allclose
  import astropy.units as u
  from gammapy.modeling.models import Model, Parameter, Parameters
  from gammapy.datasets import Datasets
  from gammapy.utils.testing import requires_data
  
  
  class MyModel(Model):
      """Simple model example"""
  
      x = Parameter("x", 1, "cm")
      y = Parameter("y", 2)
  
  
  class CoModel(Model):
      """Compound model example"""
  
      norm = Parameter("norm", 42, "cm")
  
      def __init__(self, m1, m2, norm=norm.quantity):
          self.m1 = m1
          self.m2 = m2
          super().__init__(norm=norm)
  
      @property
      def parameters(self):
          return Parameters([self.norm]) + self.m1.parameters + self.m2.parameters
  
  
  class WrapperModel(Model):
      """Wrapper compound model.
  
      Dynamically generated parameters in `__init__`,
      and a parameter name conflict with the wrapped
      model, both have a parameter called "y".
      """
  
      def __init__(self, m1, a=1, y=99):
          self.m1 = m1
          a = Parameter("a", a)
          y = Parameter("y", y)
          self.default_parameters = Parameters([a, y])
          super().__init__(a=a, y=y)
  
      @property
      def parameters(self):
          return Parameters([self.a, self.y]) + self.m1.parameters
  
  
  def test_model_class():
      assert isinstance(MyModel.parameters, property)
      assert MyModel.x.name == "x"
      assert MyModel.default_parameters["x"] is MyModel.x
  
  
  def test_model_class_par_init():
      x = Parameter("x", 4, "cm")
      y = Parameter("y", 10)
  
      model = MyModel(x=x, y=y)
  
      assert x is model.x
      assert y is model.y
  
  
  def test_model_init():
      m = MyModel()
      assert m.x.name == "x"
      assert m.x.value == 1
      assert m.x is m.parameters[0]
      assert m.y is m.parameters[1]
      assert m.parameters is not MyModel.default_parameters
  
      m = MyModel(x="99 cm")
      assert m.x.value == 99
      assert m.y.value == 2
  
      # Currently we always convert to the default unit of a parameter
      # TODO: discuss if this is the behaviour we want, or if we instead
      # should change to the user-set unit, as long as it's compatible
      m = MyModel(x=99 * u.m)
      assert_allclose(m.x.value, 9900)
      assert m.x.unit == "cm"
  
      with pytest.raises(u.UnitConversionError):
          MyModel(x=99)
  
      with pytest.raises(u.UnitConversionError):
          MyModel(x=99 * u.s)
  
  
  def test_wrapper_model():
      outer = MyModel()
      m = WrapperModel(outer)
  
      assert isinstance(m.a, Parameter)
      assert m.y.value == 99
  
      assert m.parameters.names == ["a", "y", "x", "y"]
  
  
  def test_model_parameter():
      m = MyModel(x="99 cm")
      assert isinstance(m.x, Parameter)
      assert m.x.value == 99
      assert m.x.unit == "cm"
  
      with pytest.raises(TypeError):
          m.x = 99
  
      with pytest.raises(TypeError):
          m.x = 99 * u.cm
  
  
  # TODO: implement parameter linking. Not working ATM!
  @pytest.mark.xfail()
  def test_model_parameter_link():
      # Assigning a parameter should create a link
      m = MyModel()
      par = MyModel.x.copy()
      m.x = par
      assert isinstance(m.x, Parameter)
      assert m.x is par
      # model.parameters should be in sync with attributes
      assert m.x is m.parameters["x"]
  
  
  def test_model_copy():
      m = MyModel()
  
      m2 = m.copy()
  
      # Models should be independent
      assert m.parameters is not m2.parameters
      assert m.parameters[0] is not m2.parameters[0]
  
  
  def test_model_create():
      spectral_model = Model.create(
          "PowerLaw2SpectralModel", amplitude="1e-10 cm-2 s-1", index=3
      )
      assert spectral_model.tag == "PowerLaw2SpectralModel"
      assert_allclose(spectral_model.index.value, 3)
  
  
  def test_compound_model():
      m1 = MyModel()
      m2 = MyModel(x=10 * u.cm, y=20)
      m = CoModel(m1, m2)
      assert len(m.parameters) == 5
      assert m.parameters.names == ["norm", "x", "y", "x", "y"]
      assert_allclose(m.parameters.values, [42, 1, 2, 10, 20])
  
  
  def test_parameter_link_init():
      m1 = MyModel()
      m2 = MyModel(y=m1.y)
  
      assert m1.y is m2.y
  
      m1.y.value = 100
      assert_allclose(m2.y.value, 100)
  
  
  def test_parameter_link():
      m1 = MyModel()
      m2 = MyModel()
  
      m2.y = m1.y
  
      m1.y.value = 100
      assert_allclose(m2.y.value, 100)
  
  
  @requires_data()
  def test_models_management(tmp_path):
      path = "$GAMMAPY_DATA/tests/models"
      filedata = "gc_example_datasets.yaml"
      filemodel = "gc_example_models.yaml"
  
      datasets = Datasets.read(path, filedata, filemodel)
  
      model1 = datasets.models[0].copy(name="model1", datasets_names=None)
      model2 = datasets.models[0].copy(name="model2", datasets_names=[datasets[1].name])
      model3 = datasets.models[0].copy(name="model3", datasets_names=[datasets[0].name])
  
      model1b = datasets.models[0].copy(name="model1", datasets_names=None)
      model1b.spectral_model.amplitude.value *= 2
  
      names0 = datasets[0].models.names
      names1 = datasets[1].models.names
  
      datasets[0].models.append(model1)
      _ = datasets[0].models + model2
      assert datasets[0].models.names == names0 + ["model1", "model2"]
      assert datasets[0].models["model1"].datasets_names == None
      assert datasets[0].models["model2"].datasets_names == [
          datasets[1].name,
          datasets[0].name,
      ]
      assert datasets[1].models.names == names1 + ["model1", "model2"]
  
      # TODO consistency check at datasets level ?
      # or force same Models for each dataset._models on datasets init ?
      # here we have the right behavior: model1 and model2 are also added to dataset1
      # because serialization create a global model object shared by all datasets
      # if that was not the case we could have inconsistancies
      # such as model1.datasets_names == None added only to dataset1
      # user can still create such inconsistancies if they define datasets
      # with diferent Models objects for each dataset.
  
      del datasets[0].models["model1"]
      assert datasets[0].models.names == names0 + ["model2"]
  
      datasets[0].models.remove(model2)
      assert datasets[0].models.names == names0
  
      datasets.models.append(model2)
      assert model2 in datasets.models
      assert model2 in datasets[1].models
      assert datasets[0].models.names == names0 + ["model2"]
  
      datasets[0].models.extend([model1, model3])
      assert datasets[0].models.names == names0 + ["model2", "model1", "model3"]
  
      for m in [model1, model2, model3]:
          datasets.models.remove(m)
      assert datasets[0].models.names == names0
      assert datasets[1].models.names == names1
      datasets.models.extend([model1, model2, model3])
      assert datasets[0].models.names == names0 + ["model1", "model2", "model3"]
      assert datasets[1].models.names == names1 + ["model1", "model2"]
  
      for m in [model1, model2, model3]:
          datasets.models.remove(m)
      _ = datasets.models + [model1, model2]
      assert datasets[0].models.names == names0 + ["model1", "model2"]
      assert datasets[1].models.names == names1 + ["model1", "model2"]
  
      datasets[0].models["model2"] = model3
      assert datasets[0].models.names == names0 + ["model1", "model3"]
      assert datasets[1].models.names == names1 + ["model1"]
  
      datasets.models.remove(model1)
      datasets[0].models = model1
      _ = datasets.models  # auto-update models
      assert datasets[0].models.names == ["model1", "gll_iem_v06_cutout"]
      # the consistency check added diffuse model contained in the global model
  
      npred1 = datasets[0].npred().data.sum()
      datasets.models.remove(model1)
      npred0 = datasets[0].npred().data.sum()
      datasets.models.append(model1b)
      npred1b = datasets[0].npred().data.sum()
      assert npred1b != npred1
      assert npred1b != npred0
      assert_allclose(npred1b, 2147.407023024028)
  
      datasets.models.remove(model1b)
      _ = datasets.models  # auto-update models
      newmodels = [datasets.models[0].copy() for k in range(48)]
      datasets.models.extend(newmodels)
  
      datasets[0].use_cache = False
      nocache = datasets[0].npred().data.sum()
      datasets[0].use_cache = True
      assert_allclose(datasets[0].npred().data.sum(), nocache)
  
files: gammapy/modeling/models/tests/test_core.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  import pytest
  import numpy as np
  from numpy.testing import assert_allclose
  import astropy.units as u
  from astropy.table import Table
  from astropy.utils.data import get_pkg_data_filename
  from gammapy.maps import Map, MapAxis
  from gammapy.modeling.models import (
      MODEL_REGISTRY,
      AbsorbedSpectralModel,
      Absorption,
      BackgroundModel,
      Model,
      Models,
  )
  from gammapy.utils.scripts import read_yaml, write_yaml
  from gammapy.utils.testing import requires_data
  
  
  @requires_data()
  def test_dict_to_skymodels():
      filename = get_pkg_data_filename("data/examples.yaml")
      models_data = read_yaml(filename)
      models = Models.from_dict(models_data)
  
      assert len(models) == 5
  
      model0 = models[0]
      assert isinstance(model0, BackgroundModel)
      assert model0.name == "background_irf"
  
      model0 = models[1]
      assert "ExpCutoffPowerLawSpectralModel" in model0.spectral_model.tag
      assert "PointSpatialModel" in model0.spatial_model.tag
  
      pars0 = model0.parameters
      assert pars0["index"].value == 2.1
      assert pars0["index"].unit == ""
      assert np.isnan(pars0["index"].max)
      assert np.isnan(pars0["index"].min)
      assert not pars0["index"].frozen
  
      assert pars0["lon_0"].value == -0.5
      assert pars0["lon_0"].unit == "deg"
      assert pars0["lon_0"].max == 180.0
      assert pars0["lon_0"].min == -180.0
      assert pars0["lon_0"].frozen
  
      assert pars0["lat_0"].value == -0.0005
      assert pars0["lat_0"].unit == "deg"
      assert pars0["lat_0"].max == 90.0
      assert pars0["lat_0"].min == -90.0
      assert pars0["lat_0"].frozen
  
      assert pars0["lambda_"].value == 0.006
      assert pars0["lambda_"].unit == "TeV-1"
      assert np.isnan(pars0["lambda_"].min)
      assert np.isnan(pars0["lambda_"].max)
  
      model1 = models[2]
      assert "PL" in model1.spectral_model.tag
      assert "PowerLawSpectralModel" in model1.spectral_model.tag
      assert "DiskSpatialModel" in model1.spatial_model.tag
      assert "disk" in model1.spatial_model.tag
      assert "LightCurveTemplateTemporalModel" in model1.temporal_model.tag
  
      pars1 = model1.parameters
      assert pars1["index"].value == 2.2
      assert pars1["index"].unit == ""
      assert pars1["lat_0"].scale == 1.0
      assert pars1["lat_0"].factor == pars1["lat_0"].value
  
      assert np.isnan(pars1["index"].max)
      assert np.isnan(pars1["index"].min)
  
      assert pars1["r_0"].unit == "deg"
  
      model2 = models[3]
      assert_allclose(model2.spectral_model.energy.data, [34.171, 44.333, 57.517])
      assert model2.spectral_model.energy.unit == "MeV"
      assert_allclose(
          model2.spectral_model.values.data, [2.52894e-06, 1.2486e-06, 6.14648e-06]
      )
      assert model2.spectral_model.values.unit == "1 / (cm2 MeV s sr)"
  
      assert "TemplateSpectralModel" in model2.spectral_model.tag
      assert "TemplateSpatialModel" in model2.spatial_model.tag
  
      assert model2.spatial_model.parameters["norm"].value == 1.0
      assert not model2.spatial_model.normalize
      assert model2.spectral_model.parameters["norm"].value == 2.1
  
      # TODO problem of duplicate parameter name between TemplateSpatialModel and TemplateSpectralModel
      # assert model2.parameters["norm"].value == 2.1 # fail
  
  
  @requires_data()
  def test_sky_models_io(tmp_path):
      # TODO: maybe change to a test case where we create a model programatically?
      filename = get_pkg_data_filename("data/examples.yaml")
      models = Models.read(filename)
      models.covariance = np.eye(len(models.parameters))
      models.write(tmp_path / "tmp.yaml")
      models = Models.read(tmp_path / "tmp.yaml")
      assert models._covar_file == "tmp_covariance.dat"
      assert_allclose(models.covariance.data, np.eye(len(models.parameters)))
      assert_allclose(models.parameters["lat_0"].min, -90.0)
  
      # TODO: not sure if we should just round-trip, or if we should
      # check YAML file content (e.g. against a ref file in the repo)
      # or check serialised dict content
  
  
  @requires_data()
  def test_absorption_io(tmp_path):
      dominguez = Absorption.read_builtin("dominguez")
      model = AbsorbedSpectralModel(
          spectral_model=Model.create("PowerLawSpectralModel"),
          absorption=dominguez,
          redshift=0.5,
      )
      assert len(model.parameters) == 5
  
      model_dict = model.to_dict()
      parnames = [_["name"] for _ in model_dict["parameters"]]
      assert parnames == ["redshift", "alpha_norm"]
  
      new_model = AbsorbedSpectralModel.from_dict(model_dict)
  
      assert new_model.redshift.value == 0.5
      assert new_model.alpha_norm.name == "alpha_norm"
      assert new_model.alpha_norm.value == 1
      assert "PowerLawSpectralModel" in new_model.spectral_model.tag
      assert_allclose(new_model.absorption.energy, dominguez.energy)
      assert_allclose(new_model.absorption.param, dominguez.param)
      assert len(new_model.parameters) == 5
  
      test_absorption = Absorption(
          u.Quantity(range(3), "keV"),
          u.Quantity(range(2), ""),
          u.Quantity(np.ones((2, 3)), ""),
      )
      model = AbsorbedSpectralModel(
          spectral_model=Model.create("PowerLawSpectralModel"),
          absorption=test_absorption,
          redshift=0.5,
      )
      model_dict = model.to_dict()
      new_model = AbsorbedSpectralModel.from_dict(model_dict)
  
      assert_allclose(new_model.absorption.energy, test_absorption.energy)
      assert_allclose(new_model.absorption.param, test_absorption.param)
  
      write_yaml(model_dict, tmp_path / "tmp.yaml")
      read_yaml(tmp_path / "tmp.yaml")
  
  
  def make_all_models():
      """Make an instance of each model, for testing."""
      yield Model.create("ConstantSpatialModel")
      map_constantmodel = Map.create(npix=(10, 20), unit="sr-1")
      yield Model.create("TemplateSpatialModel", map=map_constantmodel)
      yield Model.create("DiskSpatialModel", lon_0="1 deg", lat_0="2 deg", r_0="3 deg")
      yield Model.create(
          "GaussianSpatialModel", lon_0="1 deg", lat_0="2 deg", sigma="3 deg"
      )
      yield Model.create("PointSpatialModel", lon_0="1 deg", lat_0="2 deg")
      yield Model.create(
          "ShellSpatialModel", lon_0="1 deg", lat_0="2 deg", radius="3 deg", width="4 deg"
      )
      yield Model.create("ConstantSpectralModel", const="99 cm-2 s-1 TeV-1")
      # TODO: yield Model.create("CompoundSpectralModel")
      yield Model.create("PowerLawSpectralModel")
      yield Model.create("PowerLaw2SpectralModel")
      yield Model.create("ExpCutoffPowerLawSpectralModel")
      yield Model.create("ExpCutoffPowerLaw3FGLSpectralModel")
      yield Model.create("SuperExpCutoffPowerLaw3FGLSpectralModel")
      yield Model.create("SuperExpCutoffPowerLaw4FGLSpectralModel")
      yield Model.create("LogParabolaSpectralModel")
      yield Model.create(
          "TemplateSpectralModel", energy=[1, 2] * u.cm, values=[3, 4] * u.cm
      )  # TODO: add unit validation?
      yield Model.create("GaussianSpectralModel")
      # TODO: yield Model.create("AbsorbedSpectralModel")
      # TODO: yield Model.create("NaimaSpectralModel")
      # TODO: yield Model.create("ScaleSpectralModel")
      yield Model.create("ConstantTemporalModel")
      yield Model.create("LightCurveTemplateTemporalModel", Table())
      yield Model.create(
          "SkyModel",
          spatial_model=Model.create("ConstantSpatialModel"),
          spectral_model=Model.create("PowerLawSpectralModel"),
      )
      m1 = Map.create(
          npix=(10, 20, 30), axes=[MapAxis.from_nodes([1, 2] * u.TeV, name="energy")]
      )
      yield Model.create("SkyDiffuseCube", map=m1)
      m2 = Map.create(
          npix=(10, 20, 30), axes=[MapAxis.from_edges([1, 2] * u.TeV, name="energy")]
      )
      yield Model.create("BackgroundModel", map=m2)
  
  
  @pytest.mark.parametrize("model_class", MODEL_REGISTRY)
  def test_all_model_classes(model_class):
      if isinstance(model_class.tag, list):
          assert model_class.tag[0] == model_class.__name__
      else:
          assert model_class.tag == model_class.__name__
  
  
  @pytest.mark.parametrize("model", make_all_models())
  def test_all_model_instances(model):
      tag = model.tag[0] if isinstance(model.tag, list) else model.tag
      assert tag == model.__class__.__name__
  
  
  @requires_data()
  def test_missing_parameters():
      filename = get_pkg_data_filename("data/examples.yaml")
      models = Models.read(filename)
      assert models["source1"].spatial_model.e in models.parameters
      assert len(models["source1"].spatial_model.parameters) == 6
  
files: gammapy/modeling/models/tests/test_io.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  import pytest
  import numpy as np
  from numpy.testing import assert_allclose
  import astropy.units as u
  from astropy.table import Table
  from astropy.utils.data import get_pkg_data_filename
  from gammapy.maps import Map, MapAxis
  from gammapy.modeling.models import (
      MODEL_REGISTRY,
      AbsorbedSpectralModel,
      Absorption,
      BackgroundModel,
      Model,
      Models,
  )
  from gammapy.utils.scripts import read_yaml, write_yaml
  from gammapy.utils.testing import requires_data
  
  
  @requires_data()
  def test_dict_to_skymodels():
      filename = get_pkg_data_filename("data/examples.yaml")
      models_data = read_yaml(filename)
      models = Models.from_dict(models_data)
  
      assert len(models) == 5
  
      model0 = models[0]
      assert isinstance(model0, BackgroundModel)
      assert model0.name == "background_irf"
  
      model0 = models[1]
      assert model0.spectral_model.tag == "ExpCutoffPowerLawSpectralModel"
      assert model0.spatial_model.tag == "PointSpatialModel"
  
      pars0 = model0.parameters
      assert pars0["index"].value == 2.1
      assert pars0["index"].unit == ""
      assert np.isnan(pars0["index"].max)
      assert np.isnan(pars0["index"].min)
      assert not pars0["index"].frozen
  
      assert pars0["lon_0"].value == -0.5
      assert pars0["lon_0"].unit == "deg"
      assert pars0["lon_0"].max == 180.0
      assert pars0["lon_0"].min == -180.0
      assert pars0["lon_0"].frozen
  
      assert pars0["lat_0"].value == -0.0005
      assert pars0["lat_0"].unit == "deg"
      assert pars0["lat_0"].max == 90.0
      assert pars0["lat_0"].min == -90.0
      assert pars0["lat_0"].frozen
  
      assert pars0["lambda_"].value == 0.006
      assert pars0["lambda_"].unit == "TeV-1"
      assert np.isnan(pars0["lambda_"].min)
      assert np.isnan(pars0["lambda_"].max)
  
      model1 = models[2]
      assert model1.spectral_model.tag == "PowerLawSpectralModel"
      assert model1.spatial_model.tag == "DiskSpatialModel"
      assert model1.temporal_model.tag == "LightCurveTemplateTemporalModel"
  
      pars1 = model1.parameters
      assert pars1["index"].value == 2.2
      assert pars1["index"].unit == ""
      assert pars1["lat_0"].scale == 1.0
      assert pars1["lat_0"].factor == pars1["lat_0"].value
  
      assert np.isnan(pars1["index"].max)
      assert np.isnan(pars1["index"].min)
  
      assert pars1["r_0"].unit == "deg"
  
      model2 = models[3]
      assert_allclose(model2.spectral_model.energy.data, [34.171, 44.333, 57.517])
      assert model2.spectral_model.energy.unit == "MeV"
      assert_allclose(
          model2.spectral_model.values.data, [2.52894e-06, 1.2486e-06, 6.14648e-06]
      )
      assert model2.spectral_model.values.unit == "1 / (cm2 MeV s sr)"
  
      assert model2.spectral_model.tag == "TemplateSpectralModel"
      assert model2.spatial_model.tag == "TemplateSpatialModel"
  
      assert model2.spatial_model.parameters["norm"].value == 1.0
      assert not model2.spatial_model.normalize
      assert model2.spectral_model.parameters["norm"].value == 2.1
  
      # TODO problem of duplicate parameter name between TemplateSpatialModel and TemplateSpectralModel
      # assert model2.parameters["norm"].value == 2.1 # fail
  
  
  @requires_data()
  def test_sky_models_io(tmp_path):
      # TODO: maybe change to a test case where we create a model programatically?
      filename = get_pkg_data_filename("data/examples.yaml")
      models = Models.read(filename)
      models.covariance = np.eye(len(models.parameters))
      models.write(tmp_path / "tmp.yaml")
      models = Models.read(tmp_path / "tmp.yaml")
      assert models._covar_file == "tmp_covariance.dat"
      assert_allclose(models.covariance.data, np.eye(len(models.parameters)))
      assert_allclose(models.parameters["lat_0"].min, -90.0)
  
      # TODO: not sure if we should just round-trip, or if we should
      # check YAML file content (e.g. against a ref file in the repo)
      # or check serialised dict content
  
  
  @requires_data()
  def test_absorption_io(tmp_path):
      dominguez = Absorption.read_builtin("dominguez")
      model = AbsorbedSpectralModel(
          spectral_model=Model.create("PowerLawSpectralModel"),
          absorption=dominguez,
          redshift=0.5,
      )
      assert len(model.parameters) == 5
  
      model_dict = model.to_dict()
      parnames = [_["name"] for _ in model_dict["parameters"]]
      assert parnames == ["redshift", "alpha_norm"]
  
      new_model = AbsorbedSpectralModel.from_dict(model_dict)
  
      assert new_model.redshift.value == 0.5
      assert new_model.alpha_norm.name == "alpha_norm"
      assert new_model.alpha_norm.value == 1
      assert new_model.spectral_model.tag == "PowerLawSpectralModel"
      assert_allclose(new_model.absorption.energy, dominguez.energy)
      assert_allclose(new_model.absorption.param, dominguez.param)
      assert len(new_model.parameters) == 5
  
      test_absorption = Absorption(
          u.Quantity(range(3), "keV"),
          u.Quantity(range(2), ""),
          u.Quantity(np.ones((2, 3)), ""),
      )
      model = AbsorbedSpectralModel(
          spectral_model=Model.create("PowerLawSpectralModel"),
          absorption=test_absorption,
          redshift=0.5,
      )
      model_dict = model.to_dict()
      new_model = AbsorbedSpectralModel.from_dict(model_dict)
  
      assert_allclose(new_model.absorption.energy, test_absorption.energy)
      assert_allclose(new_model.absorption.param, test_absorption.param)
  
      write_yaml(model_dict, tmp_path / "tmp.yaml")
      read_yaml(tmp_path / "tmp.yaml")
  
  
  def make_all_models():
      """Make an instance of each model, for testing."""
      yield Model.create("ConstantSpatialModel")
      map_constantmodel = Map.create(npix=(10, 20), unit="sr-1")
      yield Model.create("TemplateSpatialModel", map=map_constantmodel)
      yield Model.create("DiskSpatialModel", lon_0="1 deg", lat_0="2 deg", r_0="3 deg")
      yield Model.create(
          "GaussianSpatialModel", lon_0="1 deg", lat_0="2 deg", sigma="3 deg"
      )
      yield Model.create("PointSpatialModel", lon_0="1 deg", lat_0="2 deg")
      yield Model.create(
          "ShellSpatialModel", lon_0="1 deg", lat_0="2 deg", radius="3 deg", width="4 deg"
      )
      yield Model.create("ConstantSpectralModel", const="99 cm-2 s-1 TeV-1")
      # TODO: yield Model.create("CompoundSpectralModel")
      yield Model.create("PowerLawSpectralModel")
      yield Model.create("PowerLaw2SpectralModel")
      yield Model.create("ExpCutoffPowerLawSpectralModel")
      yield Model.create("ExpCutoffPowerLaw3FGLSpectralModel")
      yield Model.create("SuperExpCutoffPowerLaw3FGLSpectralModel")
      yield Model.create("SuperExpCutoffPowerLaw4FGLSpectralModel")
      yield Model.create("LogParabolaSpectralModel")
      yield Model.create(
          "TemplateSpectralModel", energy=[1, 2] * u.cm, values=[3, 4] * u.cm
      )  # TODO: add unit validation?
      yield Model.create("GaussianSpectralModel")
      # TODO: yield Model.create("AbsorbedSpectralModel")
      # TODO: yield Model.create("NaimaSpectralModel")
      # TODO: yield Model.create("ScaleSpectralModel")
      yield Model.create("ConstantTemporalModel")
      yield Model.create("LightCurveTemplateTemporalModel", Table())
      yield Model.create(
          "SkyModel",
          spatial_model=Model.create("ConstantSpatialModel"),
          spectral_model=Model.create("PowerLawSpectralModel"),
      )
      m1 = Map.create(
          npix=(10, 20, 30), axes=[MapAxis.from_nodes([1, 2] * u.TeV, name="energy")]
      )
      yield Model.create("SkyDiffuseCube", map=m1)
      m2 = Map.create(
          npix=(10, 20, 30), axes=[MapAxis.from_edges([1, 2] * u.TeV, name="energy")]
      )
      yield Model.create("BackgroundModel", map=m2)
  
  
  @pytest.mark.parametrize("model_class", MODEL_REGISTRY)
  def test_all_model_classes(model_class):
      assert model_class.tag == model_class.__name__
  
  
  @pytest.mark.parametrize("model", make_all_models())
  def test_all_model_instances(model):
      assert model.tag == model.__class__.__name__
  
  
  @requires_data()
  def test_missing_parameters():
      filename = get_pkg_data_filename("data/examples.yaml")
      models = Models.read(filename)
      assert models["source1"].spatial_model.e in models.parameters
      assert len(models["source1"].spatial_model.parameters) == 6
  
files: gammapy/modeling/models/tests/test_io.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  __all__ = ["Registry"]
  
  
  class Registry(list):
      """Registry class."""
  
      def get_cls(self, tag):
          for cls in self:
              if hasattr(cls, "tag") and tag in cls.tag:
                  return cls
          raise KeyError(f"No model found with tag: {tag!r}")
  
      def __str__(self):
          info = "Registry\n"
          info += "--------\n\n"
          tags = [_.tag[0] if isinstance(_.tag, list) else _.tag for _ in self]
          len_max = max([len(tag) for tag in tags])
  
          for item in self:
              info += f"\t{item.tag:{len_max}s}: {item.__name__}\n"
  
          return info.expandtabs(tabsize=2)
  
files: gammapy/utils/registry.py
files: # Licensed under a 3-clause BSD style license - see LICENSE.rst
  
  __all__ = ["Registry"]
  
  
  class Registry(list):
      """Registry class."""
  
      def get_cls(self, tag):
          for cls in self:
              if hasattr(cls, "tag") and cls.tag == tag:
                  return cls
          raise KeyError(f"No model found with tag: {tag!r}")
  
      def __str__(self):
          info = "Registry\n"
          info += "--------\n\n"
  
          len_max = max([len(_.tag) for _ in self])
  
          for item in self:
              info += f"\t{item.tag:{len_max}s}: {item.__name__}\n"
  
          return info.expandtabs(tabsize=2)
  
files: gammapy/utils/registry.py