Coverage for muutils/interval.py: 98%
278 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-04 03:33 -0600
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-04 03:33 -0600
1"represents a mathematical `Interval` over the real numbers"
3from __future__ import annotations
5import math
6import typing
7from typing import Optional, Iterable, Sequence, Union, Any
9from muutils.misc import str_to_numeric
11_EPSILON: float = 1e-10
13Number = Union[float, int]
14# TODO: make this also work with decimals, fractions, numpy types, etc.
15# except we must somehow avoid importing them? idk
17_EMPTY_INTERVAL_ARGS: tuple[Number, Number, bool, bool, set[Number]] = (
18 math.nan,
19 math.nan,
20 False,
21 False,
22 set(),
23)
26class Interval:
27 """
28 Represents a mathematical interval, open by default.
30 The Interval class can represent both open and closed intervals, as well as half-open intervals.
31 It supports various initialization methods and provides containment checks.
33 Examples:
35 >>> i1 = Interval(1, 5) # Default open interval (1, 5)
36 >>> 3 in i1
37 True
38 >>> 1 in i1
39 False
40 >>> i2 = Interval([1, 5]) # Closed interval [1, 5]
41 >>> 1 in i2
42 True
43 >>> i3 = Interval(1, 5, closed_L=True) # Half-open interval [1, 5)
44 >>> str(i3)
45 '[1, 5)'
46 >>> i4 = ClosedInterval(1, 5) # Closed interval [1, 5]
47 >>> i5 = OpenInterval(1, 5) # Open interval (1, 5)
49 """
51 def __init__(
52 self,
53 *args: Union[Sequence[Number], Number],
54 is_closed: Optional[bool] = None,
55 closed_L: Optional[bool] = None,
56 closed_R: Optional[bool] = None,
57 ):
58 self.lower: Number
59 self.upper: Number
60 self.closed_L: bool
61 self.closed_R: bool
62 self.singleton_set: Optional[set[Number]] = None
63 try:
64 if len(args) == 0:
65 (
66 self.lower,
67 self.upper,
68 self.closed_L,
69 self.closed_R,
70 self.singleton_set,
71 ) = _EMPTY_INTERVAL_ARGS
72 return
73 # Handle different types of input arguments
74 if len(args) == 1 and isinstance(
75 args[0], (list, tuple, Sequence, Iterable)
76 ):
77 assert (
78 len(args[0]) == 2
79 ), "if arg is a list or tuple, it must have length 2"
80 self.lower = args[0][0]
81 self.upper = args[0][1]
82 # Determine closure type based on the container type
83 default_closed = isinstance(args[0], list)
84 elif len(args) == 1 and isinstance(
85 args[0], (int, float, typing.SupportsFloat, typing.SupportsInt)
86 ):
87 # a singleton, but this will be handled later
88 self.lower = args[0]
89 self.upper = args[0]
90 default_closed = False
91 elif len(args) == 2:
92 self.lower, self.upper = args # type: ignore[assignment]
93 default_closed = False # Default to open interval if two args
94 else:
95 raise ValueError(f"Invalid input arguments: {args}")
97 # if both of the bounds are NaN or None, return an empty interval
98 if any(x is None for x in (self.lower, self.upper)) or any(
99 math.isnan(x) for x in (self.lower, self.upper)
100 ):
101 if (self.lower is None and self.upper is None) or (
102 math.isnan(self.lower) and math.isnan(self.upper)
103 ):
104 (
105 self.lower,
106 self.upper,
107 self.closed_L,
108 self.closed_R,
109 self.singleton_set,
110 ) = _EMPTY_INTERVAL_ARGS
111 return
112 else:
113 raise ValueError(
114 "Both bounds must be NaN or None to create an empty interval. Also, just use `Interval.get_empty()` instead."
115 )
117 # Ensure lower bound is less than upper bound
118 if self.lower > self.upper:
119 raise ValueError("Lower bound must be less than upper bound")
121 if math.isnan(self.lower) or math.isnan(self.upper):
122 raise ValueError("NaN is not allowed as an interval bound")
124 # Determine closure properties
125 if is_closed is not None:
126 # can't specify both is_closed and closed_L/R
127 if (closed_L is not None) or (closed_R is not None):
128 raise ValueError("Cannot specify both is_closed and closed_L/R")
129 self.closed_L = is_closed
130 self.closed_R = is_closed
131 else:
132 self.closed_L = closed_L if closed_L is not None else default_closed
133 self.closed_R = closed_R if closed_R is not None else default_closed
135 # handle singleton/empty case
136 if self.lower == self.upper and not (self.closed_L or self.closed_R):
137 (
138 self.lower,
139 self.upper,
140 self.closed_L,
141 self.closed_R,
142 self.singleton_set,
143 ) = _EMPTY_INTERVAL_ARGS
144 return
146 elif self.lower == self.upper and (self.closed_L or self.closed_R):
147 self.singleton_set = {self.lower} # Singleton interval
148 self.closed_L = True
149 self.closed_R = True
150 return
151 # otherwise `singleton_set` is `None`
153 except (AssertionError, ValueError) as e:
154 raise ValueError(
155 f"Invalid input arguments to Interval: {args = }, {is_closed = }, {closed_L = }, {closed_R = }\n{e}\nUsage:\n{self.__doc__}"
156 ) from e
158 @property
159 def is_closed(self) -> bool:
160 if self.is_empty:
161 return True
162 if self.is_singleton:
163 return True
164 return self.closed_L and self.closed_R
166 @property
167 def is_open(self) -> bool:
168 if self.is_empty:
169 return True
170 if self.is_singleton:
171 return False
172 return not self.closed_L and not self.closed_R
174 @property
175 def is_half_open(self) -> bool:
176 return (self.closed_L and not self.closed_R) or (
177 not self.closed_L and self.closed_R
178 )
180 @property
181 def is_singleton(self) -> bool:
182 return self.singleton_set is not None and len(self.singleton_set) == 1
184 @property
185 def is_empty(self) -> bool:
186 return self.singleton_set is not None and len(self.singleton_set) == 0
188 @property
189 def is_finite(self) -> bool:
190 return not math.isinf(self.lower) and not math.isinf(self.upper)
192 @property
193 def singleton(self) -> Number:
194 if not self.is_singleton:
195 raise ValueError("Interval is not a singleton")
196 return next(iter(self.singleton_set)) # type: ignore[arg-type]
198 @staticmethod
199 def get_empty() -> Interval:
200 return Interval(math.nan, math.nan, closed_L=None, closed_R=None)
202 @staticmethod
203 def get_singleton(value: Number) -> Interval:
204 if math.isnan(value) or value is None:
205 return Interval.get_empty()
206 return Interval(value, value, closed_L=True, closed_R=True)
208 def numerical_contained(self, item: Number) -> bool:
209 if self.is_empty:
210 return False
211 if math.isnan(item):
212 raise ValueError("NaN cannot be checked for containment in an interval")
213 if self.is_singleton:
214 return item in self.singleton_set # type: ignore[operator]
215 return ((self.closed_L and item >= self.lower) or item > self.lower) and (
216 (self.closed_R and item <= self.upper) or item < self.upper
217 )
219 def interval_contained(self, item: Interval) -> bool:
220 if item.is_empty:
221 return True
222 if self.is_empty:
223 return False
224 if item.is_singleton:
225 return self.numerical_contained(item.singleton)
226 if self.is_singleton:
227 if not item.is_singleton:
228 return False
229 return self.singleton == item.singleton
231 lower_contained: bool = (
232 # either strictly wider bound
233 self.lower < item.lower
234 # if same, then self must be closed if item is open
235 or (self.lower == item.lower and self.closed_L >= item.closed_L)
236 )
238 upper_contained: bool = (
239 # either strictly wider bound
240 self.upper > item.upper
241 # if same, then self must be closed if item is open
242 or (self.upper == item.upper and self.closed_R >= item.closed_R)
243 )
245 return lower_contained and upper_contained
247 def __contains__(self, item: Any) -> bool:
248 if isinstance(item, Interval):
249 return self.interval_contained(item)
250 else:
251 return self.numerical_contained(item)
253 def __repr__(self) -> str:
254 if self.is_empty:
255 return r"∅"
256 if self.is_singleton:
257 return "{" + str(self.singleton) + "}"
258 left: str = "[" if self.closed_L else "("
259 right: str = "]" if self.closed_R else ")"
260 return f"{left}{self.lower}, {self.upper}{right}"
262 def __str__(self) -> str:
263 return repr(self)
265 @classmethod
266 def from_str(cls, input_str: str) -> Interval:
267 input_str = input_str.strip()
268 # empty and singleton
269 if input_str.count(",") == 0:
270 # empty set
271 if input_str == "∅":
272 return cls.get_empty()
273 assert input_str.startswith("{") and input_str.endswith(
274 "}"
275 ), "Invalid input string"
276 input_str_set_interior: str = input_str.strip("{}").strip()
277 if len(input_str_set_interior) == 0:
278 return cls.get_empty()
279 # singleton set
280 return cls.get_singleton(str_to_numeric(input_str_set_interior))
282 # expect commas
283 if not input_str.count(",") == 1:
284 raise ValueError("Invalid input string")
286 # get bounds
287 lower: str
288 upper: str
289 lower, upper = input_str.strip("[]()").split(",")
290 lower = lower.strip()
291 upper = upper.strip()
293 lower_num: Number = str_to_numeric(lower)
294 upper_num: Number = str_to_numeric(upper)
296 # figure out closure
297 closed_L: bool
298 closed_R: bool
299 if input_str[0] == "[":
300 closed_L = True
301 elif input_str[0] == "(":
302 closed_L = False
303 else:
304 raise ValueError("Invalid input string")
306 if input_str[-1] == "]":
307 closed_R = True
308 elif input_str[-1] == ")":
309 closed_R = False
310 else:
311 raise ValueError("Invalid input string")
313 return cls(lower_num, upper_num, closed_L=closed_L, closed_R=closed_R)
315 def __eq__(self, other: object) -> bool:
316 if not isinstance(other, Interval):
317 return False
318 if self.is_empty and other.is_empty:
319 return True
320 if self.is_singleton and other.is_singleton:
321 return self.singleton == other.singleton
322 return (self.lower, self.upper, self.closed_L, self.closed_R) == (
323 other.lower,
324 other.upper,
325 other.closed_L,
326 other.closed_R,
327 )
329 def __iter__(self):
330 if self.is_empty:
331 return
332 elif self.is_singleton:
333 yield self.singleton
334 return
335 else:
336 yield self.lower
337 yield self.upper
339 def __getitem__(self, index: int) -> float:
340 if self.is_empty:
341 raise IndexError("Empty interval has no bounds")
342 if self.is_singleton:
343 if index == 0:
344 return self.singleton
345 else:
346 raise IndexError("Singleton interval has only one bound")
347 if index == 0:
348 return self.lower
349 elif index == 1:
350 return self.upper
351 else:
352 raise IndexError("Interval index out of range")
354 def __len__(self) -> int:
355 return 0 if self.is_empty else 1 if self.is_singleton else 2
357 def copy(self) -> Interval:
358 if self.is_empty:
359 return Interval.get_empty()
360 if self.is_singleton:
361 return Interval.get_singleton(self.singleton)
362 return Interval(
363 self.lower, self.upper, closed_L=self.closed_L, closed_R=self.closed_R
364 )
366 def size(self) -> float:
367 """
368 Returns the size of the interval.
370 # Returns:
372 - `float`
373 the size of the interval
374 """
375 if self.is_empty or self.is_singleton:
376 return 0
377 else:
378 return self.upper - self.lower
380 def clamp(self, value: Union[int, float], epsilon: float = _EPSILON) -> float:
381 """
382 Clamp the given value to the interval bounds.
384 For open bounds, the clamped value will be slightly inside the interval (by epsilon).
386 # Parameters:
388 - `value : Union[int, float]`
389 the value to clamp.
390 - `epsilon : float`
391 margin for open bounds
392 (defaults to `_EPSILON`)
394 # Returns:
396 - `float`
397 the clamped value
399 # Raises:
401 - `ValueError` : If the input value is NaN.
402 """
404 if math.isnan(value):
405 raise ValueError("Cannot clamp NaN value")
407 if math.isnan(epsilon):
408 raise ValueError("Epsilon cannot be NaN")
410 if epsilon < 0:
411 raise ValueError(f"Epsilon must be non-negative: {epsilon = }")
413 if self.is_empty:
414 raise ValueError("Cannot clamp to an empty interval")
416 if self.is_singleton:
417 return self.singleton
419 if epsilon > self.size():
420 raise ValueError(
421 f"epsilon is greater than the size of the interval: {epsilon = }, {self.size() = }, {self = }"
422 )
424 # make type work with decimals and stuff
425 if not isinstance(value, (int, float)):
426 epsilon = value.__class__(epsilon)
428 clamped_min: Number
429 if self.closed_L:
430 clamped_min = self.lower
431 else:
432 clamped_min = self.lower + epsilon
434 clamped_max: Number
435 if self.closed_R:
436 clamped_max = self.upper
437 else:
438 clamped_max = self.upper - epsilon
440 return max(clamped_min, min(value, clamped_max))
442 def intersection(self, other: Interval) -> Interval:
443 if not isinstance(other, Interval):
444 raise TypeError("Can only intersect with another Interval")
446 if self.is_empty or other.is_empty:
447 return Interval.get_empty()
449 if self.is_singleton:
450 if other.numerical_contained(self.singleton):
451 return self.copy()
452 else:
453 return Interval.get_empty()
455 if other.is_singleton:
456 if self.numerical_contained(other.singleton):
457 return other.copy()
458 else:
459 return Interval.get_empty()
461 if self.upper < other.lower or other.upper < self.lower:
462 return Interval.get_empty()
464 lower: Number = max(self.lower, other.lower)
465 upper: Number = min(self.upper, other.upper)
466 closed_L: bool = self.closed_L if self.lower > other.lower else other.closed_L
467 closed_R: bool = self.closed_R if self.upper < other.upper else other.closed_R
469 return Interval(lower, upper, closed_L=closed_L, closed_R=closed_R)
471 def union(self, other: Interval) -> Interval:
472 if not isinstance(other, Interval):
473 raise TypeError("Can only union with another Interval")
475 # empty set case
476 if self.is_empty:
477 return other.copy()
478 if other.is_empty:
479 return self.copy()
481 # special case where the intersection is empty but the intervals are contiguous
482 if self.upper == other.lower:
483 if self.closed_R or other.closed_L:
484 return Interval(
485 self.lower,
486 other.upper,
487 closed_L=self.closed_L,
488 closed_R=other.closed_R,
489 )
490 elif other.upper == self.lower:
491 if other.closed_R or self.closed_L:
492 return Interval(
493 other.lower,
494 self.upper,
495 closed_L=other.closed_L,
496 closed_R=self.closed_R,
497 )
499 # non-intersecting nonempty and non-contiguous intervals
500 if self.intersection(other) == Interval.get_empty():
501 raise NotImplementedError(
502 "Union of non-intersecting nonempty non-contiguous intervals is not implemented "
503 + f"{self = }, {other = }, {self.intersection(other) = }"
504 )
506 # singleton case
507 if self.is_singleton:
508 return other.copy()
509 if other.is_singleton:
510 return self.copy()
512 # regular case
513 lower: Number = min(self.lower, other.lower)
514 upper: Number = max(self.upper, other.upper)
515 closed_L: bool = self.closed_L if self.lower < other.lower else other.closed_L
516 closed_R: bool = self.closed_R if self.upper > other.upper else other.closed_R
518 return Interval(lower, upper, closed_L=closed_L, closed_R=closed_R)
521class ClosedInterval(Interval):
522 def __init__(self, *args: Union[Sequence[float], float], **kwargs: Any):
523 if any(key in kwargs for key in ("is_closed", "closed_L", "closed_R")):
524 raise ValueError("Cannot specify closure properties for ClosedInterval")
525 super().__init__(*args, is_closed=True)
528class OpenInterval(Interval):
529 def __init__(self, *args: Union[Sequence[float], float], **kwargs: Any):
530 if any(key in kwargs for key in ("is_closed", "closed_L", "closed_R")):
531 raise ValueError("Cannot specify closure properties for OpenInterval")
532 super().__init__(*args, is_closed=False)