# Source code for ixpeobssim.srcmodel.bkg

```
#!/urs/bin/env python
#
# Copyright (C) 2021--2022, the ixpeobssim team.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Background model components.
"""
import os
from astropy.io import fits
import numpy
from ixpeobssim import IXPEOBSSIM_SRCMODEL
from ixpeobssim.core.rand import xUnivariateGenerator
from ixpeobssim.core.spline import xInterpolatedUnivariateSpline
from ixpeobssim.evt.event import xEventList
from ixpeobssim.evt.fmt import standard_radec_to_xy
from ixpeobssim.evt.ixpesim import xPhotonList
from ixpeobssim.irf.ebounds import PI_ENERGY_MIN, PI_ENERGY_MAX, ENERGY_STEP
from ixpeobssim.irfgen.gpd import GPD_FILL_TEMPERATURE, GPD_TYPICAL_ASYMTPTOTIC_PRESSURE
from ixpeobssim.irfgen.gpd import xQeffDataInterface
from ixpeobssim.instrument.gpd import phi_to_detphi
from ixpeobssim.instrument import gpd
from ixpeobssim.instrument.gpd import phi_to_detphi, GPD_PHYSICAL_MAX_RADIUS,\
GPD_PHYSICAL_HALF_SIDE_X, GPD_PHYSICAL_HALF_SIDE_Y, within_gpd_physical_area
from ixpeobssim.instrument.mma import gpd_to_sky, parse_dithering_kwargs, apply_dithering
from ixpeobssim.srcmodel.polarization import constant
from ixpeobssim.srcmodel.roi import xModelComponentBase
from ixpeobssim.srcmodel.roi import xUniformDisk
from ixpeobssim.srcmodel.spectrum import power_law, cutoff_power_law, \
xSourceSpectrum, load_spectral_spline
from ixpeobssim.utils.logging_ import logger
from ixpeobssim.utils.matplotlib_ import plt, setup_gca
from ixpeobssim.utils.units_ import arcmin_to_degrees
# pylint: disable=invalid-name, too-many-locals
[docs]
class xRadialBackgroundGenerator(xUnivariateGenerator):
"""Univariate generator sampling the radial coordinate on the detector surface
for a background component whose radial distribution, normalized by the area,
depends linearly on the radius.
This was introduced in https://github.com/lucabaldini/ixpeobssim/issues/663
The need for an instrumental background component that is not uniform in
detector coordinates stems from the analysis of a number of observations.
Interestingly enough, the radial slope of the background counts per unit
area seems to be different for different observation.
.. warning::
While initially I was hoping to code this by sampling two independent
random variables, within the proper bounds, on the x and y coordinates,
it turned out that an azimuthally symmetric bivariate pdf cannot be
expressed as the product of two independent variables on a square, and
we had to resort to sampling r over a circle and trimming in the fiducial
rectangle after the fact. This is hugly, as we need to guess in advance
how much random numbers we have to throw so that we end up with enough
counts after the trimming, but so life goes.
This is essentially an univariate generator whose pdf is a slight generalization
of the function f(r) = 2r (for r = 1) that one would use to throw random
numbers uniformly distributed in a circle:
.. math::
p(r) = \\left( 1 - \\frac{\\alpha}{2}\\right) \\frac{r}{h} +
\\alpha \\left(\\frac{r}{h}\\right)^2
The radial slope alpha repesents the fractional half-excursion of the variation
across the size h of the fiducial rectangle. For alpha = 0 the detector
position are distributed uniformly over the fiducial rectangle. For alpha = 2
the radial dependence is maximal, and the density of events is zero at the
center of the detector. For alpha = -1 (and assuming a square fiducial region)
the pdf approaches zero at the boundary of the circle.
Arguments
---------
radial_slope : float
The slope of the radial profile, that is, the fractional half-excursion
of the variation across the size of the fiducial rectangle.
"""
HALF_SIDE = 0.5 * (GPD_PHYSICAL_HALF_SIDE_X + GPD_PHYSICAL_HALF_SIDE_Y)
def __init__(self, radial_slope, num_points=100):
"""Constructor.
"""
if radial_slope > 2. or radial_slope < -1.:
raise RuntimeError('Invalid background radial slope (%.3f)' % radial_slope)
self.radial_slope = radial_slope
r = numpy.linspace(0., GPD_PHYSICAL_MAX_RADIUS, num_points)
xUnivariateGenerator.__init__(self, r, self.pdf(r, self.radial_slope))
[docs]
@staticmethod
def pdf(r, radial_slope):
"""Small function encapsulating the underlying pdf for the random generator.
Note we are using the average physical size of the GPD along the x and
y directions as an effective value for the radial parametrization.
"""
r = r / xRadialBackgroundGenerator.HALF_SIDE
return (1. - radial_slope / 2.) * r + radial_slope * r**2.
[docs]
@staticmethod
def polar_to_cartesian(r, phi):
"""Convert an array of polar coordinates in the plane into the corresponding
cartesian coordinates.
"""
x = r * numpy.cos(phi)
y = r * numpy.sin(phi)
return x, y
[docs]
@staticmethod
def average_oversample_fraction(radial_slope):
"""Return an heuristic for the average oversample fraction for a given
radial slope of the profile.
This is a purely geometric factor that is easy to calculate for a flat
profile (slope = 0), in which case it reads pi/2 but not trivial in the
general case. Our approach is to generate events within the smallest circle
ciscumscribed to the fiducial rectangle on a grid of radial slope values
and measure the fraction that ends up in the fiducial rectangle itself.
For completeness, this is calculated in tests/test_radial_bkg.py.
Note that this is accurate within a few % in the limit of infinite statistics.
"""
return 1.56258183 + 0.29704984 * radial_slope - 0.05847132 * radial_slope**2.
[docs]
def oversampled_size(self, size, radial_slope, safety_factor=1.2, min_size=1000):
"""Return the oversampled size for a given target size and radial slope.
This is essentially the function that determines how many random numbers
we need to throw to be sure that, after trimming to the fiducial region,
we end up to enough events. This is purely heuristic and is based on the
average_oversample_fraction() function when the statistics is large enough,
with a minimum bound to be sure we are not killed by statistical fluctuation
in the small number regime.
"""
size = safety_factor * size * self.average_oversample_fraction(radial_slope)
size = round(size)
return max(size, min_size)
[docs]
def rvs_xy(self, size):
"""Extract a set of arrays of coordinate detectors.
"""
oversize = self.oversampled_size(size, self.radial_slope)
logger.info('Sampling the background radial profile...')
logger.info('Initial counts: %d, target counts %d', oversize, size)
r = self.rvs(oversize)
phi = 2. * numpy.pi * numpy.random.random(oversize)
x, y = self.polar_to_cartesian(r, phi)
mask = within_gpd_physical_area(x, y)
x = x[mask]
y = y[mask]
if len(x) < size:
raise RuntimeError('Not enough background counts after trimming.')
x = x[:size]
y = y[:size]
return x, y
[docs]
class xInstrumentalBkg(xModelComponentBase):
"""
Base class for the instrumental background
In addition to the base class xModelComponentBase, this class has the
spectrum member to fully describe the spectrum of the instrumental
background.
This essentially corresponds to a uniform, unpolarized source in the GPD
reference frame, whose events are not convolved with any of the instrument
response functions, except for the energy dispersion, if explicitely requested.
Arguments
---------
name : str
The name of the component
photon_spectrum : callable
The photon spectrum in photons cm-2/s-1/keV-1 for the bkg source.
radial_slope : float
The radial slope for the distribution in detector coordinates.
identifier : int, optional
The source identifier.
convolve_energy : bool
Convolve the source spectrum with the energy dispersion (default is False).
"""
def __init__(self, name, photon_spectrum, radial_slope=0., identifier=None,
convolve_energy=False):
"""Constructor.
"""
xModelComponentBase.__init__(self, name, identifier)
self.photon_spectrum = photon_spectrum
self.radial_slope = radial_slope
self._convolve_energy = convolve_energy
def _energy_grid(self, irf_set, num_points=250, **kwargs):
"""Return the proper energy grid for the energy convolution.
This requires some care, as the implications are slightly different
depending we are convolving the input spectrum with the energy dispersion
or not.
If we are convolving the energy, we are doing exactly the same thing
that we are doing for the celestial sources. If that's not the case
we use the full 0.04--15 keV range. (Note in this case we're not starting
from 0 keV not to run into problems with power laws diverging in the
origin.)
"""
if self._convolve_energy:
emin = kwargs.get('emin', irf_set.aeff.xmin())
emax = kwargs.get('emax', irf_set.aeff.xmax())
else:
emin = PI_ENERGY_MIN + ENERGY_STEP
emax = PI_ENERGY_MAX
return numpy.linspace(emin, emax, num_points)
def _seed_columns(self, irf_set, gpd_qeff_correction=False, **kwargs):
"""Fill the seed columns.
"""
start_met, duration = self.parse_time_kwargs(**kwargs)
energy_grid = self._energy_grid(irf_set)
# Note: this is hard-coded and should be changed.
time_grid = numpy.linspace(start_met, start_met + duration, 100)
# Create the source spectrum
if gpd_qeff_correction:
# If we are using this for a photon list, then we have to de-correct
# for the effect of the GPD quantum efficiency, as ixpesim, at this
# stage, only simulates photons at the top of the window. Note this
# will never be exact, as the instrumental background is a mixture of
# different things. We might want to simulate the correct misture of
# charged particles, at some point, but for the time being this will
# get at least in the right ball-park as far as the number of events
# is concerned.
#
# First thing first, we have to override the default energy grid in
# order to avoid generating an enormous number of events below 1 keV,
# where the quantum efficiency is small. Note that, in this case,
# the bounds are essentially put by hands. It should also be
# emphasize that, in the ixpesim workflow, there is essentially no
# way to disengage the energy dispersion, and we will probably never
# get things completely right.
logger.info('Overriding the default energy grid for the efficiency decorrection...')
energy_grid = numpy.linspace(kwargs.get('emin', 1.), PI_ENERGY_MAX, 250)
# Then we have to retrieve the proper quantum efficiency data
# for the decorrection to be applied.
qeff_data = xQeffDataInterface()
# And for this we need the right temperature and asymptotic pressure.
# It is unfortunate that this information is buried into the header
# comments of the response files. As in ixpeobssim.evt.ixpesim, we
# try and start from sensible defaults...
temperature = GPD_FILL_TEMPERATURE
pressure = GPD_TYPICAL_ASYMTPTOTIC_PRESSURE
# ... and then we try and do some magic with the header comments.
for line in irf_set.aeff.header_comments():
if line.startswith('GPD filling temperature'):
temperature = float(line.split(':')[-1].replace('deg C', '').strip())
logger.info('GPD filling temperature read from comments: %s', temperature)
if line.startswith('GPD DME asymptotic pressure'):
pressure = float(line.split(':')[-1].replace('mbar', '').strip())
logger.info('GPD pressure read from comments: %s', pressure)
logger.info('Decorrecting the GPD efficiency at %.1f deg C, %.1f mbar',
temperature, pressure)
def spectrum(E, t=None):
"""Small nested function to apply the quantum efficiency decorrection
for the simulation of photon lists.
Note that, by the time this is called, E and t are both two-dimensional
arrays on the proper grid for the bivariate spline underlying the
source spectrum to be build. Since the quantum efficiency is
purely one dimensional, we do have to take one slice in energy,
calculate the efficiencty, tile the output on the time grids and
transpose the thing to have the correct output shape.
"""
energy = E[:,0]
qeff = qeff_data.quantum_efficiency(energy, temperature, pressure, contaminants=None)
qeff = numpy.tile(qeff, (len(time_grid), 1)).T
return self.photon_spectrum(E, t) / qeff
else:
# For a standard event list we do the simple thing.
spectrum = self.photon_spectrum
source_spectrum = xSourceSpectrum(energy_grid, time_grid, spectrum)
# Extract the number of events to be generated.
num_events = self.poisson(source_spectrum.build_light_curve().norm())
# Multiply by the total number of events by the detector area.
# Note the fiducial area must be converted from mm2 to cm2
num_events = int(num_events * gpd.GPD_PHYSICAL_AREA / 100. + 0.5)
logger.info('About to generate %d events...', num_events)
# Extract the event time, sort the values and initialize the event list.
time_ = source_spectrum.build_light_curve().rvs(num_events)
time_.sort()
# Apply the GTIs.
time_, _ = kwargs.get('gti_list').filter_event_times(time_)
# Extract the event energies.
mc_energy = source_spectrum.rvs(time_)
# Extract the event positions---note this was changed in response to
# https://github.com/lucabaldini/ixpeobssim/issues/663
# And we still need to handle the fiducial rectangle properly.
detx, dety = xRadialBackgroundGenerator(self.radial_slope).rvs_xy(len(time_))
return time_, mc_energy, detx, dety
[docs]
def rvs_event_list(self, parent_roi, irf_set, **kwargs):
"""Overloaded method.
"""
time_, mc_energy, detx, dety = self._seed_columns(irf_set, **kwargs)
num_events = len(time_)
if num_events == 0:
return xEventList()
event_list = xEventList(time_, self.identifier)
event_list.set_detector_position_columns(detx, dety)
# Perform the pha analysis.
mc_pha, mc_pi = irf_set.edisp.pha_analysis(mc_energy)
event_list.set_mc_energy_columns(mc_energy, mc_pha, mc_pi)
if self._convolve_energy:
energy, pha, pi = irf_set.edisp.convolve_energy(mc_energy)
else:
energy, pha, pi = mc_energy, mc_pha, mc_pi
event_list.set_energy_columns(energy, pha, pi)
# ... and azimuthal angle.
roll_angle = kwargs.get('roll')
phi = self.uniform_phi(num_events)
detphi = phi_to_detphi(phi, irf_set.du_id, roll_angle)
event_list.set_phi_columns(phi, detphi)
# Apply the dithering effect to the pointing direction (if needed).
dither_params = parse_dithering_kwargs(**kwargs)
ra_pnt, dec_pnt = apply_dithering(time_, parent_roi.ra, parent_roi.dec, dither_params)
ra, dec = gpd_to_sky(detx, dety, time_, ra_pnt, dec_pnt, irf_set.du_id, roll_angle)
# Sky positions, no difference between measured and mc because
# we are not convolving with the psf
x, y = standard_radec_to_xy(ra, dec, parent_roi.ra, parent_roi.dec)
cols = [mc_energy, mc_pha, mc_pi, ra, dec, x, y, phi, detphi]
event_list.set_seed_columns(*cols)
cols = [pha, pi, energy, ra, dec, x, y, detx, dety]
event_list.set_rec_columns(*cols)
return event_list
[docs]
def rvs_photon_list(self, parent_roi, irf_set, **kwargs):
"""Extract a random photon list.
"""
time_, mc_energy, detx, dety = self._seed_columns(irf_set, True, **kwargs)
num_events = len(time_)
if num_events == 0:
return xPhotonList()
photon_list = xPhotonList(time_, self.identifier)
# Apply the dithering effect to the pointing direction (if needed).
dither_params = parse_dithering_kwargs(**kwargs)
roll_angle = kwargs.get('roll')
ra_pnt, dec_pnt = apply_dithering(time_, parent_roi.ra, parent_roi.dec, dither_params)
# The instrumental background is unpolarized.
# Shall we allow for something to be set in the constructor?
pol_deg = numpy.full(num_events, 0.)
pol_ang = numpy.full(num_events, 0.)
pol_ang = phi_to_detphi(pol_ang, irf_set.du_id, roll_angle)
photon_list.fill(mc_energy, ra_pnt, dec_pnt, detx, dety, pol_deg, pol_ang)
return photon_list
[docs]
class xPowerLawInstrumentalBkg(xInstrumentalBkg):
"""Specialized instrumental background source with a power-law spectrum.
The default values for the spectral parameters are set after Bunner et al.
1978 (1978ApJ...220..261B), where the authors provide the non X-ray
background rates for their three detectors. We are using values for the Neon
filled detector in Table 3 of the paper. We fit the three points
(energy bins: 0.76--1.6, 1.6--3.0, 3.0--6.0) with a power law. The best-fit
values for the index and normalization of this power-law are 1.0 and 4.e-4,
respectively.
"""
def __init__(self, norm=4.e-4, index=1.0, radial_slope=0.):
"""Constructor.
"""
xInstrumentalBkg.__init__(self, 'Instrumental background', power_law(norm, index),
radial_slope)
self.norm = norm
self.index = index
[docs]
class xTemplateInstrumentalBkg(xInstrumentalBkg):
"""Instrumental background based on a template.
"""
DEFAULT_PATH = os.path.join(IXPEOBSSIM_SRCMODEL, 'ascii', 'bkg_smcx1_01903701.txt')
def __init__(self, file_path=DEFAULT_PATH, emin=0.1, emax=15., k=1, radial_slope=0.):
"""Constructor.
"""
self.spline = load_spectral_spline(file_path, emin, emax, k=k)
spec = lambda E, t=None: self.spline(E)
xInstrumentalBkg.__init__(self, 'Instrumental background', spec, radial_slope)
[docs]
class xCelestialBkgBase(xUniformDisk):
"""Base class for the celestial background components.
Celestial background is assumed to be uniform within the field of view, and
is therefore modeled as a uniform disk with a radius larger than the
field of view itself---the normalization should be computed self-consistently
for the full disk, and when the GPD fiducial cut is applied the photons that
do not hit the detector are simply thrown away.
(Other than the fact that the normalization is normalized to the solid angle
of the component, this is essentially identical to any other celestial
component.)
Note that, by construction, the component is unpolarized, and the column
density and redshift are identically zero.
"""
_RADIUS_ARCMIN = 9.
_RADIUS_DEG = arcmin_to_degrees(_RADIUS_ARCMIN)
def __init__(self, name, ra, dec, photon_spectrum, identifier=None):
"""Constructor.
"""
kwargs = dict(polarization_degree=constant(0.), polarization_angle=constant(0.),
column_density=0., redshift=0., identifier=identifier)
xUniformDisk.__init__(self, name, ra, dec, self._RADIUS_DEG, photon_spectrum, **kwargs)
[docs]
@staticmethod
def solid_angle(radius):
"""Return the solid angle subtended by a cone of aperture radius.
See https://en.wikipedia.org/wiki/Solid_angle
Args
----
radius : float
The half-apex of the cone in decimal degrees.
"""
return 2. * numpy.pi * (1. - numpy.cos(numpy.radians(radius)))
[docs]
class xExtragalacticBkg(xCelestialBkgBase):
"""Derived class for the extragalactic X-ray background.
All the basic formalism is taken from Gruber et al., 1999
https://iopscience.iop.org/article/10.1086/307450/pdf
Essentially in our energy band we model the extragalactic background as
a broken power law with index 1.29 and break energy 41.13 keV.
Note 1.29 is the index of the photon spectrum, while the 0.29 in the paper
refers to the energy spectrum, see
https://bitbucket.org/ixpesw/ixpeobssim/issues/544
"""
_SPEC_NORM = 7.877
_SPEC_INDEX = 1.29
_SPEC_CUTOFF = 41.13
def __init__(self, ra, dec, identifier=None):
"""Constructor.
"""
norm = self._SPEC_NORM * self.solid_angle(self._RADIUS_DEG)
photon_spectrum = cutoff_power_law(norm, self._SPEC_INDEX, self._SPEC_CUTOFF)
args = ra, dec, photon_spectrum, identifier
xCelestialBkgBase.__init__(self, 'Extragalactic background', *args)
[docs]
class xRosatPSPCResponseMatrix:
"""Simple interface to the ROSAT PSPC response matrix.
The FITS files was downloaded from the ROSAT HEASARC page, and the
content seems qualitatively in agreement with the expectations based on the
XRT collecting area
https://heasarc.gsfc.nasa.gov/docs/rosat/ruh/handbook/node39.html#figXMAareas
and the PSPC quantum efficiency
https://heasarc.gsfc.nasa.gov/docs/rosat/ruh/handbook/node56.html#SECTION00730000000000000000
The on-axis effective area is included in the response matrix, and to
extract it we just sum the (un-normalized) pdf in each energy bin.
The effective area is stored internally as an interpolated spline of order k=1.
"""
_FILE_PATH = os.path.join(IXPEOBSSIM_SRCMODEL, 'fits', 'pspcc_gain1_256.fits')
# Energy bounds (in keV) for the R7 channel.
R7_EMIN = 1.05
R7_EMAX = 2.04
def __init__(self):
"""Constructor.
"""
with fits.open(self._FILE_PATH) as hdu_list:
data = hdu_list['SPECRESP MATRIX'].data
energy = 0.5 * (data['ENERG_LO'] + data['ENERG_HI'])
aeff = data['MATRIX'].sum(axis=1)
fmt = dict(xlabel='Energy [keV]', ylabel='On-axis effective area [cm$^2$]')
self.aeff = xInterpolatedUnivariateSpline(energy, aeff, k=1, **fmt)
[docs]
class xGalacticBkg(xCelestialBkgBase):
"""Derived class for the galactic X-ray background.
A good reference for this is Tanaka, 2002:
https://www.aanda.org/articles/aa/pdf/2002/06/aah3204.pdf
Operationally, I think the most convenient mean to gauge the intensity of the
Galactic background is the HEASARC background tool
https://heasarc.gsfc.nasa.gov/cgi-bin/Tools/xraybg/xraybg.pl
and, particularly, the ROSAT count rate in the 1--2 keV energy band (R7).
"""
_SPEC_INDEX = 1.3
_NORM_SCALE = 5.e-6
def __init__(self, ra, dec, rosat_r7_bkg_rate, identifier=None):
"""Constructor.
Args
----
ra : float
RA for the target position in decimal degrees
dec : float
DEC for the targer position in decimal degrees
rosat_r7_bkg_rate : float
ROSAT X-ray background average count rate in the R7 band [1e-6 counts/sec/arcmin^2]
"""
norm = self._NORM_SCALE * rosat_r7_bkg_rate
photon_spectrum = power_law(norm, self._SPEC_INDEX)
args = ra, dec, photon_spectrum, identifier
xCelestialBkgBase.__init__(self, 'Galactic background', *args)
```