1#!/usr/bin/env python3
  2
  3"""Convert strings to datetime objects."""
  4
  5import re
  6import logging
  7from datetime import datetime, timedelta, date
  8
  9from chart.products.fdf.orbit import NoSuchOrbit
 10from chart.common.util import timedelta_div
 11from chart.common.util import timedelta_to_us
 12from chart.common.xml import xml_to_timedelta
 13from chart.common.exceptions import ConfigError
 14from chart.project import SID
 15from chart.sids.exceptions import BadSID
 16
 17# match YYYY-DDDxHH:MM:SS.uuu
 18THREEDAY = re.compile(r'([0-9]{4})[ :-]([0-9]{3})')
 19
 20# catch anything that looks a bit like "2014-04-02 05:00..."
 21MULTIMATCH = re.compile(r'([0-9]+)[^0-9]+([0-9]+)[^0-9]+([0-9]+)')
 22
 23class NotISO8601Exception(Exception):
 24    """Let helper functions signify a non-ISO8601 value."""
 25    pass
 26
 27
 28def decode_period(instr):
 29    """Handle the "--period, -p" flag in command line tools.
 30
 31    Return None if the string cannot be matched (maybe should be ValueError actually?)
 32    otherwise a tuple of start, stop times.
 33
 34    2017w10
 35    2017-100
 36    2017d100
 37    yesterday
 38    today
 39    lastweek
 40    lastmonth
 41    lastyear
 42    thisyear
 43    thisweek
 44    thismonth
 45    mission
 46    2017-16-23t16
 47    """
 48
 49    # handle 'yesterday'
 50    if instr == 'yesterday':
 51        today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
 52        return today - timedelta(days=1), today
 53
 54    # handle '2016w12'
 55    year_week_decoder = re.compile(r'(\d{4})w(\d{1,2})')
 56    match = year_week_decoder.match(instr)
 57    if match is not None:
 58        start = year_week_to_datetime(
 59            int(match.group(1)),
 60            int(match.group(2)))
 61        return start, start + timedelta(days=7)
 62
 63    # handle '2016-08-31'
 64    year_month_day_decoder = re.compile(r'(\d{4})-(\d{2})-(\d{2})')
 65    match = year_month_day_decoder.match(instr)
 66    if match is not None:
 67        start = datetime(int(match.group(1)),
 68                         int(match.group(2)),
 69                         int(match.group(3)))
 70        return start, start + timedelta(days=1)
 71
 72    # handle '2017-100'
 73    year_doy_decoder = re.compile(r'(\d{4})-(\d{3})')
 74    match = year_doy_decoder.match(instr)
 75    if match is not None:
 76        start = datetime(int(match.group(1)), 1, 1) + timedelta(days=int(match.group(2)))
 77        return start, start + timedelta(days=1)
 78
 79    return None
 80
 81
 82def texttime_to_timerange(start=None, stop=None, sid=None):
 83    """Return an interpreted dictionary with keys 'start' and 'stop'
 84    (both datetimes) based on our `start_time` and `stop_time` members.
 85
 86    Raises `ValueError` if the time range cannot be interpreted.
 87
 88    Returns:
 89        tuple of datetimes (start_time, stop_time)
 90
 91
 92    This might be better re-implemented as a function instead of a class
 93    as is it no longer used any other way.
 94
 95    >> texttime_to_timerange('orbit 10000', '2 orbits', 'M02')
 96    (datetime.datetime(2008, 9, 22, 13, 32, 32), datetime.datetime(2008, 9, 22, 16, 55, 16))
 97    """
 98    res_start = None
 99    res_stop = None
