Usage¶
Use polars-intervals to compare intervals, assign lanes, and choose intervals for scheduling or covering. You can also combine intervals, find gaps, and measure coverage or resource demand.
A first example¶
Suppose three appointments run from 0 to 2, 1 to 3, and 2 to 4. Count how many other appointments overlap each one:
import polars as pl
import polars_intervals as pi
appointments = pl.DataFrame({"start": [0, 1, 2], "end": [2, 3, 4]})
result = (
appointments.lazy().with_columns(pi.overlap_count("start", "end").alias("overlaps")).collect()
)
print(result["overlaps"].to_list()) # [1, 2, 1]
The middle appointment overlaps both neighbors. The first and last only touch at 2, so they do not overlap. The remaining examples use the same imports.
Inputs¶
Interval rules¶
Each row describes a half-open interval [start, end): it includes its start
and excludes its end. An interval with start == end is empty. Empty intervals
overlap nothing.
Endpoints must be non-null, with start <= end in every row. Both columns must
have the same dtype, and nothing is cast for you:
| Endpoint type | Requirement |
|---|---|
| Integer | The same signed or unsigned 8-, 16-, 32-, or 64-bit dtype |
Date |
Both columns must be Date |
Datetime |
The same time unit and timezone |
Other endpoint types, including Int128, floating-point, Boolean, Decimal,
Time and Duration, are rejected.
Weights, costs, and other values¶
Cost, weight, capacity and initial-lane columns accept Int8, Int16, Int32,
Int64, UInt8, UInt16, UInt32 and UInt64. Supply one non-null value per
input row. Values are not cast automatically. All other dtypes are rejected,
including floating-point, Boolean, Decimal, temporal and 128-bit integers.
| Input | Functions | Allowed values |
|---|---|---|
Selection weight |
max_weight_non_overlapping, max_weight_with_capacity, max_weight_with_capacity_profile, max_weight_clique |
Zero and negative values are valid but never selected. |
cost |
minimum_cost_cover, minimum_cost_dominating_set |
Nonnegative. Zero is valid. |
Quantity weight |
coverage_profile |
Nonnegative. Zero is valid. |
Profile capacity |
max_weight_with_capacity_profile |
Nonnegative. Zero is valid. |
initial_lanes |
assign_balanced_lanes |
IDs must fit UInt32 and form a valid assignment using the minimum number of lanes, with contiguous IDs from zero. |
Omitted costs in minimum_cost_dominating_set and omitted weights in
max_weight_clique or coverage_profile mean one per row. These functions do
not automatically use a column named cost, weight or load.
max_weight_with_capacity_profile reads the weight and capacity columns by
default.
Scalar options capacity, k and max_work require nonnegative Python integers.
Boolean values are rejected. See each function's API reference for its
range limits.
Column names, expressions, and results¶
Most functions accept column names such as "start" or expressions such as
pl.col("start"), and return an expression for an eager or lazy query. The
input does not need to be sorted.
Selections return a Boolean mask. Use it in filter to keep the chosen rows,
or in with_columns to flag them.
Frame results¶
These functions return a DataFrame when all inputs are DataFrames. If any input
is a LazyFrame, they return a LazyFrame and evaluate all inputs together when
collected. Call them directly or with .pipe(...).
coverage_stats appends source counts, exact covered and
query lengths, and covered fractions to every query row. It preserves query
columns and their original row order. Use by to match groups across the two
inputs, including null keys.
New segment rows¶
coverage_profile, merge_intervals and interval_gaps return new segment
rows.
subtract_intervals and intersect_intervals
take two frames and return new covered ranges. All four endpoint columns must
have exactly the same logical dtype. Shared group keys must also match in dtype.
Result groups follow their first appearance in the left input.
Endpoint arguments take literal column names. Output endpoints are always named
start and end. Endpoint and group-key dtypes are preserved, including in
empty results. Other input columns are not retained.
Use by="resource" or an ordered list of distinct column names to solve groups
independently. by=None and by=[] use one collection. Keys support String,
Boolean, the integer types above, Date and Datetime. Null key values group
together. Group keys cannot use names reserved for output columns.
Groups appear in first-appearance order, with segments sorted by start within each group. Only groups present in the input can produce output. A grouped empty frame has no output groups.
All frame operations above need their complete inputs in memory, even with
.collect(engine="streaming"). All chunks participate in the same operation.
Schema checks happen when the query is built. Every input row is validated when
it runs, including empty or clipped-away intervals.
Count within groups¶
Use .over(...) to solve each group independently while keeping row order:
appointments = pl.DataFrame(
{
"room": ["a", "b", "a", "b"],
"start": [1, 1, 2, 5],
"end": [4, 4, 3, 6],
}
)
result = (
appointments.lazy()
.with_columns(pi.overlap_count("start", "end").over("room").alias("overlaps"))
.collect()
)
print(result["overlaps"].to_list()) # [1, 0, 1, 0]
This works for expressions that return one value or selection flag per row. Use
group_by(...).agg(...) instead to get one list per group.
Choose which rows to compare¶
Filtering before an operation changes the intervals it sees. To keep a result
computed from the full input, add it with with_columns first, then filter.
Choose an algorithm¶
| Task | Algorithms |
|---|---|
| Inspect interval relationships | Count overlaps, count contained rows, measure nesting depth |
| Interval geometry | Label connected rows, combine ranges, find gaps, subtract coverage, or intersect collections |
| Assign lanes | Use the fewest lanes, then optionally balance their row counts |
| Select intervals by weight | Choose a schedule with one or more slots, or choose a mutually overlapping set |
| Covering and coverage | Cover a target, maximize covered length with a budget, or hit every interval with points |
| Choose representative intervals | Select rows so every input row is selected or overlaps a selected row |
| Coverage and load profiles | Measure how much is active at each coordinate |
| Per-query coverage statistics | Count source intervals and measure covered length inside each reporting window |
The API reference has full signatures and edge cases.
Inspect interval relationships¶
Count overlaps¶
overlap_count counts how many other intervals overlap each row, as in the
first example.
Count containment¶
containment_count counts the other intervals inside each row:
intervals = pl.DataFrame({"start": [0, 2, 4], "end": [10, 5, 12]})
result = (
intervals.lazy().with_columns(pi.containment_count("start", "end").alias("contained")).collect()
)
print(result["contained"].to_list()) # [1, 0, 0]
The first interval contains [2, 5). It overlaps [4, 12), but does not
contain it. Identical rows contain one another.
Nesting depth¶
nesting_depth counts how many levels of containers sit above each row.
Outermost intervals have depth zero:
intervals = pl.DataFrame({"start": [0, 2, 2, 3], "end": [10, 8, 8, 7]})
result = intervals.lazy().with_columns(pi.nesting_depth("start", "end").alias("depth")).collect()
print(result["depth"].to_list()) # [0, 1, 1, 2]
Identical intervals share a depth, like the two [2, 8) rows. Depth follows
the longest chain, not the number of containers: [0, 8) and [2, 10) both
contain [4, 5), but neither contains the other, so [4, 5) has depth 1.
API reference | Benchmarks and algorithm notes
Assign lanes¶
Use lane assignment when every interval needs a place, such as a room in a calendar or a track in a timeline. Intervals in the same lane do not overlap.
Assign the minimum number of lanes¶
assign_lanes places all intervals into the fewest possible lanes:
appointments = pl.DataFrame({"start": [0, 1, 2], "end": [2, 3, 4]})
result = appointments.lazy().with_columns(pi.assign_lanes("start", "end").alias("lane")).collect()
print(result["lane"].to_list()) # [0, 1, 0]
The first and last appointments touch, so they share a lane.
Balance lane row counts¶
assign_balanced_lanes also uses the fewest lanes, then tries to even out the
number of rows in each:
intervals = pl.DataFrame({"start": [0, 0, 1, 2, 3, 4], "end": [10, 10, 1, 2, 3, 4]})
result = (
intervals.lazy().with_columns(pi.assign_balanced_lanes("start", "end").alias("lane")).collect()
)
print(sorted(result["lane"].value_counts()["count"].to_list())) # [3, 3]
The two long intervals need two lanes, and the four empty rows are split between them. Balancing is a heuristic, so the most even split is not guaranteed.
API reference | Measured quality, runtime, and work limits
Select intervals by weight¶
These operations choose the rows with the largest total weight, such as revenue or priority, under a constraint. Rows with zero or negative weight are never selected.
Select a globally maximum-weight schedule¶
max_weight_non_overlapping selects intervals that can run without overlaps:
jobs = pl.DataFrame(
{
"start": [0, 0, 4, 7],
"end": [10, 4, 7, 10],
"revenue": [15, 10, 10, 10],
}
)
chosen = (
jobs.lazy().filter(pi.max_weight_non_overlapping("start", "end", weight="revenue")).collect()
)
print(chosen["revenue"].sum()) # 30
The three shorter jobs earn 30 together, compared with 15 for the long job.
Select with a simultaneous capacity¶
max_weight_with_capacity allows up to capacity selected intervals to
overlap. Using the same jobs:
chosen = (
jobs.lazy()
.filter(pi.max_weight_with_capacity("start", "end", weight="revenue", capacity=2))
.collect()
)
print(chosen["revenue"].sum()) # 45
With two slots, the long job can run alongside all three short jobs.
Select with a capacity profile¶
max_weight_with_capacity_profile handles capacity that changes over time.
Give it the jobs and a separate table describing the available slots:
jobs = pl.DataFrame(
{"start": [9, 10, 11, 14], "end": [13, 12, 15, 17], "weight": [100, 60, 130, 90]}
)
profile = pl.DataFrame({"start": [9, 12, 14], "end": [12, 14, 18], "capacity": [3, 1, 4]})
selected = pi.max_weight_with_capacity_profile(jobs, profile)
result = jobs.filter(selected)
print(selected.to_list()) # [False, True, True, True]
print(result["weight"].sum()) # 280
Capacity is three from 9 to 12, one from 12 to 14, and four from 14 to 18. The jobs worth 100 and 130 both cross the noon bottleneck, so only one can be selected. Times outside the profile have zero capacity.
API reference | Benchmarks and algorithm notes
Select a maximum-weight clique¶
Use max_weight_clique to choose intervals that all overlap one another.
Without weight, it selects as many rows as possible:
intervals = pl.DataFrame({"start": [0, 1, 2, 10], "end": [5, 4, 3, 11], "value": [1, 1, 1, 9]})
most_rows = intervals.lazy().filter(pi.max_weight_clique("start", "end")).collect()
most_value = intervals.lazy().filter(pi.max_weight_clique("start", "end", weight="value")).collect()
print(most_rows["start"].to_list()) # [0, 1, 2]
print(most_value["start"].to_list()) # [10]
The first three rows form the largest clique, but the last row alone has greater weight.
API reference | Benchmarks and correctness notes
Covering and coverage¶
Cover one continuous target¶
minimum_cover selects the fewest intervals needed to cover a target:
intervals = pl.DataFrame({"start": [0, 0, 4, 6, 7], "end": [4, 6, 7, 10, 10]})
chosen = (
intervals.lazy()
.filter(pi.minimum_cover("start", "end", target_start=0, target_end=10))
.collect()
)
print(chosen.rows()) # [(0, 6), (6, 10)]
If the target cannot be fully covered, the query raises an error.
Cover at minimum cost¶
minimum_cost_cover covers the same kind of target at the lowest total cost:
intervals = pl.DataFrame({"start": [0, 0, 5], "end": [10, 5, 10], "cost": [100, 10, 10]})
chosen = (
intervals.lazy()
.filter(pi.minimum_cost_cover("start", "end", cost="cost", target_start=0, target_end=10))
.collect()
)
print(chosen.rows()) # [(0, 5, 10), (5, 10, 10)]
print(chosen["cost"].sum()) # 20
The two shorter intervals cost 20 together, compared with 100 for the single full-length interval.
Select maximum coverage with a budget¶
max_k_coverage selects at most k intervals whose union has the greatest
total length:
intervals = pl.DataFrame({"start": [0, -5, 6], "end": [10, 4, 15]})
chosen = intervals.lazy().filter(pi.max_k_coverage("start", "end", k=2)).collect()
print(chosen.rows()) # [(-5, 4), (6, 15)]
The selected intervals cover 18 units. Starting from the longest interval,
[0, 10), would cover at most 15.
API reference | Benchmarks and algorithm notes
Minimum stabbing points¶
minimum_stabbing_points chooses the fewest points needed to hit every
interval:
intervals = pl.DataFrame({"start": [0, 2, 5], "end": [4, 6, 9]})
result = (
intervals.lazy().select(pi.minimum_stabbing_points("start", "end").alias("points")).collect()
)
print(result["points"].to_list()) # [[3, 8]]
Point 3 hits the first two intervals, and point 8 hits the third. The result is one sorted list. Empty intervals raise an error because no point can hit them.
Choose representative intervals¶
Use representatives when every input interval must be selected or overlap a selected interval.
Select a minimum-cost dominating set¶
minimum_cost_dominating_set chooses representatives at minimum total cost.
Without cost, it selects the fewest rows:
intervals = pl.DataFrame({"start": [0, 3, 6], "end": [4, 7, 10], "price": [1, 10, 1]})
fewest_rows = intervals.lazy().filter(pi.minimum_cost_dominating_set("start", "end")).collect()
lowest_cost = (
intervals.lazy().filter(pi.minimum_cost_dominating_set("start", "end", cost="price")).collect()
)
print(fewest_rows["start"].to_list()) # [3]
print(lowest_cost["start"].to_list()) # [0, 6]
print(lowest_cost["price"].sum()) # 2
The middle interval overlaps both neighbors, so it alone represents all three. With prices, the two outer intervals cost 2, compared with 10 for the middle.