# background.py: Module containing background fitting and model classes
#
# Authors: William Cleveland (USRA),
# Adam Goldstein (USRA) and
# Daniel Kocevski (NASA)
#
# Portions of the code are Copyright 2020 William Cleveland and
# Adam Goldstein, Universities Space Research Association
# All rights reserved.
#
# Written for the Fermi Gamma-ray Burst Monitor (Fermi-GBM)
#
# 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, see .
#
import numpy as np
from ..data.primitives import EventList, TimeEnergyBins, EnergyBins
from ..data import PHAII, TTE
class BackgroundFitter:
"""Class for fitting a background, given a fitting algorithm,
to time-energy data (e.g. :class:`~gbm.data.phaii.PHAII`,
:class:`~gbm.data.TTE`).
When a BackgroundFitter is created, an algorithm must be specified. In
particular, the algorithm must be a class that has two public methods:
``fit()`` and ``interpolate()``.
For PHAII data, the class, upon initialization, must take the following as
arguments:
1. a 2D array of counts with shape (``numtimes``, ``numchans``);
2. a 1D array of time bin start times, shape (``numtimes``,);
3. a 1D array of time bin end times, shape (``numtimes``,);
4. a 1D array of exposures for each time bin, shape (``numtimes``,).
While for TTE data, the class, upon initialization, must take the following
as an argument:
1. a list, of length ``numchans``, where each item is a np.array
of event times.
The ``fit()`` method takes no arguments, hoewever, parameters that are
required for the algorithm may be specified as keywords.
The interpolate() method must take in the following as arguments:
1. a 1D array of time bin start times, shape (``numtimes``,);
2. a 1D array of time bin end times, shape (``numtimes``,).
Any additional parameters required can be specified as keywords.
The ``interpolate()`` method must return:
1. a 2D rates array, shape (``numtimes``, ``numchans``);
2. a 2D rate uncertainty array, shape (``numtimes``, ``numchans``).
Additionally, the class can provide the following public attributes that
will be exposed by BackgroundFitter:
- ``dof``: The degrees of freedom of the fits, array shape (``numchans``,)
- ``statistic``: The fit statistic for each fit, array shape (``numchans``,)
- ``statistic_name``: A string of the fit statistic used
Attributes:
dof (np.array): If available, the degrees-of-freedom of the fit for
each energy channel
livetime (float): The total livetime of the data used for the background
method (str): The name of the fitting algorithm class
parameters (dict): All parameters passed to the fitting algorithm
statistic (np.array): If available, the fit statistic for each energy
channel
statistic_name (str): If available, the name of the fit statistic
type (str): The type of background algorithm, either 'binned' or 'unbinned'
"""
def __init__(self):
self._data_obj = None
self._method = None
self._type = None
self._statistic = None
self._dof = None
self._livetime = None
self._parameters = None
@property
def method(self):
return self._method.__class__.__name__
@property
def type(self):
return self._type
@property
def statistic_name(self):
return getattr(self._method, 'statistic_name', None)
@property
def statistic(self):
return getattr(self._method, 'statistic', None)
@property
def dof(self):
return getattr(self._method, 'dof', None)
@property
def livetime(self):
return self._livetime
@property
def parameters(self):
return self._parameters
@classmethod
def from_phaii(cls, phaii, method, time_ranges=None):
"""Create a background fitter from a PHAII object
Args:
phaii (:class:`~gbm.data.phaii.PHAII`): A PHAII data object
method (): A background fitting/estimation class for binned data
time_ranges ([(float, float), ...]):
The time range or time ranges over which to fit the background.
If omitted, uses the full time range of the data
Returns:
:class:`BackgroundFitter`: The initialized background fitting object
"""
if not isinstance(phaii, PHAII):
raise TypeError('Input data must be a PHAII object')
obj = cls()
obj._data_obj = phaii
obj._validate_method(method)
time_ranges = obj._validate_time_ranges(time_ranges)
# Slice the PHAII data and merge if multiple slices
data = [phaii.data.slice_time(trange[0], trange[1]) for trange in
time_ranges]
data = TimeEnergyBins.merge_time(data)
obj._method = method(data.counts, data.tstart, data.tstop,
data.exposure)
obj._type = 'binned'
obj._livetime = np.sum(data.exposure)
return obj
@classmethod
def from_tte(cls, tte, method, time_ranges=None):
"""Create a background fitter from a TTE object
Args:
tte (:class:`~gbm.data.TTE`): A TTE data object
method (): A background fitting/estimation class for unbinned data
time_ranges ([(float, float), ...]):
The time range or time ranges over which to fit the background.
If omitted, uses the full time range of the data
Returns:
:class:`BackgroundFitter`: The initialized background fitting object
"""
if not isinstance(tte, TTE):
raise TypeError('Input data must be a TTE object')
obj = cls()
obj._data_obj = tte
obj._validate_method(method)
time_ranges = obj._validate_time_ranges(time_ranges)
# Slice the TTE data and merge if multiple slices
data = [tte.data.time_slice(trange[0], trange[1]) for trange in
time_ranges]
data = EventList.merge(data)
data.sort('TIME')
# pull out the events in each channel
events = [data.channel_slice(i, i).time for i in
range(tte.numchans)]
obj._method = method(events)
obj._type = 'unbinned'
obj._livetime = data.get_exposure(time_ranges=time_ranges)
return obj
def fit(self, **kwargs):
"""Perform a background fit of the data
Args:
**kwargs: Options to be passed as parameters to the fitting class
"""
self._parameters = kwargs
self._method.fit(**kwargs)
def interpolate_bins(self, tstart, tstop, **kwargs):
"""Interpolate the fitted background model over a set of bins.
The exposure is calculated for each bin of the background model
in case the background model counts is needed.
Args:
tstart (np.array): The starting times
tstop (np.array): The ending times
**kwargs: Options to be passed as parameters to the interpolation method
Returns:
:class:`BackgroundRates`: The background rates/uncertainty object
"""
tstart_, tstop_ = (tstart.copy(), tstop.copy())
# do the interpolation
rate, rate_uncert = self._method.interpolate(tstart_, tstop_, **kwargs)
# get the exposure
numtimes = tstart_.shape[0]
exposure = np.array([self._data_obj.get_exposure((tstart_[i], tstop_[i])) \
for i in range(numtimes)])
# create the rates object
det = self._data_obj.detector
dtype = self._data_obj.datatype
trigtime = self._data_obj.trigtime
rates = BackgroundRates(rate, rate_uncert, tstart_, tstop_,
self._data_obj.data.emin.copy(),
self._data_obj.data.emax.copy(),
exposure=exposure,
detector=det, datatype=dtype,
trigtime=trigtime)
return rates
def interpolate_times(self, times, **kwargs):
"""Interpolate the fitted background model over a set of times.
Does not calculate an exposure since this returns a set of point
estimates of the background rates.
Args:
tstart (np.array): The sampling times
**kwargs: Options to be passed as parameters to the interpolation method
Returns:
:class:`BackgroundRates`: The background rates/uncertainty object
"""
# do the interpolation
rate, rate_uncert = self._method.interpolate(times, times, **kwargs)
# create the rates object
det = self._data_obj.detector
dtype = self._data_obj.datatype
trigtime = self._data_obj.trigtime
rates = BackgroundRates(rate, rate_uncert, times, times,
self._data_obj.data.emin.copy(),
self._data_obj.data.emax.copy(), detector=det,
datatype=dtype, trigtime=trigtime)
return rates
def _validate_method(self, method):
try:
method
except:
raise NameError('Input method is not a known function')
has_fit = callable(method.fit)
has_interp = callable(method.interpolate)
if (not has_fit) or (not has_interp):
raise NotImplementedError(
"User-defined Background class must have "
"both fit() and an interpolate() methods")
def _validate_time_ranges(self, time_ranges):
if time_ranges is None:
time_ranges = [self._data_obj.time_range]
try:
iter(time_ranges[0])
except:
raise TypeError('time_ranges must be a list of tuples')
return time_ranges
class BackgroundRates(TimeEnergyBins):
"""Class containing the background rate data.
Parameters:
rates (np.array): The array of background rates in each bin
rate_uncertainty (np.array): The array of background rate uncertainties
in each bin
tstart (np.array): The low-value edges of the time bins
tstop (np.array): The high-value edges of the time bins
emin (np.array): The low-value edges of the energy bins
emax (np.array): The high-value edges of the energy bins
exposure (np.array, optional): The exposure of each bin
detector (str, optional): The associated detector
datatype (str, optional): The datatype associated with the background
trigtime (float, optional): The trigger time
Attributes:
chanwidths (np.array): The bin widths along the energy axis
count_uncertainty (np.array): The counts uncertainty in each bin
energy_centroids (np.array): The bin centroids along the energy axis
energy_range (float, float): The range of the data along the energy axis
numchans (int): The number of energy channels along the energy axis
numtimes (int): The number of bins along the time axis
rates (np.array): The rates in each Time-Energy Bin
rates_per_kev (np.array): The differential rates in units of counts/s/keV
rate_uncertainty (np.array): The rate uncertainty in each bin
rate_uncertainty_per_kev (np.array):
The differential rate uncertainty in units of counts/s/keV
size (int, int): The number of bins along both axes (numtimes, numchans)
time_centroids (np.array): The bin centroids along the time axis
time_range (float, float): The range of the data along the time axis
time_widths (np.array): The bin widths along the time axis
"""
def __init__(self, rates, rate_uncertainty, tstart, tstop, emin, emax,
exposure=None, detector=None, datatype=None, trigtime=None):
if exposure is None:
exposure = np.zeros_like(tstart)
counts = np.squeeze(rates * exposure[:, np.newaxis])
super().__init__(counts, tstart, tstop, exposure, emin, emax)
self._count_uncertainty = np.squeeze(rate_uncertainty * exposure[:, np.newaxis])
self._rates = rates.squeeze()
self._rate_uncertainty = rate_uncertainty.squeeze()
self._detector = detector
self._datatype = datatype
self._trigtime = trigtime
@property
def count_uncertainty(self):
return self._count_uncertainty
@property
def rates(self):
return self._rates
@property
def rate_uncertainty(self):
return self._rate_uncertainty
def integrate_energy(self, emin=None, emax=None):
"""Integrate the over the energy axis.
Limits on the integration smaller than the full range can be set.
Args:
emin (float, optional): The low end of the integration range. If not
set, uses the lowest energy edge of the histogram
emax (float, optional): The high end of the integration range. If not
set, uses the highest energy edge of the histogram
Returns:
:class:`gbm.data.primitives.TimeBins`: A TimeBins object containing \
the lightcurve histogram
"""
if emin is None:
emin = self.energy_range[0]
if emax is None:
emax = self.energy_range[1]
mask = self._slice_energy_mask(emin, emax)
emin = self.emin[mask][0]
emax = self.emax[mask][-1]
rates = np.nansum(self.rates[:, mask], axis=1).reshape(-1,1)
rate_uncert = np.sqrt(
np.nansum(self.rate_uncertainty[:, mask] ** 2, axis=1)).reshape(-1,1)
obj = BackgroundRates(rates, rate_uncert, self.tstart.copy(),
self.tstop.copy(), np.array([emin]),
np.array([emax]), exposure=self.exposure.copy())
return obj
def integrate_time(self, tstart=None, tstop=None):
"""Integrate the background over the time axis (producing a count rate
spectrum). Limits on the integration smaller than the full range can
be set.
Args:
tstart (float, optional): The low end of the integration range.
If not set, uses the lowest time edge of the histogram
tstop (float, optional): The high end of the integration range.
If not set, uses the highest time edge of the histogram
Returns:
:class:`BackgroundSpectrum`: A BackgroundSpectrum object containing \
the count rate spectrum
"""
if tstart is None:
tstart = self.time_range[0]
if tstop is None:
tstop = self.time_range[1]
mask = self._slice_time_mask(tstart, tstop)
exposure = np.nansum(self.exposure[mask])
rates = np.nansum(self.counts[mask, :], axis=0) / exposure
rate_uncert = np.sqrt(np.nansum(self.count_uncertainty[mask, :] ** 2,
axis=0)) / exposure
exposure = np.full(rates.size, exposure)
obj = BackgroundSpectrum(rates, rate_uncert, self.emin.copy(),
self.emax.copy(), exposure)
return obj
def to_bak(self, time_range=None, **kwargs):
"""Integrate over the time axis and produce a BAK object
Args:
time_range ((float, float), optional):
The time range to integrate over
**kwargs: Options to pass to BAK.from_data()
Returns:
:class:`gbm.data.BAK`: The background BAK object
"""
from gbm.data.pha import BAK
if time_range is None:
time_range = self.time_range
back_spec = self.integrate_time(*time_range)
dtype = self._datatype.lower()
bak = BAK.from_data(back_spec, *time_range, datatype=dtype,
detector=self._detector, trigtime=self._trigtime,
**kwargs)
return bak
def rebin_time(self, method, *args, tstart=None, tstop=None):
"""Not Implemented
"""
raise NotImplementedError
def rebin_energy(self, method, *args, emin=None, emax=None):
"""Not Implemented
"""
raise NotImplementedError
@classmethod
def merge_time(cls, histos):
"""Merge multiple BackroundRates together along the time axis.
Args:
histos (list of :class:`BackgroundRates`):
A list containing the BackgroundRates to be merged
Returns:
:class:`BackgroundRates`: A new BackgroundRates object containing \
the merged BackgroundRates
"""
rates = np.vstack((histo.rates for histo in histos))
rate_uncertainty = np.vstack(
(histo.rate_uncertainty for histo in histos))
bins = TimeEnergyBins.merge_time(histos)
obj = cls(rates, rate_uncertainty, bins.tstart, bins.tstop,
bins.emin, bins.emax, exposure=bins.exposure)
return obj
@classmethod
def sum_time(cls, bkgds):
"""Sum multiple TimeBins together if they have the same time range.
Example use would be summing two backgrounds from two detectors.
Args:
bkgds (list of :class:`BackgroundRates):
A list containing the BackgroundRates to be summed
Returns:
:class:`BackgroundRates`: A new BackgroundRates object containing \
the summed BackgroundRates
"""
rates = np.zeros_like(bkgds[0].rates)
rates_var = np.zeros_like(bkgds[0].rates)
for bkgd in bkgds:
assert bkgd.numtimes == bkgds[0].numtimes, \
"The backgrounds must all have the same support"
rates += bkgd.rates
rates_var += bkgd.rate_uncertainty ** 2
# averaged exposure, sampling times
exposure = np.mean([bkgd.exposure for bkgd in bkgds], axis=0)
tstart = np.mean([bkgd.tstart for bkgd in bkgds], axis=0)
tstop = np.mean([bkgd.tstop for bkgd in bkgds], axis=0)
emin = np.array([np.min([bkgd.emin for bkgd in bkgds])])
emax = np.array([np.min([bkgd.emax for bkgd in bkgds])])
sum_bkgd = cls(rates, np.sqrt(rates_var), tstart, tstop, emin, emax,
exposure=exposure, datatype=bkgds[0]._datatype,
trigtime=bkgds[0]._trigtime)
return sum_bkgd
class BackgroundSpectrum(EnergyBins):
"""A class defining a Background Spectrum.
Parameters:
rates (np.array): The array of background rates in each bin
rate_uncertainty (np.array): The array of background rate uncertainties
in each bin
lo_edges (np.array): The low-value edges of the bins
hi_edges (np.array): The high-value edges of the bins
exposure (np.array): The exposure of each bin
Attributes:
centroids (np.array): The geometric centroids of the bins
counts (np.array): The counts in each bin
count_uncertainty (np.array): The count uncertainty in each bin
exposure (np.array): The exposure of each bin
hi_edges (np.array): The high-value edges of the bins
lo_edges (np.array): The low-value edges of the bins
range (float, float): The range of the bin edges
rates (np.array): count rate of each bin
rate_uncertainty (np.array): The count rate uncertainty of each bin
size (int): Number of bins
widths (np.array): The widths of the bins
"""
def __init__(self, rates, rate_uncertainty, lo_edges, hi_edges, exposure):
counts = rates * exposure
super().__init__(counts, lo_edges, hi_edges, exposure)
self._count_uncertainty = rate_uncertainty * exposure
self._rates = rates
self._rate_uncertainty = rate_uncertainty
@property
def count_uncertainty(self):
return self._count_uncertainty
@property
def rates(self):
return self._rates
@property
def rate_uncertainty(self):
return self._rate_uncertainty
@classmethod
def sum(cls, histos):
"""Sum multiple BackgroundSpectrums together if they have the same
energy range (support).
Args:
histos (list of :class:`BackgroundSpectrum`):
A list containing the background spectra to be summed
Returns:
:class:`BackgroundSpectrum`: A new object containing the \
summed BackgroundSpectrum
"""
counts = np.zeros(histos[0].size)
count_variance = np.zeros(histos[0].size)
exposure = 0.0
for histo in histos:
assert histo.size == histos[0].size, \
"The histograms must all have the same size"
assert np.all(histo.lo_edges == histos[0].lo_edges), \
"The histograms must all have the same support"
counts += histo.counts
count_variance += histo.count_uncertainty**2
exposure += histo.exposure
rates = counts/exposure
rate_uncertainty = np.sqrt(count_variance)/exposure
sum_bins = cls(rates, rate_uncertainty, histos[0].lo_edges,
histos[0].hi_edges, exposure)
return sum_bins
def slice(self, emin, emax):
"""Perform a slice over an energy range and return a new
BackgroundSpectrum object. Note that the emin and emax values that fall
inside a bin will result in that bin being included.
Args:
emin (float): The low energy edge of the slice
emax (float): The high energy of the slice
Returns:
:class:`BackgroundSpectrum`: A new object containing the energy slice
"""
emin_snap = self.closest_edge(emin, which='low')
emax_snap = self.closest_edge(emax, which='high')
mask = (self.lo_edges < emax_snap) & (self.hi_edges > emin_snap)
obj = self.__class__(self.rates[mask], self.rate_uncertainty[mask],
self.lo_edges[mask], self.hi_edges[mask],
self.exposure[mask])
return obj