GBM-data-tools/finder.py

1158 lines
43 KiB
Python

# finder.py: Module containing data finder and data catalog 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 <https://www.gnu.org/licenses/>.
#
import os
import socket
import ssl
import sys
import time
from ftplib import FTP_TLS
from urllib.request import urlopen
import numpy as np
from astropy import units as astro_units
from astropy.coordinates import SkyCoord
from gbm.time import Met
class FtpFinder:
"""A base class for the interface to the HEASARC FTP archive of GBM data.
Specifically, it creates a connection to legacy.gsfc.nasa.gov
Attributes:
num_files (int): Number of files in the current directory
files (list of str): The list of files in the current directory
Note:
This class should not be directly instantiated, but rather inherited.
"""
_ftp = FTP_TLS(host='heasarc.gsfc.nasa.gov')
_ftp.login()
_ftp.prot_p()
def __init__(self):
self._downloading_file = None
self._download_dir = None
self._file_list = []
def __del__(self):
self._ftp.close()
def _reconnect(self):
"""Attempt a reconnect in case connection was lost
"""
self._ftp.close()
self._ftp = FTP_TLS(host='heasarc.gsfc.nasa.gov')
self._ftp.login()
self._ftp.prot_p()
def _ftp_status(self, chunk):
"""FTP GET callback function that downloads and reports the percent
progress of the download.
Args:
chunk (str): The byte data to be written
"""
# append to file
file_path = os.path.join(self._download_dir, self._downloading_file)
with open(file_path, 'ab') as f:
f.write(chunk)
self._transferred_bytes += len(chunk)
percent = float(self._transferred_bytes) / float(self._total_bytes)
# download bar
bar = ('=' * int(percent * 30)).ljust(30)
# format percent and print along with download bar
percent = str("{0:.2f}".format(percent * 100.0))
sys.stdout.write(
"\r%s [%s] %s%%" % (self._downloading_file, bar, percent))
# file download is finished
if self._transferred_bytes == self._total_bytes:
sys.stdout.write('\n')
sys.stdout.flush()
def _ftp_silent(self, chunk):
"""FTP GET callback function that silently downloads a file.
Args:
chunk (str): The byte data to be written
"""
# append to file
file_path = os.path.join(self._download_dir, self._downloading_file)
with open(file_path, 'ab') as f:
f.write(chunk)
def _construct_path(self, id):
return NotImplemented
def _file_filter(self, file_list, filetype, extension, dets=None):
"""Filters the directory for the requested filetype, extension, and
detectors
Args:
filetype (str): The type of file, e.g. 'cspec'
extension (str): The file extension, e.g. '.pha'
dets (list, optional): The detectors. If omitted, then files for
all detectors are returned
Returns:
list: The filtered file list
"""
files = [f for f in file_list if
(filetype in f) & (f.endswith(extension))]
if dets is not None:
if type(dets) == str:
dets = [dets]
files = [f for f in files if
any('_' + det + '_' in f for det in dets)]
return files
def _get(self, download_dir, files, verbose=True):
"""Downloads a list of files from FTP
Args:
download_dir (str): The download directory location
files (list of str): The list of files to download
verbose (bool, optional): If True, will output the download status.
Default is True.
Returns:
list of str: The full paths to the downloaded files
"""
if verbose:
callback = self._ftp_status
else:
callback = self._ftp_silent
if os.path.exists(download_dir) == False:
os.makedirs(download_dir)
self._download_dir = download_dir
# download each file
filepaths = []
for file in files:
# have to save in self because this can't be passed as an argument
# in the callback
self._downloading_file = file
# download file
self._ftp.voidcmd('TYPE I')
self._total_bytes = self._ftp.size(file)
self._transferred_bytes = 0
self._ftp.retrbinary('RETR ' + file, callback=callback)
filepaths.append(os.path.join(download_dir, file))
return filepaths
@property
def num_files(self):
return len(self._file_list)
@property
def files(self):
return self._file_list
def ls(self, id):
"""List the directory contents of an FTP directory associated with
a trigger or data set.
Args:
id (str): The id associated with a trigger or data set
Returns:
list of str: Alphabetically ordered file list
"""
path = self._construct_path(id)
try:
files = self._ftp.nlst(path)
except AttributeError:
print('Connection appears to have failed. Attempting to reconnect...')
try:
self._reconnect()
print('Reconnected.')
return self.ls(id)
except:
raise RuntimeError('Failed to reconnect.')
except:
raise FileExistsError('{} does not exist'.format(path))
files = sorted([os.path.basename(f) for f in files])
return files
class TriggerFtp(FtpFinder):
"""A class that interfaces with the HEASARC FTP trigger directories.
An instance of this class will represent the available files associated
with a single trigger.
An instance can be created without a trigger number, however a trigger
number will need to be set by set_trigger(tnum) to query and download files.
An instance can also be changed from one trigger number to another without
having to create a new instance. If multiple instances are created and
exist simultaneously, they will all use a single FTP connection.
Note:
Since HEASARC transitioned to FTPS, some have had issues with
connecting to the HEASARC FTP site via Python's ftplib for no obvious
reason while it works flawlessy for others (even on the same platform).
Currently the thought is that this may be related to the underlying
OpenSSL version that is installed. If you have connection problems
using this, you may consider upgrading you OpenSSL and see if that
solves your problem. A potential solution is to do the following:
* $ pip3 install pyopenssl
* $ pip3 install requests[security]
Parameters:
tnum (str, optional): A valid trigger number
Attributes:
num_files (int): Number of files in the current directory
files (list of str): The list of files in the current directory
"""
_root = '/fermi/data/gbm/triggers'
def __init__(self, tnum=None):
self._downloading_file = None
self._download_dir = None
self._tnum = None
self._file_list = []
if tnum is not None:
try:
self._file_list = self.ls(tnum)
self._ftp.cwd(self._construct_path(tnum))
self._tnum = tnum
except FileExistsError:
raise ValueError(
'{} is not a valid trigger number'.format(tnum))
def set_trigger(self, tnum):
"""Set the trigger number. If the object was previously associated
with a trigger number, this will effectively change the working
directory to that of the new trigger number. If the trigger number is
invalid, an exception will be raised, and no directory change will be
made.
Args:
tnum (str): A valid trigger number
"""
try:
self._file_list = self.ls(tnum)
self._ftp.cwd(self._construct_path(tnum))
self._tnum = tnum
except FileExistsError:
self._tnum = None
self._file_list = []
raise ValueError('{} is not a valid trigger number'.format(tnum))
def ls_ctime(self):
"""List all ctime files for the trigger
Returns:
list of str: The file list
"""
return self._file_filter(self.files, 'ctime', 'pha')
def ls_cspec(self):
"""List all cspec files for the trigger
Returns:
list of str: The file list
"""
return self._file_filter(self.files, 'cspec', 'pha')
def ls_tte(self):
"""List all tte files for the trigger
Returns:
list of str: The file list
"""
return self._file_filter(self.files, 'tte', 'fit')
def ls_rsp(self, ctime=True, cspec=True):
"""List all response Type-I files for the trigger
Args:
ctime (bool, optional): If True, list the ctime responses.
Default is True.
cspec (bool, optional): If True, list the cspec responses.
Default is True.
Returns:
list of str: The file list
"""
files = []
if cspec:
files.extend(self._file_filter(self.files, 'cspec', 'rsp'))
if ctime:
files.extend(self._file_filter(self.files, 'ctime', 'rsp'))
return files
def ls_rsp2(self, ctime=True, cspec=True):
"""List all response Type-II files for the trigger
Args:
ctime (bool, optional): If True, list the ctime responses.
Default is True.
cspec (bool, optional): If True, list the cspec responses.
Default is True.
Returns:
list of str: The file list
"""
files = []
if cspec:
files.extend(self._file_filter(self.files, 'cspec', 'rsp2'))
if ctime:
files.extend(self._file_filter(self.files, 'ctime', 'rsp2'))
return files
def ls_lightcurve(self):
"""List all lightcurve plots for the trigger
Returns:
list of str: The file list
"""
return self._file_filter(self.files, 'lc', 'pdf')
def ls_cat_files(self):
"""List all catalog files for the trigger
Returns:
list of str: The file list
"""
files = []
files.extend(self._file_filter(self.files, 'bcat', 'fit'))
files.extend(self._file_filter(self.files, 'scat', 'fit'))
files.extend(self._file_filter(self.files, 'tcat', 'fit'))
return files
def ls_trigdat(self):
"""List the trigger data (trigdat) file for the trigger
Returns:
list of str: The file list
"""
return self._file_filter(self.files, 'trigdat', 'fit')
def ls_localization(self):
"""List all localization files for the trigger
Returns:
list of str: The file list
"""
files = []
files.extend(self._file_filter(self.files, 'healpix', 'fit'))
files.extend(self._file_filter(self.files, 'skymap', 'png'))
files.extend(self._file_filter(self.files, 'loclist', 'txt'))
files.extend(self._file_filter(self.files, 'locprob', 'fit'))
files.extend(self._file_filter(self.files, 'locplot', 'png'))
return files
def get_ctime(self, download_dir, dets=None, **kwargs):
"""Download the ctime files for the trigger
Args:
download_dir (str): The download directory
dets (list, optional): The detectors' data to download.
If omitted, will download all.
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'ctime', 'pha', dets=dets)
self._get(download_dir, files, **kwargs)
def get_cspec(self, download_dir, dets=None, **kwargs):
"""Download the cspec files for the trigger
Args:
download_dir (str): The download directory
dets (list, optional): The detectors' data to download.
If omitted, will download all.
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'cspec', 'pha', dets=dets)
self._get(download_dir, files, **kwargs)
def get_tte(self, download_dir, dets=None, **kwargs):
"""Download the TTE files for the trigger
Args:
download_dir (str): The download directory
dets (list, optional): The detectors' data to download.
If omitted, will download all.
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'tte', 'fit', dets=dets)
self._get(download_dir, files, **kwargs)
def get_rsp(self, download_dir, ctime=True, cspec=True, dets=None,
**kwargs):
"""Download the response Type-I files for the trigger
Args:
download_dir (str): The download directory
ctime (bool, optional): If True, download the ctime responses.
Default is True.
cspec (bool, optional): If True, download the cspec responses.
Default is True.
dets (list, optional): The detectors' data to download.
If omitted, will download all.
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = []
if cspec:
files.extend(
self._file_filter(self.files, 'cspec', 'rsp', dets=dets))
if ctime:
files.extend(
self._file_filter(self.files, 'ctime', 'rsp', dets=dets))
self._get(download_dir, files, **kwargs)
def get_rsp2(self, download_dir, ctime=True, cspec=True, dets=None,
**kwargs):
"""Download the response Type-I files for the trigger
Args:
download_dir (str): The download directory
ctime (bool, optional): If True, download the ctime responses.
Default is True.
cspec (bool, optional): If True, download the cspec responses.
Default is True.
dets (list, optional): The detectors' data to download.
If omitted, will download all.
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = []
if cspec:
files.extend(
self._file_filter(self.files, 'cspec', 'rsp2', dets=dets))
if ctime:
files.extend(
self._file_filter(self.files, 'ctime', 'rsp2', dets=dets))
self._get(download_dir, files, **kwargs)
def get_lightcurve(self, download_dir, **kwargs):
"""Download the lightcurve plots for the trigger
Args:
download_dir (str): The download directory
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'lc', 'pdf')
self._get(download_dir, files, **kwargs)
def get_cat_files(self, download_dir, **kwargs):
"""Download all catalog files for the trigger
Args:
download_dir (str): The download directory
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = []
files.extend(self._file_filter(self.files, 'bcat', 'fit'))
files.extend(self._file_filter(self.files, 'scat', 'fit'))
files.extend(self._file_filter(self.files, 'tcat', 'fit'))
self._get(download_dir, files, **kwargs)
def get_trigdat(self, download_dir, **kwargs):
"""Download the trigger data (trigdat) file for the trigger
Args:
download_dir (str): The download directory
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'trigdat', 'fit')
self._get(download_dir, files, **kwargs)
def get_localization(self, download_dir, **kwargs):
"""Download all localization files for the trigger
Args:
download_dir (str): The download directory
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = []
files.extend(self._file_filter(self.files, 'healpix', 'fit'))
files.extend(self._file_filter(self.files, 'skymap', 'png'))
files.extend(self._file_filter(self.files, 'loclist', 'txt'))
files.extend(self._file_filter(self.files, 'locprob', 'fit'))
files.extend(self._file_filter(self.files, 'locplot', 'png'))
self._get(download_dir, files, **kwargs)
def get_healpix(self, download_dir, **kwargs):
"""Download the healpix localization file for the trigger.
Args:
download_dir (str): The download directory
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'healpix', 'fit')
self._get(download_dir, files, **kwargs)
def get_all(self, download_dir, **kwargs):
"""Download all files associated with the trigger
Args:
download_dir (str): The download directory
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
self._get(download_dir, self._file_list, **kwargs)
def _construct_path(self, str_trigger_num):
"""Constructs the FTP path for a trigger
Args:
str_trigger_num (str): The trigger number
Returns:
str: The path of the FTP directory for the trigger
"""
year = '20' + str_trigger_num[0:2]
path = os.path.join(self._root, year, 'bn' + str_trigger_num,
'current')
return path
# mark: TODO: Need date range functionality
class ContinuousFtp(FtpFinder):
"""A class that interfaces with the HEASARC FTP continuous daily data
directories. An instance of this class will represent the available files
associated with a single day.
An instance can be created without a time, however a time will need to be
set by set_time() to query and download files. An instance can also be
changed from one time to another without having to create a new instance.
If multiple instances are created and exist simultaneously, they will all
use a single FTP connection.
Note:
Since HEASARC transitioned to FTPS, some have had issues with
connecting to the HEASARC FTP site via Python's ftplib for no obvious
reason while it works flawlessy for others (even on the same platform).
Currently the thought is that this may be related to the underlying
OpenSSL version that is installed. If you have connection problems
using this, you may consider upgrading you OpenSSL and see if that
solves your problem. A potential solution is to do the following:
* $ pip3 install pyopenssl
* $ pip3 install requests[security]
Parameters:
met (float, optional): A time in MET. Either met, utc, or gps must be set.
utc (str, optional): A UTC time in ISO format: YYYY-MM-DDTHH:MM:SS
gps (float, optional): A GPS time
Attributes:
num_files (int): Number of files in the current directory
files (list of str): The list of files in the current directory
"""
_root = '/fermi/data/gbm/daily'
def __init__(self, met=None, utc=None, gps=None):
self._downloading_file = None
self._download_dir = None
self._file_list = []
self._met = None
if met is not None:
self._met = Met(met)
elif utc is not None:
self._met = Met.from_iso(utc)
elif gps is not None:
self._met = Met.from_gps(gps)
if self._met is not None:
try:
self._file_list = self.ls(self._met)
self._ftp.cwd(self._construct_path(self._met))
except FileExistsError:
raise ValueError('{} is not a valid MET'.format(self._met))
def set_time(self, met=None, utc=None, gps=None):
"""Set the time. If the object was previously associated with a
different time, this will effectively change the working directory to
that of the new time. If the time is invalid, an exception will be
raised, and no directory change will be made.
Only one of met, utc, or gps should be defined.
Args:
met (float, optional): A time in MET.
utc (str, optional): A UTC time in ISO format: YYYY-MM-DDTHH:MM:SS
gps (float, optional): A GPS time
"""
if met is not None:
self._met = Met(met)
elif utc is not None:
self._met = Met.from_iso(utc)
elif gps is not None:
self._met = Met.from_gps(gps)
else:
raise ValueError('Either met, utc, or gps must be specified')
try:
self._file_list = self.ls(self._met)
self._ftp.cwd(self._construct_path(self._met))
except FileExistsError:
badtime = self._met
self._met = None
self._file_list = []
raise ValueError('{} is not a valid MET'.format(badtime))
def ls_ctime(self):
"""List all ctime files
Returns:
list of str: The file list
"""
return self._file_filter(self.files, 'ctime', 'pha')
def ls_cspec(self):
"""List all cspec files
Returns:
list of str: The file list
"""
return self._file_filter(self.files, 'cspec', 'pha')
def ls_poshist(self):
"""List the poshist file
Returns:
list of str: The file list
"""
return self._file_filter(self.files, 'poshist', 'fit')
def ls_spechist(self):
"""List all spechist files
Returns:
list of str: The file list
"""
return self._file_filter(self.files, 'spechist', 'fit')
def ls_tte(self, full_day=False):
"""List all TTE files
Args:
full_day (bool, optional):
If True, will return the TTE files for the full day. If False,
will return the TTE files for the hour covering the specified
time. Default is False.
Returns:
list of str: The file list
"""
files = []
files.extend(self._file_filter(self.files, 'tte', 'fit.gz'))
files.extend(self._file_filter(self.files, 'tte', 'fit'))
if not full_day:
files = self._filter_tte(files)
return files
def get_ctime(self, download_dir, dets=None, **kwargs):
"""Download the ctime files
Args:
download_dir (str): The download directory
dets (list, optional): The detectors' data to download.
If omitted, will download all.
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'ctime', 'pha', dets=dets)
self._get(download_dir, files, **kwargs)
def get_cspec(self, download_dir, dets=None, **kwargs):
"""Download the cspec files
Args:
download_dir (str): The download directory
dets (list, optional): The detectors' data to download.
If omitted, will download all.
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'cspec', 'pha', dets=dets)
self._get(download_dir, files, **kwargs)
def get_poshist(self, download_dir, **kwargs):
"""Download the poshist file
Args:
download_dir (str): The download directory
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'poshist', 'fit')
self._get(download_dir, files, **kwargs)
def get_spechist(self, download_dir, dets=None, **kwargs):
"""Download the spechist files
Args:
download_dir (str): The download directory
dets (list, optional): The detectors' data to download.
If omitted, will download all.
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = self._file_filter(self.files, 'spechist', 'fit', dets=dets)
self._get(download_dir, files, **kwargs)
def get_tte(self, download_dir, dets=None, full_day=False, **kwargs):
"""Download all TTE files associated with a time.
Note:
Unless you have a high-bandwidth connection and can handle
downloading several GBs, it is not recommended to download the
full day of TTE data.
Args:
download_dir (str): The download directory
dets (list, optional): The detectors' data to download.
If omitted, will download all.
full_day (bool, optional):
If True, will download the TTE files for the full day. If False,
will return the TTE files for the covering the specified time.
Default is False.
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
files = []
files.extend(self._file_filter(self.files, 'tte', 'fit.gz', dets=dets))
files.extend(self._file_filter(self.files, 'tte', 'fit', dets=dets))
if not full_day:
files = self._filter_tte(files)
self._get(download_dir, files, **kwargs)
def get_all(self, download_dir, **kwargs):
"""Download all files within a daily directory.
Note:
Use at your own risk. Unless you have a high-bandwidth connection
and can handle downloading several GBs, this function is not
recommended for use.
Args:
download_dir (str): The download directory
verbose (bool, optional): If True, will output the download status.
Default is True.
"""
self._get(download_dir, self._file_list, **kwargs)
def _construct_path(self, met_obj):
"""Constructs the FTP path for antime
Args:
met_obj (:class:`.time.Met`): The MET time object
Returns:
str: The path of the FTP directory for the time
"""
path = os.path.join(self._root, met_obj.datetime.strftime('%Y/%m/%d'),
'current')
return path
def _filter_tte(self, files):
"""Filters a list of TTE files for only the files that contain the
desired time
Args:
files (list of str): The list of TTE files
Returns:
list of str: The filtered list of files
"""
id = self._met.ymd_h
files = [f for f in files if id in f]
return files
class HeasarcBrowse():
"""A class that interfaces with the HEASARC Browse API. This can be
called directly, but primarily intended as a base class.
The class makes a query to HEASARC's w3query.pl perl script in
BATCHRETRIEVALCATALOG mode. All fields and rows are retrieved so that
this class, on instantiation, contains the full set of catalog data.
Any queries based on row or columns selections/slices are then done locally,
instead of making repeated requests to the HEASARC.
Parameters:
table (str, optional): The name of the table to be passed to the
w3query.pl script.
verbose (bool, optional): Default is True
Attributes:
columns (np.array): The names of the columns available in the table
num_cols (int): The total number of columns (fields) in the data table
num_rows: (int): The total number of rows in the data table
"""
def __init__(self, table=None, verbose=True):
self._verbose = verbose
host = 'https://heasarc.gsfc.nasa.gov'
script = 'cgi-bin/W3Browse/w3query.pl'
query = 'tablehead=name=BATCHRETRIEVALCATALOG_2.0+{}&Fields=All'.format(
table)
# have to add this because HEASARC changed the default behavior without
# telling anyone
query += '&ResultMax=0'
if table is not None:
self._is_connected(host)
self._header, self._table = self._read_table(
host + '/' + script + '?' + query)
self._typedefs = self._auto_typedefs()
@property
def num_rows(self):
return self._table.shape[0]
@property
def num_cols(self):
return self._table.shape[1]
@property
def columns(self):
return self._header
def _is_connected(self, host):
try:
# connect to the host -- tells us if the host is actually
# reachable
socket.create_connection((host.split('/')[-1], 80))
return True
except OSError:
raise OSError("Either you are not connected to the internet or "
"{0} is down.".format(host))
return False
def _read_table(self, url):
"""Read the table from HEASARC
Args:
url (str): The URL including the query to the HEASARC perl script
Returns:
header (np.array): The column names of the table
table (np.array): The complete data table, unformatted
"""
# secure connection
context = ssl._create_unverified_context()
page = urlopen(url, context=context)
if self._verbose:
print('Downloading Catalog from HEASARC via w3query.pl...')
t0 = time.time()
# get content, decode to ascii, and split into lines
lines = page.read().decode('utf8').splitlines(False)
if self._verbose:
print('Finished in {} s'.format(int(time.time() - t0)))
# now we have to do the following because HEASARC changed the behavior
# of their public script without telling anyone
lines = lines[1:-1]
# table header
header = np.array([col.strip() for col in lines[0].split('|')])
# the table data
lines = lines[1:]
lines = [line for line in lines if '|' in line]
table = np.array(
[item.strip() for line in lines for item in line.split('|')])
table = table.reshape(-1, header.size)
# another undocumented and unannounced change to HEASARC browse:
# they added an additional '|' delimiter at the beginning and end of
# each line
header = header[1:-1]
table = table[:, 1:-1]
# clean nulls from table
table[(table == 'null') | (table == '')] = 'nan'
return (header, table)
def _auto_typedefs(self):
"""Auto-detect the datatype for each column of the table. The HEASARC
tables are returned as strings, with no definition of datatypes, so
we have to do a little work to guess what the proper types are. This
usually works pretty well. Can be overridden in a derived class after
the base class __init__ has been called.
"""
typedefs = []
# cycle through each column
for i in range(self.num_cols):
col = self._table[:, i]
j = 0
while (True):
# cycle to the first non-null entry
if col[j] == 'nan':
j += 1
continue
# if an entry is a digit, set as integer
if col[j].isdigit():
typedefs.append('int')
else:
# otherwise try applying float
try:
float(col[j])
typedefs.append('float')
except:
# if float fails, then must be a string, try datetime
try:
Met.from_iso(col[j])
typedefs.append('datetime')
# all else fails, this is definitely a string
except ValueError:
typedefs.append('str')
break
return np.array(typedefs)
def _apply_typedef(self, typedef, column):
"""Apply the type definition to a column of data.
Args:
typedef (str): The type definition
column (np.array): A column of data
Returns:
np.array: The column of data converted to the requested type
"""
if typedef == 'int':
try:
newcol = column.astype('int')
except:
# nan doesn't work for ints, for now. Not the best solution...
mask = (column == 'nan')
newcol = np.copy(column)
newcol[mask] = '-99999'
newcol = newcol.astype('int', copy=False)
elif typedef == 'float':
newcol = column.astype('float')
elif typedef == 'datetime':
newcol = column
# newcol = np.array([Met.from_iso(item).datetime for item in column])
else:
newcol = column
return newcol
def _colname_to_idx(self, colname):
"""Convert a column name to the index into the table array
Args:
colname (str): The column name
Returns:
int: The index into the table array
"""
if colname not in self._header:
raise ValueError('{} not a valid column name'.format(colname))
idx = np.where(self._header == colname)[0][0]
return idx
def get_table(self, columns=None):
"""Return the table data as a record array with proper type conversions.
Missing values are treated as type-converted ``np.nan``.
Args:
columns (list of str, optional): The columns to return. If omitted,
returns all columns.
Returns:
np.recarray: A record array containing the requested data
"""
if columns is None:
columns = self.columns
idx = np.array([self._colname_to_idx(column) for column in columns])
data = [self._apply_typedef(self._typedefs[i], self._table[:, i]) for i
in idx]
table = np.rec.fromarrays(data, names=','.join(columns))
return table
def column_range(self, column):
"""Return the data range for a given column
Args:
column (str): The column name
Returns:
tuple: The (lo, hi) range of the data column
"""
idx = self._colname_to_idx(column)
col = self._apply_typedef(self._typedefs[idx], self._table[:, idx])
col.sort()
return (col[0], col[-1])
def slice(self, column, lo=None, hi=None):
"""Perform row slices of the data table based on a conditional of a
single column
Args:
column (str): The column name
lo (optional): The minimum (inclusive) value of the slice. If not
set, uses the lowest range of the data in the column.
hi (optional): The maximum (inclusive) value of the slice. If not
set, uses the highest range of the data in the column.
Returns:
:class:`HeasarcBrowse`: Returns a new catalog with the sliced rows
"""
# have to apply the types and create a mask
idx = self._colname_to_idx(column)
col = self._apply_typedef(self._typedefs[idx], self._table[:, idx])
if lo is None:
lo, _ = self.column_range(column)
if hi is None:
_, hi = self.column_range(column)
mask = (col >= lo) & (col <= hi)
# create a new object and fill it with the sliced data
obj = HeasarcBrowse()
obj._header = np.copy(self._header)
obj._table = self._table[mask, :]
obj._typedefs = np.copy(self._typedefs)
return obj
def slices(self, columns):
"""Perform row slices of the data table based on a conditional of
multiple columns
Args:
columns (list of tuples):
A list of tuples, where each tuple is (column, lo, hi). The
'column' is the column name, 'lo' is the lowest bounding value,
and 'hi' is the highest bouding value. If no low or high
bounding is desired, set to None. See :meth:`slice()` for more
info.
Returns:
:class:`HeasarcBrowse`: Returns a new catalog with the sliced rows.
"""
numcols = len(columns)
obj = self
for i in range(numcols):
obj = obj.slice(columns[i][0], lo=columns[i][1], hi=columns[i][2])
return obj
class TriggerCatalog(HeasarcBrowse):
"""Class that interfaces with the GBM Trigger Catalog via HEASARC Browse.
Note:
Because this calls HEASARC's w3query.pl script on initialization,
it may take several seconds for the object to load.
Parameters:
coord_units_deg (bool, optional):
If True, converts the hms sexigesimal format output by HEASARC to
decimal degree. Default is True.
verbose (bool, optional): Default is True
Attributes:
columns (np.array): The names of the columns available in the table
num_cols (int): The total number of columns (fields) in the data table
num_rows: (int): The total number of rows in the data table
"""
def __init__(self, coord_units_deg=True, **kwargs):
super().__init__(table='fermigtrig', **kwargs)
# override detector mask typedef
idx = self._colname_to_idx('detector_mask')
self._typedefs[idx] = 'str'
# heasarc only provides these coordinates in hms. if we want
# decimal degrees, do the conversion and update the table and typedefs
if coord_units_deg:
idx1 = self._colname_to_idx('ra')
idx2 = self._colname_to_idx('dec')
coords = SkyCoord(self._table[:, idx1], self._table[:, idx2],
unit=(astro_units.hourangle, astro_units.deg))
self._table[:, idx1] = coords.ra.degree.astype('str')
self._table[:, idx2] = coords.dec.degree.astype('str')
self._typedefs[idx1] = 'float'
self._typedefs[idx2] = 'float'
class BurstCatalog(HeasarcBrowse):
"""Class that interfaces with the GBM Burst Catalog via HEASARC Browse.
Note:
Because this calls HEASARC's w3query.pl script on initialization,
it may take several seconds up to a couple of minutes for the object
to load.
Parameters:
coord_units_deg (bool, optional):
If True, converts the hms sexigesimal format output by HEASARC to
decimal degree. Default is True.
verbose (bool, optional): Default is True
Attributes:
columns (np.array): The names of the columns available in the table
num_cols (int): The total number of columns (fields) in the data table
num_rows: (int): The total number of rows in the data table
"""
def __init__(self, coord_units_deg=True, **kwargs):
super().__init__(table='fermigbrst', **kwargs)
# override detector mask typedef
idx = self._colname_to_idx('bcat_detector_mask')
self._typedefs[idx] = 'str'
# heasarc only provides these coordinates in hms. if we want
# decimal degrees, do the conversion and update the table and typedefs
if coord_units_deg:
idx1 = self._colname_to_idx('ra')
idx2 = self._colname_to_idx('dec')
coords = SkyCoord(self._table[:, idx1], self._table[:, idx2],
unit=(astro_units.hourangle, astro_units.deg))
self._table[:, idx1] = coords.ra.degree.astype('str')
self._table[:, idx2] = coords.dec.degree.astype('str')
self._typedefs[idx1] = 'float'
self._typedefs[idx2] = 'float'