Comparing periods#

Equality, ordering, and the two questions about how one period nests inside another.

Equality, hashing and sorting#

Periods are immutable and hashable, so they work as dict keys and set members.

Two periods are equal when they split the timeline the same way and number it the same way - the way they were built is irrelevant:

print(Period.of_hours(24), Period.of_days(1))
print(Period.of("PT15M"), Period.of_minutes(15), Period.of_seconds(900))
P1D P1D
PT15M PT15M PT15M

Periods also sort, shortest first - including across the boundary between calendar and fixed-length periods:

periods = [Period.of_days(100), Period.of("PT1.5S"), Period.of_seconds(1), Period.of_years(1), Period.of_months(1)]

print(sorted(periods))
[PT1S, PT1.5S, P1M, P100D, P1Y]

Note

A calendar month has no fixed length, so ordering takes it as its mean Gregorian length of 30.436875 days (365.2425 / 12). That approximation is used only to order periods.

Note

Sorting periods with mismatched tzinfo values raises TypeError, exactly as sorting naive and aware datetimes does. Equality is safe in all cases - a period with a timezone is never equal to one without. See Time zones.

Counting one period inside another#

count() returns how many intervals of one period fit into each interval of another. A count exists when the smaller period’s intervals fill the larger one exactly - one starts where the larger interval starts, another ends where it ends, and no part-intervals fall in between.

pt15m = Period.of_minutes(15)
pt1h = Period.of_hours(1)
p1d = Period.of_days(1)
p1m = Period.of_months(1)
p1y = Period.of_years(1)

print(pt15m.count(pt1h))
print(pt1h.count(p1d))
print(p1m.count(p1y))
4
24
12

Two situations have no such number. Both return None rather than raising, because asking is legitimate and the answer “there isn’t one” is a real answer:

Case

Example

aligned, but the count varies

days in a month - clean boundaries, 28 to 31 of them

not aligned, or this period is the larger

15 minutes offset by 5 against a plain hour

pt1h = Period.of_hours(1)
p1d = Period.of_days(1)

print(p1d.count(Period.of_months(1)))  # aligned, but months hold 28-31 days
print(p1d.count(pt1h))  # the larger never fits inside the smaller
print(Period.of_minutes(25).count(pt1h))  # 60 is not a multiple of 25
None
None
None

To tell the two None cases apart, ask is_subperiod_of(), below - it is True for the first and False for the second.

Offsets are taken into account. What matters is not that the offsets are equal, but that they differ by a whole number of the smaller period - that is what makes every boundary of the larger land on a boundary of the smaller:

pt15m_t5m = Period.of("PT15M+T5M")

print(pt15m_t5m.count(Period.of_hours(1)))  # :05 :20 :35 :50 straddle the hour
print(pt15m_t5m.count(Period.of("PT1H+T5M")))  # both offset alike
print(pt15m_t5m.count(Period.of("PT1H+T20M")))  # offsets differ by one 15-minute step
None
4
4

Testing containment#

is_subperiod_of() asks the question: does every interval of this period fall wholly inside a single interval of the other, never straddling a boundary?

pt15m = Period.of_minutes(15)
pt1h = Period.of_hours(1)
p1d = Period.of_days(1)

pt1h.is_subperiod_of(p1d)  # True - an hour never straddles midnight
p1d.is_subperiod_of(Period.of_months(1))  # True - nor a day a month boundary
pt15m.is_subperiod_of(pt1h)  # True

p1d.is_subperiod_of(pt1h)  # False - the larger is never contained by the smaller
Period.of("PT15M+T5M").is_subperiod_of(pt1h)  # False

p1d.is_subperiod_of(p1d)  # True - a period always contains itself

Which to use: is_subperiod_of() when you need to know that aggregating is safe; count() when you also need to know how many values to expect - for example to report completeness.

Epoch agnosticism#

is_epoch_agnostic() says whether a period splits the timeline the same way regardless of where counting begins. A period is epoch agnostic when its length divides evenly into the fixed unit above it - a day for clock periods, a year for calendar ones:

Period.of_minutes(15).is_epoch_agnostic()  # True - 15 minutes divides into a day
Period.of_hours(6).is_epoch_agnostic()  # True
Period.of_days(1).is_epoch_agnostic()  # True
Period.of_months(3).is_epoch_agnostic()  # True - 3 months divides into a year

Period.of_days(7).is_epoch_agnostic()  # False - which 7 days? depends where you start
Period.of_minutes(7).is_epoch_agnostic()  # False
Period.of_months(5).is_epoch_agnostic()  # False

Why it matters: an epoch-agnostic period is safe to publish on its own - anybody reading "PT15M" will place the boundaries where you did. A period that is not needs an explicit origin to be unambiguous, so exchange it as "2024-01-01/P7D" rather than "P7D". See Offsets and origins.

Note

An origin does not change the answer. is_epoch_agnostic() reports on the period’s length alone, and 2024-01-01/P7D splits the timeline exactly as P7D+1D does - the origin only fixes which interval is numbered zero:

p7d = Period.of("2024-01-01/P7D")

p7d.is_epoch_agnostic()  # False - the same answer as a plain P7D

# The origin fixes the numbering, not the split: strip it and the plain offset period is left.
print(p7d.without_ordinal_shift())
P7D+1D