Repository: scipy/scipy

Issue:
ENH: Akima1DInterpolator Extrapolation
### Is your feature request related to a problem? Please describe.

Unlike the CubicHermiteSpline from which the Akima interpolator inherits from, attempting extrapolation with the Akima1DInterpolator returns NaN outside of the original domain. I expected this because of the hard coded `extrapolate=False` on initialization.

I think adding extrapolation capabilities to the Akima1DInterpolator class would make sense. Of the four main 1D splines (CubicSpline, CubicHermiteSpline, PchipInterpolator, and Akima1DInterpolator), Akima is the only one to not support extrapolation.

### Describe the solution you'd like.

Allowing the specification of `extrapolate=True` and exposing a similar extrapolation parameter interface as CubicHermiteSpline is the most natural. Extrapolation should then be handled similarly to all of the other splines in the user's perspective.

### Describe alternatives you've considered.

For my own project purposes, I have been using alternative splines from Scipy. The Akima1DInterpolator (and the modified Akima interpolator per #19696 ) seems to have desirable properties, but the lack of extrapolation is otherwise problematic.

### Additional context (e.g. screenshots, GIFs)

_No response_


Discussion:
You can switch extrapolation either at call site or globally (this is inherited from Akima1DInterpolator superclass, PPoly):

```
In [1]: import numpy as np

In [2]: from scipy.interpolate import Akima1DInterpolator

In [3]: a = Akima1DInterpolator([1, 2, 3], [4, 5, 6])

In [4]: a(5)
Out[4]: array(nan)

In [5]: a(5, extrapolate=True)
Out[5]: array(8.)

In [6]: a.extrapolate = True

In [7]: a(5)
Out[7]: array(8.)
```

That said, the decision to not have the extrapolate parameter is historic. A pull request adding the `extrapolate=False` parameter to the constructor is welcome.

Resolving patch:
diff --git a/scipy/interpolate/_cubic.py b/scipy/interpolate/_cubic.py
--- a/scipy/interpolate/_cubic.py
+++ b/scipy/interpolate/_cubic.py
@@ -424,6 +424,11 @@ class Akima1DInterpolator(CubicHermiteSpline):
 
         .. versionadded:: 1.13.0
 
+    extrapolate : {bool, None}, optional
+        If bool, determines whether to extrapolate to out-of-bounds points 
+        based on first and last intervals, or to return NaNs. If None, 
+        ``extrapolate`` is set to False.
+        
     Methods
     -------
     __call__
@@ -505,7 +510,8 @@ class Akima1DInterpolator(CubicHermiteSpline):
 
     """
 
-    def __init__(self, x, y, axis=0, *, method: Literal["akima", "makima"]="akima"):
+    def __init__(self, x, y, axis=0, *, method: Literal["akima", "makima"]="akima", 
+                 extrapolate:bool | None = None):
         if method not in {"akima", "makima"}:
             raise NotImplementedError(f"`method`={method} is unsupported.")
         # Original implementation in MATLAB by N. Shamsundar (BSD licensed), see
@@ -520,6 +526,9 @@ def __init__(self, x, y, axis=0, *, method: Literal["akima", "makima"]="akima"):
                    "the array before passing to `Akima1DInterpolator`.")
             warnings.warn(msg, DeprecationWarning, stacklevel=2)
 
+        # Akima extrapolation historically False; parent class defaults to True.
+        extrapolate = False if extrapolate is None else extrapolate
+
         # determine slopes between breakpoints
         m = np.empty((x.size + 3, ) + y.shape[1:])
         dx = dx[(slice(None), ) + (None, ) * (y.ndim - 1)]
@@ -552,7 +561,7 @@ def __init__(self, x, y, axis=0, *, method: Literal["akima", "makima"]="akima"):
         t[ind] = (f1[ind] * m[(x_ind + 1,) + y_ind] +
                   f2[ind] * m[(x_ind + 2,) + y_ind]) / f12[ind]
 
-        super().__init__(x, y, t, axis=0, extrapolate=False)
+        super().__init__(x, y, t, axis=0, extrapolate=extrapolate)
         self.axis = axis
 
     def extend(self, c, x, right=True):
@@ -959,8 +968,9 @@ def _validate_bc(bc_type, y, expected_deriv_shape, axis):
                 deriv_value = np.asarray(deriv_value)
                 if deriv_value.shape != expected_deriv_shape:
                     raise ValueError(
-                        "`deriv_value` shape {} is not the expected one {}."
-                        .format(deriv_value.shape, expected_deriv_shape))
+                        f"`deriv_value` shape {deriv_value.shape} is not "
+                        f"the expected one {expected_deriv_shape}."
+                    )
 
                 if np.issubdtype(deriv_value.dtype, np.complexfloating):
                     y = y.astype(complex, copy=False)


Test patch:
diff --git a/scipy/interpolate/tests/test_interpolate.py b/scipy/interpolate/tests/test_interpolate.py
--- a/scipy/interpolate/tests/test_interpolate.py
+++ b/scipy/interpolate/tests/test_interpolate.py
@@ -1015,6 +1015,25 @@ def test_mod_invalid_method(self):
         with pytest.raises(NotImplementedError, match=match):
             Akima1DInterpolator(x, y, method="invalid")  # type: ignore
 
+    def test_extrapolate_attr(self):
+        # 
+        x = np.linspace(-5, 5, 11)
+        y = x**2
+        x_ext = np.linspace(-10, 10, 17)
+        y_ext = x_ext**2
+        # Testing all extrapolate cases.
+        ak_true = Akima1DInterpolator(x, y, extrapolate=True)
+        ak_false = Akima1DInterpolator(x, y, extrapolate=False)
+        ak_none = Akima1DInterpolator(x, y, extrapolate=None)
+        # None should default to False; extrapolated points are NaN.
+        assert_allclose(ak_false(x_ext), ak_none(x_ext), equal_nan=True, atol=1e-15)
+        assert_equal(ak_false(x_ext)[0:4], np.full(4, np.nan))
+        assert_equal(ak_false(x_ext)[-4:-1], np.full(3, np.nan))
+        # Extrapolation on call and attribute should be equal.
+        assert_allclose(ak_false(x_ext, extrapolate=True), ak_true(x_ext), atol=1e-15)
+        # Testing extrapoation to actual function.
+        assert_allclose(y_ext, ak_true(x_ext), atol=1e-15)
+
     def test_complex(self):
         # Complex-valued data deprecated
         x = np.arange(0., 11.)