r"""
Base class for time- and frequency-dependent spectral models.
A :class:`SpectralModel` describes how a source's spectral luminosity,
:math:`L_\nu(\nu, t)`, evolves with observer-frame frequency :math:`\nu` and
time since explosion :math:`t`. From that single quantity, this base class
derives everything else a user typically needs to turn a physical model into
something observable: a bolometric light curve, a normalized spectral shape,
a redshifted and distance-diluted flux, a throughput-weighted band flux, AB
magnitudes, and a :class:`~synphot.SourceSpectrum` ready to feed into a
detector simulation.
To define a new model, subclass :class:`SpectralModel` and implement
:meth:`~SpectralModel._eval`, the natural-log spectral luminosity in cgs
units.
Every other quantity (fluxes, magnitudes, band-integrated fluxes, bolometric
luminosity) is derived automatically. All of these public methods come in a
consistent family of four, distinguished by suffix:
.. list-table::
:header-rows: 1
* - Suffix
- Inputs / outputs
- Example
* - ``_log_cgs``
- unit-free numbers, natural log
- :meth:`SpectralModel.eval_log_cgs`
* - ``_log``
- physical :class:`~astropy.units.Quantity`, natural log
- :meth:`SpectralModel.eval_log`
* - ``_cgs``
- unit-free numbers, linear scale
- :meth:`SpectralModel.eval_cgs`
* - (none)
- physical ``Quantity``, linear scale
- :meth:`SpectralModel.eval`
:class:`Lightcurve` and :class:`Spectrum` are the time-only and
frequency-only halves of the same idea: a :class:`Lightcurve` is just
:math:`L_\mathrm{bol}(t)`, and a :class:`Spectrum` is just a shape
:math:`S(\nu)` (not necessarily normalized to 1). :class:`ComposedSpectralModel`
combines one of each into a full :class:`SpectralModel`, with
:math:`L_\nu(\nu, t) = L_\mathrm{bol}(t) \cdot S(\nu) / \int S(\nu')\,d\nu'`.
"""
from abc import ABC, abstractmethod
from collections.abc import Iterator, Mapping
from copy import copy, deepcopy
from dataclasses import replace
from typing import ClassVar, Self
import numpy as np
from astropy import units as u
from astropy.coordinates import EarthLocation, SkyCoord
from astropy.cosmology import FLRW
from astropy.modeling import Model
from astropy.table import QTable, vstack
from astropy.time import Time
from astropy.units import Quantity
from m4opt.synphot import Detector, observing
from scipy.integrate import quad_vec
from synphot import SourceSpectrum, SpectralElement
from synphot import units as synphot_units
from uvex_transients.dust import attenuation_callable
from uvex_transients.utils import config, get_rng, logger
from uvex_transients.utils.cosmology import resolve_cosmological_distances
from .._constants import AB_MAG_ZERO_POINT, H_CGS
from .._typing import (
CGSParameterValue,
FloatArray,
FloatResult,
NumericalInput,
OverrideValue,
ParameterSamples,
ParameterValue,
PhysicalInput,
RNGInput,
)
from .._utils import (
_BOL_FLUX_UNIT,
_BOL_LUM_UNIT,
_SED_SHAPE_UNIT,
_SPEC_FLUX_UNIT,
_SPEC_LUM_UNIT,
hz_per_unit,
model_class_from_kernel,
to_cgs_value,
)
from .parameters import Parameter
__all__ = ["ComposedSpectralModel", "Lightcurve", "SpectralModel", "Spectrum"]
# `SpectralModel.simulate_photometry`'s defaults for `observer_location`/`obstime` when
# the caller doesn't supply real ones -- correct as long as `background` doesn't
# actually depend on either (true of `m4opt.synphot.background.GalacticBackground`,
# false of `ZodiacalBackground`/`EarthshineBackground`; see that method's docstring).
# `m4opt.synphot.observing`'s own state only requires these to be broadcastable
# against `coord`, not physically meaningful.
_PLACEHOLDER_OBSERVER_LOCATION = EarthLocation(0 * u.m, 0 * u.m, 0 * u.m)
_PLACEHOLDER_OBSTIME = Time("2000-01-01T00:00:00", scale="utc")
# Loose enough that it only fires on genuine `quad_vec` non-convergence (its own default
# `epsrel` is 1e-8), not routine floating-point noise -- see `_warn_if_not_converged`.
_QUAD_VEC_WARN_RTOL = 1e-4
def _warn_if_not_converged(context: str, integral: FloatArray, err: float) -> None:
"""
Log a warning if a `scipy.integrate.quad_vec` result's reported error is suspiciously large.
`quad_vec` returns its integral and a scalar error-norm estimate but never raises on
poor convergence, so silent non-convergence in a normalization/bolometric integral
would otherwise be invisible until it shows up as a subtly wrong downstream flux.
Parameters
----------
context : str
Short label identifying the caller, used to prefix the warning message.
integral : numpy.ndarray
The `quad_vec` result being checked.
err : float
`quad_vec`'s own scalar error-norm estimate for `integral`.
"""
scale = max(float(np.max(np.abs(integral))), np.finfo(float).tiny)
if err > _QUAD_VEC_WARN_RTOL * scale:
logger.warning(
"%s: quad_vec integral may not have converged (error estimate %.3g, relative to integral magnitude %.3g).",
context,
err,
scale,
)
class _ModelBase(Mapping[str, Parameter], ABC):
r"""
Shared parameter storage, construction, and bookkeeping.
Common base for :class:`SpectralModel`, :class:`Lightcurve`, and
:class:`Spectrum`. Houses everything that doesn't depend on what a
subclass physically computes: parameter declaration and validation
(:attr:`_DEFAULT_PARAMETERS`, :attr:`_DOMAIN`), construction and
copying, the :class:`~collections.abc.Mapping` interface, parameter
packing, and parameter sampling. A subclass supplies only the physics --
its own ``_eval``-style abstract method(s) mapping numerical inputs to a
natural-log cgs result, the public ``eval*`` family that wraps them (see
the module docstring for that four-way convention), and
``simulate``.
Not part of the public API; use :class:`SpectralModel`,
:class:`Lightcurve`, or :class:`Spectrum` instead.
Parameters
----------
**overrides : Parameter, ~astropy.units.Quantity, float, or int
Per-parameter overrides. See :meth:`__init__`.
"""
# -------------------------------------- #
# Class-Level Parameters #
# -------------------------------------- #
_DEFAULT_PARAMETERS: ClassVar[dict[str, Parameter]] = {}
"""dict of Parameter: This model's parameters, and their default order.
Deep-copied into each instance's own parameter set upon construction, so
mutating one instance's parameters never affects the class default or any
other instance.
"""
_DOMAIN: ClassVar[tuple[Quantity, Quantity] | None] = None
"""tuple of Quantity, or None: The ``(low, high)`` frequency range a subclass integrates over, if any.
``None`` (the default) means this subclass has no frequency-domain
integral to bound, and :meth:`__init_subclass__` skips the associated
validation entirely -- true of :class:`Lightcurve`, which has no notion
of frequency at all. :class:`SpectralModel` and :class:`Spectrum`
override this with an actual ``(low, high)`` Quantity pair; see their
own ``_DOMAIN`` documentation for what it bounds.
"""
# -------------------------------------- #
# Subclass Validation #
# -------------------------------------- #
def __init_subclass__(cls, **kwargs) -> None:
"""
Validate a subclass's :attr:`_DEFAULT_PARAMETERS` and, if set, :attr:`_DOMAIN`.
Parameters
----------
**kwargs
Forwarded to :meth:`object.__init_subclass__` unchanged; this
class declares no class-keyword-argument options of its own.
"""
super().__init_subclass__(**kwargs)
for name, parameter in cls._DEFAULT_PARAMETERS.items():
if not isinstance(parameter, Parameter):
raise TypeError(
f"{cls.__name__}._DEFAULT_PARAMETERS[{name!r}] must be a Parameter "
f"instance, got {type(parameter).__name__}."
)
if cls._DOMAIN is None:
return
low, high = cls._DOMAIN
if not (isinstance(low, Quantity) and isinstance(high, Quantity)):
raise TypeError(f"{cls.__name__}._DOMAIN must be a (low, high) pair of Quantity objects.")
if low.unit.physical_type != "frequency" or high.unit.physical_type != "frequency":
raise TypeError(f"{cls.__name__}._DOMAIN must be expressed in frequency units.")
if high <= low:
raise ValueError(f"{cls.__name__}._DOMAIN must satisfy high > low, got ({low}, {high}).")
# -------------------------------------- #
# Construction and Copying #
# -------------------------------------- #
def _init_parameters(self, overrides: Mapping[str, OverrideValue]) -> None:
"""
Deep-copy :attr:`_DEFAULT_PARAMETERS` and apply constructor ``overrides`` on top.
Parameters
----------
overrides : mapping of str to (Parameter, Quantity, float, or int)
Per-parameter overrides, keyed by parameter name. See :meth:`__init__`.
"""
self._parameters = deepcopy(self._DEFAULT_PARAMETERS)
unknown = [name for name in overrides if name not in self._parameters]
if unknown:
raise KeyError(
f"{self.__class__.__name__} has no parameter(s) named {unknown}. "
f"Valid parameters are {tuple(self._parameters)}."
)
for name, value in overrides.items():
if isinstance(value, Parameter):
self._parameters[name] = value
else:
self._parameters[name].fix(value)
def __init__(self, **overrides: OverrideValue) -> None:
"""
Create a model instance, optionally overriding some of its default parameters.
Parameters
----------
**overrides
Per-parameter overrides, keyed by parameter name (must match one
of this model's parameter names). Passing a plain value (a
:class:`~astropy.units.Quantity`, ``float``, or ``int``) fixes
that parameter to it; passing a
:class:`~uvex_transients.models.core.parameters.Parameter` replaces the
default parameter entirely (e.g. to use a different prior). In
the latter case, the *same* ``Parameter`` instance is stored
(not copied) -- passing one object to two model instances links
them, so that fixing or sampling the parameter through either
model affects both.
Raises
------
KeyError
If ``overrides`` names a parameter this model doesn't have.
"""
super().__init__()
self._init_parameters(overrides)
def __copy__(self) -> Self:
"""
Return a shallow copy: a new instance sharing this one's `Parameter` objects.
Returns
-------
instance of this class
A new instance whose `_parameters` dict is a fresh mapping, but
whose individual `Parameter` values are the *same* objects as
`self`'s -- fixing or sampling a shared parameter through either
instance affects both.
"""
new = self.__class__.__new__(self.__class__)
new._parameters = copy(self._parameters)
return new
def __deepcopy__(self, memo: dict) -> Self:
"""
Return a deep copy: a new instance with its own independent `Parameter` objects.
Parameters
----------
memo : dict
The `copy.deepcopy` memo dict, used to preserve shared/cyclic
references and avoid copying the same object twice.
Returns
-------
instance of this class
A new instance with an independently deep-copied `_parameters` dict.
"""
if id(self) in memo:
return memo[id(self)]
new = self.__class__.__new__(self.__class__)
new._parameters = deepcopy(self._parameters, memo)
memo[id(self)] = new
return new
# -------------------------------------- #
# Mapping Interface #
# -------------------------------------- #
def __len__(self) -> int:
"""
Return the number of parameters this model has.
Returns
-------
int
``len(self._parameters)``.
"""
return len(self._parameters)
def __iter__(self) -> Iterator[str]:
"""
Iterate over this model's parameter names, in declaration order.
Returns
-------
Iterator of str
An iterator over `_parameters`' keys.
"""
return iter(self._parameters)
def __getitem__(self, key: str) -> Parameter:
"""
Look up one of this model's `Parameter` objects by name.
Parameters
----------
key : str
The parameter name.
Returns
-------
Parameter
The named parameter.
"""
return self._parameters[key]
def __setitem__(self, key: str, value: Parameter) -> None:
"""
Refuse item assignment; parameters may only be modified in place.
Parameters
----------
key : str
The parameter name (unused; every call raises).
value : Parameter
The value that would have been assigned (unused; every call raises).
Raises
------
TypeError
Always -- model parameters cannot be replaced this way.
"""
raise TypeError("Model parameters cannot be replaced directly. Modify the existing parameter instead.")
def __delitem__(self, key: str) -> None:
"""
Refuse item deletion; a model's parameter set is fixed at construction.
Parameters
----------
key : str
The parameter name that would have been deleted (unused; every call raises).
Raises
------
TypeError
Always -- model parameters cannot be deleted.
"""
raise TypeError("Model parameters cannot be deleted.")
def __repr__(self) -> str:
"""
Return a multi-line representation listing every parameter's fixed value or prior.
Returns
-------
str
One line per parameter, showing either its fixed value
(``name: fixed=...``) or its prior (``name: free, prior=...``).
"""
rows = []
for name, parameter in self._parameters.items():
if parameter.is_fixed:
rows.append(f" {name}: fixed={parameter.fixed_value!r}")
else:
rows.append(f" {name}: free, prior={parameter.prior.name}")
return f"{self.__class__.__name__}(\n" + "\n".join(rows) + "\n)"
# -------------------------------------- #
# Parameter Packing #
# -------------------------------------- #
@classmethod
def pack_params_to_arrays(cls, **parameters: ParameterValue) -> tuple[ParameterValue, ...]:
"""
Convert a dict of parameter values into an ordered sequence.
The output is a plain tuple of the input values, reordered to match
this model's parameter order -- not stacked into a single array
(parameters may carry different, incompatible units).
Parameters
----------
**parameters
Parameter values, keyed by name. Must supply a value for every
one of this model's parameters.
Returns
-------
tuple
The values from ``parameters``, in this model's parameter order.
Raises
------
KeyError
If ``parameters`` is missing a value for one or more of this
model's parameters.
See Also
--------
unpack_params_from_arrays : The inverse conversion.
"""
missing = [name for name in cls._DEFAULT_PARAMETERS if name not in parameters]
if missing:
raise KeyError(f"Missing value(s) for parameter(s) {missing} required by {cls.__name__}.")
return tuple(parameters[name] for name in cls._DEFAULT_PARAMETERS)
@classmethod
def unpack_params_from_arrays(cls, *parameters: ParameterValue) -> dict[str, ParameterValue]:
"""
Convert an ordered sequence of parameter values back into a dict.
The inverse of :meth:`pack_params_to_arrays`.
Parameters
----------
*parameters
Parameter values, one per parameter of this model, in this
model's parameter order.
Returns
-------
dict
``{name: value}`` for each of this model's parameters.
Raises
------
ValueError
If the number of positional values doesn't match the number of
parameters.
See Also
--------
pack_params_to_arrays : The inverse conversion.
"""
names = tuple(cls._DEFAULT_PARAMETERS)
if len(parameters) != len(names):
raise ValueError(
f"{cls.__name__} expected {len(names)} positional parameter value(s) {names}, got {len(parameters)}."
)
return dict(zip(names, parameters))
# -------------------------------------- #
# Parameter Sampling #
# -------------------------------------- #
def sample_parameters(
self,
size: int = 1,
*,
rng: RNGInput = None,
parameters: list[str] | None = None,
) -> ParameterSamples:
"""
Draw random samples of some or all of this model's parameters.
Parameters
----------
size : int
Number of samples to draw per parameter.
rng : numpy.random.Generator, int, or None
Random-number source, forwarded to each
:meth:`~uvex_transients.models.core.parameters.Parameter.sample`. Passing
a shared :class:`~numpy.random.Generator` is recommended so that
every parameter's draws come from the same reproducible stream.
parameters : list of str, optional
Names of the parameters to sample. If ``None`` (the default),
every parameter is sampled.
Returns
-------
dict
``{name: samples}`` for each requested parameter, each with
shape ``(size,)``.
"""
if parameters is None:
parameters = list(self._parameters.keys())
return {
parameter_name: parameter.sample(size=size, rng=rng)
for parameter_name, parameter in self._parameters.items()
if parameter_name in parameters
}
[docs]
class Lightcurve(_ModelBase):
r"""
Abstract base class for time-dependent bolometric luminosity models.
A :class:`Lightcurve` describes how a source's bolometric
(frequency-integrated) luminosity, :math:`L_\mathrm{bol}(t)`, evolves
with time since explosion :math:`t`. It shares :class:`SpectralModel`'s
parameter storage, evaluation-family conventions, and sampling
machinery, but carries none of :class:`SpectralModel`'s :math:`\nu`-dependent or
observed-frame machinery (spectral shape, flux, band flux, magnitudes).
To define a new model, subclass :class:`Lightcurve` and implement
:meth:`_eval`, the natural log of :math:`L_\mathrm{bol}(t)` in cgs
units. Every other quantity (the ``*_log_cgs``/``*_log``/``*_cgs``/plain
family described in the module docstring) is derived automatically.
Pairing a :class:`Lightcurve` with a :class:`Spectrum` via
:class:`ComposedSpectralModel` turns it into a full :math:`L_\nu(\nu,
t)` :class:`SpectralModel`.
See Also
--------
Spectrum : The frequency-only counterpart this mirrors.
ComposedSpectralModel : Combines a Lightcurve and a Spectrum into a full SED.
"""
# -------------------------------------- #
# Bolometric Luminosity: L_bol(t) #
# -------------------------------------- #
@classmethod
@abstractmethod
def _eval(cls, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray:
r"""
Evaluate the natural log of :math:`L_\mathrm{bol}(t)`, in cgs units.
This is the one method every model must implement. ``t`` and every
parameter value are combined using plain NumPy broadcasting -- no
axes are inserted automatically.
Parameters
----------
t : numpy.ndarray
Time since explosion, in seconds. Always non-negative.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``t``.
Returns
-------
numpy.ndarray
The natural log of :math:`L_\mathrm{bol}(t)`, in erg/s.
"""
...
[docs]
@classmethod
def eval_log_cgs(cls, t: NumericalInput, **parameters: CGSParameterValue) -> FloatResult:
r"""
Natural log of the bolometric luminosity, taking and returning plain cgs numbers.
Parameters
----------
t : array-like
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units. To evaluate several
parameter realizations at once, give each parameter a leading
batch axis.
Returns
-------
numpy.ndarray or float
The natural log of :math:`L_\mathrm{bol}(t)`, in erg/s. A scalar
is returned if the result is 0-dimensional.
"""
t_arr = np.asarray(t, dtype=np.float64)
cgs_parameters: dict[str, CGSParameterValue] = {
name: np.asarray(value, dtype=np.float64) for name, value in parameters.items()
}
result = cls._eval(t_arr, **cgs_parameters)
return result.item() if result.ndim == 0 else result
[docs]
@classmethod
def eval_log(cls, t: Quantity, **parameters: ParameterValue) -> FloatResult:
r"""
Natural log of the bolometric luminosity, given physical-unit inputs.
Parameters
----------
t : ~astropy.units.Quantity
Time since explosion, with time units.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The natural log of :math:`L_\mathrm{bol}(t)`, in erg/s.
Raises
------
TypeError
If ``t`` is not a Quantity with time units.
"""
if not isinstance(t, Quantity) or t.unit.physical_type != "time":
raise TypeError("`t` must be an astropy Quantity with time units.")
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls.eval_log_cgs(t.cgs.value, **cgs_parameters)
[docs]
@classmethod
def eval_cgs(cls, t: NumericalInput, **parameters: CGSParameterValue) -> FloatResult:
r"""
Bolometric luminosity, taking and returning plain cgs numbers.
Parameters
----------
t : array-like
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
:math:`L_\mathrm{bol}(t)`, in erg/s. A scalar is returned if the
result is 0-dimensional.
"""
return np.exp(cls.eval_log_cgs(t, **parameters))
[docs]
@classmethod
def eval(cls, t: Quantity, **parameters: ParameterValue) -> Quantity:
r"""
Evaluate the bolometric luminosity at the given time.
Parameters
----------
t : ~astropy.units.Quantity
Time since explosion.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
:math:`L_\mathrm{bol}(t)`, in erg/s.
"""
return np.exp(cls.eval_log(t, **parameters)) * _BOL_LUM_UNIT
[docs]
@classmethod
def eval_from_arrays(cls, t: Quantity, *parameters: ParameterValue) -> Quantity:
r"""
Positional-argument form of :meth:`eval`.
Equivalent to ``cls.eval(t, **cls.unpack_params_from_arrays(*parameters))``.
Parameters
----------
t : ~astropy.units.Quantity
Time since explosion.
*parameters
This model's parameter values, one per parameter, in this
model's parameter order. See :meth:`unpack_params_from_arrays`.
Returns
-------
~astropy.units.Quantity
:math:`L_\mathrm{bol}(t)`, in erg/s.
See Also
--------
pack_params_to_arrays : The inverse conversion, dict -> ordered sequence.
unpack_params_from_arrays : Ordered sequence -> dict, used internally here.
"""
return cls.eval(t, **cls.unpack_params_from_arrays(*parameters))
# -------------------------------------- #
# Simulation #
# -------------------------------------- #
[docs]
def simulate(self, t: Quantity, size: int = 1, *, rng: RNGInput = None) -> Quantity:
r"""
Draw random parameter realizations and evaluate the model at the given time.
Equivalent to ``self.eval(t, **self.sample_parameters(size=size, rng=rng))``.
Parameters
----------
t : ~astropy.units.Quantity
Time since explosion. See :meth:`eval`.
size : int
Number of realizations to draw.
rng : numpy.random.Generator, int, or None
Random-number source, forwarded to :meth:`sample_parameters`.
Returns
-------
~astropy.units.Quantity
:math:`L_\mathrm{bol}(t)`, in erg/s.
See Also
--------
eval : The underlying evaluation.
sample_parameters : The underlying sampling.
"""
return self.eval(t, **self.sample_parameters(size=size, rng=rng))
[docs]
class Spectrum(_ModelBase):
r"""
Abstract base class for frequency-dependent spectral shape models.
A :class:`Spectrum` describes how a source's light is distributed across
frequency, as a *shape* :math:`S(\nu)`. It
shares :class:`SpectralModel`'s parameter storage, evaluation-family
conventions, and sampling machinery, but -- having no notion of time,
redshift, or distance -- carries none of :class:`SpectralModel`'s
:math:`t`-dependent or observed-frame machinery (bolometric luminosity,
flux, band flux, magnitudes).
To define a new model, subclass :class:`Spectrum` and implement
:meth:`_eval`, the natural log of :math:`S(\nu)` in cgs units. Unlike
:class:`SpectralModel`'s spectral shape, :math:`S(\nu)` need not
integrate to any particular value by construction --
:meth:`eval_normalization` computes whatever :math:`\int S(\nu)\,d\nu`
actually is, which is exactly the factor :class:`ComposedSpectralModel`
divides out to combine a :class:`Spectrum` with a :class:`Lightcurve`'s
:math:`L_\mathrm{bol}(t)` into an exactly normalized :math:`L_\nu(\nu,
t)`.
See Also
--------
Lightcurve : The time-only counterpart this mirrors.
ComposedSpectralModel : Combines a Lightcurve and a Spectrum into a full SED.
"""
_DOMAIN: ClassVar[tuple[Quantity, Quantity]] = (0 * u.Hz, np.inf * u.Hz)
"""tuple of Quantity: The ``(low, high)`` frequency range integrated over
when computing :meth:`eval_normalization` (see :meth:`_eval_normalization`).
Only consulted by the default, numerical-quadrature implementation of
:meth:`_eval_normalization`. A subclass that overrides that method with a
closed-form expression does not need this to be meaningful.
"""
# -------------------------------------- #
# Spectral Shape: S(nu) #
# -------------------------------------- #
@classmethod
@abstractmethod
def _eval(cls, nu: FloatArray, **parameters: CGSParameterValue) -> FloatArray:
r"""
Evaluate the natural log of :math:`S(\nu)`, in cgs units.
This is the one method every model must implement. ``nu`` and every
parameter value are combined using plain NumPy broadcasting -- no
axes are inserted automatically. There is no requirement that
:math:`S(\nu)` integrate to any particular value; see
:meth:`eval_normalization`.
Parameters
----------
nu : numpy.ndarray
Frequency, in Hz.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``nu``.
Returns
-------
numpy.ndarray
The natural log of :math:`S(\nu)`, in 1/Hz.
"""
...
[docs]
@classmethod
def eval_log_cgs(cls, nu: NumericalInput, **parameters: CGSParameterValue) -> FloatResult:
r"""
Natural log of the spectral shape, taking and returning plain cgs numbers.
Parameters
----------
nu : array-like
Frequency, in Hz.
**parameters
This model's parameter values, in cgs units. To evaluate several
parameter realizations at once, give each parameter a leading
batch axis.
Returns
-------
numpy.ndarray or float
The natural log of :math:`S(\nu)`, in 1/Hz. A scalar is returned
if the result is 0-dimensional.
"""
nu_arr = np.asarray(nu, dtype=np.float64)
cgs_parameters: dict[str, CGSParameterValue] = {
name: np.asarray(value, dtype=np.float64) for name, value in parameters.items()
}
result = cls._eval(nu_arr, **cgs_parameters)
return result.item() if result.ndim == 0 else result
[docs]
@classmethod
def eval_log(cls, nu: Quantity, **parameters: ParameterValue) -> FloatResult:
r"""
Natural log of the spectral shape, given physical-unit inputs.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency, with frequency units.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The natural log of :math:`S(\nu)`, in 1/Hz.
Raises
------
TypeError
If ``nu`` is not a Quantity with frequency units.
"""
if not isinstance(nu, Quantity) or nu.unit.physical_type != "frequency":
raise TypeError("`nu` must be an astropy Quantity with frequency units.")
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls.eval_log_cgs(nu.cgs.value, **cgs_parameters)
[docs]
@classmethod
def eval_cgs(cls, nu: NumericalInput, **parameters: CGSParameterValue) -> FloatResult:
r"""
Spectral shape, taking and returning plain cgs numbers.
Parameters
----------
nu : array-like
Frequency, in Hz.
**parameters
This model's parameter values, in cgs units. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
:math:`S(\nu)`, in 1/Hz. A scalar is returned if the result is
0-dimensional.
"""
return np.exp(cls.eval_log_cgs(nu, **parameters))
[docs]
@classmethod
def eval(cls, nu: Quantity, **parameters: ParameterValue) -> Quantity:
r"""
Evaluate the spectral shape at the given frequency.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency at which to evaluate the shape.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
:math:`S(\nu)`, in 1/Hz.
"""
return np.exp(cls.eval_log(nu, **parameters)) * _SED_SHAPE_UNIT
[docs]
@classmethod
def eval_from_arrays(cls, nu: Quantity, *parameters: ParameterValue) -> Quantity:
r"""
Positional-argument form of :meth:`eval`.
Equivalent to ``cls.eval(nu, **cls.unpack_params_from_arrays(*parameters))``.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency at which to evaluate the shape.
*parameters
This model's parameter values, one per parameter, in this
model's parameter order. See :meth:`unpack_params_from_arrays`.
Returns
-------
~astropy.units.Quantity
:math:`S(\nu)`, in 1/Hz.
See Also
--------
pack_params_to_arrays : The inverse conversion, dict -> ordered sequence.
unpack_params_from_arrays : Ordered sequence -> dict, used internally here.
"""
return cls.eval(nu, **cls.unpack_params_from_arrays(*parameters))
# -------------------------------------- #
# Normalization: integral of S(nu) dnu #
# -------------------------------------- #
@classmethod
def _eval_normalization(cls, **parameters: CGSParameterValue) -> FloatArray:
r"""
Evaluate the natural log of :math:`\int S(\nu)\,d\nu`, in cgs units.
Default implementation: broadcasts ``parameters`` together, then
numerically integrates :math:`\int \exp(\mathtt{\_eval}(\nu))\,d\nu`
over :attr:`_DOMAIN` (:func:`scipy.integrate.quad_vec`, which
evaluates the whole broadcast array at each trial frequency at once
rather than looping over realizations one at a time).
A model whose shape integral has a closed form (e.g. one already
normalized to 1 by construction) should override this method
directly, for both speed and exactness.
Parameters
----------
**parameters
This model's parameter values, in cgs units, broadcastable
against one another.
Returns
-------
numpy.ndarray
The natural log of :math:`\int S(\nu)\,d\nu`, dimensionless.
"""
param_arrays = [np.asarray(value, dtype=np.float64) for value in parameters.values()]
param_grids = dict(zip(parameters, np.broadcast_arrays(*param_arrays))) if param_arrays else {}
lo, hi = cls._DOMAIN
def integrand(nu: float) -> FloatArray:
r"""
Evaluate :math:`\exp(\mathtt{\_eval}(\nu))` at one trial frequency, for `quad_vec`.
Parameters
----------
nu : float
Trial frequency, in Hz, supplied by `quad_vec`.
Returns
-------
numpy.ndarray
:math:`S(\nu)` broadcast against `param_grids`.
"""
return np.exp(cls._eval(np.asarray(nu, dtype=np.float64), **param_grids))
integral, err = quad_vec(integrand, float(to_cgs_value(lo)), float(to_cgs_value(hi)))
_warn_if_not_converged(f"{cls.__name__}._eval_normalization", integral, err)
return np.log(integral)
[docs]
@classmethod
def eval_normalization_log_cgs(cls, **parameters: CGSParameterValue) -> FloatResult:
r"""
Natural log of the shape's frequency integral, taking and returning plain cgs numbers.
Parameters
----------
**parameters
This model's parameter values, in cgs units. To evaluate several
parameter realizations at once, give each parameter a leading
batch axis.
Returns
-------
numpy.ndarray or float
The natural log of :math:`\int S(\nu)\,d\nu`. A scalar is
returned if the result is 0-dimensional.
"""
cgs_parameters: dict[str, CGSParameterValue] = {
name: np.asarray(value, dtype=np.float64) for name, value in parameters.items()
}
result = cls._eval_normalization(**cgs_parameters)
return result.item() if result.ndim == 0 else result
[docs]
@classmethod
def eval_normalization_log(cls, **parameters: ParameterValue) -> FloatResult:
r"""
Natural log of the shape's frequency integral, given physical-unit inputs.
Parameters
----------
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The natural log of :math:`\int S(\nu)\,d\nu`.
"""
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls.eval_normalization_log_cgs(**cgs_parameters)
[docs]
@classmethod
def eval_normalization_cgs(cls, **parameters: CGSParameterValue) -> FloatResult:
r"""
Return the shape's frequency integral as plain cgs numbers; see :meth:`eval_normalization_log_cgs`.
Parameters
----------
**parameters
This model's parameter values, in cgs units. See :meth:`eval_normalization_log_cgs`.
Returns
-------
numpy.ndarray or float
:math:`\int S(\nu)\,d\nu`. A scalar is returned if the result is
0-dimensional.
"""
return np.exp(cls.eval_normalization_log_cgs(**parameters))
[docs]
@classmethod
def eval_normalization(cls, **parameters: ParameterValue) -> Quantity:
r"""
Evaluate :math:`\int S(\nu)\,d\nu`.
This is exactly the factor by which :math:`S(\nu)` must be divided
to turn it into a shape that integrates to 1 -- the normalization
:class:`ComposedSpectralModel` applies when combining a
:class:`Spectrum` with a :class:`Lightcurve`'s
:math:`L_\mathrm{bol}(t)` to get :math:`L_\nu(\nu, t)`.
Parameters
----------
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
:math:`\int S(\nu)\,d\nu`, dimensionless.
"""
return np.exp(cls.eval_normalization_log(**parameters)) * u.dimensionless_unscaled
# -------------------------------------- #
# Simulation #
# -------------------------------------- #
[docs]
def simulate(self, nu: Quantity, size: int = 1, *, rng: RNGInput = None) -> Quantity:
r"""
Draw random parameter realizations and evaluate the model at the given frequency.
Equivalent to ``self.eval(nu, **self.sample_parameters(size=size, rng=rng))``.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency at which to evaluate the model. See :meth:`eval`.
size : int
Number of realizations to draw.
rng : numpy.random.Generator, int, or None
Random-number source, forwarded to :meth:`sample_parameters`.
Returns
-------
~astropy.units.Quantity
:math:`S(\nu)`, in 1/Hz.
See Also
--------
eval : The underlying evaluation.
sample_parameters : The underlying sampling.
"""
return self.eval(nu, **self.sample_parameters(size=size, rng=rng))
[docs]
class SpectralModel(_ModelBase):
r"""
Abstract base class for time-dependent spectral energy distribution models.
A ``SpectralModel`` represents the intrinsic spectral luminosity
.. math::
L_\nu(\nu, t),
as a function of source-frame frequency :math:`\nu` and time since
explosion :math:`t`. From this fundamental quantity, the base class
provides a consistent interface for computing bolometric luminosities,
normalized spectral shapes, observed flux densities, band-averaged
fluxes, apparent magnitudes, and synthetic
:class:`~synphot.SourceSpectrum` objects.
Notes
-----
The core subclassing interface consists of:
- :attr:`_DEFAULT_PARAMETERS` for declaring model parameters;
- :attr:`_DOMAIN` for the frequency range used by the default bolometric
integration; and
- :meth:`_eval` for evaluating
:math:`\log L_\nu(\nu, t)` in unit-stripped cgs coordinates.
Every other public method -- including :meth:`as_astropy_model`
and :meth:`as_source_spectrum`, which build synthetic spectra -- is
derived automatically from :meth:`_eval` and requires no per-subclass
override.
Most user-facing quantities are available in parallel interfaces:
``*_log_cgs``
Unit-stripped cgs inputs and logarithmic numerical output.
``*_log``
Physical-unit inputs and logarithmic numerical output.
``*_cgs``
Unit-stripped cgs inputs and linear numerical output.
no suffix
Physical-unit inputs and physical-unit output.
Because magnitudes are already logarithmic quantities, their API consists
only of unit-stripped ``*_cgs`` and unit-aware forms.
All of the above are :class:`classmethod`\ s: they operate purely on the
``**parameters`` values passed in, not on any particular instance's
stored parameter configuration. Only parameter *storage* -- inherited
from :class:`_ModelBase` -- requires an instance.
"""
_DOMAIN: ClassVar[tuple[Quantity, Quantity]] = (0 * u.Hz, np.inf * u.Hz)
"""tuple of Quantity: The ``(low, high)`` frequency range integrated over
when computing the bolometric luminosity (see :meth:`_eval_bolometric`).
Only consulted by the default, numerical-quadrature implementation of
:meth:`_eval_bolometric`. A subclass that overrides that method with a
closed-form expression does not need this to be meaningful.
"""
# -------------------------------------- #
# Spectral Luminosity: L_nu(nu, t) #
# -------------------------------------- #
@classmethod
@abstractmethod
def _eval(cls, nu: FloatArray, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray:
r"""
Evaluate the natural log of :math:`L_\nu(\nu, t)`, in cgs units.
This is the one method every model must implement. ``nu``, ``t``,
and every parameter value are combined using plain NumPy
broadcasting -- no axes are inserted automatically. See the module
docstring for what that means in practice.
Parameters
----------
nu : numpy.ndarray
Frequency, in Hz.
t : numpy.ndarray
Time since explosion, in seconds. Always non-negative.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``nu`` and ``t``.
Returns
-------
numpy.ndarray
The natural log of :math:`L_\nu(\nu, t)`, in erg/s/Hz.
"""
...
[docs]
@classmethod
def eval_log_cgs(cls, nu: NumericalInput, t: NumericalInput, **parameters: CGSParameterValue) -> FloatResult:
"""
Natural log of the spectral luminosity, taking and returning plain cgs numbers.
Parameters
----------
nu : array-like
Frequency, in Hz.
t : array-like
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units. To evaluate several
parameter realizations at once, give each parameter a leading
batch axis.
Returns
-------
numpy.ndarray or float
The natural log of the spectral luminosity, in erg/s/Hz. A
scalar is returned if the result is 0-dimensional.
"""
nu_arr = np.asarray(nu, dtype=np.float64)
t_arr = np.asarray(t, dtype=np.float64)
cgs_parameters: dict[str, CGSParameterValue] = {
name: np.asarray(value, dtype=np.float64) for name, value in parameters.items()
}
result = cls._eval(nu_arr, t_arr, **cgs_parameters)
return result.item() if result.ndim == 0 else result
[docs]
@classmethod
def eval_log(cls, nu: Quantity, t: Quantity, **parameters: ParameterValue) -> FloatResult:
"""
Natural log of the spectral luminosity, given physical-unit inputs.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency, with frequency units.
t : ~astropy.units.Quantity
Time since explosion, with time units.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The natural log of the spectral luminosity, in erg/s/Hz.
Raises
------
TypeError
If ``nu``/``t`` are not Quantities with frequency/time units.
"""
if not isinstance(nu, Quantity) or nu.unit.physical_type != "frequency":
raise TypeError("`nu` must be an astropy Quantity with frequency units.")
if not isinstance(t, Quantity) or t.unit.physical_type != "time":
raise TypeError("`t` must be an astropy Quantity with time units.")
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls.eval_log_cgs(nu.cgs.value, t.cgs.value, **cgs_parameters)
[docs]
@classmethod
def eval_cgs(cls, nu: NumericalInput, t: NumericalInput, **parameters: CGSParameterValue) -> FloatResult:
r"""
Spectral luminosity, taking and returning plain cgs numbers.
Parameters
----------
nu : array-like
Frequency, in Hz.
t : array-like
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
:math:`L_\nu(\nu, t)`, in erg/s/Hz. A scalar is returned if the
result is 0-dimensional.
"""
return np.exp(cls.eval_log_cgs(nu, t, **parameters))
[docs]
@classmethod
def eval(cls, nu: Quantity, t: Quantity, **parameters: ParameterValue) -> Quantity:
r"""
Evaluate the spectral luminosity at the given frequency and time.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency at which to evaluate the model.
t : ~astropy.units.Quantity
Time since explosion.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
:math:`L_\nu(\nu, t)`, in erg/s/Hz.
"""
return np.exp(cls.eval_log(nu, t, **parameters)) * _SPEC_LUM_UNIT
[docs]
@classmethod
def eval_from_arrays(cls, nu: Quantity, t: Quantity, *parameters: ParameterValue) -> Quantity:
r"""
Positional-argument form of :meth:`eval`.
Equivalent to ``cls.eval(nu, t, **cls.unpack_params_from_arrays(*parameters))``.
Useful when parameter values are already stored as a plain sequence
(e.g. rows of an array) rather than a dict.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency at which to evaluate the model.
t : ~astropy.units.Quantity
Time since explosion.
*parameters
This model's parameter values, one per parameter, in this
model's parameter order. See :meth:`unpack_params_from_arrays`.
Returns
-------
~astropy.units.Quantity
:math:`L_\nu(\nu, t)`, in erg/s/Hz.
See Also
--------
pack_params_to_arrays : The inverse conversion, dict -> ordered sequence.
unpack_params_from_arrays : Ordered sequence -> dict, used internally here.
"""
return cls.eval(nu, t, **cls.unpack_params_from_arrays(*parameters))
# -------------------------------------- #
# Bolometric Luminosity: L_bol(t) #
# -------------------------------------- #
@classmethod
def _eval_bolometric(cls, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray:
r"""
Evaluate the natural log of the bolometric luminosity, in cgs units.
Default implementation: broadcasts ``t`` and ``parameters`` together,
then numerically integrates :math:`\int \exp(\mathtt{\_eval}(\nu,
t))\,d\nu` over :attr:`_DOMAIN` (:func:`scipy.integrate.quad_vec`,
which evaluates the whole broadcast array at each trial frequency at
once rather than looping over realizations/times one at a time).
A model for which this integral has a closed form should override
this method directly, for both speed and exactness.
Parameters
----------
t : numpy.ndarray
Time since explosion, in seconds, broadcastable against
``parameters``.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``t``.
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))
lo, hi = cls._DOMAIN
def integrand(nu: float) -> FloatArray:
r"""
Evaluate :math:`\exp(\mathtt{\_eval}(\nu, t))` at one trial frequency, for `quad_vec`.
Parameters
----------
nu : float
Trial frequency, in Hz, supplied by `quad_vec`.
Returns
-------
numpy.ndarray
:math:`L_\nu(\nu, t)` broadcast against `t_grid`/`param_grids`.
"""
return np.exp(cls._eval(np.asarray(nu, dtype=np.float64), t_grid, **param_grids))
integral, err = quad_vec(integrand, float(to_cgs_value(lo)), float(to_cgs_value(hi)))
_warn_if_not_converged(f"{cls.__name__}._eval_bolometric", integral, err)
return np.log(integral)
[docs]
@classmethod
def eval_bolometric_log_cgs(cls, t: NumericalInput, **parameters: CGSParameterValue) -> FloatResult:
"""
Natural log of the bolometric luminosity, taking and returning plain cgs numbers.
Parameters
----------
t : array-like
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units. To evaluate several
parameter realizations at once, give each parameter a leading
batch axis.
Returns
-------
numpy.ndarray or float
The natural log of the bolometric luminosity, in erg/s. A scalar
is returned if the result is 0-dimensional.
"""
t_arr = np.asarray(t, dtype=np.float64)
cgs_parameters: dict[str, CGSParameterValue] = {
name: np.asarray(value, dtype=np.float64) for name, value in parameters.items()
}
result = cls._eval_bolometric(t_arr, **cgs_parameters)
return result.item() if result.ndim == 0 else result
[docs]
@classmethod
def eval_bolometric_log(cls, t: Quantity, **parameters: ParameterValue) -> FloatResult:
"""
Natural log of the bolometric luminosity, given physical-unit inputs.
Parameters
----------
t : ~astropy.units.Quantity
Time since explosion.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The natural log of the bolometric luminosity, in erg/s.
Raises
------
TypeError
If ``t`` is not a Quantity with time units.
"""
if not isinstance(t, Quantity) or t.unit.physical_type != "time":
raise TypeError("`t` must be an astropy Quantity with time units.")
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls.eval_bolometric_log_cgs(t.cgs.value, **cgs_parameters)
[docs]
@classmethod
def eval_bolometric_cgs(cls, t: NumericalInput, **parameters: CGSParameterValue) -> FloatResult:
"""
Bolometric luminosity, taking and returning plain cgs numbers.
Parameters
----------
t : array-like
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The bolometric luminosity, in erg/s. A scalar is returned if the
result is 0-dimensional.
"""
return np.exp(cls.eval_bolometric_log_cgs(t, **parameters))
[docs]
@classmethod
def eval_bolometric(cls, t: Quantity, **parameters: ParameterValue) -> Quantity:
r"""
Evaluate the bolometric luminosity at the given time.
Parameters
----------
t : ~astropy.units.Quantity
Time since explosion.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
:math:`L_\mathrm{bol}(t)`, in erg/s.
"""
return np.exp(cls.eval_bolometric_log(t, **parameters)) * _BOL_LUM_UNIT
# -------------------------------------- #
# Normalized Spectral Shape: S(nu, t) #
# -------------------------------------- #
@classmethod
def _eval_spectrum(cls, nu: FloatArray, t: FloatArray, **parameters: CGSParameterValue) -> FloatArray:
r"""
Evaluate the natural log of the normalized spectral shape, in cgs units.
Default implementation: :math:`\log S(\nu, t) = \mathtt{\_eval}(\nu,
t) - \mathtt{\_eval\_bolometric}(t)`, i.e. :math:`S(\nu, t) =
L_\nu(\nu, t)/L_\mathrm{bol}(t)`, computed as a log-space subtraction
rather than a linear-space division so it stays accurate regardless
of :math:`L_\nu`'s dynamic range. This integrates to exactly 1 over
:math:`\nu`, for any :math:`t`, by construction.
A model whose spectral shape is already known independently of its
bolometric integral (e.g. one built from a separately normalized
template spectrum) should override this method directly, skipping
the bolometric-integral subtraction entirely.
Parameters
----------
nu : numpy.ndarray
Frequency, in Hz.
t : numpy.ndarray
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``nu``/``t``.
Returns
-------
numpy.ndarray
The natural log of :math:`S(\nu, t)`, in 1/Hz.
"""
return cls._eval(nu, t, **parameters) - cls._eval_bolometric(t, **parameters)
[docs]
@classmethod
def eval_spectrum_log_cgs(
cls, nu: NumericalInput, t: NumericalInput, **parameters: CGSParameterValue
) -> FloatResult:
"""
Natural log of the normalized spectral shape, taking and returning plain cgs numbers.
Parameters
----------
nu : array-like
Frequency, in Hz.
t : array-like
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units. To evaluate several
parameter realizations at once, give each parameter a leading
batch axis.
Returns
-------
numpy.ndarray or float
The natural log of the normalized spectral shape. A scalar is
returned if the result is 0-dimensional.
"""
nu_arr = np.asarray(nu, dtype=np.float64)
t_arr = np.asarray(t, dtype=np.float64)
cgs_parameters: dict[str, CGSParameterValue] = {
name: np.asarray(value, dtype=np.float64) for name, value in parameters.items()
}
result = cls._eval_spectrum(nu_arr, t_arr, **cgs_parameters)
return result.item() if result.ndim == 0 else result
[docs]
@classmethod
def eval_spectrum_log(cls, nu: Quantity, t: Quantity, **parameters: ParameterValue) -> FloatResult:
"""
Natural log of the normalized spectral shape, given physical-unit inputs.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency at which to evaluate the shape.
t : ~astropy.units.Quantity
Time since explosion.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The natural log of the normalized spectral shape, in 1/Hz.
Raises
------
TypeError
If ``nu``/``t`` are not Quantities with frequency/time units.
"""
if not isinstance(nu, Quantity) or nu.unit.physical_type != "frequency":
raise TypeError("`nu` must be an astropy Quantity with frequency units.")
if not isinstance(t, Quantity) or t.unit.physical_type != "time":
raise TypeError("`t` must be an astropy Quantity with time units.")
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls.eval_spectrum_log_cgs(nu.cgs.value, t.cgs.value, **cgs_parameters)
[docs]
@classmethod
def eval_spectrum_cgs(cls, nu: NumericalInput, t: NumericalInput, **parameters: CGSParameterValue) -> FloatResult:
"""
Return the normalized spectral shape as plain cgs numbers; see :meth:`eval_log_cgs`.
Parameters
----------
nu : array-like
Frequency, in Hz.
t : array-like
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The normalized spectral shape, in 1/Hz. A scalar is returned if
the result is 0-dimensional.
"""
return np.exp(cls.eval_spectrum_log_cgs(nu, t, **parameters))
[docs]
@classmethod
def eval_spectrum(cls, nu: Quantity, t: Quantity, **parameters: ParameterValue) -> Quantity:
r"""
Evaluate the normalized spectral shape at the given frequency and time.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency at which to evaluate the shape.
t : ~astropy.units.Quantity
Time since explosion.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
:math:`S(\nu, t)`, in 1/Hz. Integrates to 1 over :math:`\nu`
for any fixed :math:`t`.
"""
return np.exp(cls.eval_spectrum_log(nu, t, **parameters)) * _SED_SHAPE_UNIT
# -------------------------------------- #
# Synthetic Spectrum Generation #
# -------------------------------------- #
# At the m4opt.synphot level, operations are performed on Synphot / Astropy Model objects,
# which are not immediately compatible with the machinery of the SpectralModel class. These
# methods allow a user to provide a set of parameters and generate spectra objects.
[docs]
@classmethod
def as_astropy_model(
cls,
x_type: str = "lambda",
y_type: str = "lambda",
*,
y_kind: str = "energy",
wave_unit: str | u.UnitBase = u.AA,
freq_unit: str | u.UnitBase = u.Hz,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
**parameters: ParameterValue,
) -> Model:
r"""
Build an :class:`~astropy.modeling.Model` of this :class:`SpectralModel` for a given parameter set.
This method is the single entry point for converting a :class:`SpectralModel` into
a model compatible with :mod:`synphot` / :mod:`astropy.modeling` / :mod:`m4opt.synphot`.
Several parameters can be modified to specify exactly what type of spectral model to
generate:
- ``x_type``: May be either ``"lambda"`` or ``"nu"`` to control what input the
model expects.
- ``y_type``: May be either ``"lambda"`` or ``"nu"``. If ``"lambda"``, then :math:`F_\lambda` is
generated, otherwise :math:`F_\nu` is generated.
- ``y_kind``: May be either ``"energy"`` or ``"photon"``. If ``"energy"``, then the output is
an energy-flux density (:math:`F_\nu`/:math:`F_\lambda`). If ``"photon"``, then the output is
a photon count flux density.
Additionally, any of a number of cosmological parameters may be specified to provide the distance
from the source. If any of these are provided, the model will generate a flux density. Otherwise
a luminosity density is produced.
Parameters
----------
x_type : str
``"lambda"`` (the default) if the model's first input is a
wavelength, or ``"nu"`` if it is a frequency.
y_type : str
``"lambda"`` (the default) if the output is expressed per unit
wavelength, or ``"nu"`` if per unit frequency.
y_kind : str
``"energy"`` (the default) for an energy-flux density, or
``"photon"`` for a photon-count-flux density (dividing by the
photon energy :math:`h\nu`).
wave_unit : str or ~astropy.units.UnitBase
The wavelength unit used wherever ``x_type``/``y_type`` is
``"lambda"``.
freq_unit : str or ~astropy.units.UnitBase
The frequency unit used wherever ``x_type``/``y_type`` is
``"nu"``.
redshift : array-like or ~astropy.units.Quantity, optional
If any of ``redshift``/``luminosity_distance``/
``angular_diameter_distance``/``proper_distance`` is given,
exactly one of them must be given; the rest (and
``cosmology``) are as in :meth:`flux_log`, and the output is
the observed, diluted flux. If none of the four is given, the
output is the rest-frame luminosity and ``cosmology`` is
ignored.
luminosity_distance : ~astropy.units.Quantity, optional
See ``redshift`` above.
angular_diameter_distance : ~astropy.units.Quantity, optional
See ``redshift`` above.
proper_distance : ~astropy.units.Quantity, optional
See ``redshift`` above.
cosmology : ~astropy.cosmology.FLRW, optional
See ``redshift`` above.
**parameters
This model's parameter values, either
:class:`~astropy.units.Quantity` or already unit-stripped cgs
values (see :meth:`eval_log_cgs`). May carry leading batch
axes.
Returns
-------
~astropy.modeling.Model
Callable as ``model(x, t)``, with ``x``/``t`` either bare
numbers (``x`` in ``wave_unit``/``freq_unit``, ``t`` in
seconds) or :class:`~astropy.units.Quantity`. Unit-attached
only when at least one of ``x``/``t`` was itself a
``Quantity`` (a property of
:meth:`astropy.modeling.Model.__call__`, not something this
method controls).
Raises
------
ValueError
If ``x_type``/``y_type`` is not ``"lambda"``/``"nu"``, if
``y_kind`` is not ``"energy"``/``"photon"``, or if
``wave_unit``/``freq_unit`` is not a wavelength/frequency unit.
"""
# Resolve and validate the x_type, y_type and y_kind parameters.
if x_type not in ("nu", "lambda"):
raise ValueError(f"x_type must be 'nu' or 'lambda', got {x_type!r}.")
if y_type not in ("nu", "lambda"):
raise ValueError(f"y_type must be 'nu' or 'lambda', got {y_type!r}.")
if y_kind not in ("energy", "photon"):
raise ValueError(f"y_kind must be 'energy' or 'photon', got {y_kind!r}.")
x_is_wavelength = x_type == "lambda"
y_is_wavelength = y_type == "lambda"
x_unit = u.Unit(wave_unit if x_is_wavelength else freq_unit)
y_unit = u.Unit(wave_unit if y_is_wavelength else freq_unit)
x_coefficient = hz_per_unit(x_unit, is_wavelength=x_is_wavelength)
y_coefficient = hz_per_unit(y_unit, is_wavelength=y_is_wavelength)
# Determine if we are generating a flux density or luminosity. This is determined
# by the specification / lack of the redshift or other cosmological parameters.
_is_luminosity = (
redshift is None
and luminosity_distance is None
and angular_diameter_distance is None
and proper_distance is None
)
# Convert ALL of the provided parameters to their CGS value so that we
# do not need to do on-the-fly unit conversions in performance critical
# call sequences.
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
# Determine the "native" evaluation function. This is the F_nu(nu) in units of
# erg / cm^2 / Hz / s if we are computing a flux, or a luminosity L_nu(nu) in
# erg / Hz / s if we are computing a luminosity.
#
# Once this has been generated, we can modify it to generate the correct units
# and other properties.
if not _is_luminosity:
# Resolve the cosmological distances and extract them.
distances = resolve_cosmological_distances(
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
)
redshift_cgs = np.asarray(distances["redshift"], dtype=np.float64)
luminosity_distance_cgs = distances["luminosity_distance"].cgs.value
_denominator_unit = u.s**-1 * u.cm**-2
# Construct the native evaluation function.
def _eval_native(nu_hz: FloatArray, t: FloatArray) -> FloatResult:
return cls.flux_cgs(nu_hz, t, redshift_cgs, luminosity_distance_cgs, **cgs_parameters)
else:
# We are not producing a flux. We can just generate the function.
_denominator_unit = u.s**-1
def _eval_native(nu_hz: FloatArray, t: FloatArray) -> FloatResult:
return cls.eval_cgs(nu_hz, t, **cgs_parameters)
# Generate the modifications to the function to coerce x to nu.
if x_is_wavelength:
def _x_to_nu(x: FloatArray) -> FloatArray:
return x_coefficient / x
else:
def _x_to_nu(x: FloatArray) -> FloatArray:
return x_coefficient * x
# Generate the modification to the function to coerce F_nu to y.
if y_is_wavelength:
def _to_dlambda(nu_hz: FloatArray, y: FloatResult) -> FloatArray:
return np.asarray(y * (nu_hz**2 / y_coefficient))
else:
def _to_dlambda(nu_hz: FloatArray, y: FloatResult) -> FloatArray:
return np.asarray(y * y_coefficient)
if y_kind == "energy":
_numerator_unit = u.erg
def _to_output_flux(nu_hz: FloatArray, y: FloatResult) -> FloatArray:
return np.asarray(y)
else:
_numerator_unit = u.photon
def _to_output_flux(nu_hz: FloatArray, y: FloatResult) -> FloatArray:
return y / (H_CGS * nu_hz)
# Generate the final evaluator.
def _evaluate(x: FloatArray, t: FloatArray) -> FloatResult:
"""
Evaluate the composed model at unit-stripped ``(x, t)``, in the resolved output units.
Chains `_x_to_nu`, `_eval_native`, `_to_dlambda`, and
`_to_output_flux` (each resolved above, based on `x_type`/
`y_type`/`y_kind`) into the single callable passed to
:class:`~astropy.modeling.custom_model`.
Parameters
----------
x : numpy.ndarray
Wavelength or frequency, in `wave_unit`/`freq_unit`, per `x_type`.
t : numpy.ndarray
Time since explosion, in seconds.
Returns
-------
float or numpy.ndarray
The evaluated flux/luminosity density, unit-stripped, in `output_unit`.
"""
nu_hz = _x_to_nu(x)
y = _eval_native(nu_hz, t)
y = _to_dlambda(nu_hz, y)
y = _to_output_flux(nu_hz, y)
return y
output_unit = _numerator_unit / y_unit * _denominator_unit
model_class = model_class_from_kernel(
"_SpectralAstropyModel",
inputs={("wave" if x_is_wavelength else "nu"): x_unit, "t": u.s},
outputs={"y": output_unit},
evaluate=_evaluate,
)
return model_class()
[docs]
@classmethod
def as_source_spectrum(
cls,
t: PhysicalInput,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
ebv: float | Quantity | FloatArray | None = None,
dust_law: str | None = None,
**parameters: ParameterValue,
) -> SourceSpectrum:
r"""
Build a :class:`~synphot.SourceSpectrum` giving the observed flux at one fixed time :math:`t`.
A thin wrapper around :meth:`as_astropy_model`: fixes ``t`` (so the
result is a function of wavelength alone, the shape :mod:`synphot`
requires), and fixes ``x_type``/``y_type``/``y_kind`` to
wavelength-in/photon-out, in Angstrom -- because
:class:`~synphot.SourceSpectrum` always samples its wrapped model
in *wavelength* space, and always treats the model's raw return
value as already being expressed in :mod:`synphot`'s internal
:data:`~synphot.units.PHOTLAM` (*photon*-count flux density per
unit wavelength, not the energy-flux :math:`F_\lambda` one might
expect) -- :meth:`~synphot.SourceSpectrum.__call__` does not
consult a wrapped model's declared output units to convert.
Requires an observed (diluted) flux -- at least one of
``redshift``/the distance keywords -- since a
:class:`~synphot.SourceSpectrum` is meant to be a real per-area
flux for :class:`~m4opt.synphot.Detector` to consume, not a
rest-frame luminosity.
Foreground (e.g. Milky Way) dust attenuation is optional: pass an
already-resolved ``ebv`` (see :func:`~uvex_transients.dust.resolve_ebv`
for turning a dust map and sky position into one, as a separate prior
step) and it's folded into the native photon flux for you, inside this
method's own evaluation kernel, before the result is wrapped as a
:class:`~synphot.SourceSpectrum` -- applied here rather than through
:mod:`synphot`'s own spectrum-composition operators
(``spectrum * extinction``), and resolved to
:func:`~uvex_transients.dust.log_attenuation`'s callable form (via
:func:`~uvex_transients.dust.attenuation_callable`) internally, right
here, rather than something a caller ever has to build. Doing it here
rather than by composing two :class:`~astropy.modeling.Model`
instances is what lets ``ebv`` stay vector-valued (e.g. one row per
event) without resurrecting :mod:`synphot`'s ``n_models=1``
restriction, exactly like every other parameter here (see
:func:`~uvex_transients.models._utils.model_class_from_kernel`) -- the
attenuation is just one more array multiplied in via plain NumPy
broadcasting, not a second composed model.
Parameters
----------
t : array-like or Quantity
Observed time since explosion, either a
:class:`~astropy.units.Quantity` with time units or an already
unit-stripped cgs (seconds) value. May carry leading batch
axes, broadcastable against ``parameters``.
redshift : array-like or ~astropy.units.Quantity, optional
Exactly one of ``redshift`` or the three distance keywords must
be given; the rest are derived from it using ``cosmology``. See
:meth:`as_astropy_model`.
luminosity_distance : ~astropy.units.Quantity, optional
See ``redshift`` above.
angular_diameter_distance : ~astropy.units.Quantity, optional
See ``redshift`` above.
proper_distance : ~astropy.units.Quantity, optional
See ``redshift`` above.
cosmology : ~astropy.cosmology.FLRW, optional
See ``redshift`` above.
ebv : float, ~astropy.units.Quantity, or array-like, optional
Already-resolved, dimensionless E(B-V) (see
:func:`~uvex_transients.dust.resolve_ebv`). ``None`` (the default)
applies no dust attenuation.
dust_law : str, optional
Passed through to :func:`~uvex_transients.dust.get_dust_law`;
the configured default (``config["physics.default_dust_law"]``)
is almost always the right choice.
**parameters
This model's parameter values, either
:class:`~astropy.units.Quantity` or already unit-stripped cgs
values (see :meth:`eval_log_cgs`). May carry leading batch
axes.
Returns
-------
~synphot.SourceSpectrum
Callable as ``spectrum(wave)``, returning the observed flux
density at ``t``.
Raises
------
ValueError
If none of ``redshift``/``luminosity_distance``/
``angular_diameter_distance``/``proper_distance`` is given.
"""
if (
redshift is None
and luminosity_distance is None
and angular_diameter_distance is None
and proper_distance is None
):
raise ValueError(
"as_source_spectrum requires an observed flux -- pass "
"`redshift=` or one of the distance keywords accepted by "
"as_astropy_model. A SourceSpectrum must be a real per-area "
"flux, not a rest-frame luminosity."
)
model = cls.as_astropy_model(
x_type="lambda",
y_type="lambda",
y_kind="photon",
wave_unit=u.AA,
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
**parameters,
)
t_cgs = to_cgs_value(t)
log_attenuation = None if ebv is None else attenuation_callable(ebv, dust_law)
if log_attenuation is None:
def evaluate(wave: FloatArray) -> FloatResult:
return model(wave, t_cgs)
else:
def evaluate(wave: FloatArray) -> FloatResult:
return model(wave, t_cgs) * np.exp(log_attenuation(wave * u.AA))
model_class = model_class_from_kernel(
"_FixedTimeSourceSpectrumModel",
inputs={"wave": u.AA},
outputs={"y": synphot_units.PHOTLAM},
evaluate=evaluate,
)
return SourceSpectrum(model_class())
# -------------------------------------- #
# Observed Flux Density: F_nu(nu, t) #
# -------------------------------------- #
@classmethod
def _eval_flux(
cls,
nu: FloatArray,
t: FloatArray,
redshift: FloatArray,
luminosity_distance: FloatArray,
*,
log_attenuation: FloatArray | None = None,
**parameters: CGSParameterValue,
) -> FloatArray:
r"""
Evaluate the natural log of the observed flux density, in cgs units.
Implements the K-correction-free relation (Hogg 1999):
.. math::
F_\nu(\nu, t) = (1+z) \cdot L_\nu\big(\nu(1+z),\ t/(1+z)\big) / (4\pi D_L^2)
\cdot \exp(\ell(\nu))
:math:`\nu(1+z)` and :math:`t/(1+z)` convert the observed
frequency/time to the rest-frame values :meth:`_eval` expects; the
:math:`(1+z)` prefactor accounts for observed-bandwidth compression.
:math:`\ell(\nu)` is `log_attenuation`, an observed-frame effect
(e.g. Milky Way foreground dust) applied here rather than inside
:meth:`_eval`, so it does not also leak into
:meth:`_eval_bolometric`/:meth:`as_astropy_model`'s (undiluted) normalization.
Parameters
----------
nu, t : numpy.ndarray
Observed frequency (Hz) and time since explosion (s).
redshift : numpy.ndarray
Cosmological redshift, dimensionless.
luminosity_distance : numpy.ndarray
Luminosity distance, in cm.
log_attenuation : numpy.ndarray, optional
Natural log of an observed-frame multiplicative attenuation
(e.g. the log of Milky Way dust transmission), added directly to
the log flux. ``None`` (the default) applies no attenuation.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``nu``/``t``/``redshift``/``luminosity_distance``.
Returns
-------
numpy.ndarray
The natural log of :math:`F_\nu`, in erg/s/cm^2/Hz.
"""
log_flux = (
np.log1p(redshift)
+ cls._eval(nu * (1.0 + redshift), t / (1.0 + redshift), **parameters)
- np.log(4.0 * np.pi)
- 2.0 * np.log(luminosity_distance)
)
if log_attenuation is not None:
log_flux = log_flux + log_attenuation
return log_flux
[docs]
@classmethod
def flux_log_cgs(
cls,
nu: NumericalInput,
t: NumericalInput,
redshift: NumericalInput,
luminosity_distance: NumericalInput,
*,
log_attenuation: NumericalInput | None = None,
**parameters: CGSParameterValue,
) -> FloatResult:
r"""
Natural log of the observed flux density, taking and returning plain cgs numbers.
Unlike :meth:`flux_log`, ``redshift``/``luminosity_distance`` are not
resolved from a cosmology here -- pass already-computed values
directly (e.g. a precomputed per-event grid).
Parameters
----------
nu, t : array-like
Observed frequency (Hz) and time since explosion (s).
redshift : array-like
Cosmological redshift, dimensionless.
luminosity_distance : array-like
Luminosity distance, in cm.
log_attenuation : array-like, optional
Natural log of an observed-frame multiplicative attenuation,
added directly to the log flux. Must already broadcast against
the natural output shape of this call.
**parameters
This model's parameter values, in cgs units.
Returns
-------
numpy.ndarray or float
The natural log of :math:`F_\nu`, in erg/s/cm^2/Hz. A scalar is
returned if the result is 0-dimensional.
"""
nu_arr = np.asarray(nu, dtype=np.float64)
t_arr = np.asarray(t, dtype=np.float64)
redshift_arr = np.asarray(redshift, dtype=np.float64)
luminosity_distance_arr = np.asarray(luminosity_distance, dtype=np.float64)
cgs_parameters: dict[str, CGSParameterValue] = {
name: np.asarray(value, dtype=np.float64) for name, value in parameters.items()
}
result = cls._eval_flux(
nu_arr,
t_arr,
redshift_arr,
luminosity_distance_arr,
log_attenuation=None if log_attenuation is None else np.asarray(log_attenuation, dtype=np.float64),
**cgs_parameters,
)
return result.item() if result.ndim == 0 else result
[docs]
@classmethod
def flux_log(
cls,
nu: Quantity,
t: Quantity,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
log_attenuation: NumericalInput | None = None,
**parameters: ParameterValue,
) -> FloatResult:
r"""
Natural log of the observed flux density, given physical-unit inputs.
Parameters
----------
nu : ~astropy.units.Quantity
Observed frequency.
t : ~astropy.units.Quantity
Observed time since explosion.
redshift : array-like or ~astropy.units.Quantity, optional
Exactly one of ``redshift`` or the three distance keywords must
be given; the rest are derived from it using ``cosmology`` (see
:func:`~uvex_transients.utils.cosmology.resolve_cosmological_distances`).
``cosmology`` defaults to that function's configured default.
luminosity_distance : ~astropy.units.Quantity, optional
See ``redshift`` above.
angular_diameter_distance : ~astropy.units.Quantity, optional
See ``redshift`` above.
proper_distance : ~astropy.units.Quantity, optional
See ``redshift`` above.
cosmology : ~astropy.cosmology.FLRW, optional
See ``redshift`` above.
log_attenuation : array-like, optional
See :meth:`flux_log_cgs`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The natural log of :math:`F_\nu`, in erg/s/cm^2/Hz.
"""
if not isinstance(nu, Quantity) or nu.unit.physical_type != "frequency":
raise TypeError("`nu` must be an astropy Quantity with frequency units.")
if not isinstance(t, Quantity) or t.unit.physical_type != "time":
raise TypeError("`t` must be an astropy Quantity with time units.")
distances = resolve_cosmological_distances(
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
)
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls.flux_log_cgs(
nu.cgs.value,
t.cgs.value,
np.asarray(distances["redshift"], dtype=np.float64),
distances["luminosity_distance"].cgs.value,
log_attenuation=log_attenuation,
**cgs_parameters,
)
[docs]
@classmethod
def flux_cgs(
cls,
nu: NumericalInput,
t: NumericalInput,
redshift: NumericalInput,
luminosity_distance: NumericalInput,
*,
log_attenuation: NumericalInput | None = None,
**parameters: CGSParameterValue,
) -> FloatResult:
r"""
Observed flux density, taking and returning plain cgs numbers.
Parameters
----------
nu : array-like
Observed frequency, in Hz.
t : array-like
Observed time since explosion, in seconds.
redshift : array-like
Cosmological redshift, dimensionless.
luminosity_distance : array-like
Luminosity distance, in cm.
log_attenuation : array-like, optional
See :meth:`flux_log_cgs`.
**parameters
This model's parameter values, in cgs units. See :meth:`flux_log_cgs`.
Returns
-------
numpy.ndarray or float
:math:`F_\nu(\nu, t)`, in erg/s/cm^2/Hz. A scalar is returned if
the result is 0-dimensional.
"""
return np.exp(
cls.flux_log_cgs(
nu,
t,
redshift,
luminosity_distance,
log_attenuation=log_attenuation,
**parameters,
)
)
[docs]
@classmethod
def flux(
cls,
nu: Quantity,
t: Quantity,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
log_attenuation: NumericalInput | None = None,
**parameters: ParameterValue,
) -> Quantity:
r"""
Evaluate the observed flux density at the given frequency and time.
Parameters
----------
nu : ~astropy.units.Quantity
Observed frequency.
t : ~astropy.units.Quantity
Observed time since explosion.
redshift : array-like or ~astropy.units.Quantity, optional
See :meth:`flux_log`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
angular_diameter_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
proper_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
cosmology : ~astropy.cosmology.FLRW, optional
See :meth:`flux_log`.
log_attenuation : array-like, optional
See :meth:`flux_log_cgs`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
:math:`F_\nu(\nu, t)`, in erg/s/cm^2/Hz.
"""
return (
np.exp(
cls.flux_log(
nu,
t,
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
log_attenuation=log_attenuation,
**parameters,
)
)
* _SPEC_FLUX_UNIT
)
# -------------------------------------- #
# Observed Bolometric Flux: F_bol(t) #
# -------------------------------------- #
@classmethod
def _eval_flux_bolometric(
cls,
t: FloatArray,
redshift: FloatArray,
luminosity_distance: FloatArray,
**parameters: CGSParameterValue,
) -> FloatArray:
r"""
Evaluate the natural log of the observed bolometric flux, in cgs units.
.. math::
F_\mathrm{bol}(t) = L_\mathrm{bol}(t/(1+z)) / (4\pi D_L^2)
No :math:`(1+z)` prefactor appears here: integrating
:meth:`_eval_flux`'s :math:`F_\nu(\nu_\mathrm{obs})` over all
observed frequency and substituting :math:`\nu_\mathrm{emit} =
\nu_\mathrm{obs}(1+z)` makes that factor cancel exactly against the
Jacobian of the substitution.
Parameters
----------
t : numpy.ndarray
Observed time since explosion, in seconds.
redshift : numpy.ndarray
Cosmological redshift, dimensionless, broadcastable against ``t``.
luminosity_distance : numpy.ndarray
Luminosity distance, in cm, broadcastable against ``t``.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``t``.
Returns
-------
numpy.ndarray
The natural log of the observed bolometric flux, in erg/s/cm^2.
"""
return (
cls._eval_bolometric(t / (1.0 + redshift), **parameters)
- np.log(4.0 * np.pi)
- 2.0 * np.log(luminosity_distance)
)
[docs]
@classmethod
def flux_bolometric_log_cgs(
cls,
t: NumericalInput,
redshift: NumericalInput,
luminosity_distance: NumericalInput,
**parameters: CGSParameterValue,
) -> FloatResult:
"""
Natural log of the observed bolometric flux, taking and returning plain cgs numbers.
Parameters
----------
t : array-like
Time since explosion, in seconds.
redshift : array-like
Cosmological redshift, dimensionless.
luminosity_distance : array-like
Luminosity distance, in cm.
**parameters
This model's parameter values, in cgs units; must already
broadcast against ``t``.
Returns
-------
numpy.ndarray or float
The natural log of the observed bolometric flux, in erg/s/cm^2.
A scalar is returned if the result is 0-dimensional.
"""
t_arr = np.asarray(t, dtype=np.float64)
redshift_arr = np.asarray(redshift, dtype=np.float64)
luminosity_distance_arr = np.asarray(luminosity_distance, dtype=np.float64)
cgs_parameters: dict[str, CGSParameterValue] = {
name: np.asarray(value, dtype=np.float64) for name, value in parameters.items()
}
result = cls._eval_flux_bolometric(t_arr, redshift_arr, luminosity_distance_arr, **cgs_parameters)
return result.item() if result.ndim == 0 else result
[docs]
@classmethod
def flux_bolometric_log(
cls,
t: Quantity,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
**parameters: ParameterValue,
) -> FloatResult:
"""
Natural log of the observed bolometric flux, given physical-unit inputs.
Parameters
----------
t : ~astropy.units.Quantity
Observed time since explosion.
redshift : array-like or ~astropy.units.Quantity, optional
See :meth:`flux_log`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
angular_diameter_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
proper_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
cosmology : ~astropy.cosmology.FLRW, optional
See :meth:`flux_log`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The natural log of the observed bolometric flux, in erg/s/cm^2.
Raises
------
TypeError
If ``t`` is not a Quantity with time units.
"""
if not isinstance(t, Quantity) or t.unit.physical_type != "time":
raise TypeError("`t` must be an astropy Quantity with time units.")
distances = resolve_cosmological_distances(
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
)
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls.flux_bolometric_log_cgs(
t.cgs.value,
np.asarray(distances["redshift"], dtype=np.float64),
distances["luminosity_distance"].cgs.value,
**cgs_parameters,
)
[docs]
@classmethod
def flux_bolometric_cgs(
cls,
t: NumericalInput,
redshift: NumericalInput,
luminosity_distance: NumericalInput,
**parameters: CGSParameterValue,
) -> FloatResult:
"""
Observed bolometric flux, taking and returning plain cgs numbers.
Parameters
----------
t : array-like
Time since explosion, in seconds.
redshift : array-like
Cosmological redshift, dimensionless.
luminosity_distance : array-like
Luminosity distance, in cm.
**parameters
This model's parameter values, in cgs units. See :meth:`flux_log_cgs`.
Returns
-------
numpy.ndarray or float
The observed bolometric flux, in erg/s/cm^2. A scalar is
returned if the result is 0-dimensional.
"""
return np.exp(cls.flux_bolometric_log_cgs(t, redshift, luminosity_distance, **parameters))
[docs]
@classmethod
def flux_bolometric(
cls,
t: Quantity,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
**parameters: ParameterValue,
) -> Quantity:
r"""
Evaluate the observed bolometric flux at the given time.
Parameters
----------
t : ~astropy.units.Quantity
Observed time since explosion.
redshift : array-like or ~astropy.units.Quantity, optional
See :meth:`flux_log`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
angular_diameter_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
proper_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
cosmology : ~astropy.cosmology.FLRW, optional
See :meth:`flux_log`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
:math:`F_\mathrm{bol}(t)`, in erg/s/cm^2.
"""
return (
np.exp(
cls.flux_bolometric_log(
t,
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
**parameters,
)
)
* _BOL_FLUX_UNIT
)
# -------------------------------------- #
# Observed Band-Averaged Flux: F_band(t) #
# -------------------------------------- #
[docs]
@classmethod
def flux_band_log_cgs(
cls,
nu: NumericalInput,
throughput: NumericalInput,
t: NumericalInput,
redshift: NumericalInput,
luminosity_distance: NumericalInput,
*,
log_attenuation: NumericalInput | None = None,
**parameters: CGSParameterValue,
) -> FloatResult:
r"""
Natural log of the throughput-weighted mean flux density over a band, plain cgs numbers.
.. math::
\log \bar{F}_\nu = \log \frac{\int F_\nu(\nu, t) \cdot T(\nu)\,d\nu}{\int T(\nu)\,d\nu}
using :meth:`flux_cgs`'s (redshifted, distance-diluted) :math:`F_\nu`,
integrated over the observed-frame frequency grid ``nu`` and bandpass
response ``throughput`` by trapezoidal quadrature. Dividing by the
integrated throughput keeps the result dimensionally a flux density,
directly comparable to :meth:`flux_cgs`.
Because this method integrates away a frequency axis, ``t``,
``redshift``, ``luminosity_distance``, and every parameter are each
given one trailing axis internally so they broadcast against the
``nu`` grid; any shape you would otherwise pass unchanged to
:meth:`flux_log_cgs` still works here.
Parameters
----------
nu : array-like
Observed frequency grid to integrate over, in Hz, shape ``(K,)``.
Need not be sorted.
throughput : array-like
Dimensionless bandpass response at each ``nu`` sample, shape
``(K,)``.
t : array-like
Observed time since explosion, in seconds, any shape.
redshift : array-like
Cosmological redshift, dimensionless, any shape.
luminosity_distance : array-like
Luminosity distance, in cm, any shape.
log_attenuation : array-like, optional
Natural log of an observed-frame multiplicative attenuation,
sampled at the same ``nu`` grid (its last axis must have length
``K``, in ``nu``'s original, pre-sort order). Any leading axes
broadcast against ``t``/``redshift``/``luminosity_distance``/the
parameters.
**parameters
This model's parameter values, in cgs units, any shape.
Returns
-------
numpy.ndarray
The natural log of :math:`\bar{F}_\nu`, in erg/s/cm^2/Hz, with
the broadcast shape of ``t``/``redshift``/``luminosity_distance``/
the parameters (the frequency axis is integrated away).
"""
nu = np.asarray(nu, dtype=np.float64)
throughput = np.asarray(throughput, dtype=np.float64)
order = np.argsort(nu)
nu_sorted = nu[order]
throughput_sorted = throughput[order]
band_ready_parameters: dict[str, CGSParameterValue] = {
name: np.asarray(value, dtype=np.float64)[..., np.newaxis] for name, value in parameters.items()
}
log_flux_density = cls._eval_flux(
nu_sorted,
np.asarray(t, dtype=np.float64)[..., np.newaxis],
np.asarray(redshift, dtype=np.float64)[..., np.newaxis],
np.asarray(luminosity_distance, dtype=np.float64)[..., np.newaxis],
log_attenuation=(
None if log_attenuation is None else np.asarray(log_attenuation, dtype=np.float64)[..., order]
),
**band_ready_parameters,
)
numerator = np.trapezoid(np.exp(log_flux_density) * throughput_sorted, nu_sorted, axis=-1)
denominator = np.trapezoid(throughput_sorted, nu_sorted)
return np.log(numerator / denominator)
[docs]
@classmethod
def flux_band_log(
cls,
nu: Quantity,
throughput: NumericalInput,
t: Quantity,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
log_attenuation: NumericalInput | None = None,
**parameters: ParameterValue,
) -> FloatResult:
r"""
Natural log of the band-averaged observed flux density, given physical-unit inputs.
Parameters
----------
nu : ~astropy.units.Quantity
Observed frequency grid to integrate over. Need not be sorted.
throughput : array-like
Dimensionless bandpass response at each ``nu`` sample.
t : ~astropy.units.Quantity
Observed time since explosion.
redshift : array-like or ~astropy.units.Quantity, optional
See :meth:`flux_log`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
angular_diameter_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
proper_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
cosmology : ~astropy.cosmology.FLRW, optional
See :meth:`flux_log`.
log_attenuation : array-like, optional
See :meth:`flux_band_log_cgs`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
numpy.ndarray or float
The natural log of :math:`\\bar{F}_\\nu`, in erg/s/cm^2/Hz.
Raises
------
TypeError
If ``nu``/``t`` are not Quantities with frequency/time units.
"""
if not isinstance(nu, Quantity) or nu.unit.physical_type != "frequency":
raise TypeError("`nu` must be an astropy Quantity with frequency units.")
if not isinstance(t, Quantity) or t.unit.physical_type != "time":
raise TypeError("`t` must be an astropy Quantity with time units.")
distances = resolve_cosmological_distances(
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
)
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return cls.flux_band_log_cgs(
nu.cgs.value,
throughput,
t.cgs.value,
np.asarray(distances["redshift"], dtype=np.float64),
distances["luminosity_distance"].cgs.value,
log_attenuation=log_attenuation,
**cgs_parameters,
)
[docs]
@classmethod
def flux_band_cgs(
cls,
nu: NumericalInput,
throughput: NumericalInput,
t: NumericalInput,
redshift: NumericalInput,
luminosity_distance: NumericalInput,
*,
log_attenuation: NumericalInput | None = None,
**parameters: CGSParameterValue,
) -> FloatResult:
r"""
Band-averaged observed flux density as plain cgs numbers; see :meth:`flux_band_log_cgs`.
Parameters
----------
nu : array-like
Observed frequency grid to integrate over, in Hz. Need not be sorted.
throughput : array-like
Dimensionless bandpass response at each ``nu`` sample.
t : array-like
Observed time since explosion, in seconds.
redshift : array-like
Cosmological redshift, dimensionless.
luminosity_distance : array-like
Luminosity distance, in cm.
log_attenuation : array-like, optional
See :meth:`flux_band_log_cgs`.
**parameters
This model's parameter values, in cgs units. See :meth:`flux_band_log_cgs`.
Returns
-------
numpy.ndarray or float
:math:`\bar{F}_\nu`, in erg/s/cm^2/Hz. A scalar is returned if
the result is 0-dimensional.
"""
return np.exp(
cls.flux_band_log_cgs(
nu,
throughput,
t,
redshift,
luminosity_distance,
log_attenuation=log_attenuation,
**parameters,
)
)
[docs]
@classmethod
def flux_band(
cls,
nu: Quantity,
throughput: NumericalInput,
t: Quantity,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
log_attenuation: NumericalInput | None = None,
**parameters: ParameterValue,
) -> Quantity:
r"""
Evaluate the throughput-weighted mean observed flux density over a band.
Parameters
----------
nu : ~astropy.units.Quantity
Observed frequency grid to integrate over. Need not be sorted.
throughput : array-like
Dimensionless bandpass response at each ``nu`` sample.
t : ~astropy.units.Quantity
Observed time since explosion.
redshift : array-like or ~astropy.units.Quantity, optional
See :meth:`flux_log`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
angular_diameter_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
proper_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
cosmology : ~astropy.cosmology.FLRW, optional
See :meth:`flux_log`.
log_attenuation : array-like, optional
See :meth:`flux_band_log_cgs`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
:math:`\bar{F}_\nu`, in erg/s/cm^2/Hz.
"""
return (
np.exp(
cls.flux_band_log(
nu,
throughput,
t,
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
log_attenuation=log_attenuation,
**parameters,
)
)
* _SPEC_FLUX_UNIT
)
# -------------------------------------- #
# Apparent AB Magnitudes #
# -------------------------------------- #
# A magnitude already is a logarithmic quantity, so there is no separate
# "log" form here -- just a bare-float `*_cgs` form and a unitful form
# returning an `astropy.units.Magnitude` (`u.ABmag`).
[docs]
@classmethod
def mag_cgs(
cls,
nu: NumericalInput,
t: NumericalInput,
redshift: NumericalInput,
luminosity_distance: NumericalInput,
*,
log_attenuation: NumericalInput | None = None,
**parameters: CGSParameterValue,
) -> FloatResult:
r"""
Apparent AB magnitude: :math:`m_\mathrm{AB} = -2.5 \log_{10}(F_\nu / F_{\mathrm{AB},0})`.
:math:`F_\nu` is :meth:`flux_cgs`'s observed flux density;
:math:`F_{\mathrm{AB},0} = 3631` Jy. See :meth:`flux_log_cgs` for the
meaning of ``log_attenuation``.
Parameters
----------
nu : array-like
Observed frequency, in Hz.
t : array-like
Observed time since explosion, in seconds.
redshift : array-like
Cosmological redshift, dimensionless.
luminosity_distance : array-like
Luminosity distance, in cm.
log_attenuation : array-like, optional
See :meth:`flux_log_cgs`.
**parameters
This model's parameter values, in cgs units. See :meth:`flux_log_cgs`.
Returns
-------
numpy.ndarray or float
The apparent AB magnitude. A scalar is returned if the result is
0-dimensional.
"""
F_nu = cls.flux_cgs(
nu,
t,
redshift,
luminosity_distance,
log_attenuation=log_attenuation,
**parameters,
)
return -2.5 * np.log10(F_nu / AB_MAG_ZERO_POINT)
[docs]
@classmethod
def mag(
cls,
nu: Quantity,
t: Quantity,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
log_attenuation: NumericalInput | None = None,
**parameters: ParameterValue,
) -> Quantity:
"""
Evaluate the apparent AB magnitude at the given frequency and time.
Parameters
----------
nu : ~astropy.units.Quantity
Observed frequency.
t : ~astropy.units.Quantity
Observed time since explosion.
redshift : array-like or ~astropy.units.Quantity, optional
See :meth:`flux_log`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
angular_diameter_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
proper_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
cosmology : ~astropy.cosmology.FLRW, optional
See :meth:`flux_log`.
log_attenuation : array-like, optional
See :meth:`flux_log_cgs`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
The apparent AB magnitude, as an :attr:`~astropy.units.ABmag` Quantity.
"""
if not isinstance(nu, Quantity) or nu.unit.physical_type != "frequency":
raise TypeError("`nu` must be an astropy Quantity with frequency units.")
if not isinstance(t, Quantity) or t.unit.physical_type != "time":
raise TypeError("`t` must be an astropy Quantity with time units.")
distances = resolve_cosmological_distances(
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
)
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return (
cls.mag_cgs(
nu.cgs.value,
t.cgs.value,
np.asarray(distances["redshift"], dtype=np.float64),
distances["luminosity_distance"].cgs.value,
log_attenuation=log_attenuation,
**cgs_parameters,
)
* u.ABmag
)
[docs]
@classmethod
def mag_band_cgs(
cls,
nu: NumericalInput,
throughput: NumericalInput,
t: NumericalInput,
redshift: NumericalInput,
luminosity_distance: NumericalInput,
*,
log_attenuation: NumericalInput | None = None,
**parameters: CGSParameterValue,
) -> FloatResult:
"""
Apparent AB magnitude of the band-averaged flux density. See :meth:`flux_band_cgs`/:meth:`mag_cgs`.
Broadcasting (including ``log_attenuation``'s) follows :meth:`flux_band_cgs`'s rules exactly.
Parameters
----------
nu : array-like
Observed frequency grid to integrate over, in Hz. Need not be sorted.
throughput : array-like
Dimensionless bandpass response at each ``nu`` sample.
t : array-like
Observed time since explosion, in seconds.
redshift : array-like
Cosmological redshift, dimensionless.
luminosity_distance : array-like
Luminosity distance, in cm.
log_attenuation : array-like, optional
See :meth:`flux_band_log_cgs`.
**parameters
This model's parameter values, in cgs units. See :meth:`flux_band_log_cgs`.
Returns
-------
numpy.ndarray or float
The apparent AB magnitude of the band-averaged flux density. A
scalar is returned if the result is 0-dimensional.
"""
F_nu = cls.flux_band_cgs(
nu,
throughput,
t,
redshift,
luminosity_distance,
log_attenuation=log_attenuation,
**parameters,
)
return -2.5 * np.log10(F_nu / AB_MAG_ZERO_POINT)
[docs]
@classmethod
def mag_band(
cls,
nu: Quantity,
throughput: NumericalInput,
t: Quantity,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
log_attenuation: NumericalInput | None = None,
**parameters: ParameterValue,
) -> Quantity:
"""
Evaluate the apparent AB magnitude of the band-averaged flux density.
Parameters
----------
nu : ~astropy.units.Quantity
Observed frequency grid to integrate over. Need not be sorted.
throughput : array-like
Dimensionless bandpass response at each ``nu`` sample.
t : ~astropy.units.Quantity
Observed time since explosion.
redshift : array-like or ~astropy.units.Quantity, optional
See :meth:`flux_log`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
angular_diameter_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
proper_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
cosmology : ~astropy.cosmology.FLRW, optional
See :meth:`flux_log`.
log_attenuation : array-like, optional
See :meth:`flux_band_log_cgs`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
The apparent AB magnitude, as an :attr:`~astropy.units.ABmag` Quantity.
"""
if not isinstance(nu, Quantity) or nu.unit.physical_type != "frequency":
raise TypeError("`nu` must be an astropy Quantity with frequency units.")
if not isinstance(t, Quantity) or t.unit.physical_type != "time":
raise TypeError("`t` must be an astropy Quantity with time units.")
distances = resolve_cosmological_distances(
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
)
cgs_parameters: dict[str, CGSParameterValue] = {name: to_cgs_value(value) for name, value in parameters.items()}
return (
cls.mag_band_cgs(
nu.cgs.value,
throughput,
t.cgs.value,
np.asarray(distances["redshift"], dtype=np.float64),
distances["luminosity_distance"].cgs.value,
log_attenuation=log_attenuation,
**cgs_parameters,
)
* u.ABmag
)
[docs]
@classmethod
def mag_bandpass(
cls,
bandpass: SpectralElement,
t: Quantity,
*,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
log_attenuation: NumericalInput | None = None,
**parameters: ParameterValue,
) -> Quantity:
"""
Evaluate the apparent AB magnitude of the flux averaged over `bandpass`.
Convenience wrapper around :meth:`mag_band` that reads its ``nu`` and
``throughput`` straight off `bandpass` instead of requiring them as
separate arguments -- see :meth:`mag_bandpass_cgs`.
Parameters
----------
bandpass : ~synphot.SpectralElement
The bandpass to average over, e.g. one of an
`~m4opt.synphot.Detector`'s own
`~m4opt.synphot.Detector.bandpasses`.
t : ~astropy.units.Quantity
Observed time since explosion.
redshift : array-like or ~astropy.units.Quantity, optional
See :meth:`flux_log`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
angular_diameter_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
proper_distance : ~astropy.units.Quantity, optional
See :meth:`flux_log`.
cosmology : ~astropy.cosmology.FLRW, optional
See :meth:`flux_log`.
log_attenuation : array-like, optional
See :meth:`flux_band_log_cgs`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
~astropy.units.Quantity
The apparent AB magnitude, as an :attr:`~astropy.units.ABmag` Quantity.
"""
wave = bandpass.waveset
nu = wave.to(u.Hz, equivalencies=u.spectral())
throughput = bandpass(wave).to_value(u.dimensionless_unscaled)
return cls.mag_band(
nu,
throughput,
t,
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
log_attenuation=log_attenuation,
**parameters,
)
# -------------------------------------- #
# Detector-Aware (Noisy) Photometry #
# -------------------------------------- #
[docs]
def simulate_photometry(
self,
t: PhysicalInput,
exptime: Quantity,
detector: Detector,
coord: SkyCoord,
*,
background: SourceSpectrum | None = None,
bands: list | None = None,
observer_location: EarthLocation | None = None,
obstime: Time | None = None,
redshift: PhysicalInput | None = None,
luminosity_distance: Quantity | None = None,
angular_diameter_distance: Quantity | None = None,
proper_distance: Quantity | None = None,
cosmology: FLRW | None = None,
ebv: float | Quantity | FloatArray | None = None,
dust_law: str | None = None,
n_sigma: float | None = None,
sys_err: float | Mapping[str, float] | None = None,
rng: RNGInput = None,
**parameters: ParameterValue,
) -> QTable:
r"""
Simulate noisy synthetic photometry of this model at given time(s), against a real detector.
The noise-aware counterpart to :meth:`mag`/:meth:`flux`/:meth:`as_source_spectrum`:
those give the noiseless truth; this adds the detector's own noise model
(:meth:`~m4opt.synphot.Detector.get_snr`, the same OIR CCD equation
:class:`~astropy.stats.signal_to_noise_oir_ccd` implements) and reports a
synthetic *measurement* -- one Gaussian realization of the true flux at each
requested time and band, at that time/band's own implied uncertainty.
Deliberately independent of any :class:`~uvex_transients.surveys.base.SurveySchedule`
or :class:`~uvex_transients.simulation.event.Event`: `t`/`exptime` are whatever
times/exposures the caller wants evaluated (e.g. a target-of-opportunity's own
chosen observing cadence), not ones a schedule was queried for. `background` is
a required, explicit choice for the same reason -- rather than an implicit
default read off `detector`. This lets a caller decide what physics to include
(dust, via `ebv`, is always folded into the source flux itself; the
Milky Way's diffuse UV glow via
`~m4opt.synphot.background.GalacticBackground`; zodiacal light via
`~m4opt.synphot.background.ZodiacalBackground`; ...) without needing to know, let
alone undo, whatever combination a particular `detector` happens to ship with
(e.g. :data:`m4opt.missions.uvex`'s own detector defaults to
``GalacticBackground() + ZodiacalBackground()``). If `background` is given, it
replaces `detector`'s own ``background`` for this call only (`detector` itself is
never mutated); if omitted, `detector`'s own ``background`` is used unchanged.
`observer_location`/`obstime` matter only insofar as `background` actually
depends on them: `GalacticBackground` reads only sky position (`coord`, by way of
`m4opt.synphot.observing`'s target coordinate), so for a dust-and-Galactic-only
call -- e.g. a target of opportunity whose real observing time isn't known yet --
neither needs to be real, and both default to fixed placeholders. Pass real values
(e.g. a mission's own ``observer_location(obstime)``) only when `background`
includes a term that actually varies with them, such as `ZodiacalBackground`
(sun-relative sky position, from `obstime`) or `EarthshineBackground`
(spacecraft position, from `observer_location`).
Parameters
----------
t : array-like or Quantity
Time(s) since explosion, shape ``(N,)`` (or scalar, promoted to shape
``(1,)``) -- one entry per requested observation. Unlike
:meth:`as_source_spectrum`, no manual trailing batch axis is needed; this
method inserts and removes it internally.
exptime : ~astropy.units.Quantity
Exposure duration(s), scalar (applied to every entry of `t`) or shape
matching `t`.
detector : ~m4opt.synphot.Detector
Supplies bandpasses, collecting area, plate scale, and detector noise terms
(dark/read noise, gain, ...). Its own ``background`` is used only if
`background` isn't given -- see above.
coord : ~astropy.coordinates.SkyCoord
Scalar sky position of the target.
background : ~synphot.SourceSpectrum, optional
Sky background surface brightness to simulate against for this call --
e.g. `~m4opt.synphot.background.GalacticBackground()` alone, or combined
with others via ``+``. If `None` (the default), `detector`'s own
``background`` is used.
bands : list, optional
Which of `detector`'s bandpasses to evaluate. Defaults to every bandpass
`detector` has.
observer_location : ~astropy.coordinates.EarthLocation, optional
See above; defaults to a fixed placeholder location, correct whenever
`background` doesn't depend on it.
obstime : ~astropy.time.Time, optional
See above; defaults to a fixed placeholder epoch, correct whenever
`background` doesn't depend on it.
redshift : array-like or ~astropy.units.Quantity, optional
See :meth:`as_source_spectrum`.
luminosity_distance : ~astropy.units.Quantity, optional
See :meth:`as_source_spectrum`.
angular_diameter_distance : ~astropy.units.Quantity, optional
See :meth:`as_source_spectrum`.
proper_distance : ~astropy.units.Quantity, optional
See :meth:`as_source_spectrum`.
cosmology : ~astropy.cosmology.FLRW, optional
See :meth:`as_source_spectrum`.
ebv, dust_law : float, ~astropy.units.Quantity, numpy.ndarray, or str, optional
See :meth:`as_source_spectrum`.
n_sigma : float, optional
Width, in multiples of ``flux_err``, of the ``flux_upper``/``flux_lower``/
``mag_upper``/``mag_lower`` interval. If `None` (the default), uses
``config["simulation.detection_n_sigma"]`` (5 out of the box).
sys_err : float or Mapping[str, float], optional
A per-band systematic calibration error floor, in magnitudes, combined in
quadrature with the shot-noise uncertainty `detector` itself computes (via
:meth:`~m4opt.synphot.Detector.get_snr`, the OIR CCD equation -- source and
sky Poisson noise plus detector read/dark noise only, with no notion of
flat-fielding, PSF-fit, or zeropoint calibration systematics). Folded into
the noise realization itself, not just reported as a wider `flux_err` around
an unchanged draw -- otherwise the scatter of simulated points across
repeated visits would be narrower than what their own error bars claim.
A bare `float` applies the same floor to every band in `bands`; a mapping
must have an entry for every band in `bands`. If `None` (the default, and
the prior behavior), no systematic floor is added.
rng : numpy.random.Generator, int, or None
Random-number source for the noise realization; see :func:`~uvex_transients.utils.get_rng`.
**parameters
This model's parameter values. See :meth:`eval_log_cgs`.
Returns
-------
astropy.table.QTable
One row per (time, band), sorted by ``t`` then ``band``, with columns
``t``, ``exptime``, ``band``, ``snr``, ``flux``/``flux_err`` (Jy),
``flux_upper``/``flux_lower`` (Jy, ``flux ± n_sigma*flux_err``),
``ab_mag``/``mag_err``, and ``mag_upper``/``mag_lower`` -- the ``n_sigma``
interval transformed to magnitude, brighter bound first. See
:meth:`~uvex_transients.simulation.event.Event.simulate_photometry` for the
exact semantics of every column (this method implements the same math).
If `sys_err` is given, ``snr``/``flux_err``/``mag_err`` (and everything
derived from them) reflect the combined shot-noise-plus-systematic
uncertainty.
Raises
------
ValueError
If `coord` is not scalar, if `bands` contains a name `detector` doesn't
have, if `exptime` is neither scalar nor shaped like `t`, or if `sys_err`
is a mapping missing an entry for one of `bands`.
"""
if n_sigma is None:
n_sigma = config["simulation.detection_n_sigma"]
if background is not None:
detector = replace(detector, background=background)
band_names = list(detector.bandpasses) if bands is None else list(bands)
unknown = [band for band in band_names if band not in detector.bandpasses]
if unknown:
raise ValueError(f"Unknown bandpass(es) {unknown}; available: {list(detector.bandpasses)}.")
if isinstance(sys_err, Mapping):
missing_sys_err = [band for band in band_names if band not in sys_err]
if missing_sys_err:
raise ValueError(f"'sys_err' is missing entries for band(s) {missing_sys_err}.")
if not coord.isscalar:
raise ValueError("Parameter 'coord' must be a scalar SkyCoord.")
t = np.atleast_1d(u.Quantity(t))
exptime = u.Quantity(exptime)
if exptime.isscalar:
exptime = np.broadcast_to(exptime, t.shape, subok=True)
elif exptime.shape != t.shape:
raise ValueError(f"'exptime' must be scalar or match 't' shape {t.shape}, got {exptime.shape}.")
n_obs = t.shape[0]
if observer_location is None:
observer_location = _PLACEHOLDER_OBSERVER_LOCATION
if obstime is None:
obstime = _PLACEHOLDER_OBSTIME
rng = get_rng(rng)
# Trailing batch axis reserved for `t`, as in `Event.simulate_photometry` --
# keeps this batch from colliding with whatever wavelength grid the spectrum
# is later called with (e.g. a bandpass's `waveset`).
spectra = self.as_source_spectrum(
t[:, np.newaxis],
redshift=redshift,
luminosity_distance=luminosity_distance,
angular_diameter_distance=angular_diameter_distance,
proper_distance=proper_distance,
cosmology=cosmology,
ebv=ebv,
dust_law=dust_law,
**parameters,
)
tables = []
with observing(observer_location, coord, obstime):
for band in band_names:
snr = detector.get_snr(exptime, spectra, band)
band_sys_err = sys_err[band] if isinstance(sys_err, Mapping) else sys_err
pivot = detector.bandpasses[band].pivot()
with np.errstate(invalid="ignore", divide="ignore"):
true_flux = np.squeeze(spectra(pivot, flux_unit=u.Jy).to_value(u.Jy), axis=-1)
valid = np.isfinite(snr) & (snr > 0)
safe_snr = np.where(valid, snr, np.nan)
flux_err = true_flux / safe_snr
reported_snr = snr
if band_sys_err:
# `sys_err` is a fixed fractional-magnitude floor; convert to a
# fractional flux error (exact for the same small-error limit
# `mag_err = 2.5 / (ln(10) * snr)` already assumes) and combine
# in quadrature with the shot-noise flux error above, before
# anything is drawn from it -- so the noise realization itself
# carries the systematic scatter, not just a wider reported bar
# around an unchanged draw.
flux_err = np.hypot(flux_err, np.abs(true_flux) * band_sys_err * np.log(10) / 2.5)
reported_snr = np.where(valid, true_flux / flux_err, snr)
flux = rng.normal(true_flux, np.where(valid, np.abs(flux_err), 1.0))
flux = np.where(valid, flux, np.nan)
mag_err = np.where(valid, 2.5 / (np.log(10) * np.abs(reported_snr)), np.nan)
mag = np.where(flux > 0, (flux * u.Jy).to_value(u.ABmag), np.nan)
flux_upper = flux + n_sigma * flux_err
flux_lower = flux - n_sigma * flux_err
mag_lower = np.where(flux_upper > 0, (flux_upper * u.Jy).to_value(u.ABmag), np.nan)
mag_upper = np.where(flux_lower > 0, (flux_lower * u.Jy).to_value(u.ABmag), np.nan)
band_table = QTable()
band_table["t"] = t
band_table["exptime"] = exptime
band_table["band"] = np.full(n_obs, band)
band_table["snr"] = reported_snr
band_table["flux"] = flux * u.Jy
band_table["flux_err"] = flux_err * u.Jy
band_table["flux_upper"] = flux_upper * u.Jy
band_table["flux_lower"] = flux_lower * u.Jy
band_table["ab_mag"] = mag
band_table["mag_err"] = mag_err
band_table["mag_upper"] = mag_upper
band_table["mag_lower"] = mag_lower
tables.append(band_table)
table = vstack(tables)
order = np.lexsort((table["band"], table["t"].to_value(t.unit)))
return table[order]
# -------------------------------------- #
# Simulation #
# -------------------------------------- #
[docs]
def simulate(self, nu: Quantity, t: Quantity, size: int = 1, *, rng: RNGInput = None) -> Quantity:
r"""
Draw random parameter realizations and evaluate the model at the given frequency and time.
Equivalent to ``self.eval(nu, t, **self.sample_parameters(size=size, rng=rng))``.
Parameters
----------
nu : ~astropy.units.Quantity
Frequency at which to evaluate the model. See :meth:`eval`.
t : ~astropy.units.Quantity
Time since explosion. See :meth:`eval`.
size : int
Number of realizations to draw.
rng : numpy.random.Generator, int, or None
Random-number source, forwarded to :meth:`sample_parameters`.
Returns
-------
~astropy.units.Quantity
:math:`L_\nu(\nu, t)`, in erg/s/Hz. :meth:`sample_parameters`
always returns array-valued parameters, even for ``size=1``, so
the batch axis is never squeezed away here.
See Also
--------
eval : The underlying evaluation.
sample_parameters : The underlying sampling.
"""
return self.eval(nu, t, **self.sample_parameters(size=size, rng=rng))
[docs]
class ComposedSpectralModel(SpectralModel):
r"""
A :class:`SpectralModel` built by pairing a :class:`Lightcurve` with a :class:`Spectrum`.
.. math::
L_\nu(\nu, t) = L_\mathrm{bol}(t) \cdot \frac{S(\nu)}{\int S(\nu')\,d\nu'}
where :math:`L_\mathrm{bol}(t)` is :attr:`_LIGHTCURVE_CLASS`'s bolometric
luminosity and the fraction is :attr:`_SPECTRUM_CLASS`'s shape,
normalized (via :meth:`Spectrum.eval_normalization`) to integrate to 1
over :math:`\nu`. Because both halves are already exact on their own,
:meth:`_eval_bolometric` and :meth:`_eval_spectrum` are closed-form
combinations of the two components' own primitives -- unlike
:class:`SpectralModel`'s generic defaults, neither ever falls back to
numerical integration over frequency here.
A concrete SED is defined just by naming the two component classes:
.. code-block:: python
class MySED(ComposedSpectralModel):
_LIGHTCURVE_CLASS = FREDLightcurve
_SPECTRUM_CLASS = BlackbodySpectrum
At class-definition time, :meth:`__init_subclass__` merges
``_LIGHTCURVE_CLASS._DEFAULT_PARAMETERS`` and
``_SPECTRUM_CLASS._DEFAULT_PARAMETERS`` into this subclass's own
:attr:`~SpectralModel._DEFAULT_PARAMETERS` (any entries the subclass
declares directly itself win, as overrides on top of that merge) -- so
from then on, ``MySED`` behaves exactly like any other
:class:`SpectralModel` subclass: ``MySED()`` or ``MySED(amplitude=...,
temperature=...)``, one flat parameter namespace. Every parameter name
must be unique across the two component classes, checked at
class-definition time.
Because every :class:`SpectralModel` method is a classmethod operating
purely on ``**parameters`` (see the module docstring), no instance-level
wiring of components is needed: :meth:`_eval_bolometric`,
:meth:`_eval_spectrum`, and :meth:`_eval` simply split the incoming
``parameters`` dict by name and call :attr:`_LIGHTCURVE_CLASS`'s/
:attr:`_SPECTRUM_CLASS`'s own classmethods directly.
See Also
--------
Lightcurve : The time-only half of this composition.
Spectrum : The frequency-only half of this composition.
"""
_LIGHTCURVE_CLASS: ClassVar[type[Lightcurve] | None] = None
"""type of Lightcurve, or None: The bolometric lightcurve class driving this SED's time dependence.
``None`` on :class:`ComposedSpectralModel` itself; every concrete
subclass must set this (and :attr:`_SPECTRUM_CLASS`) directly in its own
class body.
"""
_SPECTRUM_CLASS: ClassVar[type[Spectrum] | None] = None
"""type of Spectrum, or None: The spectral shape class driving this SED's frequency dependence."""
# -------------------------------------- #
# Subclass Validation #
# -------------------------------------- #
def __init_subclass__(cls, **kwargs) -> None:
"""
Merge the component classes' parameters into :attr:`_DEFAULT_PARAMETERS`.
Parameters
----------
**kwargs
Forwarded to :meth:`object.__init_subclass__` unchanged; this
class declares no class-keyword-argument options of its own.
"""
super().__init_subclass__(**kwargs)
if cls._LIGHTCURVE_CLASS is None or cls._SPECTRUM_CLASS is None:
return
if not (isinstance(cls._LIGHTCURVE_CLASS, type) and issubclass(cls._LIGHTCURVE_CLASS, Lightcurve)):
raise TypeError(f"{cls.__name__}._LIGHTCURVE_CLASS must be a Lightcurve subclass.")
if not (isinstance(cls._SPECTRUM_CLASS, type) and issubclass(cls._SPECTRUM_CLASS, Spectrum)):
raise TypeError(f"{cls.__name__}._SPECTRUM_CLASS must be a Spectrum subclass.")
overlap = set(cls._LIGHTCURVE_CLASS._DEFAULT_PARAMETERS) & set(cls._SPECTRUM_CLASS._DEFAULT_PARAMETERS)
if overlap:
raise TypeError(
f"{cls.__name__}: _LIGHTCURVE_CLASS and _SPECTRUM_CLASS share "
f"parameter name(s) {sorted(overlap)}; every parameter name must "
"be unique across the two."
)
cls._DEFAULT_PARAMETERS = {
**cls._LIGHTCURVE_CLASS._DEFAULT_PARAMETERS,
**cls._SPECTRUM_CLASS._DEFAULT_PARAMETERS,
**cls.__dict__.get("_DEFAULT_PARAMETERS", {}),
}
# -------------------------------------- #
# Parameter Splitting #
# -------------------------------------- #
@classmethod
def _split_parameters(
cls, parameters: Mapping[str, CGSParameterValue]
) -> tuple[dict[str, CGSParameterValue], dict[str, CGSParameterValue]]:
"""
Split a flat ``parameters`` dict into ``(lightcurve_parameters, spectrum_parameters)``.
Parameters
----------
parameters : mapping of str to (float or numpy.ndarray)
This model's full, flat parameter dict, keyed by name.
Returns
-------
tuple of (dict of str to (float or numpy.ndarray))
``(lightcurve_parameters, spectrum_parameters)``, each holding
only the entries of `parameters` belonging to that component.
Raises
------
TypeError
If :attr:`_LIGHTCURVE_CLASS` or :attr:`_SPECTRUM_CLASS` is unset.
"""
if cls._LIGHTCURVE_CLASS is None or cls._SPECTRUM_CLASS is None:
raise TypeError(
f"{cls.__name__} must be subclassed with both _LIGHTCURVE_CLASS "
"and _SPECTRUM_CLASS set before it can be evaluated."
)
return (
{name: parameters[name] for name in cls._LIGHTCURVE_CLASS._DEFAULT_PARAMETERS},
{name: parameters[name] for name in cls._SPECTRUM_CLASS._DEFAULT_PARAMETERS},
)
# -------------------------------------- #
# 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 :attr:`_LIGHTCURVE_CLASS`.
Exact, not an approximation: unlike
:meth:`SpectralModel._eval_bolometric`'s generic numerical-quadrature
fallback, no integration is needed here -- the lightcurve's own
:meth:`Lightcurve._eval` already *is* the bolometric luminosity, by
construction.
Parameters
----------
t : numpy.ndarray
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``t``.
Returns
-------
numpy.ndarray
The natural log of :math:`L_\mathrm{bol}(t)`, in erg/s.
"""
lightcurve_parameters, _ = cls._split_parameters(parameters)
# `_split_parameters` already raises if either class is unset.
assert cls._LIGHTCURVE_CLASS is not None
return cls._LIGHTCURVE_CLASS._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)`, delegated to :attr:`_SPECTRUM_CLASS` and normalized to integrate to 1 (``t`` is unused).
Exact, not an approximation: divides out the spectrum's own
:meth:`Spectrum._eval_normalization` directly, rather than falling
back to :meth:`SpectralModel._eval_spectrum`'s
bolometric-integral-subtraction default.
Parameters
----------
nu : numpy.ndarray
Frequency, in Hz.
t : numpy.ndarray
Time since explosion, in seconds. Unused (the shape is already
normalized independently of time); accepted only to match
:meth:`SpectralModel._eval_spectrum`'s signature.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``nu``.
Returns
-------
numpy.ndarray
The natural log of :math:`S(\nu)`, in 1/Hz.
"""
_, spectrum_parameters = cls._split_parameters(parameters)
# `_split_parameters` already raises if either class is unset.
assert cls._SPECTRUM_CLASS is not None
return cls._SPECTRUM_CLASS._eval(nu, **spectrum_parameters) - cls._SPECTRUM_CLASS._eval_normalization(
**spectrum_parameters
)
# -------------------------------------- #
# 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)`.
Since this sums :meth:`_eval_bolometric`'s (``t``-shaped) and
:meth:`_eval_spectrum`'s (``nu``-shaped) results, ``nu``/``t`` must
already broadcast against each other the way the caller wants --
see :meth:`SpectralModel._eval`'s broadcasting contract.
Parameters
----------
nu : numpy.ndarray
Frequency, in Hz.
t : numpy.ndarray
Time since explosion, in seconds.
**parameters
This model's parameter values, in cgs units, broadcastable
against ``nu``/``t``.
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)