Repository: OceanParcels/parcels
Pull request #604 (2019-06-11 02:09:19)

Calendar parsing
I attempted to enable parcels to carry the cftime calendars through to the output (#602).
I added two utility functions `_get_cftime_calendars` and `_get_cftime_datetimes` which return lists of all available calendars and corresponding datetime instances. 
The other changes mostly just implement these two functions and I added some tests. 

I am not very familiar with the code structure, and am happy to rearrange things.  


Patch:
diff --git a/parcels/particlefile.py b/parcels/particlefile.py
index 61775be2..fe48eb87 100644
--- a/parcels/particlefile.py
+++ b/parcels/particlefile.py
@@ -23,6 +23,13 @@ def _is_particle_started_yet(particle, time):
     return (particle.dt*particle.time <= particle.dt*time or np.isclose(particle.time, time))
 
 
+def _set_calendar(origin_calendar):
+    if origin_calendar == 'np_datetime64':
+        return 'standard'
+    else:
+        return origin_calendar
+
+
 class ParticleFile(object):
     """Initialise netCDF4.Dataset for trajectory output.
 
@@ -84,7 +91,7 @@ class ParticleFile(object):
             self.time.units = "seconds"
         else:
             self.time.units = "seconds since " + str(self.particleset.time_origin)
-            self.time.calendar = 'standard' if self.particleset.time_origin.calendar == 'np_datetime64' else self.particleset.time_origin.calendar
+            self.time.calendar = _set_calendar(self.particleset.time_origin.calendar)
         self.time.axis = "T"
 
         if self.particleset.lonlatdepth_dtype is np.float64:
diff --git a/parcels/tools/converters.py b/parcels/tools/converters.py
index e314507f..0f7fee1e 100644
--- a/parcels/tools/converters.py
+++ b/parcels/tools/converters.py
@@ -1,6 +1,6 @@
 from math import cos, pi
 import numpy as np
-import cftime._cftime as cftime_mods
+import cftime
 import inspect
 from datetime import timedelta as delta
 
@@ -9,6 +9,17 @@ __all__ = ['UnitConverter', 'Geographic', 'GeographicPolar', 'GeographicSquare',
            'TimeConverter']
 
 
+def _get_cftime_datetimes():
+    # Is there a more elegant way to parse these from cftime?
+    cftime_calendars = tuple(x[1].__name__ for x in inspect.getmembers(cftime._cftime, inspect.isclass))
+    cftime_datetime_names = [ca for ca in cftime_calendars if 'Datetime' in ca]
+    return cftime_datetime_names
+
+
+def _get_cftime_calendars():
+    return [getattr(cftime, cf_datetime)(1990, 1, 1).calendar for cf_datetime in _get_cftime_datetimes()]
+
+
 class TimeConverter(object):
     """ Converter class for dates with different calendars in FieldSets
 
@@ -18,11 +29,10 @@ class TimeConverter(object):
 
     def __init__(self, time_origin=0):
         self.time_origin = 0 if time_origin is None else time_origin
-        cftime_calendars = tuple(x[1].__name__ for x in inspect.getmembers(cftime_mods, inspect.isclass))
         if isinstance(time_origin, np.datetime64):
             self.calendar = "np_datetime64"
-        elif type(time_origin).__name__ in cftime_calendars:
-            self.calendar = "cftime"
+        elif isinstance(time_origin, cftime._cftime.datetime):
+            self.calendar = time_origin.calendar
         else:
             self.calendar = None
 
@@ -36,7 +46,7 @@ class TimeConverter(object):
         time = time.time_origin if isinstance(time, TimeConverter) else time
         if self.calendar == 'np_datetime64':
             return (time - self.time_origin) / np.timedelta64(1, 's')
-        elif self.calendar == 'cftime':
+        elif self.calendar in _get_cftime_calendars():
             if isinstance(time, (list, np.ndarray)):
                 return np.array([(t - self.time_origin).total_seconds() for t in time])
             else:
@@ -58,7 +68,7 @@ class TimeConverter(object):
                 return [self.time_origin + np.timedelta64(int(t), 's') for t in time]
             else:
                 return self.time_origin + np.timedelta64(int(time), 's')
-        elif self.calendar == 'cftime':
+        elif self.calendar in _get_cftime_calendars():
             return self.time_origin + delta(seconds=time)
         elif self.calendar is None:
             return self.time_origin + time


Test patch:
diff --git a/tests/test_converters.py b/tests/test_converters.py
new file mode 100644
index 00000000..498ee677
--- /dev/null
+++ b/tests/test_converters.py
@@ -0,0 +1,13 @@
+from parcels.tools.converters import TimeConverter, _get_cftime_datetimes
+import cftime
+import numpy as np
+
+
+def test_TimeConverter():
+    cf_datetime_names = _get_cftime_datetimes()
+    for cf_datetime in cf_datetime_names:
+        date = getattr(cftime, cf_datetime)(1990, 1, 1)
+        assert TimeConverter(date).calendar == date.calendar
+    assert TimeConverter(None).calendar is None
+    date_datetime64 = np.datetime64('2001-01-01T12:00')
+    assert TimeConverter(date_datetime64).calendar == "np_datetime64"
diff --git a/tests/test_fieldset.py b/tests/test_fieldset.py
index 3bf9f106..7d5026c8 100644
--- a/tests/test_fieldset.py
+++ b/tests/test_fieldset.py
@@ -1,6 +1,6 @@
 from parcels import FieldSet, ParticleSet, ScipyParticle, JITParticle, Variable, AdvectionRK4, AdvectionRK4_3D, RectilinearZGrid, ErrorCode
 from parcels.field import Field, VectorField
-from parcels.tools.converters import TimeConverter
+from parcels.tools.converters import TimeConverter, _get_cftime_calendars, _get_cftime_datetimes
 from datetime import timedelta as delta
 import datetime
 import numpy as np
@@ -8,6 +8,7 @@ import xarray as xr
 import math
 import pytest
 from os import path
+import cftime
 
 
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
@@ -98,15 +99,12 @@ def test_fieldset_from_parcels(xdim, ydim, tmpdir, filename='test_parcels'):
     assert np.allclose(fieldset.V.data[0, :], data['V'], rtol=1e-12)
 
 
-@pytest.mark.parametrize('calendar', ['noleap', '360day'])
-def test_fieldset_nonstandardtime(calendar, tmpdir, filename='test_nonstandardtime.nc', xdim=4, ydim=6):
-    from cftime import DatetimeNoLeap, Datetime360Day
+@pytest.mark.parametrize('calendar, cftime_datetime',
+                         zip(_get_cftime_calendars(),
+                             _get_cftime_datetimes()))
+def test_fieldset_nonstandardtime(calendar, cftime_datetime, tmpdir, filename='test_nonstandardtime.nc', xdim=4, ydim=6):
     filepath = tmpdir.join(filename)
-
-    if calendar == 'noleap':
-        dates = [DatetimeNoLeap(0, m, 1) for m in range(1, 13)]
-    else:
-        dates = [Datetime360Day(0, m, 1) for m in range(1, 13)]
+    dates = [getattr(cftime, cftime_datetime)(1, m, 1) for m in range(1, 13)]
     da = xr.DataArray(np.random.rand(12, xdim, ydim),
                       coords=[dates, range(xdim), range(ydim)],
                       dims=['time', 'lon', 'lat'], name='U')
@@ -114,7 +112,7 @@ def test_fieldset_nonstandardtime(calendar, tmpdir, filename='test_nonstandardti
 
     dims = {'lon': 'lon', 'lat': 'lat', 'time': 'time'}
     field = Field.from_netcdf(filepath, 'U', dims)
-    assert field.grid.time_origin.calendar == 'cftime'
+    assert field.grid.time_origin.calendar == calendar
 
 
 def test_field_from_netcdf():
diff --git a/tests/test_particle_file.py b/tests/test_particle_file.py
new file mode 100644
index 00000000..2980fdd3
--- /dev/null
+++ b/tests/test_particle_file.py
@@ -0,0 +1,10 @@
+from parcels.particlefile import _set_calendar
+from parcels.tools.converters import _get_cftime_calendars, _get_cftime_datetimes
+import cftime
+
+
+def test_set_calendar():
+    for calendar_name, cf_datetime in zip(_get_cftime_calendars(), _get_cftime_datetimes()):
+        date = getattr(cftime, cf_datetime)(1990, 1, 1)
+        assert _set_calendar(date.calendar) == date.calendar
+    assert _set_calendar('np_datetime64') == 'standard'