Coverage for src / chebpy / compactfun.py: 88%

279 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 07:22 +0000

1"""Implementation of functions on (semi-)infinite intervals via numerical-support truncation. 

2 

3This module provides the :class:`CompactFun` class, which sits next to 

4:class:`~chebpy.bndfun.Bndfun` under :class:`~chebpy.classicfun.Classicfun`. 

5It represents functions whose user-facing logical interval has one or both 

6endpoints at ``±inf`` but whose **numerical support** — the set of points 

7where the function differs from its asymptotic limit by more than a 

8configured tolerance — is finite. Internally, a :class:`CompactFun` stores 

9a standard :class:`~chebpy.onefun.Onefun` (Chebtech) on the discovered 

10finite storage interval; outside that interval the function is reported as 

11the corresponding asymptotic constant (``tail_left`` or ``tail_right``, 

12default ``0``). 

13 

14This approach is a deliberate departure from MATLAB Chebfun's ``@unbndfun`` 

15(which uses a rational change of variables to map ``(-inf, inf)`` onto 

16``[-1, 1]``). See ``docs/plans/02-compactfun-integration.md`` for the 

17zero-tail design and ``docs/plans/02b-compactfun-tail-constants.md`` for 

18the non-zero asymptote extension. 

19""" 

20 

21from __future__ import annotations 

22 

23from typing import Any, cast 

24 

25import numpy as np 

26 

27from .classicfun import Classicfun, techdict 

28from .exceptions import CompactFunConstructionError, DivergentIntegralError 

29from .settings import _preferences as prefs 

30from .utilities import Interval 

31 

32 

33def _ensure_endpoints(interval: Any) -> tuple[float, float]: 

34 """Return ``(a, b)`` floats from any 2-element interval-like object. 

35 

36 Accepts :class:`Interval`, ``numpy.ndarray``, list, or tuple. Both 

37 endpoints may be ``±inf``. 

38 """ 

39 a, b = interval[0], interval[1] 

40 return float(a), float(b) 

41 

42 

43def _discover_one_side( 

44 f: Any, anchor: float, sign: int, tol: float, max_width: float, max_probes: int 

45) -> tuple[float, float, float]: 

46 """Discover the numerical-support boundary on one infinite side. 

47 

48 Probes ``f`` at ``anchor + sign * 2**k`` for ``k = 0, 1, 2, ...`` up to 

49 the configured budget. Detects the asymptotic limit ``L`` of ``f`` on 

50 this side (which may be zero or non-zero) and returns the smallest 

51 finite boundary beyond which ``|f - L| < tol * scale``. 

52 

53 Args: 

54 f: Callable being approximated. 

55 anchor: Finite anchor point (the bounded endpoint of a semi-infinite 

56 interval, or ``0.0`` for the doubly-infinite case). 

57 sign: ``+1`` for the rightward (toward ``+inf``) side, ``-1`` for the 

58 leftward side. 

59 tol: Relative tolerance threshold for both convergence detection and 

60 boundary placement. 

61 max_width: Maximum permitted boundary distance from ``anchor``. 

62 max_probes: Maximum number of geometric probes. 

63 

64 Returns: 

65 Tuple ``(boundary, tail, vscale)`` where ``boundary`` is the finite 

66 boundary, ``tail`` is the detected asymptotic constant (``0.0`` if 

67 the function decays to zero), and ``vscale`` is the largest 

68 absolute probed value on this side. 

69 

70 Raises: 

71 CompactFunConstructionError: If ``f`` does not converge to a 

72 constant within the probing budget or ``max_width``. 

73 """ 

74 radii: list[float] = [] 

75 values: list[float] = [] # signed values 

76 r = 1.0 

77 for _ in range(max_probes): 

78 if r > max_width: 

79 break 

80 x = anchor + sign * r 

81 try: 

82 v = float(f(x)) 

83 except (FloatingPointError, OverflowError, ZeroDivisionError) as err: # pragma: no cover 

84 raise CompactFunConstructionError( # noqa: TRY003 

85 f"Could not evaluate f at probe x = {x:g} during numerical-support discovery" 

86 ) from err 

