Source code for uvex_transients.models.spectra.powerlaw

r"""
Power-law spectral shapes.

:class:`PowerLawSpectrum` represents a single truncated power law

.. math::

    S(\nu) = \frac{1}{\nu_0}\left(\frac{\nu}{\nu_0}\right)^\alpha,

between finite lower and upper frequency cutoffs and zero elsewhere. The
reference frequency :math:`\nu_0` fixes the otherwise arbitrary dimensional
normalization of the power law, while the finite cutoffs make the
frequency integral well-defined -- a pure, unbroken power law cannot have a
finite integral over :math:`0 < \nu < \infty`, since at least one end always
diverges.

:class:`BrokenPowerLawSpectrum` generalizes this to two power-law segments
that meet continuously at a ``break_frequency``, which doubles as the
reference frequency for both segments (so no separate normalization
constant is needed to keep the two pieces continuous).

Neither shape is normalized to integrate to 1 by construction; each
overrides :meth:`~uvex_transients.models.core.base.Spectrum._eval_normalization`
with the closed-form integral of its own shape (see
:func:`~uvex_transients.models._util_functions.powerlaw_shape_integral_cgs`).
"""

from typing import ClassVar

import numpy as np
from astropy import units as u

from uvex_transients.models._typing import CGSParameterValue, FloatArray
from uvex_transients.models.core.base import Spectrum
from uvex_transients.models.core.parameters import Parameter
from uvex_transients.models.core.priors import ConstantPrior, NormalPrior

from .._util_functions import powerlaw_shape_integral_cgs

__all__ = ["BrokenPowerLawSpectrum", "PowerLawSpectrum"]


