Source code for uvex_transients.models.supernovae.IIb

"""Models of SEDs from type IIb supernovae and associated scenarios."""

from collections import namedtuple
from typing import ClassVar

import numpy as np
from astropy import units as u

from uvex_transients.models._constants import (
    C_CGS,
    LOG_EV_TO_ERG,
    LOG_H_CGS,
    LOG_KELVIN_PER_EV,
    MSUN_G,
    SECONDS_PER_DAY,
    SECONDS_PER_HOUR,
)
from uvex_transients.models._typing import CGSParameterValue, FloatArray
from uvex_transients.models._util_functions import (
    cooling_temperature_cgs,
    planck_Bnu_log_cgs,
    planck_shape_log_cgs,
)
from uvex_transients.models._utils import to_cgs_value
from uvex_transients.models.core.base import SpectralModel
from uvex_transients.models.core.parameters import Parameter
from uvex_transients.models.core.priors import ConstantPrior, LogNormalPrior, NormalPrior, UniformPrior
from uvex_transients.models.lightcurves.generic import TwoComponentBazinLightcurve
from uvex_transients.models.spectra.thermal import BlackbodySpectrum

__all__ = ["MoragShockCoolingBlackbodySED", "MoragShockCoolingSED", "TypeIIbSED"]

# ================================================ #
# Types / Containers                               #
# ================================================ #
_MoragComponents = namedtuple(
    "MoragComponents",
    [
        "log_v",
        "log_R",
        "log_kappa",
        "log_M_E",
        "log_M_C",
        "log_f_rho_M",
        "log_t_break",
        "log_t_rel",
        "log_L_break",
        "log_T_break",
        "log_t_tr",
        "log_t_min",
        "log_t_max",
        "valid",
    ],
)
# ================================================ #
# Constants / Scalings                             #
# ================================================ #
# Geometric prefactor (4*pi)**2 / sqrt(3) in Eq. A9 of Morag+24.
_LOG_A9_PREFACTOR = np.log((4.0 * np.pi) ** 2 / np.sqrt(3.0))


# ================================================ #
# Utility / Component Functions                    #
# ================================================ #
def _log_morag_components(
    log_time: FloatArray,
    log_v_star: CGSParameterValue,
    log_radius: CGSParameterValue,
    log_opacity: CGSParameterValue,
    log_envelope_mass: CGSParameterValue,
    log_core_mass: CGSParameterValue,
) -> _MoragComponents:
    """
    Precompute the intermediate scalings/break points shared by Morag+24's shock-cooling relations.

    Parameters
    ----------
    log_time : ~numpy.ndarray or float
        The logarithm of the time, in seconds, since the epoch of explosion.
    log_v_star : ~numpy.ndarray or float
        The logarithm of the scale velocity of the shock near the surface, in cm/s.
    log_radius : ~numpy.ndarray or float
        The logarithm of the stellar radius, in cm.
    log_opacity : ~numpy.ndarray or float
        The logarithm of the Rosseland mean opacity, in cm^2/g.
    log_envelope_mass : ~numpy.ndarray or float
        The logarithm of the envelope mass, in g.
    log_core_mass : ~numpy.ndarray or float
        The logarithm of the core mass, in g.

    Returns
    -------
    _MoragComponents
        The precomputed scalings, break time/temperature/luminosity, validity
        time bounds, and a boolean ``valid`` mask, shared by
        :func:`_log_morag_temperature_log_K` and :func:`_log_morag_bolometric`.
    """
    # Coerce inputs. Equations in the paper are normalized against standard scalings for
    # v, R, and kappa.
    log_time = np.asarray(log_time, dtype=np.float64)
    log_v, log_R, log_kappa = (log_v_star - np.log(10**8.5), log_radius - np.log(10**13), log_opacity - np.log(0.34))
    log_M_C, log_M_E = (
        log_core_mass - np.log(MSUN_G),
        log_envelope_mass - np.log(MSUN_G),
    )

    # Compute the polytropic coefficient, which is f_rho * M in solar masses.
    log_f_rho_M = 0.5 * (log_M_E - log_M_C) + np.logaddexp(log_M_C, log_M_E)

    # Determine the break from the planar to spherical evolution phase. We maintain our
    # CGS convention for all parameters.
    log_t_break = (
        np.log(0.86) + 1.26 * log_R - 1.13 * log_v - 0.13 * (log_f_rho_M + log_kappa) + np.log(SECONDS_PER_HOUR)
    )
    log_t_rel = log_time - log_t_break

    # Determine the scale temperature at the transition point. We do this also in K.
    log_T_break = np.log(8.19) - 0.32 * log_R + 0.58 * log_v + 0.03 * log_f_rho_M - 0.22 * log_kappa + LOG_KELVIN_PER_EV

    # Compute the bolometric luminosity at the break (erg/s).
    log_L_break = np.log(3.69e42) + 0.78 * log_R + 2.11 * log_v + 0.11 * log_f_rho_M - 0.89 * log_kappa

    # Compute the validity mask.
    # Compute the relevant time cuts. These are all in base CGS units.
    _log_lt_time = log_radius - np.log(C_CGS)
    _log_bo_time = np.log(30) + 2.16 * log_R - 1.58 * log_v - 0.58 * (log_f_rho_M + log_kappa)
    _log_07_time = (
        np.log(6.86) + 0.56 * log_R + 0.16 * log_v - 0.61 * log_kappa - 0.06 * log_f_rho_M + np.log(SECONDS_PER_DAY)
    )
    _log_tr_time = np.log(19.5) + 0.5 * (log_kappa + log_M_E - log_v) + np.log(SECONDS_PER_DAY)

    # Compute the regimes of validity.
    _log_t_min = np.maximum(_log_lt_time + np.log(3), _log_bo_time)
    _log_t_max = np.minimum(_log_07_time, _log_tr_time - np.log(2))

    mask = np.logical_and(log_time > _log_t_min, log_time < _log_t_max)

    return _MoragComponents(
        log_v=log_v,
        log_R=log_R,
        log_kappa=log_kappa,
        log_M_E=log_M_E,
        log_M_C=log_M_C,
        log_f_rho_M=log_f_rho_M,
        log_t_break=log_t_break,
        log_t_rel=log_t_rel,
        log_L_break=log_L_break,
        log_T_break=log_T_break,
        log_t_tr=_log_tr_time,
        log_t_min=_log_t_min,
        log_t_max=_log_t_max,
        valid=mask,
    )