87 if not np.isfinite(v): 

88 raise CompactFunConstructionError( # noqa: TRY003 

89 f"f returned non-finite value {v} at probe x = {x:g}; CompactFun " 

90 f"requires the function to be finite at all sampled points." 

91 ) 

92 radii.append(r) 

93 values.append(v) 

94 r *= 2.0 

95 

96 if not radii: 

97 return anchor + sign * 1.0, 0.0, 0.0 

98 

99 abs_values = [abs(v) for v in values] 

100 vscale = max(abs_values) if abs_values else 0.0 

101 

102 # Need at least three probes to verify convergence to a constant. 

103 last_n = 3 

104 if len(values) < last_n: 

105 raise CompactFunConstructionError( # noqa: TRY003 

106 f"Too few probes ({len(values)}) to determine the asymptotic " 

107 f"behaviour of f near {'+' if sign > 0 else '-'}inf; " 

108 f"increase numsupp_max_probes or numsupp_max_width." 

109 ) 

110 

111 # Convergence test: the last few signed probes must agree to tol*scale. 

112 tail_window = values[-last_n:] 

113 conv_threshold = tol * max(vscale, 1.0) 

114 spread = max(tail_window) - min(tail_window) 

115 if spread > conv_threshold: 

116 # Function does not settle to a constant — heavy tail or oscillation. 

117 raise CompactFunConstructionError( # noqa: TRY003 

118 f"Function does not converge to a constant within " 

119 f"{radii[-1]:g} of anchor {anchor:g} on the " 

120 f"{'+' if sign > 0 else '-'}inf side (last {last_n} probes " 

121 f"spread by {spread:g} > {conv_threshold:g}); heavy-tailed or " 

122 f"oscillating inputs are not supported in this release." 

123 ) 

124 tail = float(np.mean(tail_window)) 

125 if abs(tail) < conv_threshold: 

126 tail = 0.0 

127 

128 # Find the largest radius at which f is still "active" (above threshold 

129 # relative to the tail). 

130 threshold = tol * max(abs(tail), vscale, 1.0) 

131 active_r = 0.0 

132 for ri, vi in zip(radii, values, strict=False): 

133 if abs(vi - tail) > threshold: 

134 active_r = ri 

135 

136 boundary_r = max(2.0 * active_r, 1.0) 

137 if boundary_r > max_width: 

138 raise CompactFunConstructionError( # noqa: TRY003 

139 f"Discovered numerical support exceeds max_width = {max_width:g}; " 

140 f"heavy-tailed inputs are not supported in this release." 

141 ) 

142 return anchor + sign * boundary_r, tail, vscale 

143 

144 

145def _discover_numsupp( 

146 f: Any, a: float, b: float, tol: float, max_width: float, max_probes: int 

147) -> tuple[float, float, float, float]: 

148 """Discover the storage interval and tail constants for ``f``. 

149 

150 Args: 

151 f: Callable being approximated. 

152 a: Left endpoint of the logical interval (may be ``-inf``). 

153 b: Right endpoint of the logical interval (may be ``+inf``). 

154 tol: Relative tolerance for support detection. 

155 max_width: Maximum permitted storage interval width. 

156 max_probes: Maximum probes per unbounded side. 

157 

158 Returns: 

159 Tuple ``(a', b', tail_left, tail_right)`` where ``a' < b'`` are 

160 finite floats and the tails are the detected asymptotic constants 

161 (``0.0`` on any side whose logical endpoint is finite). 

162 

163 Raises: 

164 CompactFunConstructionError: If support cannot be discovered. 

165 """ 

166 left_inf = not np.isfinite(a) 

167 right_inf = not np.isfinite(b) 

168 

169 if not (left_inf or right_inf): 

170 return a, b, 0.0, 0.0 

171 

172 # Anchor: the finite endpoint of a semi-infinite interval, else 0. 

173 if left_inf and right_inf: 

174 anchor = 0.0 

175 elif left_inf: 

176 anchor = b 

177 else: 

178 anchor = a 

179 

180 if left_inf: 

181 a_storage, tail_left, _ = _discover_one_side(f, anchor, -1, tol, max_width, max_probes) 

