Repository: gboeing/osmnx

Issue:
features module does not work for latitudes < -80 or > 84 degrees
### Contributing guidelines

- [x] I understand the contributing guidelines

### Documentation

- [x] My problem is not addressed by the documentation or examples

### Existing issues

- [x] My problem does not appear in an existing issue

### What operating system and Python version are you using?

Ubuntu/python3.12

### What OSMnx version are you using?

2.0.4

### Environment packages and versions

```shell
Package            Version
------------------ -----------
certifi            2025.6.15
charset-normalizer 3.4.2
geopandas          1.1.1
idna               3.10
networkx           3.5
numpy              2.3.1
osmnx              2.0.4
packaging          25.0
pandas             2.3.0
pip                24.0
pyogrio            0.11.0
pyproj             3.7.1
python-dateutil    2.9.0.post0
pytz               2025.2
requests           2.32.4
shapely            2.1.1
six                1.17.0
tzdata             2025.2
urllib3            2.5.0
```

### How did you install OSMnx?

pip or uv (via PyPI)

### Problem description

`osmnx.features_from_polygon` and `osmnx.features_from_bbox`  gives `RuntimeError: Unable to determine UTM CRS`  when given latitudes < -80 or > 84 degrees.

This is because the polygon's area is calculated by first converting to UTM, but UTM is not valid for [polar regions](https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system#UTM_zone)


### Minimal standalone reproducible example

```python
import osmnx
from shapely import Polygon

tags = {'landuse': True}
coords = ((-57.01507272, -83.86728198,), (-57.01507272, -83.69376112), (-55.67423087, -83.69376112))
polygon = Polygon(coords)
features = osmnx.features_from_polygon(polygon=polygon, tags=tags)
```


Discussion:
Can you provide the full error traceback?
That said, this is a known limitation of working with UTM near the poles. In fact we just use geopandas under the hood: https://geopandas.org/en/stable/docs/reference/api/geopandas.GeoDataFrame.estimate_utm_crs.html

You may wish to download an XML extract instead and load that.
```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/osmnx/features.py", line 332, in features_from_polygon
    return _create_gdf(response_jsons, polygon, tags)
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/osmnx/features.py", line 406, in _create_gdf
    for response_json in response_jsons:
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/osmnx/_overpass.py", line 421, in _download_overpass_features
    polygon_coord_strs = _make_overpass_polygon_coord_strs(polygon)
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/osmnx/_overpass.py", line 266, in _make_overpass_polygon_coord_strs
    poly_proj, crs_proj = projection.project_geometry(polygon)
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/osmnx/projection.py", line 76, in project_geometry
    gdf_proj = project_gdf(gdf, to_crs=to_crs, to_latlong=to_latlong)
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/osmnx/projection.py", line 120, in project_gdf
    to_crs = gdf.estimate_utm_crs()
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/geopandas/geodataframe.py", line 1902, in estimate_utm_crs
    return self.geometry.estimate_utm_crs(datum_name=datum_name)
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/geopandas/geoseries.py", line 1310, in estimate_utm_crs
    return self.values.estimate_utm_crs(datum_name)
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/geopandas/_compat.py", line 84, in wrapper
    return func(*args, **kwargs)
  File "/mnt/data/tpinquan/code/eoAPI/venv/lib/python3.10/site-packages/geopandas/array.py", line 1195, in estimate_utm_crs
    raise RuntimeError("Unable to determine UTM CRS")
RuntimeError: Unable to determine UTM CRS
```

I believe this can be fixed in `_make_overpass_polygon_coord_strs` by checking if the polygon is <-80 or > 84 deg and projecting to UPS South or UPS North before subdivision and converting back to epsg:4326.

I can open a PR 
Something like this.

