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

280 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-22 21:50 +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, values = _probe_side(f, anchor, sign, max_width, max_probes) 

75 if not radii: 

76 return anchor + sign * 1.0, 0.0, 0.0 

77 

78 vscale = max(abs(v) for v in values) 

79 tail = _detect_tail(radii, values, anchor, sign, tol, vscale) 

80 

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

82 # relative to the tail). 

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

84 active_r = 0.0 

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

86 if abs(vi - tail) > threshold: 

87 active_r = ri 

88 

89 boundary_r = max(2.0 * active_r, 1.0) 

90 # Defensive: the last-3 convergence window forces active_r <= r_{N-3}, so 

91 # boundary_r = 2*active_r stays below the largest probed radius (<= max_width). 

92 if boundary_r > max_width: # pragma: no cover 

93 raise CompactFunConstructionError( # noqa: TRY003 

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

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

96 ) 

97 return anchor + sign * boundary_r, tail, vscale 

98 

99 

100def _probe_side(f: Any, anchor: float, sign: int, max_width: float, max_probes: int) -> tuple[list[float], list[float]]: 

101 """Geometrically probe ``f`` on one side, returning ``(radii, values)``. 

102 

103 Samples ``f`` at ``anchor + sign * 2**k`` for ``k = 0, 1, ...`` while the 

104 radius stays within ``max_width`` and the probe budget is not exhausted. 

105 

106 Raises: 

107 CompactFunConstructionError: If ``f`` returns a non-finite value. 

108 """ 

109 radii: list[float] = [] 

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

111 r = 1.0 

112 for _ in range(max_probes): 

113 if r > max_width: 

114 break 

115 x = anchor + sign * r 

116 try: 

117 v = float(f(x)) 

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

119 raise CompactFunConstructionError( # noqa: TRY003 

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

121 ) from err 

122 if not np.isfinite(v): 

123 raise CompactFunConstructionError( # noqa: TRY003 

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

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

126 ) 

127 radii.append(r) 

128 values.append(v) 

129 r *= 2.0 

130 return radii, values 

131 

132 

133def _detect_tail(radii: list[float], values: list[float], anchor: float, sign: int, tol: float, vscale: float) -> float: 

134 """Detect the asymptotic constant of ``f`` from its probed ``values``. 

135 

136 Requires at least three probes and that the last three agree to within 

137 ``tol * max(vscale, 1)``; a tail below that threshold is reported as ``0``. 

138 

139 Raises: 

140 CompactFunConstructionError: If there are too few probes or the last 

141 few do not settle to a constant (heavy-tailed / oscillating input). 

142 """ 

143 last_n = 3 

144 if len(values) < last_n: 

145 raise CompactFunConstructionError( # noqa: TRY003 

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

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

148 f"increase numsupp_max_probes or numsupp_max_width." 

149 ) 

150 

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

152 tail_window = values[-last_n:] 

153 conv_threshold = tol * max(vscale, 1.0) 

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

155 if spread > conv_threshold: 

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

157 raise CompactFunConstructionError( # noqa: TRY003 

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

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

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

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

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

163 ) 

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

165 if abs(tail) < conv_threshold: 

166 tail = 0.0 

167 return tail 

168 

169 

