|
4 | 4 | """Math tools.""" |
5 | 5 |
|
6 | 6 | import math |
| 7 | +from collections.abc import Iterable, Iterator |
7 | 8 | from dataclasses import dataclass |
8 | 9 | from typing import Generic, Protocol, Self, TypeVar |
9 | 10 |
|
@@ -106,3 +107,190 @@ def __str__(self) -> str: |
106 | 107 | start = "∞" if self.start is None else str(self.start) |
107 | 108 | end = "∞" if self.end is None else str(self.end) |
108 | 109 | return f"[{start}, {end}]" |
| 110 | + |
| 111 | + |
| 112 | +@dataclass(frozen=True, repr=False) |
| 113 | +class IntervalSet(Generic[LessThanComparableT]): |
| 114 | + """A normalized set of intervals for efficient membership testing. |
| 115 | +
|
| 116 | + An [`IntervalSet`][.] represents the union of a collection of |
| 117 | + [`Interval`][..Interval] values. On construction, the passed intervals are sorted |
| 118 | + by their start and any overlapping or touching intervals are merged into one, so |
| 119 | + the stored [`.intervals`][.] are canonical: sorted by start and pairwise |
| 120 | + non-overlapping. |
| 121 | +
|
| 122 | + Membership testing via the `in` operator is `O(log n)` on the number of stored |
| 123 | + intervals (after normalization), using binary search on the interval starts. |
| 124 | +
|
| 125 | + Only the normalized form is stored; the original input order and any redundant |
| 126 | + intervals are discarded. Two [`IntervalSet`][.] instances constructed from |
| 127 | + different input sequences that represent the same union of values are equal and |
| 128 | + hash the same. |
| 129 | +
|
| 130 | + Note: Unbounded intervals |
| 131 | + [`Interval`][..Interval] supports `None` as the start (meaning -∞) or the end |
| 132 | + (meaning +∞). [`IntervalSet`][.] handles these correctly during merging: for |
| 133 | + example, the set `{Interval(None, 5), Interval(3, None)}` normalizes to |
| 134 | + `{Interval(None, None)}` (the whole comparable space). `None` is a bound |
| 135 | + marker only; it is never a value in the set. |
| 136 | +
|
| 137 | + Example: |
| 138 | + ```python |
| 139 | + from frequenz.core.math import Interval, IntervalSet |
| 140 | +
|
| 141 | + allowed = IntervalSet( |
| 142 | + (Interval(1, 5), Interval(3, 10), Interval(15, 20)) |
| 143 | + ) |
| 144 | + assert tuple(allowed) == (Interval(1, 10), Interval(15, 20)) |
| 145 | + assert 7 in allowed |
| 146 | + assert 12 not in allowed |
| 147 | + ``` |
| 148 | + """ |
| 149 | + |
| 150 | + intervals: tuple[Interval[LessThanComparableT], ...] = () |
| 151 | + """The normalized intervals in this set: sorted by start and non-overlapping.""" |
| 152 | + |
| 153 | + def __post_init__(self) -> None: |
| 154 | + """Normalize the passed intervals by sorting and merging overlapping ones.""" |
| 155 | + object.__setattr__(self, "intervals", _sort_and_merge(self.intervals)) |
| 156 | + |
| 157 | + def __contains__(self, item: LessThanComparableT) -> bool: |
| 158 | + """Check whether the value is within any interval of this set. |
| 159 | +
|
| 160 | + Args: |
| 161 | + item: The value to check. |
| 162 | +
|
| 163 | + Returns: |
| 164 | + Whether `item` is within any interval of this set. |
| 165 | + """ |
| 166 | + if not self.intervals: |
| 167 | + return False |
| 168 | + |
| 169 | + # Binary search for the rightmost interval whose start is `<= item`. A `None` |
| 170 | + # start means -∞, which is always `<= item`. |
| 171 | + lo, hi = 0, len(self.intervals) |
| 172 | + while lo < hi: |
| 173 | + mid = (lo + hi) // 2 |
| 174 | + start = self.intervals[mid].start |
| 175 | + if start is None or not item < start: |
| 176 | + lo = mid + 1 |
| 177 | + else: |
| 178 | + hi = mid |
| 179 | + |
| 180 | + idx = lo - 1 |
| 181 | + return idx >= 0 and item in self.intervals[idx] |
| 182 | + |
| 183 | + def __iter__(self) -> Iterator[Interval[LessThanComparableT]]: |
| 184 | + """Iterate over the normalized intervals in ascending order of start.""" |
| 185 | + return iter(self.intervals) |
| 186 | + |
| 187 | + def __len__(self) -> int: |
| 188 | + """Return the number of intervals in the normalized form.""" |
| 189 | + return len(self.intervals) |
| 190 | + |
| 191 | + def __repr__(self) -> str: |
| 192 | + """Return a string representation of this instance.""" |
| 193 | + return f"IntervalSet({self.intervals!r})" |
| 194 | + |
| 195 | + def __str__(self) -> str: |
| 196 | + """Return a string representation of this instance.""" |
| 197 | + if not self.intervals: |
| 198 | + return "∅" |
| 199 | + return " ∪ ".join(str(iv) for iv in self.intervals) |
| 200 | + |
| 201 | + |
| 202 | +def _sort_and_merge( |
| 203 | + intervals: Iterable[Interval[LessThanComparableT]], |
| 204 | +) -> tuple[Interval[LessThanComparableT], ...]: |
| 205 | + """Sort intervals by start and merge overlapping or touching ones. |
| 206 | +
|
| 207 | + A `None` start is treated as -∞ and a `None` end as +∞. Intervals are inclusive |
| 208 | + on both bounds, so `[1, 5]` and `[5, 10]` are considered touching and merged |
| 209 | + into `[1, 10]`. |
| 210 | +
|
| 211 | + Args: |
| 212 | + intervals: The intervals to normalize. |
| 213 | +
|
| 214 | + Returns: |
| 215 | + A tuple of sorted, pairwise non-overlapping intervals covering the same |
| 216 | + values as the input. |
| 217 | + """ |
| 218 | + all_ivs = list(intervals) |
| 219 | + if not all_ivs: |
| 220 | + return () |
| 221 | + |
| 222 | + # Left-unbounded intervals (start is None) can't be sorted alongside real starts, |
| 223 | + # so pre-merge them into at most one interval spanning -∞ up to the largest end. |
| 224 | + # Pair the real-start intervals with their (narrowed) non-None start so the sort |
| 225 | + # key is directly typed as `T` — no casts needed. |
| 226 | + with_none_start: list[Interval[LessThanComparableT]] = [] |
| 227 | + with_real_start: list[tuple[LessThanComparableT, Interval[LessThanComparableT]]] = ( |
| 228 | + [] |
| 229 | + ) |
| 230 | + for iv in all_ivs: |
| 231 | + if iv.start is None: |
| 232 | + with_none_start.append(iv) |
| 233 | + else: |
| 234 | + with_real_start.append((iv.start, iv)) |
| 235 | + |
| 236 | + with_real_start.sort(key=lambda pair: pair[0]) |
| 237 | + ordered_real = [pair[1] for pair in with_real_start] |
| 238 | + |
| 239 | + initial: list[Interval[LessThanComparableT]] = [] |
| 240 | + if with_none_start: |
| 241 | + if any(iv.end is None for iv in with_none_start): |
| 242 | + initial.append(Interval(None, None)) |
| 243 | + else: |
| 244 | + non_none_ends = [iv.end for iv in with_none_start if iv.end is not None] |
| 245 | + initial.append(Interval(None, max(non_none_ends))) |
| 246 | + |
| 247 | + ordered = initial + ordered_real |
| 248 | + |
| 249 | + result: list[Interval[LessThanComparableT]] = [ordered[0]] |
| 250 | + for current in ordered[1:]: |
| 251 | + last = result[-1] |
| 252 | + if _end_covers_start(last.end, current.start): |
| 253 | + new_end = _max_end(last.end, current.end) |
| 254 | + result[-1] = Interval(last.start, new_end) |
| 255 | + else: |
| 256 | + result.append(current) |
| 257 | + |
| 258 | + return tuple(result) |
| 259 | + |
| 260 | + |
| 261 | +def _end_covers_start( |
| 262 | + end_val: LessThanComparableT | None, |
| 263 | + start_val: LessThanComparableT | None, |
| 264 | +) -> bool: |
| 265 | + """Return whether `end_val >= start_val`, treating `None` as ±∞. |
| 266 | +
|
| 267 | + Args: |
| 268 | + end_val: An interval end, where `None` means +∞. |
| 269 | + start_val: An interval start, where `None` means -∞. |
| 270 | +
|
| 271 | + Returns: |
| 272 | + Whether `end_val >= start_val` under the ±∞ convention. |
| 273 | + """ |
| 274 | + if end_val is None: |
| 275 | + return True |
| 276 | + if start_val is None: |
| 277 | + return True |
| 278 | + return not end_val < start_val |
| 279 | + |
| 280 | + |
| 281 | +def _max_end( |
| 282 | + a: LessThanComparableT | None, |
| 283 | + b: LessThanComparableT | None, |
| 284 | +) -> LessThanComparableT | None: |
| 285 | + """Return the larger of two interval ends, where `None` means +∞. |
| 286 | +
|
| 287 | + Args: |
| 288 | + a: An interval end. |
| 289 | + b: Another interval end. |
| 290 | +
|
| 291 | + Returns: |
| 292 | + The larger of `a` and `b` under the +∞ convention. |
| 293 | + """ |
| 294 | + if a is None or b is None: |
| 295 | + return None |
| 296 | + return b if a < b else a |
0 commit comments