Skip to content

Coverage and load profiles

coverage_profile turns intervals into new segments describing how much is active at each coordinate. Use it for genomic coverage depth, concurrent resource demand, or zero-load periods inside an observation horizon.

Coverage depth

Without weight, each interval contributes one unit. Columns called weight or load are otherwise ignored.

import polars as pl
import polars_intervals as pi

reads = pl.DataFrame({"start": [0, 2, 5], "end": [4, 5, 7]})
depth = pi.coverage_profile(reads)
print(depth.rows())  # [(0, 2, 1), (2, 4, 2), (4, 7, 1)]

Empty intervals contribute nothing. Duplicate nonempty rows count independently. The boundary at 5 disappears because the load stays at one even though the active read changes. Touching segments with equal loads merge. Equal loads separated by an uncovered gap remain separate segments.

Coverage depth describes coordinates. To count overlaps for each input row, use overlap_count. To measure covered length inside each reporting window, use coverage_stats.

Resource demand

Use weight for a constant quantity used throughout an interval, such as the number of machines a job needs.

jobs = reads.with_columns(pl.Series("demand", [2, 3, 3]))
load = pi.coverage_profile(jobs, weight="demand")
print(load.rows())  # [(0, 2, 2), (2, 4, 5), (4, 7, 3)]

At each coordinate, load is the sum of quantities used by active intervals. See the shared weight and cost rules for accepted types and values.

Observation horizons and gaps

By default, each group's domain runs from its earliest nonempty start to its latest nonempty end, including zero-weight intervals. Empty intervals do not extend the domain. An empty input or a group with only empty intervals has no inferred domain.

Pass domain_start and domain_end to set an observation horizon. Rows are validated first, then clipped. Use include_zero=True to include uncovered gaps across the entire domain:

observed = pi.coverage_profile(
    jobs, weight="demand", domain_start=-1, domain_end=8, include_zero=True
)
print(observed.rows())
# [(-1, 0, 0), (0, 2, 2), (2, 4, 5), (4, 7, 3), (7, 8, 0)]

The default include_zero=False omits zero-load segments. With include_zero=True, output covers the inferred or supplied domain. All-zero nonempty inputs produce one zero-load segment across their domain. An ungrouped empty input produces one zero-load segment when given nonempty bounds. Equal bounds produce no rows. Reversed bounds are invalid.

Bounds follow the scalar rules of minimum_cover. Python integers must fit the endpoint dtype. Typed one-element Series must match its dtype exactly. Use typed Series for exact nanoseconds, time units or timezone metadata:

timestamp_type = pl.Datetime("ns", "Europe/Helsinki")
timestamps = pl.DataFrame({"start": [2**53 + 1], "end": [2**53 + 5]}).cast(timestamp_type)
timed = pi.coverage_profile(
    timestamps,
    domain_start=pl.Series([2**53 + 2], dtype=timestamp_type),
    domain_end=pl.Series([2**53 + 4], dtype=timestamp_type),
)

Endpoints retain their exact types, time units and timezones.

Independent groups

chromosomes = reads.with_columns(pl.Series("chromosome", ["chr2", "chr1", "chr2"]))
per_chromosome = pi.coverage_profile(chromosomes, by="chromosome")
print(per_chromosome.rows())
# [('chr2', 0, 4, 1), ('chr2', 5, 7, 1), ('chr1', 2, 5, 1)]

Groups appear in first-appearance order, with segments sorted by start within each group. See the shared frame rules for supported group keys. Here, start, end and load are reserved output names.

Bounds apply to every group present in the input. A group containing only empty intervals produces a zero-load segment with explicit nonempty bounds and include_zero=True. A grouped empty frame produces no rows.

Output and validation

Output columns are [requested group keys..., "start", "end", "load"], even when source columns have other names. load is always non-null pl.Int128, including unweighted and empty results. See the shared interval rules for endpoint requirements.

Every input row is validated, including empty, zero-weight and clipped-away rows. Error row indices refer to the input at this operation, after any earlier filters or sorts. A segment load that exceeds Int128 raises an error.

Lazy queries

Pass a LazyFrame to keep the profile in a query plan. Input expressions run before the profile, and downstream operations consume its new segment rows:

query = (
    jobs.lazy()
    .with_columns((pl.col("demand") * 2).alias("demand"))
    .pipe(pi.coverage_profile, weight="demand")
    .filter(pl.col("load") >= 6)
)
print(query.collect().rows())  # [(2, 4, 10), (4, 7, 6)]

Filters before the profile choose which intervals contribute. Filters after it choose which computed segments to keep. Like the other frame operations, the profile needs its complete input in memory, including when collected with the streaming engine.

Passing loads to other APIs

To reuse these Int128 loads as 64-bit quantities, cast with strict=True:

bounded = load.with_columns(pl.col("load").cast(pl.UInt64, strict=True))
again = pi.coverage_profile(bounded, weight="load")
assert again.equals(load)

For example, two overlapping maximum UInt64 demands produce a load of 36893488147419103230. That value cannot fit in UInt64, so the cast raises an error.

See the API reference for the full contract and the benchmarks for runtime, memory use and algorithm details.