182 else: 

183 a_storage, tail_left = a, 0.0 

184 

185 if right_inf: 

186 b_storage, tail_right, _ = _discover_one_side(f, anchor, +1, tol, max_width, max_probes) 

187 else: 

188 b_storage, tail_right = b, 0.0 

189 

190 if b_storage - a_storage > max_width: 

191 raise CompactFunConstructionError( # noqa: TRY003 

192 f"Discovered numerical support [{a_storage:g}, {b_storage:g}] exceeds " 

193 f"max_width = {max_width:g}; heavy-tailed inputs are not supported " 

194 f"in this release." 

195 ) 

196 if b_storage <= a_storage: 

197 # Function appears identically constant on both sides; pick a small 

198 # default storage interval. 

199 a_storage, b_storage = anchor - 1.0, anchor + 1.0 

200 return a_storage, b_storage, tail_left, tail_right 

201 

202 

203class CompactFun(Classicfun): 

204 """Functions on (semi-)infinite intervals with finite numerical support. 

205 

206 A :class:`CompactFun` represents a function whose user-facing logical 

207 interval has one or both endpoints at ``±inf`` but whose numerical 

208 support — the set where the function differs from its asymptotic limit 

209 by more than a configured tolerance — is finite. Internally it 

210 inherits from :class:`Classicfun` and stores a standard 

211 :class:`Onefun` on the discovered finite storage interval; outside that 

212 interval the function is reported as the corresponding asymptotic 

213 constant ``tail_left`` or ``tail_right`` (default ``0.0``). 

214 

215 Two intervals are tracked: 

216 

217 - ``self._interval`` (inherited): the finite storage interval where the 

218 underlying ``Onefun`` lives. 

219 - ``self._logical_interval``: the user-facing interval, which may have 

220 ``±inf`` endpoints; returned by :attr:`support`. 

221 

222 Two scalar tail constants are tracked: 

223 

224 - ``tail_left``: the value reported for ``x < a_storage`` when the 

225 logical-left endpoint is ``-inf``. 

226 - ``tail_right``: the value reported for ``x > b_storage`` when the 

227 logical-right endpoint is ``+inf``. 

228 

229 For finite logical intervals the storage and logical intervals coincide 

230 and the tails are ignored, so a :class:`CompactFun` behaves identically 

231 to :class:`~chebpy.bndfun.Bndfun`. 

232 

233 Attributes: 

234 onefun: Inherited; the standard :class:`Onefun` on ``[-1, 1]``. 

235 support: The logical interval (possibly with ``±inf`` endpoints). 

236 numerical_support: The finite storage interval. 

237 tail_left: Asymptotic value at ``-inf`` (``0.0`` if logical-left is finite). 

238 tail_right: Asymptotic value at ``+inf`` (``0.0`` if logical-right is finite). 

239 """ 

240 

241 def __init__( 

242 self, 

243 onefun: Any, 

244 interval: Any, 

245 logical_interval: Any = None, 

246 tail_left: float = 0.0, 

247 tail_right: float = 0.0, 

248 ) -> None: 

249 """Create a new :class:`CompactFun` instance. 

250 

251 Args: 

252 onefun: The :class:`Onefun` representing the function on ``[-1, 1]``. 

253 interval: The finite storage :class:`Interval` (always finite). 

254 logical_interval: The user-facing interval (possibly with ``±inf`` 

255 endpoints). Defaults to ``interval`` if omitted. 

256 tail_left: Asymptotic value at ``-inf``. Default ``0.0``. 

257 tail_right: Asymptotic value at ``+inf``. Default ``0.0``. 

258 """ 

259 super().__init__(onefun, interval) 

260 if logical_interval is None: 

261 self._logical_interval = np.asarray(interval, dtype=float) 

262 else: 

263 self._logical_interval = np.asarray((float(logical_interval[0]), float(logical_interval[1])), dtype=float) 

264 self._tail_left = float(tail_left) 

265 self._tail_right = float(tail_right) 

266 

267 def _rebuild(self, onefun: Any, *, tail_left: float | None = None, tail_right: float | None = None) -> CompactFun: 