```python
def _make_overpass_polygon_coord_strs(polygon: Polygon | MultiPolygon) -> list[str]:
    """
    Subdivide query polygon and return list of coordinate strings.

    Project to UTM, divide `polygon` up into sub-polygons if area exceeds a
    max size (in meters), project back to lat-lon, then get a list of
    polygon(s) exterior coordinates. Ignore interior ("holes") coordinates.

    Parameters
    ----------
    polygon
        The (Multi)Polygon to convert to exterior coordinate strings.

    Returns
    -------
    coord_strs
        Exterior coordinates of polygon(s).
    """
    # first subdivide the polygon if its area exceeds max size
    # this results in a multipolygon of 1+ constituent polygons
    UTM_SOUTH_LIMIT = -80
    UTM_NORTH_LIMIT = 84
    to_crs = None
    if polygon.bounds[1] < UTM_SOUTH_LIMIT:
        to_crs = "epsg:32761"
    elif polygon.bounds[3] > UTM_NORTH_LIMIT:
        to_crs = "epsg:32661"

    poly_proj, crs_proj = projection.project_geometry(polygon, to_crs=to_crs)
    multi_poly_proj = utils_geo._consolidate_subdivide_geometry(poly_proj)
    multi_poly, _ = projection.project_geometry(multi_poly_proj, crs=crs_proj, to_latlong=True)

    # then extract each's exterior coords to the string format Overpass
    # expects, rounding lats and lons to 6 decimals (approx 5 to 10 cm
    # resolution) so we can hash and cache URL strings consistently
    coord_strs = []
    for geom in multi_poly.geoms:
        x, y = geom.exterior.xy
        coord_list = [f"{xy[1]:.6f}{' '}{xy[0]:.6f}" for xy in zip(x, y)]
        coord_strs.append(" ".join(coord_list))

    return coord_strs
```
Interesting suggestion. Yes if you open a PR I'll take a look.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d232f0d..fb496ae2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 - improve module exposure for better code introspection (#1308)
 - add "all" optional dependency extra (#1313)
 - bump minimum required patch versions of optional extras to earliest versions with macosx/arm64 wheels (#1313)
+- fix bug when computing area using UTM for polar regions (#1323)
 
 ## 2.0.4 (2025-06-11)
 
diff --git a/osmnx/_overpass.py b/osmnx/_overpass.py
index 83e0bb2e..c3b35168 100644
--- a/osmnx/_overpass.py
+++ b/osmnx/_overpass.py
@@ -261,9 +261,19 @@ def _make_overpass_polygon_coord_strs(polygon: Polygon | MultiPolygon) -> list[s
     coord_strs
         Exterior coordinates of polygon(s).
     """
-    # first subdivide the polygon if its area exceeds max size
+    # if polygon is outside UTM limits (80 deg south, 84 deg north), then we
+    # must use universal polar stereographic coordinate system instead
+    UTM_SOUTH_LIMIT = -80
+    UTM_NORTH_LIMIT = 84
+    to_crs = None
+    if polygon.bounds[1] < UTM_SOUTH_LIMIT:
+        to_crs = "epsg:32761"
+    elif polygon.bounds[3] > UTM_NORTH_LIMIT:
+        to_crs = "epsg:32661"
+
+    # subdivide the polygon if its area exceeds max size
     # this results in a multipolygon of 1+ constituent polygons
-    poly_proj, crs_proj = projection.project_geometry(polygon)
+    poly_proj, crs_proj = projection.project_geometry(polygon, to_crs=to_crs)
     multi_poly_proj = utils_geo._consolidate_subdivide_geometry(poly_proj)
     multi_poly, _ = projection.project_geometry(multi_poly_proj, crs=crs_proj, to_latlong=True)
 


Test patch:
diff --git a/tests/test_osmnx.py b/tests/test_osmnx.py
index 0d2caad0..f32e358c 100755
--- a/tests/test_osmnx.py
+++ b/tests/test_osmnx.py
@@ -42,6 +42,9 @@
 
 # define queries to use throughout tests
 location_point = (37.791427, -122.410018)
+polar_point_south = (-84.5501149, -64.1500283)
+polar_point_north = (85.0511092, -30.4142117)
+
 address = "Transamerica Pyramid, 600 Montgomery Street, San Francisco, California, USA"
 place1 = {"city": "Piedmont", "state": "California", "country": "USA"}
 polygon_wkt = (
@@ -701,25 +704,34 @@ def test_features() -> None:
     fig, ax = ox.plot_footprints(gdf)
     fig, ax = ox.plot_footprints(gdf, ax=ax, bbox=(0, 0, 10, 10))
 
+    # features_from_bbox - test < -80 deg latitude
+    tags2: dict[str, bool | str | list[str]] = {"natural": True, "amenity": True}
+    bbox = ox.utils_geo.bbox_from_point(polar_point_south, dist=500)
+    gdf = ox.features_from_bbox(bbox, tags=tags2)
+
+    # features_from_bbox - test > 84 deg latitude
+    bbox = ox.utils_geo.bbox_from_point(polar_point_north, dist=500)
+    gdf = ox.features_from_bbox(bbox, tags=tags2)
+
     # features_from_point - tests multipolygon creation
     gdf = ox.utils_geo.bbox_from_point(location_point, dist=500)
 
     # features_from_place - includes test of list of places
-    tags2: dict[str, bool | str | list[str]] = {
+    tags3: dict[str, bool | str | list[str]] = {
         "amenity": True,
         "landuse": ["retail", "commercial"],
         "highway": "bus_stop",
     }
-    gdf = ox.features_from_place(place1, tags=tags2)
-    gdf = ox.features_from_place([place1], which_result=[None], tags=tags2)
+    gdf = ox.features_from_place(place1, tags=tags3)
+    gdf = ox.features_from_place([place1], which_result=[None], tags=tags3)
 
     # features_from_polygon
     polygon = ox.geocode_to_gdf(place1).geometry.iloc[0]
-    ox.features_from_polygon(polygon, tags2)
+    ox.features_from_polygon(polygon, tags3)
 
     # features_from_address - includes testing overpass settings and snapshot from 2019
     ox.settings.overpass_settings = '[out:json][timeout:200][date:"2019-10-28T19:20:00Z"]'
-    gdf = ox.features_from_address(address, tags=tags2, dist=1000)
+    gdf = ox.features_from_address(address, tags=tags3, dist=1000)
 
     # features_from_xml - tests error handling of clipped XMLs with incomplete geometry
     gdf = ox.features_from_xml("tests/input_data/planet_10.068,48.135_10.071,48.137.osm")