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
« 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.
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``).
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"""
21from __future__ import annotations
23from typing import Any, cast
25import numpy as np
27from .classicfun import Classicfun, techdict
28from .exceptions import CompactFunConstructionError, DivergentIntegralError
29from .settings import _preferences as prefs
30from .utilities import Interval
33def _ensure_endpoints(interval: Any) -> tuple[float, float]:
34 """Return ``(a, b)`` floats from any 2-element interval-like object.
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)
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.
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``.
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.
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.
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
78 vscale = max(abs(v) for v in values)
79 tail = _detect_tail(radii, values, anchor, sign, tol, vscale)
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
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
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)``.
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.
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
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``.
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``.
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 )
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
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``.
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.
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).
188 Raises:
189 CompactFunConstructionError: If support cannot be discovered.
190 """
191 left_inf = not np.isfinite(a)
192 right_inf = not np.isfinite(b)
194 if not (left_inf or right_inf):
195 return a, b, 0.0, 0.0
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
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
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
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
228class CompactFun(Classicfun):
229 """Functions on (semi-)infinite intervals with finite numerical support.
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``).
240 Two intervals are tracked:
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`.
247 Two scalar tail constants are tracked:
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``.
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`.
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 """
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.
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)
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.
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 )
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))
322 @classmethod
323 def initconst(cls, c: Any, interval: Any) -> CompactFun:
324 """Initialise a constant function.
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)
348 @classmethod
349 def initidentity(cls, interval: Any) -> CompactFun:
350 """Initialise the identity function ``f(x) = x``.
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))
365 @classmethod
366 def initfun_adaptive(cls, f: Any, interval: Any) -> CompactFun:
367 """Initialise from a callable using adaptive sampling.
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.
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)
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.
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)
411 # -------------------
412 # evaluation
413 # -------------------
414 def __call__(self, x: Any, how: str = "clenshaw") -> Any:
415 """Evaluate the function at ``x``.
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
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
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)
456 @property
457 def tail_left(self) -> float:
458 """Asymptotic value of the function as ``x → -inf``.
460 Always ``0.0`` when the logical-left endpoint is finite.
461 """
462 return self._tail_left
464 @property
465 def tail_right(self) -> float:
466 """Asymptotic value of the function as ``x → +inf``.
468 Always ``0.0`` when the logical-right endpoint is finite.
469 """
470 return self._tail_right
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])
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})"
490 # ----------
491 # calculus
492 # ----------
493 def sum(self) -> Any:
494 """Compute the definite integral over the logical interval.
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()
514 def cumsum(self) -> CompactFun:
515 """Compute the indefinite integral.
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``).
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 )
553 def diff(self) -> CompactFun:
554 """Compute the derivative.
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
564 # -------------
565 # rootfinding
566 # -------------
567 def roots(self) -> Any:
568 """Find the roots, filtering out spurious roots in numerical-noise regions.
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:
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).
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]))
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
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))
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 )
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
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
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
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
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
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
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
694 def _other_tails(self, other: Any) -> tuple[float, float]:
695 """Extract ``(tail_left, tail_right)`` from a binary-op operand.
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
709 # ----------
710 # plotting
711 # ----------
712 @property
713 def plot_support(self) -> tuple[float, float]:
714 """Return a finite ``[a, b]`` plotting window.
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)
727 def plot(self, ax: Any = None, **kwds: Any) -> Any:
728 """Plot the function over a finite window derived from its numerical support.
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
736 support = kwds.pop("support", self.plot_support)
737 return plotfun(self, support, ax=ax, **kwds)