100
101    if start is not None and stop is None:
102        # we have start time only
103        res_start = texttime_to_datetime(start, sid)
104
105    elif stop is not None and start is None:
106        # we have stop time only
107        res_stop = texttime_to_datetime(stop, sid)
108
109    elif start is not None and stop is not None:
110        # we have a time range (start and stop)
111        start_duration = interpret_duration(start)
112        stop_duration = interpret_duration(stop)
113
114        if start_duration is not None and stop_duration is not None:
115            raise ValueError('Cannot use relative times for both start and stop times')
116
117        elif stop_duration is not None:
118            # user has given an absolute start and relative stop time
119            res_start = texttime_to_datetime(start, sid)
120            res_stop = start_duration_to_datetime(stop_duration,
121                                                  res_start,
122                                                  sid)
123
124        elif start_duration is not None:
125            # user has given a relative start and absolute stop time
126            res_stop = texttime_to_datetime(stop, sid)
127            res_start = stop_duration_to_datetime(start_duration,
128                                                  res_stop,
129                                                  sid)
130
131        else:
132            res_start = texttime_to_datetime(start, sid)
133            res_stop = texttime_to_datetime(stop, sid)
134
135    # logging.debug('texttime converted {start} to {res}'.format(start=start, res=res_start))
136    # logging.debug('texttime converted {stop} to {res}'.format(stop=stop, res=res_stop))
137    return res_start, res_stop
138
139
140def texttime_to_datetime(intime, sid=None):
141    """Autodetect format of input string and convert it using helper function.
142    If `start_time` is set then `intime` could also be a duration relative to `start_time`.
143    Parameters may be unicode or strings.
144
145    >>> texttime_to_datetime('201206041200005')
146    datetime.datetime(2012, 6, 4, 12, 0, 0, 500000)
147
148    >>> texttime_to_datetime('2012w4')
149    datetime.datetime(2012, 1, 23, 0, 0)
150
151    >>> texttime_to_datetime('2012w45T16:54:57')
152    datetime.datetime(2012, 11, 5, 16, 54, 57)
153    """
154
155    if len(intime) == 0:
156        raise ValueError('No input given')
157
158    elif intime.isdigit() and len(intime) >= 8:  # "gentime" like 20081112221200
159        return datetime.strptime(intime.ljust(18, '0'), '%Y%m%d%H%M%S%f')
160
161    elif intime == 'now':
162        return datetime.utcnow()
163
164    elif intime == 'today':
165        return datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
166
167    elif intime == 'yesterday':
168        return (datetime.utcnow() - timedelta(days=1)).replace(
169                hour=0, minute=0, second=0, microsecond=0)
170
171    elif intime == 'tomorrow':
172        return (datetime.utcnow() + timedelta(days=1)).replace(
173                hour=0, minute=0, second=0, microsecond=0)
174
175    elif intime == 'launch':
176        if sid is None:
177            raise ValueError('No SID supplied')
178
179        res = sid.satellite.launch_date
180        if res is None:
181            # the plot tool traps ValueError from this function and presents the result
182            # to the user
183            raise ValueError('No launch date specified for {sid}'.format(sid=sid))
184
185        return res
186
187    # For EventViewer to allow OOL snapshot view (get last available record before user-specified time)
188    elif intime.upper() == 'SNAP':
189        return 'SNAP'
190
191    # split into whitespace delimited components
192    in_parts = intime.strip().split()
193
194    # Note the following forms will fail:
195    #  - 2012-W31
196    #  - 2012-w4-3
197    #  - 2008w1
198    #  - 2002W526
199    # Test if the first component is some variation of year-week
200    # We don't allow the day-of-week to be specifies which simplifies this test
201    # a bit
202    # This test should come before the next 2 otherwise i.e.
203    # "2012w45T16:54:57" gets picked up by the regex for YYYYMMDDThhmmss for some reason
204    if 'W' in in_parts[0] or 'w' in in_parts[0]:
205        # look for things like 2012-w54 or 2012w2
206        year_week_match = re.match(r'^(?P<year>\d+)-?[wW](?P<week>\d+)', in_parts[0])
207        if year_week_match is not None:
208            timeless = year_week_to_datetime(
209                int(year_week_match.group('year')),
210                int(year_week_match.group('week')))
211            if 'T' in in_parts[0]:
212                return add_time_of_day(timeless, [in_parts[0].split('T')[1]])
213
214            else:
215                return add_time_of_day(timeless, in_parts[1:])
216
217        else:
218            raise ValueError('Cannot interpret time value due to "W" character which is usually '
219                             'used to denote a week number i.e. "2012w45". Note day-of-week '
220                             'notation is not supported')
221
222    # date with day of year
223    match = THREEDAY.match(in_parts[0])
224    if match is not None:
225        if 'T' in in_parts[0]:
226            return threeday_to_datetime(match.group(1), match.group(2), [in_parts[0].split('T')[1]])
227
228        else:
229            return threeday_to_datetime(match.group(1), match.group(2), in_parts[1:])
230
231    # see if the first component contain 3 numbers, delimited by any non-digit
232    # and interpret as YYYY-MM-DD
233    # For the time of day, we may already have in_parts[1] set to the time component
234    # (if separated by a space). Otherwise look for a 'T' delimiter now
235    # for full ISO8601
236    #a = re.compile(r'([0-9]+)[^0-9]+([0-9]+)[^0-9]+([0-9]+)')
237    match = MULTIMATCH.match(in_parts[0])
238    if match is not None:
239        if 'T' in in_parts[0]:
240            return iso8601_to_datetime(match, [in_parts[0].split('T')[1]])
241
242        else:
243            return iso8601_to_datetime(match, in_parts[1:])
244
245    # alternatively see if the first component contains 2 numbers
246    # and interpret as YYYY-DDD
247    match = re.match(r'([0-9]+)[^0-9]+([0-9]+)', in_parts[0])
248    if match is not None:
249        # could be a matlab time w/ time of day
250        if len(in_parts) == 1 and int(match.group(1)) >= 693962:
251            return matlab_to_datetime(float(in_parts[0]))
252
253        else:
254            return yyyyddd_to_datetime(match, in_parts[1:])
255
256    # test for time in terms of orbit number: "orbit 123", "o2", "orbit4524"
257    if intime[0].lower() == 'o':
258        match = re.match(r'^[oO](rbit|RBIT)? ?(?P<orbit>\d+)$', intime)
259        if match is None or len(in_parts) > 2:
260            raise ValueError('To look up an orbit number use "orbit 5.3"')
261
262        if sid is None:
263            raise ValueError('Cannot look up orbit number as SID was not specified')
264
265        return orbit_sid_to_datetime(sid, match.group('orbit'))
266
267    # see it its a single word
268    if len(in_parts) == 1:
269        # then it could be a MATLAB time
270        # ...
271        try:
272            conv = float(intime)
273        except:
274            logging.error('Timeconv could not convert: ' + intime)
275            raise ValueError('Could not convert input value ' + intime)
276
277        if conv >= 693962 and conv <= 767011:
278            return matlab_to_datetime(conv)
279
280        # or a CCSDS time
281        # ...
282        #pass
283
284    # look for <sid> <orbit>
285    if len(in_parts) == 2:
286        try:
287            sid = SID(in_parts[0])
288        except BadSID:
289            raise ValueError('Could not decode time')
290
291        orbit = in_parts[1]
292        return orbit_sid_to_datetime(sid, orbit)
293
294    # add '[sid] launch' ...
295
296    # no more options
297    raise ValueError('Could not interpret value {v}'.format(v=intime))
298
299
300def orbit_sid_to_datetime(sid, orbit):
301    """Convert string containing orbit number, possibly fractional,
302    to a datetime.
303
304    Args:
305        `sid` (str): Source ID
306        `orbit` (str): Orbit, as a string representation of an int or float
307
308    Returns:
309        datetime
310
311    Raises:
312        ValueError
313
314    """
315
316    match = re.match(r'([0-9]+)(\.[0-9]*)?', orbit)
317    if match is None:
318        raise ValueError('Cannot interpret orbit number {orbit}'.format(orbit=orbit))
319
320    orbit = match.group(1)
321
322    try:
323        lookup = sid.orbit.get_orbit_times(int(orbit))
324
325    except NoSuchOrbit:
326        raise ValueError('{sid} orbit number {orbit} not found in database'.format(
327                sid=sid,
328                orbit=orbit))
329
330    result = lookup[0]
331    frac = match.group(2)
332    if frac is not None:
333        result += timedelta(microseconds=(
334                timedelta_to_us(lookup[1] - lookup[0])) * float(frac))
335
336    result = result.replace(microsecond=0)
337
338    return result
339
340
341def matlab_to_datetime(intime):
342    """Convert a string containing a MATLAB format time (fractional days since AD 0)
343    into a datetime."""
344
345    days = int(intime)
346    us = (intime - days) * 86400 * 1000000
347    return datetime(2000, 1, 1) + timedelta(days=days - 730486, microseconds=us)
348
349
350def add_time_of_day(intime, remainder):
351    """`intime` is a datetime with only the date part set.
352    `remainder` is should be a string giving time of day which will be added to `intime`.
353    It is a list of strings. If at least 1 item long
354    that item will be parsed as a time value and added to `intime`.
355    """
356
357    if len(remainder) == 0:
358        # no time part present
359        return intime
360
361    # try and convert the rest to a time value
362    match = re.match(r'([0-9]+)[^0-9]*([0-9]*)[^0-9]*([0-9]*)\.?([0-9]*)', remainder[0])
363    if match is None:
364        return intime
365
366    # set the hour
367    intime = intime.replace(hour=int(match.group(1)))
368
369    minute = match.group(2)
370    if len(minute) == 0:
371        return intime
372
373    intime = intime.replace(minute=int(minute))
374
375    second = match.group(3)
376    if len(second) == 0:
377        return intime
378
379    intime = intime.replace(second=int(second))
380
381    subsecond = match.group(4)
382    if len(subsecond) == 0:
383        return intime
384
385    intime = intime.replace(microsecond=int(float('0.' + subsecond) * 1e6))
386    return intime
387
388
389def iso8601_to_datetime(match, remainder):
390    """Convert a time in a general format 'YYYY MM DD hh mm ss' with
391    various delimiters. Some parameters can be omitted.
392
393    `match` is a regex match of the date part of the input, containing 3 integer
394    values. `remainder` is a list of strings of the remainder of the user input, split.
395    """
396
397    res = datetime(int(match.group(1)),
398                   int(match.group(2)),
399                   int(match.group(3)))
400
401    return add_time_of_day(res, remainder)
402
403def threeday_to_datetime(year, doy, tod):
404    """Convert `year` (str), day of year `doy` (str) and time of day `tod` (str) to
405    a final datetime."""
406    res = datetime(int(year), 1, 1) + timedelta(days=int(doy)-1)
407    return add_time_of_day(res, tod)
408
409
410def yyyyddd_to_datetime(match, remainder):
411    """Convert a time in a general format 'YYYY DDD hh mm ss' with
412    various delimiters. Some parameters can be omitted. May be obsolete as threeday_to_datetime
413    works better."""
414
415    day = int(match.group(2))
416    if day == 0 or day > 366:
417        raise ValueError('Day value should be between 1 and 366')
418
419    res = datetime(int(match.group(1)), 1, 1) + \
420        timedelta(days=day - 1)
421
422    return add_time_of_day(res, remainder)
423
424
425def ccsds_to_datetime(_):
426    """Convert a time in format '<day>.<millisecond of day>'."""
427    raise ValueError('CCSDS input time not implemented')
428
429
430def interpret_duration(duration_string):
431    """See if `duration_str` looks like a duration, and return it's parts if so.
432
433    Format can be either ISO8660 (PT3DT4M0.123S) or this custom format:
434    1 day
435    2 weeks
436    3 minutes
437    4 months
438    5 seconds
439
440    Returns a dictionary containing: minute, hour, day, week, month, year, orbit, second
441    keys.
442    """
443
444    # first see if the ISO8660 handler will accept the value ...
445    try:
446        dt = xml_to_timedelta(duration_string.upper())
447    except ValueError:
448        dt = None
449
450    if dt is not None:
451        # ... if so, just use that.
452        # We return a fake regular expression match dict object
453        return dict(second=dt.seconds % 60,
454                    minute=(dt.seconds // 60) % 60,
455                    hour=dt.seconds // 3600,
456                    day=dt.days,
457                    week=None,
458                    month=None,
459                    year=None,
460                    orbit=None)
461
462    # Before our custom handler runs, make sure that year-week specifications ("2012w5")
463    # don't get matched here
464    if len(duration_string) == 0 or duration_string[-1].isdigit():
465        return None
466
467    # Now our custom handler
468    if interpret_duration.matcher is None:
469        interpret_duration.matcher = re.compile(  # (unused variable) pylint: disable=W0612
470            r'(?P<day>[0-9.-]+)\s*(day|d)s?|'
471            r'(?P<week>[0-9.-]+)\s*(week|w)s?|'
472            r'(?P<month>[0-9.-]+)\s*(month)s?|'
473            r'(?P<year>[0-9.-]+)\s*(year|y)s?|'
474            r'(?P<hour>[0-9.-]+)\s*(hour|h)s?|'
475            r'(?P<minute>[0-9.-]+)\s*(min|minute)s?|'
476            r'(?P<second>[0-9.-]+)\s*(s|sec|second)s?|'
477            r'(?P<orbit>[0-9.-]+)\s*(orbit|o)s?', flags=re.IGNORECASE)
478
479    match = interpret_duration.matcher.match(duration_string)  # input should be str
480    if match is None:
481        return None
482
483    match_dict = match.groupdict()
484    if len(match_dict) == 0:
485        return None
486
487    # logging.debug('match dur dict ' + str(match_dict))
488    return match_dict
489
490interpret_duration.matcher = None
491
492
493def start_duration_to_datetime(match, start_time, sid):
494    r"""Interpret `intime` as a string giving a duration, and return
495    `start_time` + `intime`.
496
497    Note, we cannot have a simple duration_to_datetime function
498    as the duration may be in months.
499
500    >> start_duration_to_datetime({'second': None, 'minute': None, 'hour': None, 'day': None,\
501        'week': None, 'month': None, 'year': None, 'orbit':5}, datetime(2012, 5, 4), 'M02')
502    datetime.datetime(2012, 5, 4, 8, 26, 48)
503    """
504
505    res = start_time
506
507    if match['second'] is not None:
508        res += timedelta(seconds=float(match['second']))
509
510    if match['minute'] is not None:
511        res += timedelta(minutes=float(match['minute']))
512
513    if match['hour'] is not None:
514        res += timedelta(hours=float(match['hour']))
515
516    if match['day'] is not None:
517        res += timedelta(days=float(match['day']))
518
519    if match['week'] is not None:
520        res += timedelta(days=float(match['week']) * 7)
521
522    if match['month'] is not None:
523        m = int(match['month'])
524        res = datetime(res.year + (res.month + m - 1) // 12,
525                       ((res.month + m - 1) % 12) + 1,
526                       res.day,
527                       res.hour,
528                       res.minute,
529                       res.second)
530
531    if match['year'] is not None:
532        res = res.replace(year=res.year + int(match['year']))
533
534    if match['orbit'] is not None:
535        # start_match = re.match(r'([a-zA-Z0-9]+) (\d+\.?\d*)', start_str)
536        # if start_match is None:
537            # raise ValueError("Cannot interpret start part")
538
539        start_orbit_int = sid.orbit.find_orbit(start_time)
540        start_lookup = sid.orbit.get_orbit_times(start_orbit_int)
541        start_orbit_dur = start_lookup[1] - start_lookup[0]
542        start_orbit_part = start_time - start_lookup[0]
543        start_orbit_frac = timedelta_div(start_orbit_part, start_orbit_dur)
544        start_orbit = start_orbit_int + start_orbit_frac
545
546        stop_orbit = start_orbit + float(match['orbit'])
547
548        lookup = sid.orbit.get_orbit_times(int(stop_orbit))
549        frac = stop_orbit - int(stop_orbit)
550        res = lookup[0] + timedelta(microseconds=(
551                timedelta_to_us(lookup[1] - lookup[0])) * float(frac))
552        res = res.replace(microsecond=0)
553
554    return res
555
556
557def stop_duration_to_datetime(match, stop_time, sid):
558    """Interpret `intime` as a string giving a duration, and return
559    `stop_time` + `intime`.
560
561    Note, we cannot have a simple duration_to_datetime function
562    as the duration may be in months.
563
564    >> stop_duration_to_datetime(interpret_duration('5 orbits'), datetime(2012, 5, 4), 'M02')
565    datetime.datetime(2012, 5, 3, 15, 33, 12)
566
567    >>> stop_duration_to_datetime(interpret_duration('7 months'), datetime(2012, 5, 4), 'M02')
568    datetime.datetime(2011, 10, 4, 0, 0)
569    """
570
571    res = stop_time
572
573    if match['second'] is not None:
574        res -= timedelta(minutes=float(match['second']))
575
576    if match['minute'] is not None:
577        res -= timedelta(minutes=float(match['minute']))
578
579    if match['hour'] is not None:
580        res -= timedelta(hours=float(match['hour']))
581
582    if match['day'] is not None:
583        res -= timedelta(days=float(match['day']))
584
585    if match['week'] is not None:
586        res -= timedelta(days=float(match['week']) * 7)
587
588    if match['month'] is not None:
589        m = int(match['month'])
590        res = datetime(res.year + (res.month - m - 1) // 12,
591                       ((res.month - m - 1) % 12) + 1,
592                       res.day,
593                       res.hour,
594                       res.minute,
595                       res.second)
596
597    if match['year'] is not None:
598        res = res.replace(year=res.year - int(match['year']))
599
600    if match['orbit'] is not None:
601        stop_orbit_int = sid.orbit.find_orbit(stop_time)
602        stop_lookup = sid.orbit.get_orbit_times(stop_orbit_int)
603        stop_orbit_dur = stop_lookup[1] - stop_lookup[0]
604        stop_orbit_part = stop_time - stop_lookup[0]
605        stop_orbit_frac = timedelta_div(stop_orbit_part, stop_orbit_dur)
606        stop_orbit = stop_orbit_int + stop_orbit_frac
607
608        stop_orbit = stop_orbit - float(match['orbit'])
609
610        lookup = sid.orbit.get_orbit_times(int(stop_orbit))
611        frac = stop_orbit - int(stop_orbit)
612        res = lookup[0] + timedelta(microseconds=(
613                timedelta_to_us(lookup[1] - lookup[0])) * float(frac))
614        res = res.replace(microsecond=0)
615
616    return res
617
618
619def year_week_to_datetime(year, week):
620    """Function to convert the week number properly
621        %W takes the first Monday to be in week 1 but ISO defines week 1 to contain 4 January.
622        So the result from datetime.strptime('2011221', '%Y%W%w')
623        is off by one iff the first Monday and 4 January are in different weeks.
624        The latter is the case if 4 January is a Friday, Saturday or Sunday.
625
626    Args:
627        year: the year to check
628        week: the week to check
629    """
630    ret = datetime.strptime('{year:04}-{week:02}-1'.format(year=year, week=week), '%Y-%W-%w')
631
632    # week 1 is the first week of the year to have at least 4 days
633    if date(year, 1, 4).isoweekday() > 4:
634        ret -= timedelta(days=7)
635
636    return ret