Coverage for muutils/interval.py: 98%

278 statements  

« 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" 

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)