Modeling X-ray dust scattering halos

The xdust.halos module computes the surface brightness profile of an X-ray dust scattering halo under a variety of geometric assumptions. This functionality is provided by two subclasses of xdust.halos.Halo:

  • xdust.halos.UniformGalHalo assumes the scattering dust is distributed uniformly along the line of sight between the telescope and the X-ray point source.

  • xdust.halos.ScreenGalHalo assumes the scattering dust is confined to an infinitesimally thin screen at position \(x = 1 - d/D\), where \(d\) is the distance from the telescope to the screen and \(D\) is the distance from the telescope to the X-ray point source.

See the halos module reference for the full API, including the UniformGalHaloCP15 and ScreenGalHaloCP15 shortcuts for the semi-analytic model of Corrales & Paerels (2015).

By the end of this tutorial, you will be able to:

  • initialize a UniformGalHalo and a ScreenGalHalo over an energy and angle grid

  • run a scattering halo calculation for a given GrainPop

  • verify a halo calculation by integrating its surface brightness profile

  • compute the halo intensity produced by an X-ray point-source spectrum

  • slice a Halo object by energy, the same way you would slice a numpy array

import numpy as np
import matplotlib.pyplot as plt

import astropy.units as u
import astropy.constants as c

import xdust

Setting up a scattering halo

To initialize a scattering halo object, we need a grid of photon energies and a grid of angular distances (the angles at which the scattering halo is visible around the point source).

n_energy, n_theta = 50, 200
energy_grid = np.logspace(-1, 1, n_energy) * u.keV
theta_grid = np.logspace(-1, 4, n_theta) * u.arcsec

uniform_halo = xdust.halos.UniformGalHalo(energy_grid, theta_grid)
screen_halo = xdust.halos.ScreenGalHalo(energy_grid, theta_grid)

To run the calculation, each halo also needs to know what kind of dust grains are doing the scattering – their size distribution and composition, stored in a xdust.GrainPop object.

Below, we use the Rayleigh-Gans-plus-Drude approximation, which treats a dust grain as a cloud of free electrons and is therefore relatively insensitive to grain composition (unlike Mie scattering). The xdust.make_MRN_RGDrude shortcut builds a standard power-law grain size distribution (Mathis, Rumpl & Nordsieck 1977) using the RG-Drude approximation.

dust_pop = xdust.make_MRN_RGDrude(md=1.e-6)

Now we run the calculate method for each halo, passing in the dust population.

For the screen halo, we choose \(x = 0.33\), meaning the dust screen sits two-thirds of the way along the line of sight between the telescope and the background X-ray point source. The %%time cell magic prints how long each calculation takes.

%%time
screen_halo.calculate(dust_pop, x=0.33)
CPU times: user 5.82 ms, sys: 8.96 ms, total: 14.8 ms
Wall time: 14.6 ms

For dust distributed uniformly along the line of sight, the calculation instead integrates the scattering profile over many positions between the telescope and the point source. The nx keyword sets how fine that integration grid is – try changing it to see how it affects both the calculation speed and the shape of the resulting profile (plotted below).

%%time
uniform_halo.calculate(dust_pop, nx=100)
CPU times: user 626 ms, sys: 77 μs, total: 626 ms
Wall time: 626 ms

Plotting the surface brightness profile

The results are stored in the norm_int attribute, with shape (len(energy_grid), len(theta_grid)) – the normalized intensity (surface brightness divided by flux) as a function of energy and angle. To see a single profile, pick an energy index.

Below we plot an arbitrary energy index; try other values to see how the halo profile shape changes with energy.

i = 10

plt.plot(theta_grid, screen_halo.norm_int[i, :], label='Screen halo')
plt.plot(theta_grid, uniform_halo.norm_int[i, :], label='Uniform halo')
plt.loglog()
plt.legend()

plt.title("Photon Energy = {:.2f}".format(energy_grid[i]))
plt.xlabel(r"Angular Distance from Point Source ({})".format(theta_grid.unit))
plt.ylabel(r"Normalized Intensity ({})".format(screen_halo.norm_int.unit))
Text(0, 0.5, 'Normalized Intensity (1 / arcsec2)')
../_images/273177f5427508687ea25355b925a65266dba1e546bea0685d018b4e580a85cd.png

Verifying the halo calculation

As a check, we can integrate the surface brightness profile stored in norm_int and confirm it reproduces the scattering optical depth (taux) at each energy.

