diff --git a/src/main/java/htsjdk/samtools/util/Locatable.java b/src/main/java/htsjdk/samtools/util/Locatable.java index 0d70090d76..8dbf534b39 100644 --- a/src/main/java/htsjdk/samtools/util/Locatable.java +++ b/src/main/java/htsjdk/samtools/util/Locatable.java @@ -1,5 +1,7 @@ package htsjdk.samtools.util; +import java.util.Objects; + /** * Any class that has a single logical mapping onto the genome should implement Locatable * positions should be reported as 1-based and closed at both ends @@ -22,4 +24,56 @@ public interface Locatable { * @return 1-based closed-ended position, undefined if getContig() == null */ int getEnd(); + + /** + * @return number of bases of reference covered by this interval + */ + default int getLengthOnReference() { + return CoordMath.getLength(getStart(), getEnd()); + } + + /** + * Determines whether this interval overlaps the provided locatable. + * + * @param other interval to check + * @return true if this interval overlaps other, otherwise false + */ + default boolean overlaps(Locatable other) { + return withinDistanceOf(other, 0); + } + + /** + * Determines whether this interval comes within {@code distance} of overlapping the provided locatable. + * When distance = 0 this is equal to {@link #overlaps(Locatable)} + * + * @param other interval to check + * @param distance how many bases may be between the two intervals for us to still consider them overlapping. + * @return true if this interval overlaps other, otherwise false + */ + default boolean withinDistanceOf(Locatable other, int distance) { + return contigsMatch(other) && + CoordMath.overlaps(getStart(), getEnd(), other.getStart()-distance, other.getEnd()+distance); + } + + /** + * Determines whether this interval contains the entire region represented by other + * (in other words, whether it covers it). + * + * + * @param other interval to check + * @return true if this interval contains all of the base positions spanned by other, otherwise false + */ + default boolean contains(Locatable other) { + return contigsMatch(other) && CoordMath.encloses(getStart(), getEnd(), other.getStart(), other.getEnd()); + } + + /** + * Determine if this is on the same contig as other + * this must be equivalent to this.getContig().equals(other.getContig()) but may be implemented more efficiently + * + * @return true iff this.getContig().equals(other.getContig()) + */ + default boolean contigsMatch(Locatable other) { + return getContig() != null && other != null && Objects.equals(this.getContig(), other.getContig()); + } } diff --git a/src/test/java/htsjdk/samtools/util/LocatableUnitTest.java b/src/test/java/htsjdk/samtools/util/LocatableUnitTest.java new file mode 100644 index 0000000000..9dd2be1c96 --- /dev/null +++ b/src/test/java/htsjdk/samtools/util/LocatableUnitTest.java @@ -0,0 +1,160 @@ +package htsjdk.samtools.util; + +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +public class LocatableUnitTest { + + private static Locatable getLocatable(final String contig, final int start, final int end) { + return new Locatable() { + @Override + public String getContig() { + return contig; + } + + @Override + public int getStart() { + return start; + } + + @Override + public int getEnd() { + return end; + } + + @Override + public String toString() { + return String.format("%s:%s-%s", contig, start, end); + } + }; + } + + @DataProvider(name = "IntervalSizeData") + public Object[][] getIntervalSizeData() { + // Intervals + expected sizes + return new Object[][]{ + { getLocatable("1", 1, 1), 1 }, + { getLocatable("1", 1, 2), 2 }, + { getLocatable("1", 1, 10), 10 }, + { getLocatable("1", 2, 10), 9 }, + { getLocatable("1", 1,0), 0} + }; + } + + @Test(dataProvider = "IntervalSizeData") + public void testGetSize(final Locatable interval, final int expectedSize ) { + Assert.assertEquals(interval.getLengthOnReference(), expectedSize, "size() incorrect for interval " + interval); + } + + @DataProvider(name = "IntervalOverlapData") + public static Object[][] getIntervalOverlapData() { + final Locatable standardInterval = getLocatable("1", 10, 20); + final Locatable oneBaseInterval = getLocatable("1", 10, 10); + + return new Object[][] { + { standardInterval, getLocatable("2", 10, 20), false }, + { standardInterval, getLocatable("1", 1, 5), false }, + { standardInterval, getLocatable("1", 1, 9), false }, + { standardInterval, getLocatable("1", 1, 10), true }, + { standardInterval, getLocatable("1", 1, 15), true }, + { standardInterval, getLocatable("1", 10, 10), true }, + { standardInterval, getLocatable("1", 10, 15), true }, + { standardInterval, getLocatable("1", 10, 20), true }, + { standardInterval, getLocatable("1", 15, 20), true }, + { standardInterval, getLocatable("1", 15, 25), true }, + { standardInterval, getLocatable("1", 20, 20), true }, + { standardInterval, getLocatable("1", 20, 25), true }, + { standardInterval, getLocatable("1", 21, 25), false }, + { standardInterval, getLocatable("1", 25, 30), false }, + { oneBaseInterval, getLocatable("2", 10, 10), false }, + { oneBaseInterval, getLocatable("1", 1, 5), false }, + { oneBaseInterval, getLocatable("1", 1, 9), false }, + { oneBaseInterval, getLocatable("1", 1, 10), true }, + { oneBaseInterval, getLocatable("1", 10, 10), true }, + { oneBaseInterval, getLocatable("1", 10, 15), true }, + { oneBaseInterval, getLocatable("1", 11, 15), false }, + { oneBaseInterval, getLocatable("1", 15, 20), false }, + { standardInterval, null, false }, + { standardInterval, standardInterval, true }, + }; + } + + @Test(dataProvider = "IntervalOverlapData") + public void testOverlap(final Locatable firstInterval, final Locatable secondInterval, final boolean expectedOverlapResult ) { + Assert.assertEquals(firstInterval.overlaps(secondInterval), expectedOverlapResult, + "overlap() returned incorrect result for intervals " + firstInterval + " and " + secondInterval); + } + + @DataProvider(name = "overlapsWithMargin") + public Object[][] overlapsWithMargin(){ + final Locatable standardInterval = getLocatable("1", 10, 20); + final Locatable middleInterval = getLocatable("1", 100, 200); + final Locatable zeroLengthInterval = getLocatable("1", 1, 0); + + return new Object[][] { + { standardInterval, getLocatable("2", 10, 20), 100, false }, + { standardInterval, getLocatable("1", 1, 15), 0, true }, + { standardInterval, getLocatable("1", 30, 50), 9, false }, + { standardInterval, getLocatable("1", 30, 50), 10, true }, + { middleInterval, getLocatable("1", 50, 99), 0, false }, + { middleInterval, getLocatable("1", 50, 90), 9, false }, + { middleInterval, getLocatable("1", 50, 90), 10, true }, + { middleInterval, getLocatable("1", 150, 149), 0, true }, + { middleInterval, getLocatable("1", 100, 99), 0, true }, + { middleInterval, getLocatable("1", 99, 98), 0, false }, + { standardInterval, getLocatable(null, 10, 20), 100, false } + }; + } + + @Test(dataProvider = "overlapsWithMargin") + public void testOverlapWithMargin(final Locatable firstInterval, final Locatable secondInterval, int margin, final boolean expectedOverlapResult ) { + Assert.assertEquals(firstInterval.withinDistanceOf(secondInterval, margin), expectedOverlapResult, + "overlap() returned incorrect result for intervals " + firstInterval + " and " + secondInterval); + } + + @DataProvider(name = "IntervalContainsData") + public Object[][] getIntervalContainsData() { + final Locatable containingInterval = getLocatable("1", 10, 20); + final Locatable zeroLengthIntervalBetween9And10 = getLocatable("1", 10, 9); + return new Object[][] { + { containingInterval, getLocatable("2", 10, 20), false }, + { containingInterval, getLocatable("1", 1, 5), false }, + { containingInterval, getLocatable("1", 1, 10), false }, + { containingInterval, getLocatable("1", 5, 15), false }, + { containingInterval, getLocatable("1", 9, 10), false }, + { containingInterval, getLocatable("1", 9, 20), false }, + { containingInterval, getLocatable("1", 10, 10), true }, + { containingInterval, getLocatable("1", 10, 15), true }, + { containingInterval, getLocatable("1", 10, 20), true }, + { containingInterval, getLocatable("1", 10, 21), false }, + { containingInterval, getLocatable("1", 15, 25), false }, + { containingInterval, getLocatable("1", 20, 20), true }, + { containingInterval, getLocatable("1", 20, 21), false }, + { containingInterval, getLocatable("1", 20, 25), false }, + { containingInterval, getLocatable("1", 21, 25), false }, + { containingInterval, getLocatable("1", 25, 30), false }, + { containingInterval, null, false }, + { containingInterval, containingInterval, true }, + { containingInterval, getLocatable(null, 10, 20), false}, + { getLocatable(null, 10, 20), getLocatable(null, 10, 20), false}, + + //0 length intervals + { containingInterval, zeroLengthIntervalBetween9And10, true}, + { containingInterval, getLocatable("1", 15, 14), true}, + { containingInterval, getLocatable("1", 21,20), true}, + { containingInterval, getLocatable("1", 25, 24), false}, + {zeroLengthIntervalBetween9And10, getLocatable("1", 9, 8), false}, + {zeroLengthIntervalBetween9And10, getLocatable("1", 11, 10), false}, + + //0 length interval is considered to contain itself + {zeroLengthIntervalBetween9And10, zeroLengthIntervalBetween9And10, true} + }; + } + + @Test(dataProvider = "IntervalContainsData") + public void testContains(final Locatable firstInterval, final Locatable secondInterval, final boolean expectedContainsResult ) { + Assert.assertEquals(firstInterval.contains(secondInterval), expectedContainsResult, + "contains() returned incorrect result for intervals " + firstInterval + " and " + secondInterval); + } +}