GBM-data-tools/time.py

514 lines
15 KiB
Python

# time.py: Module containing time-related functions
#
# 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 datetime
import warnings
from astropy.time import Time
from astropy.time.formats import TimeFromEpoch
class TimeFermiSec(TimeFromEpoch):
"""Represents the number of seconds elapsed since Jan 1, 2001 00:00:00 UTC
including leap seconds"""
name = 'fermi'
unit = 1.0 / 86400 # in days (1 day == 86400 seconds)
epoch_val = '2001-01-01 00:01:04.184'
epoch_val2 = None
epoch_scale = 'tt' # Scale for epoch_val class attribute
epoch_format = 'iso' # Format for epoch_val class attribute
def round_half_to_nearest_even(num):
n = int(num)
v = abs(num - n)
if v > 0.5 or (v == 0.5 and n % 2):
return n + 1 - 2 * int(n < 0)
else:
return n
def hms_to_fraction_of_day(value):
"""The fraction of day as computed by the original ops code.
Args:
value (:class:`datetime.datetime`): The date/time
Returns:
float: The fraction of day
"""
result = round_half_to_nearest_even(((
value.hour * 3600 + value.minute * 60 + value.second) / 86400) * 1000)
return min(result, 999)
def fraction_of_day_to_hms(value):
"""The hour, minute, second for a given fraction of day
Args:
value (float): The fraction of day
Returns:
(int, int, int): Hour, minute, second
"""
s = int((value / 1000) * 86400)
h = s // 3600
s -= h * 3600
m = s // 60
s -= m * 60
return h, m, s
class Met:
"""Class representing the Fermi MET Epoch and allowing time conversions
to and from it.
Parameters:
secs (float): The MET
Attributes:
bn (str): The MET converted to bust number format: 'YYMMDDfff'
datetime (:class:`datetime.datetime`): A datetime object for the MET
gps (float): The number of seconds since Jan 6, 1980 00:00:00
(leap seconds are removed)
jd (float): The Julian Date associated with the MET
met (float): The MET
mjd (float): The modified Julian Date associated with the MET
time (:class:`astropy.time.Time`): The astropy time object for the MET
unix (float): The number of seconds since Jan 1, 1970 00:00:00 with
the leap seconds removed
ymd (str): The MET converted to the form `YYMMDD` in UTC
ymd_h (str): The MET converted to the form of YYMMDD_HHz in UTC
"""
# Mission Elapsed Time (Number of seconds since 2001-01-01 00:00:00 UTC)
def __init__(self, secs):
"""Creates a Met object with the time set to the number of seconds since Jan 1, 2001 00:00:00 UTC including the
leap seconds"""
if secs < 0:
warnings.warn("Time before GBM mission epoch")
# raise Exception("Time before GBM mission epoch")
self.__time = Time(secs, format='fermi')
@classmethod
def from_iso(cls, str_time):
"""Create a new Met object from an ISO-format UTC string
Args:
str_time (str): The ISO string
Returns:
:class:`Met`: The Met object
"""
if '.' in str_time:
dt = datetime.datetime.strptime(str_time, '%Y-%m-%dT%H:%M:%S.%f')
else:
dt = datetime.datetime.strptime(str_time, '%Y-%m-%dT%H:%M:%S')
return cls.from_datetime(dt)
@property
def met(self):
return self.__time.fermi
# Astropy Time
@property
def time(self):
return self.__time
@classmethod
def from_time(cls, atime):
"""Creates a new Met object from an astropy.Time object
Args:
atime (:class:`astropy.time.Time`): The astropy time object
Returns:
:class:`Met`: The Met object
"""
obj = cls(0)
obj.__time = atime
if obj.met < 0:
raise Exception("Time before GBM mission epoch")
return obj
# Python's datetime
@property
def datetime(self):
try:
return self.__time.utc.to_datetime(datetime.timezone.utc)
except ValueError:
# Repeat last met for a leap second
return Met(self.met - 1).datetime
@classmethod
def from_datetime(cls, dt):
"""Creates a new Met object from a datetime.datetime object
Args:
dt (:class:`datetime.datetime`): The datetime object
Returns:
:class:`Met`: The Met object
"""
return cls.from_time(Time(dt, format='datetime'))
# Unix timestamp (Number of seconds since 1970-01-01 00:00:00 UTC (leap seconds are ignored))
@property
def unix(self):
return self.datetime.timestamp()
@classmethod
def from_unix(cls, unix):
"""Creates a new Met object from a Unix timestamp
Args:
unix (float): A Unix time
Returns:
:class:`Met`: The Met object
"""
return cls.from_datetime(datetime.datetime.utcfromtimestamp(unix))
# GPS timestamp (Number of seconds since Jan 6, 1980 00:00:00 UTC (leap seconds are ignored))
@property
def gps(self):
return self.__time.gps
@classmethod
def from_gps(cls, gps):
"""Creates a new Met object from a GPS timestamp
Args:
gsp (float): A GPS time
Returns:
:class:`Met`: The Met object
"""
return cls.from_time(Time(gps, format='gps'))
# Julian date
@property
def jd(self):
return self.__time.jd
@classmethod
def from_jd(cls, jd):
"""Creates a new Met object from a Julian Date
Args:
jd (float): A Julian Date
Returns:
:class:`Met`: The Met object
"""
return cls.from_time(Time(jd, format='jd'))
# Modified Julian Date
@property
def mjd(self):
return self.__time.utc.mjd
@classmethod
def from_mjd(cls, mjd):
"""Creates a new Met object from a Modified Julian Date
Args:
mjd (float): A Modified Julian Date
Returns:
:class:`Met`: The Met object
"""
return cls.from_time(Time(mjd, format='mjd'))
# GBM Burst Number (YYMMDDFFF)
@property
def bn(self):
# Adjust to match a known bug in the old pipeline software
adj_met = self.met
if 157766399.0 < adj_met < 252460801.0:
adj_met += 1
elif 252460800.0 < adj_met <= 253497600.0:
adj_met += 2
# To ensure compatibility with the number produced by the pipeline, we are doing it the inefficient way
m = Met(adj_met)
utc_val = m.datetime
fraction = hms_to_fraction_of_day(utc_val)
return "{}{:03d}".format(utc_val.strftime("%y%m%d"), fraction)
@classmethod
def from_bn(cls, bn):
"""Creates a new Met object from a 'YYMMDDfff' string
Args:
bn (str): A burst number string
Returns:
:class:`Met`: The Met object
"""
dt = datetime.datetime.strptime(bn[:6], '%y%m%d')
hms = fraction_of_day_to_hms(int(bn[6:]))
dt = datetime.datetime(dt.year, dt.month, dt.day, hms[0], hms[1],
hms[2], tzinfo=datetime.timezone.utc)
obj = cls.from_datetime(dt)
# Adjust to match a known bug in the old pipeline software
adj_met = obj.met
if 157766400.0 < adj_met < 252460802.0:
adj_met -= 1
elif 252460802.0 < adj_met <= 253497602.0:
adj_met -= 2
return Met(adj_met)
# Year, Month, and Day as YYMMDD
@property
def ymd(self):
return self.datetime.strftime("%y%m%d")
@classmethod
def from_ymd(cls, ymd):
"""Creates a new Met object from a 'YYMMDD' string
Args:
ymd (str): A YYMMDD string
Returns:
:class:`Met`: The Met object
"""
dt = datetime.datetime.strptime(ymd, '%y%m%d')
return cls.from_datetime(dt)
# Year, Month, Day, and Hour as YYMMDD_HH
@property
def ymd_h(self):
return self.datetime.strftime("%y%m%d_%Hz")
@classmethod
def from_ymd_h(cls, ymd):
"""Creates a new Met object from a 'YYMMDD_HHz' string
Args:
ymd (str): A YYMMDD_HHz string
Returns:
:class:`Met`: The Met object
"""
dt = datetime.datetime.strptime(ymd, '%y%m%d_%Hz')
return cls.from_datetime(dt)
# Current time
@classmethod
def now(cls):
"""Creates a new Met object from the current time
Returns:
:class:`Met`: The Met object
"""
m = cls(0)
m.__time = Time.now()
return m
# String functions
def iso(self):
"""Returns the MET value as a string in the form of
yyyy-mm-ddTHH:MM:SS in UT
Returns:
:str: the ISO string
"""
return self.datetime.strftime("%Y-%m-%dT%H:%M:%S")
def __repr__(self):
"""Returns a string representation of the Met object"""
return "<Met seconds = {:.6f}>".format(self.met)
# Math functions
def add(self, x):
"""Returns an Met object with its value set to this object's value
with x seconds added to it. Can also use the ``+`` operator.
Args:
x (float): seconds to add
Returns:
:class:`Met`: The Met object
"""
if not (isinstance(x, int) or isinstance(x, float)):
raise ValueError("Can only add int or float to Met")
return Met(self.met + x)
def sub(self, x):
"""Returns an Met object with its value set to this object's value
with x seconds subtracted from it. Can also use the ``-`` operator.
Args:
x (float): seconds to subtract
Returns:
:class:`Met`: The Met object
"""
if isinstance(x, Met):
return self.met - x.met
elif isinstance(x, int) or isinstance(x, float):
return Met(self.met - x)
raise ValueError("Can only subtract int, float or Met from Met")
# Overriding built-in operators
def __add__(self, other):
return self.add(other)
def __sub__(self, other):
return self.sub(other)
def __lt__(self, other):
if isinstance(other, Met):
return self.met < other.met
else:
raise TypeError(
"'<' not supported between instances of 'Met' and '{}'".format(
type(other)))
def __le__(self, other):
if isinstance(other, Met):
return self.met <= other.met
else:
raise TypeError(
"'<=' not supported between instances of 'Met' and '{}'".format(
type(other)))
def __gt__(self, other):
if isinstance(other, Met):
return self.met > other.met
else:
raise TypeError(
"'>' not supported between instances of 'Met' and '{}'".format(
type(other)))
def __ge__(self, other):
if isinstance(other, Met):
return self.met >= other.met
else:
raise TypeError(
"'>=' not supported between instances of 'Met' and '{}'".format(
type(other)))
def __eq__(self, other):
if isinstance(other, Met):
return self.met == other.met
else:
raise TypeError(
"'==' not supported between instances of 'Met' and '{}'".format(
type(other)))
def __ne__(self, other):
if isinstance(other, Met):
return self.met != other.met
else:
raise TypeError(
"'!=' not supported between instances of 'Met' and '{}'".format(
type(other)))
# Some time related functions
def inclusive_date_range(start, stop, step=datetime.timedelta(days=1)):
"""Creates a list of Met from start to stop times
Args:
start (:class:`Met`): The start MET
stop (:class:`Met`): The end MET
step (:class:`datetime.timedelta, optional):
The step size. Default is 1 day.
Returns:
list of :class:`Met`: The list of Met objects
"""
d = start
result = []
if start <= stop:
earliest, latest = start, stop
else:
earliest, latest = stop, start
while earliest <= d <= latest:
result.append(d)
d += step
return result
def dates_range_from(num_days, dt=datetime.datetime.utcnow().date()):
"""Creates a list of dates within the given range
Args:
num_days (int): Number of days to include in the list
dt (:class:`datetime.date`, optional):
The last date to be included in the list. Default is current date.
Returns:
list: List of date values representing hours.
"""
d = dt - datetime.timedelta(days=num_days - 1)
return inclusive_date_range(d, dt)
def hours_range_from(num_hours, dt=datetime.datetime.utcnow()):
"""Creates a list of datetimes within the given range
Args:
num_hours (int): Number of hours to include in the list
dt (:class:`datetime.datetime`, optional):
The last hour to be included in the list (datetime will be
truncated to hour value). Default is current hour.
Returns:
list: List of datetime values representing hours.
"""
d = datetime.datetime(dt.year, dt.month, dt.day, dt.hour, 0, 0)
d -= datetime.timedelta(hours=num_hours - 1)
return inclusive_date_range(d, dt, datetime.timedelta(hours=1))
def dates_from_hours(hours):
"""Converts a list of hours to a list of days spanned
Args:
hours (list of :class:`datetime.date`): List of hours
Returns:
list: The list of dates
"""
return inclusive_date_range(hours[0].date(), hours[-1].date())