diff --git a/mpas_analysis/ocean/ocean_modelvsobs.py b/mpas_analysis/ocean/ocean_modelvsobs.py index 750d709f8..3ddaf25ff 100644 --- a/mpas_analysis/ocean/ocean_modelvsobs.py +++ b/mpas_analysis/ocean/ocean_modelvsobs.py @@ -22,7 +22,7 @@ from ..shared.interpolation.interpolate import interp_fields, init_tree from ..shared.constants import constants -from ..shared.io import StreamsFile +from ..shared.io import NameList, StreamsFile from ..shared.io.utility import buildConfigFullPath @@ -50,16 +50,21 @@ def ocn_modelvsobs(config, field, streamMap=None, variableMap=None): # read parameters from config file inDirectory = config.get('input', 'baseDirectory') + namelistFileName = config.get('input', 'oceanNamelistFileName') + namelist = NameList(namelistFileName, path=inDirectory) + streamsFileName = config.get('input', 'oceanStreamsFileName') streams = StreamsFile(streamsFileName, streamsdir=inDirectory) + calendar = namelist.get('config_calendar_type') + # get a list of timeSeriesStats output files from the streams file, # reading only those that are between the start and end dates startDate = config.get('climatology', 'startDate') endDate = config.get('climatology', 'endDate') streamName = streams.find_stream(streamMap['timeSeriesStats']) inputFiles = streams.readpath(streamName, startDate=startDate, - endDate=endDate) + endDate=endDate, calendar=calendar) print 'Reading files {} through {}'.format(inputFiles[0], inputFiles[-1]) plotsDirectory = buildConfigFullPath(config, 'output', 'plotsSubdirectory') diff --git a/mpas_analysis/ocean/ohc_timeseries.py b/mpas_analysis/ocean/ohc_timeseries.py index f4ecbe69b..9f1c4bb18 100644 --- a/mpas_analysis/ocean/ohc_timeseries.py +++ b/mpas_analysis/ocean/ohc_timeseries.py @@ -13,7 +13,8 @@ from ..shared.io import NameList, StreamsFile from ..shared.io.utility import buildConfigFullPath -from ..shared.timekeeping.Date import Date +from ..shared.timekeeping.utility import stringToDatetime, \ + clampToNumpyDatetime64 def ohc_timeseries(config, streamMap=None, variableMap=None): @@ -35,6 +36,16 @@ def ohc_timeseries(config, streamMap=None, variableMap=None): Last Modified: 02/02/2017 """ + inDirectory = config.get('input', 'baseDirectory') + + namelistFileName = config.get('input', 'oceanNamelistFileName') + namelist = NameList(namelistFileName, path=inDirectory) + + streamsFileName = config.get('input', 'oceanStreamsFileName') + streams = StreamsFile(streamsFileName, streamsdir=inDirectory) + + calendar = namelist.get('config_calendar_type') + # read parameters from config file mainRunName = config.get('runs', 'mainRunName') preprocessedReferenceRunName = config.get('runs', @@ -56,14 +67,6 @@ def ohc_timeseries(config, streamMap=None, variableMap=None): regionIndicesToPlot = config.getExpression('timeSeriesOHC', 'regionIndicesToPlot') - inDirectory = config.get('input', 'baseDirectory') - - namelistFileName = config.get('input', 'oceanNamelistFileName') - namelist = NameList(namelistFileName, path=inDirectory) - - streamsFileName = config.get('input', 'oceanStreamsFileName') - streams = StreamsFile(streamsFileName, streamsdir=inDirectory) - # Note: input file, not a mesh file because we need dycore specific fields # such as refBottomDepth and namelist fields such as config_density0, as # well as simulationStartTime, that are not guaranteed to be in the mesh @@ -80,7 +83,7 @@ def ohc_timeseries(config, streamMap=None, variableMap=None): endDate = config.get('timeSeries', 'endDate') streamName = streams.find_stream(streamMap['timeSeriesStats']) inFiles = streams.readpath(streamName, startDate=startDate, - endDate=endDate) + endDate=endDate, calendar=calendar) print 'Reading files {} through {}'.format(inFiles[0], inFiles[-1]) # Define/read in general variables @@ -91,7 +94,7 @@ def ohc_timeseries(config, streamMap=None, variableMap=None): # simulation start time simulationStartTime = netCDF4.chartostring( ncFile.variables['simulationStartTime'][:]) - simulationStartTime = str(simulationStartTime) + simulationStartTime = str(simulationStartTime).strip() ncFile.close() # specific heat [J/(kg*degC)] cp = namelist.getfloat('config_specific_heat_sea_water') @@ -120,22 +123,21 @@ def ohc_timeseries(config, streamMap=None, variableMap=None): ds = remove_repeated_time_index(ds) - # convert the start and end dates to datetime objects using - # the Date class, which ensures the results are within the - # supported range - timeStart = Date(startDate).to_datetime(yearOffset) - timeEnd = Date(endDate).to_datetime(yearOffset) + timeStart = clampToNumpyDatetime64(stringToDatetime(startDate), yearOffset) + timeEnd = clampToNumpyDatetime64(stringToDatetime(endDate), yearOffset) # select only the data in the specified range of years ds = ds.sel(Time=slice(timeStart, timeEnd)) # Select year-1 data and average it (for later computing anomalies) - timeStartFirstYear = Date(simulationStartTime).to_datetime(yearOffset) + timeStartFirstYear = clampToNumpyDatetime64( + stringToDatetime(simulationStartTime), yearOffset) if timeStartFirstYear < timeStart: startDateFirstYear = simulationStartTime endDateFirstYear = '{}-12-31_23:59:59'.format(startDateFirstYear[0:4]) filesFirstYear = streams.readpath(streamName, startDate=startDateFirstYear, - endDate=endDateFirstYear) + endDate=endDateFirstYear, + calendar=calendar) dsFirstYear = xr.open_mfdataset( filesFirstYear, preprocess=lambda x: preprocess_mpas(x, diff --git a/mpas_analysis/ocean/sst_timeseries.py b/mpas_analysis/ocean/sst_timeseries.py index 7bc6235a0..c8ecbe90a 100644 --- a/mpas_analysis/ocean/sst_timeseries.py +++ b/mpas_analysis/ocean/sst_timeseries.py @@ -7,10 +7,11 @@ from ..shared.plot.plotting import timeseries_analysis_plot -from ..shared.io import StreamsFile +from ..shared.io import NameList, StreamsFile from ..shared.io.utility import buildConfigFullPath -from ..shared.timekeeping.Date import Date +from ..shared.timekeeping.utility import stringToDatetime, \ + clampToNumpyDatetime64 def sst_timeseries(config, streamMap=None, variableMap=None): @@ -39,13 +40,18 @@ def sst_timeseries(config, streamMap=None, variableMap=None): streamsFileName = config.get('input', 'oceanStreamsFileName') streams = StreamsFile(streamsFileName, streamsdir=inDirectory) + namelistFileName = config.get('input', 'oceanNamelistFileName') + namelist = NameList(namelistFileName, path=inDirectory) + + calendar = namelist.get('config_calendar_type') + # get a list of timeSeriesStats output files from the streams file, # reading only those that are between the start and end dates startDate = config.get('timeSeries', 'startDate') endDate = config.get('timeSeries', 'endDate') streamName = streams.find_stream(streamMap['timeSeriesStats']) inFiles = streams.readpath(streamName, startDate=startDate, - endDate=endDate) + endDate=endDate, calendar=calendar) print 'Reading files {} through {}'.format(inFiles[0], inFiles[-1]) mainRunName = config.get('runs', 'mainRunName') @@ -75,11 +81,8 @@ def sst_timeseries(config, streamMap=None, variableMap=None): varmap=variableMap)) ds = remove_repeated_time_index(ds) - # convert the start and end dates to datetime objects using - # the Date class, which ensures the results are within the - # supported range - timeStart = Date(startDate).to_datetime(yearOffset) - timeEnd = Date(endDate).to_datetime(yearOffset) + timeStart = clampToNumpyDatetime64(stringToDatetime(startDate), yearOffset) + timeEnd = clampToNumpyDatetime64(stringToDatetime(endDate), yearOffset) # select only the data in the specified range of years ds = ds.sel(Time=slice(timeStart, timeEnd)) diff --git a/mpas_analysis/sea_ice/modelvsobs.py b/mpas_analysis/sea_ice/modelvsobs.py index 6f6888d88..4b4403347 100644 --- a/mpas_analysis/sea_ice/modelvsobs.py +++ b/mpas_analysis/sea_ice/modelvsobs.py @@ -15,7 +15,7 @@ remove_repeated_time_index from ..shared.plot.plotting import plot_polar_comparison -from ..shared.io import StreamsFile +from ..shared.io import NameList, StreamsFile from ..shared.io.utility import buildConfigFullPath @@ -40,16 +40,21 @@ def seaice_modelvsobs(config, streamMap=None, variableMap=None): # read parameters from config file inDirectory = config.get('input', 'baseDirectory') + namelistFileName = config.get('input', 'seaIceNamelistFileName') + namelist = NameList(namelistFileName, path=inDirectory) + streamsFileName = config.get('input', 'seaIceStreamsFileName') streams = StreamsFile(streamsFileName, streamsdir=inDirectory) + calendar = namelist.get('config_calendar_type') + # get a list of timeSeriesStatsMonthly output files from the streams file, # reading only those that are between the start and end dates startDate = config.get('climatology', 'startDate') endDate = config.get('climatology', 'endDate') streamName = streams.find_stream(streamMap['timeSeriesStats']) infiles = streams.readpath(streamName, startDate=startDate, - endDate=endDate) + endDate=endDate, calendar=calendar) print 'Reading files {} through {}'.format(infiles[0], infiles[-1]) plotsDirectory = buildConfigFullPath(config, 'output', 'plotsSubdirectory') diff --git a/mpas_analysis/sea_ice/timeseries.py b/mpas_analysis/sea_ice/timeseries.py index dbce1a860..15355700c 100644 --- a/mpas_analysis/sea_ice/timeseries.py +++ b/mpas_analysis/sea_ice/timeseries.py @@ -8,10 +8,11 @@ from ..shared.plot.plotting import timeseries_analysis_plot -from ..shared.io import StreamsFile +from ..shared.io import NameList, StreamsFile from ..shared.io.utility import buildConfigFullPath -from ..shared.timekeeping.Date import Date +from ..shared.timekeeping.utility import stringToDatetime, \ + clampToNumpyDatetime64 def seaice_timeseries(config, streamMap=None, variableMap=None): @@ -34,16 +35,21 @@ def seaice_timeseries(config, streamMap=None, variableMap=None): # read parameters from config file inDirectory = config.get('input', 'baseDirectory') + namelistFileName = config.get('input', 'seaIceNamelistFileName') + namelist = NameList(namelistFileName, path=inDirectory) + streamsFileName = config.get('input', 'seaIceStreamsFileName') streams = StreamsFile(streamsFileName, streamsdir=inDirectory) + calendar = namelist.get('config_calendar_type') + # get a list of timeSeriesStatsMonthly output files from the streams file, # reading only those that are between the start and end dates startDate = config.get('timeSeries', 'startDate') endDate = config.get('timeSeries', 'endDate') streamName = streams.find_stream(streamMap['timeSeriesStats']) inFiles = streams.readpath(streamName, startDate=startDate, - endDate=endDate) + endDate=endDate, calendar=calendar) print 'Reading files {} through {}'.format(inFiles[0], inFiles[-1]) variableNames = ['iceAreaCell', 'iceVolumeCell'] @@ -113,11 +119,8 @@ def seaice_timeseries(config, streamMap=None, variableMap=None): varmap=variableMap)) ds = remove_repeated_time_index(ds) - # convert the start and end dates to datetime objects using - # the Date class, which ensures the results are within the - # supported range - timeStart = Date(startDate).to_datetime(yearOffset) - timeEnd = Date(endDate).to_datetime(yearOffset) + timeStart = clampToNumpyDatetime64(stringToDatetime(startDate), yearOffset) + timeEnd = clampToNumpyDatetime64(stringToDatetime(endDate), yearOffset) # select only the data in the specified range of years ds = ds.sel(Time=slice(timeStart, timeEnd)) diff --git a/mpas_analysis/shared/io/namelist_streams_interface.py b/mpas_analysis/shared/io/namelist_streams_interface.py index 29d9e9474..b5304ef41 100644 --- a/mpas_analysis/shared/io/namelist_streams_interface.py +++ b/mpas_analysis/shared/io/namelist_streams_interface.py @@ -1,10 +1,15 @@ #!/usr/bin/env python """ -Module of classes / routines to manipulate fortran namelist and streams +Module of classes/routines to manipulate fortran namelist and streams files. +Authors +------- Phillip Wolfram, Xylar Asay-Davis -Last modified: 12/05/2016 + +Last modified +------------- +02/06/2017 """ from lxml import etree @@ -13,7 +18,7 @@ from ..containers import ReadOnlyDict from .utility import paths -from ..timekeeping.Date import Date +from ..timekeeping.utility import stringToDatetime, stringToRelativeDelta def convert_namelist_to_dict(fname, readonly=True): @@ -44,8 +49,13 @@ class NameList: Class for fortran manipulation of namelist files, provides read and write functionality + Authors + ------- Phillip Wolfram, Xylar Asay-Davis - Last modified: 11/02/2016 + + Last modified + ------------- + 02/06/2017 """ # constructor @@ -138,12 +148,48 @@ def read(self, streamname, attribname): return stream.get(attribname) return None - def readpath(self, streamName, startDate=None, endDate=None): + def readpath(self, streamName, startDate=None, endDate=None, + calendar=None): """ - Returns a list of files that match the file template in the - stream streamName with attribute attribName. If the startDate - and/or endDate are supplied, only files on or after the starDate and/or - on or before the endDate are included in the file list. + Given the name of a stream and optionally start and end dates and a + calendar type, returns a list of files that match the file template in + the stream. + + Parameters + ---------- + streamName : string + The name of a stream that produced the files + + startDate, endDate : string or datetime.datetime, optional + String or datetime.datetime objects identifying the beginning + and end dates to be found. + + Note: a buffer of one output interval is subtracted from startDate + and added to endDate because the file date might be the first + or last date contained in the file (or anything in between). + + calendar: {'gregorian', 'gregorian_noleap'}, optional + The name of one of the calendars supported by MPAS cores, and is + required if startDate and/or endDate are supplied + + Returns + ------- + fileList : list + A list of file names produced by the stream that fall between + the startDate and endDate (if supplied) + + Raises + ------ + ValueError + If no files from the stream are found. + + Author + ------ + Xylar Asay-Davis + + Last modified + ------------- + 02/04/2017 """ template = self.read(streamName, 'filename_template') if template is None: @@ -168,8 +214,9 @@ def readpath(self, streamName, startDate=None, endDate=None): fileList = paths(path) if len(fileList) == 0: - raise ValueError("Path {} in streams file {} for '{}' not found.".format( - path, self.fname, streamName)) + raise ValueError( + "Path {} in streams file {} for '{}' not found.".format( + path, self.fname, streamName)) if (startDate is None) and (endDate is None): return fileList @@ -178,16 +225,33 @@ def readpath(self, streamName, startDate=None, endDate=None): if output_interval is None: # There's no file interval, so hard to know what to do # let's put a buffer of a year on each side to be safe - offsetDate = Date(dateString='0001-00-00', isInterval=True) + offsetDate = stringToRelativeDelta(dateString='0001-00-00', + calendar=calendar) else: - offsetDate = Date(dateString=output_interval, isInterval=True) + offsetDate = stringToRelativeDelta(dateString=output_interval, + calendar=calendar) if startDate is not None: # read one extra file before the start date to be on the safe side - startDate = Date(startDate) - offsetDate + if isinstance(startDate, str): + startDate = stringToDatetime(startDate) + try: + startDate -= offsetDate + except (ValueError, OverflowError): + # if the startDate would be out of range after subtracting + # the offset, we'll stick with the starDate as it is + pass + if endDate is not None: # read one extra file after the end date to be on the safe side - endDate = Date(endDate) + offsetDate + if isinstance(endDate, str): + endDate = stringToDatetime(endDate) + try: + endDate += offsetDate + except (ValueError, OverflowError): + # if the endDate would be out of range after adding + # the offset, we'll stick with the endDate as it is + pass # remove any path that's part of the template template = os.path.basename(template) @@ -204,7 +268,7 @@ def readpath(self, streamName, startDate=None, endDate=None): baseName = os.path.basename(fileName) dateEndIndex = len(baseName) - dateEndOffset fileDateString = baseName[dateStartIndex:dateEndIndex] - fileDate = Date(fileDateString) + fileDate = stringToDatetime(fileDateString) add = True if startDate is not None and startDate > fileDate: add = False @@ -241,7 +305,7 @@ def find_stream(self, possibleStreams): for streamName in possibleStreams: if self.has_stream(streamName): return streamName - + raise ValueError('Stream {} not found in streams file {}.'.format( streamName, self.fname)) diff --git a/mpas_analysis/shared/timekeeping/Date.py b/mpas_analysis/shared/timekeeping/Date.py deleted file mode 100644 index b4a751bda..000000000 --- a/mpas_analysis/shared/timekeeping/Date.py +++ /dev/null @@ -1,286 +0,0 @@ -""" - Module for the Date class used to parse and compare dates and times - - Xylar Asay-Davis - Last modified: 11/02/2016 -""" - -import functools -import numpy -import datetime - - -@functools.total_ordering -class Date(object): - """ - Class for representing dates on a 365-day calendar. - Date objects can be created either from a formatted string or - from a number of seconds (mostly intended for internal use). - Date objects can be added to or subtracted from one another and - can be compared with one another. - """ - - # constructor - def __init__(self, dateString=None, isInterval=False, totalSeconds=None, - years=None, months=None, days=None, - hours=None, minutes=None, seconds=None): - """ - creates a new Date object. If the dateString is supplied, it should - have one of the following formats: - YYYY-MM-DD_hh:mm:ss - YYYY-MM-DD_hh.mm.ss - YYYY-MM-DD_SSSSS - DDD_hh:mm:ss - DDD_hh.mm.ss - DDD_SSSSS - hh.mm.ss - hh:mm:ss - YYYY-MM-DD - SSSSS - - isInterval indicates whether the date is an interval (difference - between dates) or a normal (non-interval) date. Intervals mean that - the month and day start with 0, while strings representing non-interval - dates have day and months starting with 1. - - If a dateString is not supplied, totalSeconds can be used to supply - the date as a number of seconds (as a 64-bit integer). - - If neither dateString nor totalSeconds is given, all of years, months, - days, hours, minutes and seconds are required to represent the date. - These argument are intended mostly for internal use. - """ - - self.isInterval = isInterval - if dateString is not None: - self._parseDate(dateString) - elif totalSeconds is not None: - self._secondsToDate(totalSeconds) - else: - if years is None: - raise ValueError('years must be set') - self.years = numpy.int64(years) - if months is None: - raise ValueError('months must be set') - self.months = numpy.int64(months) - if days is None: - raise ValueError('days must be set') - self.days = numpy.int64(days) - if hours is None: - raise ValueError('hours must be set') - self.hours = numpy.int64(hours) - if minutes is None: - raise ValueError('minutes must be set') - self.minutes = numpy.int64(minutes) - if seconds is None: - raise ValueError('seconds must be set') - self.seconds = numpy.int64(seconds) - self._setTotalSeconds() - - def to_datetime(self, yearOffset=0): - """ - Converts the date object to a datetime object. - The yearOffset is added to this date's year, and - the resulting date is clamped to the range supported by - numpy's datetime64[ns], used internally by xarray an - pandas - - Last modified: 11/28/2016 - Author: Xylar Asay-Davis - """ - if self.isInterval: - raise ValueError("self.isInterval == True. Use to_timedelta " - "instead of to_datetime") - - year = numpy.maximum(datetime.MINYEAR, - numpy.minimum(datetime.MAXYEAR, - self.years+yearOffset)) - outDate = datetime.datetime(year=year, month=self.months+1, - day=self.days+1, hour=self.hours, - minute=self.minutes, second=self.seconds) - - minDate = datetime.datetime(year=1678, month=1, day=1, - hour=0, minute=0, second=0) - maxDate = datetime.datetime(year=2262, month=1, day=1, - hour=0, minute=0, second=0) - outDate = max(minDate, min(maxDate, outDate)) - return outDate - - def to_timedelta(self): - """ - Converts the date object to a timedelta object - - Last modified: 11/28/2016 - Author: Xylar Asay-Davis - """ - if not self.isInterval: - raise ValueError("self.isInterval == False. Use to_datetime " - "instead of to_timedelta") - - days = 365*self.years + self._monthsToDays(self.months) + self.days - return datetime.timedelta(days=days, hours=self.hours, - minutes=self.minutes, seconds=self.seconds) - - def __lt__(self, other): - if self.isInterval != other.isInterval: - raise ValueError('Comparing interval with non-interval Date ' - 'object') - return self.totalSeconds < other.totalSeconds - - def __eq__(self, other): - if self.isInterval != other.isInterval: - raise ValueError('Comparing interval with non-interval Date ' - 'object') - return self.totalSeconds == other.totalSeconds - - def __add__(self, other): - if self.isInterval: - raise ValueError('Attempting to add to an interval Date object') - if not other.isInterval: - raise ValueError('Attempting to add a non-interval Date object') - - seconds = self.seconds + other.seconds - minutes = self.minutes + other.minutes + seconds/60 - seconds %= 60 - hours = self.hours + other.hours + minutes/60 - minutes %= 60 - months = self.months + other.months - years = self.years + other.years + months/12 - months %= 12 - days = (self._monthsToDays(months) + self.days + other.days + hours/24) - years += days/365 - days %= 365 - (months, days) = self._daysToMonthsAndDays(days) - return Date(isInterval=False, years=years, months=months, days=days, - hours=hours, minutes=minutes, seconds=seconds) - - def __sub__(self, other): - if self.isInterval: - raise ValueError('Attempting to subtract from an interval Date ' - 'object') - - isInterval = not other.isInterval - seconds = self.seconds - other.seconds - minutes = self.minutes - other.minutes + seconds/60 - seconds %= 60 - hours = self.hours - other.hours + minutes/60 - minutes %= 60 - months = self.months - other.months - years = self.years - other.years + months/12 - months %= 12 - days = (self._monthsToDays(months) + self.days - other.days + hours/24) - years += days/365 - days %= 365 - (months, days) = self._daysToMonthsAndDays(days) - - return Date(isInterval=isInterval, years=years, months=months, - days=days, hours=hours, minutes=minutes, seconds=seconds) - - def __str__(self): - if self.isInterval: - offset = 0 - else: - offset = 1 - return '{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}'.format( - self.years, self.months+offset, self.days+offset, - self.hours, self.minutes, self.seconds) - - def _diffSeconds(self, other): - return - - def _setTotalSeconds(self): - days = self.years*365 + self._monthsToDays(self.months) + self.days - self.totalSeconds = (((days*24 + self.hours)*60 + self.minutes)*60 + - self.seconds) - - def _monthsToDays(self, months): - daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - days = numpy.int64(0) - for month in range(months): - days += daysInMonth[month] - return days - - def _daysToMonthsAndDays(self, days): - daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - assert(days < 365) - months = numpy.int64(0) - while days > daysInMonth[months]: - days -= daysInMonth[months] - months += 1 - days = numpy.int64(days) - return (months, days) - - def _secondsToDate(self, seconds): - self.totalSeconds = seconds - self.years = numpy.int64(seconds / 31536000) - seconds %= 31536000 - days = numpy.int64(seconds / 86400) - (self.months, self.days) = self._daysToMonthsAndDays(days) - seconds %= 86400 - self.hours = numpy.int64(seconds / 3600) - seconds %= 3600 - self.minutes = numpy.int64(seconds / 60) - seconds %= 60 - self.seconds = seconds - - def _parseDate(self, dateString): - """ - parses a dateString in one of the following formats into - a Date object: - YYYY-MM-DD_hh:mm:ss - YYYY-MM-DD_hh.mm.ss - YYYY-MM-DD_SSSSS - DDD_hh:mm:ss - DDD_hh.mm.ss - DDD_SSSSS - hh.mm.ss - hh:mm:ss - YYYY-MM-DD - YYYY-MM - SSSSS - """ - if self.isInterval: - offset = numpy.int64(0) - else: - offset = numpy.int64(1) - - if '_' in dateString: - ymd, hms = dateString.split('_') - else: - if '-' in dateString: - ymd = dateString - # error can result if dateString = '1990-01' - # assume this means '1990-01-01' - if len(ymd.split('-')) == 2: - ymd += '-01' - hms = '00:00:00' - else: - if self.isInterval: - ymd = '0000-00-00' - else: - ymd = '0000-01-01' - hms = dateString - - if '.' in hms: - hms = hms.replace('.', ':') - - if '-' in ymd: - (self.years, self.months, self.days) \ - = [numpy.int64(sub) for sub in ymd.split('-')] - self.months -= offset - self.days -= offset - else: - self.days = numpy.int64(ymd) - offset - self.years = numpy.int64(0) - self.months = numpy.int64(0) - - if ':' in hms: - (self.hours, self.minutes, self.seconds) \ - = [numpy.int64(sub) for sub in hms.split(':')] - else: - self.seconds = numpy.int64(hms) - self.minutes = numpy.int64(0) - self.hours = numpy.int64(0) - self._setTotalSeconds() - -# vim: foldmethod=marker ai ts=4 sts=4 et sw=4 ft=python diff --git a/mpas_analysis/shared/timekeeping/MpasRelativeDelta.py b/mpas_analysis/shared/timekeeping/MpasRelativeDelta.py new file mode 100644 index 000000000..295b8e50e --- /dev/null +++ b/mpas_analysis/shared/timekeeping/MpasRelativeDelta.py @@ -0,0 +1,155 @@ +import datetime +from dateutil.relativedelta import relativedelta +from calendar import monthrange, isleap + + +class MpasRelativeDelta(relativedelta): + """ + MpasRelativeDelta is a subclass of dateutil.relativedelta for relative time + intervals with different MPAS calendars. + + Only relative intervals (years, months, etc.) are supported and not the + absolute date specifications (year, month, etc.). Addition/subtraction + of datetime.datetime objects or other MpasRelativeDelta (but currently not + datetime.date, datetime.timedelta or other related objects) is supported. + + Author + ------ + Xylar Asay-Davis + + Last Modified + ------------- + 02/09/2017 + """ + + def __init__(self, dt1=None, dt2=None, years=0, months=0, days=0, + hours=0, minutes=0, seconds=0, calendar='gregorian'): + if calendar not in ['gregorian', 'gregorian_noleap']: + raise ValueError('Unsupported MPAs calendar {}'.format(calendar)) + self.calendar = calendar + super(MpasRelativeDelta, self).__init__(dt1=dt1, dt2=dt2, years=years, + months=months, days=days, + hours=hours, minutes=minutes, + seconds=seconds) + + def __add__(self, other): + if not isinstance(other, (datetime.datetime, MpasRelativeDelta)): + return NotImplemented + + if isinstance(other, MpasRelativeDelta): + if self.calendar != other.calendar: + raise ValueError('MpasRelativeDelta objects can only be added ' + 'if their calendars match.') + years = self.years + other.years + months = self.months + other.months + if months > 12: + years += 1 + months -= 12 + elif months < 1: + years -= 1 + months += 12 + + return self.__class__(years=years, + months=months, + days=self.days + other.days, + hours=self.hours + other.hours, + minutes=self.minutes + other.minutes, + seconds=self.seconds + other.seconds, + calendar=self.calendar) + + year = other.year+self.years + + month = other.month + if self.months != 0: + assert 1 <= abs(self.months) <= 12 + month += self.months + if month > 12: + year += 1 + month -= 12 + elif month < 1: + year -= 1 + month += 12 + + if self.calendar == 'gregorian': + daysInMonth = monthrange(year, month)[1] + elif self.calendar == 'gregorian_noleap': + # use year 0001, which is not a leapyear + daysInMonth = monthrange(1, month)[1] + + day = min(daysInMonth, other.day) + repl = {"year": year, "month": month, "day": day} + + days = self.days + if self.calendar == 'gregorian_noleap' and isleap(year): + if month == 2 and day+days >= 29: + # skip forward over the leap day + days += 1 + elif month == 3 and day+days <= 0: + # skip backward over the leap day + days -= 1 + + return (other.replace(**repl) + + datetime.timedelta(days=days, + hours=self.hours, + minutes=self.minutes, + seconds=self.seconds)) + + def __radd__(self, other): + return self.__add__(other) + + def __rsub__(self, other): + return self.__neg__().__add__(other) + + def __sub__(self, other): + if not isinstance(other, MpasRelativeDelta): + return NotImplemented + return self.__add__(other.__neg__()) + + def __neg__(self): + return self.__class__(years=-self.years, + months=-self.months, + days=-self.days, + hours=-self.hours, + minutes=-self.minutes, + seconds=-self.seconds, + calendar=self.calendar) + + def __mul__(self, other): + try: + f = float(other) + except TypeError: + return NotImplemented + + return self.__class__(years=int(self.years * f), + months=int(self.months * f), + days=int(self.days * f), + hours=int(self.hours * f), + minutes=int(self.minutes * f), + seconds=int(self.seconds * f), + calendar=self.calendar) + + __rmul__ = __mul__ + + def __div__(self, other): + try: + reciprocal = 1 / float(other) + except TypeError: + return NotImplemented + + return self.__mul__(reciprocal) + + __truediv__ = __div__ + + def __repr__(self): + outList = [] + for attr in ["years", "months", "days", "leapdays", + "hours", "minutes", "seconds", "microseconds"]: + value = getattr(self, attr) + if value: + outList.append("{attr}={value:+g}".format(attr=attr, + value=value)) + outList.append("calendar='{}'".format(self.calendar)) + return "{classname}({attrs})".format(classname=self.__class__.__name__, + attrs=", ".join(outList)) + +# vim: foldmethod=marker ai ts=4 sts=4 et sw=4 ft=python diff --git a/mpas_analysis/shared/timekeeping/utility.py b/mpas_analysis/shared/timekeeping/utility.py new file mode 100644 index 000000000..beb243419 --- /dev/null +++ b/mpas_analysis/shared/timekeeping/utility.py @@ -0,0 +1,229 @@ +""" +Time keeping utility functions + +Author +------ +Xylar Asay-Davis + +Last Modified +------------- +02/06/2017 +""" + +import datetime +from .MpasRelativeDelta import MpasRelativeDelta + + +def stringToDatetime(dateString): # {{{ + """ + Given a date string and a calendar, returns a `datetime.datetime` + + Parameters + ---------- + dateString : string + A date and time in one of the following formats: + - YYYY-MM-DD hh:mm:ss + - YYYY-MM-DD hh.mm.ss + - YYYY-MM-DD SSSSS + - DDD hh:mm:ss + - DDD hh.mm.ss + - DDD SSSSS + - hh.mm.ss + - hh:mm:ss + - YYYY-MM-DD + - YYYY-MM + - SSSSS + + Note: either underscores or spaces can be used to separate the date + from the time portion of the string. + + Returns + ------- + datetime : A `datetime.datetime` object + + Raises + ------ + ValueError + If an invalid `dateString` is supplied. + + Author + ------ + Xylar Asay-Davis + + Last modified + ------------- + 02/04/2017 + """ + + (year, month, day, hour, minute, second) = \ + _parseDateString(dateString, isInterval=False) + + return datetime.datetime(year=year, month=month, day=day, hour=hour, + minute=minute, second=second) # }}} + + +def stringToRelativeDelta(dateString, calendar='gregorian'): # {{{ + """ + Given a date string and a calendar, returns an instance of + `MpasRelativeDelta` + + Parameters + ---------- + dateString : string + A date and time in one of the following formats: + - YYYY-MM-DD hh:mm:ss + - YYYY-MM-DD hh.mm.ss + - YYYY-MM-DD SSSSS + - DDD hh:mm:ss + - DDD hh.mm.ss + - DDD SSSSS + - hh.mm.ss + - hh:mm:ss + - YYYY-MM-DD + - YYYY-MM + - SSSSS + + Note: either underscores or spaces can be used to separate the date + from the time portion of the string. + + calendar: {'gregorian', 'gregorian_noleap'}, optional + The name of one of the calendars supported by MPAS cores + + Returns + ------- + relativedelta : An `MpasRelativeDelta` object + + Raises + ------ + ValueError + If an invalid `dateString` is supplied. + + Author + ------ + Xylar Asay-Davis + + Last modified + ------------- + 02/04/2017 + """ + + (years, months, days, hours, minutes, seconds) = \ + _parseDateString(dateString, isInterval=True) + + return MpasRelativeDelta(years=years, months=months, days=days, + hours=hours, minutes=minutes, seconds=seconds, + calendar=calendar) + # }}} + + +def clampToNumpyDatetime64(date, yearOffset): + """ + Temporary function for adding an offset year and clamping a datetime to + range supported by `numpy.datetime64`. + """ + + year = date.year + yearOffset + if year < 1678: + return datetime.datetime(year=1678, month=1, day=1, hour=0, + minute=0, second=0) + + if year >= 2262: + return datetime.datetime(year=2262, month=1, day=1, hour=0, + minute=0, second=0) + + return datetime.datetime(year, date.month, date.day, + date.hour, date.minute, date.second) + + +def _parseDateString(dateString, isInterval=False): # {{{ + """ + Given a string containing a date, returns a tuple defining a date of the + form (year, month, day, hour, minute, second) appropriate for constructing + a datetime or timedelta + + Parameters + ---------- + dateString : string + A date and time in one of the followingformats: + - YYYY-MM-DD hh:mm:ss + - YYYY-MM-DD hh.mm.ss + - YYYY-MM-DD SSSSS + - DDD hh:mm:ss + - DDD hh.mm.ss + - DDD SSSSS + - hh.mm.ss + - hh:mm:ss + - YYYY-MM-DD + - YYYY-MM + - SSSSS + + Note: either underscores or spaces can be used to separate the date + from the time portion of the string. + + isInterval : bool, optional + If ``isInterval=True``, the result is appropriate for constructing + a `datetime.timedelta` object rather than a `datetime`. + + Returns + ------- + date : A tuple of (year, month, day, hour, minute, second) + + Raises + ------ + ValueError + If an invalid `dateString` is supplied. + + Author + ------ + Xylar Asay-Davis + + Last modified + ------------- + 02/04/2017 + """ + if isInterval: + offset = 0 + else: + offset = 1 + + # change underscores to spaces so both can be supported + dateString = dateString.replace('_', ' ') + if ' ' in dateString: + ymd, hms = dateString.split(' ') + else: + if '-' in dateString: + ymd = dateString + # error can result if dateString = '1990-01' + # assume this means '1990-01-01' + if len(ymd.split('-')) == 2: + ymd += '-01' + hms = '00:00:00' + else: + if isInterval: + ymd = '0000-00-00' + else: + ymd = '0001-01-01' + hms = dateString + + if '.' in hms: + hms = hms.replace('.', ':') + + if '-' in ymd: + (year, month, day) \ + = [int(sub) for sub in ymd.split('-')] + else: + day = int(ymd) + year = 0 + month = offset + + if ':' in hms: + (hour, minute, second) \ + = [int(sub) for sub in hms.split(':')] + else: + second = int(hms) + minute = 0 + hour = 0 + return (year, month, day, hour, minute, second) # }}} + + +# vim: foldmethod=marker ai ts=4 sts=4 et sw=4 ft=python diff --git a/mpas_analysis/test/test_date.py b/mpas_analysis/test/test_date.py deleted file mode 100644 index 7e9ba267e..000000000 --- a/mpas_analysis/test/test_date.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -Unit test infrastructure for the Date class - -Xylar Asay-Davis -11/02/2016 -""" - -import pytest -import datetime -from mpas_analysis.test import TestCase -from mpas_analysis.shared.timekeeping.Date import Date - - -class TestDate(TestCase): - def test_date(self): - - # test each possible format: - # YYYY-MM-DD_hh:mm:ss - # YYYY-MM-DD_hh.mm.ss - # YYYY-MM-DD_SSSSS - # DDD_hh:mm:ss - # DDD_hh.mm.ss - # DDD_SSSSS - # hh.mm.ss - # hh:mm:ss - # YYYY-MM-DD - # SSSSS - - # test with isInterval == False - # YYYY-MM-DD_hh:mm:ss - date1 = Date(dateString='0001-01-01_00:00:00', isInterval=False) - date2 = Date(years=1, months=0, days=0, hours=0, minutes=0, seconds=0, - isInterval=False) - self.assertEqual(date1, date2) - - # test with isInterval == True - # YYYY-MM-DD_hh:mm:ss - date1 = Date(dateString='0001-00-00_00:00:00', isInterval=True) - date2 = Date(years=1, months=0, days=0, hours=0, minutes=0, seconds=0, - isInterval=True) - self.assertEqual(date1, date2) - - # YYYY-MM-DD_hh.mm.ss - date1 = Date(dateString='0001-01-02_00.01.00', isInterval=False) - date2 = Date(years=1, months=0, days=1, hours=0, minutes=1, seconds=0, - isInterval=False) - self.assertEqual(date1, date2) - - # YYYY-MM-DD_SSSSS - date1 = Date(dateString='0001-01-01_00002', isInterval=False) - date2 = Date(years=1, months=0, days=0, hours=0, minutes=0, seconds=2, - isInterval=False) - self.assertEqual(date1, date2) - - # DDD_hh:mm:ss - date1 = Date(dateString='0001_00:00:01', isInterval=True) - date2 = Date(years=0, months=0, days=1, hours=0, minutes=0, seconds=1, - isInterval=True) - self.assertEqual(date1, date2) - - # DDD_hh.mm.ss - date1 = Date(dateString='0002_01.00.01', isInterval=True) - date2 = Date(years=0, months=0, days=2, hours=1, minutes=0, seconds=1, - isInterval=True) - self.assertEqual(date1, date2) - - # DDD_SSSSS - date1 = Date(dateString='0002_00003', isInterval=True) - date2 = Date(years=0, months=0, days=2, hours=0, minutes=0, seconds=3, - isInterval=True) - self.assertEqual(date1, date2) - - # hh:mm:ss - date1 = Date(dateString='00:00:01', isInterval=False) - date2 = Date(years=0, months=0, days=0, hours=0, minutes=0, seconds=1, - isInterval=False) - self.assertEqual(date1, date2) - - # hh.mm.ss - date1 = Date(dateString='00.00.01', isInterval=True) - date2 = Date(years=0, months=0, days=0, hours=0, minutes=0, seconds=1, - isInterval=True) - self.assertEqual(date1, date2) - - # YYYY-MM-DD - date1 = Date(dateString='0001-01-01', isInterval=False) - date2 = Date(years=1, months=0, days=0, hours=0, minutes=0, seconds=0, - isInterval=False) - self.assertEqual(date1, date2) - - # SSSSS - date1 = Date(dateString='00005', isInterval=True) - date2 = Date(years=0, months=0, days=0, hours=0, minutes=0, seconds=5, - isInterval=True) - self.assertEqual(date1, date2) - - # test operators - date1 = Date(dateString='1992-02-01', isInterval=False) - date2 = Date(dateString='1991-03-01', isInterval=False) - diff = date1-date2 - self.assertEqual(diff, Date(dateString='0000-11-00', isInterval=True)) - self.assertEqual(date1 < date2, False) - self.assertEqual(date2 < date1, True) - self.assertEqual(date1 < date1, False) - - date1 = Date(dateString='1996-01-15', isInterval=False) - date2 = Date(dateString='0005-00-00', isInterval=True) - diff = date1-date2 - self.assertEqual(diff, Date(dateString='1991-01-15', isInterval=False)) - - date1 = Date(dateString='1996-01-15', isInterval=False) - date2 = Date(dateString='0000-02-00', isInterval=True) - diff = date1-date2 - self.assertEqual(diff, Date(dateString='1995-11-15', isInterval=False)) - - date1 = Date(dateString='1996-01-15', isInterval=False) - date2 = Date(dateString='0000-00-20', isInterval=True) - diff = date1-date2 - self.assertEqual(diff, Date(dateString='1995-12-26', isInterval=False)) - - date = Date(dateString='1996-01-15', isInterval=False) - datetime1 = date.to_datetime(yearOffset=0) - datetime2 = datetime.datetime(year=1996, month=1, day=15) - self.assertEqual(datetime1, datetime2) - - date = Date(dateString='0000-00-20', isInterval=True) - timedelta1 = date.to_timedelta() - timedelta2 = datetime.timedelta(days=20) - self.assertEqual(timedelta1, timedelta2) - - date = Date(dateString='0001-01-20', isInterval=True) - timedelta1 = date.to_timedelta() - timedelta2 = datetime.timedelta(days=(365+31+20)) - self.assertEqual(timedelta1, timedelta2) - - # since pandas and xarray use the numpy type 'datetime[ns]`, which - # has a limited range of dates, the date 0001-01-01 gets increased to - # the minimum allowed year boundary, 1678-01-01 to avoid invalid - # dates. - date = Date(dateString='0001-01-01', isInterval=False) - datetime1 = date.to_datetime(yearOffset=0) - datetime2 = datetime.datetime(year=1678, month=1, day=1) - self.assertEqual(datetime1, datetime2) - - date = Date(dateString='0001-01-01', isInterval=False) - datetime1 = date.to_datetime(yearOffset=1849) - datetime2 = datetime.datetime(year=1850, month=1, day=1) - self.assertEqual(datetime1, datetime2) - - # since pandas and xarray use the numpy type 'datetime[ns]`, which - # has a limited range of dates, the date 9999-01-01 gets decreased to - # the maximum allowed year boundary, 2262-01-01 to avoid invalid - # dates. - date = Date(dateString='9999-01-01', isInterval=False) - datetime1 = date.to_datetime(yearOffset=0) - datetime2 = datetime.datetime(year=2262, month=1, day=1) - self.assertEqual(datetime1, datetime2) - -# vim: foldmethod=marker ai ts=4 sts=4 et sw=4 ft=python diff --git a/mpas_analysis/test/test_namelist_streams_interface.py b/mpas_analysis/test/test_namelist_streams_interface.py index 8d5d5c333..0e29c9d2e 100644 --- a/mpas_analysis/test/test_namelist_streams_interface.py +++ b/mpas_analysis/test/test_namelist_streams_interface.py @@ -58,7 +58,8 @@ def test_read_streamsfile(self): files = self.sf.readpath('output', startDate='0001-01-03', - endDate='0001-12-30') + endDate='0001-12-30', + calendar='gregorian_noleap') expectedFiles = [] for date in ['0001-01-02', '0001-02-01']: expectedFiles.append('{}/output/output.{}_00.00.00.nc' @@ -66,7 +67,8 @@ def test_read_streamsfile(self): self.assertEqual(files, expectedFiles) files = self.sf.readpath('output', - startDate='0001-01-03') + startDate='0001-01-03', + calendar='gregorian_noleap') expectedFiles = [] for date in ['0001-01-02', '0001-02-01', '0002-01-01']: expectedFiles.append('{}/output/output.{}_00.00.00.nc' @@ -74,7 +76,8 @@ def test_read_streamsfile(self): self.assertEqual(files, expectedFiles) files = self.sf.readpath('output', - endDate='0001-12-30') + endDate='0001-12-30', + calendar='gregorian_noleap') expectedFiles = [] for date in ['0001-01-01', '0001-01-02', '0001-02-01']: expectedFiles.append('{}/output/output.{}_00.00.00.nc' @@ -83,7 +86,8 @@ def test_read_streamsfile(self): files = self.sf.readpath('restart', startDate='0001-01-01', - endDate='0001-12-31') + endDate='0001-12-31', + calendar='gregorian_noleap') expectedFiles = [] for seconds in ['00010', '00020']: expectedFiles.append('{}/restarts/restart.0001-01-01_{}.nc' @@ -96,7 +100,8 @@ def test_read_streamsfile(self): files = self.sf.readpath('mesh', startDate='0001-01-01', - endDate='0001-12-31') + endDate='0001-12-31', + calendar='gregorian_noleap') expectedFiles = ['{}/mesh.nc'.format(self.sf.streamsdir)] self.assertEqual(files, expectedFiles) diff --git a/mpas_analysis/test/test_timekeeping.py b/mpas_analysis/test/test_timekeeping.py new file mode 100644 index 000000000..6592f29c5 --- /dev/null +++ b/mpas_analysis/test/test_timekeeping.py @@ -0,0 +1,231 @@ +""" +Unit test infrastructure for the Date class + +Author +------ +Xylar Asay-Davis + +Last Modified +------------- +02/09/2017 +""" + +import pytest +import datetime +from mpas_analysis.shared.timekeeping.MpasRelativeDelta \ + import MpasRelativeDelta +from mpas_analysis.test import TestCase +from mpas_analysis.shared.timekeeping.utility import stringToDatetime, \ + stringToRelativeDelta, clampToNumpyDatetime64 + + +class TestTimekeeping(TestCase): + def test_timekeeping(self): + + # test each possible format: + # YYYY-MM-DD_hh:mm:ss + # YYYY-MM-DD_hh.mm.ss + # YYYY-MM-DD_SSSSS + # DDD_hh:mm:ss + # DDD_hh.mm.ss + # DDD_SSSSS + # hh.mm.ss + # hh:mm:ss + # YYYY-MM-DD + # SSSSS + + for calendar in ['gregorian', 'gregorian_noleap']: + # test datetime.datetime + # YYYY-MM-DD_hh:mm:ss + date1 = stringToDatetime('0001-01-01_00:00:00') + date2 = datetime.datetime(year=1, month=1, day=1, hour=0, minute=0, + second=0) + self.assertEqual(date1, date2) + + delta1 = stringToRelativeDelta('0001-00-00_00:00:00', + calendar=calendar) + delta2 = MpasRelativeDelta(years=1, months=0, days=0, hours=0, + minutes=0, seconds=0, calendar=calendar) + self.assertEqual(delta1, delta2) + + # YYYY-MM-DD_hh.mm.ss + date1 = stringToDatetime('0001-01-01_00.00.00') + date2 = datetime.datetime(year=1, month=1, day=1, hour=0, minute=0, + second=0) + self.assertEqual(date1, date2) + + # YYYY-MM-DD_SSSSS + date1 = stringToDatetime('0001-01-01_00002') + date2 = datetime.datetime(year=1, month=1, day=1, hour=0, minute=0, + second=2) + self.assertEqual(date1, date2) + + # DDD_hh:mm:ss + delta1 = stringToRelativeDelta('0001_00:00:01', + calendar=calendar) + delta2 = MpasRelativeDelta(years=0, months=0, days=1, hours=0, + minutes=0, seconds=1, calendar=calendar) + self.assertEqual(delta1, delta2) + + # DDD_hh.mm.ss + delta1 = stringToRelativeDelta('0002_01.00.01', + calendar=calendar) + delta2 = MpasRelativeDelta(years=0, months=0, days=2, hours=1, + minutes=0, seconds=1, calendar=calendar) + self.assertEqual(delta1, delta2) + + # DDD_SSSSS + delta1 = stringToRelativeDelta('0002_00003', + calendar=calendar) + delta2 = MpasRelativeDelta(years=0, months=0, days=2, hours=0, + minutes=0, seconds=3, calendar=calendar) + self.assertEqual(delta1, delta2) + + # hh:mm:ss + date1 = stringToDatetime('00:00:01') + date2 = datetime.datetime(year=1, month=1, day=1, hour=0, minute=0, + second=1) + self.assertEqual(date1, date2) + + # hh.mm.ss + delta1 = stringToRelativeDelta('00.00.01', + calendar=calendar) + delta2 = MpasRelativeDelta(years=0, months=0, days=0, hours=0, + minutes=0, seconds=1, calendar=calendar) + self.assertEqual(delta1, delta2) + + # YYYY-MM-DD + date1 = stringToDatetime('0001-01-01') + date2 = datetime.datetime(year=1, month=1, day=1, hour=0, minute=0, + second=0) + self.assertEqual(date1, date2) + + # SSSSS + delta1 = stringToRelativeDelta('00005', + calendar=calendar) + delta2 = MpasRelativeDelta(years=0, months=0, days=0, hours=0, + minutes=0, seconds=5, calendar=calendar) + self.assertEqual(delta1, delta2) + + date1 = stringToDatetime('1996-01-15') + delta = stringToRelativeDelta('0005-00-00', + calendar=calendar) + date2 = date1-delta + self.assertEqual(date2, stringToDatetime('1991-01-15')) + + date1 = stringToDatetime('1996-01-15') + delta = stringToRelativeDelta('0000-02-00', + calendar=calendar) + date2 = date1-delta + self.assertEqual(date2, stringToDatetime('1995-11-15')) + + date1 = stringToDatetime('1996-01-15') + delta = stringToRelativeDelta('0000-00-20', + calendar=calendar) + date2 = date1-delta + self.assertEqual(date2, stringToDatetime('1995-12-26')) + + # since pandas and xarray use the numpy type 'datetime[ns]`, which + # has a limited range of dates, the date 0001-01-01 gets increased + # to the minimum allowed year boundary, 1678-01-01 to avoid invalid + # dates. + date1 = clampToNumpyDatetime64(stringToDatetime('0001-01-01'), + yearOffset=0) + date2 = datetime.datetime(year=1678, month=1, day=1) + self.assertEqual(date1, date2) + + date1 = clampToNumpyDatetime64(stringToDatetime('0001-01-01'), + yearOffset=1849) + date2 = datetime.datetime(year=1850, month=1, day=1) + self.assertEqual(date1, date2) + + # since pandas and xarray use the numpy type 'datetime[ns]`, which + # has a limited range of dates, the date 9999-01-01 gets decreased + # to the maximum allowed year boundary, 2262-01-01 to avoid invalid + # dates. + date1 = clampToNumpyDatetime64(stringToDatetime('9999-01-01'), + yearOffset=0) + date2 = datetime.datetime(year=2262, month=1, day=1) + self.assertEqual(date1, date2) + + def test_MpasRelativeDeltaOps(self): + # test if the calendars behave as they should close to leap day + # also, test addition and subtraction of the form + # datetime.datetime +/- MpasRelativeDelta above + # both calendars with adding one day + for calendar, expected in zip(['gregorian', 'gregorian_noleap'], + ['2016-02-29', '2016-03-01']): + self.assertEqual(stringToDatetime('2016-02-28') + + stringToRelativeDelta('0000-00-01', + calendar=calendar), + stringToDatetime(expected)) + + # both calendars with subtracting one day + for calendar, expected in zip(['gregorian', 'gregorian_noleap'], + ['2016-02-29', '2016-02-28']): + self.assertEqual(stringToDatetime('2016-03-01') - + stringToRelativeDelta('0000-00-01', + calendar=calendar), + stringToDatetime(expected)) + + # both calendars with adding one month + for calendar, expected in zip(['gregorian', 'gregorian_noleap'], + ['2016-02-29', '2016-02-28']): + self.assertEqual(stringToDatetime('2016-01-31') + + stringToRelativeDelta('0000-01-00', + calendar=calendar), + stringToDatetime(expected)) + + # both calendars with subtracting one month + for calendar, expected in zip(['gregorian', 'gregorian_noleap'], + ['2016-02-29', '2016-02-28']): + self.assertEqual(stringToDatetime('2016-03-31') - + stringToRelativeDelta('0000-01-00', + calendar=calendar), + stringToDatetime(expected)) + + for calendar in ['gregorian', 'gregorian_noleap']: + + delta1 = stringToRelativeDelta('0000-01-00', calendar=calendar) + delta2 = stringToRelativeDelta('0000-00-01', calendar=calendar) + deltaSum = stringToRelativeDelta('0000-01-01', calendar=calendar) + # test MpasRelativeDelta + MpasRelativeDelta + self.assertEqual(delta1 + delta2, deltaSum) + # test MpasRelativeDelta - MpasRelativeDelta + self.assertEqual(deltaSum - delta2, delta1) + + # test MpasRelativeDelta(date1, date2) + date1 = stringToDatetime('0002-02-02') + date2 = stringToDatetime('0001-01-01') + delta = stringToRelativeDelta('0001-01-01', calendar=calendar) + self.assertEqual(MpasRelativeDelta(dt1=date1, dt2=date2, + calendar=calendar), + delta) + + # test MpasRelativeDelta + datetime.datetime (an odd order but + # it's allowed...) + date1 = stringToDatetime('0001-01-01') + delta = stringToRelativeDelta('0001-01-01', calendar=calendar) + date2 = stringToDatetime('0002-02-02') + self.assertEqual(delta + date1, date2) + + # test multiplication/division by scalars + delta1 = stringToRelativeDelta('0001-01-01', calendar=calendar) + delta2 = stringToRelativeDelta('0002-02-02', calendar=calendar) + self.assertEqual(2*delta1, delta2) + self.assertEqual(delta2/2, delta1) + + + # make sure there's an error when we try to add MpasRelativeDeltas + # with different calendars + with self.assertRaisesRegexp(ValueError, + 'MpasRelativeDelta objects can only be ' + 'added if their calendars match.'): + delta1 = stringToRelativeDelta('0000-01-00', + calendar='gregorian') + delta2 = stringToRelativeDelta('0000-00-01', + calendar='gregorian_noleap') + deltaSum = delta1 + delta2 + + +# vim: foldmethod=marker ai ts=4 sts=4 et sw=4 ft=python