GBM-data-tools/data/poshist.py

616 lines
22 KiB
Python

# poshist.py: GBM Position History class
#
# 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 astropy.io.fits as fits
import numpy as np
from numpy.lib.recfunctions import stack_arrays
from collections import OrderedDict
from scipy.interpolate import interp1d
from warnings import warn
from gbm import coords
from .data import DataFile
from gbm.detectors import Detector
class PosHist(DataFile):
"""Class for Fermi Position History
Attributes:
datatype (str): The datatype of the file
detector (str): The GBM detector the file is associated with
directory (str): The directory the file is located in
filename (str): The filename
full_path (str): The full path+filename
gti ([(float, float), ...]): A list of time intervals where Fermi is
outside the SAA
headers (dict): The headers for each extension of the poshist file
id (str): The GBM file ID
is_gbm_file (bool): True if the file is a valid GBM standard file,
False if it is not.
is_trigger (bool): True if the file is a GBM trigger file, False if not
sun_occulted ([(float, float), ...]): A list of time intervals where the
sun is occulted by the Earth
time_range (float, float): The time range of the poshist
"""
def __init__(self):
super(PosHist, self).__init__()
self._headers = OrderedDict()
self._data = None
self._times = None
self._eic_interp = None
self._quat_interp = None
self._lat_interp = None
self._lon_interp = None
self._alt_interp = None
self._vel_interp = None
self._angvel_interp = None
self._sun_interp = None
self._saa_interp = None
self._gti = None
self._sun_occulted = None
self._earth_radius_interp = None
self._geocenter_interp = None
self._detectors = [det.short_name for det in Detector]
@property
def headers(self):
return self._headers
@property
def time_range(self):
return (self._times[0], self._times[-1])
@property
def gti(self):
return self._gti
@property
def sun_occulted(self):
return self._sun_occulted
def get_eic(self, times):
"""Retrieve the position of Fermi in Earth inertial coordinates
Args:
times (float or np.array): Time(s) in MET
Returns:
np.array: A (3, `n`) array of coordinates at `n` times
"""
return self._eic_interp(times)
def get_quaternions(self, times):
"""Retrieve the Fermi attitude quaternions
Args:
times (float or np.array): Time(s) in MET
Returns:
np.array: A (4, `n`) array of quaternions at `n` times
"""
return self._quat_interp(times)
def get_latitude(self, times):
"""Retrieve the position of Fermi in Earth latitude
Args:
times (float or np.array): Time(s) in MET
Returns:
np.array: An array of latitudes at the requested times
"""
return self._lat_interp(times)
def get_longitude(self, times):
"""Retrieve the position of Fermi in Earth East longitude
Args:
times (float or np.array): Time(s) in MET
np.array: An array of East longitudes at the requested times
"""
return self._lon_interp(times)
def get_altitude(self, times):
"""Retrieve the altitude of Fermi in orbit
Args:
times (float or np.array): Time(s) in MET
Returns:
np.array: An array of altitudes at the requested times
"""
return self._alt_interp(times)
def get_velocity(self, times):
"""Retrieve the velocity of Fermi in Earth inertial coordinates
Args:
times (float or np.array): Time(s) in MET
Returns:
np.array: A (3, `n`) array of velocity components at `n` times
"""
return self._vel_interp(times)
def get_angular_velocity(self, times):
"""Retrieve the angular velocity of Fermi
Args:
times (float or np.array): Time(s) in MET
Returns
np.array: A (3, `n`) array of angular velocity components at `n` times
"""
return self._angvel_interp(times)
def get_sun_visibility(self, times):
"""Determine if the sun is visible (not occulted)
Args:
times (float or np.array): Time(s) in MET
Returns:
np.array(dtype=bool): A boolean array, where True indicates the \
sun is visible
"""
return self._sun_interp(times).astype(bool)
def get_saa_passage(self, times):
"""Determine if Fermi is in an SAA passage
Args:
times (float or np.array): Time(s) in MET
Returns:
np.array(dtype=bool): A boolean array, where True indicates Fermi \
is in SAA
"""
return self._saa_interp(times).astype(bool)
def get_earth_radius(self, times):
"""Retrieve the angular radius of the Earth as observed by Fermi
Args:
times (float or np.array): Time(s) in MET
Returns:
np.array: An array of the apparent angular radius of the Earth
"""
return self._earth_radius_interp(times)
def get_geocenter_radec(self, times):
"""Retrieve the Equatorial position of the Geocenter as observed by Fermi
Args:
times (float or np.array): Time(s) in MET
Returns:
(np.array, np.array): The RA and Dec of the geocenter
"""
eic = self.get_eic(times)
return coords.geocenter_in_radec(eic)
def get_mcilwain_l(self, times):
"""Retrieve the approximate McIlwain L value as determined by the
orbital position of Fermi
Args:
times (float or np.array): Time(s) in MET
Returns:
np.array: An array of McIlwain L values
"""
lat = self.get_latitude(times)
lon = self.get_longitude(times)
ml = coords.calc_mcilwain_l(lat, lon)
return ml
def to_fermi_frame(self, ra, dec, times):
"""Convert an equatorial position to a position in spacecraft coordinates
Args:
ra (float): The RA of a sky position
dec (float): The Dec of a sky position
times (float or np.array): Time(s) in MET
Returns:
(np.array, np.array): The Fermi azimuth, zenith
"""
quats = self.get_quaternions(times)
az, zen = coords.radec_to_spacecraft(ra, dec, quats)
return az, zen
def to_equatorial(self, fermi_az, fermi_zen, times):
"""Convert a position in spacecraft coordinates to equatorial coordinates
Args:
fermi_az (float): The Fermi azimuth of a position
fermi_zen (float): The Fermi zenith of a position
times (float or np.array): Time(s) in MET
Returns:
(np.array, np.array): The RA, Dec of the position
"""
quats = self.get_quaternions(times)
ra, dec = coords.spacecraft_to_radec(fermi_az, fermi_zen, quats)
return ra, dec
def location_visible(self, ra, dec, times):
"""Determine if a sky location is visible or occulted by the Earth
Args:
ra (float): The RA of a sky position
dec (float): The Dec of a sky position
times (float or np.array): Time(s) in MET
Returns:
np.array(dtype=bool): A boolean array where True indicates the \
position is visible.
"""
geo = np.array(self.get_geocenter_radec(times)).reshape(2, -1)
angle = coords.haversine(geo[0, :], geo[1, :], ra, dec)
visible = (angle > self.get_earth_radius(times))
return visible
def detector_pointing(self, det, times):
"""Retrieve the pointing of a GBM detector in equatorial coordinates
Args:
det (str): The GBM detector
times (float or np.array): Time(s) in MET
Returns:
(np.array, np.array): The RA, Dec of the detector pointing
"""
det = det.lower()
if det not in self._detectors:
raise ValueError('Illegal detector name')
d = Detector.from_str(det)
az, zen = d.azimuth, d.zenith
ra, dec = self.to_equatorial(az, zen, times)
return ra, dec
def detector_angle(self, ra, dec, det, times):
"""Determine the angle between a sky location and a GBM detector
Args:
ra (float): The RA of a sky position
dec (float): The Dec of a sky position
det (str): The GBM detector
times (float or np.array): Time(s) in MET
Returns:
np.array: The angle, in degrees, between the position and \
detector pointing
"""
det_ra, det_dec = self.detector_pointing(det, times)
angle = coords.haversine(det_ra, det_dec, ra, dec)
return angle
@classmethod
def open(cls, filename):
"""Open and read a position history file
Args:
filename (str): The filename of the FITS file
Returns:
:class:`PosHist`: The PosHist object
"""
obj = cls()
obj._file_properties(filename)
# open FITS file
with fits.open(filename) as hdulist:
for hdu in hdulist:
obj._headers.update({hdu.name: hdu.header})
data = hdulist['GLAST POS HIST'].data
times = data['SCLK_UTC']
obj._times = times
obj._data = data
# set the interpolators
obj._set_interpolators()
return obj
@classmethod
def merge(cls, poshists):
"""Merge multiple PosHists into a single PosHist object
Args:
poshists (list of :class:`PosHist`): List of PosHist objects
Returns:
:class:`PosHist`: The merged PosHist object
"""
# sort by tstart, and remove overlapping times
idx = np.argsort([poshist.time_range[0] for poshist in poshists])
times = [poshists[idx[0]]._times]
data = [poshists[idx[0]]._data]
for i in idx[1:]:
mask = (poshists[i]._times > times[-1][-1])
times.append(poshists[i]._times[mask])
data.append(poshists[i]._data[mask])
# create object with merged data
obj = cls()
obj._file_properties(poshists[idx[0]].full_path)
obj._times = np.concatenate(times)
obj._data = stack_arrays(data, asrecarray=True, usemask=False)
obj._headers = poshists[idx[0]].headers
obj._set_interpolators()
return obj
def _set_interpolators(self):
times = self._times
data = self._data
# Earth inertial coordinates interpolator
eic = np.array((data['POS_X'], data['POS_Y'], data['POS_Z']))
self._eic_interp = interp1d(times, eic)
# quaternions interpolator
quat = np.array(
(data['QSJ_1'], data['QSJ_2'], data['QSJ_3'], data['QSJ_4']))
self._quat_interp = interp1d(times, quat)
# Orbital position interpolator
# mark TODO: poshist uses the "simple" version of lat/lon calc
if 'SC_LAT' in data.dtype.names:
self._lat_interp = interp1d(times, data['SC_LAT'])
self._lon_interp = interp1d(times, data['SC_LON'])
alt = self._altitude_from_scpos(eic)
else:
lat, lon, alt = self._lat_lon_from_scpos(times, eic, complex=False)
self._lat_interp = interp1d(times, lat)
self._lon_interp = interp1d(times, lon)
self._alt_interp = interp1d(times, alt)
# Earth radius and geocenter interpolators
self._earth_radius_interp = interp1d(times, self._geo_half_angle(alt))
self._geocenter_interp = interp1d(times,
coords.geocenter_in_radec(eic))
# Velocity interpolator
vel = np.array((data['VEL_X'], data['VEL_Y'], data['VEL_Z']))
self._vel_interp = interp1d(times, vel)
# Angular velocity interpolator
angvel = np.array((data['WSJ_1'], data['WSJ_2'], data['WSJ_3']))
self._angvel_interp = interp1d(times, angvel)
# mark FIXME: the FLAGS=0 and FLAGS=2 never appear in daily poshist
# Interpolators for sun visibility and SAA passage
sun_visible = ((data['FLAGS'] == 1) | (data['FLAGS'] == 3))
in_saa = ((data['FLAGS']) == 2 | (data['FLAGS'] == 3))
self._sun_interp = interp1d(times, sun_visible)
self._saa_interp = interp1d(times, in_saa)
# set GTI based on SAA passages
# also times where sun is occulted
self._gti = self._split_bool_mask(in_saa, times)
self._sun_occulted = self._split_bool_mask(sun_visible, times)
def _from_trigdat(self, times, quats, scpos):
"""Initialize the PosHist object with info from a Trigdat object
Args:
times (np.array): An array of Trigdat bin start times
quats (np.array): An array of quaternions from the Trigdat
scpos (np.array): An array of EICs from the Trigdat
"""
if quats.shape[0] == times.shape[0]:
quats = quats.T
if scpos.shape[0] == times.shape[0]:
scpos = scpos.T
# EIC is in km in Trigdat. We need it in m.
scpos *= 1000.0
# interpolators for EIC and quaternions
self._eic_interp = interp1d(times, scpos, fill_value='extrapolate')
self._quat_interp = interp1d(times, quats, fill_value='extrapolate')
# orbital position interpolators
lat, lon, alt = self._lat_lon_from_scpos(times, scpos, complex=True)
self._lat_interp = interp1d(times, lat, fill_value='extrapolate')
self._lon_interp = interp1d(times, lon, fill_value='extrapolate')
self._alt_interp = interp1d(times, alt, fill_value='extrapolate')
# georadius and geocenter interpolators
self._earth_radius_interp = interp1d(times, self._geo_half_angle(alt),
fill_value='extrapolate')
self._geocenter_interp = interp1d(times,
coords.geocenter_in_radec(scpos),
fill_value='extrapolate')
# velocity and angular velocity interpolators
vel = self._velocity_from_scpos(times, scpos)
self._vel_interp = interp1d(times, vel, fill_value='extrapolate')
angvel = self._angular_velocity_from_quaternion(times, quats)
self._angvel_interp = interp1d(times, angvel, fill_value='extrapolate')
# sun visibility
sun_visible = self._sun_visible_from_times(times)
self._sun_interp = interp1d(times, sun_visible,
fill_value='extrapolate')
self._sun_occulted = self._split_bool_mask(sun_visible, times)
def _split_bool_mask(self, mask, times):
"""Split a boolean mask into segments of contiguous values and apply
to array of times
Args:
mask (np.array(dtype=bool)): The boolean mask
times (np.array): The array of times. Must be the same size as mask
Returns
[(float, float),...]: A list of time ranges
"""
# split a boolean mask array into segments based on True/False
indices = np.nonzero(mask[1:] != mask[:-1])[0] + 1
time_segs = np.split(times, indices)
mask_segs = np.split(mask, indices)
# retrieve the start and stop times for the "off" intervals
segs = []
numsegs = len(indices) + 1
for i in range(numsegs):
if mask_segs[i][0] == 0:
segs.append((time_segs[i][0], time_segs[i][-1]))
# mark FIXME: null if mask is all True or all False
# currently assuming that it must be all True
if len(segs) == 0:
segs = [self.time_range]
return segs
def _altitude_from_scpos(self, scpos):
"""Calculate altitude from the EIC.
Will attempt to do the more complex calculation taking into account
the shape of the Earth and the variable rotational velocity of the
Earth. This requires having up-to-date IERS tables. If local tables
are not up-to-date and astropy cannot query for a new table, will
default to a simpler approximation (spherical Earth, constant rotational
velocity).
Args:
scpos (np.array): The Earth inertial coordinates
Returns:
np.array: The altitudes
"""
try:
_, alt = coords.latitude_from_geocentric_coords_complex(scpos)
except:
warn('Using simple spheroidal Earth approximation')
_, alt = coords.latitude_from_geocentric_coords_simple(scpos)
return alt
def _angular_velocity_from_quaternion(self, times, quats):
"""Calculate angular velocity from changes in quaternion over time.
This is accurate for small rotations over very short times.
Args:
times (np.array): Times in MET
quats (np.array): Quaternions; one for each time
Returns:
np.array: The angular velocity
"""
dt = times[1:] - times[0:-1]
dquat = quats[:, 1:] - quats[:, :-1]
prod_quat = dquat
for i in range(len(dt)):
conj_quat = coords.quaternion_conj(quats[:, i])
prod_quat[:, i] = coords.quaternion_prod(conj_quat, dquat[:, i])
ang_vel = 2.0 / dt[np.newaxis, :] * prod_quat[0:3, :]
ang_vel = np.append(ang_vel, ang_vel[:, -1:], axis=1)
return ang_vel
def _velocity_from_scpos(self, times, scpos):
"""Calculate velocity from changes in position over time.
Args:
times (np.array): Times in MET
scpos (np.array): Positions of the spacecraft in Earth inertial
coordinates
Returns:
np.array: The velocity
"""
dt = times[1:] - times[0:-1]
dpos = scpos[:, 1:] - scpos[:, 0:-1]
velocity = dpos / dt
# copy last entry
velocity = np.append(velocity, velocity[:, -1:], axis=1)
return velocity
def _lat_lon_from_scpos(self, times, scpos, complex=False):
"""Convert coordinates of the spacecraft in Earth inertial coordinates
to latitude, East longitude, and altitude.
Args:
times (np.array): Times in MET
scpos (np.array): Positions of the spacecraft in Earth inertial
coordinates
complex (bool, optional):
If True, then uses the non-spherical Earth model and accurate
Earth rotation model. Default is False; Earth is a sphere and
the Earth rotation model is less accurate.
GBM FSW uses the latter option.
Returns:
tuple - 3-tuple containing:
- *np.array*: latitude
- *np.array*: longitude
- *np.array*: altitude
"""
if complex is False:
lat, alt = coords.latitude_from_geocentric_coords_simple(scpos)
else:
lat, alt = coords.latitude_from_geocentric_coords_complex(scpos)
try:
lon = coords.longitude_from_geocentric_coords(scpos, times,
ut1=complex)
except:
lon = coords.longitude_from_geocentric_coords(scpos, times,
ut1=False)
return (lat, lon, alt)
def _geo_half_angle(self, altitudes):
"""Calculate the apparent angular radius of the Earth
Args:
altitudes (np.array): The altitude of Fermi
Returns:
np.array: The angular radius, in degrees
"""
r = 6371.0 * 1000.0
half_angle = np.rad2deg(np.arcsin(r / (r + altitudes)))
return half_angle
def _sun_visible_from_times(self, times):
"""Calculate the sun visibility mask from the MET
Args:
times (np.array): The METs
Returns:
np.array(dtype=bool): A boolean mask, where True indicates the sun \
is visible.
"""
sun = np.array(coords.get_sun_loc(times))
return self.location_visible(sun[0, :], sun[1, :], times)