Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions momepy/shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,24 @@ def shape_index(
return np.sqrt(geometry.area / np.pi) / (0.5 * longest_axis_length)


def _check_no_multipolygons(geometry: GeoDataFrame | GeoSeries, func_name: str) -> None:
"""Ensure no MultiPolygons are present when relying on polygon exteriors.

Corner-based metrics use ``GeoSeries.exterior``, which is undefined for
MultiPolygons. Without this check the metrics either fail with a cryptic
length-mismatch error or silently return ``NaN``. Raising here gives users an
actionable message instead.
"""
if (geometry.geom_type == "MultiPolygon").any():
raise ValueError(
f"momepy.{func_name} does not support MultiPolygon geometries when "
"include_interiors=False, as it relies on polygon exteriors. Explode "
"MultiPolygons into single-part Polygons first (e.g. "
"``geometry.explode(ignore_index=True)``) or pass "
"include_interiors=True."
)


def corners(
geometry: GeoDataFrame | GeoSeries,
eps: float = 10,
Expand Down Expand Up @@ -508,6 +526,7 @@ def _count_corners(points: DataFrame, eps: float) -> np.integer:
if include_interiors:
coords = geometry.reset_index(drop=True).get_coordinates(index_parts=False)
else:
_check_no_multipolygons(geometry, "corners")
coords = geometry.reset_index(drop=True).exterior.get_coordinates(
index_parts=False
)
Expand Down Expand Up @@ -571,6 +590,7 @@ def _squareness(points: DataFrame, eps: float):
if include_interiors:
coords = geometry.reset_index(drop=True).get_coordinates(index_parts=False)
else:
_check_no_multipolygons(geometry, "squareness")
coords = geometry.reset_index(drop=True).exterior.get_coordinates(
index_parts=False
)
Expand Down Expand Up @@ -729,6 +749,7 @@ def _ccd(points: DataFrame, eps: float) -> Series:
if include_interiors:
coords = geometry.get_coordinates(index_parts=False)
else:
_check_no_multipolygons(geometry, "centroid_corner_distance")
coords = geometry.exterior.get_coordinates(index_parts=False)
coords[["cent_x", "cent_y"]] = geometry.centroid.get_coordinates(index_parts=False)
ccd = coords.groupby(level=0).apply(_ccd, eps=eps)
Expand Down
28 changes: 28 additions & 0 deletions momepy/tests/test_shape.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import geopandas as gpd
import numpy as np
import pandas as pd
import pytest
import shapely
from libpysal.graph import Graph
from pandas.testing import assert_frame_equal, assert_series_equal

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

@pytest.mark.parametrize(
"func", ["corners", "squareness", "centroid_corner_distance"]
)
def test_corner_metrics_multipolygon(self, func):
# corner-based metrics rely on polygon exteriors, which are undefined for
# MultiPolygons. With include_interiors=False this used to raise a cryptic
# length-mismatch error (corners, squareness) or silently return NaN
# (centroid_corner_distance). See GH#739.
polygons = self.df_buildings.geometry.iloc[:3]
multi = gpd.GeoSeries(
[shapely.MultiPolygon([poly]) for poly in polygons],
crs=self.df_buildings.crs,
)

with pytest.raises(
ValueError, match="does not support MultiPolygon geometries"
):
getattr(mm, func)(multi)

# include_interiors=True handles MultiPolygons and must keep working
getattr(mm, func)(multi, include_interiors=True)

# exploding into single-part Polygons is the documented workaround
exploded = multi.explode(ignore_index=True)
getattr(mm, func)(exploded)

def test_linearity(self):
expected = {
"mean": 0.9976310491404173,
Expand Down