Dust absorption and emission

Dust absorbs the ultraviolet and optical light of a galaxy — from its stars, from the gas they ionize, and from an active nucleus — and re-radiates that energy in the infrared. This notebook analyzes a Galacticus model which follows both sides of that exchange.

The model is parameters/tutorials/dustEmission.xml. It builds a single merger tree for a halo of \(10^{12}\,M_\odot\), forming a galaxy with a supermassive black hole, and outputs it at redshift one — an epoch when it is actively forming stars — with:

  • spectra of the disk stars, the spheroid stars, and the AGN, and luminosities of emission lines from HII regions and from the narrow-line region of the AGN;

  • for each of these, attenuated by the dust model of Charlot & Fall (2000), the light transmitted, and the light absorbed by each phase of dust: birth clouds, which surround stars younger than 10 Myr, and the diffuse interstellar medium (a screen whose optical depth scales with the surface density of metals in the gas);

  • the spectrum of thermal emission from the dust. The light absorbed by birth clouds is re-emitted by warm dust at a fixed 50 K. The light absorbed by the diffuse medium heats PAHs — emitting in the single-photon approximation of Richie & Hensley (2026), with a spectrum set by the shape of the light they absorb — and larger grains, whose temperature follows from energy balance.

Run the model from the root of the Galacticus source tree before running this notebook:

./Galacticus.exe parameters/tutorials/dustEmission.xml

The output is read with dendros (pip install 'dendros[plot]').

[1]:
import os
import re
import numpy as np
import matplotlib.pyplot as plt
from dendros import open_outputs

# The model writes its output to the root of the Galacticus source tree. This notebook lives two levels below it
# (tutorials/models/); set GALACTICUS_EXEC_PATH to the root if you run it from elsewhere.
root       = os.environ.get('GALACTICUS_EXEC_PATH', os.path.abspath(os.path.join(os.getcwd(), os.pardir, os.pardir)))
outputFile = os.path.join(root, 'dustEmissionTutorial.hdf5')
if not os.path.exists(outputFile):
    raise FileNotFoundError(f"{outputFile} not found - run `./Galacticus.exe parameters/tutorials/dustEmission.xml` "
                            "from the root of the Galacticus source tree first")

# Physical constants, in the units Galacticus uses: wavelengths in Å, luminosities in L☉.
speedOfLight     = 2.99792458e18  # Å s⁻¹
luminositySolar  = 3.845e26       # W
plt.rcParams.update({'figure.dpi': 110, 'axes.grid': True, 'grid.alpha': 0.3})

Opening the output

open_outputs returns a collection which can list the outputs of a run, list the properties written for each galaxy, and read them. There is a single output, at redshift one.

[2]:
model = open_outputs(outputFile)
model.validate_completion()
for output in model.outputs:
    print(f'{output.name}: redshift {output.redshift:.3f}, expansion factor {output.scale_factor:.3f}, cosmic time {output.time:.3f} Gyr')
Output1: redshift 1.000, expansion factor 0.500, cosmic time 5.983 Gyr
[3]:
nodeData = model['Outputs/Output1/nodeData']
names    = sorted(nodeData.keys())
print(f"{len(names)} datasets per galaxy, for example:")
for name in names:
    if name in ('massStellarTotal', 'massBlackHole', 'totalStarFormationRate') or name.endswith(':dustAttenuated:charlotFall2000'):
        print("  ", name)
106 datasets per galaxy, for example:
   agnSED:dustAttenuated:charlotFall2000
   diskStellarSED:inoue2014:dustAttenuated:charlotFall2000
   luminosityEmissionLineAGN:balmerAlpha6565:dustAttenuated:charlotFall2000
   luminosityEmissionLineAGN:balmerBeta4863:dustAttenuated:charlotFall2000
   luminosityEmissionLineAGN:nitrogenII6585:dustAttenuated:charlotFall2000
   luminosityEmissionLineAGN:oxygenII3727:dustAttenuated:charlotFall2000
   luminosityEmissionLineAGN:oxygenIII5008:dustAttenuated:charlotFall2000
   luminosityEmissionLineTotal:balmerAlpha6565:dustAttenuated:charlotFall2000
   luminosityEmissionLineTotal:balmerBeta4863:dustAttenuated:charlotFall2000
   luminosityEmissionLineTotal:nitrogenII6585:dustAttenuated:charlotFall2000
   luminosityEmissionLineTotal:oxygenII3727:dustAttenuated:charlotFall2000
   luminosityEmissionLineTotal:oxygenIII5008:dustAttenuated:charlotFall2000
   massBlackHole
   massStellarTotal
   spheroidStellarSED:inoue2014:dustAttenuated:charlotFall2000
   totalStarFormationRate

