r"""
Utilities for computing Arnett-style (diffusion-limited) bolometric lightcurves.
Implements the semi-analytic model of :footcite:t:`2017ApJ...850...55N` (Sec. 2; the MOSFiT magnetar model),
building on Arnett (1982) and the gamma-ray leakage treatment of :footcite:t:`2015ApJ...799..107W`: an internal
energy-injection rate :math:`F_\mathrm{in}(t)` is diffused through a homologously expanding, grey-opacity ejecta,
.. math::
L(t) = e^{-(t/t_d)^2}\left(1 - e^{-A/t^2}\right)
\int_0^t 2 F_\mathrm{in}(t')\,\frac{t'}{t_d}\,e^{(t'/t_d)^2}\,\frac{dt'}{t_d},
with diffusion time :math:`t_d = \sqrt{2\kappa M_\mathrm{ej} / (\beta c v_\mathrm{ej})}` and high-energy leakage
parameter :math:`A = 3\kappa_\gamma M_\mathrm{ej} / (4\pi v_\mathrm{ej}^2)`.
Everything private here works on bare CGS floats/arrays. The two public functions,
:func:`get_magnetar_engine` and :func:`compute_arnett_luminosity`, accept unit-aware
:class:`~astropy.units.Quantity` inputs (unitless inputs are assumed to already be in CGS) and convert at the
boundary.
References
----------
.. footbibliography::
"""
from collections.abc import Callable
from math import exp, expm1
from typing import ClassVar
import numpy as np
from astropy import units as u
from numba import njit
from uvex_transients.models._constants import (
C_CGS,
MSUN_G,
cobalt_decay_time,
cobalt_decay_yield,
nickel_decay_time,
nickel_decay_yield,
)
from uvex_transients.models._typing import CGSParameterValue, FloatArray, FloatValue, PhysicalInput
from uvex_transients.models._util_functions import photospheric_temperature_with_floor_cgs
from uvex_transients.models._utils import ensure_in_units, 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 TruncatedNormalPrior, UniformPrior
from uvex_transients.models.spectra.thermal import BlackbodySpectrum
__all__ = [
"GAUSS_CGS",
"get_magnetar_engine",
"compute_arnett_luminosity",
"ArnettMagnetarSpindownSED",
"ArnettDecaySED",
]
GAUSS_CGS = u.g**0.5 * u.cm**-0.5 / u.s
"""astropy.units.Unit: The Gaussian-cgs gauss (numerically 1 G). Unlike astropy's SI-based ``u.G``, it decomposes
into cgs base units, so it survives the ``.cgs`` conversion applied to every model parameter."""
def _magnetic_field_to_gauss(magnetic_field: PhysicalInput) -> FloatArray:
"""Strip a magnetic field to gauss, accepting ``u.G``/``u.T`` as well as :data:`GAUSS_CGS`."""
if isinstance(magnetic_field, u.Quantity):
try:
return np.asarray(magnetic_field.to_value(u.G), dtype=np.float64)
except u.UnitConversionError:
return np.asarray(magnetic_field.to_value(GAUSS_CGS), dtype=np.float64)
return np.asarray(magnetic_field, dtype=np.float64)
# ================================================= #
# Physics (CGS) #
# ================================================= #
def _diffusion_time_cgs(kappa: FloatValue, ejecta_mass: FloatValue, ejecta_velocity: FloatValue) -> FloatValue:
r"""
Diffusion time :math:`t_d = \sqrt{2\kappa M / (\beta c v)}`, in s (Nicholl+17 Sec. 2, ``2017ApJ...850...55N``).
``kappa`` is in cm^2/g, ``ejecta_mass`` in g and ``ejecta_velocity`` in cm/s. The paper leaves the
density-profile constant :math:`\beta` unstated; we use the standard value 13.8 (Arnett 1982).
"""
return np.sqrt(2.0 * kappa * ejecta_mass / (13.8 * C_CGS * ejecta_velocity))
def _leakage_parameter_cgs(kappa_gamma: FloatValue, ejecta_mass: FloatValue, ejecta_velocity: FloatValue) -> FloatValue:
r"""
Leakage parameter :math:`A = 3\kappa_\gamma M / (4\pi v^2)`, in s^2.
From Wang+15 (``2015ApJ...799..107W``), as adopted in Nicholl+17 Sec. 2.
"""
return 3.0 * kappa_gamma * ejecta_mass / (4.0 * np.pi * ejecta_velocity**2)
def _magnetar_energy_cgs(spin_period: FloatValue, ns_mass: FloatValue) -> FloatValue:
"""Magnetar rotational energy in erg, from the spin period (s) and NS mass (g) (Nicholl+17 Sec. 2)."""
return 2.6e52 * (ns_mass / (1.4 * MSUN_G)) ** 1.5 * (spin_period / 1e-3) ** -2
def _magnetar_timescale_cgs(spin_period: FloatValue, magnetic_field: FloatValue, ns_mass: FloatValue) -> FloatValue:
"""Magnetar spin-down time in s, from the period (s), perpendicular field (G) and NS mass (g) (Nicholl+17)."""
return 1.3e5 * (ns_mass / (1.4 * MSUN_G)) ** 1.5 * (spin_period / 1e-3) ** 2 * (magnetic_field / 1e14) ** -2
def _magnetar_luminosity_cgs(t: FloatArray, energy: FloatValue, timescale: FloatValue) -> FloatArray:
r"""Magnetar spin-down power :math:`F(t) = (E/t_m)(1 + t/t_m)^{-2}`, in erg/s."""
return energy / timescale / (1.0 + np.asarray(t, dtype=np.float64) / timescale) ** 2
def _radioactive_luminosity_cgs(
t: FloatArray,
nickel_mass: FloatValue,
nickel_yield: FloatValue = nickel_decay_yield.cgs.value,
nickel_decay: FloatValue = nickel_decay_time.cgs.value,
cobalt_yield: FloatValue = cobalt_decay_yield.cgs.value,
cobalt_decay: FloatValue = cobalt_decay_time.cgs.value,
) -> FloatArray:
"""Compute the radioactive heating luminosity from the Ni-56 decay chain.
The heating rate includes energy released by the decay chain
Ni-56 -> Co-56 -> Fe-56,
assuming that all radioactive decay energy is deposited locally. Gamma-ray
leakage is therefore not included.
Parameters
----------
t:
Time since explosion in seconds.
nickel_mass:
Initial Ni-56 mass in grams.
nickel_yield:
Specific heating rate from Ni-56 decay in erg s^-1 g^-1.
nickel_decay:
Ni-56 e-folding decay time in seconds.
cobalt_yield:
Specific heating rate from Co-56 decay in erg s^-1 g^-1.
cobalt_decay:
Co-56 e-folding decay time in seconds.
Returns
-------
FloatArray
Radioactive heating luminosity in erg s^-1.
"""
nickel_heating = nickel_yield * np.exp(-t / nickel_decay)
cobalt_heating = cobalt_yield * (np.exp(-t / cobalt_decay) - np.exp(-t / nickel_decay))
return nickel_mass * (nickel_heating + cobalt_heating)
# ================================================= #
# Numerics #
# ================================================= #
_MAX_GRID_CELLS = 2_000_000
"""int: Upper bound on (groups x grid points) held in memory at once; larger batches are processed in chunks."""
@njit(cache=True)
def _diffusion_integral(dx: FloatArray, source: FloatArray) -> FloatArray:
r"""
Evaluate :math:`e^{-u}\int_0^u F\,e^{u'}\,du'` with :math:`u = (t/t_d)^2` on a grid.
Uses the exact-decay recurrence :math:`I_{i+1} = e^{-\Delta u_i} I_i + \bar F_i (1 - e^{-\Delta u_i})`, with
:math:`\bar F_i` the trapezoid mean of the source across the step. Since :math:`e^{-\Delta u} \in (0, 1]`,
it cannot overflow however late the times, unlike evaluating :math:`e^{+u}` directly.
Parameters
----------
dx : numpy.ndarray
Steps :math:`\Delta u_i = (t_{i+1}^2 - t_i^2)/t_d^2`, length ``n - 1``.
source : numpy.ndarray
Source :math:`F` evaluated on the grid, length ``n``.
"""
n = source.size
out = np.empty(n)
out[0] = 0.0
for i in range(n - 1):
out[i + 1] = exp(-dx[i]) * out[i] - 0.5 * (source[i] + source[i + 1]) * expm1(-dx[i])
return out
@njit(cache=True)
def _diffusion_integral_rows(dx: FloatArray, source: FloatArray) -> FloatArray:
"""Apply :func:`_diffusion_integral` to every row of ``dx`` (``(g, n-1)``) and ``source`` (``(g, n)``)."""
out = np.empty(source.shape)
for r in range(source.shape[0]):
out[r] = _diffusion_integral(dx[r], source[r])
return out
@njit(cache=True)
def _interp_rows(grid: FloatArray, values: FloatArray, row: np.ndarray, t: FloatArray) -> FloatArray:
"""Linearly interpolate ``values[row[i]]`` (sampled on ``grid[row[i]]``) at ``t[i]``, for every ``i``."""
n = grid.shape[1]
out = np.empty(t.size)
for i in range(t.size):
r = row[i]
j = np.searchsorted(grid[r], t[i], side="right") - 1
j = min(max(j, 0), n - 2)
x0 = grid[r, j]
x1 = grid[r, j + 1]
if x1 > x0:
w = (t[i] - x0) / (x1 - x0)
out[i] = (1.0 - w) * values[r, j] + w * values[r, j + 1]
else:
out[i] = values[r, j]
return out
def _arnett_luminosity_cgs(
t: FloatArray,
diffusion_time: FloatValue,
leakage: FloatValue,
source: Callable[..., FloatArray],
source_params: dict[str, FloatValue] | None = None,
*,
n_grid: int = 1000,
t_grid: FloatArray | None = None,
t_end: float = 0.0,
min_fraction: float = 1e-4,
) -> FloatArray:
r"""
Bolometric luminosity :math:`L(t)` of a diffusing energy source, for arbitrarily broadcast inputs.
``t``, ``diffusion_time``, ``leakage`` and every value of ``source_params`` are broadcast together against
plain NumPy rules; there is no privileged time axis, so one element may be a single event at one time
(per-event parameters) or one light curve of many times (shared parameters). Elements sharing identical
``(diffusion_time, leakage, source_params)`` are grouped, and each group's diffusion integral is computed
*once* on a grid reaching its latest time, then interpolated to that group's times.
Parameters
----------
t : numpy.ndarray
Time since explosion, in s, ``>= 0``.
diffusion_time : float or numpy.ndarray
Diffusion time :math:`t_d`, in s.
leakage : float or numpy.ndarray
Leakage parameter :math:`A`, in s^2 (``inf`` for no leakage).
source : callable
``source(grid, **params)``: injected power in erg/s on ``grid`` (shape ``(g, m)``, in s), with each
``source_params`` value shaped ``(g, 1)``. Must return shape ``(g, m)``.
source_params : dict, optional
Per-element parameters of ``source``, broadcast against ``t``.
n_grid : int, optional
Points in each of the geometric and linear halves of the automatic grid.
t_grid : numpy.ndarray, optional
Absolute grid (s) shared by every group, replacing the automatic one. It is extended to reach each group's
latest time.
t_end : float, optional
Minimum extent (s) of each group's grid, in place of its latest time.
min_fraction : float, optional
Start of the geometric grid half, as a fraction of the grid extent.
Returns
-------
numpy.ndarray
:math:`L(t)` in erg/s, with the broadcast shape. ``NaN`` in, ``NaN`` out.
"""
source_params = source_params or {}
t, diffusion_time, leakage, *extras = np.broadcast_arrays(
np.asarray(t, dtype=np.float64),
np.asarray(diffusion_time, dtype=np.float64),
np.asarray(leakage, dtype=np.float64),
*(np.asarray(v, dtype=np.float64) for v in source_params.values()),
)
shape = t.shape
t, diffusion_time, leakage = t.ravel(), diffusion_time.ravel(), leakage.ravel()
if t.size == 0:
return np.empty(shape)
keys = np.column_stack([diffusion_time, leakage, *(e.ravel() for e in extras)])
unique_keys, inverse = np.unique(keys, axis=0, return_inverse=True)
inverse = inverse.ravel()
n_groups = unique_keys.shape[0]
latest = np.zeros(n_groups)
np.maximum.at(latest, inverse, np.nan_to_num(t, nan=0.0))
extent = np.maximum(latest, t_end)
if t_grid is None:
fractions = np.unique(
np.concatenate([[0.0], np.geomspace(min_fraction, 1.0, n_grid), np.linspace(0, 1, n_grid)])
)
n_points = fractions.size
else:
n_points = t_grid.size + 1
order = np.argsort(inverse, kind="stable")
sorted_groups = inverse[order]
chunk = max(1, _MAX_GRID_CELLS // n_points)
integral_at_t = np.empty(t.size)
for start in range(0, n_groups, chunk):
stop = min(n_groups, start + chunk)
first, last = np.searchsorted(sorted_groups, [start, stop])
members = order[first:last]
keys_chunk = unique_keys[start:stop]
if t_grid is None:
grid = extent[start:stop, None] * fractions[None, :]
else:
tail = np.maximum(extent[start:stop], t_grid[-1])[:, None]
grid = np.concatenate([np.broadcast_to(t_grid, (stop - start, t_grid.size)), tail], axis=1)
grid = np.ascontiguousarray(grid)
params = {name: keys_chunk[:, 2 + i, None] for i, name in enumerate(source_params)}
power = np.ascontiguousarray(source(grid, **params), dtype=np.float64)
if power.shape != grid.shape:
raise ValueError(f"source returned shape {power.shape}, expected {grid.shape}")
dx = np.ascontiguousarray(np.diff(grid**2, axis=1) / keys_chunk[:, 0, None] ** 2)
integral = _diffusion_integral_rows(dx, power)
integral_at_t[members] = _interp_rows(grid, integral, inverse[members] - start, t[members])
# (1 - exp(-A/t^2)) -> 1 as t -> 0 (or A -> inf); the integral vanishes at t = 0 regardless.
with np.errstate(divide="ignore", invalid="ignore"):
trapped = np.where(t > 0, -np.expm1(-leakage / t**2), 1.0)
return (trapped * integral_at_t).reshape(shape)
# ================================================= #
# Public API #
# ================================================= #
[docs]
def get_magnetar_engine(
spin_period: PhysicalInput,
magnetic_field: PhysicalInput,
ns_mass: PhysicalInput = 1.4 * u.Msun,
) -> Callable[[PhysicalInput], FloatArray]:
r"""
Build a magnetar spin-down energy source for :func:`compute_arnett_luminosity`.
.. math::
F_\mathrm{mag}(t) = \frac{E_\mathrm{mag}}{t_\mathrm{mag}}\frac{1}{(1 + t/t_\mathrm{mag})^2},
with :math:`E_\mathrm{mag} = 2.6\times10^{52}\,(M_\mathrm{NS}/1.4\,M_\odot)^{3/2}(P/1\,\mathrm{ms})^{-2}` erg and
:math:`t_\mathrm{mag} = 1.3\times10^{5}\,(M_\mathrm{NS}/1.4\,M_\odot)^{3/2}(P/1\,\mathrm{ms})^{2}
(B_\perp/10^{14}\,\mathrm{G})^{-2}` s :footcite:p:`2017ApJ...850...55N`.
Parameters
----------
spin_period : ~astropy.units.Quantity or float
Initial spin period :math:`P` (a float is taken to be in s).
magnetic_field : ~astropy.units.Quantity or float
Perpendicular dipole field :math:`B_\perp` (a float is taken to be in G). Quantities may be in ``u.G``,
``u.T`` or the Gaussian-cgs base unit :data:`GAUSS_CGS`.
ns_mass : ~astropy.units.Quantity or float, optional
Neutron star mass (a float is taken to be in g). Default ``1.4 Msun``.
Returns
-------
callable
``engine(t)`` returning the injected power as a bare ``float64`` array in erg/s. ``t`` may be a Quantity
(a float is taken to be in s).
References
----------
.. footbibliography::
"""
period = float(ensure_in_units(spin_period, u.s))
field = float(_magnetic_field_to_gauss(magnetic_field))
mass = float(ensure_in_units(ns_mass, u.g))
if period <= 0 or field <= 0 or mass <= 0:
raise ValueError("spin_period, magnetic_field and ns_mass must be positive")
energy = _magnetar_energy_cgs(period, mass)
timescale = _magnetar_timescale_cgs(period, field, mass)
def _engine(t: PhysicalInput) -> FloatArray:
return _magnetar_luminosity_cgs(ensure_in_units(t, u.s), energy, timescale)
return _engine
[docs]
def get_nickel_engine(
nickel_mass: PhysicalInput,
nickel_yield: PhysicalInput = nickel_decay_yield,
nickel_decay: PhysicalInput = nickel_decay_time,
cobalt_yield: PhysicalInput = cobalt_decay_yield,
cobalt_decay: PhysicalInput = cobalt_decay_time,
) -> Callable[[PhysicalInput], FloatArray]:
r"""Construct a radioactive Ni-56/Co-56 heating engine.
The returned function evaluates the instantaneous radioactive heating
luminosity produced by the decay chain
.. math::
{}^{56}{\rm Ni} \to {}^{56}{\rm Co} \to {}^{56}{\rm Fe}
assuming complete local deposition of the radioactive decay energy.
Gamma-ray leakage is not included.
Parameters
----------
nickel_mass : ~astropy.units.Quantity or float
Initial Ni-56 mass. Must have dimensions of mass.
nickel_yield : ~astropy.units.Quantity or float, optional
Specific heating rate from Ni-56 decay. Must have dimensions of
energy per unit mass per unit time.
nickel_decay : ~astropy.units.Quantity or float, optional
Ni-56 e-folding decay time. Must have dimensions of time.
cobalt_yield : ~astropy.units.Quantity or float, optional
Specific heating rate from Co-56 decay. Must have dimensions of
energy per unit mass per unit time.
cobalt_decay : ~astropy.units.Quantity or float, optional
Co-56 e-folding decay time. Must have dimensions of time.
Returns
-------
Callable[[PhysicalInput], FloatArray]
Function that accepts the time since explosion and returns the
radioactive heating luminosity in erg s^-1.
"""
# Coerce model parameters to CGS units.
nickel_mass_cgs = float(ensure_in_units(nickel_mass, u.g))
nickel_yield_cgs = float(ensure_in_units(nickel_yield, u.erg / (u.g * u.s)))
cobalt_yield_cgs = float(ensure_in_units(cobalt_yield, u.erg / (u.g * u.s)))
nickel_decay_cgs = float(ensure_in_units(nickel_decay, u.s))
cobalt_decay_cgs = float(ensure_in_units(cobalt_decay, u.s))
def _engine(t: PhysicalInput) -> FloatArray:
t_cgs = np.asarray(ensure_in_units(t, u.s), dtype=float)
return _radioactive_luminosity_cgs(
t=t_cgs,
nickel_mass=nickel_mass_cgs,
nickel_yield=nickel_yield_cgs,
nickel_decay=nickel_decay_cgs,
cobalt_yield=cobalt_yield_cgs,
cobalt_decay=cobalt_decay_cgs,
)
return _engine
[docs]
def compute_arnett_luminosity(
t_eval: PhysicalInput,
energy_function: Callable[[FloatArray], FloatArray],
ejecta_mass: PhysicalInput,
ejecta_velocity: PhysicalInput,
kappa: PhysicalInput = 0.2 * u.cm**2 / u.g,
kappa_gamma: PhysicalInput = np.inf,
t_min: PhysicalInput | None = None,
t_max: PhysicalInput | None = None,
t_grid: PhysicalInput | None = None,
n_grid: int = 2000,
) -> u.Quantity:
r"""
Bolometric luminosity of an energy source diffusing through expanding ejecta.
Follows :footcite:t:`2017ApJ...850...55N`, including the high-energy leakage term of
:footcite:t:`2015ApJ...799..107W`.
Parameters
----------
t_eval : ~astropy.units.Quantity or array_like
Times since explosion at which to evaluate :math:`L` (floats are taken to be in s). Must be
non-negative.
energy_function : callable
Injected power :math:`F_\mathrm{in}(t)`. Called as ``energy_function(t)`` with ``t`` a bare 1D CGS array
in s, and must return an array of the same shape in erg/s -- e.g. the callable from
:func:`get_magnetar_engine`.
ejecta_mass : ~astropy.units.Quantity or float
Ejecta mass :math:`M_\mathrm{ej}` (a float is taken to be in g).
ejecta_velocity : ~astropy.units.Quantity or float
Constant ejecta velocity :math:`v_\mathrm{ej}` (a float is taken to be in cm/s).
kappa : ~astropy.units.Quantity or float, optional
Grey optical opacity (a float is taken to be in cm^2/g). Default 0.2, electron scattering in H-free
material.
kappa_gamma : ~astropy.units.Quantity or float, optional
Opacity to high-energy photons (a float is taken to be in cm^2/g). Smaller values let more of the
injected energy leak out un-thermalized at late times. The default ``inf`` means full trapping (no
leakage).
t_min, t_max : ~astropy.units.Quantity or float, optional
Range of the auto-generated grid (geometric + linear). Default to ``1e-4 * t_max`` and ``max(t_eval)``.
Ignored if ``t_grid`` is given.
t_grid : ~astropy.units.Quantity or array_like, optional
Explicit integration grid, replacing the automatic one; it is extended to ``max(t_eval)`` if it stops
short, so cover ``t_eval`` for accuracy.
n_grid : int, optional
Number of points in each of the geometric and linear parts of the automatic grid. The result is
second-order accurate in the grid spacing: the default 2000 gives about 3e-5 relative error.
Returns
-------
~astropy.units.Quantity
Luminosity in erg/s, with the shape of ``t_eval``.
Raises
------
ValueError
If ``t_eval`` or the grid is negative or non-finite, if a physical parameter is not positive, or if
``energy_function`` returns a non-finite or wrongly shaped result.
References
----------
.. footbibliography::
"""
t_eval_cgs = ensure_in_units(t_eval, u.s)
if not np.all(np.isfinite(t_eval_cgs)) or np.any(t_eval_cgs < 0):
raise ValueError("t_eval must be finite and non-negative")
mass = float(ensure_in_units(ejecta_mass, u.g))
velocity = float(ensure_in_units(ejecta_velocity, u.cm / u.s))
kappa_cgs = float(ensure_in_units(kappa, u.cm**2 / u.g))
kappa_gamma_cgs = float(ensure_in_units(kappa_gamma, u.cm**2 / u.g))
if mass <= 0 or velocity <= 0 or kappa_cgs <= 0 or kappa_gamma_cgs < 0:
raise ValueError("ejecta_mass, ejecta_velocity and kappa must be positive, and kappa_gamma non-negative")
grid_cgs = None
if t_grid is not None:
grid_cgs = np.unique(np.concatenate([[0.0], ensure_in_units(t_grid, u.s).ravel()]))
if not np.all(np.isfinite(grid_cgs)) or np.any(grid_cgs < 0):
raise ValueError("t_grid must be finite and non-negative")
t_last = float(t_eval_cgs.max()) if t_eval_cgs.size else 0.0
t_end = t_last if t_max is None else float(ensure_in_units(t_max, u.s))
min_fraction = 1e-4
if t_min is not None and t_end > 0:
min_fraction = float(ensure_in_units(t_min, u.s)) / max(t_end, t_last)
if not 0 < min_fraction < 1:
raise ValueError("t_min must satisfy 0 < t_min < t_max")
def _source(grid: FloatArray) -> FloatArray:
power = np.asarray(energy_function(grid[0]), dtype=np.float64)
if power.shape != grid[0].shape or not np.all(np.isfinite(power)):
raise ValueError("energy_function must return a finite array with the same shape as its input")
return power[np.newaxis, :]
luminosity = _arnett_luminosity_cgs(
t_eval_cgs,
_diffusion_time_cgs(kappa_cgs, mass, velocity),
_leakage_parameter_cgs(kappa_gamma_cgs, mass, velocity),
_source,
n_grid=n_grid,
t_grid=grid_cgs,
t_end=t_end,
min_fraction=min_fraction,
)
return luminosity * (u.erg / u.s)
[docs]
class ArnettMagnetarSpindownSED(SpectralModel):
r"""
Magnetar spin-down powered SED, with the light curve from Arnett-style diffusion.
This model follows the formalism of :footcite:t:`2017ApJ...850...55N` (the MOSFiT magnetar model of Type I
superluminous
supernovae) and the leakage treatment of :footcite:t:`2015ApJ...799..107W`.
A newly born magnetar injects
.. math::
F_\mathrm{mag}(t) = \frac{E_\mathrm{mag}/t_\mathrm{mag}}\frac{1}{(1 + t/t_\mathrm{mag})^{2}}
into homologously expanding, grey-opacity ejecta, which diffuses out and leaks high-energy photons according to
:func:`~uvex_transients.models.arnett.compute_arnett_luminosity`.
The temperature is computed using a floored photospheric temperature model:
.. math::
T(t) = \max\left\{\left[\frac{L(t)}{4\pi\sigma_\mathrm{SB}(v_\mathrm{ej}t)^2}\right]^{1/4},
T_\mathrm{floor}\right\}, \qquad
L_\nu(\nu, t) = L(t)\,\frac{\pi B_\nu(\nu, T(t))}{\sigma_\mathrm{SB}T(t)^4}.
The light curve :math:`L(t)` is integrated once per evaluation and reused for both the temperature and the
spectral luminosity.
.. rubric:: Parameters
.. list-table::
:header-rows: 1
:widths: 18 18 64
* - Parameter
- Symbol
- Description
* - ``spin_period``
- :math:`P`
- Initial magnetar spin period.
* - ``B_perp``
- :math:`B_\perp`
- Perpendicular component of the dipole field, in Gaussian-cgs gauss
(:data:`~uvex_transients.models.arnett.GAUSS_CGS`).
* - ``M_ej``
- :math:`M_\mathrm{ej}`
- Ejecta mass.
* - ``v_ej``
- :math:`v_\mathrm{ej}`
- Ejecta velocity, taken to be constant and equal to the photospheric velocity.
* - ``M_ns``
- :math:`M_\mathrm{NS}`
- Neutron star mass.
* - ``kappa``
- :math:`\kappa`
- Grey optical opacity.
* - ``kappa_gamma``
- :math:`\kappa_\gamma`
- Opacity to high-energy photons; sets how much of the late-time input energy leaks out.
* - ``T_floor``
- :math:`T_\mathrm{floor}`
- Minimum photospheric temperature.
References
----------
.. footbibliography::
"""
_N_GRID: ClassVar[int] = 1000
"""int: Grid points in each half of the diffusion-integral grid; the error falls as its inverse square."""
_DEFAULT_PARAMETERS: ClassVar[dict[str, Parameter]] = {
"spin_period": Parameter(
prior=TruncatedNormalPrior(mean=np.log10(3.0), sigma=0.26 * 0.4, lower=np.log10(0.7), upper=np.log10(20)),
scale=1 * u.ms,
transform="log10",
description="Initial magnetar spin period.",
latex=r"P",
),
"B_perp": Parameter(
prior=TruncatedNormalPrior(mean=np.log10(0.8), sigma=0.48 * 0.4, lower=np.log10(0.01), upper=np.log10(10)),
scale=1e14 * GAUSS_CGS,
transform="log10",
description="Perpendicular component of the magnetar dipole field.",
latex=r"B_\perp",
),
"M_ej": Parameter(
prior=TruncatedNormalPrior(mean=np.log10(4.8), sigma=0.38 * 0.4, lower=np.log10(0.1), upper=np.log10(100)),
scale=1 * u.Msun,
transform="log10",
description="Ejecta mass.",
latex=r"M_\mathrm{ej}",
),
"v_ej": Parameter(
prior=TruncatedNormalPrior(mean=0.9, sigma=0.3, lower=0.1, upper=3.0),
scale=1e4 * u.km / u.s,
description="Constant ejecta velocity, identified with the photospheric velocity.",
latex=r"v_\mathrm{ej}",
),
"M_ns": Parameter(
prior=UniformPrior(lower=1.4, upper=2.2),
scale=1 * u.Msun,
description="Neutron star mass.",
latex=r"M_\mathrm{NS}",
),
"kappa": Parameter(
prior=UniformPrior(lower=0.05, upper=0.2),
scale=1 * u.cm**2 / u.g,
description="Grey optical opacity.",
latex=r"\kappa",
),
"kappa_gamma": Parameter(
prior=UniformPrior(lower=-2.0, upper=0.0),
scale=1 * u.cm**2 / u.g,
transform="log10",
description="Opacity to high-energy photons.",
latex=r"\kappa_\gamma",
),
"T_floor": Parameter(
prior=TruncatedNormalPrior(mean=6000.0, sigma=1000.0, lower=3000.0, upper=10000.0),
scale=1 * u.K,
description="Minimum photospheric temperature.",
latex=r"T_\mathrm{floor}",
),
}
# -------------------------------------- #
# L(t) and T(t), from one integral #
# -------------------------------------- #
@classmethod
def _luminosity_and_temperature_cgs(
cls,
t: FloatArray,
*,
spin_period: CGSParameterValue,
B_perp: CGSParameterValue,
M_ej: CGSParameterValue,
v_ej: CGSParameterValue,
M_ns: CGSParameterValue,
kappa: CGSParameterValue,
kappa_gamma: CGSParameterValue,
T_floor: CGSParameterValue,
) -> tuple[FloatArray, FloatArray]:
r"""
:math:`L(t)` in erg/s and :math:`T(t)` in K, from a single diffusion integral.
The luminosity is the only expensive step; the temperature is derived from that same array.
Parameters
----------
t : numpy.ndarray
Time since explosion, in seconds.
spin_period, B_perp, M_ej, v_ej, M_ns, kappa, kappa_gamma, T_floor : float or numpy.ndarray
This model's parameter values, in cgs units; see the class docstring.
Returns
-------
tuple of (numpy.ndarray, numpy.ndarray)
:math:`L(t)`, in erg/s, and :math:`T(t)`, in Kelvin.
"""
t = np.asarray(t, dtype=np.float64)
luminosity = _arnett_luminosity_cgs(
t,
_diffusion_time_cgs(kappa, M_ej, v_ej),
_leakage_parameter_cgs(kappa_gamma, M_ej, v_ej),
_magnetar_luminosity_cgs,
{
"energy": _magnetar_energy_cgs(spin_period, M_ns),
"timescale": _magnetar_timescale_cgs(spin_period, B_perp, M_ns),
},
n_grid=cls._N_GRID,
)
# L vanishes at t = 0, where the photosphere has no radius: T is 0/0 there, and irrelevant since L_nu = 0.
positive = t > 0
temperature = photospheric_temperature_with_floor_cgs(
np.where(positive, t, 1.0), L=luminosity, v_phot=v_ej, T_floor=T_floor
)
return luminosity, np.where(positive, temperature, T_floor)
[docs]
@classmethod
def temperature(cls, t: u.Quantity, **parameters: u.Quantity) -> 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 = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls._luminosity_and_temperature_cgs(t.cgs.value, **cgs_parameters)[1] * u.K
# -------------------------------------- #
# Bolometric Luminosity: L_bol(t) #
# -------------------------------------- #
@classmethod
def _eval_bolometric(cls, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray:
r"""
:math:`\log L_\mathrm{bol}(t)`, the diffusion-integral luminosity.
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.
"""
luminosity, _ = cls._luminosity_and_temperature_cgs(t, **parameters)
with np.errstate(divide="ignore"):
return np.log(luminosity)
# -------------------------------------- #
# 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))`, a blackbody at the floored photospheric 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._luminosity_and_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(t) + \log S(\nu, T(t))`, with :math:`L` integrated only once.
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.
"""
luminosity, temperature = cls._luminosity_and_temperature_cgs(t, **parameters)
with np.errstate(divide="ignore"):
log_luminosity = np.log(luminosity)
return log_luminosity + BlackbodySpectrum._eval(nu, temperature=temperature)
[docs]
class ArnettDecaySED(SpectralModel):
r"""
Radioactively-powered (Ni-56/Co-56 decay) SED, with the light curve from Arnett-style diffusion.
This is the original Arnett (1982) formalism: a fixed mass of Ni-56 synthesized in the explosion decays
through the chain Ni-56 -> Co-56 -> Fe-56, injecting
.. math::
F_\mathrm{decay}(t) = M_\mathrm{Ni}\left[\epsilon_\mathrm{Ni}\,e^{-t/\tau_\mathrm{Ni}}
+ \epsilon_\mathrm{Co}\left(e^{-t/\tau_\mathrm{Co}} - e^{-t/\tau_\mathrm{Ni}}\right)\right]
into homologously expanding, grey-opacity ejecta, which diffuses out and leaks high-energy photons according to
:func:`~uvex_transients.models.arnett.compute_arnett_luminosity`.
The temperature is computed using a floored photospheric temperature model:
.. math::
T(t) = \max\left\{\left[\frac{L(t)}{4\pi\sigma_\mathrm{SB}(v_\mathrm{ej}t)^2}\right]^{1/4},
T_\mathrm{floor}\right\}, \qquad
L_\nu(\nu, t) = L(t)\,\frac{\pi B_\nu(\nu, T(t))}{\sigma_\mathrm{SB}T(t)^4}.
The light curve :math:`L(t)` is integrated once per evaluation and reused for both the temperature and the
spectral luminosity.
.. rubric:: Parameters
.. list-table::
:header-rows: 1
:widths: 18 18 64
* - Parameter
- Symbol
- Description
* - ``M_Ni``
- :math:`M_\mathrm{Ni}`
- Nickel-56 mass synthesized in the explosion.
* - ``M_ej``
- :math:`M_\mathrm{ej}`
- Ejecta mass.
* - ``v_ej``
- :math:`v_\mathrm{ej}`
- Ejecta velocity, taken to be constant and equal to the photospheric velocity.
* - ``kappa``
- :math:`\kappa`
- Grey optical opacity.
* - ``kappa_gamma``
- :math:`\kappa_\gamma`
- Opacity to high-energy photons; sets how much of the late-time input energy leaks out.
* - ``T_floor``
- :math:`T_\mathrm{floor}`
- Minimum photospheric temperature.
References
----------
.. footbibliography::
"""
_N_GRID: ClassVar[int] = 1000
"""int: Grid points in each half of the diffusion-integral grid; the error falls as its inverse square."""
_DEFAULT_PARAMETERS: ClassVar[dict[str, Parameter]] = {
"M_Ni": Parameter(
prior=TruncatedNormalPrior(mean=np.log10(4.8), sigma=0.38 * 0.4, lower=np.log10(0.1), upper=np.log10(100)),
scale=1 * u.Msun,
transform="log10",
description="Nickel-56 mass synthesized in the explosion.",
latex=r"M_\mathrm{Ni}",
),
"M_ej": Parameter(
prior=TruncatedNormalPrior(mean=np.log10(4.8), sigma=0.38 * 0.4, lower=np.log10(0.1), upper=np.log10(100)),
scale=1 * u.Msun,
transform="log10",
description="Ejecta mass.",
latex=r"M_\mathrm{ej}",
),
"v_ej": Parameter(
prior=TruncatedNormalPrior(mean=0.9, sigma=0.3, lower=0.1, upper=3.0),
scale=1e4 * u.km / u.s,
description="Constant ejecta velocity, identified with the photospheric velocity.",
latex=r"v_\mathrm{ej}",
),
"kappa": Parameter(
prior=UniformPrior(lower=0.05, upper=0.2),
scale=1 * u.cm**2 / u.g,
description="Grey optical opacity.",
latex=r"\kappa",
),
"kappa_gamma": Parameter(
prior=UniformPrior(lower=-2.0, upper=0.0),
scale=1 * u.cm**2 / u.g,
transform="log10",
description="Opacity to high-energy photons.",
latex=r"\kappa_\gamma",
),
"T_floor": Parameter(
prior=TruncatedNormalPrior(mean=6000.0, sigma=1000.0, lower=3000.0, upper=10000.0),
scale=1 * u.K,
description="Minimum photospheric temperature.",
latex=r"T_\mathrm{floor}",
),
}
# -------------------------------------- #
# L(t) and T(t), from one integral #
# -------------------------------------- #
@classmethod
def _luminosity_and_temperature_cgs(
cls,
t: FloatArray,
*,
M_Ni: CGSParameterValue,
M_ej: CGSParameterValue,
v_ej: CGSParameterValue,
kappa: CGSParameterValue,
kappa_gamma: CGSParameterValue,
T_floor: CGSParameterValue,
) -> tuple[FloatArray, FloatArray]:
r"""
:math:`L(t)` in erg/s and :math:`T(t)` in K, from a single diffusion integral.
The luminosity is the only expensive step; the temperature is derived from that same array.
Parameters
----------
t : numpy.ndarray
Time since explosion, in seconds.
M_Ni, M_ej, v_ej, kappa, kappa_gamma, T_floor : float or numpy.ndarray
This model's parameter values, in cgs units; see the class docstring.
Returns
-------
tuple of (numpy.ndarray, numpy.ndarray)
:math:`L(t)`, in erg/s, and :math:`T(t)`, in Kelvin.
"""
t = np.asarray(t, dtype=np.float64)
luminosity = _arnett_luminosity_cgs(
t,
_diffusion_time_cgs(kappa, M_ej, v_ej),
_leakage_parameter_cgs(kappa_gamma, M_ej, v_ej),
_radioactive_luminosity_cgs,
{
"nickel_mass": M_Ni,
},
n_grid=cls._N_GRID,
)
# L vanishes at t = 0, where the photosphere has no radius: T is 0/0 there, and irrelevant since L_nu = 0.
positive = t > 0
temperature = photospheric_temperature_with_floor_cgs(
np.where(positive, t, 1.0), L=luminosity, v_phot=v_ej, T_floor=T_floor
)
return luminosity, np.where(positive, temperature, T_floor)
[docs]
@classmethod
def temperature(cls, t: u.Quantity, **parameters: u.Quantity) -> 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 = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls._luminosity_and_temperature_cgs(t.cgs.value, **cgs_parameters)[1] * u.K
# -------------------------------------- #
# Bolometric Luminosity: L_bol(t) #
# -------------------------------------- #
@classmethod
def _eval_bolometric(cls, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray:
r"""
:math:`\log L_\mathrm{bol}(t)`, the diffusion-integral luminosity.
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.
"""
luminosity, _ = cls._luminosity_and_temperature_cgs(t, **parameters)
with np.errstate(divide="ignore"):
return np.log(luminosity)
# -------------------------------------- #
# 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))`, a blackbody at the floored photospheric 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._luminosity_and_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(t) + \log S(\nu, T(t))`, with :math:`L` integrated only once.
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.
"""
luminosity, temperature = cls._luminosity_and_temperature_cgs(t, **parameters)
with np.errstate(divide="ignore"):
log_luminosity = np.log(luminosity)
return log_luminosity + BlackbodySpectrum._eval(nu, temperature=temperature)