Interfacing nDspec with Xspec models

This tutorial shows how to use call Xspec models in Python using nDspec. This functionality allows users to call any Fortran or C model in Python, as long as the function and format are compatible with Xspec models.

[1]:
import sys
import os
import numpy as np

import matplotlib.pyplot as plt
import matplotlib.pylab as pl
from matplotlib import cm
from matplotlib.colors import TwoSlopeNorm
import matplotlib.gridspec as gridspec

from matplotlib import rc, rcParams
rc('text',usetex=True)
rc('font',**{'family':'serif','serif':['Computer Modern']})
fi = 22
plt.rcParams.update({'font.size': fi-5})

sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath('__file__'))))
from ndspec.Response import ResponseMatrix
import ndspec.XspecInterface as XSModels

Loading the Xspec library and initializing models

The Xspec models are all compiled in a library file contained at path\_to\_heasoft\_installation/Xspec/platform\_name/lib/ called libXSFunctions.so (on Linux systems) and lib.XSFunctions.dylib (on MacOS). The class FortranInterface automatically finds this file and runs the necessary HEASOFT-related setup when one of its object is initialized. After a couple more initialization steps, we will be able to use this class to run the Fortran wrappers which call any model compiled in our library file.

[2]:
model_library = XSModels.FortranInterface()
 Solar Abundance Vector set to angr:  Anders E. & Grevesse N. Geochimica et Cosmochimica Acta 53, 197 (1989)
 Cross Section Table set to vern:  Verner, Ferland, Korista, and Yakovlev 1996

We can now define a set of dummy functions, named identically to any Xspec model we might want to use. We will then use these functions to tell model_liberary which models to make available for use. We can either initialize one model at a time with the add_model method, or pass a dictionary of models we might want with the load_models method. In this case, we will use a set of models one might use to run a simple fit of an accreting black hole, including a disk (diskbb), a Comptonized component (nthcomp), line of sight absorption (tbabs) and a simple reflection model (reflect):

[3]:
def tbabs(ear, params):
    pass

def diskbb(ear, params):
    pass

def nthcomp(ear, params):
    pass

def reflect(ear,params,seed):
    pass

model_library.load_models({
    "diskbb": diskbb,
    "tbabs": tbabs,
    "nthcomp": nthcomp
})

model_library.add_model(reflect)
[3]:
<function __main__.reflect(ear, params, seed)>

We can now print the models we initialized, together with their parameters and the model type, using the print_model_info method:

[4]:
model_library.print_model_info()

Initialized Xspec models:
diskbb:
  type: add
  function called: xsdskb
  parameters:
    Tin: value: 1.0, min: 0.0, max: 1000.0, unit: keV
    norm: value: 1, min: 0, max: 1e+20, unit: n/a

tbabs:
  type: mul
  function called: C_tbabs
  parameters:
    nH: value: 1.0, min: 0.0, max: 1000000.0, unit: 10^22

nthcomp:
  type: add
  function called: C_nthcomp
  parameters:
    Gamma: value: 1.7, min: 1.001, max: 10.0, unit: n/a
    kT_e: value: 100.0, min: 1.0, max: 1000.0, unit: keV
    kT_bb: value: 0.1, min: 0.001, max: 10.0, unit: keV
    inp_type: value: 0.0, min: 0.0, max: 1.0, unit: 0/1
    Redshift: value: 0.0, min: -0.999, max: 10.0, unit: n/a
    norm: value: 1, min: 0, max: 1e+20, unit: n/a

reflect:
  type: con
  function called: C_reflct
  parameters:
    rel_refl: value: 0.0, min: -1.0, max: 1000000.0, unit: n/a
    Redshift: value: 0.0, min: -0.999, max: 10.0, unit: n/a
    abund: value: 1.0, min: 0.0, max: 1000000.0, unit: n/a
    Fe_abund: value: 1.0, min: 0.0, max: 1000000.0, unit: n/a
    cosIncl: value: 0.45, min: 0.05, max: 0.95, unit: n/a

Evaluating and plotting additive and multiplicative models

In order to evaluate models, we now need to define a grid of photon energies. Let us define our energy grid from the NICER instrument response.

IMPORTANT: because our models come from Xspec and follow its standard formatting, what we need to pass is a grid of all energy bin edges, rather than mid points of each energy bin. We can do this simply by appending the last element of the energ_hi array (which contains the upper bound of each energy bin in the matrix) to the entire energ_lo array (which contains all the lower bounds of the same grid).

[5]:
rmfpath = os.getcwd()+"/data/nicer-rmf6s-teamonly-array50.rmf"
nicer_matrix = ResponseMatrix(rmfpath)

