Skip to content

Commit a44e572

Browse files
committed
Add MpasRelativeDelta class
This merge adds a new MpasRelativeDelta class derived from dateutils.relativedelta.relativedelta. This class extends relativedelta to handle both MPAS calendars (important for addition to/subtraction from datetime.datetime objects). The timekeeping tests have been updated to work with the new class and to include new tests that ensure the right behavior for manipulating dates near leap days.
1 parent 00a30d2 commit a44e572

File tree

4 files changed

+350
-125
lines changed

4 files changed

+350
-125
lines changed

mpas_analysis/shared/io/namelist_streams_interface.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from ..containers import ReadOnlyDict
2020
from .utility import paths
21-
from ..timekeeping.utility import stringToDatetime, stringToRelativedelta
21+
from ..timekeeping.utility import stringToDatetime, stringToRelativeDelta
2222

2323

2424
def convert_namelist_to_dict(fname, readonly=True):
@@ -225,10 +225,10 @@ def readpath(self, streamName, startDate=None, endDate=None,
225225
if output_interval is None:
226226
# There's no file interval, so hard to know what to do
227227
# let's put a buffer of a year on each side to be safe
228-
offsetDate = stringToRelativedelta(dateString='0001-00-00',
228+
offsetDate = stringToRelativeDelta(dateString='0001-00-00',
229229
calendar=calendar)
230230
else:
231-
offsetDate = stringToRelativedelta(dateString=output_interval,
231+
offsetDate = stringToRelativeDelta(dateString=output_interval,
232232
calendar=calendar)
233233

234234
if startDate is not None:
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import datetime
2+
from dateutil.relativedelta import relativedelta
3+
from calendar import monthrange, isleap
4+
5+
6+
class MpasRelativeDelta(relativedelta):
7+
"""
8+
MpasRelativeDelta is a subclass of dateutil.relativedelta for relative time
9+
intervals with different MPAS calendars.
10+
11+
Only relative intervals (years, months, etc.) are supported and not the
12+
absolute date specifications (year, month, etc.). Addition/subtraction
13+
of datetime.datetime objects or other MpasRelativeDelta (but currently not
14+
datetime.date, datetime.timedelta or other related objects) is supported.
15+
16+
Author
17+
------
18+
Xylar Asay-Davis
19+
20+
Last Modified
21+
-------------
22+
02/09/2017
23+
"""
24+
25+
def __init__(self, dt1=None, dt2=None, years=0, months=0, days=0,
26+
hours=0, minutes=0, seconds=0, calendar='gregorian'):
27+
if calendar not in ['gregorian', 'gregorian_noleap']:
28+
raise ValueError('Unsupported MPAs calendar {}'.format(calendar))
29+
self.calendar = calendar
30+
super(MpasRelativeDelta, self).__init__(dt1=dt1, dt2=dt2, years=years,
31+
months=months, days=days,
32+
hours=hours, minutes=minutes,
33+
seconds=seconds)
34+
35+
def __add__(self, other):
36+
if not isinstance(other, (datetime.datetime, MpasRelativeDelta)):
37+
return NotImplemented
38+
39+
if isinstance(other, MpasRelativeDelta):
40+
if self.calendar != other.calendar:
41+
raise ValueError('MpasRelativeDelta objects can only be added '
42+
'if their calendars match.')
43+
years = self.years + other.years
44+
months = self.months + other.months
45+
if months > 12:
46+
years += 1
47+
months -= 12
48+
elif months < 1:
49+
years -= 1
50+
months += 12
51+
52+
return self.__class__(years=years,
53+
months=months,
54+
days=self.days + other.days,
55+
hours=self.hours + other.hours,
56+
minutes=self.minutes + other.minutes,
57+
seconds=self.seconds + other.seconds,
58+
calendar=self.calendar)
59+
60+
year = other.year+self.years
61+
62+
month = other.month
63+
if self.months != 0:
64+
assert 1 <= abs(self.months) <= 12
65+
month += self.months
66+
if month > 12:
67+
year += 1
68+
month -= 12
69+
elif month < 1:
70+
year -= 1
71+
month += 12
72+
73+
if self.calendar == 'gregorian':
74+
daysInMonth = monthrange(year, month)[1]
75+
elif self.calendar == 'gregorian_noleap':
76+
# use year 0001, which is not a leapyear
77+
daysInMonth = monthrange(1, month)[1]
78+
79+
day = min(daysInMonth, other.day)
80+
repl = {"year": year, "month": month, "day": day}
81+
82+
days = self.days
83+
if self.calendar == 'gregorian_noleap' and isleap(year):
84+
if month == 2 and day+days >= 29:
85+
# skip forward over the leap day
86+
days += 1
87+
elif month == 3 and day+days <= 0:
88+
# skip backward over the leap day
89+
days -= 1
90+
91+
return (other.replace(**repl) +
92+
datetime.timedelta(days=days,
93+
hours=self.hours,
94+
minutes=self.minutes,
95+
seconds=self.seconds))
96+
97+
def __radd__(self, other):
98+
return self.__add__(other)
99+
100+
def __rsub__(self, other):
101+
return self.__neg__().__add__(other)
102+
103+
def __sub__(self, other):
104+
if not isinstance(other, MpasRelativeDelta):
105+
return NotImplemented
106+
return self.__add__(other.__neg__())
107+
108+
def __neg__(self):
109+
return self.__class__(years=-self.years,
110+
months=-self.months,
111+
days=-self.days,
112+
hours=-self.hours,
113+
minutes=-self.minutes,
114+
seconds=-self.seconds,
115+
calendar=self.calendar)
116+
117+
def __mul__(self, other):
118+
try:
119+
f = float(other)
120+
except TypeError:
121+
return NotImplemented
122+
123+
return self.__class__(years=int(self.years * f),
124+
months=int(self.months * f),
125+
days=int(self.days * f),
126+
hours=int(self.hours * f),
127+
minutes=int(self.minutes * f),
128+
seconds=int(self.seconds * f),
129+
calendar=self.calendar)
130+
131+
__rmul__ = __mul__
132+
133+
def __div__(self, other):
134+
try:
135+
reciprocal = 1 / float(other)
136+
except TypeError:
137+
return NotImplemented
138+
139+
return self.__mul__(reciprocal)
140+
141+
__truediv__ = __div__
142+
143+
def __repr__(self):
144+
l = []
145+
for attr in ["years", "months", "days", "leapdays",
146+
"hours", "minutes", "seconds", "microseconds"]:
147+
value = getattr(self, attr)
148+
if value:
149+
l.append("{attr}={value:+g}".format(attr=attr, value=value))
150+
l.append("calendar='{}'".format(self.calendar))
151+
return "{classname}({attrs})".format(classname=self.__class__.__name__,
152+
attrs=", ".join(l))
153+
154+
# vim: foldmethod=marker ai ts=4 sts=4 et sw=4 ft=python

mpas_analysis/shared/timekeeping/utility.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"""
1212

1313
import datetime
14-
from dateutil.relativedelta import relativedelta
14+
from .MpasRelativeDelta import MpasRelativeDelta
1515

1616

1717
def stringToDatetime(dateString): # {{{
@@ -62,10 +62,10 @@ def stringToDatetime(dateString): # {{{
6262
minute=minute, second=second) # }}}
6363

6464

65-
def stringToRelativedelta(dateString, calendar='gregorian'): # {{{
65+
def stringToRelativeDelta(dateString, calendar='gregorian'): # {{{
6666
"""
6767
Given a date string and a calendar, returns an instance of
68-
`dateutil.relativedelta.relativedelta`
68+
`MpasRelativeDelta`
6969
7070
Parameters
7171
----------
@@ -91,7 +91,7 @@ def stringToRelativedelta(dateString, calendar='gregorian'): # {{{
9191
9292
Returns
9393
-------
94-
relativedelta : A `dateutil.relativedelta.relativedelta` object
94+
relativedelta : An `MpasRelativeDelta` object
9595
9696
Raises
9797
------
@@ -110,15 +110,9 @@ def stringToRelativedelta(dateString, calendar='gregorian'): # {{{
110110
(years, months, days, hours, minutes, seconds) = \
111111
_parseDateString(dateString, isInterval=True)
112112

113-
if calendar == 'gregorian':
114-
leapdays = True
115-
elif calendar == 'gregorian_noleap':
116-
leapdays = False
117-
else:
118-
raise ValueError('Unsupported calendar {}'.format(calendar))
119-
120-
return relativedelta(years=years, months=months, days=days, hours=hours,
121-
minutes=minutes, seconds=seconds, leapdays=leapdays)
113+
return MpasRelativeDelta(years=years, months=months, days=days,
114+
hours=hours, minutes=minutes, seconds=seconds,
115+
calendar=calendar)
122116
# }}}
123117

124118

0 commit comments

Comments
 (0)