# A 2D grid of theta values, matching the shape of norm_int
theta_grid_2d = np.repeat(theta_grid.reshape(1, n_theta), n_energy, axis=0)

# Integrate the surface brightness profile over solid angle: 2*pi*theta*dtheta.
# Because the angles are small, sin(theta) -> theta, and since norm_int has
# units of arcsec^-2, theta_grid_2d can stay in arcsec.
integrated_uniform = np.trapezoid(
    uniform_halo.norm_int * 2.0 * np.pi * theta_grid_2d, theta_grid, axis=1)
integrated_screen = np.trapezoid(
    screen_halo.norm_int * 2.0 * np.pi * theta_grid_2d, theta_grid, axis=1)

The plot below shows that integrating each normalized halo profile reproduces its taux value – as expected in the optically thin regime (\(\tau_{\rm sca} < 1\)). Note that the uniform and screen halos share the same taux values, even though their profile shapes differ.

plt.plot(energy_grid, uniform_halo.taux, 'rs', label='Uniform (taux)')
plt.plot(energy_grid, integrated_uniform, color='k', label='Uniform (integrated)')

plt.plot(energy_grid, screen_halo.taux, 'bo', label='Screen (taux)')
plt.plot(energy_grid, integrated_screen, color='gray', label='Screen (integrated)')

plt.loglog()
plt.legend()

plt.xlabel(str(energy_grid.unit))
plt.ylabel(r'X-ray Scattering Opacity ($\tau_{\rm sca}$)')
Text(0, 0.5, 'X-ray Scattering Opacity ($\\tau_{\\rm sca}$)')
../_images/33e52c8723ebd5dbf6ea82b0a68b56848a0b58f5fa9352d81e9db56b531405d4.png

We can also plot the fractional difference between the integrated profile and taux directly. A value of 0 means the integration exactly reproduces taux; in general this calculation is accurate to a few percent.

plt.plot(energy_grid, integrated_uniform / uniform_halo.taux - 1.0, label='Uniform')
plt.plot(energy_grid, integrated_screen / screen_halo.taux - 1.0, label='Screen')
plt.legend()
plt.xlabel(str(energy_grid.unit))
plt.ylabel('Fractional Difference (integrated / taux - 1.0)')
Text(0, 0.5, 'Fractional Difference (integrated / taux - 1.0)')
../_images/16932c745c975c32b76edecc2c0a8e9383b3db242f67f2bb97f544322122d904.png

Computing halo intensity from a point-source spectrum

The Halo.calculate_intensity method computes the total scattering halo intensity, summed over the energy grid stored in Halo.lam.

Below, we construct a model spectrum with a shape similar to many Galactic X-ray binaries. calculate_intensity integrates using numpy.sum, so the input flux must be bin-integrated – each point in the spectrum represents the total counts (or energy) within an energy bin centered on the corresponding lam value, not a specific flux density. Bin-integrated flux has units like counts/s/cm\(^2\) (as opposed to a specific flux, which would have units of counts/s/cm\(^2\)/keV).

# An approximation of a typical Galactic X-ray binary spectrum
source_flux = (1.0 * np.power(energy_grid.value, -2.5) *
               np.exp(-0.1 * np.power(energy_grid.value, -3.5)) *
               u.Unit('ct s^-1 cm^-2'))

plt.plot(energy_grid, source_flux)
plt.loglog()
plt.xlabel(str(energy_grid.unit))
plt.ylabel(str(source_flux.unit))
plt.ylim(1.e-12, 1.e3)
(1e-12, 1000.0)
../_images/ce0da35329442bc955b6118817581631ab866436250addcbc5ec9bf857271c06.png
screen_halo.calculate_intensity(source_flux)
uniform_halo.calculate_intensity(source_flux)

The result is stored in each halo’s intensity attribute – the surface brightness profile summed over all energies, as a 1D array with the same length as theta_grid. Note that the units carry through automatically from the input spectrum and norm_int.

plt.plot(theta_grid, screen_halo.intensity, label='Screen halo')
plt.plot(theta_grid, uniform_halo.intensity, label='Uniform halo')
plt.loglog()
plt.legend()

plt.xlabel(r"Angular Distance from Point Source ({})".format(theta_grid.unit))
plt.ylabel(str(screen_halo.intensity.unit))
plt.title("Scattering halo surface brightness ({:.2f} - {:.2f} keV)".format(
    energy_grid[0].to('keV').value, energy_grid[-1].to('keV').value))
