Skip to content

Commit 556db83

Browse files
committed
Merge pull request #126 from jhamman/cast_nc_datetime
Return numpy.datetime64 arrays for non-standard calendars
2 parents 970a90c + e07bc93 commit 556db83

File tree

2 files changed

+130
-16
lines changed

2 files changed

+130
-16
lines changed

test/test_conventions.py

Lines changed: 99 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ def test_cf_datetime(self):
8686
for calendar in ['standard', 'gregorian', 'proleptic_gregorian']:
8787
expected = nc4.num2date(num_dates, units, calendar)
8888
print(num_dates, units, calendar)
89-
actual = conventions.decode_cf_datetime(num_dates, units, calendar)
89+
with warnings.catch_warnings():
90+
warnings.filterwarnings('ignore', 'Unable to decode time axis')
91+
actual = conventions.decode_cf_datetime(num_dates, units, calendar)
9092
if (isinstance(actual, np.ndarray)
9193
and np.issubdtype(actual.dtype, np.datetime64)):
9294
self.assertEqual(actual.dtype, np.dtype('M8[ns]'))
@@ -111,8 +113,6 @@ def test_cf_datetime(self):
111113

112114
@requires_netCDF4
113115
def test_decoded_cf_datetime_array(self):
114-
import netCDF4 as nc4
115-
116116
actual = conventions.DecodedCFDatetimeArray(
117117
[0, 1, 2], 'days since 1900-01-01', 'standard')
118118
expected = pd.date_range('1900-01-01', periods=3).values
@@ -125,13 +125,103 @@ def test_decoded_cf_datetime_array(self):
125125
self.assertEqual(actual.dtype, np.dtype('datetime64[ns]'))
126126
self.assertArrayEqual(actual, expected)
127127

128-
num_dates = [722000, 720000.5]
129-
units = 'days since 0001-01-01 0:0:0'
128+
@requires_netCDF4
129+
def test_decode_non_standard_calendar(self):
130+
import netCDF4 as nc4
131+
132+
for calendar in ['noleap', '365_day', '360_day', 'julian', 'all_leap',
133+
'366_day']:
134+
units = 'days since 0001-01-01'
135+
times = pd.date_range('2001-04-01-00', end='2001-04-30-23',
136+
freq='H')
137+
noleap_time = nc4.date2num(times.to_pydatetime(), units,
138+
calendar=calendar)
139+
expected = times.values
140+
with warnings.catch_warnings():
141+
warnings.filterwarnings('ignore', 'Unable to decode time axis')
142+
actual = conventions.decode_cf_datetime(noleap_time, units,
143+
calendar=calendar)
144+
self.assertEqual(actual.dtype, np.dtype('M8[ns]'))
145+
self.assertArrayEqual(actual, expected)
146+
147+
@requires_netCDF4
148+
def test_decode_non_standard_calendar_single_element(self):
149+
units = 'days since 0001-01-01'
150+
for calendar in ['noleap', '365_day', '360_day', 'julian', 'all_leap',
151+
'366_day']:
152+
for num_time in [735368, [735368], [[735368]]]:
153+
with warnings.catch_warnings():
154+
warnings.filterwarnings('ignore', 'Unable to decode time axis')
155+
actual = conventions.decode_cf_datetime(num_time, units,
156+
calendar=calendar)
157+
self.assertEqual(actual.dtype, np.dtype('M8[ns]'))
158+
159+
@requires_netCDF4
160+
def test_decode_non_standard_calendar_single_element_fallback(self):
161+
import netCDF4 as nc4
162+
163+
units = 'days since 0001-01-01'
164+
dt = nc4.netcdftime.datetime(2001, 2, 29)
165+
for calendar in ['360_day', 'all_leap', '366_day']:
166+
num_time = nc4.date2num(dt, units, calendar)
167+
with warnings.catch_warnings(record=True) as w:
168+
warnings.simplefilter('always')
169+
actual = conventions.decode_cf_datetime(num_time, units,
170+
calendar=calendar)
171+
self.assertEqual(len(w), 1)
172+
self.assertIn('Unable to decode time axis',
173+
str(w[0].message))
174+
expected = np.asarray(nc4.num2date(num_time, units, calendar))
175+
print(num_time, calendar, actual, expected)
176+
self.assertEqual(actual.dtype, np.dtype('O'))
177+
self.assertEqual(expected, actual)
178+
179+
@requires_netCDF4
180+
def test_decode_non_standard_calendar_multidim_time(self):
181+
import netCDF4 as nc4
182+
130183
calendar = 'noleap'
131-
actual = conventions.DecodedCFDatetimeArray(num_dates, units, calendar)
132-
expected = nc4.num2date(num_dates, units, calendar)
133-
self.assertEqual(actual.dtype, np.dtype('O'))
134-
self.assertArrayEqual(actual, expected)
184+
units = 'days since 0001-01-01'
185+
times1 = pd.date_range('2001-04-01', end='2001-04-05', freq='D')
186+
times2 = pd.date_range('2001-05-01', end='2001-05-05', freq='D')
187+
noleap_time1 = nc4.date2num(times1.to_pydatetime(), units,
188+
calendar=calendar)
189+
noleap_time2 = nc4.date2num(times2.to_pydatetime(), units,
190+
calendar=calendar)
191+
mdim_time = np.empty((len(noleap_time1), 2), )
192+
mdim_time[:, 0] = noleap_time1
193+
mdim_time[:, 1] = noleap_time2
194+
195+
expected1 = times1.values
196+
expected2 = times2.values
197+
with warnings.catch_warnings():
198+
warnings.filterwarnings('ignore', 'Unable to decode time axis')
199+
actual = conventions.decode_cf_datetime(mdim_time, units,
200+
calendar=calendar)
201+
self.assertEqual(actual.dtype, np.dtype('M8[ns]'))
202+
self.assertArrayEqual(actual[:, 0], expected1)
203+
self.assertArrayEqual(actual[:, 1], expected2)
204+
205+
@requires_netCDF4
206+
def test_decode_non_standard_calendar_fallback(self):
207+
import netCDF4 as nc4
208+
for year in [2010, 2011, 2012, 2013, 2014]: # insure leap year doesn't matter
209+
for calendar in ['360_day', '366_day', 'all_leap']:
210+
calendar = '360_day'
211+
units = 'days since {0}-01-01'.format(year)
212+
num_times = np.arange(100)
213+
expected = nc4.num2date(num_times, units, calendar)
214+
215+
with warnings.catch_warnings(record=True) as w:
216+
warnings.simplefilter('always')
217+
actual = conventions.decode_cf_datetime(num_times, units,
218+
calendar=calendar)
219+
self.assertEqual(len(w), 1)
220+
self.assertIn('Unable to decode time axis',
221+
str(w[0].message))
222+
223+
self.assertEqual(actual.dtype, np.dtype('O'))
224+
self.assertArrayEqual(actual, expected)
135225

