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