"""SPICE datetime module.
The full metakernel specifications are available on NAIF website:
https://naif.jpl.nasa.gov/pub/naif/toolkit_docs/C/req/time.html
"""
import re
from datetime import datetime as dt_native
from operator import itemgetter
import numpy as np
import spiceypy as sp
MONTHS = [
'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'
]
[docs]def datetime(string, *others):
"""Parse datetime with SPICE convention.
Parameters
----------
string: str
Input datetime string. Many format are supported, see the NAIF docs:
https://naif.jpl.nasa.gov/pub/naif/toolkit_docs/C/req/time.html#Input%20String%20Conversion
If the string starts with a ``@``, this character will be discarded
before being parsed.
*others: str, optional
Addition input string(s) to parse.
Returns
-------
numpy.datetime64 or [numpy.datetime64, …]
Parsed string(s) as numpy datetime64 object(s).
Raises
------
TypeError
If the input time a not a string.
ValueError
If the provided string is not recognized by SPICE.
Note
----
This routine is the simple parser and does not require
any kernel to be loaded.
Warning
-------
This routine does not implement time conversion from ``UTC``
to ``TDB`` or ``TDT``. You need to load at least a leapsecond kernel
to perform these conversions.
"""
if others:
return [datetime(s) for s in [string, *others]]
if isinstance(string, (tuple, list, np.ndarray)):
return [datetime(s) for s in string]
if isinstance(string, np.datetime64):
return string
if isinstance(string, dt_native):
string = str(string)
if not isinstance(string, str):
raise TypeError(f'Input time must be a string not `{type(string)}`')
if string.strip() in ['NaT', 'N/A', 'Unable to determine']:
return np.datetime64('NaT')
if string.startswith('@'):
string = string[1:]
if '_' in string:
string = string.replace('_', ' ')
# Extract date and time parts and reformat the string
jdn, h, m, s, ms = jdn_hms(string)
yy, mm, dd = ymd(jdn)
time = f'{yy:02d}-{mm:02d}-{dd:02d}T{h:02d}:{m:02d}:{s:02d}.{ms:03d}'
return np.datetime64(clean_time(time))
[docs]def jdn_hms(string):
"""Extract the julian day and the time from a string.
Parameters
----------
string: str
Input string to parse.
Returns
-------
int, int, int, int, int
Julian date number, hours, minutes, seconds and milliseconds values.
Raises
------
ValueError
If the string can not be parsed as a SPICE time.
Note
----
The precision output time is rounded at 1 millisecond.
The Julian Day Number is always defined with respect to the Gregorian calendar.
Warning
-------
This definition of the Julian Date does not date into account
leapseconds. Use Ephemeris Time (``et``) if you need a 1 second
precision.
See Also
--------
:py:func:`ymd`
:py:func:`jd`
"""
# Parse the string with SPICE to get the number of seconds after 2000.
sp2000, err = sp.tparse(string)
if err:
raise ValueError(err)
# Extract the time part
s = int(np.floor(sp2000))
msec = int(np.round((sp2000 - s) * 1_000))
m, second = s // 60, s % 60
h, minute = m // 60, m % 60
hour = (h + 12) % 24
# Extract the Days past 2000
dp2000 = (s - (3_600 * hour + 60 * minute + second)) / sp.spd() + .5
jdn = int(sp.j2000() + dp2000)
return jdn, hour, minute, second, msec
[docs]def jd(string):
"""Convert time from a string to decimal Julian Date.
Parameters
----------
string: str
Input time string to convert.
Returns
-------
float
Julian day decimal value.
Raises
------
ValueError
If the string can not be parsed as a SPICE time.
Warning
-------
This definition of the Julian Date does not date into account
leapseconds. Use Ephemeris Time (``et``) if you need a 1 second
precision.
See Also
--------
:py:func:`jdn_hms`
"""
j, h, m, s, msec = jdn_hms(string)
return j + ((h - 12) * 3600 + m * 60 + s + msec / 1000) / sp.spd()
[docs]def ymd(jdn):
"""SPICE conversion from Julian Day to the Gregorian Calendar.
Parameters
----------
jdn: int
Julian Date Number (no decimal value).
Returns
-------
int, int, int
Parsed year, month and day in the Gregorian Calendar.
Warning
-------
The dates before October 15th, 1582 are still represented in the Gregorian
calendar and not in the Julian calendar. This is not strictly correct but
it does correspond to the default behavior of SPICE and Numpy:
>>> spiceypy.tparse('1582-10-14')
>>> numpy.datetime64('1582-10-14')
Don't throw errors even if theses date don't exists,
although the day before 1582-10-15 should be 1582-10-04.
>>> numpy.datetime64('1582-10-15') - numpy.datetime64('1582-10-04') == \
numpy.timedelta64(1,'D')
False
"""
alpha = np.floor((jdn - 1_867_216.25) / 36_524.25)
s = jdn + 1 + alpha - np.floor(alpha / 4)
b = s + 1_524
c = np.floor((b - 122.1) / 365.25)
d = np.floor(365.25 * c)
e = np.floor((b - d) / 30.6001)
day = b - d - np.floor(30.6001 * e)
month = e - 1 if e < 14 else e - 13
year = c - 4_716 if month > 2 else c - 4_715
return int(year), int(month), int(day)
def clean_time(time):
"""Clean time string (discard null leading values)."""
if '.' in time:
time = time.rstrip('0')
time = time.rstrip('.')
for _ in range(2): # :MM:SS
if time.endswith(':00'):
time = time[:-3]
if time.endswith('T00'): # THH
time = time[:-3]
for _ in range(2): # -MM-DD
if time.endswith('-01'):
time = time[:-3]
return time
[docs]def sorted_datetimes(times, index=None, reverse=False):
"""Sort a list of datetimes.
Parameters
----------
times: list
List of datetimes to sort.
index: int or tuple, optional
Index (or indexes) to use when a list of tuple/list is provided
(default: None).
reverse: bool, optional
Sort the list in reverse order.
Returns
-------
list
Sorted list of datetimes.
Raises
------
TypeError
If the provided ``index`` is not ``None``, ``int``, ``list`` or ``tuple``.
Note
----
- The input ``times`` don't need to be pre-formatted.
- The output list is only reordered, the input ``times`` are not post-formatted.
"""
# Extract the datetime elements
if index is None:
elements = times
elif isinstance(index, int):
elements = [t[index] for t in times]
elif isinstance(index, (list, tuple)):
elements = [[t[i] for i in index] for t in times]
else:
raise TypeError('Invalid `index`. It should be `None`, `int`, `list` or `tuple`.')
indexes = sorted(
enumerate(datetime(elements)),
key=itemgetter(slice(1, None)),
reverse=reverse
)
return [times[i] for i, _ in indexes]
[docs]def iso(time):
"""Reformat datetime to ISO format."""
if isinstance(time, str):
return iso(datetime(time))
if isinstance(time, np.datetime64):
time = time.item()
s = f'{time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]}Z'
return s[:-5] + 'Z' if s.endswith('.000Z') else s
[docs]def mapps_datetime(time):
"""Reformat datetime in MAPPS format.
.. code-block:: text
2001-JAN-01_12:34:56.789
Warning
-------
MAPPS datetime is not natively compatible with
the SPICE time patterns.
"""
t = iso(time)
year, month, day, hms = t[:4], t[5:7], t[8:10], t[11:-1]
return f'{year}-{MONTHS[int(month) - 1]}-{day}_{hms}'
def np_datetime_str(numpy_datetime64) -> str:
"""Convert datetime from numpy.datetime64 to ISO string."""
if np.isnat(numpy_datetime64):
return 'NaT'
dt = numpy_datetime64.item()
stime = dt.isoformat() + ('' if isinstance(dt, dt_native) else 'T00:00:00')
if '.' in stime:
stime = stime.rstrip('0') # Remove trailing zeros for the decimal values
stime = stime.rstrip('.')
return stime
def np_date_str(numpy_datetime64) -> str:
"""Extract date from numpy.datetime64 as a string."""
if np.isnat(numpy_datetime64):
return 'NaT'
dt = numpy_datetime64.item()
return str(dt.date()) if isinstance(dt, dt_native) else str(dt)
DT_TIMEDELTA = re.compile(
r'(?P<sign>[+-])?(?P<h>\d{2}):(?P<m>\d{2}):(?P<s>\d{2})(?:\.(?P<ms>\d+))?'
)
NP_TIMEDELTA = re.compile(
r'(?P<value>\d+)\s?(?P<unit>millisecond|month|ms|[smhHdDMyY])s?'
)
NP_TIMEDELTA_UNITS = {
'millisecond': 'ms',
'H': 'h',
'd': 'D',
'month': 'M',
'y': 'Y',
}
[docs]def timedelta(step):
"""Parse step as :class:`numpy.timedelta64` object.
The value must be a :obj:`int` followed by an optional
space and a valid unit.
Examples of valid units:
- ``ms``, ``msec``, ``millisecond``
- ``s``, ``sec``, ``second``
- ``m``, ``min``, ``minute``
- ``h``, ``hour``
- ``D``, ``day``
- ``M``, ``month``
- ``Y``, ``year``
Parameters
----------
step: str
Step to parse.
Returns
-------
numpy.timedelta64
Parsed numpy.timedelta64 step.
Raises
------
ValueError
If the provided step format or unit is invalid.
"""
if isinstance(step, np.timedelta64):
return step
if match := DT_TIMEDELTA.match(step):
sign, h, m, s, ms = match.groups()
return (-1 if sign == '-' else 1) * (
np.timedelta64(int(h), 'h') +
np.timedelta64(int(m), 'm') +
np.timedelta64(int(s), 's') +
(np.timedelta64(int(1_000 * float(f'0.{ms}')), 'ms') if ms else 0)
)
if match := NP_TIMEDELTA.match(step):
value, unit = match.groups()
return np.timedelta64(int(value), NP_TIMEDELTA_UNITS.get(unit, unit))
raise ValueError(f'Invalid step format: `{step}`')