268 """Construct a new :class:`CompactFun` preserving logical interval and tails. 

269 

270 Args: 

271 onefun: Replacement :class:`Onefun` for the new instance. 

272 tail_left: Optional override for the new instance's left tail. 

273 Defaults to ``self.tail_left``. 

274 tail_right: Optional override for the new instance's right tail. 

275 Defaults to ``self.tail_right``. 

276 """ 

277 new_tl = self._tail_left if tail_left is None else float(tail_left) 

278 new_tr = self._tail_right if tail_right is None else float(tail_right) 

279 return self.__class__( 

280 onefun, 

281 self._interval, 

282 logical_interval=self._logical_interval, 

283 tail_left=new_tl, 

284 tail_right=new_tr, 

285 ) 

286 

287 # -------------------------- 

288 # alternative constructors 

289 # -------------------------- 

290 @classmethod 

291 def initempty(cls) -> CompactFun: 

292 """Initialise an empty CompactFun on ``(-inf, +inf)``.""" 

293 storage = Interval(-1.0, 1.0) 

294 onefun = techdict[prefs.tech].initempty(interval=storage) 

295 return cls(onefun, storage, logical_interval=(-np.inf, np.inf)) 

296 

297 @classmethod 

298 def initconst(cls, c: Any, interval: Any) -> CompactFun: 

299 """Initialise a constant function. 

300 

301 On an unbounded interval the constant ``c`` becomes the asymptotic 

302 value on each unbounded side: ``tail_left = tail_right = c``. This 

303 makes ``initconst`` total — every constant is representable on every 

304 interval — but note that integrating a non-zero constant over an 

305 unbounded logical interval will (correctly) raise 

306 :class:`~chebpy.exceptions.DivergentIntegralError`. 

307 """ 

308 a, b = _ensure_endpoints(interval) 

309 c_val = float(c) 

310 if not np.isfinite(a) and not np.isfinite(b): 

311 storage = Interval(-1.0, 1.0) 

312 elif not np.isfinite(a): 

313 storage = Interval(b - 1.0, b) 

314 elif not np.isfinite(b): 

315 storage = Interval(a, a + 1.0) 

316 else: 

317 storage = Interval(a, b) 

318 onefun = techdict[prefs.tech].initconst(c_val, interval=storage) 

319 tail_left = c_val if not np.isfinite(a) else 0.0 

320 tail_right = c_val if not np.isfinite(b) else 0.0 

321 return cls(onefun, storage, logical_interval=(a, b), tail_left=tail_left, tail_right=tail_right) 

322 

323 @classmethod 

324 def initidentity(cls, interval: Any) -> CompactFun: 

325 """Initialise the identity function ``f(x) = x``. 

326 

327 The identity function is unbounded and so cannot be represented as a 

328 :class:`CompactFun` on an unbounded interval. This method is provided 

329 only for completeness and refuses any infinite endpoint. 

330 """ 

331 a, b = _ensure_endpoints(interval) 

332 if not (np.isfinite(a) and np.isfinite(b)): 

333 raise CompactFunConstructionError( # noqa: TRY003 

334 "The identity function f(x) = x cannot be represented as a CompactFun on an unbounded interval." 

335 ) 

336 storage = Interval(a, b) 

337 onefun = techdict[prefs.tech].initvalues(np.asarray(storage), interval=storage) 

338 return cls(onefun, storage, logical_interval=(a, b)) 

339 

340 @classmethod 

341 def initfun_adaptive(cls, f: Any, interval: Any) -> CompactFun: 

342 """Initialise from a callable using adaptive sampling. 

343 

344 Discovers the numerical support and asymptotic tail constants of 

345 ``f`` on the (possibly unbounded) logical interval, then builds a 

346 standard adaptive :class:`Onefun` on that finite storage interval. 

347 

348 Raises: 

349 CompactFunConstructionError: If the numerical support cannot be 

350 discovered within the configured tolerance and width budget, 

351 or if ``f`` does not converge to a constant at ``±inf``. 

352 """ 

353 a, b = _ensure_endpoints(interval) 

