Repository: geopandas/geopandas
Pull request #3075 (2023-11-15 14:31:54)

ENH: Expose transform
Exposes Shapely's `transform` function according to ENH: #2010  

JohnMoutafis: @martinfleis I have updated my branch according to your suggestions!

Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 1b91ccff..8757532b 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -15,7 +15,7 @@ labels: "bug, needs triage"
 
 ---
 
-**Note**: Please read [this guide](https://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports) detailing how to provide the necessary information for us to reproduce your bug.
+**Note**: Please read [this guide](https://matthewrocklin.com/minimal-bug-reports) detailing how to provide the necessary information for us to reproduce your bug.
 
 #### Code Sample, a copy-pastable example
 
diff --git a/.github/workflows/release_to_pypi.yml b/.github/workflows/release_to_pypi.yml
index f37abb2a..c4ab699e 100644
--- a/.github/workflows/release_to_pypi.yml
+++ b/.github/workflows/release_to_pypi.yml
@@ -15,7 +15,7 @@ jobs:
         uses: actions/checkout@v4
 
       - name: Set up Python
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: "3.x"
 
diff --git a/.github/workflows/versioneer.yml b/.github/workflows/versioneer.yml
index 1841d1fe..1605edd6 100644
--- a/.github/workflows/versioneer.yml
+++ b/.github/workflows/versioneer.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
-      - uses: actions/setup-python@v4
+      - uses: actions/setup-python@v5
       - name: Install and run versioneer
         run: |
           pip install versioneer
@@ -24,7 +24,7 @@ jobs:
         with:
           options: "--verbose"
       - name: Upload artifact
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           path: versioneer.py
       - name: Ignore changes in __init__
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f30f398..df93c46a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ New methods:
 - Added `count_coordinates` method from shapely to GeoSeries/GeoDataframe (#3026).
 - Added `minimum_clearance` method from shapely to GeoSeries/GeoDataframe (#2989).
 - Added `is_ccw` method from shapely to GeoSeries/GeoDataframe (#3027).
+- Added ``transform`` method from shapely to GeoSeries/GeoDataFrame (#3075).
 
 New features and improvements:
 
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index 8f843c91..07a451d0 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -108,6 +108,7 @@ Constructive methods and attributes
    GeoSeries.segmentize
    GeoSeries.shortest_line
    GeoSeries.simplify
+   GeoSeries.transform
 
 Affine transformations
 ----------------------
diff --git a/doc/source/docs/user_guide/io.rst b/doc/source/docs/user_guide/io.rst
index f81f4f22..3c597898 100644
--- a/doc/source/docs/user_guide/io.rst
+++ b/doc/source/docs/user_guide/io.rst
@@ -227,6 +227,12 @@ by using the :meth:`geopandas.GeoDataFrame.to_postgis` method.
     can be easily stored in the GeoDataFrame, but saving them to e.g. GeoPackage or Shapefile will raise a ValueError.
     Before saving to a file, they need to be converted to a format supported by a selected driver.
 
+.. note::
+
+    One GeoDataFrame can contain multiple geometry (GeoSeries) columns, but most standard GIS file formats, e.g. GeoPackage or ESRI Shapefile, 
+    support only a single geometry column. To store multiple geometry columns, non-active GeoSeries need to be converted to 
+    an alternative representation like well-known text (WKT) or well-known binary (WKB) before saving to file. Alternatively, they can be saved as an Apache (Geo)Parquet or Feather file, both of which support multiple geometry columns natively.
+
 **Writing to Shapefile**::
 
     countries_gdf.to_file("countries.shp")
@@ -240,6 +246,15 @@ by using the :meth:`geopandas.GeoDataFrame.to_postgis` method.
     countries_gdf.to_file("package.gpkg", layer='countries', driver="GPKG")
     cities_gdf.to_file("package.gpkg", layer='cities', driver="GPKG")
 
+**Writing with multiple geometry columns**::
+
+    countries_gdf["country_center"] = countries_gdf["geometry"].centroid
+    # Line below fails because GeoJSON can't contain multiple geometry columns
+    # countries_gdf.to_file("countries.geojson", driver='GeoJSON')
+    countries_gdf["country_center"] = countries_gdf["country_center"].to_wkt()
+    countries_gdf.to_file("countries.geojson", driver='GeoJSON')
+
+For multi-layer formats such as GeoPackage, it is possible to write additional geometry columns to separate layers instead of saving them as WKT or WKB within a single layer.
 
 Spatial databases
 -----------------
diff --git a/geopandas/array.py b/geopandas/array.py
index 5fd97462..b9052514 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -1,26 +1,25 @@
+import inspect
 import numbers
 import operator
 import warnings
-import inspect
 from functools import lru_cache
 
 import numpy as np
 import pandas as pd
-from pandas.api.extensions import (
-    ExtensionArray,
-    ExtensionDtype,
-    register_extension_dtype,
-)
-
 import shapely
 import shapely.affinity
 import shapely.geometry
-from shapely.geometry.base import BaseGeometry
 import shapely.ops
 import shapely.wkt
+from pandas.api.extensions import (
+    ExtensionArray,
+    ExtensionDtype,
+    register_extension_dtype,
+)
 from pyproj import CRS, Transformer
 from pyproj.aoi import AreaOfInterest
 from pyproj.database import query_utm_crs_info
+from shapely.geometry.base import BaseGeometry
 
 from .sindex import SpatialIndex
 
@@ -638,6 +637,11 @@ class GeometryArray(ExtensionArray):
             crs=self.crs,
         )
 
+    def transform(self, transformation, include_z=False):
+        return GeometryArray(
+            shapely.transform(self._data, transformation, include_z), crs=self.crs
+        )
+
     #
     # Binary predicates
     #
diff --git a/geopandas/base.py b/geopandas/base.py
index b80f9e6e..a2de795f 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -1,11 +1,11 @@
-from warnings import warn
 import warnings
+from warnings import warn
 
 import numpy as np
 import pandas as pd
-from pandas import DataFrame, Series
 import shapely
-from shapely.geometry import box, MultiPoint
+from pandas import DataFrame, Series
+from shapely.geometry import MultiPoint, box
 from shapely.geometry.base import BaseGeometry
 
 from . import _compat as compat
@@ -1298,6 +1298,46 @@ GeometryCollection
         """
         return _delegate_geo_method("segmentize", self, max_segment_length)
 
+    def transform(self, transformation, include_z=False):
+        """Returns a ``GeoSeries`` with the transformation function
+        applied to the geometry coordinates.
+
+        Parameters
+        ----------
+        transformation : Callable
+            A function that transforms a (N, 2) or (N, 3) ndarray of float64
+            to another (N,2) or (N, 3) ndarray of float64
+        include_z : bool, default False
+            If True include the third dimension in the coordinates array that
+            is passed to the ``transformation`` function. If a geometry has no third
+            dimension, the z-coordinates passed to the function will be NaN.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from shapely import Point, Polygon
+        >>> s = geopandas.GeoSeries([Point(0, 0)])
+        >>> s.transform(lambda x: x + 1)
+        0    POINT (1 1)
+        dtype: geometry
+
+        >>> s = geopandas.GeoSeries([Polygon([(0, 0), (1, 1), (0, 1)])])
+        >>> s.transform(lambda x: x * [2, 3])
+        0    POLYGON ((0 0, 2 3, 0 3, 0 0))
+        dtype: geometry
+
+        By default the third dimension is ignored and you need explicitly include it:
+
+        >>> s = geopandas.GeoSeries([Point(0, 0, 0)])
+        >>> s.transform(lambda x: x + 1, include_z=True)
+        0    POINT Z (1 1 1)
+        dtype: geometry
+        """
+        return _delegate_geo_method("transform", self, transformation, include_z)
+
     #
     # Reduction operations that return a Shapely geometry
     #


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index d6cd6380..3f89af96 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -18,7 +18,7 @@ jobs:
 
     steps:
       - uses: actions/checkout@v4
-      - uses: actions/setup-python@v4
+      - uses: actions/setup-python@v5
       - uses: pre-commit/action@v3.0.0
 
   Test:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 5520e53e..26d440c0 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -2,31 +2,28 @@ import string
 import warnings
 
 import numpy as np
+import pytest
+import shapely
 from numpy.testing import assert_array_equal
 from pandas import DataFrame, Index, MultiIndex, Series, concat
-
-import shapely
-
+from pandas.testing import assert_frame_equal, assert_index_equal, assert_series_equal
+from shapely import wkt
 from shapely.geometry import (
     LinearRing,
     LineString,
     MultiLineString,
     MultiPoint,
+    MultiPolygon,
     Point,
     Polygon,
-    MultiPolygon,
 )
 from shapely.geometry.collection import GeometryCollection
 from shapely.ops import unary_union
-from shapely import wkt
 
 from geopandas import GeoDataFrame, GeoSeries
 from geopandas.base import GeoPandasBase
-
 from geopandas.testing import assert_geodataframe_equal
 from geopandas.tests.util import assert_geoseries_equal, geom_almost_equals, geom_equals
-from pandas.testing import assert_frame_equal, assert_index_equal, assert_series_equal
-import pytest
 
 
 def assert_array_dtype_equal(a, b, *args, **kwargs):
@@ -919,6 +916,44 @@ class TestGeomMethods:
         assert_geoseries_equal(expected_g1, result_g1)
         assert_geoseries_equal(expected_g5, result_g5)
 
+    def test_transform(self):
+        # Test 2D
+        test_2d = GeoSeries(
+            [LineString([(2, 2), (4, 4)]), Polygon([(0, 0), (1, 1), (0, 1)])]
+        )
+        expected_2d = GeoSeries(
+            [LineString([(4, 6), (8, 12)]), Polygon([(0, 0), (2, 3), (0, 3)])]
+        )
+        result_2d = test_2d.transform(lambda x: x * [2, 3])
+        assert_geoseries_equal(expected_2d, result_2d)
+        # Test 3D
+        test_3d = GeoSeries(
+            [
+                Point(0, 0, 0),
+                LineString([(2, 2, 2), (4, 4, 4)]),
+                Polygon([(0, 0, 0), (1, 1, 1), (0, 1, 0.5)]),
+            ]
+        )
+        expected_3d = GeoSeries(
+            [
+                Point(1, 1, 1),
+                LineString([(3, 3, 3), (5, 5, 5)]),
+                Polygon([(1, 1, 1), (2, 2, 2), (1, 2, 1.5)]),
+            ]
+        )
+        result_3d = test_3d.transform(lambda x: x + 1, include_z=True)
+        assert_geoseries_equal(expected_3d, result_3d)
+        # Test 3D as 2D transformation
+        expected_3d_to_2d = GeoSeries(
+            [
+                Point(1, 1),
+                LineString([(3, 3), (5, 5)]),
+                Polygon([(1, 1), (2, 2), (1, 2)]),
+            ]
+        )
+        result_3d_to_2d = test_3d.transform(lambda x: x + 1, include_z=False)
+        assert_geoseries_equal(expected_3d_to_2d, result_3d_to_2d)
+
     def test_concave_hull(self):
         assert_geoseries_equal(self.squares, self.squares.concave_hull())