EOS/src/akkudoktoreos/utils/datetimeutil.py

286 lines
11 KiB
Python
Raw Normal View History

Add test to PVForecast (#174) * Add documentation to class_pv_forecast.py. Added documentation. Beware mostly generated by ChatGPT. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> * Add CacheFileStore, datetime and logger utilities. The `CacheFileStore` class is a singleton-based, thread-safe key-value store for managing temporary file objects, allowing the creation, retrieval, and management of cache files. The utility modules offer a flexible logging setup (`get_logger`) and utilities to handle different date-time formats (`to_datetime`, `to_timestamp`) and timezone detection (`to_timezone). - Cache files are automatically valid for the the current date unless specified otherwise. This is to mimic the current behaviour used in several classes. - The logger supports rotating log files to prevent excessive log file size. - The `to_datetime` and `to_timestamp`functions support a wide variety of input types and formats. They provide the time conversion that is e.g. used in PVForecast. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> * Improve testability of PVForecast Improvements for testing of PVForecast - Use common utility functions to allow for general testing at one spot. - to_datetime - CacheFileStore - Use logging instead of print to easily capture in testing. - Add validation of the json schema for Akkudoktor PV forecast data. - Allow to create an empty PVForecast instance as base instance for testing. - Make process_data() complete for filling a PVForecast instance for testing. - Normalize forecast datetime to timezone of system given in loaded data. - Do not print report but provide report for test checks. - Get rid of cache file path using the CachFileStore to automate cache file usage. - Improved module documentation. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> * Add test for PVForecast and newly extracted utility modules. - Add test for PVForecast - Add test for CacheFileStore in the new cachefilestore module - Add test for to_datetime, to_timestamp, to_timezone in the new datetimeutil module - Add test for get_logger in the new logutil module Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> --------- Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> Co-authored-by: Normann <github@koldrack.com>
2024-11-10 23:49:10 +01:00
"""Utility functions for date-time conversion tasks.
Functions:
----------
- to_datetime: Converts various date or time inputs to a timezone-aware or naive `datetime`
object or formatted string.
- to_timedelta: Converts various time delta inputs to a `timedelta`object.
- to_timezone: Converts position latitude and longitude to a `timezone` object.
Example usage:
--------------
# Date-time conversion
>>> date_str = "2024-10-15"
>>> date_obj = to_datetime(date_str)
>>> print(date_obj) # Output: datetime object for '2024-10-15'
# Time delta conversion
>>> to_timedelta("2 days 5 hours")
# Timezone detection
>>> to_timezone(40.7128, -74.0060)
"""
import re
from datetime import date, datetime, time, timedelta, timezone
from typing import Optional, Union
from zoneinfo import ZoneInfo
from timezonefinder import TimezoneFinder
def to_datetime(
date_input: Union[datetime, date, str, int, float, None],
as_string: Optional[Union[str, bool]] = None,
to_timezone: Optional[Union[timezone, str]] = None,
to_naiv: Optional[bool] = None,
to_maxtime: Optional[bool] = None,
):
"""Converts a date input to a datetime object or a formatted string with timezone support.
Args:
date_input (Union[datetime, date, str, int, float, None]): The date input to convert.
Accepts a date string, a datetime object, a date object or a Unix timestamp.
as_string (Optional[Union[str, bool]]): If as_string is given (a format string or true)
return datetime as a string. Otherwise, return a datetime object, which is the default.
If true is given the string will returned in ISO format.
If a format string is given it may define the special formats "UTC" or "utc"
to return a string in ISO format normalized to UTC. Otherwise the format string must be
given compliant to Python's `datetime.strptime`.
to_timezone (Optional[Union[timezone, str]]):
Optional timezone object or name (e.g., 'UTC', 'Europe/Berlin').
If provided, the datetime will be converted to this timezone.
If not provided, the datetime will be converted to the local timezone.
to_naiv (Optional[bool]):
If True, remove timezone info from datetime after conversion.
If False, keep timezone info after conversion. The default.
to_maxtime (Optional[bool]):
If True, convert to maximum time if no time is given. The default.
If False, convert to minimum time if no time is given.
Example:
to_datetime("2027-12-12 24:13:12", as_string = "%Y-%m-%dT%H:%M:%S.%f%z")
Returns:
datetime or str: Converted date as a datetime object or a formatted string with timezone.
Raises:
ValueError: If the date input is not a valid type or format.
"""
if isinstance(date_input, datetime):
dt_object = date_input
elif isinstance(date_input, date):
# Convert date object to datetime object
if to_maxtime is None or to_maxtime:
dt_object = datetime.combine(date_input, time.max)
else:
dt_object = datetime.combine(date_input, time.max)
elif isinstance(date_input, (int, float)):
# Convert timestamp to datetime object
dt_object = datetime.fromtimestamp(date_input, tz=timezone.utc)
elif isinstance(date_input, str):
# Convert string to datetime object
try:
# Try ISO format
dt_object = datetime.fromisoformat(date_input)
except ValueError as e:
formats = [
"%Y-%m-%d", # Format: 2024-10-13
"%d/%m/%y", # Format: 13/10/24
"%d/%m/%Y", # Format: 13/10/2024
"%m-%d-%Y", # Format: 10-13-2024
"%Y.%m.%d", # Format: 2024.10.13
"%d %b %Y", # Format: 13 Oct 2024
"%d %B %Y", # Format: 13 October 2024
"%Y-%m-%d %H:%M:%S", # Format: 2024-10-13 15:30:00
"%Y-%m-%d %H:%M:%S%z", # Format with timezone: 2024-10-13 15:30:00+0000
"%Y-%m-%d %H:%M:%S%z:00", # Format with timezone: 2024-10-13 15:30:00+0000
"%Y-%m-%dT%H:%M:%S.%f%z", # Format with timezone: 2024-10-13T15:30:00.000+0000
]
for fmt in formats:
try:
dt_object = datetime.strptime(date_input, fmt)
break
except ValueError as e:
dt_object = None
continue
if dt_object is None:
raise ValueError(f"Date string {date_input} does not match any known formats.")
elif date_input is None:
if to_maxtime is None or to_maxtime:
dt_object = datetime.combine(date.today(), time.max)
else:
dt_object = datetime.combine(date.today(), time.min)
else:
raise ValueError(f"Unsupported date input type: {type(date_input)}")
# Get local timezone
local_date = datetime.now().astimezone()
local_tz_name = local_date.tzname()
local_utc_offset = local_date.utcoffset()
local_timezone = timezone(local_utc_offset, local_tz_name)
# Get target timezone
if to_timezone:
if isinstance(to_timezone, timezone):
target_timezone = to_timezone
elif isinstance(to_timezone, str):
try:
target_timezone = ZoneInfo(to_timezone)
except Exception as e:
raise ValueError(f"Invalid timezone: {to_timezone}") from e
else:
raise ValueError(f"Invalid timezone: {to_timezone}")
# Adjust/Add timezone information
if dt_object.tzinfo is None or dt_object.tzinfo.utcoffset(dt_object) is None:
# datetime object is naive (not timezone aware)
# Add timezone
if to_timezone is None:
# Add local timezone
dt_object = dt_object.replace(tzinfo=local_timezone)
else:
# Set to target timezone
dt_object = dt_object.replace(tzinfo=target_timezone)
elif to_timezone:
# Localize the datetime object to given target timezone
dt_object = dt_object.astimezone(target_timezone)
else:
# Localize the datetime object to local timezone
dt_object = dt_object.astimezone(local_timezone)
if to_naiv:
# Remove timezone info to make the datetime naiv
dt_object = dt_object.replace(tzinfo=None)
if as_string:
# Return formatted string as defined by as_string
if isinstance(as_string, bool):
return dt_object.isoformat()
elif as_string == "UTC" or as_string == "utc":
dt_object = dt_object.astimezone(timezone.utc)
return dt_object.isoformat()
else:
return dt_object.strftime(as_string)
else:
return dt_object
def to_timedelta(input_value):
"""Converts various input types into a timedelta object.
Args:
input_value (Union[timedelta, str, int, float, tuple, list]): Input to be converted
timedelta.
- str: A string like "2 days", "5 hours", "30 minutes", or a combination.
- int/float: Number representing seconds.
- tuple/list: A tuple or list in the format (days, hours, minutes, seconds).
Returns:
timedelta: A timedelta object corresponding to the input value.
Raises:
ValueError: If the input format is not supported.
Examples:
>>> to_timedelta("2 days 5 hours")
datetime.timedelta(days=2, seconds=18000)
>>> to_timedelta(3600)
datetime.timedelta(seconds=3600)
>>> to_timedelta((1, 2, 30, 15))
datetime.timedelta(days=1, seconds=90315)
"""
if isinstance(input_value, timedelta):
return input_value
if isinstance(input_value, (int, float)):
# Handle integers or floats as seconds
return timedelta(seconds=input_value)
elif isinstance(input_value, (tuple, list)):
# Handle tuple or list: (days, hours, minutes, seconds)
if len(input_value) == 4:
days, hours, minutes, seconds = input_value
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
else:
raise ValueError(f"Expected a tuple or list of length 4, got {len(input_value)}")
elif isinstance(input_value, str):
# Handle strings like "2 days 5 hours 30 minutes"
total_seconds = 0
time_units = {
"day": 86400, # 24 * 60 * 60
"hour": 3600,
"minute": 60,
"second": 1,
}
# Regular expression to match time components like '2 days', '5 hours', etc.
matches = re.findall(r"(\d+)\s*(days?|hours?|minutes?|seconds?)", input_value)
if not matches:
raise ValueError(f"Invalid time string format: {input_value}")
for value, unit in matches:
unit = unit.lower().rstrip("s") # Normalize unit
if unit in time_units:
total_seconds += int(value) * time_units[unit]
else:
raise ValueError(f"Unsupported time unit: {unit}")
return timedelta(seconds=total_seconds)
else:
raise ValueError(f"Unsupported input type: {type(input_value)}")
def to_timezone(lat: float, lon: float, as_string: Optional[bool] = None):
"""Determines the timezone for a given geographic location specified by latitude and longitude.
By default, it returns a `ZoneInfo` object representing the timezone.
If `as_string` is set to `True`, the function returns the timezone name as a string instead.
Args:
lat (float): Latitude of the location in decimal degrees. Must be between -90 and 90.
lon (float): Longitude of the location in decimal degrees. Must be between -180 and 180.
as_string (Optional[bool]):
- If `True`, returns the timezone as a string (e.g., "America/New_York").
- If `False` or not provided, returns a `ZoneInfo` object for the timezone.
Returns:
str or ZoneInfo:
- A timezone name as a string (e.g., "America/New_York") if `as_string` is `True`.
- A `ZoneInfo` timezone object if `as_string` is `False` or not provided.
Raises:
ValueError: If the latitude or longitude is out of range, or if no timezone is found for
the specified coordinates.
Example:
>>> to_timezone(40.7128, -74.0060, as_string=True)
'America/New_York'
>>> to_timezone(40.7128, -74.0060)
ZoneInfo(key='America/New_York')
"""
# Initialize the static variable only once
if not hasattr(to_timezone, "timezone_finder"):
to_timezone.timezone_finder = TimezoneFinder() # static variable
# Check and convert coordinates to timezone
try:
tz_name = to_timezone.timezone_finder.timezone_at(lat=lat, lng=lon)
if not tz_name:
raise ValueError(f"No timezone found for coordinates: latitude {lat}, longitude {lon}")
except Exception as e:
raise ValueError(f"Invalid location: latitude {lat}, longitude {lon}") from e
if as_string:
return tz_name
return ZoneInfo(tz_name)