Genomic Intervals
Genomic intervals are a fundamental abstraction in bioinformatics, representing
regions on a chromosome or contig. BioLang provides the Interval type
as a first-class value with set-theoretic operations, an implicit interval tree for
efficient overlap queries, and windowing functions for sliding-window analyses.
Creating Intervals
Use the interval() constructor to create individual intervals. Coordinates
are zero-based, half-open (matching BED convention):
# Basic interval: chromosome, start, end
let region = interval("chr1", 1000, 2000)
# With strand information (4th positional argument)
let exon = interval("chr1", 1000, 2000, "+")
let antisense = interval("chr1", 1000, 2000, "-")
# Access fields
print(region.chrom) # => "chr1"
print(region.start) # => 1000
print(region.end) # => 2000
print(exon.strand) # => "+"
# Width/length
region |> len() # => 1000
Intersect
intersect() returns the overlapping portion of two intervals. If they do
not overlap, it returns nil:
# Intersect two BED tables (tables with chrom, start, end columns)
let exons = read_bed("data/regions.bed")
let peaks = read_bed("data/regions.bed")
# Find all pairwise intersections
let shared = intersect(exons, peaks)
print(nrow(shared), "overlapping regions")
Merge
merge() combines overlapping or book-ended intervals into a single
contiguous interval. On collections, it merges all overlapping intervals:
# Merge overlapping intervals in a table (collapses overlapping rows)
# The table must have chrom, start, end columns
let regions = read_bed("data/regions.bed")
let merged = merge_intervals(regions)
print(nrow(merged), "merged regions")
Subtract
subtract() removes the portion of one interval that overlaps with another.
The result may be zero, one, or two intervals:
# Subtract one table's intervals from another
let exons = read_bed("data/regions.bed")
let repeats = read_bed("data/regions.bed")
# Remove repeat-overlapping portions from exons
let clean_exons = subtract(exons, repeats)
print(nrow(clean_exons), "clean exon regions")
Closest
closest() finds the nearest interval in a collection to a query interval.
Returns both the interval and the distance:
# Find closest intervals between two tables
# Returns a table with the closest match for each row in the first table
let queries = read_bed("data/regions.bed")
let genes = read_bed("data/regions.bed")
let result = closest(queries, genes)
print(result)
Interval Tree
For repeated overlap queries against a large collection, build an interval tree for O(log n + k) query time instead of O(n):
# Build an interval tree from a BED file
let tree = read_bed("data/regions.bed") |> interval_tree()
# Query the tree for overlapping intervals
let hits = query_overlaps(tree, "chr1", 50000, 51000)
print(nrow(hits), "annotations overlap the query region")
# Find nearest interval to a region
let nearest = query_nearest(tree, "chr1", 50000, 51000)
print(nearest)
Sliding Window Analysis
Use windows() to extract typed sliding windows of a given size for
genome-wide analyses:
# Sliding-window GC content
let seq = dna"ATCGATCGATCGATCGATCGATCG"
windows(seq, 6, 1)
|> map(|window| {
start: window.pos,
gc: gc_content(window.seq)
})
|> to_table()
|> print()
Combining Operations
Interval operations compose naturally with BioLang's pipe syntax:
# Find promoter regions that overlap with ChIP-seq peaks
# but not with known repeat elements
let genes = read_bed("data/regions.bed")
let peaks = read_bed("data/regions.bed")
let repeats = read_bed("data/regions.bed")
# Use flank() to get upstream regions (2kb)
let promoters = flank(genes, 2000)
# Intersect with peaks, then subtract repeats, then merge
let result = intersect(promoters, peaks)
result = subtract(result, repeats)
result = merge_intervals(result)
write_bed(result, "clean_promoter_peaks.bed")
Table-Based BED Operations
The following functions operate on Tables that have chrom,
start, and end columns — matching standard BED format. They
implement the most-used bedtools operations natively, without shelling out to
external tools. All coordinate arithmetic follows the zero-based, half-open convention.
bed_intersect(a, b)
For each row in table a, finds every overlapping row in table b
(same chrom, overlapping coordinates). Returns a table containing all
columns from a plus three derived columns:
overlap_start— start of the shared regionoverlap_end— end of the shared regionoverlap_length— number of overlapping bases
Rows in a with no overlap in b are dropped. To keep them,
use bed_closest() with a distance filter instead.
# Find CDS intervals that overlap a variant set
let cds = read_bed("annotation/cds.bed")
let vcf = read_bed("variants/snps.bed") # bed3+ export from VCF
let coding_variants = bed_intersect(cds, vcf)
# coding_variants now has all cds columns plus overlap_start, overlap_end, overlap_length
print(nrow(coding_variants), "variants fall inside coding sequence")
# Filter to only variants with at least 1 bp overlap (always true for bed_intersect,
# but useful after a join that may produce zero-length artefacts)
let confirmed = coding_variants |> filter(|r| r.overlap_length > 0)
# Summarise per gene
confirmed
|> group_by("gene_id")
|> summarize(|gene_id, rows| {
{ gene_id: gene_id, variant_count: len(rows) }
})
|> sort_by(|row| -row.variant_count)
|> print()
bed_subtract(a, b)
Removes all bases in b from the intervals in a. An interval
in a that is only partially covered by b is split
into one or two shorter intervals. The returned table has the same columns as a;
extra columns from b are not included.
# Mask repeat regions from a gene annotation before peak analysis
let genes = read_bed("annotation/genes.bed")
let repeats = read_bed("annotation/repeatmasker.bed")
let clean_genes = bed_subtract(genes, repeats)
print(nrow(genes), "input gene intervals")
print(nrow(clean_genes), "intervals after repeat masking")
# Compare total coverage before and after
let bp_before = genes |> map(|r| r.end - r.start) |> sum()
let bp_after = clean_genes |> map(|r| r.end - r.start) |> sum()
print("Repeat content removed:", bp_before - bp_after, "bp")
# Write masked annotation for downstream analysis
write_bed(clean_genes, "annotation/genes_masked.bed")
bed_merge(a, gap?)
Merges overlapping or adjacent intervals within a. The optional
gap parameter (default 0) allows intervals separated by at
most gap bases to be merged as well — useful for collapsing nearly-adjacent
peaks into a single region. The algorithm sorts by (chrom, start) and
then greedily extends intervals. The returned table has the same columns as a;
non-coordinate columns are taken from the first row of each merged
group.
# Merge overlapping exons produced by an RNA-seq assembler
let raw_exons = read_bed("rnaseq/assembled_exons.bed")
# Strict merge: only overlapping / book-ended intervals
let merged_strict = bed_merge(raw_exons)
print(nrow(merged_strict), "exons after strict merge")
# Permissive merge: also join exons separated by <= 50 bp (e.g. small introns)
let merged_50 = bed_merge(raw_exons, 50)
print(nrow(merged_50), "exons after 50 bp gap merge")
# Typical ChIP-seq workflow: load peaks, merge, write
read_bed("chipseq/peaks.bed")
|> bed_merge(0)
|> write_bed("chipseq/peaks_merged.bed")
# Count how many raw peaks collapsed into each merged region
let with_counts =
bed_intersect(merged_strict, raw_exons)
|> group_by("chrom")
|> summarize(|chrom, rows| {
{ chrom: chrom, source_count: len(rows) }
})
print(with_counts)
bed_closest(a, b)
For each row in a, finds the single nearest row in b on the
same chromosome. The distance column is 0 when the intervals
overlap; otherwise it is the number of bases between the nearest endpoints. Columns
from b are prefixed with b_ to avoid name collisions.
# Assign each ChIP-seq peak to its nearest annotated gene
let peaks = read_bed("chipseq/peaks_merged.bed")
let genes = read_bed("annotation/genes.bed") # must have chrom, start, end, gene_name
let peak_gene = bed_closest(peaks, genes)
# peak_gene columns: all peak cols + b_chrom, b_start, b_end, b_gene_name, distance
# Keep only peaks within 50 kb of a gene
let proximal = peak_gene |> filter(|r| r.distance <= 50000)
print(nrow(proximal), "peaks within 50 kb of a gene")
# Summarise: how many peaks per gene, and what is the median distance?
proximal
|> group_by("b_gene_name")
|> summarize(|gene_name, rows| {
{
gene_name: gene_name,
peak_count: len(rows),
median_distance: rows |> col("distance") |> median()
}
})
|> sort_by(|row| -row.peak_count)
|> head(20)
|> print()
# Full pipeline: load → merge peaks → assign genes → filter → export
read_bed("chipseq/peaks.bed")
|> bed_merge(100)
|> bed_closest(genes)
|> filter(|r| r.distance <= 10000)
|> select("chrom", "start", "end", "b_gene_name", "distance")
|> write_bed("chipseq/peak_gene_assignments.bed")