Repository: geopandas/geopandas

Issue:
CLN: Readability improvements for clip.py
#### Is your feature request related to a problem?

While clip.py is fully functional and in good shape, the _mask_is_list_like_rectangle function is missing a docstring. There are also two separate variable names, "clipping_by_rectangle" and "mask_is_list_like", that share the exact same code and hence represent the same thing. Adding a docstring to this function and making the variable names consistent throughout the file would enhance clip.py's readability

#### Describe the solution you'd like

 To meet the PEP 8 style guide, I would like to add a docstring with a description, parameters, and 
returns for the _mask_is_list_like_rectangle function. I would also like to replace the "mask_is_list_like" variable name with "clipping_by_rectangle" without changing the functionality or assignment itself. The "clipping_by_rectangle" variable name is used for the same code on line 56 and thus could be reused on line 166. I believe this makes the file slightly more readable. Naming this variable “clipping_by_rectangle” effectively communicates that the variable holds True for rectangle and False for non-rectangle masks.

#### API breaking implications

No impact. Only changes are adding docstring and changing a variable name within the clip function.

#### Describe alternatives you've considered

Using the "mask_is_list_like" variable name throughout the file would also fix the inconsistency. I personal prefer "clipping_by_rectangle".

#### Additional context

I believe line 176 in clip.py is especially improved by the proposed change in variable name.

