Coverage for src / chebpy / classicfun.py: 99%
163 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 the Classicfun class for functions on arbitrary intervals.
3This module provides the Classicfun class, which represents functions on arbitrary intervals
4by mapping them to a standard domain [-1, 1] and using a Onefun representation.
5"""
7from abc import ABC
8from typing import Any
10import matplotlib.pyplot as plt
11import numpy as np
13from .chebtech import Chebtech
14from .decorators import self_empty
15from .exceptions import IntervalMismatch, NotSubinterval
16from .fun import Fun
17from .plotting import plotfun
18from .settings import _preferences as prefs
19from .trigtech import Trigtech
20from .utilities import Interval, IntervalMap
22techdict = {
23 "Chebtech": Chebtech,
24 "Trigtech": Trigtech,
25}
28class Classicfun(Fun, ABC):
29 """Abstract base class for functions defined on arbitrary intervals using a mapped representation.
31 This class implements the Fun interface for functions defined on arbitrary intervals
32 by mapping them to a standard domain [-1, 1] and using a Onefun representation
33 (such as Chebtech) on that standard domain.
35 The Classicfun class serves as a base class for specific implementations like Bndfun.
36 It handles the mapping between the arbitrary interval and the standard domain,
37 delegating the actual function representation to the underlying Onefun object.
38 """
40 # ``_singularity_priority`` lets mixed-type binary operations dispatch
41 # to the more "singular" representation when two ``Classicfun``
42 # subclasses meet on the same interval. Higher wins. ``Bndfun`` and
43 # ``CompactFun`` use the default of ``0``; ``Singfun`` overrides to
44 # ``10`` so that ``Singfun + Bndfun`` yields a ``Singfun``.
45 _singularity_priority: int = 0
47 # --------------------------
48 # alternative constructors
49 # --------------------------
50 @classmethod
51 def initempty(cls) -> "Classicfun":
52 """Initialize an empty function.
54 This constructor creates an empty function representation, which is
55 useful as a placeholder or for special cases. The interval has no
56 relevance to the emptiness status of a Classicfun, so we arbitrarily
57 set it to be the default interval [-1, 1].
59 Returns:
60 Classicfun: A new empty instance.
61 """
62 interval = Interval()
63 onefun = techdict[prefs.tech].initempty(interval=interval)
64 return cls(onefun, interval)
66 @classmethod
67 def initconst(cls, c: Any, interval: Any) -> "Classicfun":
68 """Initialize a constant function.
70 This constructor creates a function that represents a constant value
71 on the specified interval.
73 Args:
74 c: The constant value.
75 interval: The interval on which to define the function.
77 Returns:
78 Classicfun: A new instance representing the constant function f(x) = c.
79 """
80 onefun = techdict[prefs.tech].initconst(c, interval=interval)
81 return cls(onefun, interval)
83 @classmethod
84 def initidentity(cls, interval: Any) -> "Classicfun":
85 """Initialize the identity function f(x) = x.
87 This constructor creates a function that represents f(x) = x
88 on the specified interval.
90 Args:
91 interval: The interval on which to define the identity function.
93 Returns:
94 Classicfun: A new instance representing the identity function.
95 """
96 onefun = techdict[prefs.tech].initvalues(np.asarray(interval), interval=interval)
97 return cls(onefun, interval)
99 @classmethod
100 def initfun_adaptive(cls, f: Any, interval: Any) -> "Classicfun":
101 """Initialize from a callable function using adaptive sampling.
103 This constructor determines the appropriate number of points needed to
104 represent the function to the specified tolerance using an adaptive algorithm.
106 Args:
107 f (callable): The function to be approximated.
108 interval: The interval on which to define the function.
110 Returns:
111 Classicfun: A new instance representing the function f.
112 """
113 onefun = techdict[prefs.tech].initfun(lambda y: f(interval(y)), interval=interval)
114 return cls(onefun, interval)
116 @classmethod
117 def initfun_fixedlen(cls, f: Any, interval: Any, n: int) -> "Classicfun":
118 """Initialize from a callable function using a fixed number of points.
120 This constructor uses a specified number of points to represent the function,
121 rather than determining the number adaptively.
123 Args:
124 f (callable): The function to be approximated.
125 interval: The interval on which to define the function.
126 n (int): The number of points to use.
128 Returns:
129 Classicfun: A new instance representing the function f.
130 """
131 onefun = techdict[prefs.tech].initfun(lambda y: f(interval(y)), n, interval=interval)
132 return cls(onefun, interval)
134 # -------------------
135 # 'private' methods
136 # -------------------
137 def __call__(self, x: Any, how: str = "clenshaw") -> Any:
138 """Evaluate the function at points x.
140 This method evaluates the function at the specified points by mapping them
141 to the standard domain [-1, 1] and evaluating the underlying onefun.
143 Args:
144 x (float or array-like): Points at which to evaluate the function.
145 how (str, optional): Method to use for evaluation. Defaults to "clenshaw".
147 Returns:
148 float or array-like: The value(s) of the function at the specified point(s).
149 Returns a scalar if x is a scalar, otherwise an array of the same size as x.
150 """
151 y = self.map.invmap(x)
152 return self.onefun(y, how)
154 def __init__(self, onefun: Any, interval: Any) -> None:
155 """Initialize a new Classicfun instance.
157 This method initializes a new function representation on the specified interval
158 using the provided onefun object for the standard domain representation.
160 Args:
161 onefun: The Onefun object representing the function on [-1, 1].
162 interval: The Interval object defining the domain of the function.
163 """
164 self.onefun = onefun
165 self._interval = interval
167 def _rebuild(self, onefun: Any) -> "Classicfun":
168 """Construct a new instance of this class with a replacement ``onefun``.
170 Subclasses that carry additional metadata beyond ``onefun`` and
171 ``interval`` (e.g. :class:`CompactFun`'s logical interval) should
172 override this method so that operations defined on the parent class
173 preserve that metadata.
175 Args:
176 onefun: The replacement Onefun object.
178 Returns:
179 Classicfun: A new instance of ``type(self)``.
180 """
181 return self.__class__(onefun, self._interval)
183 def _can_share_onefun_with(self, other: "Classicfun") -> bool:
184 """Return True if ``self`` and ``other`` represent functions on the same t-grid.
186 Two ``Classicfun`` instances can share onefun-level arithmetic when
187 they have the same concrete subclass, the same logical interval, and
188 the same map (so the underlying ``Onefun`` coefficients refer to the
189 same Chebyshev nodes in ``t``-space). The default implementation
190 compares only the type and the interval, which is correct for the
191 affine-mapped subclasses (:class:`Bndfun`, :class:`CompactFun`).
192 :class:`~chebpy.singfun.Singfun` overrides this to additionally
193 compare maps.
194 """
195 return type(self) is type(other) and self._interval == other._interval
197 def _rebuild_from_callable(self, f: Any) -> "Classicfun":
198 """Adaptively rebuild a fun of this type evaluating callable ``f``.
200 Used by mixed-type binary operations to reconstruct the result on the
201 dominant operand's representation. Subclasses with extra metadata
202 (e.g. :class:`~chebpy.singfun.Singfun`'s map) override this.
203 """
204 return type(self).initfun_adaptive(f, self._interval)
206 def __repr__(self) -> str: # pragma: no cover
207 """Return a string representation of the function.
209 This method returns a string representation of the function that includes
210 the class name, support interval, and size.
212 Returns:
213 str: A string representation of the function.
214 """
215 out = "{0}([{2}, {3}], {1})".format(self.__class__.__name__, self.size, *self.support)
216 return out
218 # ------------
219 # properties
220 # ------------
221 @property
222 def coeffs(self) -> Any:
223 """Get the coefficients of the function representation.
225 This property returns the coefficients used in the function representation,
226 delegating to the underlying onefun object.
228 Returns:
229 array-like: The coefficients of the function representation.
230 """
231 return self.onefun.coeffs
233 @property
234 def endvalues(self) -> Any:
235 """Get the values of the function at the endpoints of its interval.
237 This property evaluates the function at the endpoints of its interval
238 of definition.
240 Returns:
241 numpy.ndarray: Array containing the function values at the endpoints
242 of the interval [a, b].
243 """
244 return self.__call__(self.support)
246 @property
247 def interval(self) -> Any:
248 """Get the interval on which this function is defined.
250 This property returns the interval object representing the domain
251 of definition for this function.
253 Returns:
254 Interval: The interval on which this function is defined.
255 """
256 return self._interval
258 @property
259 def map(self) -> IntervalMap:
260 """Return the bijective map between [-1, 1] and the function's interval.
262 Subclasses backed by a non-affine map (e.g. endpoint-clustering
263 transforms for endpoint singularities) override this to return a
264 different :class:`~chebpy.utilities.IntervalMap` implementer while
265 keeping ``self._interval`` as the logical support endpoints.
267 Returns:
268 IntervalMap: The map used to relate reference points ``y ∈ [-1, 1]``
269 to logical points ``x ∈ [a, b]``. Defaults to ``self._interval``,
270 which is the affine :class:`~chebpy.utilities.Interval` map.
271 """
272 return self._interval
274 @property
275 def isconst(self) -> Any:
276 """Check if this function represents a constant.
278 This property determines whether the function is constant (i.e., f(x) = c
279 for some constant c) over its interval of definition, delegating to the
280 underlying onefun object.
282 Returns:
283 bool: True if the function is constant, False otherwise.
284 """
285 return self.onefun.isconst
287 @property
288 def iscomplex(self) -> Any:
289 """Check if this function has complex values.
291 This property determines whether the function has complex values or is
292 purely real-valued, delegating to the underlying onefun object.
294 Returns:
295 bool: True if the function has complex values, False otherwise.
296 """
297 return self.onefun.iscomplex
299 @property
300 def isempty(self) -> Any:
301 """Check if this function is empty.
303 This property determines whether the function is empty, which is a special
304 state used as a placeholder or for special cases, delegating to the
305 underlying onefun object.
307 Returns:
308 bool: True if the function is empty, False otherwise.
309 """
310 return self.onefun.isempty
312 @property
313 def size(self) -> Any:
314 """Get the size of the function representation.
316 This property returns the number of coefficients or other measure of the
317 complexity of the function representation, delegating to the underlying
318 onefun object.
320 Returns:
321 int: The size of the function representation.
322 """
323 return self.onefun.size
325 @property
326 def support(self) -> Any:
327 """Get the support interval of this function.
329 This property returns the interval on which this function is defined,
330 represented as a numpy array with two elements [a, b].
332 Returns:
333 numpy.ndarray: Array containing the endpoints of the interval.
334 """
335 return np.asarray(self.interval)
337 @property
338 def vscale(self) -> Any:
339 """Get the vertical scale of the function.
341 This property returns a measure of the range of function values, typically
342 the maximum absolute value of the function on its interval of definition,
343 delegating to the underlying onefun object.
345 Returns:
346 float: The vertical scale of the function.
347 """
348 return self.onefun.vscale
350 # -----------
351 # utilities
352 # -----------
354 def imag(self) -> "Classicfun":
355 """Get the imaginary part of this function.
357 This method returns a new function representing the imaginary part of this function.
358 If this function is real-valued, returns a zero function.
360 Returns:
361 Classicfun: A new function representing the imaginary part of this function.
362 """
363 if self.iscomplex:
364 return self._rebuild(self.onefun.imag())
365 else:
366 return self.initconst(0, interval=self.interval)
368 def real(self) -> "Classicfun":
369 """Get the real part of this function.
371 This method returns a new function representing the real part of this function.
372 If this function is already real-valued, returns this function.
374 Returns:
375 Classicfun: A new function representing the real part of this function.
376 """
377 if self.iscomplex:
378 return self._rebuild(self.onefun.real())
379 else:
380 return self
382 def restrict(self, subinterval: Any) -> "Classicfun":
383 """Restrict this function to a subinterval.
385 This method creates a new function that is the restriction of this function
386 to the specified subinterval. The output is formed using a fixed length
387 construction with the same number of degrees of freedom as the original function.
389 Args:
390 subinterval (array-like): The subinterval to which this function should be restricted.
391 Must be contained within the original interval of definition.
393 Returns:
394 Classicfun: A new function representing the restriction of this function to the subinterval.
396 Raises:
397 NotSubinterval: If the subinterval is not contained within the original interval.
398 """
399 if subinterval not in self.interval: # pragma: no cover
400 raise NotSubinterval(self.interval, subinterval)
401 if self.interval == subinterval:
402 return self
403 else:
404 return self.__class__.initfun_fixedlen(self, subinterval, self.size)
406 def translate(self, c: float) -> "Classicfun":
407 """Translate this function by a constant c.
409 This method creates a new function g(x) = f(x-c), which is the original
410 function translated horizontally by c.
412 Args:
413 c (float): The amount by which to translate the function.
415 Returns:
416 Classicfun: A new function representing g(x) = f(x-c).
417 """
418 return self.__class__(self.onefun, self.interval + c)
420 # -------------
421 # rootfinding
422 # -------------
423 def roots(self) -> Any:
424 """Find the roots (zeros) of the function on its interval of definition.
426 This method computes the points where the function equals zero
427 within its interval of definition by finding the roots of the
428 underlying onefun and mapping them to the function's interval.
430 Returns:
431 numpy.ndarray: An array of the roots of the function in its interval of definition,
432 sorted in ascending order.
433 """
434 uroots = self.onefun.roots()
435 return self.map.formap(uroots)
437 # ----------
438 # calculus
439 # ----------
440 def cumsum(self) -> "Classicfun":
441 """Compute the indefinite integral of the function.
443 This method calculates the indefinite integral (antiderivative) of the function,
444 with the constant of integration chosen so that the indefinite integral
445 evaluates to 0 at the left endpoint of the interval.
447 Returns:
448 Classicfun: A new function representing the indefinite integral of this function.
449 """
450 a, b = self.interval
451 onefun = 0.5 * (b - a) * self.onefun.cumsum()
452 return self._rebuild(onefun)
454 def diff(self) -> "Classicfun":
455 """Compute the derivative of the function.
457 This method calculates the derivative of the function with respect to x,
458 applying the chain rule to account for the mapping between the standard
459 domain [-1, 1] and the function's interval.
461 Returns:
462 Classicfun: A new function representing the derivative of this function.
463 """
464 a, b = self.interval
465 onefun = 2.0 / (b - a) * self.onefun.diff()
466 return self._rebuild(onefun)
468 def sum(self) -> Any:
469 """Compute the definite integral of the function over its interval of definition.
471 This method calculates the definite integral of the function
472 over its interval of definition [a, b], applying the appropriate
473 scaling factor to account for the mapping from [-1, 1].
475 Returns:
476 float or complex: The definite integral of the function over its interval of definition.
477 """
478 a, b = self.interval
479 return 0.5 * (b - a) * self.onefun.sum()
481 # ----------
482 # plotting
483 # ----------
484 def plot(self, ax: Any = None, **kwds: Any) -> Any:
485 """Plot the function over its interval of definition.
487 This method plots the function over its interval of definition using matplotlib.
488 For complex-valued functions, it plots the real part against the imaginary part.
490 Args:
491 ax (matplotlib.axes.Axes, optional): The axes on which to plot. If None,
492 a new axes will be created. Defaults to None.
493 **kwds: Additional keyword arguments to pass to matplotlib's plot function.
495 Returns:
496 matplotlib.axes.Axes: The axes on which the plot was created.
497 """
498 return plotfun(self, self.support, ax=ax, **kwds)
501# ----------------------------------------------------------------
502# methods that execute the corresponding onefun method as is
503# ----------------------------------------------------------------
505methods_onefun_other = ("values", "plotcoeffs")
508def add_utility(methodname: str) -> None:
509 """Add a utility method to the Classicfun class.
511 This function creates a method that delegates to the corresponding method
512 of the underlying onefun object and adds it to the Classicfun class.
514 Args:
515 methodname (str): The name of the method to add.
517 Note:
518 The created method will have the same name and signature as the
519 corresponding method in the onefun object.
520 """
522 def method(self: Any, *args: Any, **kwds: Any) -> Any:
523 """Delegate to the corresponding method of the underlying onefun object.
525 This method calls the same-named method on the underlying onefun object
526 and returns its result.
528 Args:
529 self (Classicfun): The Classicfun object.
530 *args: Variable length argument list to pass to the onefun method.
531 **kwds: Arbitrary keyword arguments to pass to the onefun method.
533 Returns:
534 The return value from the corresponding onefun method.
535 """
536 return getattr(self.onefun, methodname)(*args, **kwds)
538 method.__name__ = methodname
539 method.__doc__ = method.__doc__
540 setattr(Classicfun, methodname, method)
543for methodname in methods_onefun_other:
544 if methodname[:4] == "plot" and plt is None: # pragma: no cover
545 continue
546 add_utility(methodname)
549# -----------------------------------------------------------------------
550# unary operators and zero-argument utlity methods returning a onefun
551# -----------------------------------------------------------------------
553methods_onefun_zeroargs = ("__pos__", "__neg__", "copy", "simplify")
556def add_zero_arg_op(methodname: str) -> None:
557 """Add a zero-argument operation method to the Classicfun class.
559 This function creates a method that delegates to the corresponding method
560 of the underlying onefun object and wraps the result in a new Classicfun
561 instance with the same interval.
563 Args:
564 methodname (str): The name of the method to add.
566 Note:
567 The created method will have the same name and signature as the
568 corresponding method in the onefun object, but will return a Classicfun
569 instance instead of an onefun instance.
570 """
572 def method(self: Any, *args: Any, **kwds: Any) -> Any:
573 """Apply a zero-argument operation and return a new Classicfun.
575 This method calls the same-named method on the underlying onefun object
576 and wraps the result in a new Classicfun instance with the same interval.
578 Args:
579 self (Classicfun): The Classicfun object.
580 *args: Variable length argument list to pass to the onefun method.
581 **kwds: Arbitrary keyword arguments to pass to the onefun method.
583 Returns:
584 Classicfun: A new Classicfun instance with the result of the operation.
585 """
586 onefun = getattr(self.onefun, methodname)(*args, **kwds)
587 return self._rebuild(onefun)
589 method.__name__ = methodname
590 method.__doc__ = method.__doc__
591 setattr(Classicfun, methodname, method)
594for methodname in methods_onefun_zeroargs:
595 add_zero_arg_op(methodname)
597# -----------------------------------------
598# binary operators returning a onefun
599# -----------------------------------------
601# Map from dunder method name to the corresponding callable acting on raw
602# values. Used by the mixed-subclass binary-op fallback to reconstruct the
603# result adaptively on the dominant operand's representation.
604_BINOP_OPERATORS: dict[str, Any] = {
605 "__add__": lambda a, b: a + b,
606 "__sub__": lambda a, b: a - b,
607 "__mul__": lambda a, b: a * b,
608 "__truediv__": lambda a, b: a / b,
609 "__div__": lambda a, b: a / b,
610 "__pow__": lambda a, b: a**b,
611 "__radd__": lambda a, b: b + a,
612 "__rsub__": lambda a, b: b - a,
613 "__rmul__": lambda a, b: b * a,
614 "__rtruediv__": lambda a, b: b / a,
615 "__rdiv__": lambda a, b: b / a,
616 "__rpow__": lambda a, b: b**a,
617}
620def _classicfun_mixed_binop(self: "Classicfun", other: "Classicfun", methodname: str) -> "Classicfun":
621 """Reconstruct a same-interval, mixed-subclass binary op on the dominant operand.
623 When two :class:`Classicfun` instances of different subclasses (or
624 same subclass but with maps that disagree) meet on the same logical
625 interval, neither's onefun-level arithmetic is correct. Pick the
626 operand with higher ``_singularity_priority`` and rebuild the
627 composition adaptively in its representation. Ties go to ``self``.
628 """
629 op_fn = _BINOP_OPERATORS[methodname]
630 owner = self if self._singularity_priority >= other._singularity_priority else other
632 def combined(x: Any) -> Any:
633 return op_fn(self(x), other(x))
635 return owner._rebuild_from_callable(combined)
638# ToDo: change these to operator module methods
639methods_onefun_binary = (
640 "__add__",
641 "__div__",
642 "__mul__",
643 "__pow__",
644 "__radd__",
645 "__rdiv__",
646 "__rmul__",
647 "__rpow__",
648 "__rsub__",
649 "__rtruediv__",
650 "__sub__",
651 "__truediv__",
652)
655def add_binary_op(methodname: str) -> None:
656 """Add a binary operation method to the Classicfun class.
658 This function creates a method that implements a binary operation between
659 two Classicfun objects or between a Classicfun and a scalar. It delegates
660 to the corresponding method of the underlying onefun object and wraps the
661 result in a new Classicfun instance with the same interval.
663 Args:
664 methodname (str): The name of the binary operation method to add.
666 Note:
667 The created method will check that both Classicfun objects have the
668 same interval before performing the operation. If one operand is not
669 a Classicfun, it will be passed directly to the onefun method.
670 """
672 @self_empty()
673 def method(self: Any, f: Any, *args: Any, **kwds: Any) -> Any:
674 """Apply a binary operation and return a new Classicfun.
676 This method implements a binary operation between this Classicfun and
677 another object (either another Classicfun or a scalar). It delegates
678 to the corresponding method of the underlying onefun object and wraps
679 the result in a new Classicfun instance with the same interval.
681 Args:
682 self (Classicfun): The Classicfun object.
683 f (Classicfun or scalar): The second operand of the binary operation.
684 *args: Variable length argument list to pass to the onefun method.
685 **kwds: Arbitrary keyword arguments to pass to the onefun method.
687 Returns:
688 Classicfun: A new Classicfun instance with the result of the operation.
690 Raises:
691 IntervalMismatch: If f is a Classicfun with a different interval.
692 """
693 if isinstance(f, Classicfun):
694 if f.isempty:
695 return f.copy()
696 if self.interval != f.interval: # pragma: no cover
697 raise IntervalMismatch(self.interval, f.interval)
698 if not self._can_share_onefun_with(f):
699 # Mixed subclasses (or same subclass with disagreeing maps):
700 # rebuild adaptively on the dominant operand's representation.
701 return _classicfun_mixed_binop(self, f, methodname)
702 g = f.onefun
703 else:
704 # let the lower level classes raise any other exceptions
705 g = f
706 onefun = getattr(self.onefun, methodname)(g, *args, **kwds)
707 return self._rebuild(onefun)
709 method.__name__ = methodname
710 method.__doc__ = method.__doc__
711 setattr(Classicfun, methodname, method)
714for methodname in methods_onefun_binary:
715 add_binary_op(methodname)
717# ---------------------------
718# numpy universal functions
719# ---------------------------
722def add_ufunc(op: Any) -> None:
723 """Add a NumPy universal function method to the Classicfun class.
725 This function creates a method that applies a NumPy universal function (ufunc)
726 to the values of a Classicfun and returns a new Classicfun representing the result.
728 Args:
729 op (callable): The NumPy universal function to apply.
731 Note:
732 The created method will have the same name as the NumPy function
733 and will take no arguments other than self.
734 """
736 @self_empty()
737 def method(self: Any) -> Any:
738 """Apply a NumPy universal function to this function.
740 This method applies a NumPy universal function (ufunc) to the values
741 of this function and returns a new function representing the result.
743 Returns:
744 Classicfun: A new function representing op(f(x)).
745 """
746 return self.__class__.initfun_adaptive(lambda x: op(self(x)), self.interval)
748 name = op.__name__
749 method.__name__ = name
750 method.__doc__ = method.__doc__
751 setattr(Classicfun, name, method)
754ufuncs = (
755 np.absolute,
756 np.arccos,
757 np.arccosh,
758 np.arcsin,
759 np.arcsinh,
760 np.arctan,
761 np.arctanh,
762 np.ceil,
763 np.cos,
764 np.cosh,
765 np.exp,
766 np.exp2,
767 np.expm1,
768 np.floor,
769 np.log,
770 np.log2,
771 np.log10,
772 np.log1p,
773 np.sign,
774 np.sinh,
775 np.sin,
776 np.tan,
777 np.tanh,
778 np.sqrt,
779)
781for op in ufuncs:
782 add_ufunc(op)