Source code for openstef_core.utils.datetime
# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project <openstef@lfenergy.org>
#
# SPDX-License-Identifier: MPL-2.0
"""Datetime manipulation utilities for time series alignment and processing.
Provides functions for aligning timestamps to specific intervals or times of day,
commonly used in energy forecasting workflows where data must be synchronized
to regular time grids or specific analysis windows.
"""
import math
from datetime import datetime, time, timedelta
from typing import Literal
[docs]
def align_datetime(timestamp: datetime, interval: timedelta, mode: Literal["ceil", "floor"] = "ceil") -> datetime:
"""Align timestamp using modulo approach.
Args:
timestamp: The datetime to align.
interval: Time interval to align to.
mode: Alignment direction - "ceil" for next interval, "floor" for previous.
Returns:
Aligned datetime matching the specified interval boundary.
Raises:
ValueError: If mode is not "ceil" or "floor".
"""
timestamp_epoch_seconds = timestamp.timestamp()
interval_seconds = interval.total_seconds()
if mode == "floor":
aligned_secs = math.floor(timestamp_epoch_seconds / interval_seconds) * interval_seconds
elif mode == "ceil":
aligned_secs = math.ceil(timestamp_epoch_seconds / interval_seconds) * interval_seconds
else:
msg = f"Unknown alignment mode: {mode}"
raise ValueError(msg)
if timestamp.tzinfo is None:
return datetime.fromtimestamp(aligned_secs, tz=None) # noqa: DTZ006
return datetime.fromtimestamp(aligned_secs, tz=timestamp.tzinfo)
[docs]
def align_datetime_to_time(timestamp: datetime, align_time: time, mode: Literal["ceil", "floor"] = "ceil") -> datetime:
"""Align timestamp to the nearest occurrence of a specific time of day.
Aligns a timestamp to either the next (ceil) or previous (floor) occurrence
of the specified time. Properly handles timezone conversions when both
timestamp and align_time have timezone information.
Args:
timestamp: The datetime to align.
align_time: Target time of day to align to. If timezone-aware and timestamp
has timezone info, converts align_time to timestamp's timezone.
mode: Alignment direction - "ceil" for next occurrence, "floor" for previous.
Returns:
Aligned datetime with the same timezone as the original timestamp.
Example:
>>> from datetime import datetime, time
>>> dt = datetime.fromisoformat("2023-01-15T14:30:00")
>>> target = time.fromisoformat("09:00:00")
>>> align_datetime_to_time(dt, target, "ceil")
datetime.datetime(2023, 1, 16, 9, 0)
>>> align_datetime_to_time(dt, target, "floor")
datetime.datetime(2023, 1, 15, 9, 0)
"""
# Convert align_time to timestamp's timezone if both have timezone info
if timestamp.tzinfo is not None and align_time.tzinfo is not None:
# Convert align_time to timestamp's timezone for comparison
temp_dt = datetime.combine(timestamp.date(), align_time)
temp_dt = temp_dt.replace(tzinfo=align_time.tzinfo)
temp_dt = temp_dt.astimezone(timestamp.tzinfo)
target_time = temp_dt.time()
elif timestamp.tzinfo is not None and align_time.tzinfo is None:
# align_time is naive, treat as if it's in timestamp's timezone
target_time = align_time
elif timestamp.tzinfo is None and align_time.tzinfo is not None:
# timestamp is naive, convert align_time to naive
target_time = align_time.replace(tzinfo=None)
else:
# Both are naive
target_time = align_time
# Create aligned datetime in the same timezone as original timestamp
aligned_date = timestamp.replace(
hour=target_time.hour,
minute=target_time.minute,
second=target_time.second if hasattr(target_time, "second") else 0,
microsecond=0,
)
# Adjust by one day if needed based on mode
if mode == "ceil" and aligned_date < timestamp:
aligned_date += timedelta(days=1)
elif mode == "floor" and aligned_date > timestamp:
aligned_date -= timedelta(days=1)
return aligned_date
__all__ = [
"align_datetime",
"align_datetime_to_time",
]