Skip to content

Commit

Permalink
Merge pull request #185 from octoenergy/ranges.any-gaps
Browse files Browse the repository at this point in the history
Add `ranges.any_gaps`
  • Loading branch information
Peter554 authored Jan 13, 2025
2 parents 4bef5b5 + e927d82 commit 12bee09
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 3 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Add `ranges.any_gaps` function [#185](https://github.com/octoenergy/xocto/pull/185).
- Improve the performance of the `ranges.any_overlapping` function
(with some benchmark showing a >100x speed up) [#184](https://github.com/octoenergy/xocto/pull/184).

Expand Down
20 changes: 17 additions & 3 deletions tests/benchmarks/test_ranges.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import random
from decimal import Decimal as D

import pytest

from xocto import ranges


def test_any_overlapping(benchmark):
ranges_ = [ranges.Range(D(i), D(i + 1)) for i in range(1000)]
random.seed(42)
def _shuffled(ranges_, *, seed=42):
ranges_ = ranges_.copy()
random.seed(seed)
random.shuffle(ranges_)
return ranges_


@pytest.mark.benchmark(group="ranges.any_overlapping")
def test_any_overlapping(benchmark):
ranges_ = _shuffled([ranges.Range(D(i), D(i + 1)) for i in range(1000)])
any_overlapping = benchmark(ranges.any_overlapping, ranges_)
assert any_overlapping is False


@pytest.mark.benchmark(group="ranges.any_gaps")
def test_any_gaps(benchmark):
ranges_ = _shuffled([ranges.Range(D(i), D(i + 1)) for i in range(1000)])
any_overlapping = benchmark(ranges.any_gaps, ranges_)
assert any_overlapping is False
94 changes: 94 additions & 0 deletions tests/test_ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,17 @@ def test_timestamps_sorted(self, period):


class TestAnyOverlapping:
def test_does_not_modify_ranges(self):
# The implementation of `any_overlapping` relies on sorting.
# Let's make sure that the ranges passed in are unchanged.
ranges_ = [
ranges.Range(1, 2),
ranges.Range(0, 1),
]
ranges_copy = ranges_.copy()
assert not ranges.any_overlapping(ranges_)
assert ranges_ == ranges_copy

@pytest.mark.parametrize(
"ranges_",
[
Expand All @@ -613,6 +624,7 @@ class TestAnyOverlapping:
)
def test_returns_true_if_and_ranges_overlap(self, ranges_):
assert ranges.any_overlapping(ranges_)
assert ranges.any_overlapping(reversed(ranges_))

@pytest.mark.parametrize(
"ranges_",
Expand All @@ -631,11 +643,93 @@ def test_returns_true_if_and_ranges_overlap(self, ranges_):
)
def test_returns_false_if_no_ranges_overlap(self, ranges_):
assert not ranges.any_overlapping(ranges_)
assert not ranges.any_overlapping(reversed(ranges_))

def test_returns_false_for_empty_set_of_ranges(self):
assert not ranges.any_overlapping([])


class TestAnyGaps:
def test_does_not_modify_ranges(self):
# The implementation of `any_gaps` relies on sorting.
# Let's make sure that the ranges passed in are unchanged.
ranges_ = [
ranges.Range(1, 2),
ranges.Range(0, 1),
]
ranges_copy = ranges_.copy()
assert not ranges.any_gaps(ranges_)
assert ranges_ == ranges_copy

@pytest.mark.parametrize(
"ranges_",
[
[
ranges.Range(0, 1),
ranges.Range(2, 3),
],
[
ranges.Range(
0, 1, boundaries=ranges.RangeBoundaries.INCLUSIVE_EXCLUSIVE
),
ranges.Range(
1, 2, boundaries=ranges.RangeBoundaries.EXCLUSIVE_INCLUSIVE
),
],
[
ranges.Range(0, 2),
ranges.Range(4, 6),
ranges.Range(1, 3),
],
],
)
def test_returns_true_if_gaps(self, ranges_):
assert ranges.any_gaps(ranges_)
assert ranges.any_gaps(reversed(ranges_))

@pytest.mark.parametrize(
"ranges_",
[
[
ranges.Range(0, 1),
ranges.Range(1, 2),
],
[
ranges.Range(
0, 1, boundaries=ranges.RangeBoundaries.EXCLUSIVE_INCLUSIVE
),
ranges.Range(
1, 2, boundaries=ranges.RangeBoundaries.EXCLUSIVE_INCLUSIVE
),
],
[
ranges.Range(0, 2),
ranges.Range(1, 3),
],
[
ranges.Range(0, 3),
ranges.Range(1, 2),
],
[
ranges.Range(0, 5),
ranges.Range(1, 2),
ranges.Range(3, 4),
],
[
ranges.Range(0, 2),
ranges.Range(4, 6),
ranges.Range(2, 4),
],
],
)
def test_returns_false_if_no_gaps(self, ranges_):
assert not ranges.any_gaps(ranges_)
assert not ranges.any_gaps(reversed(ranges_))

def test_returns_false_for_empty_set_of_ranges(self):
assert not ranges.any_gaps([])


class TestFiniteDateRange:
"""
Test class for methods specific to the the FiniteDateRange subclass.
Expand Down
5 changes: 5 additions & 0 deletions xocto/ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,11 @@ def any_overlapping(ranges: Iterable[Range[T]]) -> bool:
return False


def any_gaps(ranges: Iterable[Range[T]]) -> bool:
"""Return true if there are gaps between the passed Ranges."""
return len(RangeSet(ranges)) > 1


def as_finite_datetime_periods(
periods: Iterable[HalfFiniteDatetimeRange | DatetimeRange],
) -> Sequence[FiniteDatetimeRange]:
Expand Down

0 comments on commit 12bee09

Please sign in to comment.