![same_variable_different_names](https://github.com/user-attachments/assets/3cee781d-ce6b-42f0-ba89-027ee9deaf97)


Discussion:

Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index cea47a1c..00000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-github: numfocus
-custom: https://numfocus.org/donate-for-geopandas
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 582e6554..100c14f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,33 +2,38 @@
 
 ## Version 1.1.0
 
-Notes on dependencies:
-- GeoPandas 1.1 now requires Python 3.10 or greater and pandas 2.0, numpy 1.24, pyproj 3.5,
-  are now the minimum required version for these dependencies.
-  Furthermore, the minimum tested version for optional dependencies has been updated to
-  fiona 1.8.21, scipy 1.9, matplotlib 3.7, mapclassify 2.5, folium 0.12 and
-  SQLAlchemy 2.0. Older versions of these libraries may continue to work, but are no longer
-  considered supported (#3371).
-
-Bug fixes:
-
-- Fix an issue that showed numpy dtypes in bbox in `to_geo_dict` and `__geo_interface__`. (#3436)
+New features and improvements:
 
-## Version 1.0.2 (???)
+- Add ``grid_size`` parameter to ``union_all`` and ``dissolve`` (#3445).
+- `GeoDataFrame.plot` now supports `pd.Index` as an input for the `column` keyword (#3463).
+- Avoid change of the plot aspect when plotting missing values (#3438).
 
 Bug fixes:
 
+- Fix an issue that showed numpy dtypes in bbox in `to_geo_dict` and `__geo_interface__`. (#3436)
+- Fix an issue in `sample_points` that could occasionally result in non-uniform distribution (#3470).
 - Fix unspecified layer warning being emitted while reading multilayer datasets, even
   when layer is specified when using the mask or bbox keywords (#3378).
 - Properly support named aggregations over a geometry column in `GroupBy.agg` (#3368).
 - Support GeoDataFrame constructor receiving arguments to `geometry` which are not
   (Geo)Series, but instead should be interpreted as column names, like Enums (#3384).
-- Fix regression where constructing a GeoSeries from a pd.Series with GeometryDtype values 
+- Fix regression where constructing a GeoSeries from a pd.Series with GeometryDtype values
   failed when `crs` was provided (#3383).
 - Fix regression where `overlay` with `keep_geom_type` returns wrong results if the
   input contains invalid geometries (#3395).
-- Fix the dtype of the GeometryArray backing data being incorrect for zero length 
-  GeoDataFrames causing errors in `overlay` (3424).
+- Fix the dtype of the GeometryArray backing data being incorrect for zero length
+  GeoDataFrames causing errors in `overlay` (#3424).
+- Fix regression where constructing a GeoSeries from a pd.Series with GeometryDtype values
+  failed when `crs` was provided (#3383).
+
+Notes on dependencies:
+
+- GeoPandas 1.1 now requires Python 3.10 or greater and pandas 2.0, numpy 1.24, pyproj 3.5,
+  are now the minimum required version for these dependencies.
+  Furthermore, the minimum tested version for optional dependencies has been updated to
+  fiona 1.8.21, scipy 1.9, matplotlib 3.7, mapclassify 2.5, folium 0.12 and
+  SQLAlchemy 2.0. Older versions of these libraries may continue to work, but are no longer
+  considered supported (#3371).
 
 Deprecations and compatibility notes:
 
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index 3d269a11..53ed07e5 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -9,7 +9,6 @@ import shapely
 # pandas compat
 # -----------------------------------------------------------------------------
 
-PANDAS_GE_20 = Version(pd.__version__) >= Version("2.0.0")
 PANDAS_GE_202 = Version(pd.__version__) >= Version("2.0.2")
 PANDAS_GE_21 = Version(pd.__version__) >= Version("2.1.0")
 PANDAS_GE_22 = Version(pd.__version__) >= Version("2.2.0")
diff --git a/geopandas/array.py b/geopandas/array.py
index ffe319e0..ed917ce1 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -857,11 +857,13 @@ class GeometryArray(ExtensionArray):
         )
         return self.union_all()
 
-    def union_all(self, method="unary"):
+    def union_all(self, method="unary", grid_size=None):
         if method == "coverage":
+            if grid_size is not None:
+                raise ValueError("grid_size is not supported for method 'coverage'.")
             return shapely.coverage_union_all(self._data)
         elif method == "unary":
-            return shapely.union_all(self._data)
+            return shapely.union_all(self._data, grid_size=grid_size)
         else:
             raise ValueError(
                 f"Method '{method}' not recognized. Use 'coverage' or 'unary'."
diff --git a/geopandas/base.py b/geopandas/base.py
index 6441cd6b..a2dfdb00 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -2116,7 +2116,7 @@ GeometryCollection
 
         return self.geometry.values.union_all()
 
-    def union_all(self, method="unary"):
+    def union_all(self, method="unary", grid_size=None):
         """Returns a geometry containing the union of all geometries in the
         ``GeoSeries``.
 
@@ -2136,6 +2136,20 @@ GeometryCollection
               unary union algorithm. However, it can produce invalid geometries if the
               polygons overlap.
 
+        grid_size : float, default None
+            When grid size is specified, a fixed-precision space is used to perform the
+            union operations. This can be useful when unioning geometries that are not
+            perfectly snapped or to avoid geometries not being unioned because of
+            `robustness issues <https://libgeos.org/usage/faq/#why-doesnt-a-computed-point-lie-exactly-on-a-line>`_.
+            The inputs are first snapped to a grid of the given size. When a line
+            segment of a geometry is within tolerance off a vertex of another geometry,
+            this vertex will be inserted in the line segment. Finally, the result
+            vertices are computed on the same grid. Is only supported for ``method``
+            ``"unary"``. If None, the highest precision of the inputs will be used.
+            Defaults to None.
+
+            .. versionadded:: 1.1.0
+
         Examples
         --------
 
@@ -2149,7 +2163,7 @@ GeometryCollection
         >>> s.union_all()
         <POLYGON ((0 1, 0 2, 2 2, 2 0, 1 0, 0 0, 0 1))>
         """
-        return self.geometry.values.union_all(method=method)
+        return self.geometry.values.union_all(method=method, grid_size=grid_size)
 
     def intersection_all(self):
         """Returns a geometry containing the intersection of all geometries in
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index e3c5d5d9..3db3c0e2 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1962,6 +1962,7 @@ default 'snappy'
         observed=False,
         dropna=True,
         method="unary",
+        grid_size=None,
         **kwargs,
     ):
         """
@@ -2016,6 +2017,19 @@ default 'snappy'
               unary union algorithm. However, it can produce invalid geometries if the
               polygons overlap.
 
+        grid_size : float, default None
+            When grid size is specified, a fixed-precision space is used to perform the
+            union operations. This can be useful when unioning geometries that are not
+            perfectly snapped or to avoid geometries not being unioned because of
+            `robustness issues <https://libgeos.org/usage/faq/#why-doesnt-a-computed-point-lie-exactly-on-a-line>`_.
+            The inputs are first snapped to a grid of the given size. When a line
+            segment of a geometry is within tolerance off a vertex of another geometry,
+            this vertex will be inserted in the line segment. Finally, the result
+            vertices are computed on the same grid. Is only supported for ``method``
+            ``"unary"``. If None, the highest precision of the inputs will be used.
+            Defaults to None.
+
+            .. versionadded:: 1.1.0
         **kwargs :
             Keyword arguments to be passed to the pandas `DataFrameGroupby.agg` method
             which is used by `dissolve`. In particular, `numeric_only` may be
@@ -2066,31 +2080,13 @@ default 'snappy'
 
         # Process non-spatial component
         data = self.drop(labels=self.geometry.name, axis=1)
-        with warnings.catch_warnings(record=True) as record:
-            aggregated_data = data.groupby(**groupby_kwargs).agg(aggfunc, **kwargs)
-        for w in record:
-            if str(w.message).startswith("The default value of numeric_only"):
-                msg = (
-                    f"The default value of numeric_only in aggfunc='{aggfunc}' "
-                    "within pandas.DataFrameGroupBy.agg used in dissolve is "
-                    "deprecated. In pandas 2.0, numeric_only will default to False. "
-                    "Either specify numeric_only as additional argument in dissolve() "
-                    "or select only columns which should be valid for the function."
-                )
-                warnings.warn(msg, FutureWarning, stacklevel=2)
-            else:
-                # Only want to capture specific warning,
-                # other warnings from pandas should be passed through
-                # TODO this is not an ideal approach
-                warnings.showwarning(
-                    w.message, w.category, w.filename, w.lineno, w.file, w.line
-                )
+        aggregated_data = data.groupby(**groupby_kwargs).agg(aggfunc, **kwargs)
 
         aggregated_data.columns = aggregated_data.columns.to_flat_index()
 
         # Process spatial component
         def merge_geometries(block):
-            merged_geom = block.union_all(method=method)
+            merged_geom = block.union_all(method=method, grid_size=grid_size)
             return merged_geom
 
         g = self.groupby(group_keys=False, **groupby_kwargs)[self.geometry.name].agg(
@@ -2214,35 +2210,6 @@ default 'snappy'
 
         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=None, 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
-        """
-        if not PANDAS_GE_30 and copy is None:
-            copy = True
-        if copy is not None:
-            kwargs["copy"] = copy
-
-        df = super().astype(dtype, errors=errors, **kwargs)
-
-        try:
-            geoms = df[self._geometry_column_name]
-            if is_geometry_type(geoms):
-                return geopandas.GeoDataFrame(df, geometry=self._geometry_column_name)
-        except KeyError:
-            pass
-        # if the geometry column is converted to non-geometries or did not exist
-        # do not return a GeoDataFrame
-        return pd.DataFrame(df)
-
     def to_postgis(
         self,
         name,
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index e68b2959..2d056c99 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -233,9 +233,6 @@ class GeoSeries(GeoPandasBase, Series):
         if not self.crs:
             self.crs = crs
 
-    def append(self, *args, **kwargs) -> GeoSeries:
-        return self._wrapped_pandas_method("append", *args, **kwargs)
-
     @GeoPandasBase.crs.setter
     def crs(self, value):
         if self.crs is not None:
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 4884d0dd..274b19c8 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -20,7 +20,7 @@ from shapely.geometry import mapping
 from shapely.geometry.base import BaseGeometry
 
 from geopandas import GeoDataFrame, GeoSeries
-from geopandas._compat import HAS_PYPROJ, PANDAS_GE_20
+from geopandas._compat import HAS_PYPROJ
 from geopandas.io.util import vsi_path
 
 _VALID_URLS = set(uses_relative + uses_netloc + uses_params)
@@ -448,10 +448,7 @@ def _read_file_fiona(
                 # fiona only supports up to ms precision (any microseconds are
                 # floating point rounding error)
                 if as_dt is not None and not (as_dt.dtype == "object"):
-                    if PANDAS_GE_20:
-                        df[k] = as_dt.dt.as_unit("ms")
-                    else:
-                        df[k] = as_dt.dt.round(freq="ms")
+                    df[k] = as_dt.dt.as_unit("ms")
             return df
 
 
diff --git a/geopandas/plotting.py b/geopandas/plotting.py
index ce8d7d7b..ae5e788f 100644
--- a/geopandas/plotting.py
+++ b/geopandas/plotting.py
@@ -514,11 +514,11 @@ def plot_dataframe(
 
     Parameters
     ----------
-    column : str, np.array, pd.Series (default None)
-        The name of the dataframe column, np.array, or pd.Series to be plotted.
-        If np.array or pd.Series are used then it must have same length as
-        dataframe. Values are used to color the plot. Ignored if `color` is
-        also set.
+    column : str, np.array, pd.Series, pd.Index (default None)
+        The name of the dataframe column, np.array, pd.Series, or pd.Index
+        to be plotted. If np.array, pd.Series, or pd.Index are used then it
+        must have same length as dataframe. Values are used to color the plot.
+        Ignored if `color` is also set.
     kind: str
         The kind of plots to produce. The default is to create a map ("geo").
         Other supported kinds of plots from pandas:
@@ -699,11 +699,13 @@ def plot_dataframe(
         )
 
     # To accept pd.Series and np.arrays as column
-    if isinstance(column, np.ndarray | pd.Series):
+    if isinstance(column, np.ndarray | pd.Series | pd.Index):
         if column.shape[0] != df.shape[0]:
             raise ValueError(
                 "The dataframe and given column have different number of rows."
             )
+        elif isinstance(column, pd.Index):
+            values = column.values
         else:
             values = column
 
@@ -880,7 +882,7 @@ def plot_dataframe(
         merged_kwds = style_kwds.copy()
         merged_kwds.update(missing_kwds)
 
-        plot_series(expl_series[nan_idx], ax=ax, **merged_kwds)
+        plot_series(expl_series[nan_idx], ax=ax, **merged_kwds, aspect=None)
 
     if legend and not color:
         if legend_kwds is None:
diff --git a/geopandas/tools/_random.py b/geopandas/tools/_random.py
index 210c44e3..1d930657 100644
--- a/geopandas/tools/_random.py
+++ b/geopandas/tools/_random.py
@@ -86,4 +86,5 @@ def _uniform_polygon(geom, size, generator):
         )
         valid_samples = batch[batch.sindex.query(geom, predicate="contains")]
         candidates.extend(valid_samples)
+    generator.shuffle(candidates)  # avoid the artifacts of STRTree ordering
     return GeoSeries(candidates[:size]).union_all()
diff --git a/geopandas/tools/clip.py b/geopandas/tools/clip.py
index d1386f30..7567ad96 100644
--- a/geopandas/tools/clip.py
+++ b/geopandas/tools/clip.py
@@ -24,13 +24,29 @@ from geopandas.array import (
 
 
 def _mask_is_list_like_rectangle(mask):
+    """
+    Check if the input mask is list-like and not an instance of
+    specific geometric types.
+
+    Parameters
+    ----------
+    mask : GeoDataFrame, GeoSeries, (Multi)Polygon, list-like
+        Polygon vector layer used to clip ``gdf``.
+
+    Returns
+    -------
+    bool
+        True if `mask` is list-like and not an instance of `GeoDataFrame`,
+        `GeoSeries`, `Polygon`, or `MultiPolygon`, otherwise False.
+    """
     return pandas.api.types.is_list_like(mask) and not isinstance(
         mask, GeoDataFrame | GeoSeries | Polygon | MultiPolygon
     )
 
 
 def _clip_gdf_with_mask(gdf, mask, sort=False):
-    """Clip geometry to the polygon/rectangle extent.
+    """
+    Clip geometry to the polygon/rectangle extent.
 
     Clip an input GeoDataFrame to the polygon extent of the polygon
     parameter.
@@ -163,17 +179,17 @@ def clip(gdf, mask, keep_geom_type=False, sort=False):
     if not isinstance(gdf, GeoDataFrame | GeoSeries):
         raise TypeError(f"'gdf' should be GeoDataFrame or GeoSeries, got {type(gdf)}")
 
-    mask_is_list_like = _mask_is_list_like_rectangle(mask)
+    clipping_by_rectangle = _mask_is_list_like_rectangle(mask)
     if (
         not isinstance(mask, GeoDataFrame | GeoSeries | Polygon | MultiPolygon)
-        and not mask_is_list_like
+        and not clipping_by_rectangle
     ):
         raise TypeError(
             "'mask' should be GeoDataFrame, GeoSeries,"
             f"(Multi)Polygon or list-like, got {type(mask)}"
         )
 
-    if mask_is_list_like and len(mask) != 4:
+    if clipping_by_rectangle and len(mask) != 4:
         raise TypeError(
             "If 'mask' is list-like, it must have four values (minx, miny, maxx, maxy)"
         )
@@ -184,7 +200,7 @@ def clip(gdf, mask, keep_geom_type=False, sort=False):
 
     if isinstance(mask, GeoDataFrame | GeoSeries):
         box_mask = mask.total_bounds
-    elif mask_is_list_like:
+    elif clipping_by_rectangle:
         box_mask = mask
     else:
         # Avoid empty tuple returned by .bounds when geometry is empty. A tuple of
diff --git a/pyproject.toml b/pyproject.toml
index 842f1c34..9717a007 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,7 +31,7 @@ dependencies = [
 
 [project.optional-dependencies]
 all = [
-    "psycopg-binary>=3.1.0",
+    "psycopg[binary]>=3.1.0",
     "SQLAlchemy>=2.0",
     "geopy",
     "matplotlib>=3.7",
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 8f9395a0..0eca77c7 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -8,7 +8,7 @@ packaging
 
 # geodatabase access
 psycopg2-binary>=2.8.0
-psycopg-binary>=3.1.0
+psycopg[binary]>=3.1.0
 SQLAlchemy>=2.0
 
 # geocoding


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index d167d253..f7f05710 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -96,4 +96,4 @@ jobs:
         run: |
           pytest -v --color=yes --doctest-only geopandas --ignore=geopandas/datasets
 
-      - uses: codecov/codecov-action@v4
+      - uses: codecov/codecov-action@v5
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index 74857fef..db450144 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -18,7 +18,7 @@ from shapely.geometry import Point, Polygon, box, mapping
 
 import geopandas
 from geopandas import GeoDataFrame, read_file
-from geopandas._compat import HAS_PYPROJ, PANDAS_GE_20, PANDAS_GE_30
+from geopandas._compat import HAS_PYPROJ, PANDAS_GE_30
 from geopandas.io.file import _EXTENSION_TO_DRIVER, _detect_driver
 
 import pytest
@@ -229,8 +229,7 @@ def test_to_file_datetime(tmpdir, driver, ext, time, engine):
     assert_geodataframe_equal(df.drop(columns=["b"]), df_read.drop(columns=["b"]))
     # Check datetime column
     expected = df["b"]
-    if PANDAS_GE_20:
-        expected = df["b"].dt.as_unit("ms")
+    expected = df["b"].dt.as_unit("ms")
     actual = df_read["b"]
     if df["b"].dt.tz is not None:
         # US/Eastern becomes a UTC-5 fixed offset when read from file
@@ -281,8 +280,6 @@ def test_read_file_datetime_invalid(tmpdir, ext, engine):
 
 @pytest.mark.parametrize("ext", dt_exts)
 def test_read_file_datetime_out_of_bounds_ns(tmpdir, ext, engine):
-    if engine == "pyogrio" and not PANDAS_GE_20:
-        pytest.skip("with pyogrio requires pandas >= 2.0 to pass")
     # https://github.com/geopandas/geopandas/issues/2502
     date_str = "9999-12-31T00:00:00"  # valid to GDAL, not to [ns] format
     tempfilename = write_invalid_date_file(date_str, tmpdir, ext, engine)
diff --git a/geopandas/io/tests/test_sql.py b/geopandas/io/tests/test_sql.py
index 1232c31e..e3313744 100644
--- a/geopandas/io/tests/test_sql.py
+++ b/geopandas/io/tests/test_sql.py
@@ -767,7 +767,7 @@ class TestIO:
 
     @pytest.mark.parametrize("engine_postgis", POSTGIS_DRIVERS, indirect=True)
     @pytest.mark.xfail(
-        compat.PANDAS_GE_20 and not compat.PANDAS_GE_202,
+        not compat.PANDAS_GE_202,
         reason="Duplicate columns are dropped in read_sql with pandas 2.0.0 and 2.0.1",
     )
     def test_duplicate_geometry_column_fails(self, engine_postgis):
diff --git a/geopandas/tests/test_array.py b/geopandas/tests/test_array.py
index c9d979b2..c1c3fda4 100644
--- a/geopandas/tests/test_array.py
+++ b/geopandas/tests/test_array.py
@@ -9,7 +9,7 @@ import shapely.affinity
 import shapely.geometry
 import shapely.wkb
 import shapely.wkt
-from shapely import geos_version
+from shapely import MultiPolygon, Polygon, geos_version
 from shapely.geometry.base import CAP_STYLE, JOIN_STYLE
 
 import geopandas
@@ -693,6 +693,44 @@ def test_union_all():
         G.union_all(method="invalid")
 
 
+@pytest.mark.parametrize(
+    "grid_size, expected",
+    [
+        (
+            None,
+            MultiPolygon(
+                [
+                    Polygon([(0, 0), (10, 0), (10, 10)]),
+                    Polygon([(0, 0.4), (4.6, 5), (0, 5)]),
+                ]
+            ),
+        ),
+        (1, Polygon([(0, 5), (5, 5), (10, 10), (10, 0), (0, 0)])),
+    ],
+)
+def test_union_all_grid_size(grid_size, expected):
+    geoms = [
+        Polygon([(0, 0), (10, 0), (10, 10)]),
+        Polygon([(0, 0.4), (4.6, 5), (0, 5)]),
+    ]
+    G = from_shapely(geoms)
+    u = G.union_all(grid_size=grid_size)
+    assert u.equals(expected)
+
+
+def test_union_all_grid_size_error():
+    geoms = [
+        shapely.geometry.Polygon([(0, 0), (0, 1), (1, 1)]),
+        shapely.geometry.Polygon([(0, 0), (1, 0), (1, 1)]),
+    ]
+    G = from_shapely(geoms)
+
+    with pytest.raises(
+        ValueError, match="grid_size is not supported for method 'coverage'"
+    ):
+        G.union_all(method="coverage", grid_size=1)
+
+
 @pytest.mark.parametrize(
     "attr, arg",
     [
diff --git a/geopandas/tests/test_dissolve.py b/geopandas/tests/test_dissolve.py
index fa02fab6..0d8e5e62 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -3,9 +3,11 @@ import warnings
 import numpy as np
 import pandas as pd
 
+from shapely import MultiPolygon, Polygon
+
 import geopandas
 from geopandas import GeoDataFrame, read_file
-from geopandas._compat import HAS_PYPROJ, PANDAS_GE_20, PANDAS_GE_30
+from geopandas._compat import HAS_PYPROJ, PANDAS_GE_30
 
 import pytest
 from geopandas.testing import assert_geodataframe_equal, geom_almost_equals
@@ -96,10 +98,7 @@ def test_dissolve_emits_other_warnings(nybb_polydf):
     # test to be true on any version
     def sum_and_warn(group):
         warnings.warn("foo")  # noqa: B028
-        if PANDAS_GE_20:
-            return group.sum(numeric_only=False)
-        else:
-            return group.sum()
+        return group.sum(numeric_only=False)
 
     with pytest.warns(UserWarning, match="foo"):
         nybb_polydf.dissolve("manhattan_bronx", aggfunc=sum_and_warn)
@@ -123,6 +122,33 @@ def test_reset_index(nybb_polydf, first):
     assert_frame_equal(comparison, test, check_column_type=False)
 
 
+@pytest.mark.parametrize(
+    "grid_size, expected",
+    [
+        (
+            None,
+            MultiPolygon(
+                [
+                    Polygon([(0, 0), (10, 0), (10, 9)]),
+                    Polygon([(0, 0.4), (4.6, 5), (0, 5)]),
+                ]
+            ),
+        ),
+        (1, Polygon([(0, 5), (5, 5), (10, 9), (10, 0), (0, 0)])),
+    ],
+)
+def test_dissolve_grid_size(grid_size, expected):
+    gdf = geopandas.GeoDataFrame(
+        geometry=[
+            Polygon([(0, 0), (10, 0), (10, 9)]),
+            Polygon([(0, 0.4), (4.6, 5), (0, 5)]),
+        ]
+    )
+
+    dissolved_gdf = gdf.dissolve(grid_size=grid_size)
+    assert dissolved_gdf.geometry[0].equals(expected)
+
+
 def test_dissolve_none(nybb_polydf):
     test = nybb_polydf.dissolve(by=None)
     expected = GeoDataFrame(
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index ed2f4c1d..c9f8dad0 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -8,7 +8,7 @@ from shapely.geometry import GeometryCollection, LineString, Point, Polygon, box
 
 import geopandas
 from geopandas import GeoDataFrame, GeoSeries, overlay, read_file
-from geopandas._compat import HAS_PYPROJ, PANDAS_GE_20
+from geopandas._compat import HAS_PYPROJ
 
 import pytest
 from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
@@ -768,18 +768,12 @@ def test_non_overlapping(how):
     result = overlay(df1, df2, how=how)
 
     if how == "intersection":
-        if PANDAS_GE_20:
-            index = None
-        else:
-            index = pd.Index([], dtype="object")
-
         expected = GeoDataFrame(
             {
                 "col1": np.array([], dtype="int64"),
                 "col2": np.array([], dtype="int64"),
                 "geometry": [],
-            },
-            index=index,
+            }
         )
     elif how == "union":
         expected = GeoDataFrame(
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 83d4c767..a61849a2 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -332,10 +332,7 @@ def test_to_csv(df):
 def test_numerical_operations(s, df):
     # df methods ignore the geometry column
     exp = pd.Series([3, 4], index=["value1", "value2"])
-    if not compat.PANDAS_GE_20:
-        res = df.sum()
-    else:
-        res = df.sum(numeric_only=True)
+    res = df.sum(numeric_only=True)
     assert_series_equal(res, exp)
 
     # series methods raise error (not supported for geometry)
@@ -549,10 +546,7 @@ def test_value_counts():
     # each object is considered unique
     s = GeoSeries([Point(0, 0), Point(1, 1), Point(0, 0)])
     res = s.value_counts()
-    if compat.PANDAS_GE_20:
-        name = "count"
-    else:
-        name = None
+    name = "count"
     exp = pd.Series([2, 1], index=from_shapely([Point(0, 0), Point(1, 1)]), name=name)
     assert_series_equal(res, exp)
     # Check crs doesn't make a difference - note it is not kept in output index anyway
@@ -613,10 +607,8 @@ def test_groupby(df):
     assert_frame_equal(res, exp)
 
     # reductions ignore geometry column
-    if not compat.PANDAS_GE_20:
-        res = df.groupby("value2").sum()
-    else:
-        res = df.groupby("value2").sum(numeric_only=True)
+
+    res = df.groupby("value2").sum(numeric_only=True)
     exp = pd.DataFrame({"value1": [2, 1], "value2": [1, 2]}, dtype="int64").set_index(
         "value2"
     )
@@ -696,11 +688,7 @@ def test_groupby_metadata(crs, geometry_name):
 
     df.groupby("value2").apply(func, **kwargs)
     # selecting the non-group columns -> no need to pass the keyword
-    if (
-        compat.PANDAS_GE_22
-        or (compat.PANDAS_GE_20 and geometry_name == "geometry")
-        or not compat.PANDAS_GE_20
-    ):
+    if compat.PANDAS_GE_22 or (geometry_name == "geometry"):
         df.groupby("value2")[[geometry_name, "value1"]].apply(func)
     else:
         # https://github.com/geopandas/geopandas/pull/2966#issuecomment-1878816712
diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py
index ca41e9e0..83395ac8 100644
--- a/geopandas/tests/test_plotting.py
+++ b/geopandas/tests/test_plotting.py
@@ -451,6 +451,17 @@ class TestPointPlotting:
         df["category"] = df["values"].astype("str")
         df.plot("category", missing_kwds={"facecolor": "none"}, legend=True)
 
+    def test_missing_aspect(self):
+        self.df.loc[0, "values"] = np.nan
+        ax = self.df.plot(
+            "values",
+            missing_kwds={"color": "r"},
+            categorical=True,
+            legend=True,
+            aspect=2,
+        )
+        assert ax.get_aspect() == 2
+
 
 class TestPointZPlotting:
     def setup_method(self):
@@ -1835,6 +1846,8 @@ def test_column_values():
     t2 = Polygon([(1, 0), (2, 0), (2, 1)])
     polys = GeoSeries([t1, t2], index=list("AB"))
     df = GeoDataFrame({"geometry": polys, "values": [0, 1]})
+    numeric_index_polys = GeoSeries([t1, t2], index=[0, 1])
+    numeric_index_df = GeoDataFrame({"geometry": numeric_index_polys, "values": [0, 1]})
 
     # Test with continuous values
     ax = df.plot(column="values")
@@ -1856,6 +1869,11 @@ def test_column_values():
     colors_array = ax.collections[0].get_facecolors()
     np.testing.assert_array_equal(colors, colors_array)
 
+    # Test with pd.Index
+    ax = numeric_index_df.plot(column=numeric_index_df.index, categorical=True)
+    colors_array = ax.collections[0].get_facecolors()
+    np.testing.assert_array_equal(colors, colors_array)
+
     # Check raised error: is df rows number equal to column length?
     with pytest.raises(ValueError, match="different number of rows"):
         ax = df.plot(column=np.array([1, 2, 3]))
diff --git a/geopandas/tests/test_sindex.py b/geopandas/tests/test_sindex.py
index f8b6268f..efaf3801 100644
--- a/geopandas/tests/test_sindex.py
+++ b/geopandas/tests/test_sindex.py
@@ -71,18 +71,6 @@ class TestSeriesSindex:
         s = GeoSeries([t1, t2, sq])
         assert s.sindex.size == 3
 
-    @pytest.mark.filterwarnings("ignore:The series.append method is deprecated")
-    @pytest.mark.skipif(compat.PANDAS_GE_20, reason="append removed in pandas 2.0")
-    def test_polygons_append(self):
-        t1 = Polygon([(0, 0), (1, 0), (1, 1)])
-        t2 = Polygon([(0, 0), (1, 1), (0, 1)])
-        sq = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
-        s = GeoSeries([t1, t2, sq])
-        t = GeoSeries([t1, t2, sq], [3, 4, 5])
-        s = s.append(t)
-        assert len(s) == 6
-        assert s.sindex.size == 6
-
     def test_lazy_build(self):
         s = GeoSeries([Point(0, 0)])
         assert s.values._sindex is None
@@ -168,12 +156,12 @@ class TestFrameSindex:
         # with pandas 2.0, the column is now copied, losing the index. But
         # with pandas >= 3.0 and Copy-on-Write this is preserved again
         subset1 = self.df[["geom", "A"]]
-        if compat.PANDAS_GE_20 and not compat.PANDAS_GE_30:
+        if not compat.PANDAS_GE_30:
             assert subset1.sindex is not original_index
         else:
             assert subset1.sindex is original_index
         subset2 = self.df[["A", "geom"]]
-        if compat.PANDAS_GE_20 and not compat.PANDAS_GE_30:
+        if not compat.PANDAS_GE_30:
             assert subset2.sindex is not original_index
         else:
             assert subset2.sindex is original_index
diff --git a/geopandas/tools/tests/test_random.py b/geopandas/tools/tests/test_random.py
index a8d9a4fb..9d4c14b9 100644
--- a/geopandas/tools/tests/test_random.py
+++ b/geopandas/tools/tests/test_random.py
@@ -1,5 +1,7 @@
 import numpy
 
+import shapely
+
 import geopandas
 from geopandas.tools._random import uniform
 
@@ -65,3 +67,22 @@ def test_uniform_generator(polygons):
 
     assert sample.equals(gen_sample)
     assert not sample.equals(gen_sample2)
+
+
+@pytest.mark.parametrize("size", range(5, 12))
+def test_unimodality(size):  # GH 3470
+    circle = shapely.Point(0, 0).buffer(1)
+    generator = numpy.random.default_rng(seed=1)
+    centers_x = []
+    centers_y = []
+    for _ in range(200):
+        pts = shapely.get_coordinates(uniform(circle, size=2**size, rng=generator))
+        centers_x.append(numpy.mean(pts[:, 0]))
+        centers_y.append(numpy.mean(pts[:, 1]))
+
+    numpy.testing.assert_allclose(numpy.mean(centers_x), 0, atol=1e-2)
+    numpy.testing.assert_allclose(numpy.mean(centers_y), 0, atol=1e-2)
+
+    stats = pytest.importorskip("scipy.stats")
+    assert stats.shapiro(centers_x).pvalue > 0.05
+    assert stats.shapiro(centers_y).pvalue > 0.05