"""A submodule defining blackbody dust emission generators."""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import numpy as np
from unyt import Hz, angstrom, c, erg, s, unyt_array, unyt_quantity
from synthesizer import exceptions
from synthesizer.emission_models.base_model import EmissionModel
from synthesizer.emission_models.generators.dust.dust_emission_base import (
DustEmission,
)
from synthesizer.emissions import LineCollection, Sed
from synthesizer.units import accepts
from synthesizer.utils import planck
if TYPE_CHECKING:
from synthesizer.components.component import Component
[docs]
class Blackbody(DustEmission):
"""A class to generate a blackbody emission spectrum.
This can be used to generate standalone normalised blackbody spectra, or
it can be used to generate scaled blackbody spectra by providing either
a scaler EmissionModel or an intrinsic/attenuated EmissionModel pair to
scale the blackbody spectrum accordingly. For more details see the base
DustEmission parent class.
Attributes:
temperature (unyt_quantity):
The temperature of the blackbody.
temperature_z (unyt_quantity):
The temperature of the blackbody at redshift z, accounting for
CMB heating. Stores the last used temperature (important when
used with emitter temperatures).
cmb_factor (float):
The multiplicative factor to account for CMB heating at
high-redshift.
"""
temperature: unyt_quantity
def __init__(
self,
temperature: unyt_quantity,
do_cmb_heating: bool = False,
intrinsic: Optional[EmissionModel] = None,
attenuated: Optional[EmissionModel] = None,
scaler: Optional[EmissionModel] = None,
) -> None:
"""Initialise the blackbody base class.
Args:
temperature (unyt_array):
The temperature of the blackbody.
do_cmb_heating (bool):
Should we apply cmb heating?
intrinsic (EmissionModel):
The name of the intrinsic emission defining the unattenuated
emission for energy balance.
attenuated (EmissionModel):
The name of the attenuated emission defining the attenuated
emission for energy balance.
scaler (EmissionModel):
The name of the emission to use to scale the dust emission by.
"""
self.temperature = temperature
DustEmission.__init__(
self,
intrinsic,
attenuated,
scaler,
do_cmb_heating,
required_params=("temperature",),
)
def __repr__(self):
"""Return a string representation of the Blackbody object."""
# Start with base class repr components
parts = []
if self.is_energy_balance:
parts.append(
f"intrinsic={self._intrinsic.label}, "
f"attenuated={self._attenuated.label}"
)
elif self.is_scaled:
parts.append(f"scaler={self._scaler.label}")
# Add temperature
parts.append(f"temperature={self.temperature}")
# Add CMB heating if enabled
if self.do_cmb_heating:
parts.append("do_cmb_heating=True")
return f"Blackbody({', '.join(parts)})"
@accepts(lams=angstrom)
def _generate_spectra(
self,
lams: unyt_array,
emitter: Component,
model: EmissionModel,
emissions: dict,
redshift: float = 0,
) -> Sed:
"""Generate the dust emission spectra.
Note that the temperature here will be extracted using this priority
order:
1. From the EmissionModels fixed parameters
2. From the emitter object
3. From the Blackbody object itself
This is handle internally using the Generator's _extract_params method.
Args:
lams (unyt_array):
The wavelength grid on which to generate the spectra.
emitter (Stars/Gas/BlackHole/Galaxy):
The object emitting the emission.
model (EmissionModel):
The emission model generating the emission.
emissions (dict):
Dictionary containing all emissions generated so far.
redshift (float):
The redshift at which to calculate the CMB heating. (Ignored
if not applying CMB heating).
Returns:
Sed:
The generated dust emission SED.
"""
# Get the required parameters
params = self._extract_params(model, emitter)
# Unpack the temperature
temperature = params["temperature"]
# Define frequencies
nu = (c / lams).to(Hz)
# Account for CMB heating
cmb_factor, temperature = self.apply_cmb_heating(
temperature=temperature,
emissivity=1.0, # Blackbody emissivity=1
redshift=redshift,
)
# Compute the planck function
lnu = planck(nu, temperature)
# Create an SED object for convenience
sed = Sed(lam=lams, lnu=lnu)
# Get the bolometric luminosity with proper units
bol_lum = sed._bolometric_luminosity
# Normalise the blackbody spectrum
sed._lnu /= bol_lum
lnu = sed._lnu
# Get the scaling we will need
scaling = self.get_scaling(emitter, model, emissions)
# Handle per particle scaling (we need to expand the scaling shape)
if model is not None and model.per_particle:
if not hasattr(scaling, "shape"):
# scaling is a float, need to convert to array
scaling = np.full(emitter.nparticles, scaling)
scaling = scaling[:, np.newaxis]
# Properly handle units: normalize then scale
result = lnu * scaling * cmb_factor
sed._lnu = result.value if hasattr(result, "value") else result
return sed
@accepts(line_lams=angstrom)
def _generate_lines(
self,
line_ids,
line_lams,
emitter,
model,
emissions,
spectra,
redshift=0,
) -> LineCollection:
"""Generate line emission spectra.
Blackbody emission only contributes to line continuum. Line
luminosities will always be zero.
Args:
line_ids (list):
The IDs of the lines to generate.
line_lams (unyt_array):
The wavelength grid on which to generate the line spectra.
emitter (Stars/Gas/BlackHole/Galaxy):
The object emitting the emission.
model (EmissionModel):
The emission model generating the emission.
emissions (dict):
Dictionary containing all emissions generated so far.
spectra (dict):
Dictionary containing all spectra generated so far
(used for scaling).
redshift (float):
The redshift at which to calculate the CMB heating. (Ignored
if not applying CMB heating).
Returns:
LineCollection:
The generated line collection.
"""
# If we are missing this spectra then we cannot generate lines
if model.label not in spectra:
raise exceptions.MissingSpectraType(
f"Cannot generate lines for {model.label} as spectra are "
"missing. Please generate spectra first or remove the "
"generation of lines."
)
else:
sed = spectra[model.label]
# Get the required parameters
params = self._extract_params(model, emitter)
# Unpack the temperature
temperature = params["temperature"]
# Define frequencies
nu = (c / line_lams).to(Hz)
sed_nu = sed.nu
# Account for CMB heating
cmb_factor, temperature = self.apply_cmb_heating(
temperature=temperature,
emissivity=1.0, # Blackbody emissivity=1
redshift=redshift,
)
# Compute the planck function
lnu = planck(nu, temperature)
norm_lnu = planck(sed_nu, temperature)
# Create an SED object for convenience
norm_sed = Sed(lam=sed.lam, lnu=norm_lnu)
# Get the bolometric luminosity with proper units
bol_lum = norm_sed._bolometric_luminosity
# Normalise the blackbody spectrum
lnu /= bol_lum
# Normalise the spectrum and apply scaling with proper unit handling
scaling = self.get_scaling(emitter, model, spectra)
# Handle per particle scaling (we need to expand the scaling shape)
if model is not None and model.per_particle:
if not hasattr(scaling, "shape"):
# scaling is a float, need to convert to array
scaling = np.full(emitter.nparticles, scaling)
scaling = scaling[:, np.newaxis]
# Properly handle units: normalize then scale
lnu = (lnu * scaling * cmb_factor).value
# OK, now we have used the Sed magic lets return the LineCollection
# the outside world expects. Note that the line luminosities are
# by definition zero for generated lines and the contribution only
# goes to the continuum.
lines = LineCollection(
line_ids,
line_lams,
np.zeros(lnu.shape) * erg / s,
cont=lnu * erg / s / Hz,
)
return lines
[docs]
@accepts(lams=angstrom)
def get_spectra(self, lams: unyt_array, redshift: float = 0) -> Sed:
"""Generate dust emission spectra for given wavelength grid.
This will return the scaling free spectra for a given wavelength grid.
It will not consider any emitter or model, so the temperature must
have been provided directly to the class.
Args:
lams (unyt_array):
The wavelength grid on which to generate the spectra.
redshift (float):
The redshift at which to calculate the CMB heating.
Returns:
Sed:
The generated dust emission SED.
"""
return self._generate_spectra(lams, None, None, {}, redshift)