energs = np.append(nicer_matrix.energ_lo,nicer_matrix.energ_hi[-1])
Arf missing, please load it

Now all that we need to do to evaluate one of our models is to define the appropriate arrays of parameters. Once these are defined, we can call our model functions directly from the library, which now contains a method for each of the models initialized above, with the appropriate name:

[6]:
#define the parameters
Tin = 0.5
disknorm = 50
diskpars = np.array([Tin,disknorm])

#now call one of the models and plot it
midpoint = 0.5*(energs[1:]+energs[:-1])

disk = model_library.diskbb(energs,diskpars)

plt.loglog(midpoint,disk)
plt.title("Model in nDspec")
plt.xlabel("Energy (keV)")
plt.ylabel("Counts/s/keV")
[6]:
Text(0, 0.5, 'Counts/s/keV')
_images/xspec_interface_11_1.png

It is extremely important to be mindful of the units models are computed in. By default, Xspec functions calculate the flux in units of counts/s, and therefore renormalize by the width of the energy bin. The equivalent in nDspec is:

[7]:
binwidth = nicer_matrix.energ_hi - nicer_matrix.energ_lo

plt.loglog(midpoint,disk*binwidth)
plt.title("Model in Xspec units")
plt.xlabel("Energy (keV)")
plt.ylabel("Counts/s")
[7]:
Text(0, 0.5, 'Counts/s')
_images/xspec_interface_13_1.png

These unit conversions are handled automatically under the hood by nDspec, and the computation of folding instrument responses etc is identical. The choice of returning models in units of counts/s/keV is to expose arrays that are easily interpretable (as they lack the narrow features of models renormalized by bin width - compare the first plot in this tutorial with the second).

Now that we have corrected the units of our models, let us include two more components:

[8]:
nH = 1
abspars = np.array([nH])

Gamma = 2.1
kT_e = 10.
kT_bb = Tin
inp_type = 1
Redshift = 0
compnorm = 0.01
nthcomppars = np.array([Gamma,kT_e,kT_bb,inp_type,Redshift,compnorm])

absorption = model_library.tbabs(energs,abspars)
compton = model_library.nthcomp(energs,nthcomppars)

model = absorption*(compton+disk)

#let us also convert the y axis to flux
plt.loglog(midpoint,disk*midpoint**2,label="diskbb",lw=2.5)
plt.loglog(midpoint,compton*midpoint**2,label="nthcomp",lw=2.5)
plt.loglog(midpoint,model*midpoint**2,label="model",lw=3)
plt.xlabel("Energy (keV)")
plt.ylabel("keV$^{2}\\times$counts/s/keV")
plt.legend(loc="upper left")
plt.ylim([1.5e-3,0.05])
[8]:
(0.0015, 0.05)
_images/xspec_interface_15_1.png

Handling convolution models

Finally, we can try to include a convolution model like reflect. This case is more complex, for two reasons. First,we also need to provide the starting array to be convolved with the model. Second, in order to avoid numerical issues (particularly near the bondary of the energy grid), we actually need to use a wider energy grid, and then interpolate back to the NICER grid after performing the convolution. We will use scipy for this.

[9]:
from scipy.interpolate import interp1d

energs_extend = np.logspace(-2.,3,1000)
energs_extend = np.array(energs_extend)
binwidth_extend = np.diff(energs_extend)
compton_extend = model_library.nthcomp(energs_extend,nthcomppars)

rel_refl = 1
Redshift = 0
abund = 1
Fe_abund = 1
cosIncl = 1

refpars = np.array([rel_refl,Redshift,abund,Fe_abund,cosIncl])

reflection_extend = model_library.reflect(energs_extend,refpars,compton_extend)

#now define an interpolation object and go back to the NICER energy grid
energs_extend_center = 0.5*(energs_extend[1:]+energs_extend[:-1])
array_interp = interp1d(energs_extend_center,reflection_extend,fill_value='extrapolate')
reflection = array_interp(midpoint)

#finally, put all the model components together
model = absorption*(reflection+disk)

plt.loglog(midpoint,disk*midpoint**2,label="diskbb",lw=2)
plt.loglog(midpoint,compton*midpoint**2,label="nthcomp",lw=2)
plt.loglog(midpoint,reflection*midpoint**2,label="reflect",lw=2.5)
plt.loglog(midpoint,model*midpoint**2,label="model",lw=2.5)
plt.xlabel("Energy (keV)")
plt.ylabel("keV$^{2}\\times$counts/s/keV")
plt.legend(loc="upper left")
plt.ylim([1.5e-3,0.05])
tbvabs Version 2.3
Cosmic absorption with grains and H2, modified from
Wilms, Allen, & McCray, 2000, ApJ 542, 914-924
Questions: Joern Wilms
joern.wilms@sternwarte.uni-erlangen.de
joern.wilms@fau.de

