Source code for brutus.priors.extinction

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
Extinction priors for Bayesian stellar parameter estimation.

This module provides log-prior functions for dust extinction modeling
using 3D dust maps. These priors incorporate spatial dust distribution
information to constrain extinction in stellar fitting.

Functions
---------
logp_extinction : Dust map extinction prior
    Gaussian prior from 3D dust maps (e.g., Bayestar)

See Also
--------
brutus.dust.maps : 3D dust map utilities
brutus.priors.galactic : Galactic structure priors
brutus.analysis.individual.BruteForce : Uses extinction priors for fitting

Notes
-----
The extinction prior uses 3D dust maps (e.g., Bayestar from Green et al.
2015, 2018) which provide distance-dependent extinction estimates across
the sky.

The prior is Gaussian when dust map data is available, and uniform when
coverage is unavailable. This gracefully handles regions outside the
mapped volume.

Examples
--------
>>> from brutus.priors.extinction import logp_extinction
>>> from brutus.dust import Bayestar
>>> import numpy as np
>>>
>>> # Load 3D dust map and evaluate extinction prior at a distance
>>> # dustmap = Bayestar('bayestar2019_v1.h5')
>>> # logp = logp_extinction([0.1, 0.5], dustmap, [180.0, 30.0], distance=1.0)
"""

import warnings
from math import erf, log, pi, sqrt

import numpy as np
from numba import jit, prange
from scipy.special import erf as _np_erf

__all__ = ["logp_extinction"]

_SQRT2 = sqrt(2.0)


def _log_truncnorm_z(av_mean, av_err, av_lo, av_hi):
    """Log of the Gaussian mass on [av_lo, av_hi] (vectorized, erf-based).

    Uses the same erf formulation as the fused numba kernel so both code
    paths agree to floating-point roundoff. The mass is floored at 1e-300
    to keep the log finite when the map mean is far outside the support.
    """
    z = 0.5 * (
        _np_erf((av_hi - av_mean) / (av_err * _SQRT2))
        - _np_erf((av_lo - av_mean) / (av_err * _SQRT2))
    )
    return np.log(np.maximum(z, 1e-300))


@jit(nopython=True, parallel=True, cache=True)
def _extinction_prior_3d_fused(
    avs, distance, map_distances, av_profile, std_profile, av_lo, av_hi, truncate
):
    """Fused 3D dust prior: one binary search + Gaussian log-pdf per point.

    Interpolates the map's mean/std extinction profiles at ``distance[i]``
    (np.interp semantics: clamped at the profile boundaries) and returns
    the truncated-normal log-prior for ``avs[i]``. Points where the
    interpolated profile is non-finite or has non-positive spread get 0
    (uniform prior), matching the numpy fallback path.
    """
    N = avs.shape[0]
    n = map_distances.shape[0]
    out = np.empty(N)

    for i in prange(N):
        d = distance[i]

        # Interpolate both profiles from one shared bracket
        if d <= map_distances[0]:
            mu = av_profile[0]
            sig = std_profile[0]
        elif d >= map_distances[n - 1]:
            mu = av_profile[n - 1]
            sig = std_profile[n - 1]
        else:
            lo = 0
            hi = n - 1
            while hi - lo > 1:
                mid = (lo + hi) // 2
                if map_distances[mid] <= d:
                    lo = mid
                else:
                    hi = mid
            if d == map_distances[lo]:
                # np.interp returns fp[lo] exactly at grid points, even
                # when the right neighbor is NaN
                mu = av_profile[lo]
                sig = std_profile[lo]
            else:
                # Slope form matches np.interp's arithmetic exactly, so
                # the numpy fallback classifies validity (sig > 0)
                # identically
                dx = map_distances[lo + 1] - map_distances[lo]
                dd = d - map_distances[lo]
                mu = (av_profile[lo + 1] - av_profile[lo]) / dx * dd + av_profile[lo]
                sig = (std_profile[lo + 1] - std_profile[lo]) / dx * dd + std_profile[
                    lo
                ]

        if np.isfinite(mu) and np.isfinite(sig) and sig > 0:
            chi2 = (avs[i] - mu) ** 2 / sig**2
            lnorm = log(2.0 * pi * sig**2)
            lnp = -0.5 * (chi2 + lnorm)
            if truncate:
                z = 0.5 * (
                    erf((av_hi - mu) / (sig * _SQRT2))
                    - erf((av_lo - mu) / (sig * _SQRT2))
                )
                if z < 1e-300:
                    z = 1e-300
                lnp -= log(z)
            out[i] = lnp
        else:
            out[i] = 0.0

    return out


[docs] def logp_extinction( avs, dustmap, coord, distance=None, return_components=False, avlim=(0.0, 20.0), ): r""" Log-prior for dust extinction using 3D dust maps. Implements Gaussian extinction priors based on dust maps with systematic uncertainty treatment. Supports both 3D dust maps (e.g., Bayestar) that return distance-resolved profiles and simpler maps that return a single mean and standard deviation. Parameters ---------- avs : array_like Extinction values (A_V) in magnitudes to evaluate prior for. dustmap : object Dust map object with a ``query(coord)`` method. For 3D dust maps (e.g., ``Bayestar``), ``query`` returns ``(distances, av_mean, av_std)`` with distance-resolved profiles. For simpler maps, ``query`` returns ``(av_mean, av_std)`` scalars. Objects without a ``query`` method (e.g. an unloaded file path) raise TypeError. coord : SkyCoord or array_like Sky coordinates for dust map query. Accepts ``astropy.coordinates.SkyCoord`` or ``[l, b]`` in degrees. distance : float or array_like, optional Distance(s) in kpc at which to evaluate the extinction prior. Required for 3D dust maps to interpolate the extinction profile. If ``avs`` and ``distance`` are both arrays, they must have the same shape and the prior is evaluated element-wise. return_components : bool, optional If True, returns tuple ``(logp, (av_mean, av_err))`` including the dust map statistics used. Default is False. avlim : tuple of 2 floats or None, optional Support ``(av_min, av_max)`` over which the Gaussian prior is normalized (truncated normal). Should match the ``avlim`` bounds used to clip A(V) samples in the fitting pipeline. Default is ``(0.0, 20.0)`` (the ``BruteForce.fit`` default). Pass ``None`` for an untruncated Gaussian normalized over the full real line. Returns ------- logp : ndarray Log-prior probability density for the input extinction values. Returns 0 (uniform prior) when no dust map coverage is available. components : tuple, optional If ``return_components=True``, returns ``(av_mean, av_err)`` containing the dust map mean and standard deviation used. Raises ------ TypeError If ``dustmap`` has no ``query`` method (e.g. a ``str`` or ``pathlib.Path`` that was never loaded into a dust map object). Notes ----- The log-prior follows a truncated Gaussian distribution when dust map data is available: .. math:: \\log p(A_V | A_{V,\\text{map}}, \\sigma_{A_V}) = -\\frac{1}{2} \\left[ \\frac{(A_V - A_{V,\\text{map}})^2}{\\sigma_{A_V}^2} + \\log(2\\pi\\sigma_{A_V}^2) \\right] - \\log Z where :math:`Z = \\Phi((A_{V,\\max}-\\mu)/\\sigma) - \\Phi((A_{V,\\min}-\\mu)/\\sigma)` normalizes the density over the physical support ``avlim``. Because the map mean and spread vary with distance, omitting :math:`Z` would under-weight distances where the profile sits near the A(V) boundary (a bias of up to ``ln 2``). For 3D dust maps, the expected extinction and uncertainty at the requested distance are obtained by linear interpolation of the map's distance-resolved profiles. Distances outside the map range use the boundary values. For regions without dust map coverage (NaN values), a uniform (uninformative) prior is returned. If the dust map ``query`` call itself fails, a uniform prior is returned with a RuntimeWarning. Examples -------- >>> from astropy.coordinates import SkyCoord >>> coord = SkyCoord(l=90., b=0., unit='deg', frame='galactic') >>> # With a 3D dust map: >>> # logp = logp_extinction([0.1, 0.5], dustmap, coord, distance=1.0) >>> # logp, (mean, err) = logp_extinction([0.1], dustmap, coord, >>> # distance=1.0, >>> # return_components=True) """ avs = np.asarray(avs, dtype=float) if avlim is None: truncate = False av_lo, av_hi = 0.0, 0.0 # unused else: truncate = True av_lo, av_hi = float(avlim[0]), float(avlim[1]) # Reversed or non-finite bounds would silently corrupt the # truncation normalization (the log-mass floor turns it into a huge # constant offset instead of an error). if not (np.isfinite(av_lo) and np.isfinite(av_hi)) or av_lo >= av_hi: raise ValueError( f"avlim must be finite with avlim[0] < avlim[1]; got {avlim}" ) # A dust map must expose query(); silently returning a uniform prior for # e.g. a pathlib.Path would disable the dust prior for the whole fit. if not hasattr(dustmap, "query"): raise TypeError( f"`dustmap` has no query() method (got " f"{type(dustmap).__name__!r}). Pass a dust map object such as " f"brutus.dust.Bayestar; file paths must be loaded first " f"(`str` and `os.PathLike` paths are auto-converted inside " f"BruteForce.fit)." ) # Query the dust map try: result = dustmap.query(coord) except (AttributeError, TypeError) as e: # Query failed (e.g. coordinate outside the supported frame). Treat # as no coverage, but make the degradation visible. warnings.warn( f"Dust map query failed ({e}); returning a uniform extinction " f"prior.", RuntimeWarning, stacklevel=2, ) lnprior = np.zeros_like(avs, dtype=float) if return_components: return lnprior, (np.nan, np.nan) return lnprior # Handle 3D dust maps returning (distances, av_profile, std_profile) if isinstance(result, tuple) and len(result) == 3: map_distances, av_profile, std_profile = result # Squeeze single-coordinate queries that return (1, n_dist) arrays av_profile = np.squeeze(av_profile) std_profile = np.squeeze(std_profile) if distance is None: # Cannot evaluate distance-dependent prior without distance lnprior = np.zeros_like(avs, dtype=float) av_mean = np.nan av_err = np.nan else: distance = np.asarray(distance, dtype=float) # Fast path: fused numba kernel (one binary search per point, no # full-size temporaries) for the large elementwise-array case # used by BruteForce (Nmc * Nsel points per object). The numpy # branch below remains the fallback / NUMBA_DISABLE_JIT path. map_d = np.ascontiguousarray(map_distances, dtype=np.float64) av_p = np.ascontiguousarray(av_profile, dtype=np.float64) std_p = np.ascontiguousarray(std_profile, dtype=np.float64) if ( not return_components and avs.ndim == 1 and distance.shape == avs.shape and avs.size > 1000 and map_d.ndim == 1 and av_p.shape == map_d.shape and std_p.shape == map_d.shape ): try: return _extinction_prior_3d_fused( avs, distance, map_d, av_p, std_p, av_lo, av_hi, truncate ) except Exception as e: warnings.warn( f"Numba fused extinction prior failed, falling back " f"to numpy: {e}", RuntimeWarning, stacklevel=2, ) # Interpolate dust map profiles to requested distance(s) av_mean = np.interp(distance, map_distances, av_profile) av_err = np.interp(distance, map_distances, std_profile) # Compute Gaussian prior where valid valid = np.isfinite(av_mean) & np.isfinite(av_err) & (av_err > 0) lnprior = np.zeros_like(avs, dtype=float) if np.any(valid): # Use safe denominator to avoid division by zero av_err_safe = np.where(valid, av_err, 1.0) chi2 = (avs - av_mean) ** 2 / av_err_safe**2 lnorm = np.log(2.0 * np.pi * av_err_safe**2) lnprior = -0.5 * (chi2 + lnorm) if truncate: # Normalize over the physical support [av_lo, av_hi] lnprior -= _log_truncnorm_z(av_mean, av_err_safe, av_lo, av_hi) lnprior = np.where(valid, lnprior, 0.0) # Handle simple dust maps returning (av_mean, av_std) elif isinstance(result, tuple) and len(result) == 2: av_mean, av_err = result if np.isfinite(av_mean) and np.isfinite(av_err) and av_err > 0: chi2 = (avs - av_mean) ** 2 / av_err**2 lnorm = np.log(2.0 * np.pi * av_err**2) lnprior = -0.5 * (chi2 + lnorm) if truncate: lnprior -= _log_truncnorm_z(av_mean, av_err, av_lo, av_hi) else: lnprior = np.zeros_like(avs, dtype=float) av_mean, av_err = np.nan, np.nan else: # Unrecognized return format lnprior = np.zeros_like(avs, dtype=float) av_mean = np.nan av_err = np.nan if return_components: return lnprior, (av_mean, av_err) return lnprior