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