diff --git a/cf_units/__init__.py b/cf_units/__init__.py index 1f61a09a..17542838 100644 --- a/cf_units/__init__.py +++ b/cf_units/__init__.py @@ -686,9 +686,35 @@ def date2num(date, unit, calendar): if unit_string.endswith(" since epoch"): unit_string = unit_string.replace("epoch", EPOCH) cdftime = netcdftime.utime(unit_string, calendar=calendar) + date = _discard_microsecond(date) return cdftime.date2num(date) +def _discard_microsecond(date): + """ + Return a date with the microsecond componenet discarded. + + Works for scalars, sequences and numpy arrays. Returns a scalar + if input is a scalar, else returns a numpy array. + + Args: + + * date (datetime.datetime or netcdftime.datetime): + Date value/s + + Returns: + datetime, or list of datetime object. + + """ + is_scalar = False + if not hasattr(date, '__iter__'): + date = [date] + is_scalar = True + dates = [d.__class__(d.year, d.month, d.day, d.hour, d.minute, d.second) + for d in date] + return dates[0] if is_scalar else dates + + def num2date(time_value, unit, calendar): """ Return datetime encoding of numeric time value (resolution of 1 second). @@ -714,6 +740,9 @@ def num2date(time_value, unit, calendar): do not contain a time-zone offset, even if the specified unit contains one. + Works for scalars, sequences and numpy arrays. Returns a scalar + if input is a scalar, else returns a numpy array. + Args: * time_value (float): @@ -753,7 +782,57 @@ def num2date(time_value, unit, calendar): if unit_string.endswith(" since epoch"): unit_string = unit_string.replace("epoch", EPOCH) cdftime = netcdftime.utime(unit_string, calendar=calendar) - return cdftime.num2date(time_value) + return _num2date_to_nearest_second(time_value, cdftime) + + +def _num2date_to_nearest_second(time_value, utime): + """ + Return datetime encoding of numeric time value with respect to the given + time reference units, with a resolution of 1 second. + + * time_value (float): + Numeric time value/s. + * utime (netcdftime.utime): + netcdf.utime object with which to perform the conversion/s. + + Returns: + datetime, or numpy.ndarray of datetime object. + """ + is_scalar = False + if not hasattr(time_value, '__iter__'): + time_value = [time_value] + is_scalar = True + time_values = np.array(list(time_value)) + + # We account for the edge case where the time is in seconds and has a + # half second: utime.num2date() may produce a date that would round + # down. + # + # Note that this behaviour is different to the num2date function in older + # versions of netcdftime that didn't have microsecond precision. In those + # versions, a half-second value would be rounded up or down arbitrarily. It + # is probably not possible to replicate that behaviour with the current + # version (1.4.1), if one wished to do so for the sake of consistency. + has_half_seconds = np.logical_and(utime.units == 'seconds', + time_values % 1. == 0.5) + dates = utime.num2date(time_values) + try: + # We can assume all or none of the dates have a microsecond attribute + microseconds = np.array([d.microsecond for d in dates]) + except AttributeError: + microseconds = 0 + round_mask = np.logical_or(has_half_seconds, microseconds != 0) + ceil_mask = np.logical_or(has_half_seconds, microseconds >= 500000) + if time_values[ceil_mask].size > 0: + useconds = Unit('second') + second_frac = useconds.convert(0.75, utime.units) + dates[ceil_mask] = utime.num2date(time_values[ceil_mask] + second_frac) + # Create date objects of the same type returned by utime.num2date() + # (either datetime.datetime or netcdftime.datetime), discarding the + # microseconds + dates[round_mask] = _discard_microsecond(dates[round_mask]) + + return dates[0] if is_scalar else dates ######################################################################## @@ -2036,6 +2115,7 @@ def date2num(self, date): """ cdf_utime = self.utime() + date = _discard_microsecond(date) return cdf_utime.date2num(date) def num2date(self, time_value): @@ -2078,4 +2158,4 @@ def num2date(self, time_value): """ cdf_utime = self.utime() - return cdf_utime.num2date(time_value) + return _num2date_to_nearest_second(time_value, cdf_utime) diff --git a/cf_units/tests/integration/__init__.py b/cf_units/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cf_units/tests/integration/test__num2date_to_nearest_second.py b/cf_units/tests/integration/test__num2date_to_nearest_second.py new file mode 100644 index 00000000..6c161d7f --- /dev/null +++ b/cf_units/tests/integration/test__num2date_to_nearest_second.py @@ -0,0 +1,237 @@ +# (C) British Crown Copyright 2016, Met Office +# +# This file is part of cf_units. +# +# cf_units is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# cf_units is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with cf_units. If not, see . +"""Test function :func:`cf_units._num2date_to_nearest_second`.""" + +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa + +import unittest +import datetime + +import numpy as np +import numpy.testing +import netcdftime + +from cf_units import _num2date_to_nearest_second, Unit + + +class Test(unittest.TestCase): + def setup_units(self, calendar): + self.useconds = netcdftime.utime('seconds since 1970-01-01', calendar) + self.uminutes = netcdftime.utime('minutes since 1970-01-01', calendar) + self.uhours = netcdftime.utime('hours since 1970-01-01', calendar) + self.udays = netcdftime.utime('days since 1970-01-01', calendar) + + def check_dates(self, nums, utimes, expected): + for num, utime, exp in zip(nums, utimes, expected): + res = _num2date_to_nearest_second(num, utime) + self.assertEqual(exp, res) + + def test_scalar(self): + utime = netcdftime.utime('seconds since 1970-01-01', 'gregorian') + num = 5. + exp = datetime.datetime(1970, 1, 1, 0, 0, 5) + res = _num2date_to_nearest_second(num, utime) + self.assertEqual(exp, res) + + def test_sequence(self): + utime = netcdftime.utime('seconds since 1970-01-01', 'gregorian') + nums = [20., 40., 60., 80, 100.] + exp = [datetime.datetime(1970, 1, 1, 0, 0, 20), + datetime.datetime(1970, 1, 1, 0, 0, 40), + datetime.datetime(1970, 1, 1, 0, 1), + datetime.datetime(1970, 1, 1, 0, 1, 20), + datetime.datetime(1970, 1, 1, 0, 1, 40)] + res = _num2date_to_nearest_second(nums, utime) + np.testing.assert_array_equal(exp, res) + + def test_iter(self): + utime = netcdftime.utime('seconds since 1970-01-01', 'gregorian') + nums = iter([5., 10.]) + exp = [datetime.datetime(1970, 1, 1, 0, 0, 5), + datetime.datetime(1970, 1, 1, 0, 0, 10)] + res = _num2date_to_nearest_second(nums, utime) + np.testing.assert_array_equal(exp, res) + + # Gregorian Calendar tests + + def test_simple_gregorian(self): + self.setup_units('gregorian') + nums = [20., 40., + 75., 150., + 8., 16., + 300., 600.] + utimes = [self.useconds, self.useconds, + self.uminutes, self.uminutes, + self.uhours, self.uhours, + self.udays, self.udays] + expected = [datetime.datetime(1970, 1, 1, 0, 0, 20), + datetime.datetime(1970, 1, 1, 0, 0, 40), + datetime.datetime(1970, 1, 1, 1, 15), + datetime.datetime(1970, 1, 1, 2, 30), + datetime.datetime(1970, 1, 1, 8), + datetime.datetime(1970, 1, 1, 16), + datetime.datetime(1970, 10, 28), + datetime.datetime(1971, 8, 24)] + + self.check_dates(nums, utimes, expected) + + def test_fractional_gregorian(self): + self.setup_units('gregorian') + nums = [5./60., 10./60., + 15./60., 30./60., + 8./24., 16./24.] + utimes = [self.uminutes, self.uminutes, + self.uhours, self.uhours, + self.udays, self.udays] + expected = [datetime.datetime(1970, 1, 1, 0, 0, 5), + datetime.datetime(1970, 1, 1, 0, 0, 10), + datetime.datetime(1970, 1, 1, 0, 15), + datetime.datetime(1970, 1, 1, 0, 30), + datetime.datetime(1970, 1, 1, 8), + datetime.datetime(1970, 1, 1, 16)] + + self.check_dates(nums, utimes, expected) + + def test_fractional_second_gregorian(self): + self.setup_units('gregorian') + nums = [0.25, 0.5, 0.75, + 1.5, 2.5, 3.5, 4.5] + utimes = [self.useconds] * 7 + expected = [datetime.datetime(1970, 1, 1, 0, 0, 0), + datetime.datetime(1970, 1, 1, 0, 0, 1), + datetime.datetime(1970, 1, 1, 0, 0, 1), + datetime.datetime(1970, 1, 1, 0, 0, 2), + datetime.datetime(1970, 1, 1, 0, 0, 3), + datetime.datetime(1970, 1, 1, 0, 0, 4), + datetime.datetime(1970, 1, 1, 0, 0, 5)] + + self.check_dates(nums, utimes, expected) + + # 360 day Calendar tests + + def test_simple_360_day(self): + self.setup_units('360_day') + nums = [20., 40., + 75., 150., + 8., 16., + 300., 600.] + utimes = [self.useconds, self.useconds, + self.uminutes, self.uminutes, + self.uhours, self.uhours, + self.udays, self.udays] + expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 20), + netcdftime.datetime(1970, 1, 1, 0, 0, 40), + netcdftime.datetime(1970, 1, 1, 1, 15), + netcdftime.datetime(1970, 1, 1, 2, 30), + netcdftime.datetime(1970, 1, 1, 8), + netcdftime.datetime(1970, 1, 1, 16), + netcdftime.datetime(1970, 11, 1), + netcdftime.datetime(1971, 9, 1)] + + self.check_dates(nums, utimes, expected) + + def test_fractional_360_day(self): + self.setup_units('360_day') + nums = [5./60., 10./60., + 15./60., 30./60., + 8./24., 16./24.] + utimes = [self.uminutes, self.uminutes, + self.uhours, self.uhours, + self.udays, self.udays] + expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 5), + netcdftime.datetime(1970, 1, 1, 0, 0, 10), + netcdftime.datetime(1970, 1, 1, 0, 15), + netcdftime.datetime(1970, 1, 1, 0, 30), + netcdftime.datetime(1970, 1, 1, 8), + netcdftime.datetime(1970, 1, 1, 16)] + + self.check_dates(nums, utimes, expected) + + def test_fractional_second_360_day(self): + self.setup_units('360_day') + nums = [0.25, 0.5, 0.75, + 1.5, 2.5, 3.5, 4.5] + utimes = [self.useconds] * 7 + expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 0), + netcdftime.datetime(1970, 1, 1, 0, 0, 1), + netcdftime.datetime(1970, 1, 1, 0, 0, 1), + netcdftime.datetime(1970, 1, 1, 0, 0, 2), + netcdftime.datetime(1970, 1, 1, 0, 0, 3), + netcdftime.datetime(1970, 1, 1, 0, 0, 4), + netcdftime.datetime(1970, 1, 1, 0, 0, 5)] + + self.check_dates(nums, utimes, expected) + + # 365 day Calendar tests + + def test_simple_365_day(self): + self.setup_units('365_day') + nums = [20., 40., + 75., 150., + 8., 16., + 300., 600.] + utimes = [self.useconds, self.useconds, + self.uminutes, self.uminutes, + self.uhours, self.uhours, + self.udays, self.udays] + expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 20), + netcdftime.datetime(1970, 1, 1, 0, 0, 40), + netcdftime.datetime(1970, 1, 1, 1, 15), + netcdftime.datetime(1970, 1, 1, 2, 30), + netcdftime.datetime(1970, 1, 1, 8), + netcdftime.datetime(1970, 1, 1, 16), + netcdftime.datetime(1970, 10, 28), + netcdftime.datetime(1971, 8, 24)] + + self.check_dates(nums, utimes, expected) + + def test_fractional_365_day(self): + self.setup_units('365_day') + nums = [5./60., 10./60., + 15./60., 30./60., + 8./24., 16./24.] + utimes = [self.uminutes, self.uminutes, + self.uhours, self.uhours, + self.udays, self.udays] + + expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 5), + netcdftime.datetime(1970, 1, 1, 0, 0, 10), + netcdftime.datetime(1970, 1, 1, 0, 15), + netcdftime.datetime(1970, 1, 1, 0, 30), + netcdftime.datetime(1970, 1, 1, 8), + netcdftime.datetime(1970, 1, 1, 16)] + + self.check_dates(nums, utimes, expected) + + def test_fractional_second_365_day(self): + self.setup_units('365_day') + nums = [0.25, 0.5, 0.75, + 1.5, 2.5, 3.5, 4.5] + utimes = [self.useconds] * 7 + expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 0), + netcdftime.datetime(1970, 1, 1, 0, 0, 1), + netcdftime.datetime(1970, 1, 1, 0, 0, 1), + netcdftime.datetime(1970, 1, 1, 0, 0, 2), + netcdftime.datetime(1970, 1, 1, 0, 0, 3), + netcdftime.datetime(1970, 1, 1, 0, 0, 4), + netcdftime.datetime(1970, 1, 1, 0, 0, 5)] + + self.check_dates(nums, utimes, expected) + +if __name__ == '__main__': + unittest.main() diff --git a/cf_units/tests/integration/test_date2num.py b/cf_units/tests/integration/test_date2num.py new file mode 100644 index 00000000..1ce9de9b --- /dev/null +++ b/cf_units/tests/integration/test_date2num.py @@ -0,0 +1,64 @@ +# (C) British Crown Copyright 2016, Met Office +# +# This file is part of cf_units. +# +# cf_units is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# cf_units is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with cf_units. If not, see . +"""Test function :func:`cf_units.date2num`.""" + +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa + +import unittest +import datetime + +import numpy as np +import numpy.testing +import netcdftime + +from cf_units import date2num, Unit + + +class Test(unittest.TestCase): + def setUp(self): + self.unit = 'seconds since 1970-01-01' + self.calendar = 'gregorian' + + def test_single(self): + date = datetime.datetime(1970, 1, 1, 0, 0, 5) + exp = 5. + res = date2num(date, self.unit, self.calendar) + # num2date won't return an exact value representing the date, + # even if one exists + self.assertAlmostEqual(exp, res, places=4) + + def test_sequence(self): + dates = [datetime.datetime(1970, 1, 1, 0, 0, 20), + datetime.datetime(1970, 1, 1, 0, 0, 40), + datetime.datetime(1970, 1, 1, 0, 1), + datetime.datetime(1970, 1, 1, 0, 1, 20), + datetime.datetime(1970, 1, 1, 0, 1, 40)] + exp = [20., 40., 60., 80, 100.] + res = date2num(dates, self.unit, self.calendar) + np.testing.assert_array_almost_equal(exp, res, decimal=4) + + def test_discard_mircosecond(self): + date = datetime.datetime(1970, 1, 1, 0, 0, 5, 750000) + exp = 5. + res = date2num(date, self.unit, self.calendar) + + self.assertAlmostEqual(exp, res, places=4) + + +if __name__ == '__main__': + unittest.main()