Interval geometry¶
Use these operations to group connected intervals, merge ranges, find gaps, subtract exclusions, or intersect two collections:
| Question | Function | Output |
|---|---|---|
| Which records are connected by overlap? | cluster_intervals |
One UInt32 cluster ID per original row |
| Which coordinates are covered? | merge_intervals |
Maximal covered ranges |
| Which coordinates in a domain are uncovered? | interval_gaps |
Maximal uncovered ranges |
| Which left coordinates remain after exclusions? | subtract_intervals |
Maximal remaining ranges |
| Which coordinates do both collections cover? | intersect_intervals |
Maximal shared ranges |
All operations follow the shared input rules.
A worked example¶
import polars as pl
import polars_intervals as pi
df = pl.DataFrame({"start": [1, 3, 5, 8, 12], "end": [4, 6, 8, 10, 15]})
result = df.with_columns(
pi.cluster_intervals("start", "end").alias("strict"),
pi.cluster_intervals("start", "end", include_touching=True).alias("touching"),
)
print(result["strict"].to_list()) # [0, 0, 0, 1, 2]
print(result["touching"].to_list()) # [0, 0, 0, 0, 1]
print(pi.merge_intervals(df).rows())
# [(1, 10), (12, 15)]
print(pi.interval_gaps(df, domain_start=0, domain_end=16).rows())
# [(0, 1), (10, 12), (15, 16)]
The first three rows are connected through overlaps. They need not all share
one point. The fourth row only touches that component at 8, so it joins only
with include_touching=True. merge_intervals always joins touching ranges
because there is no uncovered coordinate between them.
Cluster IDs follow each component's first appearance in the original input.
For [10, 12), [0, 2), [1, 3), they are [0, 1, 1]. Permuting input rows may
renumber components while preserving their membership. IDs restart at zero
inside each group.
Empty rows and domains¶
Every [x, x) row is an isolated cluster, including duplicate empty rows and
clustering with include_touching=True. It never connects two nonempty rows.
Empty rows contribute nothing to merged ranges and never split a gap.
| Input | Clusters | Merged ranges | Gaps in a nonempty domain D |
|---|---|---|---|
| No rows, ungrouped | Empty UInt32 column |
No rows | D |
| Three empty rows, ungrouped | [0, 1, 2] |
No rows | D |
| No rows, grouped | No observed groups | No rows | No rows |
Both gap bounds are required. The same scalar domain applies to every observed
group. Bounds follow the scalar rules of minimum_cover.
Equal bounds return no gaps. Reversed bounds are invalid.
All original rows are validated before clipping, including rows outside the domain and calls with an empty domain. A group with no coverage inside the domain returns the whole domain. Only groups present in the input produce gaps. Gaps include uncovered leading and trailing regions within the bounds.
Grouping and schemas¶
grouped = df.with_columns(pl.Series("resource", ["a", "a", "a", "b", "b"]))
labels = grouped.with_columns(
pi.cluster_intervals("start", "end").over("resource").alias("cluster")
)
lists = grouped.group_by("resource", maintain_order=True).agg(
pi.cluster_intervals("start", "end").alias("clusters")
)
merged = pi.merge_intervals(grouped, by="resource")
gaps = pi.interval_gaps(grouped, by="resource", domain_start=0, domain_end=16)
Merged ranges and gaps return [requested keys..., "start", "end"], even with
custom input endpoint names. Endpoints keep their exact dtypes, including when
no segments remain. Segments inside each group are sorted by start, nonempty,
and strictly separated. Other input columns are omitted.
See the shared frame and grouping rules.
merge_intervals and interval_gaps reserve the output names start and end,
so group keys cannot use them.
Eager and lazy execution¶
This pipeline computes gaps for each resource, then keeps gaps starting at 8 or later:
query = (
grouped.lazy()
.pipe(pi.interval_gaps, by="resource", domain_start=0, domain_end=16)
.filter(pl.col("start") >= 8)
.select("resource", "start", "end")
)
result = query.collect(engine="streaming")
Filtering before an operation changes the intervals it sees. Filtering after it selects computed results. See frame execution for lazy and streaming behavior.
The API reference has full signatures and edge cases. The benchmark report covers runtime, memory use, and algorithm details.
Subtract and intersect two collections¶
Subtract busy windows from availability, or find times when two resources are both available. Both functions act on the union represented by each collection:
left = pl.DataFrame({"start": [0, 4, 12], "end": [5, 10, 15]})
right = pl.DataFrame({"start": [2, 6, 10], "end": [3, 8, 13]})
print(pi.subtract_intervals(left, right).rows())
# [(0, 2), (3, 6), (8, 10), (13, 15)]
print(pi.intersect_intervals(left, right).rows())
# [(2, 3), (6, 8), (12, 13)]
The original left boundary at 5 disappears. Each output contains maximal,
nonempty, disjoint ranges. Overlapping and touching fragments coalesce, and
each end is strictly below the next start. Touching inputs alone have no
intersection: [0, 2) and [2, 4) share no covered coordinates.
Subtraction cuts away coverage, so one left row may produce several ranges. It is not a whole-row anti-join or subtraction that preserves each source record's fragments. Intersection does not enumerate overlapping record pairs. Neither operation returns source IDs, payload columns, or metadata aggregation. These are geometric set operations, not a BEDTools compatibility API.
Availability by resource¶
availability = pl.DataFrame(
{"resource": ["desk", "room", "desk"], "start": [0, 0, 8], "end": [6, 10, 12]}
)
busy = pl.DataFrame({"resource": ["desk"], "start": [2], "end": [4]})
print(pi.subtract_intervals(availability, busy, by="resource").rows())
# [('desk', 0, 2), ('desk', 4, 6), ('desk', 8, 12), ('room', 0, 10)]
The room has no busy rows, so subtraction keeps all its covered ranges. A left-only group has no intersection. A right-only group emits nothing. Null key values match nulls, including in multi-column keys. There is no global right-side broadcast. Both sides use the same key names. Rename keys upstream if needed.
Groups follow their first appearance in the original left input, before empty rows are dropped. Ranges are sorted within each group. Swapping intersection's operands preserves geometry per key but can change group-block order.
Empty rows contribute nothing, and duplicates do not change coverage. Empty left input returns a typed empty result. Empty right input leaves the left union for subtraction and returns no intersection. Every row on both sides still validates, including right-only groups and calls with an empty operand. Errors name the side. Reversed intervals also report their original row within the evaluated operand.
Lazy and mixed inputs¶
query = availability.lazy().pipe(pi.subtract_intervals, busy.lazy(), by="resource")
result = query.filter(pl.col("start") >= 4).collect(engine="streaming")
# An eager operand becomes an in-memory source in the same deferred plan.
shared = pi.intersect_intervals(availability, busy.lazy(), by="resource")
result = shared.collect()
Two DataFrames return a DataFrame. If either argument is a LazyFrame, the
result stays lazy. Construction, explain() and collect_schema() read no
input rows. Both inputs are evaluated together when you collect the result.
The operation needs both complete inputs in memory, including with the streaming
engine. Downstream filters, slices and projections apply to the completed
geometry. Upstream operations change the intervals that the function sees.
Use left_start, left_end, right_start and right_end for custom literal
column names. Output names remain start and end. All four endpoints must
have the same supported dtype, including Datetime units and timezone metadata.
Corresponding key dtypes must match. These checks also apply to typed empty
frames. No casts or inferred Null endpoints are accepted. Exact logical types
survive empty results. Keys cannot be named start or end.
See the subtraction and intersection benchmarks for complete eager/lazy timings and the production algorithm comparison.