Intervals and ordinals#

Mapping between datetimes and the intervals that contain them.

Ordinals#

A period splits the timeline into consecutive intervals and numbers them with integers called ordinals. Two methods move between the two views, and they are inverses of each other:

pt1h = Period.of_hours(1)
d = datetime(2024, 3, 1, 9, 47)

n = pt1h.ordinal(d)  # datetime -> interval number
print(pt1h.datetime(n))  # interval number -> first instant
2024-03-01 09:00:00

Flooring a timestamp to its interval#

floor() snaps a datetime down onto the period’s grid. This also works for calendar units where arithmetic on a timedelta cannot:

d = datetime(2024, 3, 15, 9, 47, 30)

print(Period.of_minutes(15).floor(d))
print(Period.of_days(1).floor(d))
print(Period.of_months(1).floor(d))
print(Period.of_years(1).floor(d))
2024-03-15 09:45:00
2024-03-15 00:00:00
2024-03-01 00:00:00
2024-01-01 00:00:00

An offset moves every boundary, and with it what a timestamp floors to:

water_day = Period.of("P1D+T9H")

print(water_day.floor(datetime(2024, 3, 15, 7, 30)))
2024-03-14 09:00:00

Stepping along the timeline#

Adding to an ordinal moves whole intervals, whatever their calendar length. This is how to walk a monthly or yearly grid without worrying about month ends:

p1m = Period.of_months(1)
n = p1m.ordinal(datetime(2024, 1, 31))

for i in range(4):
    print(p1m.datetime(n + i))
2024-01-01 00:00:00
2024-02-01 00:00:00
2024-03-01 00:00:00
2024-04-01 00:00:00

range() does that walk for you, yielding the start of every interval that overlaps a window of time:

pt6h = Period.of_hours(6)

window_start = datetime(2024, 3, 1)
window_end = datetime(2024, 3, 2)

for start in pt6h.range(window_start, window_end):
    print(start)
2024-03-01 00:00:00
2024-03-01 06:00:00
2024-03-01 12:00:00
2024-03-01 18:00:00

An interval counts as overlapping if any part of it falls in the window, so a window opening part-way through an interval still yields the whole of it. This is usually what you want when deciding which intervals a batch of readings touches:

pt6h = Period.of_hours(6)

window_start = datetime(2024, 3, 1, 3)
window_end = datetime(2024, 3, 1, 13)

for start in pt6h.range(window_start, window_end):
    print(start)
2024-03-01 00:00:00
2024-03-01 06:00:00
2024-03-01 12:00:00

Values are produced lazily, so a window covering a large number of intervals costs nothing until it is iterated.

Bounding a single interval#

interval() returns the start and end of the interval holding a datetime, as a half-open pair - end is the first instant of the next interval, so start <= d < end holds for every datetime in it:

pt1h = Period.of_hours(1)

start, end = pt1h.interval(datetime(2024, 3, 15, 9, 47))

print(start, "->", end)
2024-03-15 09:00:00 -> 2024-03-15 10:00:00

The start is the same value floor() gives; the end is the new information, and it is the part you cannot get by adding a timedelta when the period is a calendar one.

Since range() yields datetimes, the two can be used together to walk a window as bounded intervals:

pt6h = Period.of_hours(6)

window_start = datetime(2024, 3, 1)
window_end = datetime(2024, 3, 1, 13)

for s in pt6h.range(window_start, window_end):
    start, end = pt6h.interval(s)
    print(start, "->", end)
2024-03-01 00:00:00 -> 2024-03-01 06:00:00
2024-03-01 06:00:00 -> 2024-03-01 12:00:00
2024-03-01 12:00:00 -> 2024-03-01 18:00:00

Grouping by interval#

Two datetimes share an ordinal exactly when they fall in the same interval, so the start of that interval makes a natural grouping key:

readings = {
    datetime(2024, 3, 15, 9, 47): 1.2,
    datetime(2024, 3, 15, 9, 52): 1.4,
    datetime(2024, 3, 15, 10, 3): 1.1,
}

pt1h = Period.of_hours(1)

hourly: dict[datetime, list[float]] = {}
for when, value in readings.items():
    hourly.setdefault(pt1h.floor(when), []).append(value)

for hour, values in hourly.items():
    print(hour, values)
2024-03-15 09:00:00 [1.2, 1.4]
2024-03-15 10:00:00 [1.1]

Checking alignment#

is_aligned() tests whether a datetime falls exactly on an interval boundary - the check to run on incoming data before trusting its claimed resolution:

pt15m = Period.of_minutes(15)

pt15m.is_aligned(datetime(2024, 3, 15, 9, 45))  # True - on a boundary
pt15m.is_aligned(datetime(2024, 3, 15, 9, 47))  # False - part-way through an interval

Offsets move the boundaries, and with them what counts as aligned:

water_day = Period.of("P1D+T9H")

water_day.is_aligned(datetime(2024, 3, 15, 9, 0))  # True - the water day starts at 09:00
water_day.is_aligned(datetime(2024, 3, 15, 0, 0))  # False - midnight no longer starts anything

What an ordinal actually is#

For the common periods, ordinals turn out to be quantities you may already recognise - the year, the number of months since year 0, and the proleptic Gregorian day ordinal:

print(Period.of_years(1).ordinal(datetime(2024, 7, 1)))  # the year
print(Period.of_months(1).ordinal(datetime(2024, 3, 1)))  # months since year 0
print(Period.of_days(1).ordinal(datetime(2024, 3, 15)))  # the proleptic Gregorian ordinal
2024
24290
738960

That is incidental, though - the guarantees are only that ordinals are consecutive integers, and that ordinal() and datetime() invert each other. Do not persist ordinals or send them between systems; store the datetime and re-derive the ordinal.

Warning

An ordinal is only meaningful to the period that produced it. Each period numbers the timeline in its own units, so passing one period’s ordinal to another period’s datetime() would not be meaningful.

The ends of the timeline#

Ordinals are unbounded integers, but datetime only covers years 1 to 9999. min_ordinal and max_ordinal give the usable range.

p1y = Period.of_years(1)

print(p1y.datetime(p1y.min_ordinal))
print(p1y.datetime(p1y.max_ordinal))
0001-01-01 00:00:00
9999-01-01 00:00:00

Going outside out these bounds raises ValueError from datetime itself:

try:
    Period.of_days(1).datetime(10**9)
except ValueError as err:
    print(err)
year 2737908 is out of range