"""Utility functions for handling time and date objects in CLISOPS."""
import re
import numpy as np
[docs]
class AnyCalendarDateTime:
"""
A class to represent a datetime that could be of any calendar.
Can add and subtract a day from the input based on MAX_DAY, MIN_DAY, MAX_MONTH and MIN_MONTH
Parameters
----------
year : int
The year of the datetime.
month : int
The month of the datetime (1-12).
day : int
The day of the month (1-31).
hour : int
The hour of the day (0-23).
minute : int
The minute of the hour (0-59).
second : int
The second of the minute (0-59).
"""
MONTH_RANGE = range(1, 13)
# 31 is the maximum number of days in any month in any of the calendars supported by cftime
DAY_RANGE = range(1, 32)
HOUR_RANGE = range(0, 24)
MINUTE_RANGE = range(0, 60)
SECOND_RANGE = range(0, 60)
def __init__(self, year, month, day, hour, minute, second):
"""
Initialize the AnyCalendarDateTime object with year, month, day, hour, minute, and second.
Parameters
----------
year : int
The year of the datetime.
month : int
The month of the datetime (1-12).
day : int
The day of the month (1-31).
hour : int
The hour of the day (0-23).
minute : int
The minute of the hour (0-59).
second : int
The second of the minute (0-59).
Raises
------
ValueError
If any of the input values are out of their respective ranges.
"""
self.year = year
self.month = month
self.validate_input(self.month, "month", self.MONTH_RANGE)
self.day = day
self.validate_input(self.day, "day", self.DAY_RANGE)
self.hour = hour
self.validate_input(self.hour, "hour", self.HOUR_RANGE)
self.minute = minute
self.validate_input(self.minute, "minute", self.MINUTE_RANGE)
self.second = second
self.validate_input(self.second, "second", self.SECOND_RANGE)
def __repr__(self):
"""Print value."""
return self.value
@property
def value(self):
"""
Show calendar value.
Returns
-------
str
A string representation of the datetime in ISO 8601 format.
"""
return f"{self.year}-{self.month:02d}-{self.day:02d}T{self.hour:02d}:{self.minute:02d}:{self.second:02d}"
[docs]
def add_day(self):
"""Add a day to the input datetime."""
self.day += 1
if self.day > self.DAY_RANGE[-1]:
self.month += 1
self.day = 1
if self.month > self.MONTH_RANGE[-1]:
self.year += 1
self.month = self.MONTH_RANGE[0]
[docs]
def sub_day(self):
"""Subtract a day to the input datetime."""
self.day -= 1
if self.day < self.DAY_RANGE[0]:
self.month -= 1
self.day = self.DAY_RANGE[-1]
if self.month < self.MONTH_RANGE[0]:
self.year -= 1
self.month = self.MONTH_RANGE[-1]
[docs]
def str_to_AnyCalendarDateTime(dt: str, defaults: list[int] | None = None):
"""
Given a string representing date/time, return a DateTimeAnyTime object.
String formats should start with Year and go through to Second, but you
can miss out anything from month onwards.
Parameters
----------
dt : str
A string representing a date/time in the format "YYYY-MM-DDTHH:MM:SS" or similar.
defaults : list, optional
A list of default values for year, month, day, hour, minute, and second
if they cannot be parsed from the string.
Returns
-------
AnyCalendarDateTime
An instance of AnyCalendarDateTime initialized with the parsed or default values.
"""
if not dt and not defaults:
raise Exception("Must provide at least the year as argument, or all defaults, to create date time.")
# Start with the most common pattern
regex = re.compile(r"^(\d+)-(\d+)-(\d+)[T ](\d+):(\d+):(\d+)$")
match = regex.match(dt)
if match:
items = match.groups()
else:
# Try a more complex split and build of the time string
if not defaults:
defaults = [-1, 1, 1, 0, 0, 0]
else:
if len(defaults) < 6:
raise Exception("A default value must be provided for year, month, day, hour, minute and second.")
components = re.split("[- T:]", dt.strip("Z"))
# Build a list of time components
items = components + defaults[len(components) :]
return AnyCalendarDateTime(*[int(float(i)) for i in items])
[docs]
def create_time_bounds(ds, freq) -> list[list[AnyCalendarDateTime]]:
"""
Generate time bounds for datasets that have been temporally averaged.
Averaging frequencies supported are yearly, monthly and daily.
Parameters
----------
ds : xarray.Dataset
The dataset containing the time variable.
freq : str
The frequency of the time bounds to create. Options are "year", "month", or "day".
Returns
-------
list of AnyCalendarDateTime
A list of lists containing the start and end datetime objects for each time point in the dataset.
"""
# get datetime class
dt_cls = ds.time.values[0].__class__
if freq == "month":
time_bounds = [
[
dt_cls(tm.year, tm.month, tm.day),
dt_cls(tm.year, tm.month, tm.daysinmonth),
]
for tm in ds.time.values
]
elif freq == "year":
# get number of days in december for calendar
dec_days = dt_cls(2000, 12, 1).daysinmonth
# generate time bounds
time_bounds = [[dt_cls(tm.year, 1, 1), dt_cls(tm.year, 12, dec_days)] for tm in ds.time.values]
elif freq == "day":
time_bounds = [
[
dt_cls(tm.year, tm.month, tm.day, 0, 0, 0),
dt_cls(tm.year, tm.month, tm.day, 23, 59, 59),
]
for tm in ds.time.values
]
else:
raise Exception("Time frequency not supported for creation of time bounds.")
return time_bounds