Repository: geopandas/geopandas
Pull request #3090 (2023-12-01 22:22:19)

ENH: add force_2d and force_3d
xref #2010 


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0a14713..3473befc 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 `force_2d` and `force_3d` methods from shapely to GeoSeries/GeoDataframe (#3090).
 - Added ``transform`` method from shapely to GeoSeries/GeoDataFrame (#3075).
 
 New features and improvements:
@@ -20,6 +21,8 @@ New features and improvements:
   output (#3087).
 - `make_valid` option in `overlay` now uses the `make_valid` method instead of
   `buffer(0)` (#3113).
+- Passing `"geometry"` as `dtype` to `pd.read_csv` will now return a GeoSeries for
+  the specified columns (#3101).
 
 Potentially breaking changes:
 - reading a data source that does not have a geometry field using ``read_file``
@@ -32,6 +35,7 @@ Bug fixes:
   geometry column (#2933).
 - Fix bug in `pandas.concat` CRS consistency checking where CRS differing by WKT
   whitespace only were treated as incompatible (#3023)
+- Fix `explore()` method when the active geometry contains missing and empty geometries (#3094)
 
 ## Version 0.14.1 (Nov 11, 2023)
 
diff --git a/doc/source/_static/code/buffer.py b/doc/source/_static/code/buffer.py
index cba059bf..24d6f539 100644
--- a/doc/source/_static/code/buffer.py
+++ b/doc/source/_static/code/buffer.py
@@ -28,14 +28,14 @@ axs[0, 0].set_title("s.buffer(0.2)")
 s.buffer(0.2, resolution=2).plot(ax=axs[0, 1], alpha=0.6)
 axs[0, 1].set_title("s.buffer(0.2, resolution=2)")
 
-s.buffer(0.2, cap_style=2).plot(ax=axs[1, 0], alpha=0.6)
-axs[1, 0].set_title("s.buffer(0.2, cap_style=2)")
+s.buffer(0.2, cap_style="square").plot(ax=axs[1, 0], alpha=0.6)
+axs[1, 0].set_title('s.buffer(0.2, cap_style="square")')
 
-s.buffer(0.2, cap_style=3).plot(ax=axs[1, 1], alpha=0.6)
-axs[1, 1].set_title("s.buffer(0.2, cap_style=3)")
+s.buffer(0.2, cap_style="flat").plot(ax=axs[1, 1], alpha=0.6)
+axs[1, 1].set_title('s.buffer(0.2, cap_style="flat")')
 
-s.buffer(0.2, join_style=2).plot(ax=axs[2, 0], alpha=0.6)
-axs[2, 0].set_title("s.buffer(0.2, join_style=2)")
+s.buffer(0.2, join_style="mitre").plot(ax=axs[2, 0], alpha=0.6)
+axs[2, 0].set_title('s.buffer(0.2, join_style="mitre")')
 
-s.buffer(0.2, join_style=3).plot(ax=axs[2, 1], alpha=0.6)
-axs[2, 1].set_title("s.buffer(0.2, join_style=3)")
+s.buffer(0.2, join_style="bevel").plot(ax=axs[2, 1], alpha=0.6)
+axs[2, 1].set_title('s.buffer(0.2, join_style="bevel")')
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index 07a451d0..1c42ac1b 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -97,6 +97,8 @@ Constructive methods and attributes
    GeoSeries.delaunay_triangles
    GeoSeries.envelope
    GeoSeries.extract_unique_points
+   GeoSeries.force_2d
+   GeoSeries.force_3d
    GeoSeries.make_valid
    GeoSeries.minimum_bounding_circle
    GeoSeries.minimum_clearance
diff --git a/geopandas/array.py b/geopandas/array.py
index b9052514..e49245e3 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -521,7 +521,6 @@ class GeometryArray(ExtensionArray):
         self.check_geographic_crs(stacklevel=5)
         return shapely.length(self._data)
 
-    @property
     def count_coordinates(self):
         out = np.empty(len(self._data), dtype=np.int_)
         out[:] = [shapely.count_coordinates(s) for s in self._data]
@@ -637,6 +636,12 @@ class GeometryArray(ExtensionArray):
             crs=self.crs,
         )
 
+    def force_2d(self):
+        return GeometryArray(shapely.force_2d(self._data), crs=self.crs)
+
+    def force_3d(self, z=0):
+        return GeometryArray(shapely.force_3d(self._data, z=z), crs=self.crs)
+
     def transform(self, transformation, include_z=False):
         return GeometryArray(
             shapely.transform(self._data, transformation, include_z), crs=self.crs
@@ -1354,6 +1359,29 @@ class GeometryArray(ExtensionArray):
             scalars = [scalars]
         return from_shapely(scalars)
 
+    @classmethod
+    def _from_sequence_of_strings(cls, strings, *, dtype=None, copy=False):
+        """
+        Construct a new ExtensionArray from a sequence of strings.
+
+        Parameters
+        ----------
+        strings : Sequence
+            Each element will be an instance of the scalar type for this
+            array, ``cls.dtype.type``.
+        dtype : dtype, optional
+            Construct for this particular dtype. This should be a Dtype
+            compatible with the ExtensionArray.
+        copy : bool, default False
+            If True, copy the underlying data.
+
+        Returns
+        -------
+        ExtensionArray
+        """
+        # GH 3099
+        return from_wkt(strings)
+
     def _values_for_factorize(self):
         # type: () -> Tuple[np.ndarray, Any]
         """Return an array and missing value suitable for factorization.
diff --git a/geopandas/base.py b/geopandas/base.py
index a2de795f..828f2369 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -329,7 +329,6 @@ GeometryCollection
         """
         return _delegate_property("is_empty", self)
 
-    @property
     def count_coordinates(self):
         """
         Returns a ``Series`` containing the count of the number of coordinate pairs
@@ -358,7 +357,7 @@ GeometryCollection
         4                                             None
         dtype: geometry
 
-        >>> s.count_coordinates
+        >>> s.count_coordinates()
         0    4
         1    3
         2    1
@@ -370,7 +369,7 @@ GeometryCollection
         --------
         GeoSeries.get_coordinates : extract coordinates as a :class:`~pandas.DataFrame`
         """
-        return _delegate_property("count_coordinates", self)
+        return Series(self.geometry.values.count_coordinates(), index=self.index)
 
     @property
     def is_simple(self):
@@ -1338,6 +1337,102 @@ GeometryCollection
         """
         return _delegate_geo_method("transform", self, transformation, include_z)
 
+    def force_2d(self):
+        """Forces the dimensionality of a geometry to 2D.
+
+        Removes the additional Z coordinate dimension from all geometries.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from shapely import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Point(0.5, 2.5, 0),
+        ...         LineString([(1, 1, 1), (0, 1, 3), (1, 0, 2)]),
+        ...         Polygon([(0, 0, 0), (0, 10, 0), (10, 10, 0)]),
+        ...     ],
+        ... )
+        >>> s
+        0                            POINT Z (0.5 2.5 0)
+        1             LINESTRING Z (1 1 1, 0 1 3, 1 0 2)
+        2    POLYGON Z ((0 0 0, 0 10 0, 10 10 0, 0 0 0))
+        dtype: geometry
+
+        >>> s.force_2d()
+        0                      POINT (0.5 2.5)
+        1           LINESTRING (1 1, 0 1, 1 0)
+        2    POLYGON ((0 0, 0 10, 10 10, 0 0))
+        dtype: geometry
+        """
+        return _delegate_geo_method("force_2d", self)
+
+    def force_3d(self, z=0):
+        """Forces the dimensionality of a geometry to 3D.
+
+        2D geometries will get the provided Z coordinate; 3D geometries
+        are unchanged (unless their Z coordinate is ``np.nan``).
+
+        Note that for empty geometries, 3D is only supported since GEOS 3.9 and then
+        still only for simple geometries (non-collections).
+
+        Parameters
+        ----------
+        z : float | array_like (default 0)
+            Z coordinate to be assigned
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from shapely import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Point(1, 2),
+        ...         Point(0.5, 2.5, 2),
+        ...         LineString([(1, 1), (0, 1), (1, 0)]),
+        ...         Polygon([(0, 0), (0, 10), (10, 10)]),
+        ...     ],
+        ... )
+        >>> s
+        0                          POINT (1 2)
+        1                  POINT Z (0.5 2.5 2)
+        2           LINESTRING (1 1, 0 1, 1 0)
+        3    POLYGON ((0 0, 0 10, 10 10, 0 0))
+        dtype: geometry
+
+        >>> s.force_3d()
+        0                                POINT Z (1 2 0)
+        1                            POINT Z (0.5 2.5 2)
+        2             LINESTRING Z (1 1 0, 0 1 0, 1 0 0)
+        3    POLYGON Z ((0 0 0, 0 10 0, 10 10 0, 0 0 0))
+        dtype: geometry
+
+        Z coordinate can be specified as scalar:
+
+        >>> s.force_3d(4)
+        0                                POINT Z (1 2 4)
+        1                            POINT Z (0.5 2.5 2)
+        2             LINESTRING Z (1 1 4, 0 1 4, 1 0 4)
+        3    POLYGON Z ((0 0 4, 0 10 4, 10 10 4, 0 0 4))
+        dtype: geometry
+
+        Or as an array-like (one value per geometry):
+
+        >>> s.force_3d(range(4))
+        0                                POINT Z (1 2 0)
+        1                            POINT Z (0.5 2.5 2)
+        2             LINESTRING Z (1 1 2, 0 1 2, 1 0 2)
+        3    POLYGON Z ((0 0 3, 0 10 3, 10 10 3, 0 0 3))
+        dtype: geometry
+        """
+        return _delegate_geo_method("force_3d", self, z=z)
+
     #
     # Reduction operations that return a Shapely geometry
     #
@@ -3734,20 +3829,51 @@ GeometryCollection
         """
         return self.geometry.values.has_sindex
 
-    def buffer(self, distance, resolution=16, **kwargs):
+    def buffer(
+        self,
+        distance,
+        resolution=16,
+        cap_style="round",
+        join_style="round",
+        mitre_limit=5.0,
+        single_sided=False,
+        **kwargs,
+    ):
         """Returns a ``GeoSeries`` of geometries representing all points within
         a given ``distance`` of each geometric object.
 
-        See http://shapely.readthedocs.io/en/latest/manual.html#object.buffer
-        for details.
+        Computes the buffer of a geometry for positive and negative buffer distance.
+
+        The buffer of a geometry is defined as the Minkowski sum (or difference, for
+        negative distance) of the geometry with a circle with radius equal to the
+        absolute value of the buffer distance.
+
+        The buffer operation always returns a polygonal result. The negative or
+        zero-distance buffer of lines and points is always empty.
 
         Parameters
         ----------
         distance : float, np.array, pd.Series
-            The radius of the buffer. If np.array or pd.Series are used
-            then it must have same length as the GeoSeries.
+            The radius of the buffer in the Minkowski sum (or difference). If np.array
+            or pd.Series are used then it must have same length as the GeoSeries.
         resolution : int (optional, default 16)
-            The resolution of the buffer around each vertex.
+            The resolution of the buffer around each vertex. Specifies the number of
+            linear segments in a quarter circle in the approximation of circular arcs.
+        cap_style : {'round', 'square', 'flat'}, default 'round'
+            Specifies the shape of buffered line endings. ``'round'`` results in
+            circular line endings (see ``resolution``). Both ``'square'`` and ``'flat'``
+            result in rectangular line endings, ``'flat'`` will end at the original
+            vertex, while ``'square'`` involves adding the buffer width.
+        join_style : {'round', 'mitre', 'bevel'}, default 'round'
+            Specifies the shape of buffered line midpoints. ``'round'`` results in
+            rounded shapes. ``'bevel'`` results in a beveled edge that touches the
+            original vertex. ``'mitre'`` results in a single vertex that is beveled
+            depending on the ``mitre_limit`` parameter.
+        mitre_limit : float, default 5.0
+            Crops of ``'mitre'``-style joins if the point is displaced from the
+            buffered vertex by more than this limit.
+        single_sided : bool, default False
+            Only buffer at one side of the geometry.
 
         Examples
         --------
@@ -3771,13 +3897,12 @@ GeometryCollection
         2    POLYGON ((2.8 -1, 2.8 1, 2.80096 1.0196, 2.803...
         dtype: geometry
 
-        ``**kwargs`` accept further specification as ``join_style`` and ``cap_style``.
-        See the following illustration of different options.
+        ``Further specification as ``join_style`` and ``cap_style`` are shown in the
+        following illustration:
 
         .. plot:: _static/code/buffer.py
 
         """
-        # TODO: update docstring based on pygeos after shapely 2.0
         if isinstance(distance, pd.Series):
             if not self.index.equals(distance.index):
                 raise ValueError(
@@ -3787,7 +3912,15 @@ GeometryCollection
             distance = np.asarray(distance)
 
         return _delegate_geo_method(
-            "buffer", self, distance, resolution=resolution, **kwargs
+            "buffer",
+            self,
+            distance,
+            resolution=resolution,
+            cap_style=cap_style,
+            join_style=join_style,
+            mitre_limit=mitre_limit,
+            single_sided=single_sided,
+            **kwargs,
         )
 
     def simplify(self, *args, **kwargs):
diff --git a/geopandas/explore.py b/geopandas/explore.py
index 49509712..c01d313a 100644
--- a/geopandas/explore.py
+++ b/geopandas/explore.py
@@ -618,7 +618,9 @@ def _explore(
         tooltip = None
         popup = None
     # escape the curly braces {{}} for jinja2 templates
-    feature_collection = gdf.__geo_interface__
+    feature_collection = gdf[
+        ~(gdf.geometry.isna() | gdf.geometry.is_empty)  # drop missing or empty geoms
+    ].__geo_interface__
     for feature in feature_collection["features"]:
         for k in feature["properties"]:
             # escape the curly braces in values


Test patch:
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index 22cd2563..4a83e4d2 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -827,6 +827,15 @@ def test_read_file_missing_geometry(tmpdir, engine):
     assert_frame_equal(df, expected)
 
 
+def test_read_csv_dtype(tmpdir, df_nybb):
+    filename = str(tmpdir / "test.csv")
+
+    df_nybb.to_csv(filename, index=False)
+    pdf = pd.read_csv(filename, dtype={"geometry": "geometry"})
+
+    assert pdf.geometry.dtype == "geometry"
+
+
 def test_read_file__where_filter(engine):
     if FIONA_GE_19 or engine == "pyogrio":
         gdf = geopandas.read_file(
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index dba61184..9e134a7b 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -2,6 +2,7 @@ import geopandas as gpd
 import numpy as np
 import pandas as pd
 import pytest
+import shapely
 from packaging.version import Version
 
 folium = pytest.importorskip("folium")
@@ -940,3 +941,17 @@ class TestExplore:
                     "zoom_control": False,
                 }
             )
+
+    def test_none_geometry(self):
+        # None and empty geoms are dropped prior plotting
+        df = self.nybb.copy()
+        df.loc[0, df.geometry.name] = None
+        m = df.explore()
+        self._fetch_map_string(m)
+
+    def test_empty_geometry(self):
+        # None and empty geoms are dropped prior plotting
+        df = self.nybb.copy()
+        df.loc[0, df.geometry.name] = shapely.Point()
+        m = df.explore()
+        self._fetch_map_string(m)
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 26d440c0..3bc1f9fb 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -499,10 +499,10 @@ class TestGeomMethods:
 
     def test_count_coordinates(self):
         expected = Series(np.array([4, 5]), index=self.g1.index)
-        self._test_unary_real("count_coordinates", expected, self.g1)
+        assert_series_equal(self.g1.count_coordinates(), expected)
 
         expected = Series(np.array([4, 0]), index=self.na_none.index)
-        self._test_unary_real("count_coordinates", expected, self.na_none)
+        assert_series_equal(self.na_none.count_coordinates(), expected)
 
     def test_crosses(self):
         expected = [False, False, False, False, False, False, False]
@@ -1766,3 +1766,46 @@ class TestGeomMethods:
     )
     def test_remove_repeated_points(self, geom, expected):
         assert_geoseries_equal(expected, geom.remove_repeated_points(tolerance=0.0))
+
+    def test_force_2d(self):
+        expected = GeoSeries(
+            [
+                Point(-73.9847, 40.7484),
+                Point(-74.0446, 40.6893),
+                self.pt2d,
+                self.pt_empty,
+            ],
+            crs=4326,
+        )
+        assert_geoseries_equal(expected, self.landmarks_mixed_empty.force_2d())
+
+    def test_force_3d(self):
+        expected = GeoSeries(
+            [
+                self.esb,
+                self.sol,
+                Point(-73.9847, 40.7484, 0),
+                self.pt_empty,
+            ],
+            crs=4326,
+        )
+        assert_geoseries_equal(expected, self.landmarks_mixed_empty.force_3d())
+
+        expected = GeoSeries(
+            [
+                self.esb,
+                self.sol,
+                Point(-73.9847, 40.7484, 2),
+                self.pt_empty,
+            ],
+            crs=4326,
+        )
+        assert_geoseries_equal(expected, self.landmarks_mixed_empty.force_3d(2))
+
+        expected = GeoSeries(
+            [
+                Polygon([(0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 0, 1)]),
+                Polygon([(0, 0, 2), (1, 0, 2), (1, 1, 2), (0, 1, 2), (0, 0, 2)]),
+            ],
+        )
+        assert_geoseries_equal(expected, self.g1.force_3d([1, 2]))