Skip to content

Commit 4b42d98

Browse files
authored
Merge pull request #388 from Unidata/tai
add new cf calendar 'tai'
2 parents ba5d66f + b8f72f0 commit 4b42d98

5 files changed

Lines changed: 121 additions & 18 deletions

File tree

Changelog

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
version 1.6.6 (not yet released)
2+
================================
3+
* added new CF calendar "tai", which is the same as proleptic_gregorian but is only
4+
valid for dates after 1958-01-01.
5+
16
version 1.6.5 (release tag v1.6.5rel)
27
=====================================
38
* python 3.14 wheels, 3.8/3.9 support dropped.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cftime"
3-
version = "1.6.5"
3+
version = "1.6.6"
44
description = "Time-handling functionality from netcdf4-python"
55
readme = "README.md"
66
authors = [

src/cftime/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from ._cftime import CFWarning
88
# these will be removed in a future release
99
from ._cftime import (DatetimeNoLeap, DatetimeAllLeap, Datetime360Day,
10-
Datetime360Day, DatetimeJulian,
10+
Datetime360Day, DatetimeJulian, DatetimeTAI,
1111
DatetimeGregorian, DatetimeProlepticGregorian)
1212

1313

src/cftime/_cftime.pyx

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ _units = microsec_units+millisec_units+sec_units+min_units+hr_units+day_units
3030
# '366_day'=='all_leap','365_day'=='noleap')
3131
# see http://cfconventions.org/cf-conventions/cf-conventions#calendar
3232
# for definitions.
33-
_calendars = ['standard', 'gregorian', 'proleptic_gregorian',
33+
_calendars = ['standard', 'gregorian', 'proleptic_gregorian', 'tai',
3434
'noleap', 'julian', 'all_leap', '365_day', '366_day', '360_day']
3535
_idealized_calendars= ['all_leap','noleap','366_day','365_day','360_day']
3636
# Following are number of days per month
@@ -83,7 +83,7 @@ def _datesplit(timestr):
8383

8484
return units.lower(), remainder
8585

86-
def _dateparse(timestr,calendar,has_year_zero=None):
86+
def _dateparse(timestr, calendar, has_year_zero=None):
8787
"""parse a string of the form time-units since yyyy-mm-dd hh:mm:ss,
8888
return a datetime instance"""
8989
# same as version in cftime, but returns a timezone naive
@@ -109,6 +109,9 @@ def _dateparse(timestr,calendar,has_year_zero=None):
109109
# parse the date string.
110110
year, month, day, hour, minute, second, microsecond, utc_offset =\
111111
_parse_date( isostring.strip() )
112+
if calendar == 'tai':
113+
if year < 1958 or utc_offset:
114+
raise ValueError('TAI calendar must have a reference date of 1958-01-01T00:00:00 or later (with no utc offset)')
112115
if year == 0 and not has_year_zero and calendar in ['julian', 'standard', 'gregorian', 'proleptic_gregorian']:
113116
msg='zero not allowed as a reference year when has_year_zero=False'
114117
raise ValueError(msg)
@@ -157,7 +160,7 @@ def date2num(dates, units, calendar=None, has_year_zero=None, longdouble=False):
157160
**calendar**: describes the calendar to be used in the time calculations.
158161
All the values currently defined in the
159162
`CF metadata convention <http://cfconventions.org/cf-conventions/cf-conventions#calendar>`__ are supported.
160-
Valid calendars **'standard', 'gregorian', 'proleptic_gregorian'
163+
Valid calendars **'standard', 'gregorian', 'proleptic_gregorian', 'tai',
161164
'noleap', '365_day', '360_day', 'julian', 'all_leap', '366_day'**.
162165
Default is `None` which means the calendar associated with the first
163166
input datetime instance will be used.
@@ -373,6 +376,7 @@ UNIT_CONVERSION_FACTORS = {
373376

374377
DATE_TYPES = {
375378
"proleptic_gregorian": DatetimeProlepticGregorian,
379+
"tai": DatetimeTAI,
376380
"standard": DatetimeGregorian,
377381
"noleap": DatetimeNoLeap,
378382
"365_day": DatetimeNoLeap,
@@ -537,7 +541,7 @@ def num2date(
537541
**calendar**: describes the calendar used in the time calculations.
538542
All the values currently defined in the
539543
`CF metadata convention <http://cfconventions.org/cf-conventions/cf-conventions#calendar>`__ are supported.
540-
Valid calendars **'standard', 'gregorian', 'proleptic_gregorian'
544+
Valid calendars **'standard', 'gregorian', 'proleptic_gregorian', 'tai',
541545
'noleap', '365_day', '360_day', 'julian', 'all_leap', '366_day'**.
542546
Default is **'standard'**, which is a mixed Julian/Gregorian calendar.
543547
@@ -652,7 +656,7 @@ def date2index(dates, nctime, calendar=None, select='exact', has_year_zero=None)
652656
**calendar**: describes the calendar to be used in the time calculations.
653657
All the values currently defined in the
654658
`CF metadata convention <http://cfconventions.org/cf-conventions/cf-conventions#calendar>`__ are supported.
655-
Valid calendars **'standard', 'gregorian', 'proleptic_gregorian'
659+
Valid calendars **'standard', 'gregorian', 'proleptic_gregorian', 'tai',
656660
'noleap', '365_day', '360_day', 'julian', 'all_leap', '366_day'**.
657661
Default is `None` which means the calendar associated with the first
658662
input datetime instance will be used.
@@ -864,7 +868,7 @@ def _date2index(dates, nctime, calendar=None, select='exact', has_year_zero=None
864868
order.
865869
866870
**calendar**: Describes the calendar used in the time calculation.
867-
Valid calendars 'standard', 'gregorian', 'proleptic_gregorian'
871+
Valid calendars 'standard', 'gregorian', 'proleptic_gregorian', 'tai',
868872
'noleap', '365_day', '360_day', 'julian', 'all_leap', '366_day'.
869873
Default is 'standard', which is a mixed Julian/Gregorian calendar
870874
If `calendar` is None, its value is given by `nctime.calendar` or
@@ -911,7 +915,7 @@ def time2index(times, nctime, calendar=None, select='exact'):
911915
order.
912916
913917
**calendar**: Describes the calendar used in the time calculation.
914-
Valid calendars 'standard', 'gregorian', 'proleptic_gregorian'
918+
Valid calendars 'standard', 'gregorian', 'proleptic_gregorian', 'tai',
915919
'noleap', '365_day', '360_day', 'julian', 'all_leap', '366_day'.
916920
Default is `standard`, which is a mixed Julian/Gregorian calendar
917921
If `calendar` is None, its value is given by `nctime.calendar` or
@@ -1072,7 +1076,7 @@ for cftime.datetime instances using
10721076
10731077
All the calendars currently defined in the
10741078
`CF metadata convention <http://cfconventions.org/cf-conventions/cf-conventions#calendar>`__ are supported.
1075-
Valid calendars are 'standard', 'gregorian', 'proleptic_gregorian'
1079+
Valid calendars are 'standard', 'gregorian', 'proleptic_gregorian', 'tai',
10761080
'noleap', '365_day', '360_day', 'julian', 'all_leap', '366_day'.
10771081
Default is 'standard', which is a mixed Julian/Gregorian calendar.
10781082
'standard' and 'gregorian' are synonyms, as are 'all_leap'/'366_day'
@@ -1152,6 +1156,12 @@ The default format of the string produced by strftime is controlled by self.form
11521156
# and has a year zero, so for now this is the default in cftime.
11531157
if calendar in ['julian','gregorian','standard'] and year <= 0:
11541158
warnings.warn(cfwarnmsg,category=CFWarning)
1159+
# raise exception if date before 1958-01-01 requested for tai calendar.
1160+
if calendar == 'tai':
1161+
if year < 1958:
1162+
raise ValueError('dates before 1958-01-01 not allowed in TAI calendar')
1163+
if has_year_zero:
1164+
raise ValueError('year zero not allowed in TAI calendar')
11551165
# raise exception if year zero requested but has_year_zero set
11561166
# to False (issue #248).
11571167
if year == 0 and has_year_zero==False:
@@ -1185,7 +1195,7 @@ The default format of the string produced by strftime is controlled by self.form
11851195
self.calendar = calendar
11861196
self.datetime_compatible = False
11871197
assert_valid_date(self, is_leap_julian, False, has_year_zero=has_year_zero)
1188-
elif calendar == 'proleptic_gregorian':
1198+
elif calendar == 'proleptic_gregorian' or calendar == 'tai':
11891199
self.calendar = calendar
11901200
self.datetime_compatible = True
11911201
assert_valid_date(self, is_leap_proleptic_gregorian, False, has_year_zero=has_year_zero)
@@ -1275,7 +1285,7 @@ The default format of the string produced by strftime is controlled by self.form
12751285
if getattr(pydatetime, 'tzinfo',None) is not None:
12761286
pydatetime = pydatetime.replace(tzinfo=None) - pydatetime.utcoffset()
12771287
compatible_date =\
1278-
calendar == 'proleptic_gregorian' or \
1288+
calendar == 'proleptic_gregorian' or calendar == 'tai' or \
12791289
(calendar in ['gregorian','standard'] and (pydatetime.year > 1582 or \
12801290
(pydatetime.year == 1582 and pydatetime.month > 10) or \
12811291
(pydatetime.year == 1582 and pydatetime.month == 10 and pydatetime.day > 15)))
@@ -1480,7 +1490,7 @@ The default format of the string produced by strftime is controlled by self.form
14801490
units = 'days since -4712-1-1-12'
14811491
else:
14821492
units = 'days since -4713-1-1-12'
1483-
elif calendar == 'proleptic_gregorian':
1493+
elif calendar == 'proleptic_gregorian' or calendar == 'tai':
14841494
if has_year_zero:
14851495
units = 'days since -4713-11-24-12'
14861496
else:
@@ -1566,6 +1576,9 @@ The default format of the string produced by strftime is controlled by self.form
15661576
elif calendar == 'proleptic_gregorian':
15671577
#return dt.__class__(*add_timedelta(dt, delta, is_leap_proleptic_gregorian, False, has_year_zero),calendar=calendar,has_year_zero=has_year_zero)
15681578
return DatetimeProlepticGregorian(*add_timedelta(dt, delta, is_leap_proleptic_gregorian, False, has_year_zero),has_year_zero=has_year_zero)
1579+
elif calendar == 'tai':
1580+
#return dt.__class__(*add_timedelta(dt, delta, is_leap_proleptic_gregorian, False, has_year_zero),calendar=calendar,has_year_zero=has_year_zero)
1581+
return DatetimeTAI(*add_timedelta(dt, delta, is_leap_proleptic_gregorian, False, has_year_zero),has_year_zero=has_year_zero)
15691582
else:
15701583
return NotImplemented
15711584

@@ -1626,6 +1639,10 @@ datetime object."""
16261639
#return self.__class__(*add_timedelta(self, -other,
16271640
# is_leap_proleptic_gregorian, False, has_year_zero),calendar=self.calendar,has_year_zero=self.has_year_zero)
16281641
return DatetimeProlepticGregorian(*add_timedelta(self, -other, is_leap_proleptic_gregorian, False, has_year_zero),has_year_zero=self.has_year_zero)
1642+
elif self.calendar == 'tai':
1643+
#return self.__class__(*add_timedelta(self, -other,
1644+
# is_leap_proleptic_gregorian, False, has_year_zero),calendar=self.calendar,has_year_zero=self.has_year_zero)
1645+
return DatetimeTAI(*add_timedelta(self, -other, is_leap_proleptic_gregorian, False, has_year_zero),has_year_zero=self.has_year_zero)
16291646
else:
16301647
return NotImplemented
16311648
else:
@@ -1944,7 +1961,7 @@ cdef _is_leap(int year, calendar, has_year_zero=None):
19441961
tyear = year + 1
19451962
else:
19461963
tyear = year
1947-
if calendar == 'proleptic_gregorian' or (calendar == 'standard' and year > 1581):
1964+
if calendar == 'proleptic_gregorian' or calendar == 'tai' or (calendar == 'standard' and year > 1581):
19481965
if tyear % 4: # not divisible by 4
19491966
leap = False
19501967
elif tyear % 100: # not divisible by 100
@@ -2040,7 +2057,7 @@ cdef _IntJulianDayFromDate(int year,int month,int day,calendar,skip_transition=F
20402057
elif calendar == '366_day':
20412058
return year*366 + _cumdayspermonth_leap[month-1] + day - 1
20422059

2043-
# handle standard, julian, proleptic_gregorian calendars.
2060+
# handle standard, julian, tai, proleptic_gregorian calendars.
20442061
if year == 0 and not has_year_zero:
20452062
raise ValueError('year zero does not exist in the %s calendar' %\
20462063
calendar)
@@ -2066,7 +2083,7 @@ cdef _IntJulianDayFromDate(int year,int month,int day,calendar,skip_transition=F
20662083
jday_greg -= 31739
20672084
if calendar == 'julian':
20682085
return jday_jul
2069-
elif calendar == 'proleptic_gregorian':
2086+
elif calendar == 'proleptic_gregorian' or calendar == 'tai':
20702087
return jday_greg
20712088
elif calendar in ['standard','gregorian']:
20722089
# check for invalid days in mixed calendar (there are 10 missing)
@@ -2080,7 +2097,7 @@ cdef _IntJulianDayFromDate(int year,int month,int day,calendar,skip_transition=F
20802097
else:
20812098
return jday_greg
20822099

2083-
# legacy calendar specific sub-classes (will be removed in a future release).
2100+
# legacy calendar specific sub-classes (may be removed in a future release).
20842101

20852102
@cython.embedsignature(True)
20862103
cdef class DatetimeNoLeap(datetime):
@@ -2141,3 +2158,13 @@ but allows for dates that don't exist in the proleptic gregorian calendar.
21412158
def __init__(self, *args, **kwargs):
21422159
kwargs['calendar']='proleptic_gregorian'
21432160
super().__init__( *args, **kwargs)
2161+
2162+
@cython.embedsignature(True)
2163+
cdef class DatetimeTAI(datetime):
2164+
"""
2165+
Phony datetime object which mimics the python datetime object,
2166+
but allows for dates that don't exist in the proleptic gregorian calendar.
2167+
"""
2168+
def __init__(self, *args, **kwargs):
2169+
kwargs['calendar']='tai'
2170+
super().__init__( *args, **kwargs)

test/test_cftime.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from cftime import real_datetime
44
from cftime import (Datetime360Day, DatetimeAllLeap,
55
DatetimeGregorian, DatetimeJulian, DatetimeNoLeap,
6-
DatetimeProlepticGregorian, _parse_date,
6+
DatetimeProlepticGregorian, DatetimeTAI, _parse_date,
77
date2index, date2num, num2date, UNIT_CONVERSION_FACTORS)
88
import copy
99
import unittest
@@ -2248,6 +2248,77 @@ def test_num2date_precision():
22482248
assert np.ma.is_masked(date2[0])
22492249
assert date[1] == date2[1]
22502250

2251+
# NOTE: using pytest style tests -- these won't run without pytest
2252+
# but it looks like you're using pytest, so this is cleaner and easier
2253+
2254+
# There's really no reason to put these in a class, but it does organize things
2255+
class Test_tai:
2256+
"""
2257+
tests specific to the tai calendar
2258+
"""
2259+
def test_dateparse_valid(self):
2260+
"""
2261+
It should raise for an epoch before 1958
2262+
"""
2263+
# This is directly testing the _dateparse function
2264+
# Which is a "proper" unit test, but also testing an internal function.
2265+
timestring = "seconds since 1958-01-01T00:00:00"
2266+
basedate = cftime._dateparse(timestring, 'tai')
2267+
2268+
print(repr(basedate))
2269+
2270+
assert basedate == cftime.datetime(1958, 1, 1, 0, 0, 0, 0, calendar='tai', has_year_zero=False)
2271+
2272+
def test_dateparse_before_1958(self):
2273+
"""
2274+
It should raise for an epoch before 1958
2275+
"""
2276+
# This is directly testing the _dateparse function
2277+
# Which is a "proper" unit test, but also testing an internal function.
2278+
timestring = "seconds since 1957-01-01T00:00:00"
2279+
with pytest.raises(ValueError):
2280+
basedate = cftime._dateparse(timestring, 'tai')
2281+
# cftime._dateparse(timestring, 'tai', has_year_zero=None)
2282+
print(basedate)
2283+
2284+
def test_dateparse_with_offset(self):
2285+
"""
2286+
It should raise if there's an offset
2287+
"""
2288+
# This is directly testing the _dateparse function
2289+
# Which is a "proper" unit test, but also testing an internal function.
2290+
timestring = "seconds since 1965-01-01T00:00:00+08:00"
2291+
with pytest.raises(ValueError):
2292+
basedate = cftime._dateparse(timestring, 'tai')
2293+
# cftime._dateparse(timestring, 'tai', has_year_zero=None)
2294+
print(basedate)
2295+
2296+
def test_year_zero(self):
2297+
"""
2298+
tai does not have a year zero -- not sure this is worth testing, but for full coverage.
2299+
"""
2300+
# This is directly testing the _dateparse function
2301+
# Which is a "proper" unit test, but also testing an internal function.
2302+
timestring = "seconds since 1965-01-01T00:00:00"
2303+
with pytest.raises(ValueError):
2304+
basedate = cftime._dateparse(timestring, 'tai', has_year_zero=True)
2305+
# cftime._dateparse(timestring, 'tai', has_year_zero=None)
2306+
print(basedate)
2307+
2308+
def test_creation_valid(self):
2309+
dt = DatetimeTAI(2025, 1, 9, 14, 18)
2310+
2311+
assert dt.calendar == 'tai'
2312+
assert dt.has_year_zero is False
2313+
2314+
print(repr(dt))
2315+
2316+
def test_creation_before_1958(self):
2317+
with pytest.raises(ValueError):
2318+
dt = DatetimeTAI(1957, 1, 9, 14, 18)
2319+
print(repr(dt))
2320+
2321+
22512322

22522323
if __name__ == '__main__':
22532324
unittest.main()

0 commit comments

Comments
 (0)