Skip to content

Commit 74d297e

Browse files
authored
BUG: raise informative error for MultiPolygon in corner-based shape metrics (#739) (#763)
corners, squareness and centroid_corner_distance use GeoSeries.exterior when include_interiors=False, but exterior is undefined (None) for MultiPolygons. The dropped rows make the grouped result shorter than the input index, so corners/squareness raise a cryptic 'Length mismatch' and centroid_corner_distance silently returns all-NaN. Add a shared _check_no_multipolygons guard that raises an actionable ValueError pointing to explode() or include_interiors=True. Adds a parametrized test over the three metrics. Per the discussion on #739 this takes the informative-error path; happy to extend to full MultiPolygon support instead if preferred.
1 parent e2e75d9 commit 74d297e

2 files changed

Lines changed: 49 additions & 0 deletions

File tree

momepy/shape.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,24 @@ def shape_index(
455455
return np.sqrt(geometry.area / np.pi) / (0.5 * longest_axis_length)
456456

457457

458+
def _check_no_multipolygons(geometry: GeoDataFrame | GeoSeries, func_name: str) -> None:
459+
"""Ensure no MultiPolygons are present when relying on polygon exteriors.
460+
461+
Corner-based metrics use ``GeoSeries.exterior``, which is undefined for
462+
MultiPolygons. Without this check the metrics either fail with a cryptic
463+
length-mismatch error or silently return ``NaN``. Raising here gives users an
464+
actionable message instead.
465+
"""
466+
if (geometry.geom_type == "MultiPolygon").any():
467+
raise ValueError(
468+
f"momepy.{func_name} does not support MultiPolygon geometries when "
469+
"include_interiors=False, as it relies on polygon exteriors. Explode "
470+
"MultiPolygons into single-part Polygons first (e.g. "
471+
"``geometry.explode(ignore_index=True)``) or pass "
472+
"include_interiors=True."
473+
)
474+
475+
458476
def corners(
459477
geometry: GeoDataFrame | GeoSeries,
460478
eps: float = 10,
@@ -508,6 +526,7 @@ def _count_corners(points: DataFrame, eps: float) -> np.integer:
508526
if include_interiors:
509527
coords = geometry.reset_index(drop=True).get_coordinates(index_parts=False)
510528
else:
529+
_check_no_multipolygons(geometry, "corners")
511530
coords = geometry.reset_index(drop=True).exterior.get_coordinates(
512531
index_parts=False
513532
)
@@ -571,6 +590,7 @@ def _squareness(points: DataFrame, eps: float):
571590
if include_interiors:
572591
coords = geometry.reset_index(drop=True).get_coordinates(index_parts=False)
573592
else:
593+
_check_no_multipolygons(geometry, "squareness")
574594
coords = geometry.reset_index(drop=True).exterior.get_coordinates(
575595
index_parts=False
576596
)
@@ -729,6 +749,7 @@ def _ccd(points: DataFrame, eps: float) -> Series:
729749
if include_interiors:
730750
coords = geometry.get_coordinates(index_parts=False)
731751
else:
752+
_check_no_multipolygons(geometry, "centroid_corner_distance")
732753
coords = geometry.exterior.get_coordinates(index_parts=False)
733754
coords[["cent_x", "cent_y"]] = geometry.centroid.get_coordinates(index_parts=False)
734755
ccd = coords.groupby(level=0).apply(_ccd, eps=eps)

momepy/tests/test_shape.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import geopandas as gpd
22
import numpy as np
33
import pandas as pd
4+
import pytest
5+
import shapely
46
from libpysal.graph import Graph
57
from pandas.testing import assert_frame_equal, assert_series_equal
68

@@ -272,6 +274,32 @@ def test_centroid_corner_distance(self):
272274
r = mm.centroid_corner_distance(self.df_buildings, include_interiors=True)
273275
assert_frame_equal(r.describe(), expected)
274276

277+
@pytest.mark.parametrize(
278+
"func", ["corners", "squareness", "centroid_corner_distance"]
279+
)
280+
def test_corner_metrics_multipolygon(self, func):
281+
# corner-based metrics rely on polygon exteriors, which are undefined for
282+
# MultiPolygons. With include_interiors=False this used to raise a cryptic
283+
# length-mismatch error (corners, squareness) or silently return NaN
284+
# (centroid_corner_distance). See GH#739.
285+
polygons = self.df_buildings.geometry.iloc[:3]
286+
multi = gpd.GeoSeries(
287+
[shapely.MultiPolygon([poly]) for poly in polygons],
288+
crs=self.df_buildings.crs,
289+
)
290+
291+
with pytest.raises(
292+
ValueError, match="does not support MultiPolygon geometries"
293+
):
294+
getattr(mm, func)(multi)
295+
296+
# include_interiors=True handles MultiPolygons and must keep working
297+
getattr(mm, func)(multi, include_interiors=True)
298+
299+
# exploding into single-part Polygons is the documented workaround
300+
exploded = multi.explode(ignore_index=True)
301+
getattr(mm, func)(exploded)
302+
275303
def test_linearity(self):
276304
expected = {
277305
"mean": 0.9976310491404173,

0 commit comments

Comments
 (0)