Fitting NuSTAR Spectra: General Tutorial#

A real example of fitting a NuSTAR spectrum.

import warnings

import matplotlib.pyplot as plt
import numpy as np
from numpy.exceptions import VisibleDeprecationWarning
from parfive import Downloader

from sunkit_spex.legacy.fitting.fitter import Fitter, load

warnings.filterwarnings("ignore", category=RuntimeWarning)
try:
    warnings.filterwarnings("ignore", category=VisibleDeprecationWarning)
except AttributeError:
    warnings.filterwarnings("ignore", category=np.exceptions.VisibleDeprecationWarning)

Set up some plotting numbers

spec_single_plot_size = (8, 10)
spec_plot_size = (25, 10)
spec_font_size = 18
default_font_size = 10
x_limits, y_limits = [1.6, 8.5], [1e-1, 1e3]

Download the example data

dl = Downloader()

base_url = "https://sky.dias.ie/public.php/dav/files/BHW6y6aXiGGosM6/nustar/m3_time2628/"
file_names = [
    "nu80414202001A06_chu23_S_cl_grade0_sr.pha",
    "nu80414202001A06_chu23_S_cl_grade0_sr.arf",
    "nu80414202001A06_chu23_S_cl_grade0_sr.rmf",
]

for fname in file_names:
    dl.enqueue_file(base_url + fname, path="../../nustar/m3_time2628/")