354 a_s, b_s, tl, tr = _discover_numsupp( 

355 f, 

356 a, 

357 b, 

358 prefs.numsupp_tol, 

359 prefs.numsupp_max_width, 

360 prefs.numsupp_max_probes, 

361 ) 

362 storage = Interval(a_s, b_s) 

363 onefun = techdict[prefs.tech].initfun(lambda y: f(storage(y)), interval=storage) 

364 return cls(onefun, storage, logical_interval=(a, b), tail_left=tl, tail_right=tr) 

365 

366 @classmethod 

367 def initfun_fixedlen(cls, f: Any, interval: Any, n: int) -> CompactFun: 

368 """Initialise from a callable using a fixed number of points. 

369 

370 Discovers numerical support and tails as in :meth:`initfun_adaptive`, 

371 then builds a fixed-length :class:`Onefun` on the storage interval. 

372 """ 

373 a, b = _ensure_endpoints(interval) 

374 a_s, b_s, tl, tr = _discover_numsupp( 

375 f, 

376 a, 

377 b, 

378 prefs.numsupp_tol, 

379 prefs.numsupp_max_width, 

380 prefs.numsupp_max_probes, 

381 ) 

382 storage = Interval(a_s, b_s) 

383 onefun = techdict[prefs.tech].initfun(lambda y: f(storage(y)), n, interval=storage) 

384 return cls(onefun, storage, logical_interval=(a, b), tail_left=tl, tail_right=tr) 

385 

386 # ------------------- 

387 # evaluation 

388 # ------------------- 

389 def __call__(self, x: Any, how: str = "clenshaw") -> Any: 

390 """Evaluate the function at ``x``. 

391 

392 Outside the storage interval, returns the corresponding tail constant 

393 when the matching logical endpoint is ``±inf`` (default ``0.0``), or 

394 ``0.0`` when the logical endpoint is finite. 

395 """ 

396 scalar_input = np.isscalar(x) or np.ndim(x) == 0 

397 x_arr = np.atleast_1d(np.asarray(x)) 

398 is_complex = bool(getattr(self.onefun, "iscomplex", False)) 

399 result = np.zeros(x_arr.shape, dtype=complex if is_complex else float) 

400 a_s, b_s = self._interval 

401 a_log, b_log = float(self._logical_interval[0]), float(self._logical_interval[1]) 

402 # Outside-storage values: tail constants where the logical edge is ±inf. 

403 left_mask = x_arr < a_s 

404 right_mask = x_arr > b_s 

405 if not np.isfinite(a_log) and self._tail_left != 0.0: 

406 result[left_mask] = self._tail_left 

407 if not np.isfinite(b_log) and self._tail_right != 0.0: 

408 result[right_mask] = self._tail_right 

409 # Inside-storage values: standard onefun evaluation. 

410 mask = (x_arr >= a_s) & (x_arr <= b_s) 

411 if mask.any(): 

412 y = self._interval.invmap(x_arr[mask]) 

413 result[mask] = self.onefun(y, how) 

414 if scalar_input: 

415 return result.item() 

416 return result 

417 

418 # ------------ 

419 # properties 

420 # ------------ 

421 @property 

422 def support(self) -> Any: 

423 """Return the logical (user-facing) interval, possibly with ``±inf`` endpoints.""" 

424 return self._logical_interval 

425 

426 @property 

427 def numerical_support(self) -> Any: 

428 """Return the finite storage interval ``[a, b]`` discovered at construction.""" 

429 return np.asarray(self._interval) 

430 

431 @property 

432 def tail_left(self) -> float: 

433 """Asymptotic value of the function as ``x → -inf``. 

434 

435 Always ``0.0`` when the logical-left endpoint is finite. 

436 """ 

437 return self._tail_left 

438 

439 @property 

440 def tail_right(self) -> float: 

441 """Asymptotic value of the function as ``x → +inf``. 

442 

443 Always ``0.0`` when the logical-right endpoint is finite. 

444 """ 

445 return self._tail_right 

446 

447 @property 

448 def endvalues(self) -> Any: 