def _log_morag_temperature_log_K(
    components: _MoragComponents,
    log_time: FloatArray,
    mask_invalid: bool = False,
) -> FloatArray:
    r"""
    Color temperature (natural log, Kelvin) from precomputed Morag+24 :attr:`components`.

    Shared by :func:`_log_morag_Tcolor` (which computes :attr:`components` itself, for
    standalone use) and any caller that already has :attr:`components` in hand, to avoid
    recomputing :func:`_log_morag_components`.

    Parameters
    ----------
    components : _MoragComponents
        Precomputed scalings/break points, from :func:`_log_morag_components`.
    log_time : ~numpy.ndarray or float
        The logarithm of the time, in seconds, since the epoch of explosion.
    mask_invalid : bool, optional
        If ``True``, mask out invalid parameter ranges (see
        :attr:`_MoragComponents.valid`) with ``nan``. Default ``False``.

    Returns
    -------
    ~numpy.ndarray or float
        The natural log of the color temperature, in Kelvin.
    """
    log_t_rel = log_time - components.log_t_break
    log_T = components.log_T_break + np.minimum(
        np.log(0.97) - (1 / 3) * log_t_rel,
        -0.45 * log_t_rel,
    )

    if mask_invalid:
        log_T = np.where(components.valid, log_T, np.nan)

    return log_T


def _log_morag_bolometric(
    components: _MoragComponents,
    log_time: FloatArray,
    mask_invalid: bool = False,
) -> FloatArray:
    r"""
    Bolometric luminosity (natural log, cgs), Eq. A1 of Morag+24, from precomputed :attr:`components`.

    :math:`L(t) = L_\mathrm{break} \left[ \tilde t^{-4/3} +
    0.9\,e^{-\sqrt{2t/t_\mathrm{tr}}}\,\tilde t^{-0.17} \right]`,
    where :math:`\tilde t = t / t_\mathrm{break}`.

    Parameters
    ----------
    components : _MoragComponents
        Precomputed scalings/break points, from :func:`_log_morag_components`.
    log_time : ~numpy.ndarray or float
        The logarithm of the time, in seconds, since the epoch of explosion.
    mask_invalid : bool, optional
        If ``True``, mask out invalid parameter ranges (see
        :attr:`_MoragComponents.valid`) with ``nan``. Default ``False``.

    Returns
    -------
    ~numpy.ndarray or float
        The natural log of the bolometric luminosity, in erg/s.
    """
    log_t_rel = components.log_t_rel
    log_sqrt_arg = 0.5 * (np.log(2.0) + log_time - components.log_t_tr)
    log_L = components.log_L_break + np.logaddexp(
        -(4.0 / 3.0) * log_t_rel,
        np.log(0.9) - np.exp(log_sqrt_arg) - 0.17 * log_t_rel,
    )

    if mask_invalid:
        log_L = np.where(components.valid, log_L, np.nan)

    return log_L


