Repository: geopandas/geopandas

Issue:
ENH: method to estimate UTM zone of a gdf
following up on [this conversation](https://github.com/UDST/osmnet/issues/19) from awhile ago, I'm curious whether others would find it useful to have something like `estimated_utm` as a property on a GeoDataFrame? (though i'm not picky about property/method/function/nomenclature)

For context, `osmnx` has [this](https://osmnx.readthedocs.io/en/stable/osmnx.html#osmnx.projection.project_gdf) nifty little function that guesses a gdf's UTM zone using its geographic coordinates. Although it's not perfect, it's super handy when, say, iterating over several gdfs representing different parts of a large country (e.g. metro areas in the USA) and calculating something like the population density in each. It's especially handy given that #1376 isnt available quite yet

i know a handful of packages that implement this function on their own, so seems like there would be a benefit to providing it in a central place like geopandas





Discussion:
Sounds like a nice idea. It'll soon get complicated with GeoDataFrames spanning across UTM zones, but for a lot of cases, it could work well. Not sure if the estimation itself should be in GeoPandas or in pyproj. They have[ something like that](https://pyproj4.github.io/pyproj/stable/api/crs/crs.html?highlight=utm#pyproj.crs.CRS.utm_zone), but not exactly afaik.

cc @snowman2 
This line from osmnx looks particularly useful, but not entirely accurate: https://github.com/gboeing/osmnx/blob/e1bfad35d8591f8b0e71110476b456c2c62d6e08/osmnx/projection.py#L95. I think it would be useful to get the UTM zone from latlon. The UTM package does this as well.
https://github.com/Turbo87/utm
With: https://github.com/pyproj4/pyproj/pull/712

```python
from pyproj import CRS
from pyproj.aoi import AreaOfInterest
from pyproj.database import query_utm_crs_info

centroid = gdf["geometry"].unary_union.centroid
utm_crs_list = query_utm_crs_info(
    datum_name="WGS 84",
    area_of_interest=AreaOfInterest(
        west_lon_degree=centroid.x,
        south_lat_degree=centroid.y,
        east_lon_degree=centroid.x,
        north_lat_degree=centroid.y,
    )
)

utm_crs = CRS.from_epsg(utm_crs_list[0].code)
```

Then. geopandas could add a `to_utm_crs` method or something. Thoughts?
@snowman2 Great! Any idea when the next release of pyproj with this PR will be out?

We can add a function estimating UTM zone CRS. So in the end you would do `df.to_crs(df.estimate_utm())`.

One note implementation-wise - we should use `total_bounds` to get coordinates, that is way faster than unary_union.

> We can add a function estimating UTM zone CRS. So in the end you would do df.to_crs(df.estimate_utm())

:+1:

> Any idea when the next release of pyproj with this PR will be out?

The next pyproj release depends on PROJ 7.2 https://github.com/pyproj4/pyproj/issues/689 which is due Nov 1 [7.2 milestone](https://github.com/OSGeo/PROJ/milestone/25). So, sometime after that.

Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1077,6 +1077,46 @@ def to_crs(self, crs=None, epsg=None, inplace=False):
         if not inplace:
             return df
 
+    def estimate_utm_crs(self, datum_name="WGS 84"):
+        """Returns the estimated UTM CRS based on the bounds of the dataset.
+
+        .. versionadded:: 0.9
+
+        .. note:: Requires pyproj 3+
+
+        Parameters
+        ----------
+        datum_name : str, optional
+            The name of the datum to use in the query. Default is WGS 84.
+
+        Returns
+        -------
+        pyproj.CRS
+
+        Examples
+        --------
+        >>> world = geopandas.read_file(
+        ...     geopandas.datasets.get_path("naturalearth_lowres")
+        ... )
+        >>> germany = world.loc[world.name == "Germany"]
+        >>> germany.estimate_utm_crs()  # doctest: +SKIP
+        <Projected CRS: EPSG:32632>
+        Name: WGS 84 / UTM zone 32N
+        Axis Info [cartesian]:
+        - E[east]: Easting (metre)
+        - N[north]: Northing (metre)
+        Area of Use:
+        - name: World - N hemisphere - 6°E to 12°E - by country
+        - bounds: (6.0, 0.0, 12.0, 84.0)
+        Coordinate Operation:
+        - name: UTM zone 32N
+        - method: Transverse Mercator
+        Datum: World Geodetic System 1984
+        - Ellipsoid: WGS 84
+        - Prime Meridian: Greenwich
+        """
+        return self.geometry.estimate_utm_crs(datum_name=datum_name)
+
     def __getitem__(self, key):
         """
         If the result is a column containing only 'geometry', return a
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -777,6 +777,79 @@ def to_crs(self, crs=None, epsg=None):
             GeometryArray(new_data), crs=crs, index=self.index, name=self.name
         )
 
+    def estimate_utm_crs(self, datum_name="WGS 84"):
+        """Returns the estimated UTM CRS based on the bounds of the dataset.
+
+        .. versionadded:: 0.9
+
+        .. note:: Requires pyproj 3+
+
+        Parameters
+        ----------
+        datum_name : str, optional
+            The name of the datum to use in the query. Default is WGS 84.
+
+        Returns
+        -------
+        pyproj.CRS
+
+        Examples
+        --------
+        >>> world = geopandas.read_file(
+        ...     geopandas.datasets.get_path("naturalearth_lowres")
+        ... )
+        >>> germany = world.loc[world.name == "Germany"]
+        >>> germany.geometry.estimate_utm_crs()  # doctest: +SKIP
+        <Projected CRS: EPSG:32632>
+        Name: WGS 84 / UTM zone 32N
+        Axis Info [cartesian]:
+        - E[east]: Easting (metre)
+        - N[north]: Northing (metre)
+        Area of Use:
+        - name: World - N hemisphere - 6°E to 12°E - by country
+        - bounds: (6.0, 0.0, 12.0, 84.0)
+        Coordinate Operation:
+        - name: UTM zone 32N
+        - method: Transverse Mercator
+        Datum: World Geodetic System 1984
+        - Ellipsoid: WGS 84
+        - Prime Meridian: Greenwich
+        """
+        try:
+            from pyproj.aoi import AreaOfInterest
+            from pyproj.database import query_utm_crs_info
+        except ImportError:
+            raise RuntimeError("pyproj 3+ required for estimate_utm_crs.")
+
+        if not self.crs:
+            raise RuntimeError("crs must be set to estimate UTM CRS.")
+
+        minx, miny, maxx, maxy = self.total_bounds
+        # ensure using geographic coordinates
+        if not self.crs.is_geographic:
+            lon, lat = Transformer.from_crs(
+                self.crs, "EPSG:4326", always_xy=True
+            ).transform((minx, maxx, minx, maxx), (miny, miny, maxy, maxy))
+            x_center = np.mean(lon)
+            y_center = np.mean(lat)
+        else:
+            x_center = np.mean([minx, maxx])
+            y_center = np.mean([miny, maxy])
+
+        utm_crs_list = query_utm_crs_info(
+            datum_name=datum_name,
+            area_of_interest=AreaOfInterest(
+                west_lon_degree=x_center,
+                south_lat_degree=y_center,
+                east_lon_degree=x_center,
+                north_lat_degree=y_center,
+            ),
+        )
+        try:
+            return CRS.from_epsg(utm_crs_list[0].code)
+        except IndexError:
+            raise RuntimeError("Unable to determine UTM CRS")
+
     def to_json(self, **kwargs):
         """
         Returns a GeoJSON string representation of the GeoSeries.


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -2,11 +2,14 @@
 import os
 import shutil
 import tempfile
+from distutils.version import LooseVersion
 
 import numpy as np
 import pandas as pd
 
 import fiona
+import pyproj
+from pyproj import CRS
 from pyproj.exceptions import CRSError
 from shapely.geometry import Point
 
@@ -20,6 +23,9 @@
 import pytest
 
 
+PYPROJ_LT_3 = LooseVersion(pyproj.__version__) < LooseVersion("3")
+
+
 class TestDataFrame:
     def setup_method(self):
         N = 10
@@ -659,6 +665,14 @@ def test_pickle_method(self):
         assert_frame_equal(self.df, unpickled)
         assert self.df.crs == unpickled.crs
 
+    def test_estimate_utm_crs(self):
+        if PYPROJ_LT_3:
+            with pytest.raises(RuntimeError, match=r"pyproj 3\+ required"):
+                self.df.estimate_utm_crs()
+        else:
+            assert self.df.estimate_utm_crs() == CRS("EPSG:32618")
+            assert self.df.estimate_utm_crs("NAD83") == CRS("EPSG:26918")
+
 
 def check_geodataframe(df, geometry_column="geometry"):
     assert isinstance(df, GeoDataFrame)
diff --git a/geopandas/tests/test_geoseries.py b/geopandas/tests/test_geoseries.py
--- a/geopandas/tests/test_geoseries.py
+++ b/geopandas/tests/test_geoseries.py
@@ -1,3 +1,4 @@
+from distutils.version import LooseVersion
 import json
 import os
 import random
@@ -8,6 +9,7 @@
 from numpy.testing import assert_array_equal
 import pandas as pd
 
+from pyproj import CRS
 from shapely.geometry import (
     LineString,
     MultiLineString,
@@ -17,6 +19,7 @@
     Polygon,
 )
 from shapely.geometry.base import BaseGeometry
+import pyproj
 
 from geopandas import GeoSeries, GeoDataFrame
 from geopandas.array import GeometryArray, GeometryDtype
@@ -26,6 +29,9 @@
 import pytest
 
 
+PYPROJ_LT_3 = LooseVersion(pyproj.__version__) < LooseVersion("3")
+
+
 class TestSeries:
     def setup_method(self):
         self.tempdir = tempfile.mkdtemp()
@@ -182,6 +188,32 @@ def test_transform(self):
         with pytest.raises(ValueError):
             self.landmarks.to_crs(crs=None, epsg=None)
 
+    def test_estimate_utm_crs__geographic(self):
+        if PYPROJ_LT_3:
+            with pytest.raises(RuntimeError, match=r"pyproj 3\+ required"):
+                self.landmarks.estimate_utm_crs()
+        else:
+            assert self.landmarks.estimate_utm_crs() == CRS("EPSG:32618")
+            assert self.landmarks.estimate_utm_crs("NAD83") == CRS("EPSG:26918")
+
+    @pytest.mark.skipif(PYPROJ_LT_3, reason="requires pyproj 3 or higher")
+    def test_estimate_utm_crs__projected(self):
+        assert self.landmarks.to_crs("EPSG:3857").estimate_utm_crs() == CRS(
+            "EPSG:32618"
+        )
+
+    @pytest.mark.skipif(PYPROJ_LT_3, reason="requires pyproj 3 or higher")
+    def test_estimate_utm_crs__out_of_bounds(self):
+        with pytest.raises(RuntimeError, match="Unable to determine UTM CRS"):
+            GeoSeries(
+                [Polygon([(0, 90), (1, 90), (2, 90)])], crs="EPSG:4326"
+            ).estimate_utm_crs()
+
+    @pytest.mark.skipif(PYPROJ_LT_3, reason="requires pyproj 3 or higher")
+    def test_estimate_utm_crs__missing_crs(self):
+        with pytest.raises(RuntimeError, match="crs must be set"):
+            GeoSeries([Polygon([(0, 90), (1, 90), (2, 90)])]).estimate_utm_crs()
+
     def test_fillna(self):
         # default is to fill with empty geometry
         na = self.na_none.fillna()