http://pulsar.sternwarte.uni-erlangen.de/wilms/research/tbabs/

PLEASE NOTICE:
To get the model described by the above paper
you will also have to set the abundances:
   abund wilm

Note that this routine ignores the current cross section setting
as it always HAS to use the Verner cross sections as a baseline.
Compton reflection. See help for details.
If you use results of this model in a paper,
please refer to Magdziarz & Zdziarski 1995 MNRAS, 273, 837
[9]:
(0.0015, 0.05)
_images/xspec_interface_17_2.png

User-defined C models

Users can also load user-defined libraries and models other than those built by a HEASOFT installation, provided that they use the same input and output format. In this example, we will use the CInterface class to initialize and run the Relxill reflection model. In this case, users always need to specify both the path to the library they want to load, as well as that to the corresponding lmodel.dat file containing the model definitions and parameters.

[10]:
#define the path to the libraries and lmodel file to be loaded
relxillpath = '/home/matteo/Software/Relxill/librelxill.so'
relxillfile = '/home/matteo/Software/Relxill/lmodel_relxill.dat'

custom_library = XSModels.CInterface(relxillpath,relxillfile)

def relxill(ear, params):
    pass

custom_library.add_model(relxill)
custom_library.print_model_info()

Initialized Xspec models:
relxill:
  type: add
  function called: c_lmodrelxill
  parameters:
    Index1: value: 3.0, min: -10.0, max: 10.0, unit:
    Index2: value: 3.0, min: -10.0, max: 10.0, unit:
    Rbr: value: 15.0, min: 1.0, max: 1000.0, unit:
    a: value: 0.998, min: -0.998, max: 0.998, unit:
    Incl: value: 30.0, min: 3.0, max: 87.0, unit: deg
    Rin: value: -1.0, min: -100.0, max: -1.0, unit:
    Rout: value: 400.0, min: 1.0, max: 1000.0, unit:
    z: value: 0.0, min: 0.0, max: 10.0, unit:
    gamma: value: 2.0, min: 1.0, max: 3.4, unit:
    logxi: value: 3.1, min: 0.0, max: 4.7, unit:
    Afe: value: 1.0, min: 0.5, max: 10.0, unit:
    Ecut: value: 300.0, min: 5.0, max: 1000.0, unit: keV
    refl_frac: value: 3.0, min: -1000.0, max: 1000.0, unit:
    norm: value: 1, min: 0, max: 1e+20, unit: n/a

We can now define a set of parameters and evaluate the model identically to before:

[11]:
Index1 = 3
Index2 = 3
Rbr = 15
a = 0.998
incl = 30
Rin = -5
Rout = 400
z = 0
gamma = 2
logxi = 3
Afe = 1
Ecut = 50
refl_frac = 1
norm = 1

params_relxill = np.array([Index1,Index2,Rbr,a,incl,Rin,Rout,z,gamma,logxi,Afe,Ecut,refl_frac,norm])

energs = np.logspace(-1.,2,1000)
binwidth = np.diff(energs)
midpoint = 0.5*(energs[1:]+energs[:-1])

relxill_flux = custom_library.relxill(energs,params_relxill)

plt.figure(1)
plt.loglog(midpoint,relxill_flux*midpoint**2,lw=2.5,label="Relxill")
plt.xlabel("Energy (keV)")
plt.ylabel("keV$^{2}\\times$counts/s/keV")
plt.legend(loc="lower left")
[11]:
<matplotlib.legend.Legend at 0x76b9f6e855d0>
_images/xspec_interface_21_1.png

Setting up XspecInterface calls for a nDspec fitter object

Finally, we would like to prepare a function that can be passed to an nDspec fitter object for our inference. Doing this is as simple as wrapping the function call in a LMFit Model object, taking care to use a naming convention consistent with nDspec (ie, the input energy array needs to be called “ear”):

[12]:
from lmfit import Model as LM_Model

def ndspec_relxill(ear,Index1,Index2,Rbr,a,incl,Rin,Rout,z,gamma,logxi,Afe,Ecut,refl_frac,norm):
    par_array = np.array([Index1,Index2,Rbr,a,incl,Rin,Rout,z,gamma,logxi,Afe,Ecut,refl_frac,norm])
    model = custom_library.relxill(ear,par_array)
    return model

reflection_model = LM_Model(ndspec_relxill)