Repository: astropy/astropy

Issue:
# BUG:  SigmaClip objects cannot be pickled when Bottleneck is installed

### Description

With `Bottleneck` installed:

```python
import pickle
from astropy.stats import SigmaClip

sigclip = SigmaClip(sigma=3.0, maxiters=5)
with open('sigclip.pkl', 'wb') as fh:
    pickle.dump(sigclip, fh)
```
error:
```
_pickle.PicklingError: Can't pickle local object <function _dtype_dispatch.<locals>.wrapped at 0x1096a43b0>
when serializing dict item '_cenfunc_parsed'
when serializing astropy.stats.sigma_clipping.SigmaClip state
when serializing astropy.stats.sigma_clipping.SigmaClip object
```

Git bisect points to this commit:  c06e4f7915bf889a64a84200e2d90cf23044bcae (https://github.com/astropy/astropy/pull/17204)

CC: @neutrinoceros 


Implementation patch:
diff --git a/astropy/stats/nanfunctions.py b/astropy/stats/nanfunctions.py
index efa9679d401b..20f64eb85def 100644
--- a/astropy/stats/nanfunctions.py
+++ b/astropy/stats/nanfunctions.py
@@ -101,29 +101,38 @@ def _apply_bottleneck(
         nanvar=np.nanvar,
     )
 
-    def _dtype_dispatch(func_name):
-        # dispatch to bottleneck or numpy depending on the input array dtype
-        # this is done to workaround known accuracy bugs in bottleneck
-        # affecting float32 calculations
-        # see https://github.com/pydata/bottleneck/issues/379
-        # see https://github.com/pydata/bottleneck/issues/462
-        # see https://github.com/astropy/astropy/issues/17185
-        # see https://github.com/astropy/astropy/issues/11492
-        def wrapped(*args, **kwargs):
-            if args[0].dtype.str[1:] == "f8":
-                return bn_funcs[func_name](*args, **kwargs)
-            else:
-                return np_funcs[func_name](*args, **kwargs)
+    class _DtypeDispatch:
+        """Picklable dispatcher that routes to bottleneck or numpy based on dtype.
+
+        Using a class instead of a closure ensures instances can be pickled,
+        which is required for pickling objects that store these functions as
+        attributes (e.g., SigmaClip._cenfunc_parsed).
+
+        Bottleneck is only used for float64 (f8) arrays to work around known
+        accuracy bugs affecting float32 calculations:
+        - https://github.com/pydata/bottleneck/issues/379
+        - https://github.com/pydata/bottleneck/issues/462
+        - https://github.com/astropy/astropy/issues/17185
+        - https://github.com/astropy/astropy/issues/11492
+        """
 
-        return wrapped
+        def __init__(self, func_name):
+            self.func_name = func_name
 
-    nansum = _dtype_dispatch("nansum")
-    nanmin = _dtype_dispatch("nanmin")
-    nanmax = _dtype_dispatch("nanmax")
-    nanmean = _dtype_dispatch("nanmean")
-    nanmedian = _dtype_dispatch("nanmedian")
-    nanstd = _dtype_dispatch("nanstd")
-    nanvar = _dtype_dispatch("nanvar")
+        def __call__(self, *args, **kwargs):
+            dt = args[0].dtype
+            if dt.kind == "f" and dt.itemsize == 8:
+                return bn_funcs[self.func_name](*args, **kwargs)
+            else:
+                return np_funcs[self.func_name](*args, **kwargs)
+
+    nansum = _DtypeDispatch("nansum")
+    nanmin = _DtypeDispatch("nanmin")
+    nanmax = _DtypeDispatch("nanmax")
+    nanmean = _DtypeDispatch("nanmean")
+    nanmedian = _DtypeDispatch("nanmedian")
+    nanstd = _DtypeDispatch("nanstd")
+    nanvar = _DtypeDispatch("nanvar")
 
 else:
     nansum = np.nansum


Test patch:
diff --git a/astropy/stats/tests/test_sigma_clipping.py b/astropy/stats/tests/test_sigma_clipping.py
index 9a5ee359fd01..c71d324c8f0d 100644
--- a/astropy/stats/tests/test_sigma_clipping.py
+++ b/astropy/stats/tests/test_sigma_clipping.py
@@ -1,5 +1,7 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
 
+import pickle
+
 import numpy as np
 import pytest
 from numpy.testing import assert_allclose, assert_equal
@@ -721,3 +723,18 @@ def test_propagation_of_mask():
     y = np.ma.masked_where(x > 1, x)
 
     assert_allclose(sigma_clipped_stats(y, grow=1), (1, 1, 0))
+
+
+def test_sigmaclip_pickle():
+    """Regression test for gh-19343: SigmaClip cannot be pickled
+    when Bottleneck is installed."""
+
+    sigclip = SigmaClip(sigma=3.0, maxiters=5)
+    restored = pickle.loads(pickle.dumps(sigclip))
+    assert restored.sigma == sigclip.sigma
+    assert restored.maxiters == sigclip.maxiters
+    # verify the restored object still works correctly
+    data = np.ma.array([1, 2, 3, 100, 4, 5])
+    result = restored(data)
+    expected = sigclip(data)
+    np.testing.assert_array_equal(result, expected)