Repository: geopandas/geopandas

Issue:
ENH: adapt apply() to handle CRS
Following #1339 and discussion there, we should adapt `apply` to preserve CRS if it is applied on GeometryArrays. Once the CRS on GA level is merged. Currently (within the PR yet) any function applied on any other column than `geometry` drops CRS.

As discussed, it should come with the ability to opt-out/assign different CRS.

See https://github.com/geopandas/geopandas/pull/1339#issuecomment-605295675 and https://github.com/geopandas/geopandas/pull/1339#issuecomment-605676457 for details.

Discussion:
martinfleis: You may have a case when you want your resulting GeoSeries to have no CRS (e.g. transforming from state plane to naive geometry with custom 0,0 for construction purposes), we should allow that. Now you can either pass new one or inherit existing. We should have 3 options:

- preserve CRS
- set specific CRS
- use no CRS

I don't have a strong opinion on the actual keyword, but it should make sense in all three cases, so `crs` might be optimal for that.
jorisvandenbossche: So add `crs=False` ? 
martinfleis: Having `None`, `value`, `False` options? Can we change the default to `True`?
jorisvandenbossche: Hmm, I find `crs=True` a bit strange. `preserve_crs=True/False` seems better, but that then doesn't fully fit with passing a CRS object ..
martinfleis: Yeah, but `crs=None` to preserve and `crs=False` to set CRS to None is also strange. 
martinfleis: What about `apply_crs` as a keyword?
brendan-ward: Another option would be to always preserve the existing CRS if one exists, and let the user set it / clear it properly after `apply`; this seems potentially easiest from an API documentation standpoint (`always preserves existing CRS, if set.`).  Since CRS is set in place within `apply`, there should be low memory churn to setting it after the call.

inplace
```
result = s.apply(lambda x: x.centroid)
result.set_crs(4326, inplace=True)
```

chained
```
result = s.apply(lambda x: x.centroid).set_crs(4326)
```



Otherwise, it seems like the trap of a 3-way parameter remains: preseve existing, override with new, set to `None`
martinfleis: @brendan-ward that is actually quite a good point. I'd go with that.
jorisvandenbossche: That's fine for me as well.

In practice, it will be `s.apply(lambda x: x.centroid).set_crs(4326, allow_override=True)` (with the `allow_override` keyword) if you want to change the preserved CRS though.

Patch:
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 52eea7c1..ba73e695 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -42,6 +42,24 @@ def _geoseries_constructor_with_fallback(data=None, index=None, crs=None, **kwar
         return Series(data=data, index=index, **kwargs)
 
 
+def inherit_doc(cls):
+    """
+    A decorator adding a docstring from an existing method.
+    """
+
+    def decorator(decorated):
+        original_method = getattr(cls, decorated.__name__, None)
+        if original_method:
+            doc = original_method.__doc__ or ""
+        else:
+            doc = ""
+
+        decorated.__doc__ = doc
+        return decorated
+
+    return decorator
+
+
 class GeoSeries(GeoPandasBase, Series):
     """
     A Series object designed to store shapely geometry objects.
@@ -284,15 +302,26 @@ class GeoSeries(GeoPandasBase, Series):
     def __getitem__(self, key):
         return self._wrapped_pandas_method("__getitem__", key)
 
+    @inherit_doc(pd.Series)
     def sort_index(self, *args, **kwargs):
         return self._wrapped_pandas_method("sort_index", *args, **kwargs)
 
+    @inherit_doc(pd.Series)
     def take(self, *args, **kwargs):
         return self._wrapped_pandas_method("take", *args, **kwargs)
 
+    @inherit_doc(pd.Series)
     def select(self, *args, **kwargs):
         return self._wrapped_pandas_method("select", *args, **kwargs)
 
+    @inherit_doc(pd.Series)
+    def apply(self, func, args=(), **kwargs):
+        result = super().apply(func, args=args, **kwargs)
+        if isinstance(result, GeoSeries):
+            if self.crs is not None:
+                result.set_crs(self.crs, inplace=True)
+        return result
+
     def __finalize__(self, other, method=None, **kwargs):
         """ propagate metadata from other to self """
         # NOTE: backported from pandas master (upcoming v0.13)


Test patch:
diff --git a/geopandas/tests/test_crs.py b/geopandas/tests/test_crs.py
index ec212627..222b3ba2 100644
--- a/geopandas/tests/test_crs.py
+++ b/geopandas/tests/test_crs.py
@@ -553,6 +553,14 @@ class TestGeometryArrayCRS:
         df2 = df.astype({"col1": str})
         assert df2.crs == self.osgb
 
+    def test_apply(self):
+        s = GeoSeries(self.arr)
+        assert s.crs == 27700
+
+        # apply preserves the CRS if the result is a GeoSeries
+        result = s.apply(lambda x: x.centroid)
+        assert result.crs == 27700
+
 
 class TestSetCRS:
     @pytest.mark.parametrize(
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 7078331f..ed63b36b 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -489,6 +489,26 @@ def test_groupby_groups(df):
     assert_frame_equal(res, exp)
 
 
+def test_apply(s):
+    # function that returns geometry preserves GeoSeries class
+    def geom_func(geom):
+        assert isinstance(geom, Point)
+        return geom
+
+    result = s.apply(geom_func)
+    assert isinstance(result, GeoSeries)
+    assert_geoseries_equal(result, s)
+
+    # function that returns non-geometry results in Series
+    def numeric_func(geom):
+        assert isinstance(geom, Point)
+        return geom.x
+
+    result = s.apply(numeric_func)
+    assert not isinstance(result, GeoSeries)
+    assert_series_equal(result, pd.Series([0.0, 1.0, 2.0]))
+
+
 def test_apply_loc_len1(df):
     # subset of len 1 with loc -> bug in pandas with inconsistent Block ndim
     # resulting in bug in apply