Repository: geopandas/geopandas
Pull request #2986 (2023-08-28 21:10:03)

COMPAT: add fillna copy keyword for pandas 2.1
Fixing some of the warnings we have on the dev build:

```
 geopandas/tests/test_extension_array.py::TestMissing::test_fillna_series
  /home/runner/work/geopandas/geopandas/geopandas/tests/test_extension_array.py:372: FutureWarning: ExtensionArray.fillna added a 'copy' keyword in pandas 2.1.0. In a future version, ExtensionArray subclasses will need to implement this keyword or an exception will be raised. In the interim, the keyword is ignored by GeometryArray.
    result = ser.fillna(fill_value)
```

I have to check if this is properly tested in the upstream tests we inherit (maybe not since they were not failing before, only warning)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63689cb2..18dd96ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@ New methods:
 - Added ``delaunay_triangles`` method from shapely to GeoSeries/GeoDataframe (#2907).
 - Added ``concave_hull`` method from shapely to GeoSeries/GeoDataframe (#2903).
 - Added ``offset_curve`` method from shapely to GeoSeries/GeoDataframe (#2902).
+- Added ``shortest_line`` method from shapely to GeoSeries/GeoDataframe (#2960).
+- Added ``minimum_rotated_rectangle`` method from shapely to GeoSeries/GeoDataframe (#2541).
 
 New features and improvements:
 
@@ -20,7 +22,7 @@ Bug fixes:
 
 - Fix a color assignment in ``explore`` when using ``UserDefined`` bins (#2923)
 - ``assert_geodataframe_equal`` now handles GeoDataFrames with no active geometry (#2498)
-- Fix bug in `apply` with `axis=1` where the given user defined function returns nested 
+- Fix bug in `apply` with `axis=1` where the given user defined function returns nested
   data in the geometry column (#2959)
 - Properly infer schema for np.int32 and pd.Int32Dtype columns (#2950)
 
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index 755b56a2..9e55f779 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -95,9 +95,11 @@ Constructive methods and attributes
    GeoSeries.extract_unique_points
    GeoSeries.make_valid
    GeoSeries.minimum_bounding_circle
+   GeoSeries.minimum_rotated_rectangle
    GeoSeries.normalize
    GeoSeries.sample_points
    GeoSeries.segmentize
+   GeoSeries.shortest_line
    GeoSeries.simplify
 
 Affine transformations
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index 3ccf3490..ce05edd4 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -21,7 +21,7 @@ PANDAS_GE_13 = Version(pd.__version__) >= Version("1.3.0")
 PANDAS_GE_14 = Version(pd.__version__) >= Version("1.4.0rc0")
 PANDAS_GE_15 = Version(pd.__version__) >= Version("1.5.0")
 PANDAS_GE_20 = Version(pd.__version__) >= Version("2.0.0")
-PANDAS_GE_21 = Version(pd.__version__) >= Version("2.1.0.dev0")
+PANDAS_GE_21 = Version(pd.__version__) >= Version("2.1.0")
 
 
 # -----------------------------------------------------------------------------
diff --git a/geopandas/_vectorized.py b/geopandas/_vectorized.py
index 596df147..9d5cafdf 100644
--- a/geopandas/_vectorized.py
+++ b/geopandas/_vectorized.py
@@ -658,6 +658,15 @@ def envelope(data):
         return _unary_geo("envelope", data)
 
 
+def minimum_rotated_rectangle(data):
+    if compat.USE_SHAPELY_20:
+        return shapely.oriented_envelope(data)
+    elif compat.USE_PYGEOS:
+        return pygeos.oriented_envelope(data)
+    else:
+        return _unary_geo("minimum_rotated_rectangle", data)
+
+
 def exterior(data):
     if compat.USE_SHAPELY_20:
         return shapely.get_exterior_ring(data)
@@ -941,6 +950,18 @@ def union(data, other):
         return _binary_geo("union", data, other)
 
 
+def shortest_line(data, other):
+    if compat.USE_SHAPELY_20:
+        return shapely.shortest_line(data, other)
+    elif compat.USE_PYGEOS:
+        return _binary_method("shortest_line", data, other)
+    else:
+        raise NotImplementedError(
+            f"shapely >= 2.0 or PyGEOS is required, "
+            f"version {shapely.__version__} is installed"
+        )
+
+
 #
 # Other operations
 #
diff --git a/geopandas/array.py b/geopandas/array.py
index 6869b27f..405e51d8 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -529,6 +529,11 @@ class GeometryArray(ExtensionArray):
     def envelope(self):
         return GeometryArray(vectorized.envelope(self._data), crs=self.crs)
 
+    def minimum_rotated_rectangle(self):
+        return GeometryArray(
+            vectorized.minimum_rotated_rectangle(self.data), crs=self.crs
+        )
+
     @property
     def exterior(self):
         return GeometryArray(vectorized.exterior(self._data), crs=self.crs)
@@ -658,6 +663,11 @@ class GeometryArray(ExtensionArray):
     def union(self, other):
         return GeometryArray(self._binary_method("union", self, other), crs=self.crs)
 
+    def shortest_line(self, other):
+        return GeometryArray(
+            self._binary_method("shortest_line", self, other), crs=self.crs
+        )
+
     #
     # Other operations
     #
@@ -1066,7 +1076,7 @@ class GeometryArray(ExtensionArray):
         self._data[idx] = value_arr
         return self
 
-    def fillna(self, value=None, method=None, limit=None):
+    def fillna(self, value=None, method=None, limit=None, copy=True):
         """
         Fill NA values with geometry (or geometries) or using the specified method.
 
@@ -1090,6 +1100,10 @@ class GeometryArray(ExtensionArray):
             maximum number of entries along the entire axis where NaNs will be
             filled.
 
+        copy : bool, default True
+            Whether to make a copy of the data before filling. If False, then
+            the original should be modified and no new memory should be allocated.
+
         Returns
         -------
         GeometryArray
@@ -1098,7 +1112,10 @@ class GeometryArray(ExtensionArray):
             raise NotImplementedError("fillna with a method is not yet supported")
 
         mask = self.isna()
-        new_values = self.copy()
+        if copy:
+            new_values = self.copy()
+        else:
+            new_values = self
         return new_values._fill(mask, value) if mask.any() else new_values
 
     def astype(self, dtype, copy=True):
diff --git a/geopandas/base.py b/geopandas/base.py
index 1fc7a038..1c12d4a3 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -697,6 +697,46 @@ GeometryCollection
         """
         return _delegate_property("envelope", self)
 
+    def minimum_rotated_rectangle(self):
+        """Returns a ``GeoSeries`` of the general minimum bounding rectangle
+        that contains the object.
+
+        Unlike envelope this rectangle is not constrained to be parallel
+        to the coordinate axes. If the convex hull of the object is a
+        degenerate (line or point) this degenerate is returned.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import Polygon, LineString, Point, MultiPoint
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1)]),
+        ...         LineString([(0, 0), (1, 1), (1, 0)]),
+        ...         MultiPoint([(0, 0), (1, 1)]),
+        ...         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        MULTIPOINT (0.00000 0.00000, 1.00000 1.00000)
+        3                              POINT (0.00000 0.00000)
+        dtype: geometry
+
+        >>> s.minimum_rotated_rectangle()
+        0    POLYGON ((1.00000 1.00000, 0.50000 1.50000, -0...
+        1    POLYGON ((0.00000 0.00000, 0.50000 -0.50000, 1...
+        2        LINESTRING (0.00000 0.00000, 1.00000 1.00000)
+        3                              POINT (0.00000 0.00000)
+        dtype: geometry
+
+        See also
+        --------
+        GeoSeries.envelope : bounding rectangle
+        """
+        return _delegate_geo_method("minimum_rotated_rectangle", self)
+
     @property
     def exterior(self):
         """Returns a ``GeoSeries`` of LinearRings representing the outer
@@ -3099,6 +3139,109 @@ GeometryCollection
         clipped_geometry = geometry_array.clip_by_rect(xmin, ymin, xmax, ymax)
         return GeoSeries(clipped_geometry, index=self.index, crs=self.crs)
 
+    def shortest_line(self, other, align=True):
+        """
+        Returns the shortest two-point line between two geometries.
+
+        The resulting line consists of two points, representing the nearest points
+        between the geometry pair. The line always starts in the first geometry a
+        and ends in he second geometry b. The endpoints of the line will not
+        necessarily be existing vertices of the input geometries a and b, but
+        can also be a point along a line segment.
+
+
+        The operation works on a 1-to-1 row-wise manner:
+
+        .. image:: ../../../_static/binary_op-01.svg
+        :align: center
+
+        Parameters
+        ----------
+        other : Geoseries or geometric object
+            The Geoseries (elementwise) or geometric object to find the
+            shortest line with.
+        align : bool (default True)
+            If True, automatically aligns GeoSeries based on their indices.
+            If False, the order of elements is preserved.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from shapely.geometry import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (2, 2), (0, 2)]),
+        ...         Polygon([(0, 0), (2, 2), (0, 2)]),
+        ...         LineString([(0, 0), (2, 2)]),
+        ...         LineString([(2, 0), (0, 2)]),
+        ...         Point(0, 1),
+        ...     ],
+        ...     crs=5514
+        ... )
+        >>> s
+        0    POLYGON ((0.000 0.000, 2.000 2.000, 0.000 2.00...
+        1    POLYGON ((0.000 0.000, 2.000 2.000, 0.000 2.00...
+        2                LINESTRING (0.000 0.000, 2.000 2.000)
+        3                LINESTRING (2.000 0.000, 0.000 2.000)
+        4                                  POINT (0.000 1.000)
+        dtype: geometry
+
+        We can also do intersection of each geometry and a single
+        shapely geometry:
+
+        .. image:: ../../../_static/binary_op-03.svg
+           :align: center
+
+        >>> p = Point(3, 3)
+        >>> s.shortest_line(p)
+        0    LINESTRING (2.000 2.000, 3.000 3.000)
+        1    LINESTRING (2.000 2.000, 3.000 3.000)
+        2    LINESTRING (2.000 2.000, 3.000 3.000)
+        3    LINESTRING (1.000 1.000, 3.000 3.000)
+        4    LINESTRING (0.000 1.000, 3.000 3.000)
+        dtype: geometry
+
+        We can also check two GeoSeries against each other, row by row.
+        The GeoSeries above have different indices than the one below. We can either
+        align both GeoSeries based on index values and compare elements with the same
+        index using ``align=True`` or ignore index and compare elements based on their
+        matching order using ``align=False``:
+
+        .. image:: ../../../_static/binary_op-02.svg
+
+        >>> s2 = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0.5, 0.5), (1.5, 0.5), (1.5, 1.5), (0.5, 1.5)]),
+        ...         Point(3, 1),
+        ...         LineString([(1, 0), (2, 0)]),
+        ...         Point(10, 15),
+        ...         Point(0, 1),
+        ...     ],
+        ...     index=range(1, 6),
+        ...     crs=5514,
+        ... )
+        >>> s.shortest_line(s2, align=True)
+        0                                       None
+        1      LINESTRING (0.500 0.500, 0.500 0.500)
+        2      LINESTRING (2.000 2.000, 3.000 1.000)
+        3      LINESTRING (2.000 0.000, 2.000 0.000)
+        4    LINESTRING (0.000 1.000, 10.000 15.000)
+        5                                       None
+        dtype: geometry
+
+        >>> s.shortest_line(s2, align=False)
+        0      LINESTRING (0.500 0.500, 0.500 0.500)
+        1      LINESTRING (2.000 2.000, 3.000 1.000)
+        2      LINESTRING (0.500 0.500, 1.000 0.000)
+        3    LINESTRING (0.000 2.000, 10.000 15.000)
+        4      LINESTRING (0.000 1.000, 0.000 1.000)
+        dtype: geometry
+        """
+        return _binary_geo("shortest_line", self, other, align)
+
     #
     # Other operations
     #


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index b7a753b7..731cf42f 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -368,6 +368,40 @@ class TestGeomMethods:
         expected = GeoSeries([self.t1, self.t1])
         self._test_binary_topological("difference", expected, self.g1, self.t2)
 
+    @pytest.mark.skipif(
+        not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="get_coordinates not implemented for shapely<2",
+    )
+    def test_shortest_line(self):
+        expected = GeoSeries([LineString([(1, 1), (5, 5)]), None])
+        assert_array_dtype_equal(expected, self.na_none.shortest_line(self.p0))
+
+        expected = GeoSeries(
+            [
+                LineString([(5, 5), (1, 1)]),
+                LineString([(2, 0), (2, 0)]),
+            ]
+        )
+        assert_array_dtype_equal(expected, self.g6.shortest_line(self.g7))
+
+        expected = GeoSeries(
+            [LineString([(0.5, 0.5), (0.5, 0.5)]), LineString([(0.5, 0.5), (0.5, 0.5)])]
+        )
+        crossed_lines_inv = self.crossed_lines[::-1]
+        assert_array_dtype_equal(
+            expected, self.crossed_lines.shortest_line(crossed_lines_inv, align=False)
+        )
+
+    @pytest.mark.skipif(
+        (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
+        reason="get_coordinates not implemented for shapely<2",
+    )
+    def test_shortest_line_not(self):
+        with pytest.raises(
+            NotImplementedError, match="shapely >= 2.0 or PyGEOS is required"
+        ):
+            self.na_none.shortest_line(self.p0)
+
     def test_geo_op_empty_result(self):
         l1 = LineString([(0, 0), (1, 1)])
         l2 = LineString([(2, 2), (3, 3)])
@@ -1189,6 +1223,20 @@ class TestGeomMethods:
         assert isinstance(e, GeoSeries)
         assert self.g3.crs == e.crs
 
+    def test_minimum_rotated_rectangle(self):
+        s = GeoSeries([self.sq, self.t5], crs=3857)
+        r = s.minimum_rotated_rectangle()
+        exp = GeoSeries.from_wkt(
+            [
+                "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))",
+                "POLYGON ((2 0, 2 3, 3 3, 3 0, 2 0))",
+            ]
+        )
+
+        assert np.all(r.normalize().geom_equals_exact(exp, 0.001))
+        assert isinstance(r, GeoSeries)
+        assert s.crs == r.crs
+
     @pytest.mark.skipif(
         not (compat.USE_PYGEOS or compat.USE_SHAPELY_20),
         reason=(
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 2f569b4d..0daee615 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -433,6 +433,16 @@ def test_fillna_series(s):
     assert_geoseries_equal(res, s2)
 
 
+def test_fillna_inplace(s):
+    s2 = GeoSeries([Point(0, 0), None, Point(2, 2)])
+    arr = s2.array
+    s2.fillna(Point(1, 1), inplace=True)
+    assert_geoseries_equal(s2, s)
+    if compat.PANDAS_GE_21:
+        # starting from pandas 2.1, there is support to do this actually inplace
+        assert s2.array is arr
+
+
 def test_dropna():
     s2 = GeoSeries([Point(0, 0), None, Point(2, 2)])
     res = s2.dropna()