Skip to content

Instantly share code, notes, and snippets.

@decatur
Last active November 19, 2024 09:47
Show Gist options
  • Save decatur/72746dfdd2f2e6dd185d5594acc3e036 to your computer and use it in GitHub Desktop.
Save decatur/72746dfdd2f2e6dd185d5594acc3e036 to your computer and use it in GitHub Desktop.
DeliveryTime is a point in time Python abstraction aligned to a 1/4hour grid. TransactionTime is a point in time abstraction

Here we are talking about the datetime object of the Python datetime package.

The datetime object is not good or bad, it is complex. In combination with the datetime.tz module almost all of your date and time related needs can be solved. But at a cost!

Problem

  • datetime object has a fat state of 9 fields (year, month, ..., tzinfo, fold).
  • You must know the state to have a correct mental model what your code does.
  • But it is hard or impossible to have a mental model of datetime locally.
  • A typical datetime operation
    • stuffs something into a datetime
    • transforms the datetime, mutating it
    • gets something out of it
  • Btw the time object is almost as percky as datetime

Possible Solution

  • Use datetime only locally
  • Make wrapper objects conveying different types of date and time
  • Have at least two new types according the bitemporal model
    1. transaction time
    2. valid time
  • For the valid time, make it impossible to express invalid 😜 times.
  • Do all time arithmetics and conversions locally.

Bitemporal Example

At transaction time 2024-11-19T09:19:54.226175+01:00 we plan the electrical power of 20MW to be delivered in the hour (valid time) starting at 2024-11-19T13:00+01:00.

Illustrativ Code

from datetime import datetime, timedelta, time, tzinfo
from dateutil import tz

BERLIN = tz.gettz('Europe/Berlin')

# Stuff something in
now = datetime.now()
# Transform
now = now.astimezone(BERLIN)
# Get something out
now.time() > time(9, 0)

# Btw a better solution would be
datetime.now().time() > time(10, 0, tzinfo=BERLIN)

def foo(d1: datetime, d2: datetime):
    """ There is no way to locally argue that the assertion is true """
    # Difference in wall clock time
    delta_wall_clock = (d2 - d1).total_seconds()
    delta_absolute   = d2.timestamp() - d1.timestamp()
    assert delta_wall_clock == delta_absolute

foo(datetime(2024, 10, 27, 1, tzinfo=BERLIN), datetime(2024, 10, 27, 3, tzinfo=BERLIN))
# -> AssertionError

References

from __future__ import annotations
from datetime import datetime, timedelta, time, tzinfo
from dateutil import tz
BERLIN = tz.gettz('Europe/Berlin')
class DeliveryTime:
"""
DeliveryTime is a point in time abstraction aligned to a ¼hour grid and
optimized for arithmetic operations.
It is robust in the sense that it does not leak datetime nor time of the datetime package.
"""
qh: int
def __init__(self, qh: int):
self.qh = qh
@staticmethod
def fromisoformat(s: str):
i = datetime.fromisoformat(s).timestamp() / 15 / 60
assert i == int(i)
return DeliveryTime(i)
@staticmethod
def head():
"""
Returns the DeliveryTime next from now.
"""
return DeliveryTime(1 + int(datetime.now(tz=tz.UTC).timestamp() / 15 / 60))
def __eq__(self, other):
return self.qh == other.qh
def __repr__(self) -> str:
return datetime.fromtimestamp(self.qh * 15 * 60, tz.UTC).astimezone(BERLIN).isoformat()
def __add__(self, i: int) -> DeliveryTime:
return DeliveryTime(self.qh + i)
def __sub__(self, other: DeliveryTime) -> int:
return self.qh - other.qh
def wallclock_time(self, tz_info: tzinfo) -> timedelta:
t = self._datetime(tz_info).time()
return timedelta(hours=t.hour, minutes=t.minute)
def start_of_day(self, tz_info: tzinfo) -> DeliveryTime:
dt = datetime.fromordinal(self._datetime(tz_info).toordinal()).astimezone(tz_info)
return DeliveryTime(dt.timestamp() / 15 / 60)
def _datetime(self, tz_info: tzinfo) -> datetime:
return datetime.fromtimestamp(self.qh * 15 * 60, tz.UTC).astimezone(tz_info)
[DeliveryTime.head() + i for i in range(0, 4)]
# -> [2024-11-19T10:00:00+01:00, 2024-11-19T10:15:00+01:00, 2024-11-19T10:30:00+01:00, 2024-11-19T10:45:00+01:00]
dt1 = DeliveryTime(0)
assert repr(dt1) == '1970-01-01T01:00:00+01:00'
dt2 = DeliveryTime.fromisoformat('1970-01-01T00:00:00+00:00')
assert dt1 == dt2
assert dt1 + 1 == DeliveryTime(1)
assert (dt1 + 1) - dt2 == 1.
assert (dt1 + 1).wallclock_time(BERLIN) == timedelta(hours=1, minutes=15)
assert repr(DeliveryTime.fromisoformat('2024-10-27T03:00:00+01:00').start_of_day(BERLIN)) == '2024-10-27T00:00:00+02:00'
print(DeliveryTime.head())
# -> e.g 2024-11-18T17:45:00+01:00
from __future__ import annotations
from datetime import datetime, timedelta, time, tzinfo
from dateutil import tz
BERLIN = tz.gettz('Europe/Berlin')
class TransactionTime:
"""
TransactionTime is a point in time abstraction.
It is robust in the sense that it does not leak datetime nor time of the datetime package.
"""
second: float
def __init__(self, second: float):
self.second = second
@staticmethod
def fromisoformat(s: str):
return TransactionTime(datetime.fromisoformat(s).timestamp())
@staticmethod
def now():
"""
Returns the DeliveryTime next from now.
"""
return TransactionTime(datetime.now(tz=tz.UTC).timestamp())
def __repr__(self) -> str:
return datetime.fromtimestamp(self.second, tz.UTC).astimezone(BERLIN).isoformat()
def __sub__(self, other: TransactionTime) -> float:
return self.second - other.second
tt1 = TransactionTime(0.)
assert repr(tt1) == '1970-01-01T01:00:00+01:00'
print(TransactionTime.now())
# -> e.g 2024-11-18T17:45:00+01:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment