Repository: geopandas/geopandas
Pull request #3026 (2023-09-25 00:19:56)

ENH: Add count coordinates
Adding count coordinates from shapely


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 815ddac1..e4a86df0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,12 +2,25 @@
 
 ## Development version
 
+New methods:
+
+- Added ``count_coordinates`` method from shapely to GeoSeries/GeoDataframe (#3026).
+
 Notes on dependencies:
 
 - GeoPandas 1.0 drops support for shapely<2 and PyGEOS. The only geometry engine that is
   currently supported is shapely >= 2. As a consequence, spatial indexing based on the
   rtree package has also been removed. (#3035)
 
+New methods:
+
+- Added ``minimum_clearance`` method from shapely to GeoSeries/GeoDataframe (#2989).
+
+Bug fixes:
+- Fix `GeoDataFrame.merge()` incorrectly returning a `DataFrame` instead of a 
+  `GeoDataFrame` when the `suffixes` argument is applied to the active
+  geometry column (#2933).
+
 
 ## Version 0.14 (Sep 15, 2023)
 
@@ -41,7 +54,6 @@ New features and improvements:
   for files with the `.fgb` extension (#2958)
 
 Bug fixes:
-
 - Fix ambiguous error when GeoDataFrame is initialized with a column called ``"crs"`` (#2944)
 - Fix a color assignment in ``explore`` when using ``UserDefined`` bins (#2923)
 - Fix bug in `apply` with `axis=1` where the given user defined function returns nested
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index c33dd575..8b8075be 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -34,6 +34,7 @@ General methods and attributes
    GeoSeries.y
    GeoSeries.z
    GeoSeries.get_coordinates
+   GeoSeries.count_coordinates
 
 Unary predicates
 ----------------
@@ -96,6 +97,7 @@ Constructive methods and attributes
    GeoSeries.extract_unique_points
    GeoSeries.make_valid
    GeoSeries.minimum_bounding_circle
+   GeoSeries.minimum_clearance
    GeoSeries.minimum_rotated_rectangle
    GeoSeries.normalize
    GeoSeries.remove_repeated_points
diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst
index 30c3983a..286564a1 100644
--- a/doc/source/getting_started/install.rst
+++ b/doc/source/getting_started/install.rst
@@ -92,16 +92,14 @@ as well::
     When using pip to install GeoPandas, you need to make sure that all dependencies are
     installed correctly.
 
-    - `fiona`_ provides binary wheels with the dependencies included for Mac and Linux,
-      but not for Windows. Alternatively, you can install `pyogrio`_ which does
-      have wheels for Windows.
-    - `pyproj`_ and `shapely`_ provide binary wheels with dependencies included
-      for Mac, Linux, and Windows.
-
-    Depending on your platform, you might need to compile and install their
-    C dependencies manually. We refer to the individual packages for more
-    details on installing those.
-    Using conda (see above) avoids the need to compile the dependencies yourself.
+    Our main dependencies (`shapely`_, `pyproj`_, `fiona`_, `pyogrio`_) provide binary
+    wheels with dependencies included for Mac, Linux, and Windows.
+
+    However, depending on your platform or Python version, there might be no
+    pre-compiled wheels available, and then you need to compile and install their C
+    dependencies manually. We refer to the individual packages for more details on
+    installing those. Using conda (see above) avoids the need to compile the
+    dependencies yourself.
 
 Installing from source
 ----------------------
diff --git a/geopandas/array.py b/geopandas/array.py
index 234dc21c..e22ee9d7 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -518,6 +518,12 @@ 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]
+        return out
+
     #
     # Unary operations that return new geometries
     #
@@ -590,7 +596,9 @@ class GeometryArray(ExtensionArray):
                 "geometry types, None is returned.",
                 stacklevel=2,
             )
-        data = np.array(inner_rings, dtype=object)
+        # need to allocate empty first in case of all empty lists in inner_rings
+        data = np.empty(len(inner_rings), dtype=object)
+        data[:] = inner_rings
         return data
 
     def remove_repeated_points(self, tolerance=0.0):
@@ -608,6 +616,9 @@ class GeometryArray(ExtensionArray):
     def minimum_bounding_radius(self):
         return shapely.minimum_bounding_radius(self._data)
 
+    def minimum_clearance(self):
+        return shapely.minimum_clearance(self._data)
+
     def normalize(self):
         return GeometryArray(shapely.normalize(self._data), crs=self.crs)
 
diff --git a/geopandas/base.py b/geopandas/base.py
index 46c30f42..77f275f4 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -328,6 +328,49 @@ GeometryCollection
         """
         return _delegate_property("is_empty", self)
 
+    @property
+    def count_coordinates(self):
+        """
+        Returns a ``Series`` containing the count of the number of coordinate pairs
+        in a geometry array.
+
+        Examples
+        --------
+        An example of a GeoDataFrame with two line strings, one point and one None
+        value:
+
+        >>> from shapely.geometry import LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (1, 1), (1, -1), (0, 1)]),
+        ...         LineString([(0, 0), (1, 1), (1, -1)]),
+        ...         Point(0, 0),
+        ...         Polygon([(10, 10), (10, 20), (20, 20), (20, 10), (10, 10)]),
+        ...         None
+        ...     ]
+        ... )
+        >>> s
+        0    LINESTRING (0.00000 0.00000, 1.00000 1.00000, ...
+        1    LINESTRING (0.00000 0.00000, 1.00000 1.00000, ...
+        2                              POINT (0.00000 0.00000)
+        3    POLYGON ((10.00000 10.00000, 10.00000 20.00000...
+        4                                                 None
+        dtype: geometry
+
+        >>> s.count_coordinates
+        0    4
+        1    3
+        2    1
+        3    5
+        4    0
+        dtype: object
+
+        See also
+        --------
+        GeoSeries.get_coordinates : extract coordinates as a :class:`~pandas.DataFrame`
+        """
+        return _delegate_property("count_coordinates", self)
+
     @property
     def is_simple(self):
         """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for
@@ -1038,6 +1081,39 @@ GeometryCollection
         """
         return Series(self.geometry.values.minimum_bounding_radius(), index=self.index)
 
+    def minimum_clearance(self):
+        """Returns a ``Series`` containing the minimum clearance distance,
+        which is the smallest distance by which a vertex of the geometry
+        could be moved to produce an invalid geometry.
+
+        If no minimum clearance exists for a geometry (for example,
+        a single point, or an empty geometry), infinity is returned.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1), (0, 0)]),
+        ...         LineString([(0, 0), (1, 1), (3, 2)]),
+        ...         Point(0, 0),
+        ...     ]
+        ... )
+        >>> s
+        0    POLYGON ((0.00000 0.00000, 1.00000 1.00000, 0....
+        1    LINESTRING (0.00000 0.00000, 1.00000 1.00000, ...
+        2                              POINT (0.00000 0.00000)
+        dtype: geometry
+
+        >>> s.minimum_clearance()
+        0    0.707107
+        1    1.414214
+        2         inf
+        dtype: float64
+        """
+        return Series(self.geometry.values.minimum_clearance(), index=self.index)
+
     def normalize(self):
         """Returns a ``GeoSeries`` of normalized
         geometries to normal form (or canonical form).
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 1be99545..e7a6e4f0 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1550,34 +1550,6 @@ individually so that features may have different properties
             copied._geometry_column_name = self._geometry_column_name
         return copied
 
-    def merge(self, *args, **kwargs):
-        r"""Merge two ``GeoDataFrame`` objects with a database-style join.
-
-        Returns a ``GeoDataFrame`` if a geometry column is present; otherwise,
-        returns a pandas ``DataFrame``.
-
-        Returns
-        -------
-        GeoDataFrame or DataFrame
-
-        Notes
-        -----
-        The extra arguments ``*args`` and keyword arguments ``**kwargs`` are
-        passed to DataFrame.merge.
-        See https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas\
-.DataFrame.merge.html
-        for more details.
-        """
-        result = DataFrame.merge(self, *args, **kwargs)
-        geo_col = self._geometry_column_name
-        if isinstance(result, DataFrame) and geo_col in result:
-            result.__class__ = GeoDataFrame
-            result.crs = self.crs
-            result._geometry_column_name = geo_col
-        elif isinstance(result, DataFrame) and geo_col not in result:
-            result.__class__ = DataFrame
-        return result
-
     @doc(pd.DataFrame)
     def apply(self, func, axis=0, raw=False, result_type=None, args=(), **kwargs):
         result = super().apply(
@@ -1946,15 +1918,13 @@ individually so that features may have different properties
         return df
 
     # overrides the pandas astype method to ensure the correct return type
+    # should be removable when pandas 1.4 is dropped
     def astype(self, dtype, copy=True, errors="raise", **kwargs):
         """
         Cast a pandas object to a specified dtype ``dtype``.
-
         Returns a GeoDataFrame when the geometry column is kept as geometries,
         otherwise returns a pandas DataFrame.
-
         See the pandas.DataFrame.astype docstring for more details.
-
         Returns
         -------
         GeoDataFrame or DataFrame
@@ -1971,29 +1941,6 @@ individually so that features may have different properties
         # do not return a GeoDataFrame
         return pd.DataFrame(df)
 
-    def convert_dtypes(self, *args, **kwargs):
-        """
-        Convert columns to best possible dtypes using dtypes supporting ``pd.NA``.
-
-        Always returns a GeoDataFrame as no conversions are applied to the
-        geometry column.
-
-        See the pandas.DataFrame.convert_dtypes docstring for more details.
-
-        Returns
-        -------
-        GeoDataFrame
-
-        """
-        # Overridden to fix GH1870, that return type is not preserved always
-        # (and where it was, geometry col was not)
-
-        return GeoDataFrame(
-            super().convert_dtypes(*args, **kwargs),
-            geometry=self.geometry.name,
-            crs=self.crs,
-        )
-
     def to_postgis(
         self,
         name,
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index c6d5f9f8..7330a030 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -417,6 +417,10 @@ def _read_file_pyogrio(path_or_bytes, bbox=None, mask=None, rows=None, **kwargs)
             kwargs["max_features"] = rows
         elif isinstance(rows, slice):
             if rows.start is not None:
+                if rows.start < 0:
+                    raise ValueError(
+                        "Negative slice start not supported with the 'pyogrio' engine."
+                    )
                 kwargs["skip_features"] = rows.start
             if rows.stop is not None:
                 kwargs["max_features"] = rows.stop - (rows.start or 0)


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index cb7b31b0..40a43feb 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -44,6 +44,7 @@ jobs:
           - ci/envs/310-pd20-conda-forge.yaml
           - ci/envs/310-latest-conda-forge.yaml
           - ci/envs/311-latest-conda-forge.yaml
+          - ci/envs/312-latest-conda-forge.yaml
         include:
           - env: ci/envs/39-latest-conda-forge_no_fiona.yaml
             os: macos-latest
diff --git a/ci/envs/312-latest-conda-forge.yaml b/ci/envs/312-latest-conda-forge.yaml
new file mode 100644
index 00000000..546fc5c5
--- /dev/null
+++ b/ci/envs/312-latest-conda-forge.yaml
@@ -0,0 +1,35 @@
+name: test
+channels:
+  - conda-forge
+dependencies:
+  - python=3.12
+  # required
+  - pandas
+  - shapely
+  - fiona
+  - pyproj
+  - packaging
+  # testing
+  - pytest
+  - pytest-cov
+  - pytest-xdist
+  - fsspec
+  # optional
+  - pyogrio
+  # - matplotlib
+  - mapclassify
+  - folium
+  - xyzservices
+  - scipy
+  - geopy
+  # - pointpats
+  - geodatasets
+  # - SQLalchemy>=2
+  # - psycopg2
+  # - libspatialite
+  # - geoalchemy2
+  - pyarrow
+  - pip
+  - pip:
+    - matplotlib
+    - pointpats
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index 14cb71c0..7edcd2cc 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -23,8 +23,11 @@ from geopandas.tests.util import PACKAGE_DIR, validate_boro_df
 
 try:
     import pyogrio
+
+    PYOGRIO_GE_07 = Version(pyogrio.__version__) > Version("0.6.0")
 except ImportError:
     pyogrio = False
+    PYOGRIO_GE_07 = False
 
 
 try:
@@ -43,6 +46,9 @@ FIONA_MARK = pytest.mark.skipif(not fiona, reason="fiona not installed")
 _CRS = "epsg:4326"
 
 
+pytestmark = pytest.mark.filterwarnings("ignore:Value:RuntimeWarning:pyogrio")
+
+
 @pytest.fixture(
     params=[
         pytest.param("fiona", marks=FIONA_MARK),
@@ -362,13 +368,13 @@ def test_to_file_int32(tmpdir, df_points, engine, driver, ext):
     df = GeoDataFrame(geometry=geometry)
     df["data"] = pd.array([1, np.nan] * 5, dtype=pd.Int32Dtype())
     df.to_file(tempfilename, driver=driver, engine=engine)
-    df_read = GeoDataFrame.from_file(tempfilename, driver=driver, engine=engine)
+    df_read = GeoDataFrame.from_file(tempfilename, engine=engine)
     assert_geodataframe_equal(df_read, df, check_dtype=False, check_like=True)
     if engine == "pyogrio":
         tempfilename2 = os.path.join(str(tmpdir), f"int32_2.{ext}")
         df2 = df.dropna()
         df2.to_file(tempfilename2, driver=driver, engine=engine)
-        df2_read = GeoDataFrame.from_file(tempfilename2, driver=driver, engine=engine)
+        df2_read = GeoDataFrame.from_file(tempfilename2, engine=engine)
         assert df2_read["data"].dtype == "int32"
 
 
@@ -379,7 +385,7 @@ def test_to_file_int64(tmpdir, df_points, engine, driver, ext):
     df = GeoDataFrame(geometry=geometry)
     df["data"] = pd.array([1, np.nan] * 5, dtype=pd.Int64Dtype())
     df.to_file(tempfilename, driver=driver, engine=engine)
-    df_read = GeoDataFrame.from_file(tempfilename, driver=driver, engine=engine)
+    df_read = GeoDataFrame.from_file(tempfilename, engine=engine)
     assert_geodataframe_equal(df_read, df, check_dtype=False, check_like=True)
 
 
@@ -526,6 +532,7 @@ def test_mode_unsupported(tmpdir, df_nybb, engine):
         df_nybb.to_file(tempfilename, mode="r", engine=engine)
 
 
+@pytest.mark.filterwarnings("ignore:'crs' was not provided:UserWarning:pyogrio")
 @pytest.mark.parametrize("driver,ext", driver_ext_pairs)
 def test_empty_crs(tmpdir, driver, ext, engine):
     """Test handling of undefined CRS with GPKG driver (GH #1975)."""
@@ -733,7 +740,7 @@ def test_read_file_filtered__rows_bbox(df_nybb, engine):
         1047224.3104931959,
         244317.30894023244,
     )
-    if engine == "pyogrio":
+    if engine == "pyogrio" and not PYOGRIO_GE_07:
         with pytest.raises(ValueError, match="'skip_features' must be between 0 and 1"):
             # combination bbox and rows (rows slice applied after bbox filtering!)
             filtered_df = read_file(
@@ -748,7 +755,10 @@ def test_read_file_filtered__rows_bbox(df_nybb, engine):
 
     if engine == "pyogrio":
         # TODO: support negative rows in pyogrio
-        with pytest.raises(ValueError, match="'skip_features' must be between 0 and 1"):
+        with pytest.raises(
+            ValueError,
+            match="'skip_features' must be between 0 and 1|Negative slice start",
+        ):
             filtered_df = read_file(
                 nybb_filename, bbox=bbox, rows=slice(-1, None), engine=engine
             )
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index d6710387..44f3c355 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -500,6 +500,13 @@ class TestGeomMethods:
         with pytest.warns(UserWarning, match="Geometry is in a geographic CRS"):
             self.g4.length
 
+    def test_count_coordinates(self):
+        expected = Series(np.array([4, 5]), index=self.g1.index)
+        self._test_unary_real("count_coordinates", expected, self.g1)
+
+        expected = Series(np.array([4, 0]), index=self.na_none.index)
+        self._test_unary_real("count_coordinates", expected, self.na_none)
+
     def test_crosses(self):
         expected = [False, False, False, False, False, False, False]
         assert_array_dtype_equal(expected, self.g0.crosses(self.t1))
@@ -964,6 +971,10 @@ class TestGeomMethods:
         expected = LinearRing(self.inner_sq.boundary)
         assert original.interiors[1][0].equals(expected)
 
+        no_interiors = GeoSeries([self.t1, self.sq])
+        assert no_interiors.interiors[0] == []
+        assert no_interiors.interiors[1] == []
+
     def test_interpolate(self):
         expected = GeoSeries([Point(0.5, 1.0), Point(0.75, 1.0)])
         self._test_binary_topological(
@@ -1639,6 +1650,21 @@ class TestGeomMethods:
             Series([0.707106, 0.707106]),
         )
 
+    def test_minimum_clearance(self):
+        mc_geoms = self.g1.minimum_clearance()
+
+        assert_series_equal(
+            mc_geoms,
+            Series([0.707107, 1.000000]),
+        )
+
+        mc_lines = self.g5.minimum_clearance()
+
+        assert_series_equal(
+            mc_lines,
+            Series([1.0, 1.0]),
+        )
+
     @pytest.mark.parametrize("size", [10, 20, 50])
     def test_sample_points(self, size):
         for gs in (
diff --git a/geopandas/tests/test_op_output_types.py b/geopandas/tests/test_op_output_types.py
index 74a1587e..2d6b37c8 100644
--- a/geopandas/tests/test_op_output_types.py
+++ b/geopandas/tests/test_op_output_types.py
@@ -6,7 +6,7 @@ from shapely.geometry import Point
 import numpy as np
 
 from geopandas import GeoDataFrame, GeoSeries
-
+from geopandas.testing import assert_geodataframe_equal
 
 crs_osgb = pyproj.CRS(27700)
 crs_wgs = pyproj.CRS(4326)
@@ -370,3 +370,13 @@ def test_constructor_sliced_in_pandas_methods(df2):
     assert type(hashable_test_df.duplicated()) == pd.Series
     assert type(df2.quantile(numeric_only=True)) == pd.Series
     assert type(df2.memory_usage()) == pd.Series
+
+
+def test_merge_preserve_geodataframe():
+    # https://github.com/geopandas/geopandas/issues/2932
+    ser = GeoSeries.from_xy([1], [1])
+    df = GeoDataFrame({"geo": ser})
+    res = df.merge(df, left_index=True, right_index=True)
+    assert_obj_no_active_geo_col(res, GeoDataFrame, geo_colname=None)
+    expected = GeoDataFrame({"geo_x": ser, "geo_y": ser})
+    assert_geodataframe_equal(expected, res)