The galaxies in the tree are all written; we analyze the one with the most stars — the central galaxy of the halo.

[4]:
def read(name):
    # Read one dataset for every galaxy in the output.
    # Values are returned in Galacticus' own units (as_quantity=False skips conversion to astropy Quantities).
    return model.read('Output1', [f'nodeData/{name}'], as_quantity=False)[f'nodeData/{name}']

massStellar = read('massStellarTotal')
galaxy      = int(np.argmax(massStellar))
print(f"stellar mass           : {massStellar[galaxy]:.3e} M☉")
print(f"black hole mass        : {read('massBlackHole')[galaxy]:.3e} M☉")
print(f"star formation rate    : {read('totalStarFormationRate')[galaxy]:.3e} M☉/Gyr")
stellar mass           : 1.198e+10 M☉
black hole mass        : 1.163e+07 M☉
star formation rate    : 3.738e+08 M☉/Gyr

Finding the spectra

Properties written by the dust attenuation extractor are named after the property they attenuate. For a source named X, X is the intrinsic luminosity, X:dustAttenuated:charlotFall2000 the luminosity transmitted through the dust, and X:dustAbsorbed:charlotFall2000:<phase> the luminosity absorbed by each phase. Spectra are arrays of \(L_\nu\) (in \(L_\odot\,\mathrm{Hz}^{-1}\)) with one column per wavelength; the wavelengths are in a companion ...ColumnValues dataset, written once for each distinct grid.

[5]:
attenuator = 'charlotFall2000'
phases     = sorted({name.split(':')[-1] for name in names if f':dustAbsorbed:{attenuator}:' in name and not name.endswith(('ColumnValues', 'Columns'))})
sources    = sorted({name.split(':dustAttenuated:')[0] for name in names if name.endswith(f':dustAttenuated:{attenuator}')})
spectra    = [source for source in sources if nodeData[source + f':dustAttenuated:{attenuator}'].ndim == 2]
lines      = [source for source in sources if nodeData[source + f':dustAttenuated:{attenuator}'].ndim == 1]
print("phases of dust :", phases)
print("spectra        :", spectra)
print("emission lines :", lines)

def wavelengths(name):
    # The wavelengths of a spectrum: its own column values, or those of another property of the same source, or of any
    # other property on a grid of the same length.
    count      = nodeData[name].shape[1]
    base       = name.split(':dust')[0]
    candidates = [name] + [other for other in names if other.split(':dust')[0] == base and not other.endswith(('ColumnValues', 'Columns'))]
    candidates = [candidate + 'ColumnValues' for candidate in candidates] + [other for other in names if other.endswith('ColumnValues')]
    for candidate in candidates:
        if candidate in nodeData and nodeData[candidate].shape[0] == count:
            return nodeData[candidate][:]
    raise KeyError(f"no wavelengths found for {name}")

def spectrum(name):
    return wavelengths(name), read(name)[galaxy]

def unitsInSI(name):
    # The factor converting a property to SI units, from the `unitsInSI` field of its `units` attribute.
    return nodeData[name].attrs['units']['unitsInSI']

def luminosityPerLogWavelength(wavelength, luminosityNu):
    # ν L_ν = λ L_λ, in L☉: the luminosity per unit ln λ.
    return speedOfLight / wavelength * luminosityNu

def positive(values):
    # Values for a logarithmic plot: zeros become gaps rather than vertical drops.
    return np.where(values > 0.0, values, np.nan)

def limitsLogarithmic(axes, curves, decades):
    # Set the vertical range of a logarithmic plot to span the given number of decades below the largest value plotted.
    peak = max(np.nanmax(curve) for curve in curves if np.any(np.isfinite(curve)))
    axes.set_ylim(peak / 10.0**decades, 2.0 * peak)
phases of dust : ['birthCloud', 'screenSurfaceDensityMetals']
spectra        : ['agnSED', 'diskStellarSED:inoue2014', 'spheroidStellarSED:inoue2014']
emission lines : ['luminosityEmissionLineAGN:balmerAlpha6565', 'luminosityEmissionLineAGN:balmerBeta4863', 'luminosityEmissionLineAGN:nitrogenII6585', 'luminosityEmissionLineAGN:oxygenII3727', 'luminosityEmissionLineAGN:oxygenIII5008', 'luminosityEmissionLineTotal:balmerAlpha6565', 'luminosityEmissionLineTotal:balmerBeta4863', 'luminosityEmissionLineTotal:nitrogenII6585', 'luminosityEmissionLineTotal:oxygenII3727', 'luminosityEmissionLineTotal:oxygenIII5008']