449 """Return values at the logical endpoints; tails at any ``±inf`` endpoint.""" 

450 a_log, b_log = float(self._logical_interval[0]), float(self._logical_interval[1]) 

451 yl = self._tail_left if not np.isfinite(a_log) else self.__call__(a_log) 

452 yr = self._tail_right if not np.isfinite(b_log) else self.__call__(b_log) 

453 return np.array([yl, yr]) 

454 

455 def __repr__(self) -> str: # pragma: no cover 

456 """Return a string representation showing the logical interval, size, and tails.""" 

457 a_log, b_log = self._logical_interval 

458 if self._tail_left != 0.0 or self._tail_right != 0.0: 

459 return ( 

460 f"{self.__class__.__name__}([{a_log}, {b_log}], {self.size}, " 

461 f"tails=({self._tail_left}, {self._tail_right}))" 

462 ) 

463 return f"{self.__class__.__name__}([{a_log}, {b_log}], {self.size})" 

464 

465 # ---------- 

466 # calculus 

467 # ---------- 

468 def sum(self) -> Any: 

469 """Compute the definite integral over the logical interval. 

470 

471 Raises: 

472 DivergentIntegralError: If the logical interval is unbounded on 

473 a side where the corresponding tail is non-zero (the integral 

474 of a non-decaying function over a half-line diverges). 

475 """ 

476 a_log, b_log = float(self._logical_interval[0]), float(self._logical_interval[1]) 

477 if (not np.isfinite(a_log)) and self._tail_left != 0.0: 

478 raise DivergentIntegralError( # noqa: TRY003 

479 f"Integrand has non-zero left asymptote tail_left={self._tail_left}; " 

480 f"integral over (-inf, ...) diverges." 

481 ) 

482 if (not np.isfinite(b_log)) and self._tail_right != 0.0: 

483 raise DivergentIntegralError( # noqa: TRY003 

484 f"Integrand has non-zero right asymptote tail_right={self._tail_right}; " 

485 f"integral over (..., +inf) diverges." 

486 ) 

487 return super().sum() 

488 

489 def cumsum(self) -> CompactFun: 

490 """Compute the indefinite integral. 

491 

492 For a :class:`CompactFun` with zero asymptote on the unbounded 

493 left/right side, the antiderivative is well-defined; it is itself a 

494 :class:`CompactFun` whose right-tail equals ``∫f`` and whose 

495 left-tail is ``0`` (anchored so ``F(-inf) = 0``). 

496 

497 Raises: 

498 DivergentIntegralError: If the logical interval is unbounded on 

499 a side where the corresponding tail is non-zero, in which 

500 case the antiderivative diverges. 

501 """ 

502 a_log, b_log = float(self._logical_interval[0]), float(self._logical_interval[1]) 

503 if (not np.isfinite(a_log)) and self._tail_left != 0.0: 

504 raise DivergentIntegralError( # noqa: TRY003 

505 f"Antiderivative diverges at -inf because tail_left={self._tail_left} != 0." 

506 ) 

507 if (not np.isfinite(b_log)) and self._tail_right != 0.0: 

508 raise DivergentIntegralError( # noqa: TRY003 

509 f"Antiderivative diverges at +inf because tail_right={self._tail_right} != 0." 

510 ) 

511 # Standard cumsum on the storage interval anchors F(a_storage) = 0. 

512 # When logical-left is -inf with tail_left=0, this approximates 

513 # F(-inf) = 0 (since f is below tolerance below a_storage). 

514 inner = super().cumsum() 

515 # The right-tail of F is the total integral. 

516 total = float(super().sum()) 

517 # The left-tail is 0 when logical-left is -inf (anchor at -inf). 

518 new_tail_left = 0.0 

519 new_tail_right = total 

520 return self.__class__( 

521 inner.onefun, 

522 inner._interval, 

523 logical_interval=self._logical_interval, 

524 tail_left=new_tail_left, 

525 tail_right=new_tail_right, 

526 ) 

527 

528 def diff(self) -> CompactFun: 

529 """Compute the derivative. 

530 

531 The derivative of a function with constant asymptotic limits has 

532 zero asymptotes, so the result has ``tail_left = tail_right = 0``. 

533 """ 

