diff --git a/setup.py b/setup.py index 4ca4fe561..4d0b311fa 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ "xdgenvpy==2.3.5", "pydantic>=1.10.13", "h5netcdf>=0.8.1", + "deprecated", ] setup_requirements = [] diff --git a/src/esm_calendar/esm_calendar.py b/src/esm_calendar/esm_calendar.py index 885b2bd02..c277452f8 100644 --- a/src/esm_calendar/esm_calendar.py +++ b/src/esm_calendar/esm_calendar.py @@ -1,34 +1,114 @@ """ -Module Docstring.,..? +Module for handling date and calendar functionalities in the esm-tools framework. + +This module provides classes and functions to handle various date operations, +including date manipulation, calendar handling with different types of calendars, +and date formatting. + +Classes +------- +DateFormat + Handles date formatting options. +Calendar + Contains various types of calendars and their functionalities. +Date + Handles date operations and manipulations. + +Functions +--------- +find_remaining_minutes(seconds) + Finds the leftover seconds after full minutes are subtracted. +date_range(start_date, stop_date, frequency) + (Deprecated) Yields dates from start_date to stop_date with a given frequency. + +Examples +-------- +Using ``find_remaining_minutes``: + +.. code-block:: python + + >>> find_remaining_minutes(125) + 5 + +Creating a date and formatting it: + +.. code-block:: python + + >>> d = Date("2024-02-16T11:30:00") + >>> print(d) + 2024-02-16T11:30:00 + >>> d.format(form=2) + '2024-02-16T11:30:00' """ -import copy -import logging -import sys +from typing import Generator, List, Optional, Tuple, Union -def find_remaining_minutes(seconds): +from deprecated import deprecated + + +def find_remaining_minutes(seconds: int) -> int: """ - Finds the remaining full minutes of a given number of seconds + Finds the remaining full minutes given a number of seconds. Parameters ---------- seconds : int - The number of seconds to allocate + The number of seconds to evaluate. Returns ------- int - The leftover seconds once new minutes have been filled. + The leftover seconds after full minutes have been allocated. + + Examples + -------- + >>> find_remaining_minutes(125) + 5 """ + if not isinstance(seconds, int): + raise TypeError( + f"You must provide an integer, instead got: {seconds} of type {type(seconds)}" + ) + if seconds < 0: + raise ValueError(f"You must provide a positive integer, instead got {seconds}") return seconds % 60 -# NOTE: This actually kills the docstring, but minutes and seconds are the -# same... +# Alias remaining hours to the same as remaining minutes for now. find_remaining_hours = find_remaining_minutes -def date_range(start_date, stop_date, frequency): +@deprecated(reason="This function is not used anywhere, and will be removed!") +def date_range( + start_date: Union[str, "Date"], + stop_date: Union[str, "Date"], + frequency: Union[str, "Date"], +) -> Generator["Date", None, None]: + """ + Yields dates from start_date to stop_date with a given frequency. + + Parameters + ---------- + start_date : str or Date + The starting date. + stop_date : str or Date + The ending date. + frequency : str or Date + A date representing the frequency interval. + + Yields + ------ + Date + The next date in the range. + + Examples + -------- + >>> for d in date_range("2024-01-01T00:00:00", "2024-01-03T00:00:00", "0000-00-01T00:00:00"): + ... print(d) + 2024-01-01T00:00:00 + 2024-01-02T00:00:00 + 2024-01-03T00:00:00 + """ if isinstance(start_date, str): start_date = Date(start_date) if isinstance(stop_date, str): @@ -41,63 +121,37 @@ def date_range(start_date, stop_date, frequency): current_date += frequency -class Dateformat(object): - datesep = ["", "-", "-", "-", " ", " ", "", "-", "", "", "/"] - timesep = ["", ":", ":", ":", " ", ":", ":", "", "", "", ":"] - dtsep = ["_", "_", "T", " ", " ", " ", "_", "_", "", "_", " "] - - def __init__(self, form=1, printhours=True, printminutes=True, printseconds=True): - self.form = form - self.printseconds = printseconds - self.printminutes = printminutes - self.printhours = printhours - - def __repr__(self): - return ( - "Dateformat(form=%s, printhours=%s, printminutes=%s, printseconds=%s)" - % (self.form, self.printhours, self.printminutes, self.printseconds) - ) - - -class Calendar(object): +class Calendar: """ - Class to contain various types of calendars. + A class to handle various calendar systems. Parameters ---------- - calendar_type : int - The type of calendar to use. - - Supported calendar types: - 0 - no leap years - 1 - proleptic greogrian calendar (default) - ``n`` - equal months of ``n`` days + calendar_type : Union[str, int], optional + The type of calendar to use. Acceptable values: + - "no_leap" or 0: No leap years. + - "gregorian" or 1: Proleptic Gregorian calendar (default). + - Any integer value: Represents a calendar with equal months of given days. Attributes ---------- - timeunits : list of str - A list of accepted time units. - monthnames : list of str - A list of valid month names, using 3 letter English abbreviation. + TIME_UNITS : List[str] + List of accepted time units: years, months, days, hours, minutes, and seconds. + MONTH_NAMES : List[str] + List of month names as 3-letter English abbreviations. Methods ------- - isleapyear(year) - Returns a boolean testing if the given year is a leapyear - - day_in_year(year) - Returns the total number of days in a given year - - day_in_month(year, month) - Returns the total number of days in a given month for a given year - (considering leapyears) + is_leap_year(year: int) -> bool + Returns True if the specified year is a leap year. + days_in_year(year: int) -> int + Returns the total number of days in the specified year. + days_in_month(year: int, month: Union[int, str]) -> int + Returns the number of days in a given month for a given year. """ - timeunits = ["years", "months", "days", "hours", "minutes", "seconds"] - monthnames = [ + TIME_UNITS: List[str] = ["years", "months", "days", "hours", "minutes", "seconds"] + MONTH_NAMES: List[str] = [ "Jan", "Feb", "Mar", @@ -112,819 +166,1134 @@ class Calendar(object): "Dec", ] - def __init__(self, calendar_type=1): + @staticmethod + @deprecated( + reason="Using 'calendar_type = 1' is deprecated, use 'calendar_type = \"gregorian\"' instead." + ) + def _set_gregorian() -> str: + """ + (Deprecated) Returns the Gregorian calendar type. + """ + return "gregorian" + + @staticmethod + @deprecated( + reason="Using 'calendar_type = 0' is deprecated, use 'calendar_type = \"no_leap\"' instead." + ) + def _set_no_leap() -> str: + """ + (Deprecated) Returns the no-leap calendar type. + """ + return "no_leap" + + def __init__(self, calendar_type: Union[str, int] = "gregorian") -> None: + # Support deprecated integer values for backwards compatibility. + if calendar_type == 1: + calendar_type = self._set_gregorian() + if calendar_type == 0: + calendar_type = self._set_no_leap() self.calendar_type = calendar_type - def isleapyear(self, year): + def is_leap_year(self, year: int) -> bool: """ - Checks if a year is a leapyear + Checks if a year is a leap year based on the calendar type. Parameters ---------- year : int - The year to check + The year to evaluate. Returns ------- bool - True if the given year is a leapyear - """ - if self.calendar_type == 1: - if (year % 4) == 0: - if (year % 100) == 0: - if (year % 400) == 0: - leapyear = True - else: - leapyear = False - else: - leapyear = True - else: - leapyear = False - else: - leapyear = False - return leapyear + True if the year is a leap year (for Gregorian), False otherwise. + + Examples + -------- + >>> cal = Calendar("gregorian") + >>> cal.is_leap_year(2024) + True + >>> cal.is_leap_year(1900) + False + """ + if self.calendar_type == "gregorian": + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + # For 'no_leap' and other calendar types, adjust accordingly. + return False - def day_in_year(self, year): + def days_in_year(self, year: int) -> int: """ - Finds total number of days in a year, considering leapyears if the - calendar type allows for them. + Returns the total number of days in a given year. Parameters ---------- year : int - The year to check + The year to evaluate. Returns ------- int - The total number of days for this specific calendar type - """ - if self.calendar_type == 0: - number_of_days = 365 - elif self.calendar_type == 1: - number_of_days = 365 + int(self.isleapyear(year)) - else: - number_of_days = self.calendar_type * 12 - return number_of_days + Total number of days in the year (366 for leap years in the Gregorian calendar). - def day_in_month(self, year, month): + Examples + -------- + >>> cal = Calendar("gregorian") + >>> cal.days_in_year(2024) + 366 + """ + if self.calendar_type == "no_leap": + return 365 + elif self.calendar_type == "gregorian": + return 365 + int(self.is_leap_year(year)) + # For equal-month calendars, assume the provided integer indicates days per month. + return int(self.calendar_type) * 12 + + def days_in_month(self, year: int, month: Union[int, str]) -> int: """ - Finds the number of days in a given month + Returns the number of days in a specific month for a given year. Parameters ---------- year : int - The year to check + The year. month : int or str - The month number or short name. + The month (either as an integer 1-12 or a 3-letter month name). Returns ------- int - The number of days in this month, considering leapyears if needed. + Number of days in the month. Raises ------ - TypeError - Raised when you give an incorrect type for month + ValueError + If the month is invalid. + + Examples + -------- + >>> cal = Calendar("gregorian") + >>> cal.days_in_month(2024, 2) + 29 """ - if isinstance(month, str): - month = month.capitalize() # Clean up possible badly formated month - month = self.monthnames.index(month) + 1 # Remember, python is 0 indexed - # Make sure you gave a month from 1 to 12 - if month > 12: - raise TypeError("You have given an idiotic month, please reconsider") - elif not isinstance(month, int): - raise TypeError( - "You must supply either a str with short month name, or an int!" - ) - if self.calendar_type == 0 or self.calendar_type == 1: + try: + month = self.MONTH_NAMES.index(month.capitalize()) + 1 + except ValueError: + raise ValueError("Invalid month name provided.") + if not 1 <= month <= 12: + raise ValueError("Invalid month. Must be between 1 and 12.") + + if self.calendar_type in ["no_leap", "gregorian"]: if month in [1, 3, 5, 7, 8, 10, 12]: return 31 if month in [4, 6, 9, 11]: return 30 - return 28 + int(self.isleapyear(year)) - # I don't really like this, but if the calendar type is not 1 or 0, it - # is ``n``, with n being the number of days in equal-length months... - return self.calendar_type + return 28 + int(self.is_leap_year(year)) + return int(self.calendar_type) - def __repr__(self): - return "esm_calendar(calendar_type=%s)" % self.calendar_type + def __repr__(self) -> str: + return f"Calendar(calendar_type={self.calendar_type})" - def __str__(self): - if self.calendar_type == 0: - return "esm_calender object with no leap years allowed" - if self.calendar_type == 1: - return "esm_calendar object with allowed leap years" - return ( - "esm_calendar object with equal-length months of %s days" - % self.calendar_type - ) + def __str__(self) -> str: + if self.calendar_type == "no_leap": + return "Calendar object with no leap years allowed" + if self.calendar_type == "gregorian": + return "Calendar object with allowed leap years" + return f"Calendar object with equal-length months of {self.calendar_type} days" -class Date(object): +class DateFormat: """ - A class to contain dates, also compatiable with paleo (negative dates) + Class for handling date formatting configuration. Parameters ---------- - indate : str - The date to use. + form : int + The format code determining the output layout. + print_hours : bool + Whether hours are printed. + print_minutes : bool + Whether minutes are printed. + print_seconds : bool + Whether seconds are printed. + + Attributes + ---------- + datesep : dict + Mapping between format codes and date separators. + dtsep : dict + Mapping between format codes and datetime separators. + timesep : dict + Mapping between format codes and time separators. + + Examples + -------- + Using the date 2001-11-03 12:34:56, the formats would be: + + Format 0: 2001-11-03T12:34:56 + Format 1: 2001-11-03_12:34:56 + Format 2: 2001-11-03T12:34:56 + Format 3: 2001-11-03 12:34:56 + Format 4: 2001 11 03 12 34 56 + Format 5: 2001 11 03 12:34:56 + Format 6: 20011103_12:34:56 + Format 7: 2001-11-03_123456 + Format 8: 20011103 123456 + Format 9: 20011103_123456 + Format 10: 2001/11/03 12:34:56 + """ + + def __init__( + self, form: int, print_hours: bool, print_minutes: bool, print_seconds: bool + ) -> None: + self.form = form + self.print_hours = print_hours + self.print_minutes = print_minutes + self.print_seconds = print_seconds + self.datesep = { + 0: "-", + 1: "-", + 2: "-", + 3: "-", + 4: " ", + 5: " ", + 6: "", + 7: "-", + 8: "", + 9: "", + 10: "/", + } + self.dtsep = { + 0: "T", + 1: "_", + 2: "T", + 3: " ", + 4: " ", + 5: " ", + 6: "_", + 7: "_", + 8: "", + 9: "_", + 10: " ", + } + self.timesep = { + 0: ":", + 1: ":", + 2: ":", + 3: ":", + 4: " ", + 5: ":", + 6: ":", + 7: "", + 8: "", + 9: "", + 10: ":", + } + + def __repr__(self) -> str: + return f"DateFormat(form={self.form}, print_hours={self.print_hours}, print_minutes={self.print_minutes}, print_seconds={self.print_seconds})" + + +class DateDelta: + """ + A class to represent the difference between two dates. + + Attributes + ---------- + years : int + The difference in years. + months : int + The difference in months. + days : int + The difference in days. + hours : int + The difference in hours. + minutes : int + The difference in minutes. + seconds : int + The difference in seconds. + number_leap_years : int + The number of leap years between the two dates. + """ + + def __init__( + self, + years: int, + months: int, + days: int, + hours: int, + minutes: int, + seconds: int, + number_leap_years: int = 0, + ): + self.years = years + self.months = months + self.days = days + self.hours = hours + self.minutes = minutes + self.seconds = seconds + self.number_leap_years = number_leap_years + + # Support for indexing and transforming to tuple + + def _as_tuple(self) -> Tuple[int, int, int, int, int, int]: + """ + Return the date components as a tuple. + + Returns + ------- + tuple + A tuple of (years, months, days, hours, minutes, seconds). + """ + return ( + self.years, + self.months, + self.days, + self.hours, + self.minutes, + self.seconds, + ) + + def __iter__(self): + return iter(self._as_tuple()) + + def __getitem__(self, index: int) -> int: + return self._as_tuple()[index] + + @classmethod + def from_dates( + cls, date1: "Date", date2: "Date", calendar: Optional[Calendar] = None + ) -> "DateDelta": + """ + Create a DateDelta object from two Date objects. + + Parameters + ---------- + date1 : Date + The first Date object. + date2 : Date + The second Date object. + calendar : Calendar, optional + An object that provides methods `days_in_year` and `is_leap_year`. Defaults + to a gregorian calendar. + + Returns + ------- + DateDelta + The difference between the two dates. + """ + if calendar is None: + calendar = Calendar("gregorian") + + # Swap dates if necessary + date1, date2 = (date1, date2) if date1 > date2 else (date2, date1) + diff = date1 - date2 + + # Count the number of leap years between date1 and date2 + leap_years = sum( + 1 + for year in range(date2.year, date1.year + 1) + if calendar.is_leap_year(year) + ) + + return cls(*diff, number_leap_years=leap_years) + + def __repr__(self): + return ( + f"DateDelta(years={self.years}, months={self.months}, days={self.days}, " + f"hours={self.hours}, minutes={self.minutes}, seconds={self.seconds}, " + f"number_leap_years={self.number_leap_years})" + ) - See `pyesm.core.time_control.esm_calendar.Dateformat` for available - formatters. + def accumulate( + self, + start_date: "Date", + end_date: "Date", + calendar: Optional[Calendar] = None, + rtype: type = dict, + ): + """ + Accumulate the differences into total months, days, hours, minutes, and seconds if it + is applied from start_date to end_date. + + Parameters + ---------- + start_date : Date + The starting date. + end_date : Date + The ending date. + calendar : Calendar, optional + An object that provides methods `days_in_year` and `days_in_month`. Defaults + to a gregorian calendar. + rtype : type, optional + The return type. Defaults to dict. - calendar : ~`pyesm.core.time_control.esm_calendar.Calendar`, optional - The type of calendar to use. Defaults to a greogrian proleptic calendar - if nothing is specified. + Returns + ------- + obj: + The accumulated differences, as either dict, list, or tuple. + + Raises + ------ + ValueError + If an unsupported return type is provided. + """ + if calendar is None: + calendar = Calendar("gregorian") + + # Determine the number of days in a year and a month using the start_date + days_in_year = calendar.days_in_year(start_date.year) + days_in_month = calendar.days_in_month(start_date.year, start_date.month) + + total_months = self.years * 12 + self.months + total_days = ( + self.years * days_in_year + + self.months * days_in_month + + self.days + + self.number_leap_years # Adjust for leap years + ) + total_hours = total_days * 24 + self.hours + total_minutes = total_hours * 60 + self.minutes + total_seconds = total_minutes * 60 + self.seconds + + if rtype == dict: + return { + "total_years": self.years, + "total_months": total_months, + "total_days": total_days, + "total_hours": total_hours, + "total_minutes": total_minutes, + "total_seconds": total_seconds, + } + elif rtype == list: + return [ + self.years, + total_months, + total_days, + total_hours, + total_minutes, + total_seconds, + ] + elif rtype == tuple: + return ( + self.years, + total_months, + total_days, + total_hours, + total_minutes, + total_seconds, + ) + else: + raise ValueError(f"Unsupported return type: {rtype}") + + +class Date: + """ + Class for handling dates and times, including support for paleo (negative) dates. + + Parameters + ---------- + indate : Union[str, Date] + A string representation of the date or another Date object. + calendar : Optional[Calendar], optional + Calendar system to use. Defaults to a Gregorian calendar. Attributes ---------- year : int - The year + Year component. month : int - The month + Month component. day : int - The day + Day component. hour : int - The hour + Hour component. minute : int - The minute + Minute component. second : int - The second - _calendar : ~`pyesm.core.time_control.esm_calendar.Calendar` - The type of calendar to use - - Methods - ------- + Second component. + calendar : Calendar + The calendar system. + doy : int + Day of the year. + _date_format : DateFormat + Date formatting configuration. + + Examples + -------- + .. code-block:: python + + >>> d = Date("2024-02-16T11:30:00") + >>> print(d) + 2024-02-16T11:30:00 + >>> d.day_of_year() + 47 """ - def __init__(self, indate, calendar=Calendar()): + def __init__( + self, indate: Union[str, "Date"], calendar: Optional[Calendar] = None + ) -> None: + self.calendar: Calendar = calendar or Calendar("gregorian") + self.year: int = 0 + self.month: int = 0 + self.day: int = 0 + self.hour: int = 0 + self.minute: int = 0 + self.second: int = 0 + self.doy: int = 0 + self._date_format: DateFormat = DateFormat(0, True, True, True) + if isinstance(indate, str): - self._init_from_str(indate, calendar=Calendar()) + self._init_from_str(indate) elif isinstance(indate, Date): - self._init_from_date(indate, calendar=Calendar()) + self._init_from_date(indate) else: raise TypeError( - f"{type(indate)} is not a valid type to initialize a Date object " - "(valid types: str, Date)" + f"{type(indate)} is not valid to initialize a Date object (valid types: str, Date)" ) - def _init_from_str(self, indate, calendar=Calendar()): + def _init_from_str(self, indate: str) -> None: printhours = True printminutes = True printseconds = True - ndate = ["1900", "01", "01", "00", "00", "00"] - ds = "" - ts = "" - if "T" in indate: - indate2 = indate.replace("T", "_") - ts = ":" - else: - indate2 = indate - if "_" in indate2: - date, time = indate2.split("_") - elif ":" in indate2 and not "-" in indate2: - date = "0000-00-00" - time = indate2 + components: List[str] = ["1900", "01", "01", "00", "00", "00"] + time_sep_used = "" + original_format = "T" if "T" in indate else "_" + + working_date = indate.replace("T", "_") if "T" in indate else indate + + if "_" in working_date: + date_part, time_part = working_date.split("_", 1) + time_sep_used = ":" if ":" in time_part else "" + elif ":" in working_date and "-" not in working_date: + date_part = "0000-00-00" + time_part = working_date + time_sep_used = ":" else: - date = indate2 - time = "" - ts = ":" - for index in [3, 4, 5]: - if len(time) == 2: - ndate[index] = time + date_part = working_date + time_part = "" + time_sep_used = ":" + + # Process time components + time = time_part + for idx in [3, 4, 5]: + if len(time) >= 2: + components[idx] = time[:2] time = time[2:] - elif len(time) > 2: - ndate[index] = time[:2] - if len(time) > 2: - time = time[2:] - if time[0] == ":": - time = time[1:] - ts = ":" + if time and time[0] == ":": + time = time[1:] else: - ndate[index] = "00" - if index == 3: + components[idx] = "00" + if idx == 3: printhours = False - elif index == 4: + elif idx == 4: printminutes = False - elif index == 5: + elif idx == 5: printseconds = False - for index in [2, 1]: - ndate[index] = date[-2:] - date = date[:-2] - if len(date) > 0 and date[-1] == "-": - date = date[:-1] - ds = "-" - ndate[0] = date - if ds == "-" and ts == ":": - if "T" not in indate: - form = 1 - else: - form = 2 - elif ds == "-" and ts == "": - form = 7 - elif ds == "" and ts == ":": - form = 6 - elif ds == "" and ts == "": - form = 9 - self.year, self.month, self.day, self.hour, self.minute, self.second = map( - int, ndate - ) - ( - self.syear, - self.smonth, - self.sday, - self.shour, - self.sminute, - self.ssecond, - ) = map(str, ndate) - self._calendar = calendar - self.doy = self.day_of_year() - self.sdoy = str(self.day_of_year()) + # Process date components + date = date_part + date_sep = "-" if "-" in date else "" + for idx in [2, 1]: + if date: + components[idx] = date[-2:] + date = date[:-2] + if date and date[-1] == "-": + date = date[:-1] + components[0] = date if date else components[0] - self._date_format = Dateformat(form, printhours, printminutes, printseconds) + # Set the format based on the original separators + form = self._determine_format(date_sep, time_sep_used, original_format == "T") - def _init_from_date(self, indate, calendar=Calendar()): - self.year, self.month, self.day, self.hour, self.minute, self.second = ( - indate.year, indate.month, indate.day, indate.hour, indate.minute, indate.second + self.year, self.month, self.day, self.hour, self.minute, self.second = map( + int, components ) + self.doy = self.day_of_year() + self._date_format = DateFormat(form, printhours, printminutes, printseconds) - @property - def sdoy(self): - return self.__sdoy + def _determine_format(self, date_sep: str, time_sep: str, has_t: bool) -> int: + """ + Determines a format code based on the separators in the input string. - @sdoy.setter - def sdoy(self, sdoy): - self.__sdoy = str(self.doy) + Parameters + ---------- + date_sep : str + The date separator used. + time_sep : str + The time separator used. + has_t : bool + Flag indicating whether 'T' was in the original string. - @property - def syear(self): - return self.__syear + Returns + ------- + int + The determined format code. + """ + if date_sep == "-" and time_sep == ":": + return 2 if has_t else 1 + elif date_sep == "-" and not time_sep: + return 7 + elif not date_sep and time_sep == ":": + return 6 + return 9 + + def _init_from_date(self, other: "Date") -> None: + """ + Initialize a Date object from another Date instance. - @syear.setter - def syear(self, syear): - self.__syear = str(self.year) + Parameters + ---------- + other : Date + The source Date object. + """ + self.year = other.year + self.month = other.month + self.day = other.day + self.hour = other.hour + self.minute = other.minute + self.second = other.second + self.calendar = other.calendar + self.doy = other.day_of_year() + self._date_format = other._date_format @property - def smonth(self): - return self.__smonth + def syear(self) -> str: + """ + Returns a zero-padded string of the year. - @smonth.setter - def smonth(self, smonth): - self.__smonth = str(self.month).zfill(2) + Returns + ------- + str + The year as a 4-digit string. + """ + return f"{self.year:04d}" @property - def sday(self): - return self.__sday + def sdoy(self) -> str: + """ + Returns a string of the day of the year. - @sday.setter - def sday(self, sday): - self.__sday = str(self.day).zfill(2) + Returns + ------- + str + The day of the year as a 3-digit string. + """ + return f"{self.day_of_year()}" @property - def shour(self): - return self.__shour + def smonth(self) -> str: + """ + Returns a zero-padded string of the month. - @shour.setter - def shour(self, shour): - self.__shour = str(self.hour).zfill(2) + Returns + ------- + str + The month as a 2-digit string. + """ + return f"{self.month:02d}" @property - def sminute(self): - return self.__sminute + def sday(self) -> str: + """ + Returns a zero-padded string of the day. - @sminute.setter - def sminute(self, sminute): - self.__sminute = str(self.minute).zfill(2) + Returns + ------- + str + The day as a 2-digit string. + """ + return f"{self.day:02d}" @property - def ssecond(self): - return self.__ssecond + def shour(self) -> str: + """ + Returns a zero-padded string of the hour. - @ssecond.setter - def ssecond(self, ssecond): - self.__ssecond = str(self.second).zfill(2) + Returns + ------- + str + The hour as a 2-digit string. + """ + return f"{self.hour:02d}" - def output(self, form="SELF"): - return self.format(form) + @property + def sminute(self) -> str: + """ + Returns a zero-padded string of the minute. - @classmethod - def from_list(cls, _list): + Returns + ------- + str + The minute as a 2-digit string. """ - Creates a new Date from a list + return f"{self.minute:02d}" - Parameters - ---------- - _list : list of ints - A list of [year, month, day, hour, minute, second] + @property + def ssecond(self) -> str: + """ + Returns a zero-padded string of the second. Returns ------- - date : ~`pyesm.core.time_control.esm_calendar.Date` - A new date of year month day, hour minute, second + str + The second as a 2-digit string. """ + return f"{self.second:02d}" - negative = ["-" if _list[i] < 0 else "" for i in range(6)] - _list = [str(abs(element)) for element in _list] + def day_of_year(self) -> int: + """ + Compute the day of the year. - if len(_list[0]) < 4: - _list[0] = _list[0].zfill(4) - _list[1:6] = [ - element.zfill(2) if len(element) < 2 else element for element in _list[1:6] - ] + Returns + ------- + int + The day number (1 through 366). - _list = [negative[i] + _list[i] for i in range(6)] + Examples + -------- + >>> d = Date("2024-02-16T00:00:00") + >>> d.day_of_year() + 47 + """ + if self.month == 1: + return self.day - indate = ( - _list[0] - + "-" - + _list[1] - + "-" - + _list[2] - + "_" - + _list[3] - + ":" - + _list[4] - + ":" - + _list[5] - ) + # Sum days in all completed months + days = 0 + for month in range(1, self.month): + days += self.calendar.days_in_month(self.year, month) - return cls(indate) + # Add days in current month + days += self.day - fromlist = from_list + return days - def __repr__(self): - return f"Date({self.year:02}-{self.month:02}-{self.day:02}T{self.hour:02}:{self.minute:02}:{self.second:02})" + def time_between(self, other: "Date", unit: str = "seconds") -> Optional[int]: + """ + Compute the time difference between two dates in a specified unit. - def __getitem__(self, item): - return (self.year, self.month, self.day, self.hour, self.minute, self.second)[ - item - ] + Parameters + ---------- + other : Date + The date to compare against. + unit : str, optional + The unit of time (years, months, days, hours, minutes, seconds). - def __setitem__(self, item, value): - value = int(value) - if item == 0: - self.year = value - elif item == 1: - self.month = value - elif item == 2: - self.day = value - elif item == 3: - self.hour = value - elif item == 4: - self.minute = value - elif item == 5: - self.second = value - else: - raise IndexError("You can only assign up to 5!") - - def __lt__(self, other): - self_tup = ( - self.year, - self.month, - self.day, - self.hour, - self.minute, - self.second, - ) - other_tup = ( - other.year, - other.month, - other.day, - other.hour, - other.minute, - other.second, - ) - return self_tup < other_tup - - def __le__(self, other): - self_tup = ( - self.year, - self.month, - self.day, - self.hour, - self.minute, - self.second, - ) - other_tup = ( - other.year, - other.month, - other.day, - other.hour, - other.minute, - other.second, - ) - return self_tup <= other_tup + Returns + ------- + int: + The time difference in the specified unit. - def __eq__(self, other): - if not isinstance(other, Date): - return False + Raises + ------ + ValueError + If an unsupported time unit is provided. + """ + if unit not in self.calendar.TIME_UNITS: + return None + + diff = self - other if self > other else other - self + + # Convert to the requested unit + if unit == "seconds": + return ( + (diff[0] * 365 + diff[1] * 30 + diff[2]) * 86400 + + diff[3] * 3600 + + diff[4] * 60 + + diff[5] + ) + elif unit == "minutes": + return ( + (diff[0] * 365 + diff[1] * 30 + diff[2]) * 1440 + diff[3] * 60 + diff[4] + ) + elif unit == "hours": + return (diff[0] * 365 + diff[1] * 30 + diff[2]) * 24 + diff[3] + elif unit == "days": + return diff[0] * 365 + diff[1] * 30 + diff[2] + elif unit == "months": + return diff[0] * 12 + diff[1] + elif unit == "years": + return diff[0] + + raise ValueError(f"Unsupported time unit: {unit}") + + def output(self, form="SELF") -> str: + """Shortcut method to nicely print a Date""" + return self.format(form) - self_tup = ( - self.year, - self.month, - self.day, - self.hour, - self.minute, - self.second, - ) - other_tup = ( - other.year, - other.month, - other.day, - other.hour, - other.minute, - other.second, - ) - return self_tup == other_tup + def format( + self, + form: Union[str, int] = "SELF", + print_hours: Optional[bool] = None, + print_minutes: Optional[bool] = None, + print_seconds: Optional[bool] = None, + ) -> str: + """ + Format the Date into a string representation. - def __ne__(self, other): - if not isinstance(other, Date): - return True - - self_tup = ( - self.year, - self.month, - self.day, - self.hour, - self.minute, - self.second, - ) - other_tup = ( - other.year, - other.month, - other.day, - other.hour, - other.minute, - other.second, - ) - return self_tup != other_tup - - def __ge__(self, other): - self_tup = ( - self.year, - self.month, - self.day, - self.hour, - self.minute, - self.second, - ) - other_tup = ( - other.year, - other.month, - other.day, - other.hour, - other.minute, - other.second, - ) - return self_tup >= other_tup - - def __gt__(self, other): - self_tup = ( - self.year, - self.month, - self.day, - self.hour, - self.minute, - self.second, - ) - other_tup = ( - other.year, - other.month, - other.day, - other.hour, - other.minute, - other.second, - ) - return self_tup > other_tup + Parameters + ---------- + form : Union[str, int] + Format style ("SELF" to use the original format or an int code, e.g. 0-10). + print_hours : bool, optional + Override printing of hours. + print_minutes : bool, optional + Override printing of minutes. + print_seconds : bool, optional + Override printing of seconds. - def __sub__(self, other): - if isinstance(other, Date): - return self.sub_date(other) - elif isinstance(other, tuple): - return self.sub_tuple(other) - else: - print("No known combination for subtraction") - sys.exit(1) + Returns + ------- + str + The formatted date string. + + Examples + -------- + >>> d = Date("2024-02-16T11:30:00") + >>> d.format(form=2) + '2024-02-16T11:30:00' + >>> d.format(form=5) + '16 Feb 2024 11:30:00' + """ + if form == "SELF": + form = self._date_format.form - def sub_date(self, other): - d2 = copy.deepcopy( - [self.year, self.month, self.day, self.hour, self.minute, self.second] + ph = print_hours if print_hours is not None else self._date_format.print_hours + pm = ( + print_minutes + if print_minutes is not None + else self._date_format.print_minutes ) - d1 = copy.deepcopy( - [other.year, other.month, other.day, other.hour, other.minute, other.second] + ps = ( + print_seconds + if print_seconds is not None + else self._date_format.print_seconds ) - diff = [0, 0, 0, 0, 0, 0] - - while d1[1] > 1: - diff[1] -= 1 - d1[1] -= 1 - diff[2] -= self._calendar.day_in_month(d1[0], d1[1]) - - while d2[1] > 1: - diff[1] += 1 - d2[1] -= 1 - diff[2] += self._calendar.day_in_month(d2[0], d2[1]) - - while d1[2] > 1: - diff[2] -= 1 - d1[2] -= 1 - - while d2[2] > 1: - diff[2] += 1 - d2[2] -= 1 - - if diff[1] < 0: - diff[0] = diff[0] - 1 + components = [ + str(self.year), + self.smonth, + self.sday, + self.shour, + self.sminute, + self.ssecond, + ] - while d2[0] > d1[0]: - diff[0] += 1 - diff[1] += 12 - diff[2] += self._calendar.day_in_year(d1[0]) - d1[0] += 1 + if form == 0 and len(components[0]) < 4: + components[0] = components[0].zfill(4) + elif form == 5: + # Swap day and year, use month name. + components[0], components[2] = components[2], components[0] + components[1] = self.calendar.MONTH_NAMES[int(components[1]) - 1] + elif form == 10: + # Rotate date components. + components[0], components[1], components[2] = ( + components[1], + components[2], + components[0], + ) - diff[3] += diff[2] * 24 - if diff[3] < 0: - diff[3] += 24 - diff[4] += diff[3] * 60 - if diff[4] < 0: - diff[4] += 60 - diff[5] += diff[4] * 60 - if diff[5] < 0: - diff[5] += 60 + date_sep = self._date_format.datesep.get(form, "") + dt_sep = self._date_format.dtsep.get(form, "") + time_sep = self._date_format.timesep.get(form, "") - return diff + result = components[0] + date_sep + components[1] + date_sep + components[2] + if ph: + result += dt_sep + components[3] + if pm: + result += time_sep + components[4] + if ps: + result += time_sep + components[5] + return result - def time_between(self, date, outformat="seconds"): + def __str__(self) -> str: """ - Computes the time between two dates - - Parameters - ---------- - date : ~`pyesm.core.time_control.date` - The date to compare against. + Return the ISO-like string representation of the Date. Returns ------- - ?? + str + A string in the form 'YYYY-MM-DDTHH:MM:SS'. """ - if date > self: - diff = date - self - else: - diff = self - date + return f"{self.year:04d}-{self.month:02d}-{self.day:02d}T{self.hour:02d}:{self.minute:02d}:{self.second:02d}" - for index in range(0, 6): - if outformat == self._calendar.timeunits[index]: - # FIXME: Wouldn't this stop after the very first index that matches? - # I think that is the point, but I'm not sure. - return diff[index] - return None # ...? Or raise an error? + def __repr__(self) -> str: + return f"Date({self.format()})" - def day_of_year(self): + def _as_tuple(self) -> Tuple[int, int, int, int, int, int]: """ - Gets the day of the year, counting from Jan. 1 + Return the date components as a tuple. Returns ------- - int - The day of the current year. + tuple + A tuple of (year, month, day, hour, minute, second). """ - if self[1] == self[2] == 1: - return 1 - else: - date2 = Date(str(self[0]) + "-01-01T00:00:00", self._calendar) - return self.time_between(date2, "days") + 1 + return (self.year, self.month, self.day, self.hour, self.minute, self.second) - def __str__(self): - return ( - "-".join([str(self.year), str(self.month).zfill(2), str(self.day).zfill(2)]) - + "T" - + ":".join( - [ - str(self.hour).zfill(2), - str(self.minute).zfill(2), - str(self.second).zfill(2), - ] - ) - ) + def __getitem__(self, index: int) -> int: + return self._as_tuple()[index] - def format( - self, form="SELF", givenph=None, givenpm=None, givenps=None - ): # basically format_date - """ - Beautifully returns a ``Date`` object as a string. + def __setitem__(self, index: int, value: int) -> None: + if not 0 <= index <= 5: + raise IndexError("Index must be between 0 and 5") + attrs = ["year", "month", "day", "hour", "minute", "second"] + setattr(self, attrs[index], int(value)) - Parameters - ---------- - form : str or int - Logic taken from from MPI-Met - givenph : bool-ish - Print hours - givenpm : bool-ish - Print minutes - givenps : bool-ish - Print seconds + def __lt__(self, other: "Date") -> bool: + return self._as_tuple() < other._as_tuple() - Note - ---- - **How to use the ``form`` argument** - The following forms are accepted: - + SELF: uses the format which was given when constructing the date - + 0: A Date formatted as YYYY + def __le__(self, other: "Date") -> bool: + return self._as_tuple() <= other._as_tuple() - In [5]: test.format(form=1) - Out[5]: '1850-01-01_00:00:00' + def __eq__(self, other: object) -> bool: + if not isinstance(other, Date): + return False + return self._as_tuple() == other._as_tuple() - In [6]: test.format(form=2) - Out[6]: '1850-01-01T00:00:00' + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) - In [7]: test.format(form=3) - Out[7]: '1850-01-01 00:00:00' + def __gt__(self, other: "Date") -> bool: + return self._as_tuple() > other._as_tuple() - In [8]: test.format(form=4) - Out[8]: '1850 01 01 00 00 00' + def __ge__(self, other: "Date") -> bool: + return self._as_tuple() >= other._as_tuple() - In [9]: test.format(form=5) - Out[9]: '01 Jan 1850 00:00:00' + def __sub__( + self, other: Union["Date", Tuple[int, int, int, int, int, int]] + ) -> List[int]: + """ + Subtract another Date or a tuple of time components from this Date. - In [10]: test.format(form=6) - Out[10]: '18500101_00:00:00' + Parameters + ---------- + other : Date or tuple of int + The Date or tuple to subtract. - In [11]: test.format(form=7) - Out[11]: '1850-01-01_000000' + Returns + ------- + list of int + Differences as [years, months, days, hours, minutes, seconds]. These diffs + represent the cumulative time between the two dates in each of the units. + + Examples + -------- + >>> d1 = Date("2024-02-16T12:30:00") + >>> d2 = Date("2024-02-16T11:30:00") + >>> d1 - d2 + [0, 0, 0, 1, 60, 3600] + """ + if isinstance(other, Date): + return self._sub_date(other) + elif isinstance(other, tuple): + return self._sub_tuple(other) + else: + raise TypeError(f"Unsupported type for subtraction: {type(other)}") - In [12]: test.format(form=8) - Out[12]: '18500101000000' + ################################################################################ + def _sub_date(self, other: "Date") -> List[int]: + """ + Subtract one Date from another component-wise. - In [13]: test.format(form=9) - Out[13]: '18500101_000000' + Parameters + ---------- + other : Date + The Date to subtract from this Date. - In [14]: test.format(form=10) - Out[14]: '01/01/1850 00:00:00' + Returns + ------- + list of int + Differences as [years, months, days, hours, minutes, seconds]. """ - # Programmer notes to not be ever included in the doc-string: Paul really, Really, REALLY - # dislikes this function. It rubs me in the wrong way. - if form == "SELF": - form = self._date_format.form + d1 = list(other._as_tuple()) # The earlier date + d2 = list(self._as_tuple()) # The later date - ph = self._date_format.printhours - pm = self._date_format.printminutes - ps = self._date_format.printseconds - - # These variables are....utterly pointless - if givenph is not None: - ph = givenph - if givenpm is not None: - pm = givenpm - if givenps is not None: - ps = givenps - ndate = list( - map( - str, - ( - self.year, - self.smonth, - self.sday, - self.shour, - self.sminute, - self.ssecond, - ), - ) - ) - if form == 0: - if len(ndate[0]) < 4: - for _ in range(1, 4 - len(ndate[0])): - ndate[0] = "0" + ndate[0] - elif form == 5: - temp = ndate[0] - ndate[0] = ndate[2] - ndate[2] = temp - ndate[1] = self._calendar.monthnames[int(ndate[1]) - 1] - elif form == 8: - if len(ndate[0]) < 4: - print("Format 8 clear with 4 digit year only") - sys.exit(2) - elif form == 10: - temp = ndate[0] - ndate[0] = ndate[1] - ndate[1] = ndate[2] - ndate[2] = temp + # Initialize the difference list + diff = [0, 0, 0, 0, 0, 0] - for index in range(0, 6): - if len(ndate[index]) < 2: - ndate[index] = "0" + ndate[index] + # Handle seconds + if d2[5] < d1[5]: + d2[4] -= 1 + d2[5] += 60 + diff[5] = d2[5] - d1[5] - ndate[1] = self._date_format.datesep[form] + ndate[1] - ndate[2] = self._date_format.datesep[form] + ndate[2] - ndate[3] = self._date_format.dtsep[form] + ndate[3] - ndate[4] = self._date_format.timesep[form] + ndate[4] - ndate[5] = self._date_format.timesep[form] + ndate[5] + # Handle minutes + if d2[4] < d1[4]: + d2[3] -= 1 + d2[4] += 60 + diff[4] = d2[4] - d1[4] - if not ps: - ndate[5] = "" - if not pm and ndate[5] == "": - ndate[4] = "" - if not ph and ndate[4] == "": - ndate[3] = "" + # Handle hours + if d2[3] < d1[3]: + d2[2] -= 1 + d2[3] += 24 + diff[3] = d2[3] - d1[3] - return ndate[0] + ndate[1] + ndate[2] + ndate[3] + ndate[4] + ndate[5] + # Handle days + if d2[2] < d1[2]: + d2[1] -= 1 + if d2[1] == 0: + d2[0] -= 1 + d2[1] = 12 + d2[2] += self.calendar.days_in_month(d2[0], d2[1]) + diff[2] = d2[2] - d1[2] + + # Handle months + if d2[1] < d1[1]: + d2[0] -= 1 + d2[1] += 12 + diff[1] = d2[1] - d1[1] + + # Handle years + diff[0] = d2[0] - d1[0] + + # FIXME(PG): Mimic old behavior with the accumulated differences + return DateDelta(*diff).accumulate( + start_date=other, + end_date=self, + calendar=self.calendar, + rtype=list, + ) - def makesense(self, ndate): + def _sub_tuple(self, to_sub: Tuple[int, int, int, int, int, int]) -> List[int]: """ - Puts overflowed time back into the correct unit. + Subtract a tuple of time components from this Date. - When manipulating the date, it might be that you have "70 seconds", or - something similar. Here, we put the overflowed time into the - appropriate unit. + Parameters + ---------- + to_sub : tuple of int + A tuple in the form (years, months, days, hours, minutes, seconds). + + Returns + ------- + list of int + Normalized differences as [years, months, days, hours, minutes, seconds]. """ - # ndate = copy.deepcopy(self) - ndate[4] = ndate[4] + ndate[5] // 60 - ndate[5] = ndate[5] % 60 + me = list(self._as_tuple()) + result = [me[i] - to_sub[i] for i in range(6)] + result = self._normalize_date(result) + return self.from_list(result) - ndate[3] = ndate[3] + ndate[4] // 60 - ndate[4] = ndate[4] % 60 + def _normalize_date(self, comps: List[int]) -> List[int]: + """ + Normalize the time components, cascading overflows correctly. - ndate[2] = ndate[2] + ndate[3] // 24 - ndate[3] = ndate[3] % 24 + Parameters + ---------- + comps : list of int + A list of date components [year, month, day, hour, minute, second]. - ndate[0] = ndate[0] + (ndate[1] - 1) // 12 - ndate[1] = (ndate[1] - 1) % 12 + 1 + Returns + ------- + list of int + The normalized date components. + """ + comps[4] += comps[5] // 60 + comps[5] %= 60 - while ndate[2] > self._calendar.day_in_month(ndate[0], ndate[1]): - ndate[2] = ndate[2] - self._calendar.day_in_month(ndate[0], ndate[1]) - ndate[1] = ndate[1] + 1 - ndate[0] = ndate[0] + (ndate[1] - 1) // 12 - ndate[1] = (ndate[1] - 1) % 12 + 1 + comps[3] += comps[4] // 60 + comps[4] %= 60 - ndate[0] = ndate[0] + (ndate[1] - 1) // 12 - ndate[1] = (ndate[1] - 1) % 12 + 1 + comps[2] += comps[3] // 24 + comps[3] %= 24 - while ndate[2] <= 0: - ndate[1] = ndate[1] - 1 - if ndate[1] == 0: - ndate[1] = 12 - ndate[0] = ndate[0] - 1 - ndate[2] = ndate[2] + self._calendar.day_in_month(ndate[0], ndate[1]) + comps[0] += (comps[1] - 1) // 12 + comps[1] = (comps[1] - 1) % 12 + 1 - if ndate[1] == 0: - ndate[1] = 12 - ndate[0] = ndate[0] - 1 + while comps[2] > self.calendar.days_in_month(comps[0], comps[1]): + comps[2] -= self.calendar.days_in_month(comps[0], comps[1]) + comps[1] += 1 + if comps[1] > 12: + comps[1] = 1 + comps[0] += 1 - return ndate + while comps[2] <= 0: + comps[1] -= 1 + if comps[1] == 0: + comps[1] = 12 + comps[0] -= 1 + comps[2] += self.calendar.days_in_month(comps[0], comps[1]) - # self.year, self.month, self.day, self.hour, self.minute, self.second = map( - # int, ndate - # ) - # self.syear, self.smonth, self.sday, self.shour, self.sminute, self.ssecond = map( - # str, ndate - # ) + return comps - def add(self, to_add): + def add(self, other: "Date") -> "Date": """ - Adds another date to this one. + Add another Date's components to this Date. Parameters ---------- - to_add : ~`pyesm.core.time_control.Date` - The other date to add to this one. + other : Date + The Date whose components will be added. Returns ------- - new_date : ~`pyesm.core.time_control.Date` - A new date object with the added dates + Date + A new Date with added components. + + Examples + -------- + >>> d1 = Date("2024-02-16T11:30:00") + >>> d2 = Date("0000-00-00T01:00:00") + >>> d3 = d1 + d2 + >>> print(d3) + 2024-02-16T12:30:00 """ - - me = [self.year, self.month, self.day, self.hour, self.minute, self.second] - result = [me[i] + to_add[i] for i in range(6)] - result = self.makesense(result) - - new_date = self.from_list(result) - return new_date + my_comps = list(self._as_tuple()) + other_comps = [other[i] for i in range(6)] + result = [my_comps[i] + other_comps[i] for i in range(6)] + result = self._normalize_date(result) + return Date.from_list(result) __add__ = add - def sub_tuple(self, to_sub): + @classmethod + def from_list(cls, components: List[int]) -> "Date": """ - Adds another date to from one. + Create a Date from a list of integer components. Parameters ---------- - to_sub : ~`pyesm.core.time_control.Date` - The other date to sub from this one. + components : list of int + List in the order [year, month, day, hour, minute, second]. Returns ------- - new_date : ~`pyesm.core.time_control.Date` - A new date object with the subtracted dates - """ + Date + A new Date instance. - me = [self.year, self.month, self.day, self.hour, self.minute, self.second] - result = [me[i] - to_sub[i] for i in range(6)] - result = self.makesense(result) + Examples + -------- + .. code-block:: python - new_date = self.from_list(result) - return new_date + >>> Date.from_list([2024, 2, 16, 11, 30, 0]) + Date(2024-02-16_11:30:00) + """ + signs = ["-" if n < 0 else "" for n in components] + str_components = [str(abs(n)) for n in components] + str_components[0] = str_components[0].zfill(4) + for i in range(1, 6): + str_components[i] = str_components[i].zfill(2) + date_str = ( + signs[0] + + str_components[0] + + "-" + + signs[1] + + str_components[1] + + "-" + + signs[2] + + str_components[2] + + "_" + + signs[3] + + str_components[3] + + ":" + + signs[4] + + str_components[4] + + ":" + + signs[5] + + str_components[5] + ) + return cls(date_str) + + fromlist = from_list + """Alias for :meth:`from_list`.""" diff --git a/src/esm_runscripts/batch_system.py b/src/esm_runscripts/batch_system.py index 94bb03a28..3e47a431e 100644 --- a/src/esm_runscripts/batch_system.py +++ b/src/esm_runscripts/batch_system.py @@ -530,7 +530,10 @@ def write_simple_runscript(config, cluster, batch_or_shell="batch"): + " -p ${process}" + " -s " + config["general"]["current_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, + print_hours=False, + print_minutes=False, + print_seconds=False, ) + " -r " + str(config["general"]["run_number"]) diff --git a/src/esm_runscripts/oasis.py b/src/esm_runscripts/oasis.py index fb78609c7..a62159496 100644 --- a/src/esm_runscripts/oasis.py +++ b/src/esm_runscripts/oasis.py @@ -46,7 +46,11 @@ def __init__( self.namcouple += [" $RUNTIME", " " + str(runtime), " $END"] if lucia: if mct_version >= (5, 0): - self.namcouple += [" $NLOGPRT", " " + str(debug_level) + " 0 1", " $END"] + self.namcouple += [ + " $NLOGPRT", + " " + str(debug_level) + " 0 1", + " $END", + ] else: self.namcouple += [" $NLOGPRT", " " + "1 -1", " $END"] else: @@ -242,9 +246,7 @@ def add_coupling( ) trafo_details += [stack_line.strip()] elif trans.upper() == "HCSBB": - stack_line = ( - trans.upper() - ) + stack_line = trans.upper() trafo_details += [stack_line.strip()] else: # OASIS with SCRIP interpolation library @@ -491,10 +493,10 @@ def add_restart_files(self, restart_file_label, fconfig): gconfig = fconfig["general"] is_runtime = gconfig["run_or_compile"] == "runtime" enddate = "_" + gconfig["end_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) parentdate = "_" + config["parent_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) if "restart_out_files" not in config: @@ -608,7 +610,7 @@ def add_restart_files(self, restart_file_label, fconfig): def prepare_restarts(self, restart_file, all_fields, models, config): enddate = "_" + config["general"]["end_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) # enddate = "_" + str(config["general"]["end_date"].year) + str(config["general"]["end_date"].month) + str(config["general"]["end_date"].day) @@ -665,11 +667,13 @@ def prepare_restarts(self, restart_file, all_fields, models, config): if os.path.isfile(restart_file): logger.debug(f"{restart_file} already exits, overwriting") - cdo_merge_command = f"cdo -O -f nc4c merge {filelist} {restart_file}" # {enddate}" + cdo_merge_command = ( + f"cdo -O -f nc4c merge {filelist} {restart_file}" # {enddate}" + ) logger.info(cdo_merge_command) exit_code = os.system(cdo_merge_command) if exit_code != 0: - cdo_merge_command = f"cdo -O merge {filelist} {restart_file}" # {enddate}" + cdo_merge_command = f"cdo -O merge {filelist} {restart_file}" # {enddate}" logger.warning("nc4c merge failed, trying without it...") logger.info(cdo_merge_command) os.system(cdo_merge_command) diff --git a/src/esm_runscripts/prepare.py b/src/esm_runscripts/prepare.py index 10477d388..28557b594 100644 --- a/src/esm_runscripts/prepare.py +++ b/src/esm_runscripts/prepare.py @@ -387,9 +387,13 @@ def find_last_prepared_run(config): end_date = next_date - (0, 0, 1, 0, 0, 0) datestamp = ( - current_date.format(form=9, givenph=False, givenpm=False, givenps=False) + current_date.format( + form=9, print_hours=False, print_minutes=False, print_seconds=False + ) + "-" - + end_date.format(form=9, givenph=False, givenpm=False, givenps=False) + + end_date.format( + form=9, print_hours=False, print_minutes=False, print_seconds=False + ) ) # Solve base_dir with variables @@ -448,21 +452,21 @@ def set_most_dates(config): config["general"]["run_datestamp"] = ( config["general"]["current_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) + "-" + config["general"]["end_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) ) config["general"]["last_run_datestamp"] = ( config["general"]["last_start_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) + "-" + config["general"]["prev_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) ) return config diff --git a/src/esm_runscripts/prev_run.py b/src/esm_runscripts/prev_run.py index 3451289b2..42b8f4baf 100644 --- a/src/esm_runscripts/prev_run.py +++ b/src/esm_runscripts/prev_run.py @@ -6,7 +6,7 @@ from loguru import logger import esm_parser -from esm_calendar import Calendar, Date +from esm_calendar import Date from esm_tools import user_error, user_note @@ -65,7 +65,6 @@ def __init__(self, config={}, prev_config=None): # Counter for debuggin self._prev_config_count = 0 - def components_with_prev_run(self): """ Lists components containning variables using the ``prev_run`` feature. Reading @@ -235,10 +234,10 @@ def prev_run_config(self): # Check that the data really comes from the previous run prev_date = prev_config["general"]["end_date"] prev_date_stamp = Date(prev_date).format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) calc_prev_date_stamp = calc_prev_date.format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) # Dates don't match if calc_prev_date_stamp != prev_date_stamp and self.warn: @@ -392,7 +391,7 @@ def find_config(self, component): # Calculate end date for the previous run prev_datestamp = prev_date.format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) # List all the config files in the config folder diff --git a/src/esm_runscripts/resubmit.py b/src/esm_runscripts/resubmit.py index 1b6130afd..ba4f19c6c 100644 --- a/src/esm_runscripts/resubmit.py +++ b/src/esm_runscripts/resubmit.py @@ -196,7 +196,7 @@ def _increment_date_and_run_number(config): config["general"]["command_line_config"]["current_date"] = config["general"][ "current_date" - ].format(form=9, givenph=False, givenpm=False, givenps=False) + ].format(form=9, print_hours=False, print_minutes=False, print_seconds=False) config["general"]["command_line_config"]["run_number"] = config["general"][ "run_number" diff --git a/src/esm_runscripts/yac.py b/src/esm_runscripts/yac.py index 7fbdc4e8d..73dcc6a8c 100644 --- a/src/esm_runscripts/yac.py +++ b/src/esm_runscripts/yac.py @@ -269,10 +269,10 @@ def add_restart_files(self, restart_file, fconfig): # enddate = "_" + str(gconfig["end_date"].year) + str(gconfig["end_date"].month) + str(gconfig["end_date"].day) # parentdate = "_" + str(config["parent_date"].year) + str(config["parent_date"].month) + str(config["parent_date"].day) enddate = "_" + gconfig["end_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) parentdate = "_" + config["parent_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) if "restart_out_files" not in config: @@ -307,7 +307,7 @@ def add_restart_files(self, restart_file, fconfig): def prepare_restarts(self, restart_file, all_fields, model, config): enddate = "_" + config["general"]["end_date"].format( - form=9, givenph=False, givenpm=False, givenps=False + form=9, print_hours=False, print_minutes=False, print_seconds=False ) # enddate = "_" + str(config["general"]["end_date"].year) + str(config["general"]["end_date"].month) + str(config["general"]["end_date"].day) import glob diff --git a/tests/test_esm_calendar/__init__.py b/tests/test_esm_calendar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_esm_calendar/test_esm_calendar.py b/tests/test_esm_calendar/test_esm_calendar.py new file mode 100644 index 000000000..a3a61dce5 --- /dev/null +++ b/tests/test_esm_calendar/test_esm_calendar.py @@ -0,0 +1,304 @@ +import pytest + +from esm_calendar import Calendar, Date, DateFormat, find_remaining_minutes + + +def test_find_remaining_minutes_typical_value(): + assert find_remaining_minutes(125) == 5 + + +def test_find_remaining_minutes_zero_seconds(): + assert find_remaining_minutes(0) == 0 + + +def test_find_remaining_minutes_exactly_one_minute(): + assert find_remaining_minutes(60) == 0 + + +def test_find_remaining_minutes_less_than_a_minute(): + assert find_remaining_minutes(59) == 59 + + +def test_find_remaining_minutes_multiple_minutes(): + assert find_remaining_minutes(3600) == 0 + + +def test_find_remaining_minutes_large_number_of_seconds(): + assert find_remaining_minutes(987654321) == 21 + + +def test_find_remaining_minutes_invalid_type_string(): + with pytest.raises(TypeError): + find_remaining_minutes("125") + + +def test_find_remaining_minutes_invalid_type_float(): + with pytest.raises(TypeError): + find_remaining_minutes(125.5) + + +def test_find_remaining_minutes_negative_value(): + with pytest.raises(ValueError): + find_remaining_minutes(-125) + + +def test_is_leap_year_gregorian_true(): + cal = Calendar("gregorian") + assert cal.is_leap_year(2024) is True + + +def test_is_leap_year_gregorian_false(): + cal = Calendar("gregorian") + assert cal.is_leap_year(2023) is False + + +def test_is_leap_year_gregorian_century_false(): + cal = Calendar("gregorian") + assert cal.is_leap_year(1900) is False + + +def test_is_leap_year_gregorian_century_true(): + cal = Calendar("gregorian") + assert cal.is_leap_year(2000) is True + + +def test_is_leap_year_no_leap(): + cal = Calendar("no_leap") + assert cal.is_leap_year(2024) is False + + +def test_days_in_year_gregorian_leap(): + cal = Calendar("gregorian") + assert cal.days_in_year(2024) == 366 + + +def test_days_in_year_gregorian_non_leap(): + cal = Calendar("gregorian") + assert cal.days_in_year(2023) == 365 + + +def test_days_in_year_no_leap(): + cal = Calendar("no_leap") + assert cal.days_in_year(2024) == 365 + + +def test_days_in_month_gregorian_january(): + cal = Calendar("gregorian") + assert cal.days_in_month(2024, 1) == 31 + + +def test_days_in_month_gregorian_february_leap(): + cal = Calendar("gregorian") + assert cal.days_in_month(2024, 2) == 29 + + +def test_days_in_month_gregorian_february_non_leap(): + cal = Calendar("gregorian") + assert cal.days_in_month(2023, 2) == 28 + + +def test_days_in_month_gregorian_april(): + cal = Calendar("gregorian") + assert cal.days_in_month(2024, 4) == 30 + + +def test_days_in_month_no_leap_february(): + cal = Calendar("no_leap") + assert cal.days_in_month(2024, 2) == 28 + + +def test_days_in_month_invalid_month(): + cal = Calendar("gregorian") + with pytest.raises(ValueError): + cal.days_in_month(2024, 13) + + +def test_days_in_month_string_month(): + cal = Calendar("gregorian") + assert cal.days_in_month(2024, "Feb") == 29 + + +def test_repr(): + cal = Calendar("gregorian") + assert repr(cal) == "Calendar(calendar_type=gregorian)" + + +def test_str_no_leap(): + cal = Calendar("no_leap") + assert str(cal) == "Calendar object with no leap years allowed" + + +def test_str_gregorian(): + cal = Calendar("gregorian") + assert str(cal) == "Calendar object with allowed leap years" + + +def test_str_equal_months(): + cal = Calendar(30) + assert str(cal) == "Calendar object with equal-length months of 30 days" + + +def test_date_initialization_from_string(): + d = Date("2024-02-16T11:30:00") + assert str(d) == "2024-02-16T11:30:00" + + +def test_date_initialization_from_date(): + d1 = Date("2024-02-16T11:30:00") + d2 = Date(d1) + assert str(d2) == "2024-02-16T11:30:00" + + +def test_date_year_component(): + d = Date("2024-02-16T11:30:00") + assert d.year == 2024 + + +def test_date_month_component(): + d = Date("2024-02-16T11:30:00") + assert d.month == 2 + + +def test_date_day_component(): + d = Date("2024-02-16T11:30:00") + assert d.day == 16 + + +def test_date_hour_component(): + d = Date("2024-02-16T11:30:00") + assert d.hour == 11 + + +def test_date_minute_component(): + d = Date("2024-02-16T11:30:00") + assert d.minute == 30 + + +def test_date_second_component(): + d = Date("2024-02-16T11:30:00") + assert d.second == 0 + + +def test_date_day_of_year(): + d = Date("2024-02-16T00:00:00") + assert d.day_of_year() == 47 + + +def test_date_format_self(): + d = Date("2024-02-16T11:30:00") + assert d.format() == "2024-02-16T11:30:00" + + +def test_date_format_1(): + d = Date("2024-02-16T11:30:00") + assert d.format(form=1) == "2024-02-16_11:30:00" + + +def test_date_format_2(): + d = Date("2024-02-16T11:30:00") + assert d.format(form=2) == "2024-02-16T11:30:00" + + +def test_date_format_3(): + d = Date("2024-02-16T11:30:00") + assert d.format(form=3) == "2024-02-16 11:30:00" + + +def test_date_format_4(): + d = Date("2024-02-16T11:30:00") + assert d.format(form=4) == "2024 02 16 11 30 00" + + +def test_date_format_5(): + d = Date("2024-02-16T11:30:00") + assert d.format(form=5) == "16 Feb 2024 11:30:00" + + +def test_date_syear(): + d = Date("2024-02-16T11:30:00") + assert d.syear == "2024" + + +def test_date_smonth(): + d = Date("2024-02-16T11:30:00") + assert d.smonth == "02" + + +def test_date_sday(): + d = Date("2024-02-16T11:30:00") + assert d.sday == "16" + + +def test_date_shour(): + d = Date("2024-02-16T11:30:00") + assert d.shour == "11" + + +def test_date_sminute(): + d = Date("2024-02-16T11:30:00") + assert d.sminute == "30" + + +def test_date_ssecond(): + d = Date("2024-02-16T11:30:00") + assert d.ssecond == "00" + + +def test_date_time_between(): + d1 = Date("2024-02-16T12:30:00") + d2 = Date("2024-02-16T11:30:00") + assert d1.time_between(d2, "hours") == 1 + + +def test_date_subtraction(): + d1 = Date("2024-02-16T12:30:00") + d2 = Date("2024-02-16T11:30:00") + assert d1 - d2 == [0, 0, 0, 1, 60, 3600] + + +def test_date_addition(): + d1 = Date("2024-02-16T11:30:00") + d2 = Date("0000-00-00T01:00:00") + d3 = d1 + d2 + assert str(d3) == "2024-02-16T12:30:00" + + +def test_date_from_list(): + d = Date.from_list([2024, 2, 16, 11, 30, 0]) + assert str(d) == "2024-02-16T11:30:00" + + +def test_date_comparison_lt(): + d1 = Date("2024-02-16T11:30:00") + d2 = Date("2024-02-17T11:30:00") + assert d1 < d2 + + +def test_date_comparison_le(): + d1 = Date("2024-02-16T11:30:00") + d2 = Date("2024-02-16T11:30:00") + assert d1 <= d2 + + +def test_date_comparison_eq(): + d1 = Date("2024-02-16T11:30:00") + d2 = Date("2024-02-16T11:30:00") + assert d1 == d2 + + +def test_date_comparison_ne(): + d1 = Date("2024-02-16T11:30:00") + d2 = Date("2024-02-17T11:30:00") + assert d1 != d2 + + +def test_date_comparison_gt(): + d1 = Date("2024-02-17T11:30:00") + d2 = Date("2024-02-16T11:30:00") + assert d1 > d2 + + +def test_date_comparison_ge(): + d1 = Date("2024-02-16T11:30:00") + d2 = Date("2024-02-16T11:30:00") + assert d1 >= d2