files = dl.download()
Files Downloaded:   0%|          | 0/3 [00:00<?, ?file/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:   0%|          | 0.00/34.0M [00:00<?, ?B/s]


nu80414202001A06_chu23_S_cl_grade0_sr.arf:   0%|          | 0.00/63.4k [00:00<?, ?B/s]

nu80414202001A06_chu23_S_cl_grade0_sr.pha:   0%|          | 0.00/132k [00:00<?, ?B/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:   0%|          | 1.02k/34.0M [00:00<1:28:19, 6.41kB/s]


nu80414202001A06_chu23_S_cl_grade0_sr.arf:   2%|▏         | 1.02k/63.4k [00:00<00:09, 6.72kB/s]

nu80414202001A06_chu23_S_cl_grade0_sr.pha:   1%|          | 1.02k/132k [00:00<00:19, 6.66kB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:   0%|          | 42.0k/34.0M [00:00<04:09, 136kB/s]


nu80414202001A06_chu23_S_cl_grade0_sr.arf:  66%|██████▋   | 42.0k/63.4k [00:00<00:00, 136kB/s]



Files Downloaded:  33%|███▎      | 1/3 [00:00<00:01,  1.35file/s]

nu80414202001A06_chu23_S_cl_grade0_sr.pha:  32%|███▏      | 42.0k/132k [00:00<00:00, 136kB/s]

nu80414202001A06_chu23_S_cl_grade0_sr.pha: 100%|█████████▉| 132k/132k [00:00<00:00, 379kB/s]


Files Downloaded:  67%|██████▋   | 2/3 [00:00<00:00,  2.68file/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:   1%|          | 214k/34.0M [00:00<01:08, 490kB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:   2%|▏         | 554k/34.0M [00:00<00:26, 1.24MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:   4%|▎         | 1.23M/34.0M [00:00<00:11, 2.74MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:   7%|▋         | 2.30M/34.0M [00:00<00:06, 4.94MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:  14%|█▍        | 4.83M/34.0M [00:00<00:02, 10.7MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:  23%|██▎       | 7.77M/34.0M [00:01<00:01, 16.1MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:  33%|███▎      | 11.1M/34.0M [00:01<00:01, 19.8MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:  44%|████▎     | 14.8M/34.0M [00:01<00:00, 22.9MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:  55%|█████▍    | 18.6M/34.0M [00:01<00:00, 25.0MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:  66%|██████▌   | 22.3M/34.0M [00:01<00:00, 26.2MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:  77%|███████▋  | 26.1M/34.0M [00:01<00:00, 27.3MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:  88%|████████▊ | 29.9M/34.0M [00:01<00:00, 28.3MB/s]



nu80414202001A06_chu23_S_cl_grade0_sr.rmf:  99%|█████████▉| 33.6M/34.0M [00:01<00:00, 29.4MB/s]




Files Downloaded: 100%|██████████| 3/3 [00:02<00:00,  1.16file/s]
Files Downloaded: 100%|██████████| 3/3 [00:02<00:00,  1.31file/s]

Load in one spectrum and fit it with one model

# First, load in your data files, here we load in 1 spectrum
_dir = "../../nustar/m3_time2628/"
spec = Fitter(pha_file=[_dir + "nu80414202001A06_chu23_S_cl_grade0_sr.pha"])

All the data can be accessed via spec.loaded_spec_data

Next you can define a model, here we go for a single isothermal model

spec.model = "f_vth"

And all data can be accessed via spec.params.

print(spec.params)
              Status  Value       Bounds       Error
T1_spectrum1    free    1.0  (0.0, None)  (0.0, 0.0)
EM1_spectrum1   free    1.0  (0.0, None)  (0.0, 0.0)

Set your count energy fitting range. Here we choose 2.5–8.1 keV

spec.energy_fitting_range = [2.5, 8.1]

To set the initial value and boundary of your parameters we can do the following:

spec.params["T1_spectrum1"] = {"Value": 4, "Bounds": (2.5, 8)}  # units MK
spec.params["EM1_spectrum1"] = {"Value": 0.3, "Bounds": (1e-1, 8e-1)}  # units 1e46 cm^-3

Setting spec.params["param_spec"] = string will set the Status, spec.params["param_spec"] = int or float will set the Value, spec.params["param_spec"] = tuple will set the Bounds.

I.e., spec.params["T1_spectrum1"] = {"Value":3.05, "Bounds":(2.5, 6)} is the same as doing spec.params["T1_spectrum1"]=3.05 and then spec.params["T1_spectrum1"]=(2.5, 6).

print(spec.params)
              Status  Value      Bounds       Error
T1_spectrum1    free    4.0    (2.5, 8)  (0.0, 0.0)
EM1_spectrum1   free    0.3  (0.1, 0.8)  (0.0, 0.0)

Once data has been loaded and a model set then the data can be fitted.

Any kwargs passed to fit will be sent to Scipy’s minimize. E.g., tol=1e-6 for setting a tolerance for the fit.

minimised_params = spec.fit()

The spec.params will be updated with the best fit values and errors (if obtained) too.

print(spec.params)
              Status  ...                                       Error
T1_spectrum1    free  ...  (0.06054753323903626, 0.06054753323903626)
EM1_spectrum1   free  ...    (0.0222790370742351, 0.0222790370742351)

[2 rows x 4 columns]

Plot the result with the plot method:

plt.rcParams["font.size"] = spec_font_size
plt.figure(figsize=spec_single_plot_size)

# the only line needed to plot the result
axes, res_axes = spec.plot()

for a in axes:
    a.set_xlim(x_limits)
    a.set_ylim(y_limits)
plt.show()
plt.rcParams["font.size"] = default_font_size
Spectrum 1

The data loader class

The data loader class (LoadSpec in data_loader.py) makes use of instrument specific loaders. Each instrument loader’s job is to essentially create the following dictionary for each loaded spectrum, found with the attribute _loaded_spec_data in the instrument class which makes up the loader class’s data.loaded_spec_data dictionary. E.g., with NuSTAR:

nust_loader._loaded_spec_data = {"photon_channel_bins":channel_bins,
                               "photon_channel_mids":np.mean(channel_bins, axis=1),
                               "photon_channel_binning":channel_binning,
                               "count_channel_bins":channel_bins,
                               "count_channel_mids":np.mean(channel_bins, axis=1),
                               "count_channel_binning":channel_binning,
                               "counts":counts,
                               "count_error":count_error,
                               "count_rate":count_rate,
                               "count_rate_error":count_rate_error,
                               "effective_exposure":eff_exp,
                               "srm":srm,
                               "extras":{"pha.file":f_pha,
                                         "arf.file":f_arf,
                                         "arf.e_lo":e_lo_arf,
                                         "arf.e_hi":e_hi_arf,
                                         "arf.effective_area":eff_area,
                                         "rmf.file":f_rmf,
                                         "rmf.e_lo":e_lo_rmf,
                                         "rmf.e_hi":e_hi_rmf,
                                         "rmf.ngrp":ngrp,
                                         "rmf.fchan":fchan,
                                         "rmf.nchan":nchan,
                                         "rmf.matrix":matrix,
                                         "rmf.redistribution_matrix":redist_m}
                              }

such that, spec.data.loaded_spec_data == {"spectrum1":nust_loader._loaded_spec_data, ...}

Multiple ways to set the fitting range

Fit the energy range while missing bins:

This only will fit the counts from 2.5–4 keV and 4.5–8.1 keV and is applied to all spectra loaded

spec.energy_fitting_range = [[2.5, 4], [4.5, 8.1]]

Rebin the data (not just for plotting)

Rebin all the count data being fitted to have a minimum of 4 counts in a bin, any counts left over will not be included and the user will be told.

spec.rebin = 4
# or equivalently
spec.rebin = {"all": 4}
# or equivalently if just one spectrum is loaded
spec.rebin = {"spectrum1": 4}

Undo the rebinning of the data

To revert back to native binning for all spectra:

# For one spectrum
spec.undo_rebin = "spectrum1"
# or (indicate the spectrum with just its number)
spec.undo_rebin = 1
# or explicitly state the rebinning should be reverted to all spectra
spec.undo_rebin = "all"

Save what you have

To save the fitting class to a file called ‘test.pickle’.

spec.save("./test.pickle")

Load it back in and continue analysis:

To load a saved session back in

new_spec = load("./test.pickle")

print(new_spec.params)
              Status  ...                                       Error
T1_spectrum1    free  ...  (0.06054753323903626, 0.06054753323903626)
EM1_spectrum1   free  ...    (0.0222790370742351, 0.0222790370742351)

[2 rows x 4 columns]

run fit again since the energy range being fitted over has been changed after the last fit

new_spec.fit(tol=1e-6)  # Scipy minimize tolerance of 1e-6
[4.717251178489729, 0.30766783569520506]

Plot the data and rebin it (just for plotting), fit was in natural binning

rebin=10 means the count bins were combined so that all bins had at least 10 counts in them or were ignored

Spectrum 1

Easily run an MCMC on your data with your model

mcmc_result = spec.run_mcmc(steps_per_walker=100)
  0%|          | 0/100 [00:00<?, ?it/s]
  4%|▍         | 4/100 [00:00<00:02, 34.39it/s]
  8%|▊         | 8/100 [00:00<00:03, 30.42it/s]
 12%|█▏        | 12/100 [00:00<00:03, 29.26it/s]
 15%|█▌        | 15/100 [00:00<00:02, 28.73it/s]
 18%|█▊        | 18/100 [00:00<00:02, 28.56it/s]
 21%|██        | 21/100 [00:00<00:02, 28.47it/s]
 24%|██▍       | 24/100 [00:00<00:02, 28.37it/s]
 27%|██▋       | 27/100 [00:00<00:02, 28.30it/s]
 30%|███       | 30/100 [00:01<00:02, 28.26it/s]
 33%|███▎      | 33/100 [00:01<00:02, 28.15it/s]
 36%|███▌      | 36/100 [00:01<00:02, 28.08it/s]
 39%|███▉      | 39/100 [00:01<00:02, 27.99it/s]
 42%|████▏     | 42/100 [00:01<00:02, 28.02it/s]
 45%|████▌     | 45/100 [00:01<00:01, 28.03it/s]
 48%|████▊     | 48/100 [00:01<00:01, 28.02it/s]
 51%|█████     | 51/100 [00:01<00:01, 28.08it/s]
 54%|█████▍    | 54/100 [00:01<00:01, 27.99it/s]
 57%|█████▋    | 57/100 [00:02<00:01, 28.00it/s]
 60%|██████    | 60/100 [00:02<00:01, 27.76it/s]
 63%|██████▎   | 63/100 [00:02<00:01, 27.65it/s]
 66%|██████▌   | 66/100 [00:02<00:01, 27.79it/s]
 69%|██████▉   | 69/100 [00:02<00:01, 27.90it/s]
 72%|███████▏  | 72/100 [00:02<00:01, 27.98it/s]
 75%|███████▌  | 75/100 [00:02<00:00, 28.03it/s]
 78%|███████▊  | 78/100 [00:02<00:00, 28.11it/s]
 81%|████████  | 81/100 [00:02<00:00, 28.06it/s]
 84%|████████▍ | 84/100 [00:02<00:00, 28.13it/s]
 87%|████████▋ | 87/100 [00:03<00:00, 28.16it/s]
 90%|█████████ | 90/100 [00:03<00:00, 27.67it/s]
 93%|█████████▎| 93/100 [00:03<00:00, 27.33it/s]
 96%|█████████▌| 96/100 [00:03<00:00, 27.56it/s]
 99%|█████████▉| 99/100 [00:03<00:00, 27.60it/s]
100%|██████████| 100/100 [00:03<00:00, 28.13it/s]

To add more MCMC runs, avoid overwriting by setting append_runs=True.

mcmc_result = spec.run_mcmc(steps_per_walker=50, append_runs=True)
  0%|          | 0/50 [00:00<?, ?it/s]
  6%|▌         | 3/50 [00:00<00:01, 26.30it/s]
 12%|█▏        | 6/50 [00:00<00:01, 26.86it/s]
 18%|█▊        | 9/50 [00:00<00:01, 27.06it/s]
 24%|██▍       | 12/50 [00:00<00:01, 27.43it/s]
 30%|███       | 15/50 [00:00<00:01, 27.23it/s]
 36%|███▌      | 18/50 [00:00<00:01, 27.23it/s]
 42%|████▏     | 21/50 [00:00<00:01, 27.53it/s]
 48%|████▊     | 24/50 [00:00<00:00, 27.71it/s]
 54%|█████▍    | 27/50 [00:00<00:00, 27.83it/s]
 60%|██████    | 30/50 [00:01<00:00, 27.94it/s]
 66%|██████▌   | 33/50 [00:01<00:00, 27.98it/s]
 72%|███████▏  | 36/50 [00:01<00:00, 27.96it/s]
 78%|███████▊  | 39/50 [00:01<00:00, 28.02it/s]
 84%|████████▍ | 42/50 [00:01<00:00, 28.00it/s]
 90%|█████████ | 45/50 [00:01<00:00, 27.55it/s]
 96%|█████████▌| 48/50 [00:01<00:00, 27.17it/s]
100%|██████████| 50/50 [00:01<00:00, 27.54it/s]

MCMC runs are easily burned and are always burned from the original sampling. E.g., burning 50 samples twice still only discards 50 samples, to discard 100 the user needs to just discard 100.

spec.burn_mcmc = 30

Plot the log-probability chains

plt.figure()
spec.plot_log_prob_chain()
plt.show()
fitting NuSTAR spectra general

Plot the corner plot from the MCMC run (just send the data to corner.py)

  • T1_spectrum1 = 4.7e+00$^{+5.5e-02}_{-6.3e-02}$, EM1_spectrum1 = 3.1e-01$^{+2.5e-02}_{-2.6e-02}$, logProb = -4.1e+01$^{+6.7e-01}_{-1.3e+00}$
  • Spectrum 1

Note that the log-likelihood displayed on the spectral plots show the the maximum log-likelihood found in the MCMC run, not the log-likelihood of the maximum a posteriori (MAP) fit plotted.

The log-likelihoods of the median and confidence ranges can be found on the corner plot at the minute and the parameter values that produce these can be found with the mcmc_table attribute.

Table length=2
ParamLowBMidHighBMaxLog
str13float64float64float64float64
T1_spectrum14.654.714.774.72
EM1_spectrum10.280.310.340.31


An alternative to plotting a number of random MCMC samples as faded lines, the user can plot a colour map showing where all run models (that are not burned) accumulate counts. This will obviously take longer.

Spectrum 1

Total running time of the script: (0 minutes 34.390 seconds)

Gallery generated by Sphinx-Gallery