Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure dates are rounded to nearest second #66

Merged
merged 5 commits into from
Sep 15, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions cf_units/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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):
Expand Down Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of interest, why has this been provided as a multi-line comment rather than a docstring?

Copy link
Contributor Author

@djkirkham djkirkham Sep 12, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a comment for developers, so I don't think it should go in a docstring. I can provide a docstring based on L760-761 if you like, although this is a private function.

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


########################################################################
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Empty file.
237 changes: 237 additions & 0 deletions cf_units/tests/integration/test__num2date_to_nearest_second.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
"""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()
Loading