Source code for gammapy.astro.darkmatter.spectra

# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""Dark matter spectra."""

import copy
import warnings
import logging
from pathlib import Path

import astropy.units as u
import numpy as np
from astropy.table import Table

from gammapy.maps import Map, MapAxis, RegionGeom
from gammapy.modeling import Parameter
from gammapy.modeling.models import SpectralModel, TemplateNDSpectralModel
from gammapy.utils.deprecation import (
    GammapyDeprecationWarning,
    deprecated,
    deprecated_renamed_argument,
)
from gammapy.utils.scripts import make_path
from gammapy.utils.table import table_map_columns

__all__ = [
    "ContinuumPrimaryFlux",
    "DarkMatterSpectralModel",
    "DarkMatterAnnihilationSpectralModel",
    "DarkMatterDecaySpectralModel",
    "PrimaryFlux",
]
log = logging.getLogger(__name__)


[docs] class ContinuumPrimaryFlux(TemplateNDSpectralModel): """Continuum gamma-ray spectrum from dark matter annihilation. Based on the precomputed tables of PPPC4 DM ID and CosmiXs. All available annihilation channels can be found in those tables. For a requested dark matter mass and channel, this class builds a `~gammapy.modeling.models.TemplateNDSpectralModel` over a 2D grid of (``Log[10,x]``, ``mDM``), enabling interpolation in dark matter mass while keeping ``mDM`` itself frozen as a model parameter. Parameters ---------- mDM : `~astropy.units.Quantity` Dark matter particle mass as rest mass energy. Must lie within the mass range tabulated by the chosen ``source``. channel : str Annihilation channel, e.g. ``"b"`` for bb̄. See `allowed_channels` for the full list of supported channel labels. Availability of a given channel may depend on ``source``. source : str, optional Data source for the spectral tables. Options are: * ``"pppc4"`` (default): Cirelli et al. (2011, 2016) PPPC4DMID tables. * ``"cosmixs"``: Cirelli et al. (2024) / CosmiXs tables. * A path to a custom file readable by `astropy.table.Table.read` (extensions ``.dat``, ``.txt``, ``.csv``, or ``.ecsv``). If a custom file path is provided, it must contain ``"mDM"`` and ``"Log[10,x]"`` columns (after applying ``mapping_dict`` if given), plus columns named after the requested annihilation channel(s) using the internal channel registry naming convention. mapping_dict : dict, optional Mapping dictionary used to rename the columns of a custom source file to the expected internal column names. Only used when ``source`` is a custom file path. Format is ``{actual_column_name: expected_column_name}``, and must cover the mandatory columns ``"mDM"`` and ``"Log[10,x]"``. Notes ----- Internally, the spectral table is read and reshaped into a 2D `~gammapy.maps.RegionGeom` over (``Log[10,x]``, ``mDM``) axes, wrapped in a `~gammapy.maps.Map`, and passed to the `~gammapy.modeling.models.TemplateNDSpectralModel` constructor with linear interpolation and zero-valued extrapolation. The ``mass`` parameter inherited from `TemplateNDSpectralModel` is frozen after initialization, since ``mDM`` is treated as a fixed configuration value rather than a fit parameter. References ---------- .. [1] `Cirelli et al. (2016), "PPPC 4 DM ID: A Poor Particle Physicist Cookbook for Dark Matter Indirect Detection" <http://www.marcocirelli.net/PPPC4DMID.html>`_ .. [2] `Arina et al. (2024), "CosmiXs: Cosmic messenger spectra for indirect dark matter searches" <https://arxiv.org/abs/2312.01153>`_ .. [3] `Di Mauro et al. (2025), "Nailing down the theoretical uncertainties of Dbar spectrum produced from dark matter" <https://arxiv.org/abs/2411.04815>`_ """ # noqa: E501 _unavailable_channels = { "pppc4": { "aZ": "choose another channel or use CosmiXs ('cosmixs') as source", "HZ": "choose another channel or use CosmiXs ('cosmixs') as source", "d": ( "choose the equivalent channel 'q' or use CosmiXs ('cosmixs') as source" ), "u": ( "choose the equivalent channel 'q' or use CosmiXs ('cosmixs') as source" ), "s": ( "choose the equivalent channel 'q' or use CosmiXs ('cosmixs') as source" ), }, "cosmixs": { "V->e": "choose another channel or use PPPC4 ('pppc4') as source", "V->mu": "choose another channel or use PPPC4 ('pppc4') as source", "V->tau": "choose another channel or use PPPC4 ('pppc4') as source", "q": ( "choose an equivalent channel such as 'd', 'u', or 's' " "or use PPPC4 ('pppc4') as source" ), }, } channel_registry = { "eL": "eL", "eR": "eR", "e": "e", "muL": r"\[Mu]L", "muR": r"\[Mu]R", "mu": r"\[Mu]", "tauL": r"\[Tau]L", "tauR": r"\[Tau]R", "tau": r"\[Tau]", "q": "q", "c": "c", "b": "b", "t": "t", "WL": "WL", "WT": "WT", "W": "W", "ZL": "ZL", "ZT": "ZT", "Z": "Z", "g": "g", "gamma": r"\[Gamma]", "h": "h", "nu_e": r"\[Nu]e", "nu_mu": r"\[Nu]\[Mu]", "nu_tau": r"\[Nu]\[Tau]", "V->e": "V->e", "V->mu": r"V->\[Mu]", "V->tau": r"V->\[Tau]", "aZ": "aZ", "HZ": "HZ", "d": "d", "u": "u", "s": "s", } mandatory_keys = ["mDM", "Log[10,x]"] mapping_dict_PPPC4_to_CosmiXs = { "DM": "mDM", "Log10[x]": "Log[10,x]", "dNdLog10x[eL]": "eL", "dNdLog10x[eR]": "eR", "dNdLog10x[e]": "e", "dNdLog10x[muL]": "\\[Mu]L", "dNdLog10x[muR]": "\\[Mu]R", "dNdLog10x[mu]": "\\[Mu]", "dNdLog10x[tauL]": "\\[Tau]L", "dNdLog10x[tauR]": "\\[Tau]R", "dNdLog10x[tau]": "\\[Tau]", "dNdLog10x[nue]": "\\[Nu]e", "dNdLog10x[numu]": "\\[Nu]\\[Mu]", "dNdLog10x[nutau]": "\\[Nu]\\[Tau]", "dNdLog10x[u]": "u", # no PPPC4 equivalent; maps to q "dNdLog10x[d]": "d", # no PPPC4 equivalent; maps to q "dNdLog10x[s]": "s", # no PPPC4 equivalent; maps to q "dNdLog10x[c]": "c", "dNdLog10x[b]": "b", "dNdLog10x[t]": "t", "dNdLog10x[a]": "\\[Gamma]", "dNdLog10x[g]": "g", "dNdLog10x[W]": "W", "dNdLog10x[WL]": "WL", "dNdLog10x[WT]": "WT", "dNdLog10x[Z]": "Z", "dNdLog10x[ZL]": "ZL", "dNdLog10x[ZT]": "ZT", "dNdLog10x[H]": "h", "dNdLog10x[aZ]": None, # does not exist in PPPC4 "dNdLog10x[HZ]": None, # does not exist in PPPC4 } tag = ["ContinuumPrimaryFlux", "dm-pf"]
[docs] def __init__(self, mDM, channel, source=None, mapping_dict=None): self.source = source if self._source_type == "custom_file": self.mapping_dict = mapping_dict if isinstance(self.source, Table): self.table = self.source else: table_filename = self.source self.table_path = make_path(table_filename) if self.table_path is None or not self.table_path.exists(): raise FileNotFoundError( # pragma: no cover f"\n\nFile not found: {table_filename}\n" "You may download the dataset needed with the \ following command:\n" "gammapy download datasets --src dark_matter_spectra" ) self.table = Table.read(self.table_path) else: base_data_path = "$GAMMAPY_DATA/dark_matter_spectra" if self.source == "pppc4": table_filename = f"{base_data_path}/PPPC4DMID/AtProduction_gammas.dat" elif self.source == "cosmixs": table_filename = f"{base_data_path}/cosmixs/AtProduction-Gamma.dat" self.table_path = make_path(table_filename) if self.table_path is None or not self.table_path.exists(): raise FileNotFoundError( f"\n\nFile not found: {table_filename}\n" "You may download the dataset needed with the following command:\n" "gammapy download datasets --src dark_matter_spectra" ) ascii_format = ( "ascii.commented_header" if self.source == "cosmixs" else "ascii.fast_basic" ) self.table = Table.read( self.table_path, format=ascii_format, guess=False, delimiter=" ", ) if self._source_type == "custom_file" and self.mapping_dict: self.table = table_map_columns(self.table, self.mapping_dict) if self.source == "cosmixs": self.table = table_map_columns( self.table, self.mapping_dict_PPPC4_to_CosmiXs ) self.channel = channel masses = np.unique(self.table["mDM"]) log10x = np.unique(self.table["Log[10,x]"]) mass_axis = MapAxis.from_nodes(masses, name="mass", interp="log", unit="GeV") log10x_axis = MapAxis.from_nodes(log10x, name="energy_true") channel_name = self.channel_registry[self.channel] geom = RegionGeom(region=None, axes=[log10x_axis, mass_axis]) region_map = Map.from_geom( geom=geom, data=self.table[channel_name].reshape(geom.data_shape) ) interp_kwargs = {"extrapolate": True, "fill_value": 0, "values_scale": "lin"} super().__init__(region_map, interp_kwargs=interp_kwargs) self.mDM = mDM self.mass.frozen = True
def _resolve_table_path(self): """Resolve and validate the path to the spectra data file.""" base = "$GAMMAPY_DATA/dark_matter_spectra" if self._source_type == "custom_file": table_filename = self.source elif self.source == "pppc4": table_filename = f"{base}/PPPC4DMID/AtProduction_gammas.dat" elif self.source == "cosmixs": table_filename = f"{base}/cosmixs/AtProduction-Gamma.dat" else: raise ValueError(f"Unknown source: '{self.source}'") path = make_path(table_filename) if path is None or not path.exists(): raise FileNotFoundError( f"\n\nFile not found: {table_filename}\n" "You may download the required dataset with:\n" " gammapy download datasets --src dark_matter_spectra" ) return path
[docs] def evaluate(self, energy, *args): """Evaluate the continuum primary flux spectrum dN/dE. Converts the requested ``energy`` to ``log10(energy / mDM)``, evaluates the underlying `~gammapy.modeling.models.TemplateNDSpectralModel` (interpolated over ``Log[10,x]`` and ``mDM``) to obtain ``dN/dlog10(x)``, and converts this to ``dN/dE`` via the Jacobian ``dN/dE = dN/dlog10(x) / (E * ln(10))``. Parameters ---------- energy : `~astropy.units.Quantity` Energy values (array-like) at which to evaluate the spectrum. *args Additional template axis coordinates required by the parent `~gammapy.modeling.models.TemplateNDSpectralModel.evaluate` (the current ``mDM`` value is appended automatically). Returns ------- dN_dE : `~astropy.units.Quantity` Differential photon yield per unit energy. """ args = list(args) args.append(self.mDM) log10x = np.log10(energy / self.mDM) dN_dlogx = super().evaluate(log10x, *args) dN_dE = dN_dlogx / (energy * np.log(10)) return dN_dE
@property def mDM(self): """Dark matter mass.""" return u.Quantity(self.mass.value, self.mass.unit) @mDM.setter def mDM(self, mDM): unit = self.mass.unit _mDM = u.Quantity(mDM).to(unit) _mDM_val = _mDM.to_value(unit) min_mass = u.Quantity(self.mass.min, unit) max_mass = u.Quantity(self.mass.max, unit) if _mDM_val < self.mass.min or _mDM_val > self.mass.max: raise ValueError( f"The mass {_mDM} is out of the bounds of the model. Please choose a \ mass between {min_mass} < `mDM` < {max_mass}" ) self.mass.value = _mDM_val @property def allowed_channels(self): """List of allowed annihilation channels.""" return list(self.channel_registry.keys()) @property def source(self): """Data source for the spectra.""" return self._source @source.setter def source(self, source): if source is None: self._source = "pppc4" log.info( "\nSince no spectra source has been chosen, PPPC4 will be \ used by default.\n", ) elif isinstance(source, Table): self._source = source elif isinstance(source, str): if source.lower() in ("pppc4", "cosmixs"): self._source = source.lower() else: path = Path(make_path(source)) if path.exists() and path.is_file(): if path.stat().st_size == 0: raise KeyError("Source file is empty.") self._source = source else: raise ValueError( f"Invalid source: {source}\nAvailable options: 'pppc4', \ 'cosmixs' or a valid file path.\n" ) else: raise TypeError( f"source must be None, a string ('pppc4', 'cosmixs', or a file " f"path), or an astropy.table.Table instance, got {type(source)}" ) @property def _source_type(self): """Return source type (predefined or custom).""" return "predefined" if self.source in ("pppc4", "cosmixs") else "custom_file" @property def channel(self): """Annihilation channel as a string.""" return self._channel @channel.setter def channel(self, channel): if channel not in self.allowed_channels: raise ValueError( f"Invalid channel: '{channel}'\n" f"Available channels: {self.allowed_channels}" ) else: if self._source_type == "custom_file": channel_translation = self.channel_registry[channel] if self.mapping_dict is not None: if channel_translation not in self.mapping_dict.values(): raise ValueError( f"The channel {channel_translation} is not available \ in the provided mapping dictionary. Please choose another \ channel or check the mapping_dict provided.\n" ) if channel_translation not in self.table.colnames: raise ValueError( f"\n\nThe channel {channel_translation} is not available \ in the provided source file. Please choose another channel \ or check the column names in the file.\n" ) elif self.source == "pppc4": if channel in ("aZ", "HZ"): raise ValueError( f"\n\nThe channel {channel} is not available in PPPC4, please \ choose another channel or use CosmiXs (cosmixs) as source\n" ) elif channel in ("d", "u", "s"): raise ValueError( f"\n\nThe channel {channel} is not available in PPPC4, \ please choose the equivalent channel q \ or CosmiXs (cosmixs) as source\n" ) elif self.source == "cosmixs": if channel in ("V->e", "V->mu", "V->tau"): raise ValueError( f"\n\nThe channel {channel} is not available in CosmiXs, \ please choose another channel or use PPPC4 as source\n" ) elif channel == "q": raise ValueError( "\n\nThe channel q is not available in cosmixs, please \ choose an equivalent channel such as d, u or s or \ use PPPC4 as source\n" ) self._channel = channel @property def mapping_dict(self): """Mapping dictionary for the spectra file.""" return self._mapping_dict @mapping_dict.setter def mapping_dict(self, mapping_dict): if mapping_dict is not None: if not isinstance(mapping_dict, dict): raise TypeError("mapping_dict must be a dictionary.") for key in self.mandatory_keys: if key not in mapping_dict.values(): raise KeyError( f"Mandatory column {key} not found in file. \ Please check the mapping_dict provided or the column \ names in the file.\n" ) self._mapping_dict = mapping_dict
[docs] def to_dict(self, full_output=False): """Serialize the model to a dictionary. Parameters ---------- full_output : bool, optional Unused; present for interface compatibility. Default is False. Returns ------- data : dict Dictionary representation containing the model type, ``mDM``, ``channel``, and ``source``, suitable for round-tripping via `from_dict`. """ return { "type": "ContinuumPrimaryFlux", "mDM": self.mDM.to_string(), "channel": self.channel, "source": self.source, }
[docs] @classmethod def from_dict(cls, data): """Construct a `ContinuumPrimaryFlux` from a dictionary. Parameters ---------- data : dict Dictionary as produced by `to_dict`, containing ``mDM`` and ``channel``, and optionally ``source`` (defaults to ``"pppc4"`` if absent). Returns ------- flux : `ContinuumPrimaryFlux` New instance reconstructed from ``data``. """ return cls( mDM=u.Quantity(data["mDM"]), channel=data["channel"], source=data.get("source", "pppc4"), )
[docs] @deprecated("2.2", alternative="ContinuumPrimaryFlux") class PrimaryFlux(ContinuumPrimaryFlux): tag = ["PrimaryFlux", "dm-pf"] pass
PRIMARY_FLUX_REGISTRY = {cls.tag[0]: cls for cls in (ContinuumPrimaryFlux, PrimaryFlux)} # --------------------------------------------------------------------------- # Spectral model # ---------------------------------------------------------------------------
[docs] class DarkMatterSpectralModel(SpectralModel): r"""Dark matter spectral model. Computes the differential gamma-ray flux expected from dark matter annihilation or decay in a region with a given astrophysical factor, combining the thermally averaged annihilation cross-section or particle decay lifetime, the chosen primary particle-physics spectrum, and a fit-time normalization scale. For annihilation, the gamma-ray flux is computed as: .. math:: \frac{\mathrm d \phi}{\mathrm d E} = \frac{\langle \sigma\nu \rangle}{4\pi k m^2_{\mathrm{DM}}} \frac{\mathrm d N}{\mathrm dE} \times J(\Delta\Omega) where :math:`\langle \sigma\nu \rangle` is the thermal relic cross-section, :math:`k` accounts for the dark matter particle type (Majorana or Dirac), :math:`m_{\mathrm{DM}}` is the dark matter mass, :math:`\mathrm dN/\mathrm dE` is the primary photon spectrum per annihilation, and :math:`J(\Delta\Omega)` is the astrophysical J-factor. For decay, the flux is computed as: .. math:: \frac{\mathrm d \phi}{\mathrm d E} = \frac{\Gamma}{4\pi m_{\mathrm{DM}}} \frac{\mathrm d N}{\mathrm dE} \times J(\Delta\Omega) where :math:`\Gamma = 1/\tau` is the decay rate (inverse lifetime), :math:`m_{\mathrm{DM}}` is the dark matter mass, :math:`\mathrm dN/\mathrm dE` is the primary photon spectrum per decay, and :math:`J(\Delta\Omega)` is the astrophysical D-factor (here denoted generically as the astrophysical factor). Parameters ---------- mDM : `~astropy.units.Quantity` Dark matter particle mass. channel : str Annihilation/Decay channel for the primary flux spectrum e.g. ``"b"`` for bb̄. See `ContinuumPrimaryFlux.channel_registry` for available channels. scale : float, optional Dimensionless normalization parameter applied multiplicatively to the predicted flux, intended to be left free in spectral fits. Default is 1. factor : `~astropy.units.Quantity`, optional Astrophysical factor (integrated squared dark matter density along the line of sight and over the solid angle), needed when a `~gammapy.modeling.models.PointSpatialModel` is used for the spatial component. Default is 1 (dimensionless). z : float, optional Redshift of the source. The primary flux is evaluated at the redshifted energy ``energy * (1 + z)``. Default is 0. k : int, optional Only used in the case of annihilation. Dark matter particle type: ``2`` for Majorana particles (which are their own antiparticles, giving a factor of 2 in the annihilation rate) or ``4`` for Dirac particles. Default is 2. primary_flux : primary flux model, optional Primary photon spectrum per annihilation event. Must be an instance of `ContinuumPrimaryFlux`. If not provided, a default `ContinuumPrimaryFlux` is constructed using ``mDM`` and ``channel``. source : str, optional Data source for the spectral tables. Options are: * ``"pppc4"`` (default): Cirelli et al. (2011, 2016) PPPC4DMID tables. * ``"cosmixs"``: Cirelli et al. (2024) / CosmiXs tables. * A path to a custom file readable by `astropy.table.Table.read` (extensions ``.dat``, ``.txt``, ``.csv``, or ``.ecsv``). If a custom file path is provided, it must contain ``"mDM"`` and ``"Log[10,x]"`` columns (after applying ``mapping_dict`` if given), plus columns named after the requested annihilation channel(s) using the internal channel registry naming convention. mapping_dict : dict, optional Mapping dictionary used to rename the columns of a custom source file to the expected internal column names. Only used when ``source`` is a custom file path. Format is ``{actual_column_name: expected_column_name}``, and must cover the mandatory columns ``"mDM"`` and ``"Log[10,x]"``. annihilation : bool Boolean value indicating if the dark matter spectral model comes from an annihilation or decay scenario. Default is True, which indicates annihilation. Examples -------- This is how to instantiate a `DarkMatterSpectralModel`:: >>> import astropy.units as u >>> from gammapy.astro.darkmatter import DarkMatterSpectralModel >>> channel = "b" >>> mDM = 5000*u.Unit("GeV") >>> factor = 3.41e19 * u.Unit("GeV2 cm-5") >>> modelDM = DarkMatterSpectralModel(mDM=mDM, channel=channel, factor=factor, annihilation=True) # noqa: E501 References ---------- .. [1] `Cirelli et al. (2016), "PPPC 4 DM ID: A Poor Particle Physicist Cookbook for Dark Matter Indirect Detection" <http://www.marcocirelli.net/PPPC4DMID.html>`_ """ THERMAL_RELIC_CROSS_SECTION = 3e-26 * u.Unit("cm3 s-1") """Thermally averaged annihilation cross-section.""" LIFETIME_AGE_OF_UNIVERSE = 4.3e17 * u.Unit("s") """Use age of univserse as lifetime""" scale = Parameter("scale", 1, unit="", interp="log") tag = ["DarkMatterSpectralModel", "dm-spectralmodel"]
[docs] @deprecated_renamed_argument("mass", "mDM", "2.2") @deprecated_renamed_argument("jfactor", "factor", "2.2") def __init__( self, mDM, channel, *, scale=scale.quantity, factor=1, z=0, k=2, primary_flux=None, source=None, mapping_dict=None, annihilation=True, ): self._annihilation = annihilation if self._annihilation: if k not in (2, 4): raise ValueError(f"k must be 2 (Majorana) or 4 (Dirac), got: {k}.") self._k = k else: self._k = None if z < 0: raise ValueError(f"Redshift z must be >= 0, got: {z}.") if u.Quantity(factor).value <= 0: raise ValueError("The astrophysical factor must be strictly positive.") self._z = z self._mDM = u.Quantity(mDM) self._channel = channel self._factor = u.Quantity(factor) self.source = source self.mapping_dict = mapping_dict if primary_flux is not None: if hasattr(primary_flux, "channel"): primary_flux.channel = channel primary_flux.mDM = self._expected_primary_flux_mass else: primary_flux = ContinuumPrimaryFlux( self._expected_primary_flux_mass, channel=self.channel, source=self.source, mapping_dict=self.mapping_dict, ) self._primary_flux = primary_flux super().__init__(scale=scale)
@property def annihilation(self): """Annihilation/Decay flag""" return self._annihilation @property def mDM(self): """Dark matter mass.""" return self._mDM @property def factor(self): """Astrophysical Factor.""" return self._factor @property def z(self): """Source redshift (must be >= 0).""" return self._z @property def primary_flux(self): """Primary flux model.""" return self._primary_flux @property def channel(self): """Channel of the default primary flux spectrum.""" return self._channel @property def k(self): """DM particle type (2: Majorana, 4: Dirac).""" return self._k @property def _expected_primary_flux_mass(self): """Expected primary flux mass for annihilation. Returns ------- mass : `~astropy.units.Quantity` Equal to ``mDM``, since in annihilation each dark matter particle pair has a total rest energy of ``2 * mDM``, shared between two particles, and the primary flux tables are indexed by the per-particle mass ``mDM``. """ return self._mDM if self._annihilation else self._mDM / 2
[docs] def evaluate(self, energy, scale): """Evaluate the dark matter annihilation differential flux. For annihilation, computes: ``flux = scale * factor * THERMAL_RELIC_CROSS_SECTION * primary_flux(energy * (1 + z)) / k / mDM**2 / (4 * pi)``. For decay, computes: ``flux = scale * factor * primary_flux(energy * (1 + z)) / LIFETIME_AGE_OF_UNIVERSE / mDM / (4 * pi)``. Parameters ---------- energy : `~astropy.units.Quantity` Energy values (array-like) at which to evaluate the flux. scale : float Current value of the ``scale`` normalization parameter. Returns ------- flux : `~astropy.units.Quantity` Differential gamma-ray flux per unit energy. """ if self.annihilation: return ( scale * self.factor * self.THERMAL_RELIC_CROSS_SECTION * self.primary_flux(energy=energy * (1 + self.z)) / self.k / self.mDM / self.mDM / (4 * np.pi) ) else: return ( scale * self.factor * self.primary_flux(energy=energy * (1 + self.z)) / self.LIFETIME_AGE_OF_UNIVERSE / self.mDM / (4 * np.pi) )
[docs] def to_dict(self, full_output=False): """Serialize the model to a dictionary. Extends the base `~gammapy.modeling.models.SpectralModel.to_dict` output with ``channel``, ``mDM``, ``factor``, ``z``, ``k``, the serialized ``primary_flux`` (via its own `to_dict`), ``source``, ``mapping_dict`` and ``annihilation``. Parameters ---------- full_output : bool, optional Passed through to the parent class's `to_dict`. Default is False. Returns ------- data : dict Dictionary representation suitable for round-tripping via `from_dict`. """ data = super().to_dict(full_output=full_output) data["spectral"]["channel"] = self.channel data["spectral"]["mDM"] = self.mDM.to_string() data["spectral"]["factor"] = self.factor.to_string() data["spectral"]["z"] = self.z data["spectral"]["k"] = self.k data["spectral"]["primary_flux"] = self.primary_flux.to_dict() data["spectral"]["source"] = self.source data["spectral"]["mapping_dict"] = self.mapping_dict data["spectral"]["annihilation"] = self.annihilation return data
[docs] @classmethod def from_dict(cls, data): """Construct a `DarkMatterSpectralModel` from a dictionary. Reconstructs the ``primary_flux`` sub-model using the registry of known primary flux types, extracts the ``scale`` parameter value from the serialized parameter list, and passes the remaining fields through to the constructor. Parameters ---------- data : dict Dictionary with a top-level ``"spectral"`` key, as produced by `to_dict`, containing ``mDM``, ``channel``, ``factor``, ``z``, ``k``, ``primary_flux``, and ``parameters`` (including ``scale``). Returns ------- model : `DarkMatterSpectralModel` New instance reconstructed from ``data``. """ data = copy.deepcopy(data["spectral"]) model_type = data.get("type", "") default_annihilation = "Decay" not in model_type data.pop("type") _RENAMED_FIELDS = {"mass": "mDM", "jfactor": "factor"} for old_name, new_name in _RENAMED_FIELDS.items(): if old_name in data: warnings.warn( f"The '{old_name}' field is deprecated since v2.2 and will " f"be removed in a future version. Use '{new_name}' instead. " f"This serialized model appears to use the old format.", GammapyDeprecationWarning, ) data[new_name] = data.pop(old_name) pf_data = data.pop("primary_flux", None) if pf_data is None: # Old format primary_flux = ContinuumPrimaryFlux( data.get("mDM", data.get("mass")), channel=data["channel"], source=data.get("source"), mapping_dict=data.get("mapping_dict"), ) else: pf_cls = PRIMARY_FLUX_REGISTRY.get(pf_data["type"]) if pf_cls is None: raise ValueError( f"Unknown primary_flux type: '{pf_data['type']}'. " f"Available: {list(PRIMARY_FLUX_REGISTRY.keys())}" ) primary_flux = pf_cls.from_dict(pf_data) data.pop("source", None) data.pop("mapping_dict", None) annihilation = data.pop("annihilation", default_annihilation) parameters = data.pop("parameters") scale = next(p["value"] for p in parameters if p["name"] == "scale") return cls( scale=scale, primary_flux=primary_flux, annihilation=annihilation, **data )
[docs] @deprecated("2.2", alternative="DarkMatterSpectralModel") class DarkMatterAnnihilationSpectralModel(DarkMatterSpectralModel): scale = Parameter("scale", 1, unit="", interp="log") tag = ["DarkMatterAnnihilationSpectralModel", "dm-annihilation"]
[docs] def __init__(self, *args, **kwargs): kwargs["annihilation"] = True super().__init__(*args, **kwargs)
[docs] @deprecated("2.2", alternative="DarkMatterSpectralModel") class DarkMatterDecaySpectralModel(DarkMatterSpectralModel): scale = Parameter("scale", 1, unit="", interp="log") tag = ["DarkMatterDecaySpectralModel", "dm-decay"]
[docs] def __init__(self, *args, **kwargs): kwargs["annihilation"] = False super().__init__(*args, **kwargs)