Text(0.5, 1.0, 'Scattering halo surface brightness (0.10 - 10.00 keV)')
../_images/9ca6b22e51c494e3e3d8403a09b02001e784abc6f3fd2c123acd9cc321026081.png

To get the total flux contained in the scattering halo, integrate intensity over solid angle (again, \(2\pi\theta\,d\theta\)). Because both halos share the same GrainPop and dust mass column, they integrate to the same total flux, despite having different profile shapes.

total_flux_uniform = np.trapezoid(uniform_halo.intensity * 2.0 * np.pi * theta_grid, theta_grid)
total_flux_screen = np.trapezoid(screen_halo.intensity * 2.0 * np.pi * theta_grid, theta_grid)

print("Total flux in uniform scattering halo: {:.3f}".format(total_flux_uniform))
print("Total flux in screen scattering halo: {:.3f}".format(total_flux_screen))
Total flux in uniform scattering halo: 0.305 ct / (s cm2)
Total flux in screen scattering halo: 0.309 ct / (s cm2)

After calculate_intensity has run, the fhalo attribute gives the flux spectrum of the scattering halo itself, as a function of energy.

plt.plot(energy_grid, source_flux, color='k', label='Input flux (absorbed)')
plt.plot(energy_grid, uniform_halo.fhalo, color='g', label='Uniform halo flux')
plt.plot(energy_grid, screen_halo.fhalo, color='b', ls='--', label='Screen halo flux')
plt.loglog()
plt.legend()
plt.ylim(1.e-12, 1e3)
plt.xlabel(str(energy_grid.unit))
plt.ylabel('Flux ({})'.format(uniform_halo.fhalo.unit))
Text(0, 0.5, 'Flux (ct / (s cm2))')
../_images/157c08bfb0f13612017d497bf4258978715280da14323455fc36b632dd583e69.png

Indexing and slicing a Halo

A Halo object can be indexed or sliced much like a numpy array. Slicing returns a new Halo covering a sub-range of the original energy grid, with all of its calculated attributes (including intensity) carried over automatically.

The slice bounds must be given in the same units used to define Halo.lam – if lam was defined in keV, you cannot slice using Angstrom values.

low_energy_halo = uniform_halo[:3.0]   # everything below 3 keV
high_energy_halo = uniform_halo[3.0:]   # everything above 3 keV

print("Low-energy slice: {:.2f} - {:.2f} keV".format(
    low_energy_halo.lam[0].value, low_energy_halo.lam[-1].value))
print("High-energy slice: {:.2f} - {:.2f} keV".format(
    high_energy_halo.lam[0].value, high_energy_halo.lam[-1].value))
Low-energy slice: 0.10 - 2.95 keV
High-energy slice: 3.24 - 10.00 keV
plt.plot(low_energy_halo.lam, low_energy_halo.taux, 'rs', label='Low-energy slice')
plt.plot(high_energy_halo.lam, high_energy_halo.taux, 'gs', label='High-energy slice')
plt.plot(uniform_halo.lam, uniform_halo.taux, 'k-', label='Original halo')
plt.loglog()
plt.xlabel(str(low_energy_halo.lam.unit))
plt.ylabel('X-ray Scattering Opacity (taux)')
plt.legend()
<matplotlib.legend.Legend at 0x7fec2f3d7650>
../_images/40aa7311f8f5f0b8c97a014b471183ba3b42e4caefc08239bef67131310bc6ad.png
plt.plot(low_energy_halo.theta, low_energy_halo.intensity,
         label='{:.2f}-{:.2f} keV'.format(low_energy_halo.lam[0].value, low_energy_halo.lam[-1].value))
plt.plot(high_energy_halo.theta, high_energy_halo.intensity,
         label='{:.2f}-{:.2f} keV'.format(high_energy_halo.lam[0].value, high_energy_halo.lam[-1].value))
plt.loglog()
plt.legend()
plt.xlabel(str(low_energy_halo.theta.unit))
plt.ylabel(str(high_energy_halo.intensity.unit))
plt.title("Uniform halo surface brightness profile")
Text(0.5, 1.0, 'Uniform halo surface brightness profile')
../_images/dbedff72ba0f506c84b284a8081441ba5c31cb053ddd9385087aa2605333004e.png