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