def _log_morag_Tcolor(
    log_time: FloatArray,
    log_v_star: CGSParameterValue,
    log_radius: CGSParameterValue,
    log_opacity: CGSParameterValue,
    log_envelope_mass: CGSParameterValue,
    log_core_mass: CGSParameterValue,
    mask_invalid: bool = False,
):
    r"""
    Color temperature from Morag+24\ :footcite:p:`2024MNRAS.528.7137M` for shock cooling IIb's.

    Parameters
    ----------
    log_time : ~numpy.ndarray or float
        The logarithm of the time, in seconds, since the epoch of explosion.
    log_v_star : ~numpy.ndarray or float
        The logarithm of the scale velocity of the shock near the surface in :math:`{\rm cm/s}`.
        See equations (2) and (3) of :footcite:t:`2024MNRAS.528.7137M`.
    log_radius : ~numpy.ndarray or float
        The logarithm of the stellar radius in :math:`{\rm cm}`.
    log_opacity : ~numpy.ndarray or float
        The logarithm of the Rosseland mean opacity in :math:`{\rm cm^2/g}`.
    log_envelope_mass : ~numpy.ndarray or float
        The logarithm of the envelope mass in :math:`{\rm g}`.
    log_core_mass : ~numpy.ndarray or float
        The logarithm of the core mass in :math:`{\rm g}`.
    mask_invalid : bool
        If ``True``, then invalid parameter ranges will be masked out of the final result. Otherwise
        they will be retained. By default, this is ``False``.

    Returns
    -------
    ~numpy.ndarray or float
        The logarithmic temperature produced by the model.
    """
    # Coerce inputs. Equations in the paper are normalized against standard scalings for
    # v, R, and kappa.
    log_time = np.asarray(log_time, dtype=np.float64)

    components = _log_morag_components(
        log_time=log_time,
        log_v_star=log_v_star,
        log_radius=log_radius,
        log_opacity=log_opacity,
        log_envelope_mass=log_envelope_mass,
        log_core_mass=log_core_mass,
    )

    return _log_morag_temperature_log_K(components, log_time, mask_invalid=mask_invalid)


def _log_morag_Lnu(
    log_time: FloatArray,
    log_frequency: FloatArray,
    log_v_star: CGSParameterValue,
    log_radius: CGSParameterValue,
    log_opacity: CGSParameterValue,
    log_envelope_mass: CGSParameterValue,
    log_core_mass: CGSParameterValue,
    mask_invalid: bool = False,
):
    r"""
    Specific luminosity :math:`L_\nu` from Morag+24\ :footcite:p:`2024MNRAS.528.7137M` for shock cooling IIb's.

    Frequency-dependent SED of Eq. A7 -- line suppression above :math:`3.5\,T_\mathrm{col}`, plus
    the free-free correction below it. See :class:`MoragShockCoolingBlackbodySED` for the simpler
    pure-blackbody form (Eq. A8), which is bolometrically exact by construction; this is not (the
    diffusive/free-free redistribution of Eq. A9 does not conserve bolometric energy relative to a
    blackbody).

    Parameters
    ----------
    log_time : ~numpy.ndarray or float
        The logarithm of the time, in seconds, since the epoch of explosion.
    log_frequency : ~numpy.ndarray or float
        The logarithm of the (rest-frame) frequency in :math:`{\rm Hz}`.
    log_v_star : ~numpy.ndarray or float
        The logarithm of the scale velocity of the shock near the surface in :math:`{\rm cm/s}`.
        See equations (2) and (3) of :footcite:t:`2024MNRAS.528.7137M`.
    log_radius : ~numpy.ndarray or float
        The logarithm of the stellar radius in :math:`{\rm cm}`.
    log_opacity : ~numpy.ndarray or float
        The logarithm of the Rosseland mean opacity in :math:`{\rm cm^2/g}`.
    log_envelope_mass : ~numpy.ndarray or float
        The logarithm of the envelope mass in :math:`{\rm g}`.
    log_core_mass : ~numpy.ndarray or float
        The logarithm of the core mass in :math:`{\rm g}`.
    mask_invalid : bool
        If ``True``, then invalid parameter ranges will be masked out of the final result. Otherwise
        they will be retained. By default, this is ``False``.

    Returns
    -------
    ~numpy.ndarray or float
        The logarithm of the specific luminosity, in :math:`{\rm erg\,s^{-1}\,Hz^{-1}}`.
    """
    # Coerce components.
    log_time = np.asarray(log_time, dtype=np.float64)
    log_frequency = np.asarray(log_frequency, dtype=np.float64)
    nu = np.exp(log_frequency)
    components = _log_morag_components(
        log_time=log_time,
        log_v_star=log_v_star,
        log_radius=log_radius,
        log_opacity=log_opacity,
        log_envelope_mass=log_envelope_mass,
        log_core_mass=log_core_mass,
    )

    # Calculate the color temperature using the components (Kelvin -- `LOG_KELVIN_PER_EV` is folded in).
    log_T_break = components.log_T_break
    log_t_rel = components.log_t_rel
    log_T = _log_morag_temperature_log_K(components, log_time)

    # Bolometric luminosity (A1).
    log_L = _log_morag_bolometric(components, log_time)

    # The power-law scalings below (Lb, Tb, T_nu, k_ff, hnu) follow Morag+24's equations
    # exactly, which are fit with T and h*nu in eV. Only the two blackbody evaluations
    # themselves need Kelvin, so that conversion (`+ LOG_KELVIN_PER_EV`) is applied inline,
    # right where each is evaluated, rather than eV in general.
    log_T_eV = log_T - LOG_KELVIN_PER_EV
    log_T_break_eV = log_T_break - LOG_KELVIN_PER_EV
    log_hnu = LOG_H_CGS + log_frequency - LOG_EV_TO_ERG

    log_Lb = components.log_L_break - 42.5 * np.log(10.0)
    log_Tb = log_T_break_eV - np.log(5.0)
    log_k = components.log_kappa
    log_R13 = components.log_R

    # --- frequency-dependent thermal depth, low-absorption regime (A9-A12) ---
    log_r_col = np.logaddexp(
        log_radius,
        np.log(2.18e13) + 0.48 * log_Lb - 1.97 * log_Tb - 0.07 * log_k + 0.80 * log_t_rel - 0.08 * log_hnu,
    )  # A10 [cm]
    log_T_nu = (
        np.log(5.47) + 0.05 * log_Lb + 0.92 * log_Tb + 0.22 * log_k - 0.42 * log_t_rel + 0.25 * log_hnu
    )  # A11 [eV]
    log_k_ff = (
        np.log(0.03) - 0.37 * log_Lb + 0.56 * log_Tb - 0.47 * log_k - 0.19 * log_t_rel - 1.66 * log_hnu
    )  # A12 [cm^2/g]
    log_eps = log_k_ff - np.logaddexp(log_k_ff, log_opacity)
    sqrt_eps = np.exp(0.5 * log_eps)

    # B_nu(T_nu), the un-normalized Planck function; T_nu converted from eV to Kelvin.
    log_T_nu_kelvin = log_T_nu + LOG_KELVIN_PER_EV
    log_B_nu_Tnu = planck_Bnu_log_cgs(nu, np.exp(log_T_nu_kelvin))
    log_L_eps = _LOG_A9_PREFACTOR + 2.0 * log_r_col + 0.5 * log_eps - np.log1p(sqrt_eps) + log_B_nu_Tnu  # A9

    m = 5.0
    log_T_low_kelvin = np.log(0.85) + log_T_eV + LOG_KELVIN_PER_EV
    log_L_BB_low = log_L + planck_shape_log_cgs(nu, np.exp(log_T_low_kelvin))
    log_low = -(1.0 / m) * np.logaddexp(-m * log_L_BB_low, -m * log_L_eps)  # A7, h nu < 3.5 T_col

    log_time_days = log_time - np.log(SECONDS_PER_DAY)
    log_T_high_kelvin = np.log(0.85) + 0.13 * log_R13 - 0.13 * log_time_days + log_T_eV + LOG_KELVIN_PER_EV
    log_high = np.log(1.2) + log_L + planck_shape_log_cgs(nu, np.exp(log_T_high_kelvin))  # A7, h nu > 3.5 T_col

    log_L_nu = np.where(log_hnu < np.log(3.5) + log_T_eV, log_low, log_high)

    if mask_invalid:
        log_L_nu = np.where(components.valid, log_L_nu, np.nan)

    return log_L_nu


