Source code for uvex_transients.dust

"""
Milky Way foreground dust extinction.

This module provides access to the PlanckGNILC E(B-V) map (`dust_map`) and a
reddening law (`get_dust_law`, Gordon+2023's `G23` by default -- see
``config["physics.default_dust_law"]``), combined by a single vectorized entry point
(`log_attenuation`) into a natural-log attenuation array ready to add directly into a
`~uvex_transients.models.core.base.SpectralModel` flux calculation (its
``log_attenuation`` keyword argument). `attenuation_callable` gives the same thing as
a callable; `~uvex_transients.models.core.base.SpectralModel.as_source_spectrum`/
`~uvex_transients.models.core.base.SpectralModel.simulate_photometry` call it
internally whenever their own ``ebv`` keyword is given, so ordinary callers of this
package never touch this module, a callable, or `functools.partial` at all.
"""

from collections.abc import Callable
from functools import cache, partial
from typing import Protocol, Union, runtime_checkable

import numpy as np
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.units import Quantity
from astropy.utils.data import download_file
from dust_extinction.parameter_averages import G23
from dustmaps.planck import PlanckGNILCQuery
from numpy.typing import NDArray

from uvex_transients.utils import config, logger


# =========================================================================== #
# TYPING MANAGEMENT                                                           #
# =========================================================================== #
[docs] @runtime_checkable class DustMapLike(Protocol): """Structural type for anything queryable for E(B-V) at a sky position, like `PlanckGNILCQuery`."""
[docs] def query(self, coord: SkyCoord) -> float: """ Query the dust map at a sky coordinate. Parameters ---------- coord : astropy.coordinates.SkyCoord Sky coordinate at which to evaluate the dust map. Returns ------- float Dust-map value at the specified coordinate. """ ...
Reddening = Union[float, Quantity, NDArray[np.float64], DustMapLike] """An explicit E(B-V) value/array (`float`/dimensionless `Quantity`/`numpy.ndarray`), or a `DustMapLike` to query at a given `~astropy.coordinates.SkyCoord` -- see `resolve_ebv`.""" # =========================================================================== # # DUST MAPS # # =========================================================================== # # Same two mirrors (IRSA, then ESA as fallback) and the same `astropy` download # cache that m4opt's own `m4opt.synphot.extinction._dust.dust_map` uses. Reusing # the exact URLs means that if `m4opt prime` (or any other m4opt code path) has # already warmed the astropy download cache for this file, we get a cache hit # instead of a second multi-hundred-megabyte download -- even though this # loader is otherwise fully independent of m4opt. Configurable via # ``config["physics.dust_map_sources"]`` (see `uvex_transients.utils.config`), # in case a mirror goes stale.
[docs] @cache def dust_map() -> PlanckGNILCQuery: """ Return the Planck GNILC E(B-V) sky map, downloading and caching it on first use. The map is fetched via :func:`astropy.utils.data.download_file` (cached to disk, with the ESA mirror as a fallback source), and the resulting :class:`PlanckGNILCQuery` is itself process-cached via :func:`functools.cache`, so repeated calls -- even across millions of per-event reddening lookups -- pay the download/load cost only once. Returns ------- dustmaps.planck.PlanckGNILCQuery Queryable dust map: ``dust_map().query(coord)`` returns E(B-V) at ``coord``, vectorized over an array-valued ``coord``. """ sources = list(config["physics.dust_map_sources"]) logger.info( "Fetching Planck GNILC dust map (first call only; cached to disk on success) from %s.", sources[0], ) path = download_file(sources[-1], cache=True, sources=sources) logger.info("Planck GNILC dust map ready at %s.", path) return PlanckGNILCQuery(path)
# =========================================================================== # # REDDENING LAW # # =========================================================================== # # To model the extinction for UVEX, we adopt the Gordon+2023 dust model, which covers # IR through FUV and is suitable for our needs -- kept as a name in this registry, # resolved through `config["physics.default_dust_law"]`, rather than hardcoded, purely # so it's a config knob and not a code change. In practice nobody is expected to reach # for anything but the default. _DUST_LAWS = {"g23": G23} @cache def _instantiate_dust_law(name: str): """ Build (and cache) the named law. See `get_dust_law`; split out so the cache key is always the resolved name, never `None`. """ try: return _DUST_LAWS[name]() except KeyError: raise KeyError(f"Unknown dust law {name!r}; known laws are {sorted(_DUST_LAWS)}.") from None
[docs] def get_dust_law(dust_law: str | None = None): """ Return the (cached, instantiated) reddening law to use. Parameters ---------- dust_law : str, optional Name of a registered law (case-insensitive), currently just ``"g23"`` (`dust_extinction.parameter_averages.G23`). If ``None`` (the default), the configured default (``config["physics.default_dust_law"]``, ``"g23"`` out of the box) is used. Returns ------- dust_extinction.baseclasses.BaseExtModel Instantiated reddening law, exposing ``.Rv``, ``.x_range``, and ``law(x) -> A(x)/A(V)`` -- see `log_attenuation`. Raises ------ KeyError If ``dust_law`` (or the configured default) doesn't name a registered law. """ name = (dust_law or config["physics.default_dust_law"]).lower() return _instantiate_dust_law(name)
# =========================================================================== # # REDDENING RESOLUTION # # =========================================================================== #
[docs] def resolve_ebv(reddening: Reddening, coord: SkyCoord | None = None) -> NDArray[np.float64]: """ Resolve a `Reddening` value into a plain E(B-V) array. Parameters ---------- reddening : float, ~astropy.units.Quantity, numpy.ndarray, or DustMapLike Either an explicit :math:`E(B-V)` value/array, or an object exposing ``.query(coord) -> E(B-V)`` (e.g. `dust_map`'s return value). coord : ~astropy.coordinates.SkyCoord, optional Sky position(s) to query, any shape -- required if ``reddening`` is a `DustMapLike`; ignored (and may be omitted) otherwise. Returns ------- numpy.ndarray Dimensionless E(B-V), shape matching ``reddening`` (if already a value) or ``coord`` (if queried from a map). Raises ------ TypeError If ``reddening`` is a `DustMapLike` and ``coord`` isn't given. """ if isinstance(reddening, DustMapLike): if coord is None: raise TypeError("`coord` is required to resolve a `DustMapLike` `reddening`.") return np.asarray(reddening.query(coord), dtype=np.float64) if isinstance(reddening, Quantity): return np.asarray(reddening.to_value(u.dimensionless_unscaled), dtype=np.float64) return np.asarray(reddening, dtype=np.float64)
[docs] def log_attenuation( nu: Quantity, Ebv: float | Quantity | NDArray[np.float64], dust_law: str | None = None, ) -> NDArray[np.float64]: r""" Natural log of the Milky Way foreground attenuation, :math:`\ln(10^{-0.4\,A(\nu)})`, at ``nu``. Takes an already-resolved E(B-V) -- resolving one from a dust map and a sky position is a separate, prior step (`resolve_ebv`), deliberately kept out of this function: fixing ``Ebv``'s shape once, up front, is what lets this stay a single, fully vectorized computation (no per-position or per-frequency Python loop, and, unlike building a `~synphot.SpectralElement` per position, no repeated `~astropy.modeling.Model` construction either) -- the reddening law's dimensionless shape :math:`A(\nu)/A(V)` is evaluated once at every ``nu`` sample (shape ``(K,)``), and combined with ``Ebv`` (shape ``S``) by plain numpy broadcasting into a result of shape ``S + (K,)``. Add this directly to a `~uvex_transients.models.core.base.SpectralModel` flux calculation's log flux (its ``log_attenuation`` keyword) -- e.g., having already called ``ebv = resolve_ebv(dust_map(), coord)`` once, ``model.flux_log(nu, t, ..., log_attenuation=log_attenuation(nu, ebv))`` -- rather than multiplying a linear transmission fraction in, since everything on that side is already computed in log space. `SpectralModel.as_source_spectrum`/`as_astropy_model` take the same shape one level removed, as a callable (their wavelength grid isn't known until the resulting `~synphot.SourceSpectrum` is actually called): use `attenuation_callable` rather than binding this function yourself, e.g. ``model.as_source_spectrum(t, ..., log_attenuation=attenuation_callable(ebv))``. Parameters ---------- nu : ~astropy.units.Quantity Frequency(ies) (or frequency-equivalent, e.g. wavelength) to evaluate the reddening law at, any shape ``(K,)`` (or scalar, in which case the trailing frequency axis below is squeezed away). Ebv : float, ~astropy.units.Quantity, or numpy.ndarray Already-resolved, dimensionless :math:`E(B-V)`, any shape ``S`` -- see `resolve_ebv` for turning a dust map + sky position into this. dust_law : str, optional Passed through to `get_dust_law`; ``None`` (the default) uses the configured default reddening law. Returns ------- numpy.ndarray Natural log of the dimensionless attenuation, ``NaN`` wherever ``nu`` falls outside the reddening law's native range (roughly 900 Angstrom to 32 microns for the default, `dust_extinction.parameter_averages.G23`). Shape ``S + (K,)``, or plain ``S`` if ``nu`` was scalar. """ law = get_dust_law(dust_law) Ebv = ( np.asarray(Ebv.to_value(u.dimensionless_unscaled), dtype=np.float64) if isinstance(Ebv, Quantity) else np.asarray(Ebv, dtype=np.float64) ) x = np.atleast_1d(np.asarray(nu.to_value(1 / u.micron, equivalencies=u.spectral()), dtype=np.float64)) # `G23.__call__` (inherited from `dust_extinction.baseclasses.BaseExtModel`) raises # `ValueError` if *any* input falls outside its native range, rather than returning # `NaN` for just the offending samples -- so out-of-range `x` is masked out *before* # the call. It's tempting to pass `NaN` through for those samples and let the range # check's own `<=`/`>=` comparisons (always `False` against `NaN`) silently wave them # through -- the previous version of this module did exactly that -- but verify before # relying on it: empirically, G23's spline evaluates `NaN` in as ``0`` (zero # extinction) out, not `NaN` out, so that trick only avoids the exception, it doesn't # produce the documented `NaN`. This masks the *output* explicitly instead, using # `valid` computed from `x` directly, which is correct regardless of what G23 happens # to do internally with a `NaN` input. lo, hi = law.x_range delta = 1e-6 valid = (x > lo - delta) & (x < hi + delta) x_safe = np.where(valid, x, lo) axav = np.where(valid, np.asarray(law(x_safe / u.micron), dtype=np.float64), np.nan) Av = law.Rv.value * Ebv result = -0.4 * np.log(10.0) * Av[..., np.newaxis] * axav return result if nu.shape else result[..., 0]
[docs] def attenuation_callable( Ebv: float | Quantity | NDArray[np.float64], dust_law: str | None = None, ) -> Callable[[Quantity], NDArray[np.float64]]: """ Bind `log_attenuation` to a fixed E(B-V)/dust law, as a plain ``nu -> ln(transmission)`` callable. This is what `~uvex_transients.models.core.base.SpectralModel.as_source_spectrum` and `~uvex_transients.models.core.base.SpectralModel.simulate_photometry` call internally, themselves, whenever their own ``ebv`` keyword is given -- callers of *those* methods just pass ``ebv=...`` directly and never see this function, a callable, or `functools.partial` at all. It's exposed here only for the rare case of building a bespoke :class:`~astropy.modeling.Model`/:class:`~synphot.SourceSpectrum` by hand, outside of `as_source_spectrum`, that still wants this module's already-resolved-:math:`E(B-V)`-to-callable contract (`log_attenuation(wave) -> ln(transmission)`, evaluated against a wavelength grid unknown until :mod:`synphot` samples it). Parameters ---------- Ebv : float, ~astropy.units.Quantity, or numpy.ndarray Already-resolved E(B-V) -- see `resolve_ebv`. dust_law : str, optional Passed through to `log_attenuation`/`get_dust_law`. The configured default is almost always the right choice; this is here mainly so the resulting callable stays a pure function of its inputs. Returns ------- Callable[[~astropy.units.Quantity], numpy.ndarray] ``nu -> log_attenuation(nu, Ebv, dust_law)``. """ return partial(log_attenuation, Ebv=Ebv, dust_law=dust_law)