[docs] class PowerLawSpectrum(Spectrum): r""" Finite-band power-law spectral shape. Represents :math:`S(\nu) = \nu_0^{-1}(\nu/\nu_0)^\alpha` between ``frequency_min`` and ``frequency_max``, and zero outside that interval. ``spectral_index`` is the usual frequency-space index :math:`\alpha` defined by :math:`S(\nu) \propto \nu^\alpha`. The default ``spectral_index`` is sampled from a Gaussian prior centered on -1. ``reference_frequency`` and the cutoffs use constant priors by default, making them deterministic unless replaced or overridden explicitly. .. rubric:: Parameters The spectral shape parameters are summarized below. .. list-table:: :header-rows: 1 :widths: 18 18 64 * - Parameter - Symbol - Description * - ``spectral_index`` - :math:`\alpha` - Frequency-space power-law index, S(nu) proportional to nu^alpha. * - ``reference_frequency`` - :math:`\nu_0` - Reference frequency anchoring the power-law shape. * - ``frequency_min`` - :math:`\nu_{\min}` - Lower frequency cutoff of the power-law spectrum. * - ``frequency_max`` - :math:`\nu_{\max}` - Upper frequency cutoff of the power-law spectrum. """ _DEFAULT_PARAMETERS: ClassVar[dict[str, Parameter]] = { "spectral_index": Parameter( prior=NormalPrior(mean=-1.0, sigma=0.5), scale=1.0, description="Frequency-space power-law index, S(nu) proportional to nu^alpha.", latex=r"\alpha", ), "reference_frequency": Parameter( prior=ConstantPrior(value=1.0), scale=1e15 * u.Hz, description="Reference frequency anchoring the power-law shape.", latex=r"\nu_0", ), "frequency_min": Parameter( prior=ConstantPrior(value=1.0), scale=1e12 * u.Hz, description="Lower frequency cutoff of the power-law spectrum.", latex=r"\nu_{\min}", ), "frequency_max": Parameter( prior=ConstantPrior(value=1.0), scale=1e20 * u.Hz, description="Upper frequency cutoff of the power-law spectrum.", latex=r"\nu_{\max}", ), } # ----------------------------------- # # Shape: S(nu) # # ----------------------------------- # # Narrowing `**parameters` to this model's own named parameters (rather # than matching the base class's generic `**parameters` exactly) is the # intended pattern for every `_eval` override -- see `Spectrum._eval`. @classmethod def _eval( # type: ignore[override] cls, nu: FloatArray, *, spectral_index: CGSParameterValue, reference_frequency: CGSParameterValue, frequency_min: CGSParameterValue, frequency_max: CGSParameterValue, ) -> FloatArray: valid = (nu >= frequency_min) & (nu <= frequency_max) safe_nu = np.where(valid, nu, reference_frequency) log_shape = spectral_index * (np.log(safe_nu) - np.log(reference_frequency)) - np.log(reference_frequency) return np.where(valid, log_shape, -np.inf) # ----------------------------------- # # Normalization: integral of S(nu) dnu # # ----------------------------------- # @classmethod def _eval_normalization(cls, **parameters: CGSParameterValue) -> FloatArray: """Natural log of the analytic power-law integral over ``[frequency_min, frequency_max]``.""" return np.log( powerlaw_shape_integral_cgs( parameters["spectral_index"], parameters["reference_frequency"], parameters["frequency_min"], parameters["frequency_max"], ) )
[docs] class BrokenPowerLawSpectrum(Spectrum): r""" Finite-band, two-segment power-law spectral shape. Represents .. math:: S(\nu) = \nu_{\rm break}^{-1} \left(\frac{\nu}{\nu_{\rm break}}\right)^{\alpha_1 \,\mathrm{or}\,\alpha_2}, using index :math:`\alpha_1` below ``break_frequency`` and :math:`\alpha_2` above it, and zero outside ``[frequency_min, frequency_max]``. Because both segments are anchored to the same ``break_frequency``, :math:`S(\nu)` is automatically continuous there (both give :math:`\nu_{\rm break}^{-1}`) without any extra matching condition. The default indices are sampled from Gaussian priors centered on -1 (below the break) and -2 (above it) -- a generic steepening spectrum; like any other parameter, both can be overridden per instance. ``break_frequency`` and the cutoffs use constant priors by default, making them deterministic unless replaced or overridden explicitly. .. rubric:: Parameters The spectral shape parameters are summarized below. .. list-table:: :header-rows: 1 :widths: 18 18 64 * - Parameter - Symbol - Description * - ``spectral_index_1`` - :math:`\alpha_1` - Power-law index below ``break_frequency``. * - ``spectral_index_2`` - :math:`\alpha_2` - Power-law index above ``break_frequency``. * - ``break_frequency`` - :math:`\nu_{\rm break}` - Frequency at which the spectral index switches from alpha_1 to alpha_2. * - ``frequency_min`` - :math:`\nu_{\min}` - Lower frequency cutoff of the spectrum. * - ``frequency_max`` - :math:`\nu_{\max}` - Upper frequency cutoff of the spectrum. """ _DEFAULT_PARAMETERS: ClassVar[dict[str, Parameter]] = { "spectral_index_1": Parameter( prior=NormalPrior(mean=-1.0, sigma=0.5), scale=1.0, description="Power-law index below ``break_frequency``.", latex=r"\alpha_1", ), "spectral_index_2": Parameter( prior=NormalPrior(mean=-2.0, sigma=0.5), scale=1.0, description="Power-law index above ``break_frequency``.", latex=r"\alpha_2", ), "break_frequency": Parameter( prior=ConstantPrior(value=1.0), scale=1e15 * u.Hz, description="Frequency at which the spectral index switches from alpha_1 to alpha_2.", latex=r"\nu_{\rm break}", ), "frequency_min": Parameter( prior=ConstantPrior(value=1.0), scale=1e12 * u.Hz, description="Lower frequency cutoff of the spectrum.", latex=r"\nu_{\min}", ), "frequency_max": Parameter( prior=ConstantPrior(value=1.0), scale=1e20 * u.Hz, description="Upper frequency cutoff of the spectrum.", latex=r"\nu_{\max}", ), } # ----------------------------------- # # Shape: S(nu) # # ----------------------------------- # # Narrowing `**parameters` to this model's own named parameters (rather # than matching the base class's generic `**parameters` exactly) is the # intended pattern for every `_eval` override -- see `Spectrum._eval`. @classmethod def _eval( # type: ignore[override] cls, nu: FloatArray, *, spectral_index_1: CGSParameterValue, spectral_index_2: CGSParameterValue, break_frequency: CGSParameterValue, frequency_min: CGSParameterValue, frequency_max: CGSParameterValue, ) -> FloatArray: valid = (nu >= frequency_min) & (nu <= frequency_max) safe_nu = np.where(valid, nu, break_frequency) alpha = np.where(safe_nu < break_frequency, spectral_index_1, spectral_index_2) log_shape = alpha * (np.log(safe_nu) - np.log(break_frequency)) - np.log(break_frequency) return np.where(valid, log_shape, -np.inf) # ----------------------------------- # # Normalization: integral of S(nu) dnu # # ----------------------------------- # @classmethod def _eval_normalization(cls, **parameters: CGSParameterValue) -> FloatArray: """Natural log of the analytic integral, summed over the two segments.""" break_frequency = parameters["break_frequency"] integral = powerlaw_shape_integral_cgs( parameters["spectral_index_1"], break_frequency, parameters["frequency_min"], break_frequency, ) + powerlaw_shape_integral_cgs( parameters["spectral_index_2"], break_frequency, break_frequency, parameters["frequency_max"], ) return np.log(integral)