# ======================================== #
# Model: Shared Parameters                 #
# ======================================== #
class _MoragShockCoolingBase(SpectralModel):
    r"""
    Shared parameters for the Morag+24\ :footcite:p:`2024MNRAS.528.7137M` shock-cooling SED family.

    Not instantiable directly (leaves ``_eval`` abstract) -- see
    :class:`MoragShockCoolingSED` (the full UV-suppressed/free-free SED, Eq. A7) and
    :class:`MoragShockCoolingBlackbodySED` (the simpler pure-blackbody SED, Eq. A8), which share
    this parameter set and differ only in their spectral shape.

    .. rubric:: Parameters

    .. list-table::
       :header-rows: 1
       :widths: 18 18 64

       * - Parameter
         - Symbol
         - Description
       * - ``v_star``
         - :math:`v_*`
         - Scale velocity of the shock near the stellar surface.
       * - ``radius``
         - :math:`R`
         - Progenitor stellar radius.
       * - ``opacity``
         - :math:`\kappa`
         - Rosseland mean opacity. Fixed at the electron-scattering value.
       * - ``envelope_mass``
         - :math:`M_E`
         - Envelope mass.
       * - ``core_mass``
         - :math:`M_C`
         - Core mass.

    References
    ----------
    .. footbibliography::
    """

    _MASK_INVALID: bool = False
    """
    bool: If ``True``, invalid points are masked out of the analysis.
    """

    _DEFAULT_PARAMETERS: ClassVar[dict[str, Parameter]] = {
        "v_star": Parameter(
            prior=LogNormalPrior(mean=0.0, sigma=0.5),
            scale=10**8.5 * u.cm / u.s,
            description="Scale velocity of the shock near the stellar surface.",
            latex=r"v_*",
        ),
        "radius": Parameter(
            prior=LogNormalPrior(mean=0.0, sigma=0.5),
            scale=1e13 * u.cm,
            description="Progenitor stellar radius.",
            latex=r"R",
        ),
        "opacity": Parameter(
            prior=ConstantPrior(value=1.0),
            scale=0.34 * u.cm**2 / u.g,
            description="Rosseland mean opacity. Fixed at the electron-scattering value.",
            latex=r"\kappa",
        ),
        "envelope_mass": Parameter(
            prior=LogNormalPrior(mean=0.0, sigma=0.5),
            scale=1.0 * u.Msun,
            description="Envelope mass.",
            latex=r"M_E",
        ),
        "core_mass": Parameter(
            prior=LogNormalPrior(mean=0.0, sigma=0.5),
            scale=1.0 * u.Msun,
            description="Core mass.",
            latex=r"M_C",
        ),
    }

    @classmethod
    def _components(
        cls,
        t: FloatArray,
        *,
        v_star: CGSParameterValue,
        radius: CGSParameterValue,
        opacity: CGSParameterValue,
        envelope_mass: CGSParameterValue,
        core_mass: CGSParameterValue,
        **_ignored: CGSParameterValue,
    ) -> _MoragComponents:
        """
        Compute this SED's :func:`_log_morag_components` from its own cgs parameter values.

        Parameters
        ----------
        t : numpy.ndarray
            Time since explosion, in seconds.
        v_star, radius, opacity, envelope_mass, core_mass : float or numpy.ndarray
            This model's parameter values, in cgs units; see the class docstring.
        **_ignored
            Any other model parameter values, ignored.

        Returns
        -------
        _MoragComponents
            The precomputed scalings/break points, from :func:`_log_morag_components`.
        """
        log_time = np.log(np.asarray(t, dtype=np.float64))
        return _log_morag_components(
            log_time=log_time,
            log_v_star=np.log(v_star),
            log_radius=np.log(radius),
            log_opacity=np.log(opacity),
            log_envelope_mass=np.log(envelope_mass),
            log_core_mass=np.log(core_mass),
        )

    @classmethod
    def temperature(cls, t: u.Quantity, **parameters: CGSParameterValue) -> u.Quantity:
        r"""
        :math:`T_\mathrm{col}(t)` in Kelvin -- the color temperature shared by both subclasses.

        For :class:`MoragShockCoolingSED`, this is the underlying blackbody temperature Eq. A7
        reshapes in frequency, not a temperature that alone characterizes its emergent spectrum.

        Parameters
        ----------
        t : ~astropy.units.Quantity
            Time since explosion.
        **parameters
            This model's parameter values. See :meth:`eval_log_cgs`.

        Returns
        -------
        ~astropy.units.Quantity
            :math:`T_\mathrm{col}(t)`, in Kelvin.
        """
        cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
        log_time = np.log(t.cgs.value)
        components = cls._components(t.cgs.value, **cgs_parameters)
        return np.exp(_log_morag_temperature_log_K(components, log_time, mask_invalid=cls._MASK_INVALID)) * u.K


