Skip to content

Commit 3c7ee1b

Browse files
djkirkhammarqh
authored andcommitted
Ensure dates are rounded to nearest second (#66)
* Ensure dates are rounded to nearest second
1 parent bd72437 commit 3c7ee1b

4 files changed

Lines changed: 383 additions & 2 deletions

File tree

cf_units/__init__.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -686,9 +686,35 @@ def date2num(date, unit, calendar):
686686
if unit_string.endswith(" since epoch"):
687687
unit_string = unit_string.replace("epoch", EPOCH)
688688
cdftime = netcdftime.utime(unit_string, calendar=calendar)
689+
date = _discard_microsecond(date)
689690
return cdftime.date2num(date)
690691

691692

693+
def _discard_microsecond(date):
694+
"""
695+
Return a date with the microsecond componenet discarded.
696+
697+
Works for scalars, sequences and numpy arrays. Returns a scalar
698+
if input is a scalar, else returns a numpy array.
699+
700+
Args:
701+
702+
* date (datetime.datetime or netcdftime.datetime):
703+
Date value/s
704+
705+
Returns:
706+
datetime, or list of datetime object.
707+
708+
"""
709+
is_scalar = False
710+
if not hasattr(date, '__iter__'):
711+
date = [date]
712+
is_scalar = True
713+
dates = [d.__class__(d.year, d.month, d.day, d.hour, d.minute, d.second)
714+
for d in date]
715+
return dates[0] if is_scalar else dates
716+
717+
692718
def num2date(time_value, unit, calendar):
693719
"""
694720
Return datetime encoding of numeric time value (resolution of 1 second).
@@ -714,6 +740,9 @@ def num2date(time_value, unit, calendar):
714740
do not contain a time-zone offset, even if the specified unit
715741
contains one.
716742
743+
Works for scalars, sequences and numpy arrays. Returns a scalar
744+
if input is a scalar, else returns a numpy array.
745+
717746
Args:
718747
719748
* time_value (float):
@@ -753,7 +782,57 @@ def num2date(time_value, unit, calendar):
753782
if unit_string.endswith(" since epoch"):
754783
unit_string = unit_string.replace("epoch", EPOCH)
755784
cdftime = netcdftime.utime(unit_string, calendar=calendar)
756-
return cdftime.num2date(time_value)
785+
return _num2date_to_nearest_second(time_value, cdftime)
786+
787+
788+
def _num2date_to_nearest_second(time_value, utime):
789+
"""
790+
Return datetime encoding of numeric time value with respect to the given
791+
time reference units, with a resolution of 1 second.
792+
793+
* time_value (float):
794+
Numeric time value/s.
795+
* utime (netcdftime.utime):
796+
netcdf.utime object with which to perform the conversion/s.
797+
798+
Returns:
799+
datetime, or numpy.ndarray of datetime object.
800+
"""
801+
is_scalar = False
802+
if not hasattr(time_value, '__iter__'):
803+
time_value = [time_value]
804+
is_scalar = True
805+
time_values = np.array(list(time_value))
806+
807+
# We account for the edge case where the time is in seconds and has a
808+
# half second: utime.num2date() may produce a date that would round
809+
# down.
810+
#
811+
# Note that this behaviour is different to the num2date function in older
812+
# versions of netcdftime that didn't have microsecond precision. In those
813+
# versions, a half-second value would be rounded up or down arbitrarily. It
814+
# is probably not possible to replicate that behaviour with the current
815+
# version (1.4.1), if one wished to do so for the sake of consistency.
816+
has_half_seconds = np.logical_and(utime.units == 'seconds',
817+
time_values % 1. == 0.5)
818+
dates = utime.num2date(time_values)
819+
try:
820+
# We can assume all or none of the dates have a microsecond attribute
821+
microseconds = np.array([d.microsecond for d in dates])
822+
except AttributeError:
823+
microseconds = 0
824+
round_mask = np.logical_or(has_half_seconds, microseconds != 0)
825+
ceil_mask = np.logical_or(has_half_seconds, microseconds >= 500000)
826+
if time_values[ceil_mask].size > 0:
827+
useconds = Unit('second')
828+
second_frac = useconds.convert(0.75, utime.units)
829+
dates[ceil_mask] = utime.num2date(time_values[ceil_mask] + second_frac)
830+
# Create date objects of the same type returned by utime.num2date()
831+
# (either datetime.datetime or netcdftime.datetime), discarding the
832+
# microseconds
833+
dates[round_mask] = _discard_microsecond(dates[round_mask])
834+
835+
return dates[0] if is_scalar else dates
757836

758837

759838
########################################################################
@@ -2036,6 +2115,7 @@ def date2num(self, date):
20362115
"""
20372116

20382117
cdf_utime = self.utime()
2118+
date = _discard_microsecond(date)
20392119
return cdf_utime.date2num(date)
20402120

20412121
def num2date(self, time_value):
@@ -2078,4 +2158,4 @@ def num2date(self, time_value):
20782158
20792159
"""
20802160
cdf_utime = self.utime()
2081-
return cdf_utime.num2date(time_value)
2161+
return _num2date_to_nearest_second(time_value, cdf_utime)

cf_units/tests/integration/__init__.py

Whitespace-only changes.
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# (C) British Crown Copyright 2016, Met Office
2+
#
3+
# This file is part of cf_units.
4+
#
5+
# cf_units is free software: you can redistribute it and/or modify it under
6+
# the terms of the GNU Lesser General Public License as published by the
7+
# Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# cf_units is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU Lesser General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Lesser General Public License
16+
# along with cf_units. If not, see <http://www.gnu.org/licenses/>.
17+
"""Test function :func:`cf_units._num2date_to_nearest_second`."""
18+
19+
from __future__ import (absolute_import, division, print_function)
20+
from six.moves import (filter, input, map, range, zip) # noqa
21+
22+
import unittest
23+
import datetime
24+
25+
import numpy as np
26+
import numpy.testing
27+
import netcdftime
28+
29+
from cf_units import _num2date_to_nearest_second, Unit
30+
31+
32+
class Test(unittest.TestCase):
33+
def setup_units(self, calendar):
34+
self.useconds = netcdftime.utime('seconds since 1970-01-01', calendar)
35+
self.uminutes = netcdftime.utime('minutes since 1970-01-01', calendar)
36+
self.uhours = netcdftime.utime('hours since 1970-01-01', calendar)
37+
self.udays = netcdftime.utime('days since 1970-01-01', calendar)
38+
39+
def check_dates(self, nums, utimes, expected):
40+
for num, utime, exp in zip(nums, utimes, expected):
41+
res = _num2date_to_nearest_second(num, utime)
42+
self.assertEqual(exp, res)
43+
44+
def test_scalar(self):
45+
utime = netcdftime.utime('seconds since 1970-01-01', 'gregorian')
46+
num = 5.
47+
exp = datetime.datetime(1970, 1, 1, 0, 0, 5)
48+
res = _num2date_to_nearest_second(num, utime)
49+
self.assertEqual(exp, res)
50+
51+
def test_sequence(self):
52+
utime = netcdftime.utime('seconds since 1970-01-01', 'gregorian')
53+
nums = [20., 40., 60., 80, 100.]
54+
exp = [datetime.datetime(1970, 1, 1, 0, 0, 20),
55+
datetime.datetime(1970, 1, 1, 0, 0, 40),
56+
datetime.datetime(1970, 1, 1, 0, 1),
57+
datetime.datetime(1970, 1, 1, 0, 1, 20),
58+
datetime.datetime(1970, 1, 1, 0, 1, 40)]
59+
res = _num2date_to_nearest_second(nums, utime)
60+
np.testing.assert_array_equal(exp, res)
61+
62+
def test_iter(self):
63+
utime = netcdftime.utime('seconds since 1970-01-01', 'gregorian')
64+
nums = iter([5., 10.])
65+
exp = [datetime.datetime(1970, 1, 1, 0, 0, 5),
66+
datetime.datetime(1970, 1, 1, 0, 0, 10)]
67+
res = _num2date_to_nearest_second(nums, utime)
68+
np.testing.assert_array_equal(exp, res)
69+
70+
# Gregorian Calendar tests
71+
72+
def test_simple_gregorian(self):
73+
self.setup_units('gregorian')
74+
nums = [20., 40.,
75+
75., 150.,
76+
8., 16.,
77+
300., 600.]
78+
utimes = [self.useconds, self.useconds,
79+
self.uminutes, self.uminutes,
80+
self.uhours, self.uhours,
81+
self.udays, self.udays]
82+
expected = [datetime.datetime(1970, 1, 1, 0, 0, 20),
83+
datetime.datetime(1970, 1, 1, 0, 0, 40),
84+
datetime.datetime(1970, 1, 1, 1, 15),
85+
datetime.datetime(1970, 1, 1, 2, 30),
86+
datetime.datetime(1970, 1, 1, 8),
87+
datetime.datetime(1970, 1, 1, 16),
88+
datetime.datetime(1970, 10, 28),
89+
datetime.datetime(1971, 8, 24)]
90+
91+
self.check_dates(nums, utimes, expected)
92+
93+
def test_fractional_gregorian(self):
94+
self.setup_units('gregorian')
95+
nums = [5./60., 10./60.,
96+
15./60., 30./60.,
97+
8./24., 16./24.]
98+
utimes = [self.uminutes, self.uminutes,
99+
self.uhours, self.uhours,
100+
self.udays, self.udays]
101+
expected = [datetime.datetime(1970, 1, 1, 0, 0, 5),
102+
datetime.datetime(1970, 1, 1, 0, 0, 10),
103+
datetime.datetime(1970, 1, 1, 0, 15),
104+
datetime.datetime(1970, 1, 1, 0, 30),
105+
datetime.datetime(1970, 1, 1, 8),
106+
datetime.datetime(1970, 1, 1, 16)]
107+
108+
self.check_dates(nums, utimes, expected)
109+
110+
def test_fractional_second_gregorian(self):
111+
self.setup_units('gregorian')
112+
nums = [0.25, 0.5, 0.75,
113+
1.5, 2.5, 3.5, 4.5]
114+
utimes = [self.useconds] * 7
115+
expected = [datetime.datetime(1970, 1, 1, 0, 0, 0),
116+
datetime.datetime(1970, 1, 1, 0, 0, 1),
117+
datetime.datetime(1970, 1, 1, 0, 0, 1),
118+
datetime.datetime(1970, 1, 1, 0, 0, 2),
119+
datetime.datetime(1970, 1, 1, 0, 0, 3),
120+
datetime.datetime(1970, 1, 1, 0, 0, 4),
121+
datetime.datetime(1970, 1, 1, 0, 0, 5)]
122+
123+
self.check_dates(nums, utimes, expected)
124+
125+
# 360 day Calendar tests
126+
127+
def test_simple_360_day(self):
128+
self.setup_units('360_day')
129+
nums = [20., 40.,
130+
75., 150.,
131+
8., 16.,
132+
300., 600.]
133+
utimes = [self.useconds, self.useconds,
134+
self.uminutes, self.uminutes,
135+
self.uhours, self.uhours,
136+
self.udays, self.udays]
137+
expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 20),
138+
netcdftime.datetime(1970, 1, 1, 0, 0, 40),
139+
netcdftime.datetime(1970, 1, 1, 1, 15),
140+
netcdftime.datetime(1970, 1, 1, 2, 30),
141+
netcdftime.datetime(1970, 1, 1, 8),
142+
netcdftime.datetime(1970, 1, 1, 16),
143+
netcdftime.datetime(1970, 11, 1),
144+
netcdftime.datetime(1971, 9, 1)]
145+
146+
self.check_dates(nums, utimes, expected)
147+
148+
def test_fractional_360_day(self):
149+
self.setup_units('360_day')
150+
nums = [5./60., 10./60.,
151+
15./60., 30./60.,
152+
8./24., 16./24.]
153+
utimes = [self.uminutes, self.uminutes,
154+
self.uhours, self.uhours,
155+
self.udays, self.udays]
156+
expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 5),
157+
netcdftime.datetime(1970, 1, 1, 0, 0, 10),
158+
netcdftime.datetime(1970, 1, 1, 0, 15),
159+
netcdftime.datetime(1970, 1, 1, 0, 30),
160+
netcdftime.datetime(1970, 1, 1, 8),
161+
netcdftime.datetime(1970, 1, 1, 16)]
162+
163+
self.check_dates(nums, utimes, expected)
164+
165+
def test_fractional_second_360_day(self):
166+
self.setup_units('360_day')
167+
nums = [0.25, 0.5, 0.75,
168+
1.5, 2.5, 3.5, 4.5]
169+
utimes = [self.useconds] * 7
170+
expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 0),
171+
netcdftime.datetime(1970, 1, 1, 0, 0, 1),
172+
netcdftime.datetime(1970, 1, 1, 0, 0, 1),
173+
netcdftime.datetime(1970, 1, 1, 0, 0, 2),
174+
netcdftime.datetime(1970, 1, 1, 0, 0, 3),
175+
netcdftime.datetime(1970, 1, 1, 0, 0, 4),
176+
netcdftime.datetime(1970, 1, 1, 0, 0, 5)]
177+
178+
self.check_dates(nums, utimes, expected)
179+
180+
# 365 day Calendar tests
181+
182+
def test_simple_365_day(self):
183+
self.setup_units('365_day')
184+
nums = [20., 40.,
185+
75., 150.,
186+
8., 16.,
187+
300., 600.]
188+
utimes = [self.useconds, self.useconds,
189+
self.uminutes, self.uminutes,
190+
self.uhours, self.uhours,
191+
self.udays, self.udays]
192+
expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 20),
193+
netcdftime.datetime(1970, 1, 1, 0, 0, 40),
194+
netcdftime.datetime(1970, 1, 1, 1, 15),
195+
netcdftime.datetime(1970, 1, 1, 2, 30),
196+
netcdftime.datetime(1970, 1, 1, 8),
197+
netcdftime.datetime(1970, 1, 1, 16),
198+
netcdftime.datetime(1970, 10, 28),
199+
netcdftime.datetime(1971, 8, 24)]
200+
201+
self.check_dates(nums, utimes, expected)
202+
203+
def test_fractional_365_day(self):
204+
self.setup_units('365_day')
205+
nums = [5./60., 10./60.,
206+
15./60., 30./60.,
207+
8./24., 16./24.]
208+
utimes = [self.uminutes, self.uminutes,
209+
self.uhours, self.uhours,
210+
self.udays, self.udays]
211+
212+
expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 5),
213+
netcdftime.datetime(1970, 1, 1, 0, 0, 10),
214+
netcdftime.datetime(1970, 1, 1, 0, 15),
215+
netcdftime.datetime(1970, 1, 1, 0, 30),
216+
netcdftime.datetime(1970, 1, 1, 8),
217+
netcdftime.datetime(1970, 1, 1, 16)]
218+
219+
self.check_dates(nums, utimes, expected)
220+
221+
def test_fractional_second_365_day(self):
222+
self.setup_units('365_day')
223+
nums = [0.25, 0.5, 0.75,
224+
1.5, 2.5, 3.5, 4.5]
225+
utimes = [self.useconds] * 7
226+
expected = [netcdftime.datetime(1970, 1, 1, 0, 0, 0),
227+
netcdftime.datetime(1970, 1, 1, 0, 0, 1),
228+
netcdftime.datetime(1970, 1, 1, 0, 0, 1),
229+
netcdftime.datetime(1970, 1, 1, 0, 0, 2),
230+
netcdftime.datetime(1970, 1, 1, 0, 0, 3),
231+
netcdftime.datetime(1970, 1, 1, 0, 0, 4),
232+
netcdftime.datetime(1970, 1, 1, 0, 0, 5)]
233+
234+
self.check_dates(nums, utimes, expected)
235+
236+
if __name__ == '__main__':
237+
unittest.main()

0 commit comments

Comments
 (0)