136226
@requires_netCDF4
137227
def test_cf_datetime_nan(self):

xray/conventions.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import numpy as np
22
import pandas as pd
3+
import warnings
34
from collections import defaultdict, OrderedDict
45
from datetime import datetime
56

@@ -88,7 +89,25 @@ def nan_safe_num2date(num):
8889
if ((calendar not in _STANDARD_CALENDARS
8990
or min_date.year < 1678 or max_date.year >= 2262)
9091
and min_date is not pd.NaT):
92+
9193
dates = nc4.num2date(num_dates, units, calendar)
94+
95+
if min_date.year >= 1678 and max_date.year < 2262:
96+
try:
97+
dates = nctime_to_nptime(dates)
98+
except ValueError as e:
99+
warnings.warn('Unable to decode time axis into full '
100+
'numpy.datetime64 objects, continuing using '
101+
'dummy netCDF4.datetime objects instead, reason:'
102+
'{0}'.format(e), RuntimeWarning, stacklevel=2)
103+
dates = np.asarray(dates)
104+
else:
105+
warnings.warn('Unable to decode time axis into full '
106+
'numpy.datetime64 objects, continuing using dummy '
107+
'netCDF4.datetime objects instead, reason: dates out'
108+
' of range', RuntimeWarning, stacklevel=2)
109+
dates = np.asarray(dates)
110+
92111
else:
93112
# we can safely use np.datetime64 with nanosecond precision (pandas
94113
# likes ns precision so it can directly make DatetimeIndex objects)
@@ -122,6 +141,7 @@ def nan_safe_num2date(num):
122141
+ np.datetime64(min_date))
123142
# restore original shape and ensure dates are given in ns
124143
dates = dates.reshape(num_dates.shape).astype('M8[ns]')
144+
125145
return dates
126146

127147

@@ -144,6 +164,16 @@ def guess_time_units(dates):
144164
return '%s since %s' % (time_unit, dates[0])
145165

146166

167+
def nctime_to_nptime(times):
168+
"""Given an array of netCDF4.datetime objects, return an array of
169+
numpy.datetime64 objects of the same size"""
170+
times = np.asarray(times)
171+
new = np.empty(times.shape, dtype='M8[ns]')
172+
for i, t in np.ndenumerate(times):
173+
new[i] = np.datetime64(datetime(*t.timetuple()[:6]))
174+
return new
175+
176+
147177
def encode_cf_datetime(dates, units=None, calendar=None):
148178
"""Given an array of datetime objects, returns the tuple `(num, units,
149179
calendar)` suitable for a CF complient time variable.
@@ -246,13 +276,7 @@ def __init__(self, array, units, calendar=None):
246276

247277
@property
248278
def dtype(self):
249-
if self.calendar is None or self.calendar in _STANDARD_CALENDARS:
250-
# TODO: return the proper dtype (object) for a standard calendar
251-
# that can't be expressed in ns precision. Perhaps we could guess
252-
# this from the units?
253-
return np.dtype('datetime64[ns]')
254-
else:
255-
return np.dtype('O')
279+
return np.dtype('datetime64[ns]')
256280

257281
def __getitem__(self, key):
258282
return decode_cf_datetime(self.array, units=self.units,

0 commit comments

Comments
 (0)