534 result = cast(CompactFun, super().diff()) 

535 result._tail_left = 0.0 

536 result._tail_right = 0.0 

537 return result 

538 

539 # ------------- 

540 # rootfinding 

541 # ------------- 

542 def roots(self) -> Any: 

543 """Find the roots, filtering out spurious roots in numerical-noise regions. 

544 

545 The underlying polynomial approximation can produce many spurious 

546 roots in regions where the function has decayed to numerical noise 

547 (typically near the boundary of the storage interval). We keep a 

548 candidate root ``r`` only if both: 

549 

550 - ``f(r - δ)`` and ``f(r + δ)`` have opposite signs (the function 

551 actually crosses zero), **and** 

552 - ``max(|f(r - δ)|, |f(r + δ)|)`` exceeds ``numsupp_tol * vscale`` 

553 (the values are above numerical noise). 

554 

555 Here ``delta = 1e-3 * storage_width``. This heuristic does not preserve 

556 double roots; that is a documented limitation since double roots are 

557 uncommon in the decay-to-zero functions that :class:`CompactFun` is 

558 designed for. 

559 """ 

560 raw = super().roots() 

561 if raw.size == 0: 

562 return raw 

563 a_s, b_s = float(self._interval[0]), float(self._interval[1]) 

564 vals = np.abs(np.atleast_1d(self.onefun.values())) 

565 vscale = float(vals.max()) if vals.size else 1.0 

566 threshold = prefs.numsupp_tol * max(vscale, 1.0) 

567 delta = 1e-3 * (b_s - a_s) 

568 left = np.clip(raw - delta, a_s, b_s) 

569 right = np.clip(raw + delta, a_s, b_s) 

570 f_left = np.atleast_1d(self.__call__(left)) 

571 f_right = np.atleast_1d(self.__call__(right)) 

572 sign_flip = np.sign(f_left) != np.sign(f_right) 

573 above_noise = np.maximum(np.abs(f_left), np.abs(f_right)) > threshold 

574 keep = sign_flip & above_noise 

575 return np.sort(np.unique(raw[keep])) 

576 

577 # ----------- 

578 # utilities 

579 # ----------- 

580 def restrict(self, subinterval: Any) -> Any: 

581 """Restrict to a finite subinterval, returning a :class:`Bndfun`.""" 

582 from .bndfun import Bndfun 

583 

584 sub_a, sub_b = _ensure_endpoints(subinterval) 

585 if not (np.isfinite(sub_a) and np.isfinite(sub_b)): 

586 raise NotImplementedError( 

587 "CompactFun.restrict() requires a finite subinterval; " 

588 "restriction to unbounded subintervals is not supported." 

589 ) 

590 return Bndfun.initfun_adaptive(self, Interval(sub_a, sub_b)) 

591 

592 def translate(self, c: float) -> CompactFun: 

593 """Translate by ``c`` along the real line, preserving both intervals and tails.""" 

594 new_storage = Interval(float(self._interval[0]) + c, float(self._interval[1]) + c) 

595 a_log, b_log = float(self._logical_interval[0]), float(self._logical_interval[1]) 

596 new_logical = (a_log + c, b_log + c) 

597 return self.__class__( 

598 self.onefun, 

599 new_storage, 

600 logical_interval=new_logical, 

601 tail_left=self._tail_left, 

602 tail_right=self._tail_right, 

603 ) 

604 

605 # ------------ 

606 # arithmetic 

607 # ------------ 

608 def __neg__(self) -> CompactFun: 

609 """Return ``-f``; negates both tail constants.""" 

610 result = cast(CompactFun, super().__neg__()) 

611 result._tail_left = -self._tail_left 

612 result._tail_right = -self._tail_right 

613 return result 

614 

615 def __add__(self, other: Any) -> Any: 

616 """Pointwise addition; combines tail constants additively.""" 

617 result = super().__add__(other) 

618 if isinstance(result, CompactFun): 

619 other_tl, other_tr = self._other_tails(other) 

620 result._tail_left = self._tail_left + other_tl 