Starlight and AGN light, before and after dust

Each source is shown intrinsic (dashed) and after passing through the dust (solid). Dust removes most of the ultraviolet light, and reddens what remains. The AGN is seen through the dust of both the disk and the spheroid, but not through birth clouds, which surround only young stars. The black hole in this galaxy accretes slowly, so its AGN is faint — far below the starlight, and may fall below the plotted range; its light is treated in the same way as that of a luminous AGN, which a more massive halo (try massTree of \(10^{13}\,M_\odot\)) is more likely to host.

[6]:
labels = {'diskStellarSED': 'disk stars', 'spheroidStellarSED': 'spheroid stars', 'agnSED': 'AGN'}
def label(source):
    for key, value in labels.items():
        if source.startswith(key):
            return value
    return source

figure, axes = plt.subplots(figsize=(8, 5))
curves = []
for source, color in zip(spectra, plt.rcParams['axes.prop_cycle'].by_key()['color']):
    wavelength, intrinsic = spectrum(source)
    _         , attenuated = spectrum(source + f':dustAttenuated:{attenuator}')
    inRange = (wavelength > 912.0) & (wavelength < 1.0e5)
    curves += [positive(luminosityPerLogWavelength(wavelength, intrinsic))[inRange]]
    axes.plot(wavelength, positive(luminosityPerLogWavelength(wavelength, intrinsic )), color=color, ls='--', lw=1)
    axes.plot(wavelength, positive(luminosityPerLogWavelength(wavelength, attenuated)), color=color, ls='-' , lw=1.5, label=label(source))
axes.set_xscale('log'); axes.set_yscale('log')
axes.set_xlim(912.0, 1.0e5)
limitsLogarithmic(axes, curves, 5)
axes.set_xlabel('wavelength [Å]'); axes.set_ylabel(r'$\lambda L_\lambda$ [$L_\odot$]')
axes.set_title('intrinsic (dashed) and dust-attenuated (solid) spectra')
axes.legend()
plt.show()
../../_images/tutorials_models_01-dust-absorption-and-emission_10_0.png

Where the light is absorbed

The light absorbed by each phase of dust, summed over all sources. Birth clouds absorb the ultraviolet light of the youngest stars — a large share of the energy despite surrounding only a small fraction of the stellar mass — while the diffuse medium absorbs light from stars of all ages, and from the AGN.

[7]:
figure, axes = plt.subplots(figsize=(8, 5))
phaseLabels = {'birthCloud': 'birth clouds', 'screenSurfaceDensityMetals': 'diffuse interstellar medium'}
grid        = np.logspace(np.log10(912.0), 5.0, 600)
curves      = []
for phase in phases:
    total = np.zeros_like(grid)
    for source in spectra:
        wavelength, absorbed = spectrum(source + f':dustAbsorbed:{attenuator}:{phase}')
        total += np.interp(grid, wavelength, absorbed, left=0.0, right=0.0)
    curve = positive(luminosityPerLogWavelength(grid, total))
    if np.any(np.isfinite(curve)):
        curves.append(curve)
        axes.plot(grid, curve, label=phaseLabels.get(phase, phase))
    else:
        print(f"the {phaseLabels.get(phase, phase)} absorb no continuum light in this galaxy")
axes.set_xscale('log'); axes.set_yscale('log')
limitsLogarithmic(axes, curves, 4)
axes.set_xlabel('wavelength [Å]'); axes.set_ylabel(r'absorbed $\lambda L_\lambda$ [$L_\odot$]')
axes.set_title('light absorbed by each phase of dust')
axes.legend()
plt.show()
../../_images/tutorials_models_01-dust-absorption-and-emission_12_0.png

Re-emission in the infrared

The dust emission spectrum, dustEmissionSED:charlotFall2000, and the emission of each phase. Warm dust in birth clouds peaks near 60 μm. In the diffuse medium, PAHs produce the emission bands between 3 and 20 μm, and larger grains the far-infrared peak.

[8]:
emission = f'dustEmissionSED:{attenuator}'
figure, (axes, zoom) = plt.subplots(1, 2, figsize=(11, 4.5), gridspec_kw={'width_ratios': [3, 2]})
wavelength, total = spectrum(emission)
for target in (axes, zoom):
    target.plot(wavelength / 1.0e4, positive(luminosityPerLogWavelength(wavelength, total)), color='k', lw=1.5, label='total')
    for phase in phases:
        _, perPhase = spectrum(f'{emission}:{phase}')
        target.plot(wavelength / 1.0e4, positive(luminosityPerLogWavelength(wavelength, perPhase)), lw=1, label=phaseLabels.get(phase, phase))
