# Copyright (c) 2014-2021, Sandia Corporation. All rights
# reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Utility classes for position update data
"""
from __future__ import print_function
import copy
import datetime
import math
import re
import sys
import time
from .simple_timezone import SimpleTimeZone
try:
import pytz
PYTZ_AVAILABLE = True
DEFAULT_TIMEZONE = pytz.utc
except ImportError:
PYTZ_AVAILABLE = False
DEFAULT_TIMEZONE = SimpleTimeZone(hours=0)
from .core_types import set_default_timezone
set_default_timezone(DEFAULT_TIMEZONE)
# ----------------------------------------------------------------------
[docs]def localize_timestamp(naive_ts, utc_offset=0):
"""Imbue a naive timestamp with a timezone
Python has two kinds of timestamps: naive (just a datetime, no
time zone) and aware (time plus time zone). Mixing the two is
awkward. Thie routine will assign a time zone to a datetime (UTC
by default) for consistency throughout Tracktable.
Note:
You can change the default timezone by setting the
module-level variable DEFAULT_TIMEZONE. This is not recommended.
Args:
naive_ts (datetime): Timestamp to localize
Keyword Args:
utc_offset (integer): Number of hours offset from UTC. You can also specify a fraction of an hour as '0530', meaning 5 hours 30 minutes. (Default: 0)
Returns:
A new datetime imbued with the desired time zone
"""
absolute_offset = abs(utc_offset)
if absolute_offset > 100:
hours = int(absolute_offset / 100)
minutes = absolute_offset % 100
else:
hours = absolute_offset
minutes = 0
if utc_offset >= 0:
utc_time = naive_ts - datetime.timedelta(hours=hours, minutes=minutes)
else:
utc_time = naive_ts + datetime.timedelta(hours=hours, minutes=minutes)
return DEFAULT_TIMEZONE.localize(utc_time)
# ----------------------------------------------------------------------
[docs]class Timestamp(object):
"""Convenience class that can convert from different formats to an 'aware' datetime
"""
"""No timestamp should ever be before this."""
BEGINNING_OF_TIME = datetime.datetime(1400, 1, 1)
[docs] @staticmethod
def beginning_of_time():
"""Return a timestamp guaranteed to be before any legal data point
Returns:
Timestamp equal to January 1, 1400.
"""
return datetime.datetime(1400, 1, 1)
[docs] @staticmethod
def sanity_check(timestamp):
"""Check to see whether a timestamp might be real
We assume that any timestamp after the year 1600 has a
non-zero chance of being real and that anything before that is
bogus.
Args:
timestamp (datetime): Timestamp to check
Returns:
Timestamp argument if sane, None if not
"""
if timestamp.year > 1600:
return timestamp
else:
return None
[docs] @staticmethod
def from_string(timestring, format_string=None):
"""Convert from a string to a datetime
Populate from a string such as '2012-09-10 12:34:56' or
'2012-09-10T12:34:56'. Note that you *must* have both a date
and a time in that format or else the method will fail.
You can use a different format if you like but you will have
to supply the 'format_string' argument. It will be passed to
datetime.strptime. In Python 3 you can use the '%z' directive
to parse a time zone declaration -- for example, '2017-06-01
12:34:56-0500' is June 5, 2017 in UTC-5, aka the US east coast.
Note:
Python 2.7 does not have the %z directive. You must use
Python 3.4 or newer to get that.
Args:
timestring (str): String containing your timestamp
Keyword Arguments:
format_string (str): Format string for datetime.strptime
Returns:
An aware datetime object. By default this will be imbued
with tracktable.core.timestamp.DEFAULT_TIMEZONE. If you
used a format string with %z or %Z then you will get
whatever time zone Python parsed.
"""
if timestring:
if format_string is None:
if timestring[10] == 'T':
format_string = '%Y-%m-%dT%H:%M:%S'
else:
format_string = '%Y-%m-%d %H:%M:%S'
parsed_time = datetime.datetime.strptime(timestring, format_string)
if parsed_time.tzinfo is not None:
return parsed_time
else:
return DEFAULT_TIMEZONE.localize(parsed_time)
else:
return Timestamp.beginning_of_time()
[docs] @staticmethod
def from_struct_time(mytime):
"""Construct a datetime from a time.struct_time object.
Args:
mytime (time.struct_time): Source time
Returns:
An aware datetime object imbued with tracktable.core.timestamp.DEFAULT_TIMEZONE.
"""
return DEFAULT_TIMEZONE.localize(datetime.datetime.fromtimestamp(time.mktime(mytime)))
[docs] @staticmethod
def from_dict(mydict):
"""Construct a datetime from a dict with named elements.
Args:
mydict (dict): Dict with zero or more of 'hour',
'minute', 'second', 'year', 'month', 'day', and 'utc_offset'
entries. Missing entries will be set to their minimum legal values.
Returns:
An aware datetime object imbued with tracktable.core.DEFAULT_TIMEZONE
unless a 'utc_offset' value is specified, in which case the specified
time zone will be used instead.
"""
timestamp = datetime.datetime.now()
timestamp.year=mydict.get('year', datetime.MINYEAR)
timestamp.month=mydict.get('month', 1)
timestamp.day=mydict.get('day', 1)
timestamp.hour=mydict.get('hour', 0)
timestamp.minute=mydict.get('minute', 0)
timestamp.second=mydict.get('second', 0)
if 'utc_offset' in mydict:
return localize_timestamp(timestamp, mydict['utc_offset'])
else:
return DEFAULT_TIMEZONE.localize(timestamp)
[docs] @staticmethod
def from_any(thing):
"""Try to construct a timestamp from whatever we're given.
The possible inputs can be:
- a Python datetime (in which case we just return a copy of the input)
- a string in the format '2013-04-05 11:23:45', in which case
we will assume that it resides in timestamp.DEFAULT_TIMEZONE
- a string in the format '2013-04-05 11:23:45-05', in which case we will
assume that it's UTC-5 (or other time zone, accordingly)
- a string in the format '2013-04-05T11:23:45' or
'2013-04-05T11:23:45-05' -- just like above but with a T in
the middle instead of a space
- a string in the format '20130405112345' - these are assumed
to reside in timestamp.DEFAULT_TIMEZONE
- a string in the format 'MM-DD-YYYY HH:MM:SS'
- a string such as '08-Aug-2013 12:34:45' where 'Aug' is the
abbreviated name for a month in your local environment
- a dict containing at least 'year', 'month', 'day' entries
and optionally 'hour', 'minute' and 'second' - these will
always represent UTC times until I implement it otherwise
Args:
thing (Various): String, datetime, or dict (see above)
Returns:
Timezone-aware datetime object
"""
# If it's a datetime then we might need to assign a timezone. That's all.
if type(thing) == datetime.datetime:
if thing.tzinfo is None:
return localize_timestamp(thing, 0)
else:
return copy.copy(thing)
# If it's a string, try to detect a timezone at the end and
# then try to parse the rest of it with a whole slew of format
# strings. Here we define 'string' as types str and unicode.
elif type(thing) == str or type(thing) == unicode:
try:
(timestamp, utc_offset) = _fastparse(thing)
return localize_timestamp(timestamp, utc_offset)
except (ValueError, TypeError):
match = re.search(r'([+-]\d{1,2})(00|)$', thing)
if match:
match_length = len(match.group(0))
string_without_tz = thing[0:-match_length]
utc_offset = int(match.group(1))
else:
string_without_tz = thing
utc_offset = 0
format_strings = [ '%Y-%m-%d %H:%M:%S',
'%Y-%m-%dT%H:%M:%S',
'%Y-%b-%d %H:%M:%S',
'%m-%d-%Y %H:%M:%S',
'%Y%m%d%H%M%S' ]
for format_str in format_strings:
try:
dt = datetime.datetime.strptime(string_without_tz, format_str)
return localize_timestamp(dt, utc_offset)
except:
continue
elif type(thing) == dict:
return Timestamp.from_dict(thing)
else:
raise ValueError('ERROR: Thing (%s) is not any kind of timestamp I understand.' % thing)
[docs] @staticmethod
def from_datetime(mytime):
"""Convert a datetime to an aware timestamp
Args:
mytime (datetime): Possibly-naive timestamp
Returns:
New datetime that will definitely have a time zone attached
"""
if mytime:
if not mytime.tzinfo:
return DEFAULT_TIMEZONE.localize(mytime)
else:
return mytime
else:
return None
[docs] @staticmethod
def to_string(dt, format_string='%Y-%m-%d %H:%M:%S', include_tz=True):
"""Convert a datetime to a string
Format contents as a string, by default formatted as
'2013-04-21 14:45:00'. You may supply an argument
'format_string' if you want it in a different form. See the
documentation for datetime.strftime() for information on what
this format string looks like.
Args:
dt (datetime): Timestamp object to stringify
Keyword Args:
format_string (str): String to pass to datetime.strftime()
that describes format (Default: '%Y-%m-%d %H:%M:%S)
include_tz (bool): Whether or not to append timezone UTC offset (Default: True)
Returns:
String version of timestamp
"""
just_the_time = dt.strftime(format_string)
if include_tz:
offset_in_minutes = dt.utcoffset().seconds / 60
absolute_offset = abs(offset_in_minutes)
hours = absolute_offset / 60
minutes = absolute_offset % 30
if offset_in_minutes > 0:
return '%s+%02d%02d' % ( just_the_time, hours, minutes )
else:
return '%s-%02d%02d' % ( just_the_time, hours, minutes )
else:
return just_the_time
[docs] @staticmethod
def to_iso_string(dt, include_tz=True):
"""Convert a timestamp to a string in format YYYY-MM-DDTHH:MM:SS
Args:
dt (datetime): Timezone-aware datetime object
Keyword Args:
include_tz (bool): Whether or not to append a '+XXXX' timezone offset (Default: True)
Returns:
String representation of the timestamp
"""
return Timestamp.to_string(dt, format_string='%Y-%m-%dT%H:%M:%S', include_tz=include_tz)
[docs] @staticmethod
def truncate_to_minute(orig_dt):
"""Zero out the seconds in a timestamp
Args:
orig_dt (datetime): Input datetime
Returns:
New timestamp with seconds=0
"""
new_dt = Timestamp.from_any(datetime.datetime(
year=orig_dt.year,
month=orig_dt.month,
day=orig_dt.day,
hour=orig_dt.hour,
minute=orig_dt.minute))
return new_dt
[docs] @staticmethod
def truncate_to_hour(orig_dt):
"""Zero out the minutes and seconds in a timestamp
Args:
orig_dt (datetime): Input datetime
Returns:
New timestamp with minutes=0 and seconds=0
"""
new_dt = Timestamp.from_any(datetime.datetime(
year=orig_dt.year,
month=orig_dt.month,
day=orig_dt.day,
hour=orig_dt.hour))
return new_dt
[docs] @staticmethod
def truncate_to_day(orig_dt):
"""Zero out the time portion of a timestamp
Args:
orig_dt (datetime): Input datetime
Returns:
New timestamp with hours=0, minutes=0 and seconds=0
"""
new_dt = Timestamp.from_any(datetime.datetime(
year=orig_dt.year,
month=orig_dt.month,
day=orig_dt.day))
return new_dt
[docs] @staticmethod
def truncate_to_year(orig_dt):
"""Zero out all but the year in a timestamp
Args:
orig_dt (datetime): Input datetime
Returns:
New timestamp with month=1, day=1, hours=0, minutes=0 and seconds=0
"""
new_dt = Timestamp.from_any(datetime.datetime(
year=orig_dt.year,
month=1,
day=1))
return new_dt
# ----------------------------------------------------------------------
def _fastparse(text):
"""INTERNAL METHOD
Because of the string processing we have to do, methods like
strptime are relatively slow. We can go a lot faster if we
know exactly which characters in a substring correspond
to different parts of a timestamp. This method is for that
case.
We assume that the timestamp is in the format 'YYYY-MM-DD
HH:MM:SS' with an optional addendum of '+XX' or '-XX' for
a UTC offset.
We deliberately don't trap any exceptions here. If anything
goes wrong, the caller will find out about it and fall back
to a slower but more robust method.
Args:
text (str): String representation of timestamp
Returns:
Timezone-aware datetime object
"""
textlen = len(text)
# YYYY-MM-DD HH:MM:SS
# 0123456789012345678
if len(text) == 19:
year = int(text[0:4])
month = int(text[5:7])
day = int(text[8:10])
hour = int(text[11:13])
minute = int(text[14:16])
second = int(text[17:19])
return ( datetime.datetime(year=year,
month=month,
day=day,
hour=hour,
minute=minute,
second=second), 0)
elif len(text) > 19 and (text[19] == '+' or text[19] == '-'):
year = int(text[0:4])
month = int(text[5:7])
day = int(text[8:10])
hour = int(text[11:13])
minute = int(text[14:16])
second = int(text[17:19])
offset = int(text[19:22])
return ( datetime.datetime(year=year,
month=month,
day=day,
hour=hour,
minute=minute,
second=second),
offset )