170def _discover_numsupp( 

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

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

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

174 

175 Args: 

176 f: Callable being approximated. 

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

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

179 tol: Relative tolerance for support detection. 

180 max_width: Maximum permitted storage interval width. 

181 max_probes: Maximum probes per unbounded side. 

182 

183 Returns: 

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

185 finite floats and the tails are the detected asymptotic constants 

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

187 

188 Raises: 

189 CompactFunConstructionError: If support cannot be discovered. 

190 """ 

191 left_inf = not np.isfinite(a) 

192 right_inf = not np.isfinite(b) 

193 

194 if not (left_inf or right_inf): 

195 return a, b, 0.0, 0.0 

196 

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

198 if left_inf and right_inf: 

199 anchor = 0.0 

200 elif left_inf: 

201 anchor = b 

202 else: 

203 anchor = a 

204 

205 if left_inf: 

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

207 else: 

208 a_storage, tail_left = a, 0.0 

209 

210 if right_inf: 

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

212 else: 

213 b_storage, tail_right = b, 0.0 

214 

215 if b_storage - a_storage > max_width: 

216 raise CompactFunConstructionError( # noqa: TRY003 

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

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

219 f"in this release." 

220 ) 

221 if b_storage <= a_storage: # pragma: no cover 

222 # Defensive: each discovered boundary is >= 1 from the anchor, so 

223 # b_storage > a_storage always holds; kept as a safety net. 

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

225 return a_storage, b_storage, tail_left, tail_right 

226 

227 

228class CompactFun(Classicfun): 

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

230 

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

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

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

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

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

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

237 interval the function is reported as the corresponding asymptotic 

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

239 

240 Two intervals are tracked: 

241 

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

243 underlying ``Onefun`` lives. 

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

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

246 

247 Two scalar tail constants are tracked: 

248 

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

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

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

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

253 

254 For finite logical intervals the storage and logical intervals coincide 

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

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

257 

258 Attributes: 

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

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

261 numerical_support: The finite storage interval. 

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

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

264 """ 

265 

266 def __init__( 

267 self, 

268 onefun: Any, 

269 interval: Any, 

270 logical_interval: Any = None, 

271 tail_left: float = 0.0, 

272 tail_right: float = 0.0, 

273 ) -> None: 

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

275 

276 Args: 

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

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

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

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

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

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

283 """ 

284 super().__init__(onefun, interval) 

285 if logical_interval is None: 

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

287 else: 

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

289 self._tail_left = float(tail_left) 

290 self._tail_right = float(tail_right) 

291 

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

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

294 

295 Args: 

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

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

298 Defaults to ``self.tail_left``. 

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

300 Defaults to ``self.tail_right``. 

301 """ 

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

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

304 return self.__class__( 

305 onefun, 

306 self._interval, 

307 logical_interval=self._logical_interval, 

308 tail_left=new_tl, 

309 tail_right=new_tr, 

310 ) 

311 

312 # -------------------------- 

313 # alternative constructors 

314 # -------------------------- 

315 @classmethod 

316 def initempty(cls) -> CompactFun: 

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

318 storage = Interval(-1.0, 1.0) 

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

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

321 

322 @classmethod 

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

324 """Initialise a constant function. 

325 

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

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

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

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

330 unbounded logical interval will (correctly) raise 

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

332 """ 

333 a, b = _ensure_endpoints(interval) 

334 c_val = float(c) 

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

336 storage = Interval(-1.0, 1.0) 

337 elif not np.isfinite(a): 

338 storage = Interval(b - 1.0, b) 

339 elif not np.isfinite(b): 

340 storage = Interval(a, a + 1.0) 

341 else: 

342 storage = Interval(a, b) 

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

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

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

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

347 

348 @classmethod 

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

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

351 

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

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

354 only for completeness and refuses any infinite endpoint. 

355 """ 

356 a, b = _ensure_endpoints(interval) 

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

358 raise CompactFunConstructionError( # noqa: TRY003 

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

360 ) 

361 storage = Interval(a, b) 

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

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

364 

365 @classmethod 

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

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

368 

369 Discovers the numerical support and asymptotic tail constants of 

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

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

372 

373 Raises: 

374 CompactFunConstructionError: If the numerical support cannot be 

375 discovered within the configured tolerance and width budget, 

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

377 """ 

378 a, b = _ensure_endpoints(interval) 

379 a_s, b_s, tl, tr = _discover_numsupp( 

380 f, 

381 a, 

382 b, 

383 prefs.numsupp_tol, 

384 prefs.numsupp_max_width, 

385 prefs.numsupp_max_probes, 

386 ) 

387 storage = Interval(a_s, b_s) 

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

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

390 

391 @classmethod 

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

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

394 

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

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

397 """ 

398 a, b = _ensure_endpoints(interval) 

399 a_s, b_s, tl, tr = _discover_numsupp( 

400 f, 

401 a, 

402 b, 

403 prefs.numsupp_tol, 

404 prefs.numsupp_max_width, 

405 prefs.numsupp_max_probes, 

406 ) 

407 storage = Interval(a_s, b_s) 

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

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

410 

411 # ------------------- 

412 # evaluation 

413 # ------------------- 

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

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

416 

417 Outside the storage interval, returns the corresponding tail constant 

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

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

420 """ 

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

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

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

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

425 a_s, b_s = self._interval 

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

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

428 left_mask = x_arr < a_s 

429 right_mask = x_arr > b_s 

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

431 result[left_mask] = self._tail_left 

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

433 result[right_mask] = self._tail_right 

434 # Inside-storage values: standard onefun evaluation. 

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

436 if mask.any(): 

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

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

439 if scalar_input: 

440 return result.item() 

441 return result 

442 

443 # ------------ 

444 # properties 

445 # ------------ 

446 @property 

447 def support(self) -> Any: 

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

449 return self._logical_interval 

450 

451 @property 

452 def numerical_support(self) -> Any: 

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

454 return np.asarray(self._interval) 

455 

456 @property 

457 def tail_left(self) -> float: 

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

459 

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

461 """ 

462 return self._tail_left 

463 

464 @property 

465 def tail_right(self) -> float: 

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

467 

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

469 """ 

470 return self._tail_right 

471 

472 @property 

473 def endvalues(self) -> Any: 

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

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

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

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

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

479 

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

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

482 a_log, b_log = self._logical_interval 

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

484 return ( 

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

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

487 ) 

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

489 

490 # ---------- 

491 # calculus 

492 # ---------- 

493 def sum(self) -> Any: 

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

495 

496 Raises: 

497 DivergentIntegralError: If the logical interval is unbounded on 

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

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

500 """ 

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

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

503 raise DivergentIntegralError( # noqa: TRY003 

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

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

506 ) 

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

508 raise DivergentIntegralError( # noqa: TRY003 

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

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

511 ) 

512 return super().sum() 

513 

514 def cumsum(self) -> CompactFun: 

515 """Compute the indefinite integral. 

516 

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

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

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

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

521 

522 Raises: 

523 DivergentIntegralError: If the logical interval is unbounded on 

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

525 case the antiderivative diverges. 

526 """ 

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

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

529 raise DivergentIntegralError( # noqa: TRY003 

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

531 ) 

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

533 raise DivergentIntegralError( # noqa: TRY003 

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

535 ) 

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

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

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

539 inner = super().cumsum() 

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

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

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

543 new_tail_left = 0.0 

544 new_tail_right = total 

545 return self.__class__( 

546 inner.onefun, 

547 inner._interval, 

548 logical_interval=self._logical_interval, 

549 tail_left=new_tail_left, 

550 tail_right=new_tail_right, 

551 ) 

552 

553 def diff(self) -> CompactFun: 

554 """Compute the derivative. 

555 

556 The derivative of a function with constant asymptotic limits has 

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

558 """ 

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

560 result._tail_left = 0.0 

561 result._tail_right = 0.0 

562 return result 

563 

564 # ------------- 

565 # rootfinding 

566 # ------------- 

567 def roots(self) -> Any: 

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

569 

570 The underlying polynomial approximation can produce many spurious 

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

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

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

574 

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

576 actually crosses zero), **and** 

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

578 (the values are above numerical noise). 

579 

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

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

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

583 designed for. 

584 """ 

585 raw = super().roots() 

586 if raw.size == 0: 

587 return raw 

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

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

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

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

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

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

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

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

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

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

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

599 keep = sign_flip & above_noise 

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

601 

602 # ----------- 

603 # utilities 

604 # ----------- 

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

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

607 from .bndfun import Bndfun 

608 

609 sub_a, sub_b = _ensure_endpoints(subinterval) 

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

611 raise NotImplementedError( 

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

613 "restriction to unbounded subintervals is not supported." 

614 ) 

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

616 

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

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

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

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

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

622 return self.__class__( 

623 self.onefun, 

624 new_storage, 

625 logical_interval=new_logical, 

626 tail_left=self._tail_left, 

627 tail_right=self._tail_right, 

628 ) 

629 

630 # ------------ 

631 # arithmetic 

632 # ------------ 

633 def __neg__(self) -> CompactFun: 

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

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

636 result._tail_left = -self._tail_left 

637 result._tail_right = -self._tail_right 

638 return result 

639 

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

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

642 result = super().__add__(other) 

643 if isinstance(result, CompactFun): 

644 other_tl, other_tr = self._other_tails(other) 

645 result._tail_left = self._tail_left + other_tl 

646 result._tail_right = self._tail_right + other_tr 

647 return result 

648 

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

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

651 result = super().__radd__(other) 

652 if isinstance(result, CompactFun): 

653 other_tl, other_tr = self._other_tails(other) 

654 result._tail_left = self._tail_left + other_tl 

655 result._tail_right = self._tail_right + other_tr 

656 return result 

657 

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

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

660 result = super().__sub__(other) 

661 if isinstance(result, CompactFun): 

662 other_tl, other_tr = self._other_tails(other) 

663 result._tail_left = self._tail_left - other_tl 

664 result._tail_right = self._tail_right - other_tr 

665 return result 

666 

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

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

669 result = super().__rsub__(other) 

670 if isinstance(result, CompactFun): 

671 other_tl, other_tr = self._other_tails(other) 

672 result._tail_left = other_tl - self._tail_left 

673 result._tail_right = other_tr - self._tail_right 

674 return result 

675 

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

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

678 result = super().__mul__(other) 

679 if isinstance(result, CompactFun): 

680 other_tl, other_tr = self._other_tails(other) 

681 result._tail_left = self._tail_left * other_tl 

682 result._tail_right = self._tail_right * other_tr 

683 return result 

684 

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

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

687 result = super().__rmul__(other) 

688 if isinstance(result, CompactFun): 

689 other_tl, other_tr = self._other_tails(other) 

690 result._tail_left = self._tail_left * other_tl 

691 result._tail_right = self._tail_right * other_tr 

692 return result 

693 

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

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

696 

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

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

699 """ 

700 if isinstance(other, CompactFun): 

701 return other._tail_left, other._tail_right 

702 if np.isscalar(other): 

703 v = float(cast(Any, other)) 

704 return v, v 

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

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

707 return 0.0, 0.0 

708 

709 # ---------- 

710 # plotting 

711 # ---------- 

712 @property 

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

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

715 

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

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

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

719 """ 

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

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

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

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

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

725 return (a, b) 

726 

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

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

729 

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

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

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

733 """ 

734 from .plotting import plotfun 

735 

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

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