diff --git a/lib/iris/coords.py b/lib/iris/coords.py index 01017bb2a2..8da58ef2b0 100644 --- a/lib/iris/coords.py +++ b/lib/iris/coords.py @@ -558,17 +558,13 @@ def _repr_other_metadata(self): return result def _str_dates(self, dates_as_numbers): - date_obj_array = self.units.num2date(dates_as_numbers) + date_obj_array = np.array( + [iris.util._num2date_to_nearest_second(num, self.units) + for num in dates_as_numbers]) kwargs = {'separator': ', ', 'prefix': ' '} - try: - # With NumPy 1.7 we need to ask for 'str' formatting. - result = np.core.arrayprint.array2string( - date_obj_array, formatter={'numpystr': str}, **kwargs) - except TypeError: - # But in 1.6 we don't need to ask, and the option doesn't - # even exist! - result = np.core.arrayprint.array2string(date_obj_array, **kwargs) - return result + return np.core.arrayprint.array2string(date_obj_array, + formatter={'numpystr': str}, + **kwargs) def __str__(self): if self.units.is_time_reference(): diff --git a/lib/iris/fileformats/grib/_save_rules.py b/lib/iris/fileformats/grib/_save_rules.py index b9a224be2b..bbf52066f4 100644 --- a/lib/iris/fileformats/grib/_save_rules.py +++ b/lib/iris/fileformats/grib/_save_rules.py @@ -40,7 +40,7 @@ from iris.fileformats.grib import grib_phenom_translation as gptx from iris.fileformats.grib._load_convert import (_STATISTIC_TYPE_NAMES, _TIME_RANGE_UNITS) -from iris.util import is_regular, regular_step +from iris.util import is_regular, regular_step, _num2date_to_nearest_second # Invert code tables from :mod:`iris.fileformats.grib._load_convert`. @@ -604,7 +604,7 @@ def _missing_forecast_period(cube): t = t_coord.bounds[0, 0] if t_coord.has_bounds() else t_coord.points[0] frt = frt_coord.points[0] # Calculate GRIB parameters. - rt = frt_coord.units.num2date(frt) + rt = _num2date_to_nearest_second(frt, frt_coord.units) rt_meaning = 1 # Forecast reference time. fp = t - frt integer_fp = int(fp) @@ -619,7 +619,7 @@ def _missing_forecast_period(cube): # reference time significance of "Observation time" and set the # forecast period to 0h. t = t_coord.bounds[0, 0] if t_coord.has_bounds() else t_coord.points[0] - rt = t_coord.units.num2date(t) + rt = _num2date_to_nearest_second(t, t_coord.units) rt_meaning = 3 # Observation time fp = 0 fp_meaning = 1 # Hours diff --git a/lib/iris/pandas.py b/lib/iris/pandas.py index 67a651a42a..944b8566d6 100644 --- a/lib/iris/pandas.py +++ b/lib/iris/pandas.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2013 - 2015, Met Office +# (C) British Crown Copyright 2013 - 2016, Met Office # # This file is part of Iris. # @@ -35,6 +35,7 @@ import iris from iris.coords import AuxCoord, DimCoord from iris.cube import Cube +from iris.util import _num2date_to_nearest_second def _add_iris_coord(cube, name, points, dim, calendar=None): @@ -119,7 +120,8 @@ def _as_pandas_coord(coord): """Convert an Iris Coord into a Pandas index or columns array.""" index = coord.points if coord.units.is_time_reference(): - index = coord.units.num2date(index) + index = np.array([_num2date_to_nearest_second(point, coord.units) + for point in index]) return index diff --git a/lib/iris/plot.py b/lib/iris/plot.py index 9aaba2eb7e..0b554898ad 100644 --- a/lib/iris/plot.py +++ b/lib/iris/plot.py @@ -47,6 +47,7 @@ from iris.exceptions import IrisError # Importing iris.palette to register the brewer palettes. import iris.palette +from iris.util import _num2date_to_nearest_second # Cynthia Brewer citation text. @@ -407,7 +408,7 @@ def _fixup_dates(coord, values): if coord.units.calendar is not None and values.ndim == 1: # Convert coordinate values into tuples of # (year, month, day, hour, min, sec) - dates = [coord.units.num2date(val).timetuple()[0:6] + dates = [_num2date_to_nearest_second(val, coord.units).timetuple()[0:6] for val in values] if coord.units.calendar == 'gregorian': r = [datetime.datetime(*date) for date in dates] diff --git a/lib/iris/tests/unit/util/test__num2date_to_nearest_second.py b/lib/iris/tests/unit/util/test__num2date_to_nearest_second.py new file mode 100644 index 0000000000..b7ab08fe0c --- /dev/null +++ b/lib/iris/tests/unit/util/test__num2date_to_nearest_second.py @@ -0,0 +1,214 @@ +# (C) British Crown Copyright 2016, Met Office +# +# This file is part of Iris. +# +# Iris 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. +# +# Iris 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 Iris. If not, see . +"""Test function :func:`iris.util._num2date_to_nearest_second`.""" + +from __future__ import (absolute_import, division, print_function) +from six.moves import (filter, input, map, range, zip) # noqa + +# import iris tests first so that some things can be initialised before +# importing anything else +import iris.tests as tests + +import datetime + +from cf_units import Unit +import numpy as np +import netcdftime + +from iris.util import _num2date_to_nearest_second + + +class Test(tests.IrisTest): + def setup_units(self, calendar): + self.useconds = Unit('seconds since epoch', calendar) + self.uminutes = Unit('minutes since epoch', calendar) + self.uhours = Unit('hours since epoch', calendar) + self.udays = Unit('days since epoch', calendar) + + def check_dates(self, nums, units, expected): + for num, unit, exp in zip(nums, units, expected): + res = _num2date_to_nearest_second(num, unit) + self.assertEqual(exp, res) + + # Gregorian Calendar tests + + def test_simple_gregorian(self): + self.setup_units('gregorian') + nums = [20., 40., + 75., 150., + 8., 16., + 300., 600.] + units = [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, units, expected) + + def test_fractional_gregorian(self): + self.setup_units('gregorian') + nums = [5./60., 10./60., + 15./60., 30./60., + 8./24., 16./24.] + units = [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, units, 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] + units = [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, units, 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.] + units = [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, units, 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.] + units = [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, units, 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] + units = [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, units, 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.] + units = [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, units, 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.] + units = [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, units, 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] + units = [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, units, expected) + +if __name__ == '__main__': + tests.main() diff --git a/lib/iris/util.py b/lib/iris/util.py index 7809dade10..153b050a68 100644 --- a/lib/iris/util.py +++ b/lib/iris/util.py @@ -1589,3 +1589,35 @@ def demote_dim_coord_to_aux_coord(cube, name_or_coord): cube.remove_coord(dim_coord) cube.add_aux_coord(dim_coord, coord_dim) + + +def _num2date_to_nearest_second(time_value, units): + # Return datetime encoding of numeric time value with respect to the given + # time reference units, with a resolution of 1 second. + + # We account for the edge case where the time is in seconds and has a + # half second: units.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_second = units.utime().units == 'seconds' and \ + time_value % 1. == 0.5 + date = units.num2date(time_value) + try: + microsecond = date.microsecond + except AttributeError: + microsecond = 0 + if has_half_second or microsecond > 0: + if has_half_second or microsecond >= 500000: + seconds = cf_units.Unit('second') + second_frac = seconds.convert(0.75, units.utime().units) + time_value += second_frac + date = units.num2date(time_value) + date = date.__class__(date.year, date.month, date.day, + date.hour, date.minute, date.second) + + return date