621 result._tail_right = self._tail_right + other_tr 

622 return result 

623 

624 def __radd__(self, other: Any) -> Any: 

625 """Right-hand addition for scalar + CompactFun.""" 

626 result = super().__radd__(other) 

627 if isinstance(result, CompactFun): 

628 other_tl, other_tr = self._other_tails(other) 

629 result._tail_left = self._tail_left + other_tl 

630 result._tail_right = self._tail_right + other_tr 

631 return result 

632 

633 def __sub__(self, other: Any) -> Any: 

634 """Pointwise subtraction; combines tail constants additively.""" 

635 result = super().__sub__(other) 

636 if isinstance(result, CompactFun): 

637 other_tl, other_tr = self._other_tails(other) 

638 result._tail_left = self._tail_left - other_tl 

639 result._tail_right = self._tail_right - other_tr 

640 return result 

641 

642 def __rsub__(self, other: Any) -> Any: 

643 """Right-hand subtraction for scalar - CompactFun.""" 

644 result = super().__rsub__(other) 

645 if isinstance(result, CompactFun): 

646 other_tl, other_tr = self._other_tails(other) 

647 result._tail_left = other_tl - self._tail_left 

648 result._tail_right = other_tr - self._tail_right 

649 return result 

650 

651 def __mul__(self, other: Any) -> Any: 

652 """Pointwise multiplication; combines tail constants multiplicatively.""" 

653 result = super().__mul__(other) 

654 if isinstance(result, CompactFun): 

655 other_tl, other_tr = self._other_tails(other) 

656 result._tail_left = self._tail_left * other_tl 

657 result._tail_right = self._tail_right * other_tr 

658 return result 

659 

660 def __rmul__(self, other: Any) -> Any: 

661 """Right-hand multiplication for scalar * CompactFun.""" 

662 result = super().__rmul__(other) 

663 if isinstance(result, CompactFun): 

664 other_tl, other_tr = self._other_tails(other) 

665 result._tail_left = self._tail_left * other_tl 

666 result._tail_right = self._tail_right * other_tr 

667 return result 

668 

669 def _other_tails(self, other: Any) -> tuple[float, float]: 

670 """Extract ``(tail_left, tail_right)`` from a binary-op operand. 

671 

672 For a :class:`CompactFun` operand, returns its tail attributes; for 

673 a scalar, returns ``(scalar, scalar)``. 

674 """ 

675 if isinstance(other, CompactFun): 

676 return other._tail_left, other._tail_right 

677 if np.isscalar(other): 

678 v = float(other) 

679 return v, v 

680 # Anything else (e.g. a different Classicfun subclass) is treated as 

681 # zero-tailed; tail propagation may be inexact in that case. 

682 return 0.0, 0.0 

683 

684 # ---------- 

685 # plotting 

686 # ---------- 

687 @property 

688 def plot_support(self) -> tuple[float, float]: 

689 """Return a finite ``[a, b]`` plotting window. 

690 

691 Replaces any ``±inf`` logical endpoint with the corresponding 

692 numerical-support endpoint padded by 10% of the storage width 

693 (minimum padding of 1.0) so the decay-to-zero region is visible. 

694 """ 

695 a_s, b_s = float(self._interval[0]), float(self._interval[1]) 

696 a_log, b_log = float(self._logical_interval[0]), float(self._logical_interval[1]) 

697 pad = max(0.1 * (b_s - a_s), 1.0) 

698 a = a_log if np.isfinite(a_log) else a_s - pad 

699 b = b_log if np.isfinite(b_log) else b_s + pad 

700 return (a, b) 

701 

702 def plot(self, ax: Any = None, **kwds: Any) -> Any: 

703 """Plot the function over a finite window derived from its numerical support. 

704 

705 For doubly- or singly-infinite logical intervals, the plotting window 

706 defaults to the numerical-support interval padded by 10% on each 

707 unbounded side. Pass an explicit ``support=(a, b)`` keyword to override. 

708 """ 

709 from .plotting import plotfun 

710 

711 support = kwds.pop("support", self.plot_support) 

712 return plotfun(self, support, ax=ax, **kwds)