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
« 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.
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: 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
96 if not radii:
97 return anchor + sign * 1.0, 0.0, 0.0
99 abs_values = [abs(v) for v in values]
100 vscale = max(abs_values) if abs_values else 0.0
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 )
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
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
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
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``.
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.
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).
163 Raises:
164 CompactFunConstructionError: If support cannot be discovered.
165 """
166 left_inf = not np.isfinite(a)
167 right_inf = not np.isfinite(b)
169 if not (left_inf or right_inf):
170 return a, b, 0.0, 0.0
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
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
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
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
203class CompactFun(Classicfun):
204 """Functions on (semi-)infinite intervals with finite numerical support.
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``).
215 Two intervals are tracked:
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`.
222 Two scalar tail constants are tracked:
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``.
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`.
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 """
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.
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)
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.
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 )
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))
297 @classmethod
298 def initconst(cls, c: Any, interval: Any) -> CompactFun:
299 """Initialise a constant function.
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)
323 @classmethod
324 def initidentity(cls, interval: Any) -> CompactFun:
325 """Initialise the identity function ``f(x) = x``.
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))
340 @classmethod
341 def initfun_adaptive(cls, f: Any, interval: Any) -> CompactFun:
342 """Initialise from a callable using adaptive sampling.
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.
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)
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.
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)
386 # -------------------
387 # evaluation
388 # -------------------
389 def __call__(self, x: Any, how: str = "clenshaw") -> Any:
390 """Evaluate the function at ``x``.
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
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
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)
431 @property
432 def tail_left(self) -> float:
433 """Asymptotic value of the function as ``x → -inf``.
435 Always ``0.0`` when the logical-left endpoint is finite.
436 """
437 return self._tail_left
439 @property
440 def tail_right(self) -> float:
441 """Asymptotic value of the function as ``x → +inf``.
443 Always ``0.0`` when the logical-right endpoint is finite.
444 """
445 return self._tail_right
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])
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})"
465 # ----------
466 # calculus
467 # ----------
468 def sum(self) -> Any:
469 """Compute the definite integral over the logical interval.
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()
489 def cumsum(self) -> CompactFun:
490 """Compute the indefinite integral.
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``).
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 )
528 def diff(self) -> CompactFun:
529 """Compute the derivative.
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
539 # -------------
540 # rootfinding
541 # -------------
542 def roots(self) -> Any:
543 """Find the roots, filtering out spurious roots in numerical-noise regions.
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:
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).
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]))
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
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))
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 )
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
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
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
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
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
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
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
669 def _other_tails(self, other: Any) -> tuple[float, float]:
670 """Extract ``(tail_left, tail_right)`` from a binary-op operand.
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
684 # ----------
685 # plotting
686 # ----------
687 @property
688 def plot_support(self) -> tuple[float, float]:
689 """Return a finite ``[a, b]`` plotting window.
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)
702 def plot(self, ax: Any = None, **kwds: Any) -> Any:
703 """Plot the function over a finite window derived from its numerical support.
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
711 support = kwds.pop("support", self.plot_support)
712 return plotfun(self, support, ax=ax, **kwds)