Source code for jdaviz.models.physical_models

# Licensed under a 3-clause BSD style license - see LICENSE.rst
# copied and modified from astropy: https://github.com/astropy/astropy/pull/12304
"""
Models that have physical origins.
"""
# pylint: disable=invalid-name, no-member

import warnings

import numpy as np

from astropy import constants as const
from astropy import units as u
from astropy.utils.exceptions import AstropyUserWarning
from astropy.modeling.core import Fittable1DModel
from astropy.modeling.parameters import Parameter

__all__ = ["BlackBody"]

# ASTROPY_LT_5_3
__doctest_requires__ = {"BlackBody": ["astropy<5.3"]}


[docs] class BlackBody(Fittable1DModel): """ Blackbody model using the Planck function. Parameters ---------- temperature : `~astropy.units.Quantity` ['temperature'] Blackbody temperature. scale : float or `~astropy.units.Quantity` ['dimensionless'] Scale factor output_units: `~astropy.units.CompositeUnit` or string Desired output units when evaluating. If a unit object, must be equivalent to one of erg / (cm ** 2 * s * Hz * sr) or erg / (cm ** 2 * s * AA * sr) for surface brightness or erg / (cm ** 2 * s * Hz) or erg / (cm ** 2 * s * AA) for flux density (in which case the pi is included internally and should not be passed in `scale` - see notes below). If a string, can be one of 'SNU', 'SLAM', 'FNU', 'FLAM' which reference the units above, respectively. Notes ----- Model formula: .. math:: B_{\\nu}(T) = A \\frac{2 h \\nu^{3} / c^{2}}{exp(h \\nu / k T) - 1} Deprecated support for non-dimensionless units in `scale`: If `scale` is passed with non-dimensionless units that are equivalent to one of the supported `output_units`, the unit is stripped and interpreted as `output_units` and the float value will account for the scale between the passed units and native `output_units`. If `scale` includes flux units, the value will be divided by pi (since solid angle is included in the units), and re-added internally when returning results. Note that this can result in ambiguous and unexpected results. To avoid confusion, pass unitless `scale` and `output_units` separately. Examples -------- >>> from astropy.modeling import models >>> from astropy import units as u >>> bb = models.BlackBody(temperature=5000*u.K) >>> bb(6000 * u.AA) # doctest: +FLOAT_CMP <Quantity 1.53254685e-05 erg / (cm2 Hz s sr)> .. plot:: :include-source: import numpy as np import matplotlib.pyplot as plt from astropy.modeling.models import BlackBody from astropy import units as u from astropy.visualization import quantity_support bb = BlackBody(temperature=5778*u.K) wav = np.arange(1000, 110000) * u.AA flux = bb(wav) with quantity_support(): plt.figure() plt.semilogx(wav, flux) plt.axvline(bb.nu_max.to_value(u.AA, equivalencies=u.spectral()), ls='--') plt.show() """ # We parametrize this model with a temperature and a scale. temperature = Parameter(default=5000.0, min=0, unit=u.K, description="Blackbody temperature") scale = Parameter(default=1.0, min=0, description="Scale factor") # We allow values without units to be passed when evaluating the model, and # in this case the input x values are assumed to be frequencies in Hz or wavelengths # in AA (depending on the choice of output_units). _input_units_allow_dimensionless = True # We enable the spectral equivalency by default for the spectral axis input_units_equivalencies = {'x': u.spectral()} # Store the native units returned by B_nu equation _native_units = u.erg / (u.cm ** 2 * u.s * u.Hz * u.sr) # Store the base native output units. If scale is passed with units # equivalent to these, the dimensionless factor is converted to these # units. _native_output_units = {'FNU': u.erg / (u.cm ** 2 * u.s * u.Hz), 'FLAM': u.erg / (u.cm ** 2 * u.s * u.AA), 'SNU': u.erg / (u.cm ** 2 * u.s * u.Hz * u.sr), 'SLAM': u.erg / (u.cm ** 2 * u.s * u.AA * u.sr)} # Until unit-support on scale is removed, let's raise a warning for the ambiguous cases # when calling bolometric_flux _bolometric_flux_ambig_warn = False def __init__(self, *args, **kwargs): output_units = kwargs.pop('output_units', None) scale = kwargs.get('scale', None) # DEPRECATE: for now we'll continue to support scale with non-dimensionless units # by stripping the unit and applying that to output_units. if hasattr(scale, 'unit') and not scale.unit.is_equivalent(u.dimensionless_unscaled): if output_units is None: # NOTE: output_units will have to pass validation when assigned below # so we don't need to duplicate the checks here output_units = scale.unit # NOTE: if the scale units are equivalent (but not identical) to the # "native" output units, we want to convert the dimensionless scale # to account for the scaling between these units. This is self-consistent # with the treatment of pi below for flux units, and addresses # https://github.com/astropy/astropy/issues/11547#issuecomment-823772098 # but is a change in behavior from the previous treatment where the # float value was treated as the scale factor, ignoring units for native_output_unit in self._native_output_units.values(): if output_units.is_equivalent(native_output_unit): scale = scale.to(native_output_unit) break # If the scale had FNU or FLAM units, then the scale quantity INCLUDES # pi*u.sr. We need to remove the pi when passing the dimensionless # scale as it will be re-added later in evaluate. if output_units.is_equivalent(self._native_units*u.sr, u.spectral_density(1*u.Hz)): kwargs['scale'] = scale.value / np.pi else: kwargs['scale'] = scale.value self._bolometric_flux_ambig_warn = True else: # Do not allow passing scale with unit AND output_units # (even though they may be the same) raise ValueError("cannot pass output_units and scale with units") self.output_units = output_units return super().__init__(*args, **kwargs)
[docs] def evaluate(self, x, temperature, scale): """Evaluate the model. Parameters ---------- x : float, `~numpy.ndarray`, or `~astropy.units.Quantity` ['frequency'] Frequency at which to compute the blackbody. If no units are given, this defaults to Hz. temperature : float, `~numpy.ndarray`, or `~astropy.units.Quantity` Temperature of the blackbody. If no units are given, this defaults to Kelvin. scale : float, `~numpy.ndarray`, or `~astropy.units.Quantity` ['dimensionless'] Desired scale for the blackbody. Returns ------- y : number or ndarray Blackbody spectrum. The units are determined from the units of ``scale``. .. note:: Use `numpy.errstate` to suppress Numpy warnings, if desired. .. warning:: Output values might contain ``nan`` and ``inf``. Raises ------ ValueError Invalid temperature. ZeroDivisionError Wavelength is zero (when converting to frequency). """ if not isinstance(temperature, u.Quantity): in_temp = u.Quantity(temperature, u.K) else: in_temp = temperature if not isinstance(x, u.Quantity): # then we assume it has input_units which depends on the # requested output units (either Hz or AA) in_x = u.Quantity(x, self.input_units['x']) else: in_x = x # Convert to units for calculations, also force double precision with u.add_enabled_equivalencies(u.spectral() + u.temperature()): freq = u.Quantity(in_x, u.Hz, dtype=np.float64) temp = u.Quantity(in_temp, u.K) # Check if input values are physically possible if np.any(temp < 0): raise ValueError(f"Temperature should be positive: {temp}") if not np.all(np.isfinite(freq)) or np.any(freq <= 0): warnings.warn( "Input contains invalid wavelength/frequency value(s)", AstropyUserWarning, ) log_boltz = const.h * freq / (const.k_B * temp) boltzm1 = np.expm1(log_boltz) # Calculate blackbody flux bb_nu = 2.0 * const.h * freq ** 3 / (const.c ** 2 * boltzm1) / u.sr if self.scale.unit is not None: # Will be dimensionless at this point, but may not be dimensionless_unscaled if not hasattr(scale, 'unit'): # during fitting, scale will be passed without units # but we still need to convert from the input dimensionless # to dimensionless unscaled scale = scale * self.scale.unit scale = scale.to(u.dimensionless_unscaled) if self.output_units.is_equivalent(self._native_units, u.spectral_density(freq)): # then requesting SNU or SLAM bb_unit = self.output_units scale = scale # dimensionless else: # then requesting FNU or FLAM, which means the bb_unit # needs to be the requested output units without sr # and we'll then multiply by pi * u.sr separately bb_unit = self.output_units / u.sr scale = scale * np.pi * u.sr y = scale * bb_nu.to(bb_unit, u.spectral_density(freq)) # If the temperature parameter has no unit, we should return a unitless # value. This occurs for instance during fitting, since we drop the # units temporarily. if hasattr(temperature, "unit"): return y return y.value
@property def output_units(self): if self._output_units is None: # default to SNU if not passed (or set internally from units on scale) return u.erg / (u.cm ** 2 * u.s * u.Hz * u.sr) return self._output_units @output_units.setter def output_units(self, unit): """ Ensure `output_units` is valid.""" if isinstance(unit, str): if unit in ['SNU', 'SLAM', 'FNU', 'FLAM']: # let's provide some convenience for passing these as strings unit = self._native_output_units.get(unit) else: try: unit = u.Unit(unit) except ValueError: # then the string wasn't a valid unit, we'll allow the error below to be raised pass if unit is None: pass elif isinstance(unit, u.UnitBase): # support SNU, SLAM, FNU, FLAM output units (and equivalent) if not (unit.is_equivalent(self._native_units, u.spectral_density(1*u.AA)) or unit.is_equivalent(self._native_units*u.sr, u.spectral_density(1*u.AA))): raise ValueError(f"output_units not in surface brightness or flux density: {unit}") else: raise ValueError("output_units must be of type Unit, None, " "or one of 'SNU', 'SLAM', 'FNU', 'FLAM'") self._output_units = unit @property def input_units(self): # The input units are those of the 'x' value, which will depend on the # units compatible with the expected output units. output_units = self.output_units if (output_units.is_equivalent(self._native_output_units['SNU']) or output_units.is_equivalent(self._native_output_units['FNU'])): return {self.inputs[0]: u.Hz} else: return {self.inputs[0]: u.AA} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): return {"temperature": u.K} @property def bolometric_flux(self): """Bolometric flux.""" if self.scale.unit is not None: # Will be dimensionless at this point, but may not be dimensionless_unscaled scale = self.scale.quantity.to(u.dimensionless_unscaled) else: scale = self.scale.value if self._bolometric_flux_ambig_warn: warnings.warn( f"scale was originally passed with units, " f"but being treated as unitless with value={scale}", AstropyUserWarning, ) # bolometric flux in the native units of the planck function native_bolflux = ( scale * const.sigma_sb * self.temperature ** 4 / np.pi ) # return in more "astro" units return native_bolflux.to(u.erg / (u.cm ** 2 * u.s)) @property def lambda_max(self): """Peak wavelength when the curve is expressed as power density.""" return const.b_wien / self.temperature @property def nu_max(self): """Peak frequency when the curve is expressed as power density.""" return 2.8214391 * const.k_B * self.temperature / const.h