#!/usr/bin/env python
"""
Conversion of datetime formats
The module enhances cftime by non-CF date formats.
This module was written by Matthias Cuntz while at Institut National de
Recherche pour l'Agriculture, l'Alimentation et l'Environnement (INRAE), Nancy,
France.
:copyright: Copyright 2022- Matthias Cuntz, see AUTHORS.rst for details.
:license: MIT License, see LICENSE for details.
.. moduleauthor:: Matthias Cuntz
The following functions are provided:
.. autosummary::
date2dec
date2num
dec2date
num2date
datetime
History
* Written date2dec and dec2date Jun 2010, Arndt Piayda
* Input can be scalar or array_like, Feb 2012, Matthias Cuntz
* fulldate=True default in dec2date, Feb 2012, Matthias Cuntz
* Added calendars decimal and decimal360, Feb 2012, Matthias Cuntz
* Rename units to refdate and add units as in netcdftime in dec2date,
Jun 2012, Matthias Cuntz
* Add units='day as %Y%m%d.%f' in dec2date, Jun 2012, Matthias Cuntz
* Change units of proleptic_gregorian from 'days since 0001-01-01 00:00:00'
to 'days since 0001-01-00 00:00:00' in date2dec, Dec 2012, Matthias Cuntz
* Bug in Excel and leap years, Feb 2013
* Ported to Python 3, Feb 2013
* Bug in 'eng' output of dec2date, May 2013, Arndt Piayda
* Times with keywords ascii and eng default to 00:00:00 in date2dec,
Jul 2013, Matthias Cuntz
* Corrected that Excel year starts as 1 not at 0, Oct 2013, Matthias Cuntz
* But in units keyword and Julian calendar, day was subtracted even if
units were given, Oct 2013, Matthias Cuntz
* Removed remnant of time treatment before time check in eng keyword in
date2dec, Nov 2013, Matthias Cuntz
* Adapted date2dec to new netCDF4/netcdftime (>=v1.0) and Python datetime
(>v2.7.9), Jun 2015, Matthias Cuntz
* Add units=='month as %Y%m.%f' and units=='year as %Y.%f' in dec2date,
May 2016, Matthias Cuntz
* Now possible to pass array_like to date2num instead of single
netCDF4.datetime objects in date2dec, Oct 2016, Matthias Cuntz
* Provide netcdftime even with netCDF4 > v1.0.0, Oct 2016, Matthias Cuntz
* mo is always integer in date2dec, Oct 2016, Matthias Cuntz
* leap is always integer in dec2date, Oct 2016, Matthias Cuntz
* Corrected 00, 01, etc. in date2dec, which are not accepted as integer
constants by Python 3, Nov 2016, Matthias Cuntz
* numpydoc docstring format, May 2020, Matthias Cuntz
* Renamed eng keword to en, Jul 2020, Matthias Cuntz
* Use proleptic_gregorian calendar for Excel dates,
Jul 2020, Matthias Cuntz
* Change all np.int, np.float, etc. to Python equivalents,
May 2021, Matthias Cuntz
* flake8 compatible, May 2021, Matthias Cuntz
* Written class_datetime, Jun 2022, Matthias Cuntz
Complete rewrite from scratch following closely cftime but for
non-CF-conform calendars such as decimal, Excel, and the cdo
absolute time formats (e.g. units='day as %Y%m%d.%f').
Provides its own datetime class.
Use cftime notation now, i.e. date2num and num2date but provide
date2dec and dec2date wrappers for backward compatibility (almost).
Provide microsecond resolution with all supported calendars no matter
of the units, which means that date2num returns np.longdouble values.
date2num works together with date2date and can have formatted date
strings as input.
* calendar keyword takes precedence on calendar attribute of
datetime objects in date2num, Jul 2022, Matthias Cuntz
* return_arrays keyword in date2num, Jul 2022, Matthias Cuntz
* round_microseconds method for datetime, Jul 2022, Matthias Cuntz
* only_use_pyjams_datetimes keyword in num2date, Jan 2022, Matthias Cuntz
* Also CF-calendars in datetime class, Jan 2023, Matthias Cuntz
* Use longdouble keyword with date2num if cftime > v1.6.1,
Aug 2024, Matthias Cuntz
* Filter UserWarning from cftime, Aug 2024, Matthias Cuntz
* ensure_seconds keyword in date2num, Aug 2024, Matthias Cuntz
ToDo
* Check why datetime + timedelta but not timedelta + datetime
* add date2index
* add time2index
* implement fromordinal
* implement change_calendar
* strptime
"""
from datetime import datetime as datetime_python
from datetime import timedelta
import re
import time as ptime
import warnings
import numpy as np
import cftime as cf
from .helper import input2array, array2input
from .date2date import date2date
# from pyjams.helper import input2array, array2input
# from pyjams.date2date import date2date
__all__ = ['date2dec', 'date2num', 'dec2date', 'num2date', 'datetime']
# supported calendars. Includes synonyms ('excel'=='excel1900')
_excelcalendars = ['excel', 'excel1900', 'excel1904']
_decimalcalendars = ['decimal', 'decimal360', 'decimal365', 'decimal366']
_noncfcalendars = _excelcalendars + _decimalcalendars
_cfcalendars = ['standard', 'gregorian', 'proleptic_gregorian',
'noleap', 'julian', 'all_leap', '365_day', '366_day',
'360_day']
_idealized_cfcalendars = ['all_leap', 'noleap', '366_day', '365_day',
'360_day']
# number of days in year
_dayspermonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
_dayspermonth_leap = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
_dayspermonth_360 = [30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30]
_cumdayspermonth = [0, 31, 59, 90, 120, 151, 181,
212, 243, 273, 304, 334, 365]
_cumdayspermonth_leap = [0, 31, 60, 91, 121, 152, 182,
213, 244, 274, 305, 335, 366]
_cumdayspermonth_360 = [0, 30, 60, 90, 120, 150, 180,
210, 240, 270, 300, 330, 360]
# feps = np.finfo(np.float64).eps
deps = np.finfo(np.longdouble).eps
#
# Exact copies of private cftime routines
# (changed Python time to ptime)
#
_illegal_s = re.compile(r"((^|[^%])(%%)*%s)")
def _findall(text, substr):
# Also finds overlaps
sites = []
i = 0
while 1:
j = text.find(substr, i)
if j == -1:
break
sites.append(j)
i = j + 1
return sites
def to_tuple(dt):
"""
Turn a datetime instance into a tuple of integers. Elements go
in the order of decreasing significance, making it easy to compare
datetime instances. Parts of the state that don't affect ordering
are omitted. Compare to timetuple().
"""
return (dt.year, dt.month, dt.day, dt.hour, dt.minute,
dt.second, dt.microsecond)
# factory function without optional kwargs that can be used in
# datetime.__reduce_
def _create_datetime(date_type, args, kwargs):
return date_type(*args, **kwargs)
#
# Adapted cftime routines
#
# Every 28 years the calendar repeats, except through century leap
# years where it's 6 years. But only if you're using the Gregorian
# calendar. ;-)
# Make also 4-digit negative years
# Allow .%f for microseconds
def _strftime(dt, fmt):
if _illegal_s.search(fmt):
raise TypeError("This strftime implementation does not handle %s")
if '%f' in fmt:
if not fmt.endswith('.%f'):
raise TypeError('If %f is used for microseconds it must be the'
' at the end as .%f')
else:
ihavems = True
fmt1 = fmt[:-3]
else:
ihavems = False
fmt1 = fmt
# don't use strftime method at all.
# if dt.year > 1900:
# return dt.strftime(fmt)
year = dt.year
# For every non-leap year century, advance by
# 6 years to get into the 28-year repeat cycle
delta = 2000 - year
off = 6 * (delta // 100 + delta // 400)
year = year + off
# Move to around the year 2000
year = year + ((2000 - year) // 28) * 28
# timetuple does not include microseconds
timetuple = dt.timetuple()
# time.strftime does hence not treat microseconds. i.e. format code %f
s1 = ptime.strftime(fmt1, (year,) + timetuple[1:])
sites1 = _findall(s1, str(year))
s2 = ptime.strftime(fmt1, (year + 28,) + timetuple[1:])
sites2 = _findall(s2, str(year + 28))
sites = []
for site in sites1:
if site in sites2:
sites.append(site)
s = s1
if dt.year < 0:
syear = "%05d" % (dt.year,)
else:
syear = "%04d" % (dt.year,)
for site in sites:
s = s[:site] + syear + s[site + 4:]
if ihavems:
s = s + '.{:06d}'.format(dt.microsecond)
return s
def _datesplit(timestr):
"""
Split a unit string for time into its three components:
unit, string 'since' or 'as', and the remainder
"""
try:
(units, sincestring, remainder) = timestr.split(None, 2)
except ValueError:
raise ValueError(f'Incorrectly formatted date-time unit_string:'
f' {timestr}')
if sincestring.lower() not in ['since', 'as']:
raise ValueError(f"No 'since' or 'as' in unit_string:"
f" {timestr}")
return units.lower(), sincestring.lower(), remainder
def _year_zero_defaults(calendar):
"""
Set calendar specific defaults for having year 0 or not
Excel calendars *excel*, *excel1900*, *excel1904* start only 1900
or above, i.e. no year 0.
Decimal calendars *decimal*, *decimal360*, *decimal365*, *decimal366*
have year 0 by default but might also omit it.
Real-world calendars *standard*, *gregorian*, *julian* have no year 0.
*proleptic_gregorian* (ISO 8601) and the idealized calendars
*noleap*/*365_day*, *360_day*, *366_day*/*all_leap* have by default
a year 0.
Parameters
----------
calendar : str
One of the supported calendar names in *_noncfcalendars*
Returns
-------
bool
True if calendar includes year 0 by default
Examples
--------
>>> print(_year_zero_defaults('Excel'))
False
>>> print(_year_zero_defaults('decimal'))
True
"""
calendar = calendar.lower()
if calendar in ['standard', 'gregorian', 'julian']:
return False
elif calendar in ['proleptic_gregorian']:
return True # ISO 8601 year zero=1 BC
elif calendar in _idealized_cfcalendars:
return True
elif calendar in _excelcalendars:
return False
elif calendar in _decimalcalendars:
return True
else:
raise ValueError(f'Unknown calendar: {calendar}')
def _is_leap(year, calendar, has_year_zero=None):
"""
Determines if a specific year in a given calendar is a leap year
*has_year_zero* controls whether astronomical year numbering
is used and the year zero exists. If not specified,
calendar-specific default is assumed.
Excel calendars *excel*, *excel1900*, *excel1904* start only in 1900
or above, i.e. no year 0 allowed.
Decimal calendars *decimal*, *decimal360*, *decimal365*, *decimal366*
have year 0 by default but might omit it by setting *has_year_zero=False*.
Parameters
----------
year : int or array_like of int
Year(s) to check if leap year
calendar : str
One of the supported calendar names in *_noncfcalendars*
has_year_zero : bool, optional
Astronomical year numbering is used, i.e. year zero exists, if True
and possible for the given *calendar*. If *None* (default),
calendar-specific defaults are assumed.
Returns
-------
bool or array of bool
True if year is a leap year
Notes
-----
If there is no year 0 in a calendar, years -1, -5, -9, etc. are leap years.
Year 0 is a leap year if it exists.
Examples
--------
>>> years = [1900, 1904]
>>> print(_is_leap(years, 'decimal'))
[False, True]
>>> print(_is_leap(years, 'Excel'))
[True, True]
"""
myear = input2array(year, default=1990)
# set calendar-specific defaults for has_year_zero
if has_year_zero is None:
has_year_zero = _year_zero_defaults(calendar)
if has_year_zero and (calendar in _excelcalendars):
raise ValueError('year 0 not allowed with Excel calendars')
if np.any(myear == 0) and (not has_year_zero):
raise ValueError(f'year 0 does not exist in the calendar {calendar}')
if calendar in _cfcalendars:
leap = [ cf.is_leap_year(yy, calendar, has_year_zero) for yy in myear ]
else:
# If there is no year 0 in the calendar, years -1, -5, -9, etc.
# are leap years. year 0 is a leap year if it exists.
if not has_year_zero:
myear = np.where(myear < 0, myear + 1, myear)
if calendar in _excelcalendars:
# Excel calendars are supposedly Julian calendars
leap = (myear % 4) == 0
elif calendar == 'decimal':
leap = ( (((myear % 4) == 0) & ((myear % 100) != 0)) |
((myear % 400) == 0) )
elif calendar in ['decimal360', 'decimal365']:
leap = np.zeros_like(myear, dtype=bool)
elif calendar == 'decimal366':
leap = np.ones_like(myear, dtype=bool)
else:
raise ValueError(f'Calendar not known: {calendar}')
oleap = array2input(leap, year)
return oleap
def _month_lengths(year, calendar, has_year_zero=None):
"""
Number of days of the 12 months in specific year for a given calendar
Parameters
----------
year : int
Year to inquire
calendar : str
One of the supported calendar names in *_noncfcalendars*
has_year_zero : bool, optional
Astronomical year numbering is used, i.e. year zero exists, if True
and possible for the given *calendar*. If *None* (default),
calendar-specific defaults are assumed.
Returns
-------
list
Lengths of the 12 months in specified *year*
Examples
--------
>>> print(_month_lengths(1990, 'decimal'))
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
>>> print(_month_lengths(1990, 'decimal360'))
[30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30]
"""
leap = _is_leap(year, calendar, has_year_zero)
if calendar == 'decimal360':
return _dayspermonth_360
else:
if leap:
return _dayspermonth_leap
else:
return _dayspermonth
def _int_julian_day_from_date(year, month, day, calendar,
skip_transition=False, has_year_zero=None):
"""
Compute integer Julian Day from year, month, day, and calendar
Integer julian day is number of days since noon UTC -4713-1-1
in the julian or mixed julian/gregorian calendar, or noon UTC
-4714-11-24 in the proleptic_gregorian calendar (without year zero).
Reference date is noon UTC 0000-01-01 for other calendars.
Excel calendars are supposedly Julian calendars with other reference dates.
Julian calendar is hence used for the Excel calendars *excel*, *excel1900*,
*excel1904*, i.e. integer julian day is the same number of days since
-4713-01-01 12:00:00 in the Julian calendar.
Integer julian day in the decimal calendars *decimal*, *decimal360*,
*decimal365*, *decimal366* is the number of days after 0000-01-01 12:00:00.
If the has_year_zero kwarg is set to True, astronomical year numbering
is used and the year zero exists for the decimal calendars.
If set to False, then historical year numbering is used and the year 1 is
preceded by year -1 and no year zero exists.
The defaults (has_year_zero=None) uses astronomical year numbering
for the decimal calendars.
CF version 1.9 conventions are:
False for 'julian', 'gregorian'/'standard',
True for 'proleptic_gregorian' (ISO 8601), and
True for the idealized calendars 'noleap'/'365_day', '360_day',
366_day'/'all_leap'
'skip_transition': When True, leave a 10-day
gap in Julian day numbers between Oct 4 and Oct 15 1582 (the transition
from Julian to Gregorian calendars). Default: False,
ignored unless calendar = 'standard' or 'gregorian'.
"""
if calendar:
calendar = calendar.lower()
if has_year_zero is None:
has_year_zero = _year_zero_defaults(calendar)
if (calendar == 'decimal360') or (calendar == '360_day'):
# return year * 360 + (month - 1) * 30 + day - 1
return year * 360 + _cumdayspermonth_360[month - 1] + day - 1
elif ( (calendar == 'decimal365') or (calendar == '365_day') or
(calendar == 'noleap')):
return year * 365 + _cumdayspermonth[month - 1] + day - 1
elif ( (calendar == 'decimal366') or (calendar == '366_day') or
(calendar == 'all_leap')):
return year * 366 + _cumdayspermonth_leap[month - 1] + day - 1
else:
leap = _is_leap(year, calendar, has_year_zero=has_year_zero)
if leap:
jday = day + _cumdayspermonth_leap[month - 1]
else:
jday = day + _cumdayspermonth[month - 1]
# If there is no year 0, years -1, -5, -9, etc,
# are leap years. year zero is a leap year if it exists.
if (year < 0) and (not has_year_zero):
year += 1
if calendar == 'decimal':
# 1st term is the number of days in the last year
# 2nd term is the number of days in each preceding non-leap year
# last terms are the number of preceding leap years
jday_greg = (jday + 365 * (year - 1) +
(year - 1) // 4 - (year - 1) // 100 +
(year - 1) // 400)
return jday_greg
elif (calendar in _excelcalendars) or (calendar == 'julian'):
year += 4800 # add offset so -4800 is year 0.
# 1st term is the number of days in the last year
# 2nd term is the number of days in each preceding non-leap year
# last terms are the number of preceding leap years
jday_jul = jday + 365 * (year - 1) + (year - 1) // 4
# remove offset for 87 years before -4713 (including leap days)
jday_jul -= 31777
return jday_jul
elif ( (calendar == 'standard') or (calendar == 'gregorian') or
(calendar == 'proleptic_gregorian') ):
year += 4800 # add offset so -4800 is year 0.
# 1st term is the number of days in the last year
# 2nd term is the number of days in each preceding non-leap year
# last terms are the number of preceding leap years since -4800
jday_jul = jday + 365 * (year - 1) + (year - 1) // 4
# remove offset for 87 years before -4713 (including leap days)
jday_jul -= 31777
jday_greg = (jday + 365 * (year - 1) +
(year - 1) // 4 - (year - 1) // 100 +
(year - 1) // 400)
# remove offset, and account for the fact that -4713/1/1 is jday=38
# in gregorian calendar.
jday_greg -= 31739
if calendar == 'proleptic_gregorian':
return jday_greg
else:
# check for invalid days in mixed calendar
# (there are 10 missing)
if jday_jul >= 2299161 and jday_jul < 2299171:
raise ValueError('invalid date in mixed calendar')
if jday_jul < 2299161: # 1582 October 15
return jday_jul
else:
if skip_transition:
return jday_greg + 10
else:
return jday_greg
else:
raise ValueError(f'Unknown calendar: {calendar}')
# Add a datetime.timedelta to a pyjams.datetime instance. Uses
# integer arithmetic to avoid rounding errors and preserve
# microsecond accuracy.
def _add_timedelta(dt, delta):
# extract these inputs here to avoid type conversion in the code below
delta_microseconds = delta.microseconds
delta_seconds = delta.seconds
delta_days = delta.days
# shift microseconds, seconds, days
calendar = dt.calendar
has_year_zero = dt.has_year_zero
microsecond = dt.microsecond + delta_microseconds
second = dt.second + delta_seconds
minute = dt.minute
hour = dt.hour
day = dt.day
month = dt.month
year = dt.year
month_length = _month_lengths(year, calendar, has_year_zero)
# Normalize microseconds, seconds, minutes, hours.
second += microsecond // 1000000
microsecond = microsecond % 1000000
minute += second // 60
second = second % 60
hour += minute // 60
minute = minute % 60
extra_days = hour // 24
hour = hour % 24
delta_days += extra_days
while delta_days < 0:
# not done compared to cftime because Excel dates > 1900 and
# decimal dates include 1582-10-05 to 1582-10-14
# if (year == 1582 and month == 10 and day > 14 and
# day + delta_days < 15):
# delta_days -= n_invalid_dates # skip over invalid dates
if (day + delta_days) < 1:
delta_days += day
# decrement month
month -= 1
if month < 1:
month = 12
year -= 1
if (year == 0) and (not has_year_zero):
year = -1
month_length = _month_lengths(year, calendar, has_year_zero)
day = month_length[month - 1]
else:
day += delta_days
delta_days = 0
while delta_days > 0:
# not done compared to cftime because Excel dates > 1900 and
# decimal dates include 1582-10-05 to 1582-10-14
# if (year == 1582 and month == 10 and day < 5 and
# day + delta_days > 4):
# delta_days += n_invalid_dates # skip over invalid dates
if (day + delta_days) > month_length[month - 1]:
delta_days -= month_length[month - 1] - (day - 1)
# increment month
month += 1
if month > 12:
month = 1
year += 1
if (year == 0) and (not has_year_zero):
year = 1
month_length = _month_lengths(year, calendar, has_year_zero)
day = 1
else:
day += delta_days
delta_days = 0
return (year, month, day, hour, minute, second, microsecond)
#
# New routines
#
def _units_defaults(calendar, has_year_zero=None):
"""
Set calendar specific default units as 'days since reference_date'
Day 0 of *excel* and *excel1900* starts at 1899-12-31 00:00:00.
Day 0 of *excel1904* starts at 1903-12-31 00:00:00.
Decimal calendars *decimal*, *decimal360*, *decimal365*, and
*decimal366* do not need units so 0001-01-01 00:00:00 is taken.
Day 0 of *julian*, *gregorian* and *standard* starts at
-4713-01-01 12:00:00 if not has_year_zero, and at
-4712-01-01 12:00:00 if has_year_zero.
Day 0 of *proleptic_gregorian* starts at
-4714-11-24 12:00:00 if not has_year_zero, and at
-4713-11-24 12:00:00 if has_year_zero.
Day 0 of *360_day*, *365_day*, *366_day*, *all_leap*, and
*noleap* starts at 0000-01-01 12:00:00.
Parameters
----------
calendar : str
One of the supported calendar names in *_cfcalendars* and
*_noncfcalendars*
has_year_zero : bool, optional
Astronomical year numbering is used, i.e. year zero exists, if True
and possible for the given *calendar*. If *None* (default),
calendar-specific defaults are assumed.
Returns
-------
str
'days since reference_date' with calendar-specific reference_date
Examples
--------
>>> print(_units_defaults('Excel'))
'days since 1899-12-31 00:00:00'
"""
calendar = calendar.lower()
if has_year_zero is None:
has_year_zero = _year_zero_defaults(calendar)
if calendar in ['standard', 'gregorian', 'julian']:
if has_year_zero:
return 'days since -4712-01-01 12:00:00'
else:
return 'days since -4713-01-01 12:00:00'
elif calendar in ['proleptic_gregorian']:
if has_year_zero:
return 'days since -4713-11-24 12:00:00'
else:
return 'days since -4714-11-24 12:00:00'
elif calendar in _idealized_cfcalendars:
return 'days since 0000-01-01 12:00:00'
elif calendar in ['excel', 'excel1900']:
return 'days since 1899-12-31 00:00:00'
elif calendar in ['excel1904']:
return 'days since 1903-12-31 00:00:00'
elif calendar in _decimalcalendars:
return 'days since 0001-01-01 00:00:00'
else:
raise ValueError(f'Unknown calendar: {calendar}')
def _date2decimal(date, calendar):
"""
Decimal date from datetime object
Parameters
----------
date : datetime instance
Instance of pyjams.datetime class
calendar : str
One of the decimal calendar names *decimal*, *decimal360*,
*decimal365*, or *decimal366*
Returns
-------
longdouble
decimal date
Examples
--------
>>> dt = datetime(1990, 1, 1)
>>> dec = _date2decimal(dt, 'decimal')
>>> print(dec)
1990.
"""
year = date.year
month = date.month
day = np.longdouble(date.day)
hour = np.longdouble(date.hour)
minute = np.longdouble(date.minute)
second = np.longdouble(date.second)
msecond = np.longdouble(date.microsecond)
calendar = calendar.lower()
days_year = np.longdouble(365)
diy = np.array([ [-9] + _cumdayspermonth,
[-9] + _cumdayspermonth_leap ], dtype=np.longdouble)
if calendar == 'decimal':
leap = int( (((year % 4) == 0) & ((year % 100) != 0)) |
((year % 400) == 0) )
elif calendar == 'decimal360':
leap = 0
days_year = np.longdouble(360)
diy = np.array([ [-9] + _cumdayspermonth_360,
[-9] + _cumdayspermonth_360 ], dtype=np.longdouble)
elif calendar == 'decimal365':
leap = 0
elif calendar == 'decimal366':
leap = 1
fleap = np.longdouble(leap)
tday = diy[leap, month] + day
thour = ( (tday - 1.) * 24. +
hour +
minute / 60. +
second / 3600. +
msecond / 3600000000. )
out = np.longdouble(year) + thour / ((days_year + fleap) * 24.)
return out
def _dates2decimal(dates, calendar):
"""
Decimal dates from datetime objects
Parameters
----------
dates : datetime instance or array_like of datetime instances
Instances of pyjams.datetime class
calendar : str
One of the decimal calendar names *decimal*, *decimal360*,
*decimal365*, or *decimal366*
Returns
-------
array_like of longdouble
decimal dates
Examples
--------
>>> dt = [datetime(1990, 1, 1), datetime(1991, 1, 1)]
>>> dec = _dates2decimal(dt, 'decimal')
>>> print(dec)
[1990., 1991.]
"""
mdates = input2array(dates, default=datetime(1990, 1, 1))
# wrapper might be slow
out = [ _date2decimal(dd, calendar) for dd in mdates ]
out = array2input(out, dates)
return out
def _date2absolute(date, units):
"""
Absolute date from datetime object
Parameters
----------
date : datetime instance
Instances of pyjams.datetime class
units : str
'day as %Y%m%d.%f', 'month as %Y%m.%f', or 'year as %Y.%f'
Returns
-------
longdouble
absolute date
Examples
--------
>>> dt = datetime(1990, 1, 1)
>>> dec = _date2absolute(dt, 'day as %Y%m%d.%f')
>>> print(np.around(dec, 1))
19900101.0
"""
year = date.year
month = date.month
day = np.longdouble(date.day)
hour = np.longdouble(date.hour)
minute = np.longdouble(date.minute)
second = np.longdouble(date.second)
msecond = np.longdouble(date.microsecond)
if units == 'day as %Y%m%d.%f':
tday = (np.longdouble(year) * 10000. +
np.longdouble(month) * 100. +
day)
thour = (hour +
minute / 60. +
second / 3600. +
msecond / 3600000000.)
out = tday + thour / 24.
elif units == 'month as %Y%m.%f':
leap = int( (((year % 4) == 0) & ((year % 100) != 0)) |
((year % 400) == 0) )
dim = np.array([ [-9] + _dayspermonth,
[-9] + _dayspermonth_leap ], dtype=np.longdouble)
tmonth = (np.longdouble(year) * 100. +
np.longdouble(month))
thour = (day * 24. +
hour +
minute / 60. +
second / 3600. +
msecond / 3600000000.)
out = tmonth + thour / (dim[leap, month] * 24.)
elif units == 'year as %Y.%f':
# same as decimal date
out = _date2decimal(date, 'decimal')
else:
raise ValueError(f'Unknown absolute units: {units}')
return out
def _dates2absolute(dates, units):
"""
Absolute dates from datetime object
Parameters
----------
dates : datetime instance or array_like of datetime instances
Instances of pyjams.datetime class
units : str
'day as %Y%m%d.%f', 'month as %Y%m.%f', or 'year as %Y.%f'
Returns
-------
longdouble or array_like of longdouble
absolute dates
Examples
--------
>>> dt = [datetime(1990, 1, 1), datetime(1991, 1, 1)]
>>> dec = _dates2absolute(dt, 'day as %Y%m%d.%f')
>>> print(np.around(dec, 1))
[19900101.0, 19910101.0]
"""
mdates = input2array(dates, default=datetime(1990, 1, 1))
# wrapper might be slow
out = [ _date2absolute(dd, units) for dd in mdates ]
out = array2input(out, dates)
return out
def _decimal2date(times, calendar):
"""
Split decimal dates into year, month, day, hour, minute, second,
microsecond
Parameters
----------
times : float or array_like
Decimal dates such as 1990.5102
calendar : str
One of the decimal calendar names *decimal*, *decimal360*,
*decimal365*, or *decimal366*
Returns
-------
tuple
arrays of year, month, day, hour, minute, second, microsecond
Examples
--------
>>> dec = [1990., 1991.5]
>>> yr, mo, dy, hr, mi, sc, ms = _decimal2date(dec, 'decimal')
>>> print(yr)
[1990, 1991]
>>> print(mo)
[1, 7]
>>> print(dy)
[1, 3]
"""
# old algorithm does decomposition on all array elements
# should try similar to decode_dates_from_array for speed
# where decomposition is done for the first (oldest) element only
# and then timedeltas are added subsequently
mtimes = input2array(times, default=1.)
mtimes = np.array(mtimes, dtype=np.longdouble)
calendar = calendar.lower()
# year
fyear = np.trunc(mtimes)
fyear = np.where(mtimes < 0., fyear - 1., fyear)
year = fyear.astype(np.int64)
frac_year = mtimes - fyear
days_year = np.longdouble(365)
diy = np.array([ [-9] + _cumdayspermonth,
[-9] + _cumdayspermonth_leap ])
if calendar == 'decimal':
leap = ( (((year % 4) == 0) & ((year % 100) != 0)) |
((year % 400) == 0) ).astype(int)
fleap = leap.astype(np.longdouble)
elif calendar == 'decimal360':
leap = np.zeros_like(mtimes, dtype=int)
fleap = np.zeros_like(mtimes, dtype=np.longdouble)
days_year = np.longdouble(360)
diy = np.array([ [-9] + _cumdayspermonth_360,
[-9] + _cumdayspermonth_360 ])
elif calendar == 'decimal365':
leap = np.zeros_like(mtimes, dtype=int)
fleap = np.zeros_like(mtimes, dtype=np.longdouble)
elif calendar == 'decimal366':
leap = np.ones_like(mtimes, dtype=int)
fleap = np.ones_like(mtimes, dtype=np.longdouble)
else:
raise ValueError(f'Unknown decimal calendar: {calendar}')
# change to microseconds to catch round-off errors,
# i.e. cases 1 microsec less or greater than a second
# cf. issue #187 of cftime
# day of year in microseconds
fhoy = frac_year * (days_year + fleap) * 86400000000.
ihoy = np.rint(fhoy).astype(np.int64)
# Done in cftime for issue #187
# ihoy = np.where(ihoy%1000000 == 1,
# np.floor(fhoy).astype(np.int64), ihoy)
# ihoy = np.where(ihoy%1000000 == 999999,
# np.ceil(fhoy).astype(np.int64), ihoy)
# microsecond
msecond = ihoy % 1000000
ihoy = ihoy // 1000000
# second
second = ihoy % 60
ihoy = ihoy // 60
# minute
minute = ihoy % 60
ihoy = ihoy // 60
# hour
hour = ihoy % 24
ihoy = ihoy // 24
# day and month
idoy = ihoy + 1
month = np.zeros_like(mtimes, dtype=np.int64)
day = np.zeros_like(mtimes, dtype=np.int64)
for i in range(mtimes.size):
ii = np.where(idoy[i] > diy[leap[i], :])[0]
month[i] = ii[-1]
day[i] = idoy[i] - diy[leap[i], month[i]]
return year, month, day, hour, minute, second, msecond
def _absolute2date(times, units):
"""
Split date(s) in absolute date format into
year, month, day, hour, minute, second, microsecond
Parameters
----------
times : float or array_like
Absolute dates such as 20070102.0034722
units : str
'day as %Y%m%d.%f', 'month as %Y%m.%f', or 'year as %Y.%f'
Returns
-------
tuple
arrays of year, month, day, hour, minute, second, microsecond
Examples
--------
>>> absolut = [20070102.0034722, 20070102.0069444]
>>> yr, mo, dy, hr, mi, sc, ms = _absolute2date(absolut,
... 'day as %Y%m%d.%f')
>>> print(yr)
[2007, 2007]
>>> print(mi)
[5, 10]
"""
# old algorithm does decomposition on all array elements
# should try similar to decode_dates_from_array for speed
# where decomposition is done for the first (oldest) element only
# and then timedeltas are added subsequently
mtimes = input2array(times, default=10101.)
mtimes = np.array(mtimes, dtype=np.longdouble)
if units == 'day as %Y%m%d.%f':
# change to microseconds to catch round-off errors,
# i.e. cases 1 microsec less or greater than a second
# cf. issue #187 of cftime
# day of year in microseconds
fhoy = mtimes * 86400000000.
ihoy = np.rint(fhoy).astype(np.int64)
# Done in cftime for issue #187
# ihoy = np.where(ihoy%1000000 == 1,
# np.floor(fhoy).astype(np.int64), ihoy)
# ihoy = np.where(ihoy%1000000 == 999999,
# np.ceil(fhoy).astype(np.int64), ihoy)
# microsecond
msecond = ihoy % 1000000
ihoy = ihoy // 1000000
# second
second = ihoy % 60
ihoy = ihoy // 60
# minute
minute = ihoy % 60
ihoy = ihoy // 60
# hour
hour = ihoy % 24
ihoy = ihoy // 24
# day
day = ihoy % 100
ihoy = ihoy // 100
# month
month = ihoy % 100
ihoy = ihoy // 100
# year
year = ihoy
elif units == 'month as %Y%m.%f':
fmo = mtimes % 1. # month fraction
# month
mtimes -= fmo
month = np.rint(mtimes % 100.).astype(np.int64)
# year
mtimes -= month
year = np.rint(mtimes / 100.).astype(np.int64)
# day of month in microseconds
leap = np.where((((year % 4) == 0) & ((year % 100) != 0)) |
((year % 400) == 0), 1, 0)
dim = np.array([ [-9] + _dayspermonth,
[-9] + _dayspermonth_leap ])
fhoy = dim[(leap, month)] * fmo * 86400000000.
ihoy = np.rint(fhoy).astype(np.int64)
# Done in cftime for issue #187
# ihoy = np.where(ihoy%1000000 == 1,
# np.floor(fhoy).astype(np.int64), ihoy)
# ihoy = np.where(ihoy%1000000 == 999999,
# np.ceil(fhoy).astype(np.int64), ihoy)
# microsecond
msecond = ihoy % 1000000
ihoy = ihoy // 1000000
# second
second = ihoy % 60
ihoy = ihoy // 60
# minute
minute = ihoy % 60
ihoy = ihoy // 60
# hour
hour = ihoy % 24
ihoy = ihoy // 24
# day
day = ihoy
# mtimes = dim[(leap, month)] * fmo
# fdy = mtimes % 1. # day fraction
# mtimes -= fdy
# day = np.rint(mtimes % 100.).astype(np.int64)
# # hour
# secs = fdy * 86400.
# fsecs = secs % 1. # second fraction
# secs = np.rint(secs)
# hour = np.floor(secs / 3600.).astype(np.int64)
# # minute
# secs -= 3600. * hour
# minute = np.floor(secs / 60.).astype(np.int64)
# # second
# secs -= 60. * minute
# second = np.rint(secs).astype(np.int64)
# # millisecond
# msecond = np.rint(fsecs * 1000000.).astype(np.int64)
elif units == 'year as %Y.%f':
# same as decimal date
year, month, day, hour, minute, second, msecond = _decimal2date(
times, 'decimal')
else:
raise ValueError(f'Unknown absolute units: {units}')
return year, month, day, hour, minute, second, msecond
#
# Main routines and class
#
[docs]
def date2num(dates, units='', calendar=None, has_year_zero=None,
format='', timesep=' ', fr=False, return_arrays=False,
ensure_seconds=False):
"""Return numeric time values given datetime objects or strings
The units of the numeric time values are described by the
*units* and *calendar* keywords for CF-conform calendars, i.e.
*standard*, *gregorian*, *julian*, *proleptic_gregorian*,
*360_day*, *365_day*, *366_day*, *noleap*, *all_leap*.
See http://cfconventions.org/cf-conventions/cf-conventions#calendar
These times will be passed to cftime.num2date.
Standard *units* are used for the non-CF-conform calendars
*excel*, *excel1900*, *excel1904*, and
*decimal*, *decimal360*, *decimal365*, *decimal366*,
and given *units* will hence be ignored.
Parameters
----------
dates : datetime instance or str, or array_like of datetime or str
datetime objects or strings with string representations.
datetime objects can either be Python datetime.datetime,
cf.datetime, or pyjams.datetime objects. If *dates* are strings,
then *format* keyword is relevant.
units : str, optional
Units string such as 'seconds since 1900-01-01 00:00:00' or
'day as %Y%m%d.%f'. Standard units corresponding to days after
day 0 of a given calendar will be used if omitted, i.e. assuming
Julian day ordinals.
In the form *time_units since reference_time*,
*time_units* can be days, hours, minutes, seconds, milliseconds,
or microseconds. *reference_time* is the time origin.
*months since* is allowed only for the *360_day* calendar
and *common_years since* is allowed only for the *365_day* calendar.
There are currently only three valid forms for *time_units as format*:
'day as %Y%m%d.%f', 'month as %Y%m.%f', 'year as %Y.%f'. The latter is
the same as 'calendar=decimal'. *calendar='decimal' will be set in
case units *time_units as format*.
calendar : str, optional
One of the supported calendars, i.e. the CF-conform calendars
*standard*, *gregorian*, *julian*, *proleptic_gregorian*,
*360_day*, *365_day*, *366_day*, *noleap*, *all_leap*,
as well as the non-CF-conform calendars
*excel*, *excel1900*, *excel1904*, and
*decimal*, *decimal360*, *decimal365*, *decimal366*.
*standard* will be taken by default, which is a mixed
Julian/Gregorian calendar.
The keyword takes precedence on calendar in datetime objects.
has_year_zero : bool, optional
Astronomical year numbering is used and the year zero exists, if set to
True. If set to False for real-world calendars, then historical year
numbering is used and the year 1 is preceded by year -1 and no year
zero exists.
The defaults are set to conform with CF version 1.9 conventions, i.e.
False for 'standard', 'gregorian', and 'julian', True
for 'proleptic_gregorian' (ISO 8601), and True for the idealized
calendars 'noleap'/'365_day', '360_day', 366_day'/'all_leap'.
Excel calendars *excel*, *excel1900*, *excel1904* start only 1900
or above so *has_year_zero* is always False.
Decimal calendars *decimal*, *decimal360*, *decimal365*, *decimal366*
have always year 0, i.e. *has_year_zero* is True.
format : str, optional
If *dates* are strings, then *format* is the Python
datetime.strftime/strptime format string if given. If empty (default),
then the routine pyjams.date2date will be used, which converts between
formats '%Y-%m-%d %H:%M:%S', '%d.%m.%Y %H:%M:%S', and '%m/%d/%Y
%H:%M:%S' (called English *YYYY-MM-DD hh:mm:ss*, standard
*DD.MM.YYYY hh:mm:ss*, and American *MM/DD/YYYY hh:mm:ss* in
pyjams.date2date), where times can be partial or missing.
timesep : str, optional
Separator string between date and time used by pyjams.date2date if
if *dates* are strings and *format* is empty (default: ' ')
fr : bool, optional
If True, pyjams.date2date will interpret input dates with '/'
separators not as the American format '%m/%d/%Y %H:%M:%S' but the
French way as '%d/%m/%Y %H:%M:%S', if *dates* are strings and
*format* is empty
return_arrays : bool, optional
If True, then return a tuple with individual arrays for
year, month, day, hour, minute, second, microsecond
ensure_seconds : bool, optional
If True, add small number (< 20 microseconds) to results to
ensure that back-conversion (`num2date`) gives the same results
up to seconds.
Results should have an accuracy of approximately 1 microseconds.
This is however only possible with 64-bits if the discretization
of the time variable is an integer multiple of the units.
`cftime` introduced the longdouble keyword in `cftime.date2num`
to get microseconds accuracy. The datatype is, however, not available
on all platforms. The algorithm has the tendency to give
1-3 microseconds less at back-conversion in this case, which gives
times such as "11:59:59" instead of "12:00:00". `ensure_seconds`
toggles back-conversion above the next second to ensure that the
seconds are correct.
Returns
-------
array_like
numeric time values
Examples
--------
>>> idates = ['2000-01-05 12:30:15', '1810-04-24 16:15:10',
... '1630-07-15 10:20:40', '1510-09-20 14:35:50',
... '1271-03-18 19:41:34', '0619-08-27 11:08:37',
... '0001-01-01 12:00:00']
>>> decimal = date2num(idates, calendar='decimal')
>>> num2date(decimal, calendar='decimal', format='%Y-%m-%d %H:%M:%S')
['2000-01-05 12:30:15',
'1810-04-24 16:15:10',
'1630-07-15 10:20:40',
'1510-09-20 14:35:50',
'1271-03-18 19:41:34',
'0619-08-27 11:08:37',
'0001-01-01 12:00:00']
"""
date0 = np.ravel(dates)[0]
if isinstance(date0, str):
if format:
iform = format
else:
iform = '%Y-%m-%d %H:%M:%S'
default = cf.real_datetime(1990, 1, 1).strftime(iform)
else:
default = cf.real_datetime(1990, 1, 1)
mdates = input2array(dates, default=default)
# datetime with calendar
date0 = mdates[0]
if calendar is not None:
icalendar = calendar
else:
try:
icalendar = date0.calendar
except AttributeError:
# take standard otherwise
icalendar = 'standard'
if icalendar:
icalendar = icalendar.lower()
else:
icalendar = 'standard'
if (icalendar not in _cfcalendars) and (icalendar not in _noncfcalendars):
raise ValueError(f'Unknown calendar: {icalendar}')
if not units:
units = _units_defaults(icalendar)
# transform strings to datetime objects
# only possible for year > 0
isstr = all([ isinstance(dd, str) for dd in mdates ])
if isstr:
if format:
iform = format
else:
mdates = date2date(mdates, format='en', full=True,
timesep=timesep, fr=fr)
iform = '%Y-%m-%d %H:%M:%S'
mmdates = [ cf.real_datetime.strptime(dd, iform)
for dd in mdates ]
if icalendar in _cfcalendars:
mmdates = [ cf.datetime(*to_tuple(dt), calendar=icalendar,
has_year_zero=has_year_zero)
for dt in mmdates ]
else:
mmdates = [ datetime(*to_tuple(dt), calendar=icalendar,
has_year_zero=has_year_zero)
for dt in mmdates ]
mdates = input2array(mmdates, default=cf.datetime(1990, 1, 1))
else:
mdates = input2array(dates, default=cf.real_datetime(1990, 1, 1))
# if year, month, ... wanted, no need to go further
if return_arrays:
out = np.array([ to_tuple(dt) for dt in mdates ])
year = array2input(out[:, 0], dates)
month = array2input(out[:, 1], dates)
day = array2input(out[:, 2], dates)
hour = array2input(out[:, 3], dates)
minute = array2input(out[:, 4], dates)
second = array2input(out[:, 5], dates)
microsecond = array2input(out[:, 6], dates)
return year, month, day, hour, minute, second, microsecond
# check if we can parse to cftime
if icalendar in _cfcalendars:
iscf = True
elif icalendar in _noncfcalendars:
iscf = False
else: # pragma: no cover
# should be impossible to reach
raise ValueError(f'Unknown calendar: {icalendar}')
unit, sincestr, remainder = _datesplit(units)
if sincestr == 'as':
iscf = False
icalendar = ''
# use cftime.date2num if possible
if iscf:
if not remainder.startswith('-'):
if int(remainder.split('-')[0]) == 0:
if has_year_zero is not None:
has_year_zero = True
if cf.__version__ > '1.6.1':
out = cf.date2num(mdates, units, calendar=icalendar,
has_year_zero=has_year_zero,
longdouble=True)
else:
out = cf.date2num(mdates, units, calendar=icalendar,
has_year_zero=has_year_zero)
if sincestr == 'as':
if units not in ['day as %Y%m%d.%f', 'month as %Y%m.%f',
'year as %Y.%f']:
raise ValueError(f'Absolute date format unknown: {units}')
out = _dates2absolute(mdates, units)
# use cftime.date2num with Excel
if icalendar in _excelcalendars:
cfcalendar = 'julian'
cfdates = [ cf.datetime(*to_tuple(dt), calendar=cfcalendar)
for dt in mdates ]
if cf.__version__ > '1.6.1':
out = cf.date2num(cfdates, units, calendar=cfcalendar,
has_year_zero=has_year_zero,
longdouble=True)
else:
out = cf.date2num(cfdates, units, calendar=cfcalendar,
has_year_zero=has_year_zero)
# no cftime.num2date possible
if icalendar in _decimalcalendars:
out = _dates2decimal(mdates, icalendar)
# toggle back-conversion above the second
if ensure_seconds:
out += np.abs(out) * deps
out = array2input(out, dates)
return out
[docs]
def date2dec(*args, **kwargs):
"""
Wrapper for :func:`date2num`
"""
return date2num(*args, **kwargs)
[docs]
def num2date(times, units='', calendar='standard',
only_use_pyjams_datetimes=True,
only_use_cftime_datetimes=True,
only_use_python_datetimes=False,
has_year_zero=None,
format='', return_arrays=False):
"""
Return datetime objects given numeric time values
The units of the numeric time values are described by the
*units* and *calendar* keywords for CF-conform calendars, i.e.
*standard*, *gregorian*, *julian*, *proleptic_gregorian*,
*360_day*, *365_day*, *366_day*, *noleap*, *all_leap*.
See http://cfconventions.org/cf-conventions/cf-conventions#calendar
These times will be passed to cftime.num2date.
Standard *units* are used for the non-CF-conform calendars
*excel*, *excel1900*, *excel1904*, and
*decimal*, *decimal360*, *decimal365*, *decimal366*,
and given *units* will hence be ignored.
Parameters
----------
times : float or array_like
Numeric time values
units : str, optional
Units string such as 'seconds since 1900-01-01 00:00:00' or
'day as %Y%m%d.%f'. Standard units corresponding to days after
day 0 of a given calendar will be used if omitted, i.e. assuming
Julian day ordinals.
In the form *time_units since reference_time*,
*time_units* can be days, hours, minutes, seconds, milliseconds,
or microseconds. *reference_time* is the time origin.
*months since* is allowed only for the *360_day* calendar
and *common_years since* is allowed only for the *365_day* calendar.
There are currently only three valid forms for *time_units as format*:
'day as %Y%m%d.%f', 'month as %Y%m.%f', 'year as %Y.%f'. The latter is
the same as 'calendar=decimal'. *calendar='decimal' will be set in
case units *time_units as format*.
calendar : str, optional
One of the support calendars, i.e. the CF-conform calendars
*standard*, *gregorian*, *julian*, *proleptic_gregorian*,
*360_day*, *365_day*, *366_day*, *noleap*, *all_leap*,
as well as the non-CF-conform calendars
*excel*, *excel1900*, *excel1904*, and
*decimal*, *decimal360*, *decimal365*, *decimal366*.
*standard* will be taken by default, which is a mixed
Julian/Gregorian calendar.
only_use_pyjams_datetimes : bool, optional
pyjams.datetime objects are returned by default.
Only if only_use_pyjams_datetimes is set to False (default: True) and
only_use_cftime_datetimes is set to True then cftime.datetime objects
will be returned where possible.
Only if only_use_pyjams_datetimes and only_use_cftime_datetimes are set
to False and only_use_python_datetimes is set to True then Python
datetime.datetime objects will be returned where possible.
only_use_cftime_datetimes : bool, optional
pyjams.datetime objects are returned by default.
Only if only_use_pyjams_datetimes is set to False and
only_use_cftime_datetimes is set to True (default: False) then
cftime.datetime objects will be returned where possible.
Only if only_use_pyjams_datetimes and only_use_cftime_datetimes are set
to False and only_use_python_datetimes is set to True then Python
datetime.datetime objects will be returned where possible.
only_use_python_datetimes : bool, optional
pyjams.datetime objects are returned by default.
Only if only_use_pyjams_datetimes is set to False and
only_use_cftime_datetimes is set to True then cftime.datetime objects
will be returned where possible.
Only if only_use_pyjams_datetimes and only_use_cftime_datetimes are set
to False and only_use_python_datetimes is set to True (default: False)
then Python datetime.datetime objects will be returned where possible.
has_year_zero : bool, optional
Astronomical year numbering is used and the year zero exists, if set to
True. If set to False for real-world calendars, then historical year
numbering is used and the year 1 is preceded by year -1 and no year
zero exists.
The defaults are set to conform with CF version 1.9 conventions, i.e.
False for 'standard', 'gregorian', and 'julian', True
for 'proleptic_gregorian' (ISO 8601), and True for the idealized
calendars 'noleap'/'365_day', '360_day', 366_day'/'all_leap'.
Excel calendars *excel*, *excel1900*, *excel1904* start only 1900
or above so *has_year_zero* is always False.
Decimal calendars *decimal*, *decimal360*, *decimal365*, *decimal366*
have always year 0, i.e. *has_year_zero* is True.
format : str, optional
If format string is given than a string representation of the
datetime objects will be returned.
return_arrays : bool, optional
If True, then return a tuple with individual arrays for
year, month, day, hour, minute, second, microsecond
Returns
-------
array_like
datetime instances or string representations of datetime objects, or
tuple with individual arrays for year, month, day, hour, minute,
second, microsecond
Examples
--------
>>> idates = ['2000-01-05 12:30:15', '1810-04-24 16:15:10',
... '1630-07-15 10:20:40', '1510-09-20 14:35:50',
... '1271-03-18 19:41:34', '0619-08-27 11:08:37',
... '0001-01-01 12:00:00']
>>> decimal = date2num(idates, calendar='decimal')
>>> num2date(decimal, calendar='decimal', format='%Y-%m-%d %H:%M:%S')
['2000-01-05 12:30:15',
'1810-04-24 16:15:10',
'1630-07-15 10:20:40',
'1510-09-20 14:35:50',
'1271-03-18 19:41:34',
'0619-08-27 11:08:37',
'0001-01-01 12:00:00']
"""
if format and return_arrays:
raise ValueError('Keywords format and return_arrays mutually'
' exclusive')
if calendar:
calendar = calendar.lower()
else:
calendar = 'standard'
# check if we can parse to cftime
if calendar in _cfcalendars:
iscf = True
elif calendar in _noncfcalendars:
iscf = False
else:
raise ValueError(f'Unknown calendar: {calendar}')
if not units:
units = _units_defaults(calendar)
unit, sincestr, remainder = _datesplit(units)
if sincestr == 'as':
iscf = False
calendar = ''
mtimes = input2array(times, default=1)
# use cftime.num2date if possible
if iscf:
if remainder.startswith('-'):
# negative years
only_use_python_datetimes = False
else:
if int(remainder.split('-')[0]) == 0:
# reference year is year 0
only_use_python_datetimes = False
if has_year_zero is not None:
has_year_zero = True
out = cf.num2date(
mtimes, units, calendar=calendar,
only_use_cftime_datetimes=only_use_cftime_datetimes,
only_use_python_datetimes=only_use_python_datetimes,
has_year_zero=has_year_zero)
if only_use_pyjams_datetimes:
out = [ datetime(*to_tuple(dt), calendar=calendar)
for dt in out ]
# cdo absolute time format
if sincestr == 'as':
if units not in ['day as %Y%m%d.%f', 'month as %Y%m.%f',
'year as %Y.%f']:
raise ValueError(f'Absolute date format unknown: {units}')
# old algorithm does decomposition on all array elements
# should try similar to decode_dates_from_array for speed
# where decomposition is done for the first (oldest) element only
# and then timedeltas are added subsequently
year, month, day, hour, minute, second, microsecond = (
_absolute2date(mtimes, units))
# shortcut
if return_arrays:
year = array2input(year, times)
month = array2input(month, times)
day = array2input(day, times)
hour = array2input(hour, times)
minute = array2input(minute, times)
second = array2input(second, times)
microsecond = array2input(microsecond, times)
return year, month, day, hour, minute, second, microsecond
out = np.empty_like(year, dtype=object)
if ( (not only_use_pyjams_datetimes) and
(not only_use_cftime_datetimes) and
only_use_python_datetimes and
(year.min() > 0) ):
for i in range(year.size):
out[i] = cf.real_datetime(year[i], month[i], day[i],
hour[i], minute[i], second[i],
microsecond[i])
elif ( (not only_use_pyjams_datetimes) and
only_use_cftime_datetimes ):
for i in range(year.size):
out[i] = cf.datetime(year[i], month[i], day[i],
hour[i], minute[i], second[i],
microsecond[i])
else:
for i in range(year.size):
out[i] = datetime(year[i], month[i], day[i],
hour[i], minute[i], second[i],
microsecond[i],
calendar='decimal',
has_year_zero=has_year_zero)
# use cftime.num2date for Excel but return pyjams.datetime
if calendar in _excelcalendars:
cfcalendar = 'julian'
cfunits = _units_defaults(calendar)
cfdates = cf.num2date(
mtimes, cfunits, calendar=cfcalendar,
only_use_cftime_datetimes=only_use_cftime_datetimes,
only_use_python_datetimes=False,
has_year_zero=has_year_zero)
# shortcut
if format:
# Assure 4 digit years on all platforms
# see https://github.com/python/cpython/issues/76376
iform = format
if '%Y' in format:
format04 = format.replace('%Y', '%04Y')
try:
dttest = cfdates[0].strftime(format04)
if '4Y' in dttest:
iform = format
else:
iform = format04
except ValueError:
iform = format
out = [ dt.strftime(iform) for dt in cfdates ]
out = array2input(out, times)
return out
out = [ datetime(*to_tuple(dt), calendar=calendar)
for dt in cfdates ]
# no cftime.num2date possible
if calendar in _decimalcalendars:
# old algorithm does decomposition on all array elements
# should try similar to decode_dates_from_array for speed
# where decomposition is done for the first (oldest) element only
# and then timedeltas are added subsequently
year, month, day, hour, minute, second, microsecond = (
_decimal2date(mtimes, calendar))
# shortcut
if return_arrays:
year = array2input(year, times)
month = array2input(month, times)
day = array2input(day, times)
hour = array2input(hour, times)
minute = array2input(minute, times)
second = array2input(second, times)
microsecond = array2input(microsecond, times)
return year, month, day, hour, minute, second, microsecond
out = np.empty_like(year, dtype=object)
if ( (not only_use_pyjams_datetimes) and
(not only_use_cftime_datetimes) and
only_use_python_datetimes and (year.min() > 0) ):
for i in range(year.size):
out[i] = cf.real_datetime(year[i], month[i], day[i],
hour[i], minute[i], second[i],
microsecond[i])
elif ( (not only_use_pyjams_datetimes) and
only_use_cftime_datetimes ):
for i in range(year.size):
out[i] = cf.datetime(year[i], month[i], day[i],
hour[i], minute[i], second[i],
microsecond[i])
else:
for i in range(year.size):
out[i] = datetime(year[i], month[i], day[i],
hour[i], minute[i], second[i],
microsecond[i],
calendar=calendar,
has_year_zero=has_year_zero)
if return_arrays:
out = np.array([ to_tuple(dt) for dt in out ])
year = array2input(out[:, 0], times)
month = array2input(out[:, 1], times)
day = array2input(out[:, 2], times)
hour = array2input(out[:, 3], times)
minute = array2input(out[:, 4], times)
second = array2input(out[:, 5], times)
microsecond = array2input(out[:, 6], times)
return year, month, day, hour, minute, second, microsecond
else:
if format:
# Assure 4 digit years on all platforms
# see https://github.com/python/cpython/issues/76376
iform = format
if '%Y' in format:
years0 = [ dd.year < 0 for dd in out ]
if any(years0):
y4 = '%05Y'
else:
y4 = '%04Y'
format04 = format.replace('%Y', y4)
try:
dttest = out[0].strftime(format04)
if ('4Y' in dttest) or ('5Y' in dttest):
iform = format
else:
iform = format04
except ValueError:
iform = format
out = [ dt.strftime(iform) for dt in out ]
out = array2input(out, times)
return out
[docs]
def dec2date(*args, **kwargs):
"""
Wrapper for :func:`num2date`
"""
return num2date(*args, **kwargs)
# Could not inherit from cf.datetime because all methods are read-only,
# so had to re-code all methods, even identical ones
[docs]
class datetime(object):
"""
This class mimics cftime.datetime but for non-CF-conform calendars
The cftime.datetime class mimics itself datetime.datetime but
supports calendars other than the proleptic Gregorian calendar.
This class supports timedelta operations by overloading +/-, and
comparisons with other instances using the same calendar.
Current supported calendars are *excel*, *excel1900*, *excel1904*,
and *decimal*, *decimal360*, *decimal365*, *decimal366*. Excel
calendars are supposedly Julian calendars with other reference dates.
Day 0 of *excel*/*excel1900* starts at 1899-12-31 00:00:00 and day 0
of *excel1904* (old Lotus date) starts at 1903-12-31 00:00:00.
Decimal calendars have the form "%Y.%f", where the fractional year
assumes leap years as the proleptic Gregorian calendar, or fixed 360,
365, or 366 days per year.
If the has_year_zero keyword argument is set to True, astronomical year
numbering is used and the year zero exists, which is the default for the
decimal calendars. The keyword will be ignored for Excel calendars.
The class has the methods isoformat, strftime, timetuple, replace,
dayofwk, dayofyr, daysinmonth, __repr__, __format__, __add__, __sub__,
__str__, and comparison methods.
The default format of the string produced by strftime is controlled by
self.format (default %Y-%m-%d %H:%M:%S).
"""
# Python's datetime.datetime uses the proleptic Gregorian
# calendar. This boolean is used to decide whether a
# cftime.datetime instance can be converted to
# datetime.datetime.
def __init__(self, year, month, day,
hour=0, minute=0, second=0, microsecond=0,
dayofwk=-1, dayofyr=-1,
calendar='decimal', has_year_zero=None):
"""
Initialise new datetime instance
"""
self.year = year
self.month = month
self.day = day
self.hour = hour
self.minute = minute
self.second = second
self.microsecond = microsecond
self._dayofwk = dayofwk
self._dayofyr = dayofyr
self.tzinfo = None
self.cf = None
if calendar:
self.calendar = calendar.lower()
else:
self.calendar = 'decimal'
# if self.calendar in _cfcalendars:
# raise ValueError(f'Use cftime.datetime for CF-conform'
# f' calendars: {self.calendar}')
if has_year_zero is None:
self.has_year_zero = _year_zero_defaults(self.calendar)
else:
self.has_year_zero = has_year_zero
# if self.calendar and (self.calendar not in _noncfcalendars):
# raise ValueError(f'Unknown calendar: {self.calendar}')
if ( self.calendar and (self.calendar not in _cfcalendars) and
(self.calendar not in _noncfcalendars) ):
raise ValueError(f'Unknown calendar: {self.calendar}')
if self.calendar in _cfcalendars:
self.cf = cf.datetime(self.year, self.month, self.day,
self.hour, self.minute, self.second,
self.microsecond,
calendar=self.calendar,
has_year_zero=self.has_year_zero)
self.datetime_compatible = self.cf.datetime_compatible
elif self.calendar in _excelcalendars:
self.cf = cf.datetime(self.year, self.month, self.day,
self.hour, self.minute, self.second,
self.microsecond,
calendar='julian',
has_year_zero=self.has_year_zero)
self.datetime_compatible = self.cf.datetime_compatible
else:
self.datetime_compatible = False
self.assert_valid_date()
[docs]
def assert_valid_date(self):
"""
Check that datetime is a valid date for given calendar
"""
# year
if not self.has_year_zero:
if self.year == 0:
raise ValueError("Invalid year provided in {0!r}".format(self))
# Comment next block to allow negative days with Excel calendars,
# which does not exist in Excel
# if ( ((self.calendar == 'excel') or (self.calendar == 'excel1900'))
# and self.year < 1900):
# raise ValueError('Year must be >= 1900 for Excel dates')
# if ( (self.calendar == 'excel1904') and self.year < 1904):
# raise ValueError('Year must be >= 1904 for Excel1904 dates')
# month
if (self.month < 1) or (self.month > 12):
raise ValueError("Invalid month provided in {0!r}".format(self))
# day
month_length = _month_lengths(self.year, self.calendar,
self.has_year_zero)
if (self.day < 1) or (self.day > month_length[self.month - 1]):
raise ValueError(
"Invalid day number provided in {0!r}".format(self))
# hour
if (self.hour < 0) or (self.hour > 23):
raise ValueError("Invalid hour provided in {0!r}".format(self))
# minute
if (self.minute < 0) or (self.minute > 59):
raise ValueError("Invalid minute provided in {0!r}".format(self))
# second
if (self.second < 0) or (self.second > 59):
raise ValueError("Invalid second provided in {0!r}".format(self))
# microsecond
if (self.microsecond) < 0 or (self.microsecond > 999999):
raise ValueError(
"Invalid microsecond provided in {0!r}".format(self))
[docs]
def change_calendar(self, calendar, has_year_zero=None):
return NotImplemented
[docs]
def dayofwk(self):
"""
Day of the week
Identical to cftime.datetime
"""
if (self._dayofwk < 0) and self.calendar:
ord0 = 0
if self.calendar == 'decimal':
ord0 = 1721425
jd = self.toordinal() + ord0
dayofwk = (jd + 1) % 7
# convert to ISO 8601 (0 = Monday, 6 = Sunday), like python
# datetime
dayofwk -= 1
if dayofwk == -1:
dayofwk = 6
# cache results for dayofwk
self._dayofwk = dayofwk
return dayofwk
else:
return self._dayofwk
[docs]
def dayofyr(self):
"""
Day of year
"""
if (self._dayofyr < 0) and self.calendar:
if self.calendar == 'decimal360':
# dayofyr = (self.month - 1) * 30 + self.day
dayofyr = _cumdayspermonth_360[self.month - 1] + self.day
else:
if _is_leap(self.year, self.calendar,
has_year_zero=self.has_year_zero):
dayofyr = _cumdayspermonth_leap[self.month - 1] + self.day
else:
dayofyr = _cumdayspermonth[self.month - 1] + self.day
# cache results for dayofyr
self._dayofyr = dayofyr
return dayofyr
else:
return self._dayofyr
[docs]
def daysinmonth(self):
"""
Number of days in current month
"""
if self.calendar == 'decimal360':
# return 30
return _dayspermonth_360[self.month - 1]
else:
if _is_leap(self.year, self.calendar,
has_year_zero=self.has_year_zero):
return _dayspermonth_leap[self.month - 1]
else:
return _dayspermonth[self.month - 1]
[docs]
def fromordinal(jday, calendar='decimal', has_year_zero=None):
return NotImplemented
[docs]
def replace(self, **kwargs):
"""
Return datetime with new specified fields
Identical to cftime.datetime
"""
args = {"year": self.year,
"month": self.month,
"day": self.day,
"hour": self.hour,
"minute": self.minute,
"second": self.second,
"microsecond": self.microsecond,
"has_year_zero": self.has_year_zero,
"calendar": self.calendar}
if 'dayofyr' in kwargs or 'dayofwk' in kwargs:
raise ValueError('Replacing the dayofyr or dayofwk of a datetime'
' is not supported.')
if 'calendar' in kwargs:
raise ValueError('Replacing the calendar of a datetime is '
'not supported.')
# if attempting to set year to zero, also set has_year_zero=True
# (issue #248)
if 'year' in kwargs:
if (kwargs['year'] == 0) and ('has_year_zero' not in kwargs):
kwargs['has_year_zero'] = True
for name, value in kwargs.items():
args[name] = value
return self.__class__(**args)
[docs]
def round_microseconds(self):
"""
Mathematically round microseconds to nearest second.
"""
iadd = round(self.microsecond / 1000000.)
if iadd:
other = timedelta(seconds=1)
odt = self.__add__(other)
else:
odt = self
odt.microsecond = 0
return odt
[docs]
def strftime(self, format=None):
"""
Return a string representing the date, controlled by an explicit format
string
For a complete list of formatting directives, see section 'strftime()
and strptime() Behavior' in the base Python documentation.
Identical to cftime.datetime
"""
if format is None:
format = self.format()
return _strftime(self, format)
[docs]
def timetuple(self):
"""
Return a time.struct_time such as returned by time.localtime()
The DST flag is -1. d.timetuple() is equivalent to
time.struct_time((d.year, d.month, d.day, d.hour, d.minute,
d.second, d.weekday(), yday, dst)), where yday is the
day number within the current year starting with 1 for January 1st.
Identical to cftime.datetime
"""
return ptime.struct_time((self.year, self.month, self.day, self.hour,
self.minute, self.second, self.dayofwk(),
self.dayofyr(), -1))
[docs]
def toordinal(self, fractional=False):
"""
Julian day (integer) ordinal
Day 0 starts at noon January 1 of the year -4713 for the
Excel calendars.
Day 0 starts at noon on January 1 of the year zero for
the decimal calendars.
If fractional=True, fractional part of day is included (default
False).
"""
ijd = _int_julian_day_from_date(
self.year, self.month, self.day, self.calendar,
has_year_zero=self.has_year_zero)
if fractional:
# At this point ijd is an integer representing noon UTC on the
# given year, month, day.
# Compute fractional day from hour, minute, second, microsecond
fracday = ( self.hour / np.array(24., np.longdouble) +
self.minute / np.array(1440., np.longdouble) +
(self.second +
self.microsecond / (np.array(1.e6, np.longdouble))) /
np.array(86400., np.longdouble) )
return ijd - 0.5 + fracday
else:
return ijd
[docs]
def to_tuple(self):
"""
Turn a datetime instance into a tuple of integers. Elements go
in the order of decreasing significance, making it easy to compare
datetime instances. Parts of the state that don't affect ordering
are omitted.
to_tuple(dt) is identical to (dt.year, dt.month, dt.day,
dt.hour, dt.minute, dt.second, dt.microsecond).
Compare to timetuple().
Identical to cftime.datetime
"""
return (self.year, self.month, self.day, self.hour, self.minute,
self.second, self.microsecond)
def _add_timedelta(self, other):
return self.__add__(other)
def _getstate(self):
"""
return args and kwargs needed to create class instance
Identical to cftime.datetime
"""
args = (self.year, self.month, self.day)
kwargs = {'hour': self.hour,
'minute': self.minute,
'second': self.second,
'microsecond': self.microsecond,
'dayofwk': self._dayofwk,
'dayofyr': self._dayofyr,
'calendar': self.calendar,
'has_year_zero': self.has_year_zero}
return args, kwargs
def _to_real_datetime(self):
"""
Extended Python datetime class
Extra attributes are dayofwk, dayofyr, and daysinmonth.
Identical to cftime.datetime
"""
return cf.real_datetime(self.year, self.month, self.day,
self.hour, self.minute, self.second,
self.microsecond)
def __add__(self, other):
"""
Add timedelta to datetime
"""
if isinstance(self, datetime) and isinstance(other, timedelta):
dt = self
calendar = self.calendar
# has_year_zero = self.has_year_zero
delta = other
elif isinstance(self, timedelta) and isinstance(other, datetime):
dt = other
calendar = other.calendar
# has_year_zero = other.has_year_zero
delta = self
else:
return NotImplemented
# dt = self
# calendar = self.calendar
# has_year_zero = self.has_year_zero
# delta = other
if calendar == 'decimal360':
with warnings.catch_warnings():
warnings.simplefilter("ignore")
cfdt = cf.datetime(*to_tuple(dt), calendar='360_day',
has_year_zero=dt.has_year_zero)
cfdt = cfdt + delta
year, month, day, hour, minute, second, microsecond = (
cfdt.year, cfdt.month, cfdt.day, cfdt.hour, cfdt.minute,
cfdt.second, cfdt.microsecond)
else:
year, month, day, hour, minute, second, microsecond = (
_add_timedelta(dt, delta))
return datetime(year, month, day,
hour, minute, second, microsecond,
calendar=dt.calendar, has_year_zero=dt.has_year_zero)
def __eq__(self, other):
"""
Compare two datetime instances
"""
dt = self
if isinstance(other, (datetime, cf.datetime)):
dt_other = other
# comparing two datetime instances
if ( (dt.calendar == dt_other.calendar) and
(dt.has_year_zero == dt_other.has_year_zero) ):
return to_tuple(dt) == to_tuple(dt_other)
else:
ord1 = 0
if dt.calendar == 'decimal':
ord1 = 1721425
ord2 = 0
if dt_other.calendar == 'decimal': # pragma: no cover
ord2 = 1721425
return (dt.toordinal(fractional=True) + ord1 ==
dt_other.toordinal(fractional=True) + ord2)
else:
return NotImplemented
def __format__(self, format):
"""
Return a string representing the date, controlled by an explicit format
string
For a complete list of formatting directives, see section 'strftime()
and strptime() Behavior' in the base Python documentation.
Identical to cftime.datetime
"""
# the string format "{t_obj}".format(t_obj=t_obj)
# without an explicit format gives an empty string (format='')
# so set this to None to get the default strftime behaviour
if not format:
format = None
return self.strftime(format)
def __hash__(self):
"""
Identical to cftime.datetime
"""
try:
d = self._to_real_datetime()
except ValueError:
return hash(self.timetuple())
return hash(d)
def __reduce__(self):
"""
Special method that allows instance to be pickled
Identical to cftime.datetime
"""
args, kwargs = self._getstate()
date_type = type(self)
return (_create_datetime, (date_type, args, kwargs))
def __repr__(self):
"""
String representation
"""
return ("{0}.{1}({2}, {3}, {4}, {5}, {6}, {7}, {8},"
" calendar={9}, has_year_zero={10})".format(
'pyjams',
self.__class__.__name__,
self.year, self.month, self.day,
self.hour, self.minute, self.second,
self.microsecond, self.calendar, self.has_year_zero))
def __str__(self):
"""
ISO date representation
Identical to cftime.datetime
"""
return self.isoformat(' ')
def __sub__(self, other):
"""
Substract timedelta or datetime from the datetime instance
"""
if isinstance(self, datetime): # left arg is a datetime instance
dt = self
if isinstance(other, datetime):
# datetime - datetime
if dt.calendar != other.calendar:
raise TypeError("Cannot compute the time difference"
" between dates with different calendars")
if dt.calendar == "":
raise TypeError("Cannot compute the time difference"
" between dates that are not"
" calendar-aware")
if dt.has_year_zero != other.has_year_zero:
raise TypeError("Cannot compute the time difference"
" between dates with different year zero"
" conventions")
ord1 = 0
if self.calendar == 'decimal':
ord1 = 1721425
ord2 = 0
if other.calendar == 'decimal':
ord2 = 1721425
ordinal_self = self.toordinal() + ord1 # julian day
ordinal_other = other.toordinal() + ord2
days = ordinal_self - ordinal_other
seconds_self = dt.second + 60 * dt.minute + 3600 * dt.hour
seconds_other = (other.second + 60 * other.minute +
3600 * other.hour)
seconds = seconds_self - seconds_other
microseconds = dt.microsecond - other.microsecond
return timedelta(days, seconds, microseconds)
elif (isinstance(other, datetime_python) or
isinstance(other, cf.real_datetime)):
# datetime - real_datetime
if not dt.datetime_compatible:
msg = ("Cannot compute the time difference between dates"
" with different calendars. One of the datetime"
" objects may have been converted to a native"
" python datetime instance. Try using"
" only_use_cftime_datetimes=True when creating the"
" datetime object.")
raise TypeError(msg)
return dt._to_real_datetime() - other
elif isinstance(other, timedelta):
# datetime - timedelta
if dt.calendar == 'decimal360':
with warnings.catch_warnings():
warnings.simplefilter("ignore")
cfdt = cf.datetime(*to_tuple(dt), calendar='360_day',
has_year_zero=dt.has_year_zero)
cfdt = cfdt - other
year, month, day, hour, minute, second, microsecond = (
cfdt.year, cfdt.month, cfdt.day, cfdt.hour,
cfdt.minute, cfdt.second, cfdt.microsecond)
else:
year, month, day, hour, minute, second, microsecond = (
_add_timedelta(dt, -other))
return datetime(year, month, day,
hour, minute, second, microsecond,
calendar=dt.calendar,
has_year_zero=dt.has_year_zero)
else:
return NotImplemented
else:
if ( isinstance(self, datetime_python) or
isinstance(self, cf.real_datetime) ):
# real_datetime - datetime
if not other.datetime_compatible:
msg = ("Cannot compute the time difference between dates"
" with different calendars. One of the datetime"
" objects may have been converted to a native"
" python datetime instance. Try using"
" only_use_cftime_datetimes=True when creating the"
" datetime object.")
raise TypeError(msg)
return self - other._to_real_datetime()
else:
return NotImplemented