# ======================================== #
# Model: Full UV-Suppressed SED            #
# ======================================== #
[docs] class MoragShockCoolingSED(_MoragShockCoolingBase): r""" Full UV-suppressed shock-cooling SED (Eq. A7). Based on Morag+24\ :footcite:p:`2024MNRAS.528.7137M`. The frequency-dependent SED: line suppression above :math:`3.5\,T_\mathrm{col}`, plus a free-free correction below it. See :class:`MoragShockCoolingBlackbodySED` for the simpler pure-blackbody form (Eq. A8) instead. Notes ----- Unlike :class:`MoragShockCoolingBlackbodySED`, this SED has no closed-form bolometric luminosity: Eq. A7's UV-suppression/free-free reshaping of the underlying blackbody is not constructed to conserve the Eq. A1 diffusion-envelope :math:`L_\mathrm{bol}(t)` upon frequency integration (by as much as ~15-20%, depending on epoch), so :meth:`_eval_bolometric` genuinely needs the frequency integral of :meth:`_eval` itself, not Eq. A1. A generic adaptive :func:`scipy.integrate.quad_vec` over :math:`(0, \infty)` converges too slowly through Eq. A7's line-suppression kink to be practical, so this is overridden with a fixed, log-spaced grid in :math:`h\nu` (0.1-10\ :sup:`4`\ eV, comfortably spanning the Wien tail on both sides of any physically reasonable :math:`T_\mathrm{col}`) and a trapezoidal quadrature in :math:`\ln\nu` -- accurate to <0.2% against an adaptive reference across the whole validity window, and orders of magnitude faster. References ---------- .. footbibliography:: """ #: Fixed, log-spaced grid in :math:`h\nu` [eV] used by :meth:`_eval_bolometric`'s trapezoidal #: quadrature -- see the class docstring's Notes for why a fixed grid, and why these bounds. _LOG_HNU_EV_GRID: ClassVar[FloatArray] = np.log(np.geomspace(1e-1, 1e4, 300)) @classmethod def _log_Lnu( cls, nu: FloatArray, t: FloatArray, *, v_star: CGSParameterValue, radius: CGSParameterValue, opacity: CGSParameterValue, envelope_mass: CGSParameterValue, core_mass: CGSParameterValue, **_ignored: CGSParameterValue, ) -> FloatArray: """ Compute :func:`_log_morag_Lnu` from this SED's own cgs parameter values. Parameters ---------- nu : numpy.ndarray Frequency, in Hz. t : numpy.ndarray Time since explosion, in seconds. v_star, radius, opacity, envelope_mass, core_mass : float or numpy.ndarray This model's parameter values, in cgs units; see the class docstring. **_ignored Any other model parameter values, ignored. Returns ------- numpy.ndarray The natural log of the specific luminosity, in erg/s/Hz. """ return _log_morag_Lnu( log_time=np.log(t), log_frequency=np.log(nu), log_v_star=np.log(v_star), log_radius=np.log(radius), log_opacity=np.log(opacity), log_envelope_mass=np.log(envelope_mass), log_core_mass=np.log(core_mass), mask_invalid=cls._MASK_INVALID, ) @classmethod def _eval(cls, nu: FloatArray, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray: r""" :math:`\log L_\nu(\nu, t)`, delegated to :func:`_log_morag_Lnu`. Parameters ---------- nu : numpy.ndarray Frequency, in Hz. t : numpy.ndarray Time since explosion, in seconds. **parameters This model's parameter values, in cgs units. Returns ------- numpy.ndarray The natural log of :math:`L_\nu(\nu, t)`, in erg/s/Hz. """ return cls._log_Lnu(nu, t, **parameters) @classmethod def _eval_bolometric(cls, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray: r""" :math:`\log L_\mathrm{bol}(t)`, via the fixed-grid quadrature described in the class docstring. Parameters ---------- t : numpy.ndarray Time since explosion, in seconds. **parameters This model's parameter values, in cgs units. Returns ------- numpy.ndarray The natural log of :math:`L_\mathrm{bol}(t)`, in erg/s. """ t_grid, *param_arrays = np.broadcast_arrays(np.asarray(t, dtype=np.float64), *parameters.values()) param_grids = dict(zip(parameters, param_arrays)) # A new leading frequency axis, broadcasting against every existing `t`/parameter axis. expand = (slice(None),) + (np.newaxis,) * t_grid.ndim log_nu_grid = (cls._LOG_HNU_EV_GRID + LOG_EV_TO_ERG - LOG_H_CGS)[expand] nu_grid = np.exp(log_nu_grid) log_L_nu = cls._eval( nu_grid, t_grid[np.newaxis, ...], **{name: value[np.newaxis, ...] for name, value in param_grids.items()}, ) # d(ln nu) substitution: integrand is L_nu * nu, trapezoidal in ln(nu). integrand = np.where(np.isnan(log_L_nu), 0.0, np.exp(log_L_nu) * nu_grid) integral = np.trapezoid(integrand, x=cls._LOG_HNU_EV_GRID, axis=0) with np.errstate(divide="ignore"): log_L = np.log(integral) # nan wherever every frequency sample was masked invalid (integral == 0). return np.where(np.any(np.isfinite(log_L_nu), axis=0), log_L, np.nan)
# ======================================== # # Model: Pure Blackbody SED # # ======================================== #
[docs] class MoragShockCoolingBlackbodySED(_MoragShockCoolingBase): r""" Pure-blackbody shock-cooling SED (Eq. A8). Based on Morag+24\ :footcite:p:`2024MNRAS.528.7137M`. A pure blackbody at the color temperature :math:`T_\mathrm{col}(t)`, which integrates exactly to the bolometric luminosity :math:`L(t)` (Eq. A1) -- unlike :class:`MoragShockCoolingSED`, whose UV line-suppression/free-free redistribution (Eq. A9) does not conserve bolometric energy relative to a blackbody, this variant needs no numerical frequency integration to recover :math:`L_\mathrm{bol}(t)`. References ---------- .. footbibliography:: """ @classmethod def _eval_bolometric(cls, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray: r""" :math:`\log L_\mathrm{bol}(t)`, Eq. A1 -- exact, no integration needed. Parameters ---------- t : numpy.ndarray Time since explosion, in seconds. **parameters This model's parameter values, in cgs units. Returns ------- numpy.ndarray The natural log of :math:`L_\mathrm{bol}(t)`, in erg/s. """ log_time = np.log(np.asarray(t, dtype=np.float64)) components = cls._components(t, **parameters) return _log_morag_bolometric(components, log_time, mask_invalid=cls._MASK_INVALID) @classmethod def _eval_spectrum(cls, nu: FloatArray, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray: r""" :math:`\log S(\nu, T_\mathrm{col}(t))`, delegated to :func:`planck_shape_log_cgs`. Parameters ---------- nu : numpy.ndarray Frequency, in Hz. t : numpy.ndarray Time since explosion, in seconds. **parameters This model's parameter values, in cgs units. Returns ------- numpy.ndarray The natural log of the normalized spectral shape, in 1/Hz. """ log_time = np.log(np.asarray(t, dtype=np.float64)) components = cls._components(t, **parameters) log_T = _log_morag_temperature_log_K(components, log_time, mask_invalid=cls._MASK_INVALID) return planck_shape_log_cgs(nu, np.exp(log_T)) @classmethod def _eval(cls, nu: FloatArray, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray: r""" :math:`\log L_\nu(\nu, t) = \log L_\mathrm{bol}(t) + \log S(\nu, T_\mathrm{col}(t))`. Parameters ---------- nu : numpy.ndarray Frequency, in Hz. t : numpy.ndarray Time since explosion, in seconds. **parameters This model's parameter values, in cgs units. Returns ------- numpy.ndarray The natural log of :math:`L_\nu(\nu, t)`, in erg/s/Hz. """ return cls._eval_bolometric(t, **parameters) + cls._eval_spectrum(nu, t, **parameters)
# ======================================== # # Model: Two-Component SED # # ======================================== #
[docs] class TypeIIbSED(SpectralModel): r""" Phenomenological Type IIb supernova SED. Two superposed Bazin pulses times a cooling blackbody photosphere. Unlike :class:`MoragShockCoolingSED`/:class:`MoragShockCoolingBlackbodySED`, which model only the early shock-cooling phase from first principles, this is a purely empirical light-curve shape intended to span a full Type IIb light curve. It fits real double- and single-peaked Type IIb events better than a single modulated pulse: .. math:: L_\mathrm{bol}(t) = A_0\, \frac{\exp[-(t-t_0)/\tau_{\mathrm{fall},0}]}{1 + \exp[-(t-t_0)/\tau_{\mathrm{rise},0}]} + A_1\, \frac{\exp[-(t-t_1)/\tau_{\mathrm{fall},1}]}{1 + \exp[-(t-t_1)/\tau_{\mathrm{rise},1}]}, delegated directly to :class:`~uvex_transients.models.lightcurves.generic.TwoComponentBazinLightcurve` -- see that class's docstring for why an additive superposition of two independent pulses covers both single- and double-peaked light curves. The first component (centered on :math:`t_0`) stands in for the early shock-cooling peak, the second (centered on :math:`t_1`) for the radioactively powered main/nickel peak. The photospheric temperature follows the same single-power-law cooling law used elsewhere in this package (:func:`~uvex_transients.models._util_functions.cooling_temperature_cgs`): .. math:: T(t) = T_\mathrm{floor} + (T_0 - T_\mathrm{floor})\left(1 + \frac{t}{\tau_T}\right)^{-\alpha_T}. .. rubric:: Parameters .. list-table:: :header-rows: 1 :widths: 18 18 64 * - Parameter - Symbol - Description * - ``amplitude_0`` - :math:`A_0` - Luminosity normalization of the early, shock-cooling peak. * - ``t0`` - :math:`t_0` - Characteristic transition time of the early peak. * - ``rise_0`` - :math:`\tau_{\mathrm{rise},0}` - Logistic rise timescale of the early peak. * - ``fall_0`` - :math:`\tau_{\mathrm{fall},0}` - Exponential decline timescale of the early peak. * - ``amplitude_1`` - :math:`A_1` - Luminosity normalization of the main, radioactively powered peak. * - ``t1`` - :math:`t_1` - Characteristic transition time of the main peak. * - ``rise_1`` - :math:`\tau_{\mathrm{rise},1}` - Logistic rise timescale of the main peak. * - ``fall_1`` - :math:`\tau_{\mathrm{fall},1}` - Exponential decline timescale of the main peak. * - ``T0`` - :math:`T_0` - Photospheric temperature at t=0 (the T(t) -> T0 limit, not literally T at peak). * - ``T_floor`` - :math:`T_\mathrm{floor}` - Asymptotic late-time photospheric temperature (T(t) -> T_floor as t -> infinity). * - ``tau_T`` - :math:`\tau_T` - Photospheric cooling timescale. * - ``alpha_T`` - :math:`\alpha_T` - Photospheric cooling power-law index. Notes ----- ``amplitude_0`` -- the early peak's normalization -- is uniform in :math:`\log_{10}(A_0/\mathrm{erg\,s^{-1}})` between 39 and 43, spanning several decades from far fainter than the main peak up to brighter than it. This single prior covers both populations at once: draws where the early peak ends up negligible are effectively single-peaked Type IIb light curves, and draws where it is comparable to or exceeds the main peak are double-peaked -- roughly a third of draws from the default priors below are double-peaked. ``t0`` and ``T_floor`` are held fixed; every other parameter is drawn from a broad Uniform (or, for ``amplitude_1``/``T0``, Normal-in-log) prior over an order-of-magnitude-motivated range, not yet a fit to any specific real Type IIb event (compare :class:`~uvex_transients.models.supernovae.IIp.TypeIIPSED`, whose light-curve priors do come from fitting real SNe). """ _DEFAULT_PARAMETERS: ClassVar[dict[str, Parameter]] = { "amplitude_0": Parameter( prior=UniformPrior(lower=39, upper=43), scale=1 * u.erg / u.s, transform="log10", description="Luminosity normalization of the early, shock-cooling peak; uniform in " "log10(amplitude_0 / erg/s) between 39 and 43, spanning far fainter to brighter than " "the main peak.", latex=r"A_0", ), "t0": Parameter( prior=ConstantPrior(value=1.0), scale=2.0 * u.day, description="Characteristic transition time of the early peak.", latex=r"t_0", ), "rise_0": Parameter( prior=UniformPrior(lower=0.3, upper=1), scale=1 * u.day, description="Logistic rise timescale of the early peak.", latex=r"\tau_{\mathrm{rise},0}", ), "fall_0": Parameter( prior=UniformPrior(lower=3, upper=10), scale=1 * u.day, description="Exponential decline timescale of the early peak.", latex=r"\tau_{\mathrm{fall},0}", ), "amplitude_1": Parameter( prior=NormalPrior(mean=42.5, sigma=0.1), scale=1 * u.erg / u.s, transform="log10", description="Luminosity normalization of the main, radioactively powered peak.", latex=r"A_1", ), "t1": Parameter( prior=UniformPrior(lower=10, upper=20), scale=1 * u.day, description="Characteristic transition time of the main peak.", latex=r"t_1", ), "rise_1": Parameter( prior=UniformPrior(lower=2, upper=4), scale=1 * u.day, description="Logistic rise timescale of the main peak.", latex=r"\tau_{\mathrm{rise},1}", ), "fall_1": Parameter( prior=UniformPrior(lower=30, upper=55), scale=1 * u.day, description="Exponential decline timescale of the main peak.", latex=r"\tau_{\mathrm{fall},1}", ), "T0": Parameter( prior=NormalPrior(mean=4.1, sigma=0.05), scale=1 * u.K, transform="log10", description="Photospheric temperature at t=0 (the T(t) -> T0 limit, not literally T at peak).", latex=r"T_0", ), "T_floor": Parameter( prior=ConstantPrior(value=1.0), scale=4e3 * u.K, description="Asymptotic late-time photospheric temperature (T(t) -> T_floor as t -> infinity).", latex=r"T_\mathrm{floor}", ), "tau_T": Parameter( prior=UniformPrior(lower=5, upper=10), scale=1 * u.day, description="Photospheric cooling timescale.", latex=r"\tau_T", ), "alpha_T": Parameter( prior=UniformPrior(lower=0.7, upper=1.3), scale=1.0, description="Photospheric cooling power-law index.", latex=r"\alpha_T", ), } # -------------------------------------- # # Cooling Law: T(t) # # -------------------------------------- # @classmethod def _temperature_cgs( cls, t: FloatArray, *, T0: CGSParameterValue, T_floor: CGSParameterValue, tau_T: CGSParameterValue, alpha_T: CGSParameterValue, **_ignored: CGSParameterValue, ) -> FloatArray: r""" :math:`T(t) = T_\mathrm{floor} + (T_0 - T_\mathrm{floor})(1 + t/\tau_T)^{-\alpha_T}`. Parameters ---------- t : numpy.ndarray Time since explosion, in seconds. T0, T_floor, tau_T, alpha_T : float or numpy.ndarray This model's parameter values, in cgs units; see the class docstring. **_ignored Any other model parameter values, ignored. Returns ------- numpy.ndarray :math:`T(t)`, in Kelvin. """ return cooling_temperature_cgs(t, T0=T0, T_floor=T_floor, timescale=tau_T, alpha=alpha_T)
[docs] @classmethod def temperature(cls, t: u.Quantity, **parameters: CGSParameterValue) -> u.Quantity: r""" :math:`T(t)` in Kelvin. Parameters ---------- t : ~astropy.units.Quantity Time since explosion. **parameters This model's parameter values. See :meth:`eval_log_cgs`. Returns ------- ~astropy.units.Quantity :math:`T(t)`, in Kelvin. """ cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()} return cls._temperature_cgs(t.cgs.value, **cgs_parameters) * u.K
# -------------------------------------- # # Bolometric Luminosity: L_bol(t) # # -------------------------------------- # @classmethod def _eval_bolometric(cls, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray: r""" :math:`\log L_\mathrm{bol}(t)`, delegated directly to :class:`TwoComponentBazinLightcurve`. Exact -- no integration needed. Parameters ---------- t : numpy.ndarray Time since explosion, in seconds. **parameters This model's parameter values, in cgs units. Returns ------- numpy.ndarray The natural log of :math:`L_\mathrm{bol}(t)`, in erg/s. """ lightcurve_parameters = {name: parameters[name] for name in TwoComponentBazinLightcurve._DEFAULT_PARAMETERS} return TwoComponentBazinLightcurve._eval(t, **lightcurve_parameters) # -------------------------------------- # # Normalized Spectral Shape: S(nu, t) # # -------------------------------------- # @classmethod def _eval_spectrum(cls, nu: FloatArray, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray: r""" :math:`\log S(\nu, T(t))`, delegated to :class:`~uvex_transients.models.spectra.thermal.BlackbodySpectrum`. Evaluated at this ``t``'s own cooling-law temperature. Parameters ---------- nu : numpy.ndarray Frequency, in Hz. t : numpy.ndarray Time since explosion, in seconds. **parameters This model's parameter values, in cgs units. Returns ------- numpy.ndarray The natural log of the normalized spectral shape, in 1/Hz. """ temperature = cls._temperature_cgs(t, **parameters) return BlackbodySpectrum._eval(nu, temperature=temperature) # -------------------------------------- # # Spectral Luminosity: L_nu(nu, t) # # -------------------------------------- # @classmethod def _eval(cls, nu: FloatArray, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray: r""" :math:`\log L_\nu(\nu, t) = \log L_\mathrm{bol}(t) + \log S(\nu, T(t))`. Parameters ---------- nu : numpy.ndarray Frequency, in Hz. t : numpy.ndarray Time since explosion, in seconds. **parameters This model's parameter values, in cgs units. Returns ------- numpy.ndarray The natural log of :math:`L_\nu(\nu, t)`, in erg/s/Hz. """ return cls._eval_bolometric(t, **parameters) + cls._eval_spectrum(nu, t, **parameters)