Interoperability#

Describing a period as a string, and handing it to other tools.

Describing a period as a string#

Three representations, for three purposes:

Form

Period.of("P1D+T9H")

Use for

iso_duration

"P1D"

strict ISO 8601 - the duration alone, no offset

str()

"P1D+T9H"

round-tripping through of()

repr()

"P1D+T9H"

logging and debugging - as str(), plus the timezone

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

print(water_day.iso_duration)  # strict ISO 8601 - the duration alone
print(str(water_day))  # round-trips through Period.of()
print(repr(water_day))  # as str(), plus the timezone when there is one
P1D
P1D+T9H
P1D+T9H

A period with an origin renders in the <origin>/<duration> form, so it round-trips too:

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

print(p7d)
print(Period.of(str(p7d)) == p7d)
2024-01-01/P7D
True

repr() differs from str() only by adding the timezone, which str() leaves out because of() cannot read one back:


print(repr(Period.of("PT15M").with_tzinfo(UTC)))
print(repr(Period.of("PT15M")))
PT15M[Z]
PT15M

Use repr() - or f"{period!r}" - in log lines and error messages, where the timezone matters and nothing is going to parse the result back.

Note

iso_duration deliberately drops the offset, so it is not enough to reconstruct an offset period. Persist str() if you need the period back.

Converting to a timedelta#

A period whose step is seconds or microseconds has a fixed length, so it converts:

Months and years do not, and return None rather than an approximation:

print(Period.of_minutes(15).timedelta)
print(Period.of_days(1).timedelta)
print(Period.of("PT0.04S").timedelta == timedelta(microseconds=40_000))

print(Period.of_months(1).timedelta)  # no fixed length, so None
print(Period.of_years(1).timedelta)
0:15:00
1 day, 0:00:00
True
None
None

Always check for None before doing timedelta arithmetic - or better, use ordinal() and datetime(), which work for calendar periods too.

Using periods with Polars#

Two properties emit Polars duration strings, so a period can drive a Polars grouping or range directly:

print(Period.of_minutes(15).pl_interval)
print(Period.of_months(1).pl_interval)
print(Period.of("PT0.04S").pl_interval)

print(Period.of("P1D+T9H").pl_offset)
print(Period.of("P1Y+9M").pl_offset)
900s
1mo
40000us
0mo32400000000us
9mo0us

pl_interval gives the period’s length, for arguments such as every and interval; pl_offset gives its offset, for offset and Expr.dt.offset_by:

import polars as pl

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

df = pl.DataFrame(
    {
        "timestamp": pl.datetime_range(datetime(2024, 3, 1), datetime(2024, 3, 4), interval="1h", eager=True),
        "flow": range(73),
    }
)

daily = (
    df.sort("timestamp")
    .group_by_dynamic("timestamp", every=water_day.pl_interval, offset=water_day.pl_offset)
    .agg(pl.col("flow").mean().alias("mean_flow"), pl.len().alias("n"))
)

print(daily)
shape: (4, 3)
┌─────────────────────┬───────────┬─────┐
│ timestamp           ┆ mean_flow ┆ n   │
│ ---                 ┆ ---       ┆ --- │
│ datetime[μs]        ┆ f64       ┆ u32 │
╞═════════════════════╪═══════════╪═════╡
│ 2024-02-29 09:00:00 ┆ 4.0       ┆ 9   │
│ 2024-03-01 09:00:00 ┆ 20.5      ┆ 24  │
│ 2024-03-02 09:00:00 ┆ 44.5      ┆ 24  │
│ 2024-03-03 09:00:00 ┆ 64.5      ┆ 16  │
└─────────────────────┴───────────┴─────┘

Every window starts at 09:00, so the hourly readings are grouped into hydrological days rather than calendar ones. The first and last are partial, because the data does not begin or end on a 09:00 boundary.