axes.set_xscale('log'); axes.set_yscale('log')
axes.set_xlim(1.0, 1.0e3)
limitsLogarithmic(axes, [luminosityPerLogWavelength(wavelength, total)], 4)
axes.set_xlabel('wavelength [μm]'); axes.set_ylabel(r'$\lambda L_\lambda$ [$L_\odot$]')
axes.set_title('thermal emission from dust')
axes.legend()
from matplotlib.ticker import FixedLocator, FixedFormatter, NullFormatter
zoom.set_xscale('log'); zoom.set_xlim(3.0, 20.0)
inBand = (wavelength > 3.0e4) & (wavelength < 2.0e5)
zoom.set_ylim(0.0, 1.1 * luminosityPerLogWavelength(wavelength, total)[inBand].max())
zoom.xaxis.set_major_locator(FixedLocator([3.3, 6.2, 7.7, 11.3, 17.0]))
zoom.xaxis.set_major_formatter(FixedFormatter(['3.3', '6.2', '7.7', '11.3', '17']))
zoom.xaxis.set_minor_formatter(NullFormatter())
zoom.set_xlabel('wavelength [μm]')
zoom.set_title('PAH emission bands')
plt.tight_layout()
plt.show()
../../_images/tutorials_models_01-dust-absorption-and-emission_14_0.png

The panchromatic spectrum

Putting the two halves together: the light which escapes the dust, and the light the dust emits, compared with the intrinsic light of the galaxy.

[9]:
grid = np.logspace(np.log10(912.0), 7.5, 1500)
def onGrid(name):
    wavelength, values = spectrum(name)
    return np.interp(grid, wavelength, values, left=0.0, right=0.0)
intrinsic   = sum(onGrid(source                                     ) for source in spectra)
transmitted = sum(onGrid(source + f':dustAttenuated:{attenuator}'   ) for source in spectra)
dust        = onGrid(emission)
figure, axes = plt.subplots(figsize=(9, 5))
observed = transmitted + dust
axes.plot(grid / 1.0e4, positive(luminosityPerLogWavelength(grid, intrinsic  )), color='0.6', ls='--', label='intrinsic stars + AGN')
axes.plot(grid / 1.0e4, positive(luminosityPerLogWavelength(grid, transmitted)), color='C0' , label='transmitted through dust')
axes.plot(grid / 1.0e4, positive(luminosityPerLogWavelength(grid, dust       )), color='C3' , label='emitted by dust')
axes.plot(grid / 1.0e4, positive(luminosityPerLogWavelength(grid, observed   )), color='k'  , lw=1.5, label='observed')
axes.set_xscale('log'); axes.set_yscale('log')
limitsLogarithmic(axes, [positive(luminosityPerLogWavelength(grid, intrinsic)), positive(luminosityPerLogWavelength(grid, observed))], 4)
axes.set_xlabel('wavelength [μm]'); axes.set_ylabel(r'$\lambda L_\lambda$ [$L_\odot$]')
axes.set_title('the spectrum of the galaxy from the ultraviolet to the far-infrared')
axes.legend()
plt.show()
../../_images/tutorials_models_01-dust-absorption-and-emission_16_0.png

Energy balance

The dust must emit exactly the energy it absorbs. The absorbed luminosity is integrated over frequency for each spectrum, and summed with the absorbed luminosity of the emission lines (converted to \(L_\odot\) using their units). The emitted luminosity is integrated over the resolution elements of the dust emission spectrum. Adjacent elements are spaced by a factor \(f^2\) in wavelength, and each spans \(\lambda/f\) to \(\lambda f\), so its width in frequency is \(\nu (f - 1/f)\).

[10]:
absorbed = {}
for phase in phases:
    total = 0.0
    for source in spectra:
        wavelength, values = spectrum(source + f':dustAbsorbed:{attenuator}:{phase}')
        frequency = speedOfLight / wavelength
        total    += np.sum(0.5 * (values[1:] + values[:-1]) * np.abs(frequency[:-1] - frequency[1:]))
    for source in lines:
        name   = source + f':dustAbsorbed:{attenuator}:{phase}'
        total += read(name)[galaxy] * unitsInSI(name) / luminositySolar
    absorbed[phase] = total

wavelength, _ = spectrum(emission)
factor        = np.sqrt(wavelength[1] / wavelength[0])  # the factor f, from the spacing of the wavelength grid
for phase in phases:
    wavelength, values = spectrum(f'{emission}:{phase}')
    emitted = np.sum(values * speedOfLight / wavelength * (factor - 1.0 / factor))
    print(f"{phaseLabels.get(phase, phase):28s}: absorbed {absorbed[phase]:.4e} L☉, emitted {emitted:.4e} L☉, ratio {emitted / absorbed[phase]:.5f}")
