1"""
2Convert COARDS time specification to a datetime object.
3
4"""
5
6from datetime import datetime, timedelta
7import re
8import warnings
9
10
11EPOCH = datetime(1970, 1, 1)
12
13
14# constants in seconds
15SECOND = 1.0
16MINUTE = 60.0
17HOUR = 3.6e3
18DAY = 8.64e4
19SHAKE = 1e-8
20SIDEREAL_DAY = 8.616409e4
21SIDEREAL_HOUR = 3.590170e3
22SIDEREAL_MINUTE = 5.983617e1
23SIDEREAL_SECOND = 0.9972696
24SIDEREAL_YEAR = 3.155815e7
25TROPICAL_YEAR = 3.15569259747e7
26LUNAR_MONTH = 29.530589 * DAY
27COMMON_YEAR = 365 * DAY
28LEAP_YEAR = 366 * DAY
29JULIAN_YEAR = 365.25 * DAY
30GREGORIAN_YEAR = 365.2425 * DAY
31SIDEREAL_MONTH = 27.321661 * DAY
32TROPICAL_MONTH = 27.321582 * DAY
33FORTNIGHT = 14 * DAY
34WEEK = 7 * DAY
35JIFFY = 1e-2
36EON = 1e9 * TROPICAL_YEAR
37MONTH = TROPICAL_YEAR/12
38MILLISECOND = 1e-3
39MICROSECOND = 1e-6
40
41
42class ParserError(Exception):
43    pass
44
45
46class Parser(object):
47    def __init__(self, units):
48        parts = units.split(' since ')
49        self.units = parse_units(parts[0])
50        self.offset = parse_date(parts[1])
51
52    def __call__(self, value):
53        seconds = value*self.units
54
55        try:
56            date = EPOCH + timedelta(seconds=self.offset+seconds)
57        except OverflowError:
58            warnings.warn(
59                    "Shifted data 366 days to the future, since year zero does not exist.",
60                    UserWarning)
61            date = EPOCH + timedelta(seconds=self.offset+seconds+LEAP_YEAR)
62
63        return date
64
65
66class Formatter(object):
67    def __init__(self, units):
68        parts = units.split(' since ')
69        self.units = parse_units(parts[0])
70        self.offset = parse_date(parts[1])
71
72    def __call__(self, date):
73        dt = (date - EPOCH)
74        value = dt.days*DAY + dt.seconds + dt.microseconds*MICROSECOND - self.offset
75        return value / self.units
76
77
78class Converter(object):
79    def __init__(self, from_, to):
80        self.parser = Parser(from_)
81        self.formatter = Formatter(to)
82
83    def __call__(self, value):
84        return self.formatter(self.parser(value))
85
86
87def parse(value, units):
88    """
89    Parse a COARDS compliant date::
90
91        >>> parse(0, "hours since 1970-01-01 00:00:00")
92        datetime.datetime(1970, 1, 1, 0, 0)
93        >>> parse(0, "hours since 1970-01-01 00:00:00 +2:30")
94        datetime.datetime(1969, 12, 31, 21, 30)
95        >>> parse(10, "hours since 1996-1-1")
96        datetime.datetime(1996, 1, 1, 10, 0)
97        >>> parse(10, "hours since 1-1-1")
98        datetime.datetime(1, 1, 1, 10, 0)
99        >>> parse(10, "hours since 1990-11-25 12:00:00")
100        datetime.datetime(1990, 11, 25, 22, 0)
101        >>> parse(10, "hours since 1990-11-25 12:00")
102        datetime.datetime(1990, 11, 25, 22, 0)
103        >>> parse(10, "hours since 1990-11-25 12:00 +2:00")
104        datetime.datetime(1990, 11, 25, 20, 0)
105        >>> parse(10, "hours since 1990-11-25 12:00 UTC")
106        datetime.datetime(1990, 11, 25, 22, 0)
107        >>> parse(10, "seconds since 1970-1-1")
108        datetime.datetime(1970, 1, 1, 0, 0, 10)
109
110    It works with a year that never existed, since it's usual to have the
111    origin set to the year zero in climatological datasets::
112
113        >>> parse(366, "days since 0000-01-01 00:00:00")
114        datetime.datetime(1, 1, 1, 0, 0)
115
116    """
117    parser = Parser(units)
118    return parser(value)
119
120
121def format(date, units):
122    """
123    Convert a datetime object into a COARDS compliant date::
124
125        >>> print format(datetime(1970, 1, 1, 0, 0), "hours since 1970-01-01 00:00:00")
126        0.0
127        >>> print format(datetime(1969, 12, 31, 21, 30), "hours since 1970-01-01 00:00:00 +2:30")
128        0.0
129        >>> print format(datetime(1996, 1, 1, 10, 0), "hours since 1996-1-1")
130        10.0
131        >>> print format(datetime(1, 1, 1, 10, 0), "hours since 1-1-1")
132        10.0
133        >>> print format(datetime(1990, 11, 25, 22, 0), "hours since 1990-11-25 12:00:00")
134        10.0
135        >>> print format(datetime(1990, 11, 25, 22, 0), "hours since 1990-11-25 12:00")
136        10.0
137        >>> print format(datetime(1990, 11, 25, 20, 0), "hours since 1990-11-25 12:00 +2:00")
138        10.0
139        >>> print format(datetime(1990, 11, 25, 22, 0), "hours since 1990-11-25 12:00 UTC")
140        10.0
141        >>> print format(datetime(1970, 1, 1, 0, 0, 10), "seconds since 1970-1-1")
142        10.0
143
144    It works with a year that never existed, since it's usual to have the
145    origin set to the year zero in climatological datasets::
146
147        >>> print format(datetime(1, 1, 1, 0, 0), "days since 0000-01-01 00:00:00")
148        366.0
149
150    """
151    formatter = Formatter(units)
152    return formatter(date)
153
154
155def parse_units(units):
156    """
157    Parse units.
158
159    This function transforms all Udunits defined time units, returning it
160    converted to seconds::
161
162        >>> print parse_units("min")
163        60.0
164
165    """
166    udunits = [(SECOND,             ['second', 'seconds', 'sec', 's', 'secs']),
167               (MINUTE,             ['minute', 'minutes', 'min']),
168               (HOUR,               ['hour', 'hours', 'hr', 'h']),
169               (DAY,                ['day', 'days', 'd']),
170               (SHAKE,              ['shake', 'shakes']),
171               (SIDEREAL_DAY,       ['sidereal_day', 'sidereal_days']),
172               (SIDEREAL_HOUR,      ['sidereal_hour', 'sidereal_hours']),
173               (SIDEREAL_MINUTE,    ['sidereal_minute', 'sidereal_minutes']),
174               (SIDEREAL_SECOND,    ['sidereal_second', 'sidereal_seconds']),
175               (SIDEREAL_YEAR,      ['sidereal_year', 'sidereal_years']),
176               (TROPICAL_YEAR,      ['tropical_year', 'tropical_years', 'year', 'years', 'yr', 'a']),
177               (LUNAR_MONTH,        ['lunar_month', 'lunar_months']),
178               (COMMON_YEAR,        ['common_year', 'common_years']),
179               (LEAP_YEAR,          ['leap_year', 'leap_years']),
180               (JULIAN_YEAR,        ['julian_year', 'julian_years']),
181               (GREGORIAN_YEAR,     ['gregorian_year', 'gregorian_years']),
182               (SIDEREAL_MONTH,     ['sidereal_month', 'sidereal_months']),
183               (TROPICAL_MONTH,     ['tropical_month', 'tropical_months']),
184               (FORTNIGHT,          ['fortnight', 'fortnights']),
185               (WEEK,               ['week', 'weeks']),
186               (JIFFY,              ['jiffy', 'jiffies']),
187               (EON,                ['eon', 'eons']),
188               (MONTH,              ['month', 'months']),
189               (MILLISECOND,        ['msec', 'msecs']),
190               (MICROSECOND,        ['usec', 'usecs', 'microsecond', 'microseconds']),
191              ]
192
193    for seconds, valid in udunits:
194        if units in valid:
195            return seconds
196
197    raise ParserError('Invalid date units: %s' % units)
198
199
200def parse_date(date):
201    """
202    Parses a date string and returns number of seconds from the EPOCH.
203
204    """
205    # yyyy-mm-dd [hh:mm:ss[.s][ [+-]hh[:][mm]]]
206    p = re.compile( r'''(?P<year>\d{1,4})           # yyyy
207                        -                           #
208                        (?P<month>\d{1,2})          # mm or m
209                        -                           #
210                        (?P<day>\d{1,2})            # dd or d
211                                                    #
212                        (?:                         # [optional time and timezone]
213                            (?:\s|T)                #
214                            (?P<hour>\d{1,2})       #   hh or h
215                            :?                      #
216                            (?P<min>\d{1,2})?       #   mm or m
217                            (?:                     #   [optional seconds]
218                                :                   #
219                                (?P<sec>\d{1,2})    #       ss or s
220                                                    #
221                                (?:                 #       [optional decisecond]
222                                    \.              #           .
223                                    (?P<dsec>\d)    #           s
224                                )?                  #
225                            )?                      #
226                            (?:                     #   [optional timezone]
227                                \s?                 #
228                                ((?:                #
229                                    (?P<ho>[+-]?    #           [+ or -]
230                                    \d{1,2})        #           hh or h
231                                    :?              #           [:]
232                                    (?P<mo>\d{2})?  #           [mm]
233                                )                   #
234                                |                   #           or
235                                (?:UTC)|(?:Z))      #           UTC | Z
236                            )?                      #
237                        )?                          #
238                        $                           # EOL
239                    ''', re.VERBOSE)
240
241    m = p.match(date)
242    if m:
243        c = m.groupdict(0)
244        for k, v in c.items():
245            c[k] = int(v)
246
247        # get timezone offset in seconds
248        tz_offset = c['ho']*HOUR + c['mo']*MINUTE
249
250        # Some datasets use the date "0000-01-01 00:00:00" as an origin, even though
251        # the year zero does not exist in the Gregorian/Julian calendars.
252        if c['year'] == 0:
253            c['year'] = 1
254            year_offset = LEAP_YEAR
255        else:
256            year_offset = 0
257
258        origin = datetime(c['year'], c['month'], c['day'], c['hour'], c['min'], c['sec'], c['dsec'] * 100000)
259        dt = origin - EPOCH
260        return dt.days*DAY + dt.seconds + dt.microseconds*MICROSECOND - year_offset - tz_offset
261
262    raise ParserError('Invalid date: %s' % date)
263
264
265from_udunits = parse
266to_udunits = format
267
268
269def _test():
270    import doctest
271    doctest.testmod()
272
273
274if __name__ == "__main__":
275    _test()
276