Genomic ranges¶
Open this tutorial as a notebook
A SeqPosition is a single site, optionally tied to a SeqRecord holding the
sequence it came from. An SRange is an interval between two of them, and an
SRangeSet is a sorted collection of intervals. Together they cover much of
what you would otherwise reach for bedtools to do.
All coordinates here are 1-based and inclusive at both ends, matching GFF and BED conventions rather than Kotlin's 0-based array indexing.
%use biokotlin
import biokotlin.genome.*
import biokotlin.seq.*
import org.jetbrains.kotlinx.dataframe.api.print
Sequences and sequence records¶
Seq and NucSeq build a nucleotide sequence; ProteinSeq builds a peptide.
A NucSeqRecord pairs a sequence with an identifier, which is what ranges
refer back to.
val seq = Seq("GCAGAT")
val rec1 = NucSeqRecord(NucSeq("ATAACACAGAGATATATC"), "1")
val rec1a = NucSeqRecord(seq, "1")
println(rec1)
println(rec1a)
ID: 1
Sequence: ATAACACAGAGATATATC
ID: 1
Sequence: GCAGAT
Subsetting a sequence¶
Index a record with an IntRange to pull out part of its sequence. The range
is inclusive at both ends, so 1..6 returns six bases.
TAACAC
Positions¶
A SeqPosition may be created with or without a record. With one, the site
must fall inside the stored sequence.
val seqPos1 = SeqPosition(rec1, 8)
println(seqPos1)
val seqPosNoRecord = SeqPosition(null, 8)
println(seqPosNoRecord)
1:8
null:8
Ranges¶
Build an SRange from a record and a coordinate range, or from two
SeqPosition objects directly.
val sRange = rec1.range(8..12)
println(sRange)
// The `..` operator also works directly on positions.
val sRange2 = seqPos1..seqPos1.plus(4)
println(sRange2)
1:8..1:12
1:8..1:12
Flanking¶
Flanking works like bedtools flank. flankBoth returns two new intervals, one
on each side of the original; flankLeft and flankRight return one. When
the range carries a record, flanking is clipped to the length of that
sequence, so no interval runs off the end of the chromosome.
val sRangeFlanked = sRange.flankBoth(5)
println("sRange: $sRange")
println("flanked both: $sRangeFlanked")
sRange: 1:8..1:12
flanked both: [1:3..1:7, 1:13..1:17]
// The sequence in rec1 is only 18 bases long, so flanking right by 10 from
// position 12 stops at 18 rather than reaching 22.
val sRangeFlankRight = sRange.flankRight(10)
println("flanked right: $sRangeFlankRight")
val sRangeFlankLeft = sRange.flankLeft(4)
println("flanked left: $sRangeFlankLeft")
flanked right: 1:13..1:18
flanked left: 1:4..1:7
Sets of ranges¶
An SRangeSet keeps its ranges sorted by a comparator you supply.
SeqRangeSort provides the usual building blocks: numberThenAlphaSort
orders sequence identifiers the way a person would, and leftEdge orders
ranges on the same sequence by their start.
val dnaString = "ACGTGGTGAATATATATGCGCGCGTGCGTGGATCAGTCAGTCATGCATGCATGTGTGTACACACATGTGATCGTAGCTAGCTAGCTGACTGACTAGCTGACCGTACGTACGTATCAGTCAGCTGACACGTGGTGAATATATATGCGCGCGTGCGTGGATCAGTCAGTCATGCATGCATGTGTGTACACA"
val dnaString2 = "ACGTGGTGAATATATATGCGCGCGTGCGTGGACGTACGTACGTACGTATCAGTCAGCTGAC"
val dnaString3 = "TCAGTGATGATGATGCACACACACACACGTAGCTAGCTGCTAGCTAGTGATACGTAGCAAAAAATTTTTT"
val record1 = NucSeqRecord(NucSeq(dnaString), "Seq1")
val record2 = NucSeqRecord(NucSeq(dnaString2), "Seq2")
val record3 = NucSeqRecord(NucSeq(dnaString3), "Seq3")
val sort = SeqRangeSort.by(SeqRangeSort.numberThenAlphaSort, SeqRangeSort.leftEdge)
val set1 = nonCoalescingSetOf(
sort,
record1.range(27..40), record1.range(44..58), record1.range(1..15),
record3.range(18..33), record2.range(3..13), record2.range(25..35)
)
println("SRangeSet 1:")
set1.toDataFrame().print()
val set2 = nonCoalescingSetOf(
sort,
record1.range(30..35), record1.range(40..50), record1.range(18..22),
record3.range(1..10), record2.range(10..13), record2.range(45..55)
)
println("SRangeSet 2:")
set2.toDataFrame().print()
SRangeSet 1:
ID start end range
0 Seq1 1 15 1..15
1 Seq1 27 40 27..40
2 Seq1 44 58 44..58
3 Seq2 3 13 3..13
4 Seq2 25 35 25..35
5 Seq3 18 33 18..33
SRangeSet 2:
ID start end range
0 Seq1 18 22 18..22
1 Seq1 30 35 30..35
2 Seq1 40 50 40..50
3 Seq2 10 13 10..13
4 Seq2 45 55 45..55
5 Seq3 1 10 1..10
Intersections¶
intersect returns the overlapping portions of two sets, the same way
bedtools intersect does.
val intersections = set1.intersect(set2)
println("intersection size: ${intersections.size}")
intersections.toDataFrame().print()
intersection size: 4
ID start end range
0 Seq1 30 35 30..35
1 Seq1 40 40 40..40
2 Seq1 44 50 44..50
3 Seq2 10 13 10..13
Coalescing and non-coalescing sets¶
A coalescing set merges overlapping ranges as they are added; a
non-coalescing set keeps them separate. Below, the two ranges on
Sequence 1 overlap, so only the coalescing set merges them.
val recordA = NucSeqRecord(
NucSeq("ACGTGGTGAATATATATGCGCGCGTGCGTGGATCAGTCAGTCATGCATGCATGTGTGTACACACATGTGATCGTAGCTAGCTAGCTGACTGACTAGCTGAC"),
"Sequence 1", description = "The first sequence", annotations = mapOf("key1" to "value1")
)
val recordB = NucSeqRecord(
NucSeq("ACGTGGTGAATATATATGCGCGCGTGCGTGGACGTACGTACGTACGTATCAGTCAGCTGAC"),
"Sequence 2", description = "The second sequence", annotations = mapOf("key1" to "value1")
)
val srangeList = listOf(
SeqPositionRanges.of(recordA, 8..28),
recordB.range(25..40),
SeqPositionRanges.of(SeqPosition(recordA, 27), SeqPosition(recordA, 40)),
SeqPositionRanges.of(recordB, 3..19)
)
println("Ranges in the list:")
srangeList.forEach { println(" $it") }
Ranges in the list:
Sequence 1:8..Sequence 1:28
Sequence 2:25..Sequence 2:40
Sequence 1:27..Sequence 1:40
Sequence 2:3..Sequence 2:19
val comparator: Comparator<SRange> = SeqRangeSort.by(SeqRangeSort.numberThenAlphaSort, SeqRangeSort.leftEdge)
println("Non-coalescing set:")
nonCoalescingSetOf(comparator, srangeList).forEach { println(" $it") }
println()
println("Coalescing set:")
coalescingSetOf(comparator, srangeList).forEach { println(" $it") }
Non-coalescing set:
Sequence 1:8..Sequence 1:28
Sequence 1:27..Sequence 1:40
Sequence 2:3..Sequence 2:19
Sequence 2:25..Sequence 2:40
Coalescing set:
Sequence 1:8..Sequence 1:40
Sequence 2:3..Sequence 2:19
Sequence 2:25..Sequence 2:40
Reading BED files¶
Given a FASTA file and a BED file with coordinates relative to it,
bedfileToSRangeSet builds an SRangeSet. From there the whole range API
applies, and toDataFrame renders the result as a Kotlin DataFrame.
val fasta = "../src/test/kotlin/biokotlin/genome/chr9chr10short.fa"
val bedFile = "../src/test/kotlin/biokotlin/genome/chr9chr10_SHORTwithOverlaps.bed"
val srangeSet = bedfileToSRangeSet(bedFile, fasta)
println("Size of srangeSet: ${srangeSet.size}")
srangeSet.toDataFrame().print()
bedfileToSRangeSet: calling fastaToNucSeq
fastaToNucSeq: finished chrom chr9
fastaToNucSeq: finished chrom chr10
Size of srangeSet: 10
ID start end range
0 chr9 21 24 21..24
1 chr9 51 75 51..75
2 chr9 151 225 151..225
3 chr9 201 300 201..300
4 chr9 341 375 341..375
5 chr9 501 600 501..600
6 chr10 71 109 71..109
7 chr10 131 178 131..178
8 chr10 176 300 176..300
9 chr10 503 678 503..678
Intersecting a single range against a set¶
intersectingRanges finds every range in a set that overlaps one range of
interest. A single-position range is a convenient way to ask "which features
cover this SNP?".
// Reuse a record that is already in the set so the coordinates line up.
val seqRecT = srangeSet.elementAt(0).start.seqRecord
val snp = SeqPosition(seqRecT, 220)..SeqPosition(seqRecT, 220)
val snpHits = snp.intersectingRanges(srangeSet)
println("ranges covering position 220: $snpHits")
snpHits.toDataFrame().print()
ranges covering position 220: [chr9:151..chr9:225, chr9:201..chr9:300]
ID start end range
0 chr9 151 225 151..225
1 chr9 201 300 201..300
The same call works for a wider interval:
val window = SeqPosition(seqRecT, 70)..SeqPosition(seqRecT, 200)
val windowHits = window.intersectingRanges(srangeSet)
println("window: $window")
println("ranges overlapping it: $windowHits")
windowHits.toDataFrame().print()
window: chr9:70..chr9:200
ranges overlapping it: [chr9:51..chr9:75, chr9:151..chr9:225]
ID start end range
0 chr9 51 75 51..75
1 chr9 151 225 151..225