birth clouds                : absorbed 1.8294e+09 L☉, emitted 1.8294e+09 L☉, ratio 1.00001
diffuse interstellar medium : absorbed 7.5659e+09 L☉, emitted 7.5671e+09 L☉, ratio 1.00017

Emission lines

Dust also attenuates emission lines. The ratio of H\(\alpha\) to H\(\beta\) — the Balmer decrement — is about 2.86 for unattenuated gas, and grows as dust preferentially removes the bluer H\(\beta\). Lines from HII regions pass through birth clouds as well as the diffuse medium, so are more heavily attenuated than the continuum of the stars which power them; lines from the AGN pass only through the diffuse medium.

[11]:
def lineLuminosity(source, suffix=''):
    name = source + suffix
    return read(name)[galaxy] * unitsInSI(name) / luminositySolar

groups = {}
for source in lines:
    match = re.match(r'(.*):(balmerAlpha6565|balmerBeta4863|oxygenII3727|oxygenIII5008|nitrogenII6585)$', source)
    if match:
        groups.setdefault(match.group(1), {})[match.group(2)] = source
for group, members in groups.items():
    if not {'balmerAlpha6565', 'balmerBeta4863'} <= set(members):
        continue
    kind = 'AGN narrow-line region' if 'AGN' in group else 'HII regions'
    hydrogenAlpha, hydrogenBeta = members['balmerAlpha6565'], members['balmerBeta4863']
    ratioIntrinsic  = lineLuminosity(hydrogenAlpha) / lineLuminosity(hydrogenBeta) if hydrogenAlpha in nodeData else float('nan')
    ratioAttenuated = lineLuminosity(hydrogenAlpha, f':dustAttenuated:{attenuator}') / lineLuminosity(hydrogenBeta, f':dustAttenuated:{attenuator}')
    print(f"{kind:24s}: Hα/Hβ intrinsic {ratioIntrinsic:.2f}, attenuated {ratioAttenuated:.2f}")
    for line, source in sorted(members.items()):
        transmitted = lineLuminosity(source, f':dustAttenuated:{attenuator}')
        intrinsic   = lineLuminosity(source) if source in nodeData else float('nan')
        print(f"    {line:16s}: {intrinsic:.3e} L☉ intrinsic, {transmitted:.3e} L☉ transmitted ({transmitted / intrinsic:.1%})")
AGN narrow-line region  : Hα/Hβ intrinsic 2.77, attenuated 6.45
    balmerAlpha6565 : 3.492e+01 L☉ intrinsic, 9.429e-01 L☉ transmitted (2.7%)
    balmerBeta4863  : 1.260e+01 L☉ intrinsic, 1.463e-01 L☉ transmitted (1.2%)
    nitrogenII6585  : 4.840e+00 L☉ intrinsic, 1.317e-01 L☉ transmitted (2.7%)
    oxygenII3727    : 1.608e+01 L☉ intrinsic, 7.497e-02 L☉ transmitted (0.5%)
    oxygenIII5008   : 1.844e+02 L☉ intrinsic, 2.345e+00 L☉ transmitted (1.3%)
HII regions             : Hα/Hβ intrinsic 2.81, attenuated 7.78
    balmerAlpha6565 : 1.426e+07 L☉ intrinsic, 1.822e+05 L☉ transmitted (1.3%)
    balmerBeta4863  : 5.078e+06 L☉ intrinsic, 2.342e+04 L☉ transmitted (0.5%)
    nitrogenII6585  : 1.824e+06 L☉ intrinsic, 2.347e+04 L☉ transmitted (1.3%)
    oxygenII3727    : 2.907e+06 L☉ intrinsic, 4.463e+03 L☉ transmitted (0.2%)
    oxygenIII5008   : 1.989e+07 L☉ intrinsic, 1.025e+05 L☉ transmitted (0.5%)

Going further

  • Change the dust model: dustAttenuation accepts other attenuators — screenSurfaceDensityMetals alone, or the radiative transfer atlases atlasFerrara2000 and atlasCompendium (for which the spectra of stars, but not of the AGN, can be attenuated).

  • Change the emission: replace blackBodyModified by the templates of Dale & Helou (2002) (daleHelou2002) or the models of Draine & Li (2007) (draineLi2007), or vary the PAH size distribution and ionization (sizeDistribution, ionizationFunction) of richieHensley2026.

  • Run the model at higher redshift, or for a suite of trees, and compare the infrared luminosity with the star formation rate.