From 3abc6a79cacfe5b18299074fc3ed9ab993e80c2f Mon Sep 17 00:00:00 2001 From: bstadlbauer <11799671+bstadlbauer@users.noreply.github.com> Date: Fri, 29 Apr 2022 00:20:30 +0200 Subject: [PATCH] ENH: Add fast rectangle clipping option to `tools.clip` (#2366) * Add rectangle clipping to `clip.py` * Add minor changes requested in PR * Allow for list-like masks * Update docstrings for GeoSeries.clip() and GeoDataFrame.clip() --- geopandas/geodataframe.py | 14 +- geopandas/geoseries.py | 8 +- geopandas/tools/clip.py | 93 +++++-- geopandas/tools/tests/test_clip.py | 416 ++++++++++++++++------------- 4 files changed, 313 insertions(+), 218 deletions(-) diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py index c82cba5..ab92ed7 100644 --- a/geopandas/geodataframe.py +++ b/geopandas/geodataframe.py @@ -2106,17 +2106,21 @@ countries_w_city_data[countries_w_city_data["name_left"] == "Italy"] """Clip points, lines, or polygon geometries to the mask extent. Both layers must be in the same Coordinate Reference System (CRS). - The GeoDataFrame will be clipped to the full extent of the `mask` object. + The GeoDataFrame will be clipped to the full extent of the ``mask`` object. If there are multiple polygons in mask, data from the GeoDataFrame will be clipped to the total boundary of all polygons in mask. Parameters ---------- - mask : GeoDataFrame, GeoSeries, (Multi)Polygon - Polygon vector layer used to clip `gdf`. + mask : GeoDataFrame, GeoSeries, (Multi)Polygon, list-like + Polygon vector layer used to clip the GeoDataFrame. The mask's geometry is dissolved into one geometric feature - and intersected with `gdf`. + and intersected with GeoDataFrame. + If the mask is list-like with four elements ``(minx, miny, maxx, maxy)``, + ``clip`` will use a faster rectangle clipping + (:meth:`~GeoSeries.clip_by_rect`), possibly leading to slightly different + results. keep_geom_type : boolean, default False If True, return only geometries of original type in case of intersection resulting in multiple geometry types or GeometryCollections. @@ -2125,7 +2129,7 @@ countries_w_city_data[countries_w_city_data["name_left"] == "Italy"] Returns ------- GeoDataFrame - Vector data (points, lines, polygons) from `gdf` clipped to + Vector data (points, lines, polygons) from the GeoDataFrame clipped to polygon boundary from mask. See also diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py index 395a6a8..eb9bd8e 100644 --- a/geopandas/geoseries.py +++ b/geopandas/geoseries.py @@ -1288,10 +1288,14 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3 Parameters ---------- - mask : GeoDataFrame, GeoSeries, (Multi)Polygon + mask : GeoDataFrame, GeoSeries, (Multi)Polygon, list-like Polygon vector layer used to clip `gdf`. The mask's geometry is dissolved into one geometric feature - and intersected with `gdf`. + and intersected with GeoSeries. + If the mask is list-like with four elements ``(minx, miny, maxx, maxy)``, + ``clip`` will use a faster rectangle clipping + (:meth:`~GeoSeries.clip_by_rect`), possibly leading to slightly different + results. keep_geom_type : boolean, default False If True, return only geometries of original type in case of intersection resulting in multiple geometry types or GeometryCollections. diff --git a/geopandas/tools/clip.py b/geopandas/tools/clip.py index 27ff372..c10b12b 100644 --- a/geopandas/tools/clip.py +++ b/geopandas/tools/clip.py @@ -7,16 +7,23 @@ A module to clip vector data using GeoPandas. """ import warnings -from shapely.geometry import Polygon, MultiPolygon +import pandas.api.types +from shapely.geometry import Polygon, MultiPolygon, box from geopandas import GeoDataFrame, GeoSeries from geopandas.array import _check_crs, _crs_mismatch_warn -def _clip_gdf_with_polygon(gdf, poly): - """Clip geometry to the polygon extent. +def _mask_is_list_like_rectangle(mask): + return pandas.api.types.is_list_like(mask) and not isinstance( + mask, (GeoDataFrame, GeoSeries, Polygon, MultiPolygon) + ) - Clip an input GeoDataFrame to the polygon extent of the poly + +def _clip_gdf_with_mask(gdf, mask): + """Clip geometry to the polygon/rectangle extent. + + Clip an input GeoDataFrame to the polygon extent of the polygon parameter. Parameters @@ -24,16 +31,22 @@ def _clip_gdf_with_polygon(gdf, poly): gdf : GeoDataFrame, GeoSeries Dataframe to clip. - poly : (Multi)Polygon - Reference polygon for clipping. + mask : (Multi)Polygon, list-like + Reference polygon/rectangle for clipping. Returns ------- GeoDataFrame The returned GeoDataFrame is a clipped subset of gdf - that intersects with poly. + that intersects with polygon/rectangle. """ - gdf_sub = gdf.iloc[gdf.sindex.query(poly, predicate="intersects")] + clipping_by_rectangle = _mask_is_list_like_rectangle(mask) + if clipping_by_rectangle: + intersection_polygon = box(*mask) + else: + intersection_polygon = mask + + gdf_sub = gdf.iloc[gdf.sindex.query(intersection_polygon, predicate="intersects")] # For performance reasons points don't need to be intersected with poly non_point_mask = gdf_sub.geom_type != "Point" @@ -45,14 +58,25 @@ def _clip_gdf_with_polygon(gdf, poly): # Clip the data with the polygon if isinstance(gdf_sub, GeoDataFrame): clipped = gdf_sub.copy() - clipped.loc[ - non_point_mask, clipped._geometry_column_name - ] = gdf_sub.geometry.values[non_point_mask].intersection(poly) + if clipping_by_rectangle: + clipped.loc[ + non_point_mask, clipped._geometry_column_name + ] = gdf_sub.geometry.values[non_point_mask].clip_by_rect(*mask) + else: + clipped.loc[ + non_point_mask, clipped._geometry_column_name + ] = gdf_sub.geometry.values[non_point_mask].intersection(mask) else: # GeoSeries clipped = gdf_sub.copy() - clipped[non_point_mask] = gdf_sub.values[non_point_mask].intersection(poly) + if clipping_by_rectangle: + clipped[non_point_mask] = gdf_sub.values[non_point_mask].clip_by_rect(*mask) + else: + clipped[non_point_mask] = gdf_sub.values[non_point_mask].intersection(mask) + if clipping_by_rectangle: + # clip_by_rect might return empty geometry collections in edge cases + clipped = clipped[~clipped.is_empty] return clipped @@ -60,19 +84,29 @@ def clip(gdf, mask, keep_geom_type=False): """Clip points, lines, or polygon geometries to the mask extent. Both layers must be in the same Coordinate Reference System (CRS). - The `gdf` will be clipped to the full extent of the clip object. + The ``gdf`` will be clipped to the full extent of the clip object. - If there are multiple polygons in mask, data from `gdf` will be + If there are multiple polygons in mask, data from ``gdf`` will be clipped to the total boundary of all polygons in mask. + If the ``mask`` is list-like with four elements ``(minx, miny, maxx, maxy)``, a + faster rectangle clipping algorithm will be used. Note that this can lead to + slightly different results in edge cases, e.g. if a line would be reduced to a + point, this point might not be returned. + The geometry is clipped in a fast but possibly dirty way. The output is not + guaranteed to be valid. No exceptions will be raised for topological errors. + Parameters ---------- gdf : GeoDataFrame or GeoSeries Vector layer (point, line, polygon) to be clipped to mask. - mask : GeoDataFrame, GeoSeries, (Multi)Polygon - Polygon vector layer used to clip `gdf`. + mask : GeoDataFrame, GeoSeries, (Multi)Polygon, list-like + Polygon vector layer used to clip ``gdf``. The mask's geometry is dissolved into one geometric feature - and intersected with `gdf`. + and intersected with ``gdf``. + If the mask is list-like with four elements ``(minx, miny, maxx, maxy)``, + ``clip`` will use a faster rectangle clipping (:meth:`~GeoSeries.clip_by_rect`), + possibly leading to slightly different results. keep_geom_type : boolean, default False If True, return only geometries of original type in case of intersection resulting in multiple geometry types or GeometryCollections. @@ -81,7 +115,7 @@ def clip(gdf, mask, keep_geom_type=False): Returns ------- GeoDataFrame or GeoSeries - Vector data (points, lines, polygons) from `gdf` clipped to + Vector data (points, lines, polygons) from ``gdf`` clipped to polygon boundary from mask. See also @@ -110,10 +144,19 @@ def clip(gdf, mask, keep_geom_type=False): "'gdf' should be GeoDataFrame or GeoSeries, got {}".format(type(gdf)) ) - if not isinstance(mask, (GeoDataFrame, GeoSeries, Polygon, MultiPolygon)): + mask_is_list_like = _mask_is_list_like_rectangle(mask) + if ( + not isinstance(mask, (GeoDataFrame, GeoSeries, Polygon, MultiPolygon)) + and not mask_is_list_like + ): raise TypeError( - "'mask' should be GeoDataFrame, GeoSeries or" - "(Multi)Polygon, got {}".format(type(mask)) + "'mask' should be GeoDataFrame, GeoSeries," + f"(Multi)Polygon or list-like, got {type(mask)}" + ) + + if mask_is_list_like and len(mask) != 4: + raise TypeError( + "If 'mask' is list-like, it must have four values (minx, miny, maxx, maxy)" ) if isinstance(mask, (GeoDataFrame, GeoSeries)): @@ -122,6 +165,8 @@ def clip(gdf, mask, keep_geom_type=False): if isinstance(mask, (GeoDataFrame, GeoSeries)): box_mask = mask.total_bounds + elif mask_is_list_like: + box_mask = mask else: box_mask = mask.bounds box_gdf = gdf.total_bounds @@ -132,11 +177,11 @@ def clip(gdf, mask, keep_geom_type=False): return gdf.iloc[:0] if isinstance(mask, (GeoDataFrame, GeoSeries)): - poly = mask.geometry.unary_union + combined_mask = mask.geometry.unary_union else: - poly = mask + combined_mask = mask - clipped = _clip_gdf_with_polygon(gdf, poly) + clipped = _clip_gdf_with_mask(gdf, combined_mask) if keep_geom_type: geomcoll_concat = (clipped.geom_type == "GeometryCollection").any() diff --git a/geopandas/tools/tests/test_clip.py b/geopandas/tools/tests/test_clip.py index 8076747..c643d5a 100644 --- a/geopandas/tools/tests/test_clip.py +++ b/geopandas/tools/tests/test_clip.py @@ -14,6 +14,7 @@ from shapely.geometry import ( LinearRing, GeometryCollection, MultiPoint, + box, ) import geopandas @@ -22,9 +23,20 @@ from geopandas import GeoDataFrame, GeoSeries, clip from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal import pytest +from geopandas.tools.clip import _mask_is_list_like_rectangle pytestmark = pytest.mark.skip_no_sindex pandas_133 = Version(pd.__version__) == Version("1.3.3") +mask_variants_single_rectangle = [ + "single_rectangle_gdf", + "single_rectangle_gdf_list_bounds", + "single_rectangle_gdf_tuple_bounds", + "single_rectangle_gdf_array_bounds", +] +mask_variants_large_rectangle = [ + "larger_single_rectangle_gdf", + "larger_single_rectangle_gdf_bounds", +] @pytest.fixture @@ -62,6 +74,24 @@ def single_rectangle_gdf(): return gdf +@pytest.fixture +def single_rectangle_gdf_tuple_bounds(single_rectangle_gdf): + """Bounds of the created single rectangle""" + return tuple(single_rectangle_gdf.total_bounds) + + +@pytest.fixture +def single_rectangle_gdf_list_bounds(single_rectangle_gdf): + """Bounds of the created single rectangle""" + return list(single_rectangle_gdf.total_bounds) + + +@pytest.fixture +def single_rectangle_gdf_array_bounds(single_rectangle_gdf): + """Bounds of the created single rectangle""" + return single_rectangle_gdf.total_bounds + + @pytest.fixture def larger_single_rectangle_gdf(): """Create a slightly larger rectangle for clipping. @@ -75,6 +105,12 @@ def larger_single_rectangle_gdf(): return gdf +@pytest.fixture +def larger_single_rectangle_gdf_bounds(larger_single_rectangle_gdf): + """Bounds of the created single rectangle""" + return tuple(larger_single_rectangle_gdf.total_bounds) + + @pytest.fixture def buffered_locations(point_gdf): """Buffer points to create a multi-polygon.""" @@ -174,19 +210,11 @@ def test_not_gdf(single_rectangle_gdf): with pytest.raises(TypeError): clip((2, 3), single_rectangle_gdf) with pytest.raises(TypeError): - clip(single_rectangle_gdf, (2, 3)) - - -def test_returns_gdf(point_gdf, single_rectangle_gdf): - """Test that function returns a GeoDataFrame (or GDF-like) object.""" - out = clip(point_gdf, single_rectangle_gdf) - assert isinstance(out, GeoDataFrame) - - -def test_returns_series(point_gdf, single_rectangle_gdf): - """Test that function returns a GeoSeries if GeoSeries is passed.""" - out = clip(point_gdf.geometry, single_rectangle_gdf) - assert isinstance(out, GeoSeries) + clip(single_rectangle_gdf, "foobar") + with pytest.raises(TypeError): + clip(single_rectangle_gdf, (1, 2, 3)) + with pytest.raises(TypeError): + clip(single_rectangle_gdf, (1, 2, 3, 4, 5)) def test_non_overlapping_geoms(): @@ -203,50 +231,175 @@ def test_non_overlapping_geoms(): assert_geoseries_equal(out2, GeoSeries(crs=unit_gdf.crs)) -def test_clip_points(point_gdf, single_rectangle_gdf): - """Test clipping a points GDF with a generic polygon geometry.""" - clip_pts = clip(point_gdf, single_rectangle_gdf) - pts = np.array([[2, 2], [3, 4], [9, 8]]) - exp = GeoDataFrame([Point(xy) for xy in pts], columns=["geometry"], crs="EPSG:3857") - assert_geodataframe_equal(clip_pts, exp) +@pytest.mark.parametrize("mask_fixture_name", mask_variants_single_rectangle) +class TestClipWithSingleRectangleGdf: + @pytest.fixture + def mask(self, mask_fixture_name, request): + return request.getfixturevalue(mask_fixture_name) + + def test_returns_gdf(self, point_gdf, mask): + """Test that function returns a GeoDataFrame (or GDF-like) object.""" + out = clip(point_gdf, mask) + assert isinstance(out, GeoDataFrame) + + def test_returns_series(self, point_gdf, mask): + """Test that function returns a GeoSeries if GeoSeries is passed.""" + out = clip(point_gdf.geometry, mask) + assert isinstance(out, GeoSeries) + + def test_clip_points(self, point_gdf, mask): + """Test clipping a points GDF with a generic polygon geometry.""" + clip_pts = clip(point_gdf, mask) + pts = np.array([[2, 2], [3, 4], [9, 8]]) + exp = GeoDataFrame( + [Point(xy) for xy in pts], columns=["geometry"], crs="EPSG:3857" + ) + assert_geodataframe_equal(clip_pts, exp) + + def test_clip_points_geom_col_rename(self, point_gdf, mask): + """Test clipping a points GDF with a generic polygon geometry.""" + point_gdf_geom_col_rename = point_gdf.rename_geometry("geometry2") + clip_pts = clip(point_gdf_geom_col_rename, mask) + pts = np.array([[2, 2], [3, 4], [9, 8]]) + exp = GeoDataFrame( + [Point(xy) for xy in pts], + columns=["geometry2"], + crs="EPSG:3857", + geometry="geometry2", + ) + assert_geodataframe_equal(clip_pts, exp) + + def test_clip_poly(self, buffered_locations, mask): + """Test clipping a polygon GDF with a generic polygon geometry.""" + clipped_poly = clip(buffered_locations, mask) + assert len(clipped_poly.geometry) == 3 + assert all(clipped_poly.geom_type == "Polygon") + + def test_clip_poly_geom_col_rename(self, buffered_locations, mask): + """Test clipping a polygon GDF with a generic polygon geometry.""" + + poly_gdf_geom_col_rename = buffered_locations.rename_geometry("geometry2") + clipped_poly = clip(poly_gdf_geom_col_rename, mask) + assert len(clipped_poly.geometry) == 3 + assert "geometry" not in clipped_poly.keys() + assert "geometry2" in clipped_poly.keys() + + def test_clip_poly_series(self, buffered_locations, mask): + """Test clipping a polygon GDF with a generic polygon geometry.""" + clipped_poly = clip(buffered_locations.geometry, mask) + assert len(clipped_poly) == 3 + assert all(clipped_poly.geom_type == "Polygon") + + @pytest.mark.xfail(pandas_133, reason="Regression in pandas 1.3.3 (GH #2101)") + def test_clip_multipoly_keep_geom_type(self, multi_poly_gdf, mask): + """Test a multi poly object where the return includes a sliver. + Also the bounds of the object should == the bounds of the clip object + if they fully overlap (as they do in these fixtures).""" + clipped = clip(multi_poly_gdf, mask, keep_geom_type=True) + expected_bounds = ( + mask if _mask_is_list_like_rectangle(mask) else mask.total_bounds + ) + assert np.array_equal(clipped.total_bounds, expected_bounds) + # Assert returned data is a not geometry collection + assert (clipped.geom_type.isin(["Polygon", "MultiPolygon"])).all() + + def test_clip_multiline(self, multi_line, mask): + """Test that clipping a multiline feature with a poly returns expected + output.""" + clipped = clip(multi_line, mask) + assert clipped.geom_type[0] == "MultiLineString" + + def test_clip_multipoint(self, multi_point, mask): + """Clipping a multipoint feature with a polygon works as expected. + should return a geodataframe with a single multi point feature""" + clipped = clip(multi_point, mask) + assert clipped.geom_type[0] == "MultiPoint" + assert hasattr(clipped, "attr") + # All points should intersect the clip geom + assert len(clipped) == 2 + clipped_mutltipoint = MultiPoint( + [ + Point(2, 2), + Point(3, 4), + Point(9, 8), + ] + ) + assert clipped.iloc[0].geometry.wkt == clipped_mutltipoint.wkt + shape_for_points = ( + box(*mask) if _mask_is_list_like_rectangle(mask) else mask.unary_union + ) + assert all(clipped.intersects(shape_for_points)) + + def test_clip_lines(self, two_line_gdf, mask): + """Test what happens when you give the clip_extent a line GDF.""" + clip_line = clip(two_line_gdf, mask) + assert len(clip_line.geometry) == 2 + + def test_mixed_geom(self, mixed_gdf, mask): + """Test clipping a mixed GeoDataFrame""" + clipped = clip(mixed_gdf, mask) + assert ( + clipped.geom_type[0] == "Point" + and clipped.geom_type[1] == "Polygon" + and clipped.geom_type[2] == "LineString" + ) + + def test_mixed_series(self, mixed_gdf, mask): + """Test clipping a mixed GeoSeries""" + clipped = clip(mixed_gdf.geometry, mask) + assert ( + clipped.geom_type[0] == "Point" + and clipped.geom_type[1] == "Polygon" + and clipped.geom_type[2] == "LineString" + ) + + def test_clip_warning_no_extra_geoms(self, buffered_locations, mask): + """Test a user warning is provided if no new geometry types are found.""" + with pytest.warns(UserWarning): + clip(buffered_locations, mask, True) + warnings.warn( + "keep_geom_type was called when no extra geometry types existed.", + UserWarning, + ) + + def test_clip_with_line_extra_geom(self, sliver_line, mask): + """When the output of a clipped line returns a geom collection, + and keep_geom_type is True, no geometry collections should be returned.""" + clipped = clip(sliver_line, mask, keep_geom_type=True) + assert len(clipped.geometry) == 1 + # Assert returned data is a not geometry collection + assert not (clipped.geom_type == "GeometryCollection").any() + + def test_clip_no_box_overlap(self, pointsoutside_nooverlap_gdf, mask): + """Test clip when intersection is empty and boxes do not overlap.""" + clipped = clip(pointsoutside_nooverlap_gdf, mask) + assert len(clipped) == 0 + + def test_clip_box_overlap(self, pointsoutside_overlap_gdf, mask): + """Test clip when intersection is empty and boxes do overlap.""" + clipped = clip(pointsoutside_overlap_gdf, mask) + assert len(clipped) == 0 + + def test_warning_extra_geoms_mixed(self, mixed_gdf, mask): + """Test the correct warnings are raised if keep_geom_type is + called on a mixed GDF""" + with pytest.warns(UserWarning): + clip(mixed_gdf, mask, keep_geom_type=True) + + def test_warning_geomcoll(self, geomcol_gdf, mask): + """Test the correct warnings are raised if keep_geom_type is + called on a GDF with GeometryCollection""" + with pytest.warns(UserWarning): + clip(geomcol_gdf, mask, keep_geom_type=True) -def test_clip_points_geom_col_rename(point_gdf, single_rectangle_gdf): - """Test clipping a points GDF with a generic polygon geometry.""" - point_gdf_geom_col_rename = point_gdf.rename_geometry("geometry2") - clip_pts = clip(point_gdf_geom_col_rename, single_rectangle_gdf) - pts = np.array([[2, 2], [3, 4], [9, 8]]) - exp = GeoDataFrame( - [Point(xy) for xy in pts], - columns=["geometry2"], - crs="EPSG:3857", - geometry="geometry2", - ) - assert_geodataframe_equal(clip_pts, exp) - - -def test_clip_poly(buffered_locations, single_rectangle_gdf): - """Test clipping a polygon GDF with a generic polygon geometry.""" - clipped_poly = clip(buffered_locations, single_rectangle_gdf) - assert len(clipped_poly.geometry) == 3 - assert all(clipped_poly.geom_type == "Polygon") - - -def test_clip_poly_geom_col_rename(buffered_locations, single_rectangle_gdf): - """Test clipping a polygon GDF with a generic polygon geometry.""" - - poly_gdf_geom_col_rename = buffered_locations.rename_geometry("geometry2") - clipped_poly = clip(poly_gdf_geom_col_rename, single_rectangle_gdf) - assert len(clipped_poly.geometry) == 3 - assert "geometry" not in clipped_poly.keys() - assert "geometry2" in clipped_poly.keys() - - -def test_clip_poly_series(buffered_locations, single_rectangle_gdf): - """Test clipping a polygon GDF with a generic polygon geometry.""" - clipped_poly = clip(buffered_locations.geometry, single_rectangle_gdf) - assert len(clipped_poly) == 3 - assert all(clipped_poly.geom_type == "Polygon") +def test_clip_line_keep_slivers(sliver_line, single_rectangle_gdf): + """Test the correct output if a point is returned + from a line only geometry type.""" + clipped = clip(sliver_line, single_rectangle_gdf) + # Assert returned data is a geometry collection given sliver geoms + assert "Point" == clipped.geom_type[0] + assert "LineString" == clipped.geom_type[1] @pytest.mark.xfail(pandas_133, reason="Regression in pandas 1.3.3 (GH #2101)") @@ -260,93 +413,9 @@ def test_clip_multipoly_keep_slivers(multi_poly_gdf, single_rectangle_gdf): assert "GeometryCollection" in clipped.geom_type[0] -@pytest.mark.xfail(pandas_133, reason="Regression in pandas 1.3.3 (GH #2101)") -def test_clip_multipoly_keep_geom_type(multi_poly_gdf, single_rectangle_gdf): - """Test a multi poly object where the return includes a sliver. - Also the bounds of the object should == the bounds of the clip object - if they fully overlap (as they do in these fixtures).""" - clipped = clip(multi_poly_gdf, single_rectangle_gdf, keep_geom_type=True) - assert np.array_equal(clipped.total_bounds, single_rectangle_gdf.total_bounds) - # Assert returned data is a not geometry collection - assert (clipped.geom_type == "Polygon").any() - - -def test_clip_single_multipoly_no_extra_geoms( - buffered_locations, larger_single_rectangle_gdf -): - """When clipping a multi-polygon feature, no additional geom types - should be returned.""" - multi = buffered_locations.dissolve(by="type").reset_index() - clipped = clip(multi, larger_single_rectangle_gdf) - assert clipped.geom_type[0] == "Polygon" - - -def test_clip_multiline(multi_line, single_rectangle_gdf): - """Test that clipping a multiline feature with a poly returns expected output.""" - clipped = clip(multi_line, single_rectangle_gdf) - assert clipped.geom_type[0] == "MultiLineString" - - -def test_clip_multipoint(single_rectangle_gdf, multi_point): - """Clipping a multipoint feature with a polygon works as expected. - should return a geodataframe with a single multi point feature""" - clipped = clip(multi_point, single_rectangle_gdf) - assert clipped.geom_type[0] == "MultiPoint" - assert hasattr(clipped, "attr") - # All points should intersect the clip geom - assert len(clipped) == 2 - clipped_mutltipoint = MultiPoint( - [ - Point(2, 2), - Point(3, 4), - Point(9, 8), - ] - ) - assert clipped.iloc[0].geometry.wkt == clipped_mutltipoint.wkt - assert all(clipped.intersects(single_rectangle_gdf.unary_union)) - - -def test_clip_lines(two_line_gdf, single_rectangle_gdf): - """Test what happens when you give the clip_extent a line GDF.""" - clip_line = clip(two_line_gdf, single_rectangle_gdf) - assert len(clip_line.geometry) == 2 - - -def test_clip_with_multipolygon(buffered_locations, single_rectangle_gdf): - """Test clipping a polygon with a multipolygon.""" - multi = buffered_locations.dissolve(by="type").reset_index() - clipped = clip(single_rectangle_gdf, multi) - assert clipped.geom_type[0] == "Polygon" - - -def test_mixed_geom(mixed_gdf, single_rectangle_gdf): - """Test clipping a mixed GeoDataFrame""" - clipped = clip(mixed_gdf, single_rectangle_gdf) - assert ( - clipped.geom_type[0] == "Point" - and clipped.geom_type[1] == "Polygon" - and clipped.geom_type[2] == "LineString" - ) - - -def test_mixed_series(mixed_gdf, single_rectangle_gdf): - """Test clipping a mixed GeoSeries""" - clipped = clip(mixed_gdf.geometry, single_rectangle_gdf) - assert ( - clipped.geom_type[0] == "Point" - and clipped.geom_type[1] == "Polygon" - and clipped.geom_type[2] == "LineString" - ) - - -def test_clip_warning_no_extra_geoms(buffered_locations, single_rectangle_gdf): - """Test a user warning is provided if no new geometry types are found.""" - with pytest.warns(UserWarning): - clip(buffered_locations, single_rectangle_gdf, True) - warnings.warn( - "keep_geom_type was called when no extra geometry types existed.", - UserWarning, - ) +def test_warning_crs_mismatch(point_gdf, single_rectangle_gdf): + with pytest.warns(UserWarning, match="CRS mismatch between the CRS"): + clip(point_gdf, single_rectangle_gdf.to_crs(4326)) def test_clip_with_polygon(single_rectangle_gdf): @@ -361,50 +430,23 @@ def test_clip_with_polygon(single_rectangle_gdf): assert_geodataframe_equal(clipped, exp) -def test_clip_with_line_extra_geom(single_rectangle_gdf, sliver_line): - """When the output of a clipped line returns a geom collection, - and keep_geom_type is True, no geometry collections should be returned.""" - clipped = clip(sliver_line, single_rectangle_gdf, keep_geom_type=True) - assert len(clipped.geometry) == 1 - # Assert returned data is a not geometry collection - assert not (clipped.geom_type == "GeometryCollection").any() +def test_clip_with_multipolygon(buffered_locations, single_rectangle_gdf): + """Test clipping a polygon with a multipolygon.""" + multi = buffered_locations.dissolve(by="type").reset_index() + clipped = clip(single_rectangle_gdf, multi) + assert clipped.geom_type[0] == "Polygon" -def test_clip_line_keep_slivers(single_rectangle_gdf, sliver_line): - """Test the correct output if a point is returned - from a line only geometry type.""" - clipped = clip(sliver_line, single_rectangle_gdf) - # Assert returned data is a geometry collection given sliver geoms - assert "Point" == clipped.geom_type[0] - assert "LineString" == clipped.geom_type[1] - - -def test_clip_no_box_overlap(pointsoutside_nooverlap_gdf, single_rectangle_gdf): - """Test clip when intersection is empty and boxes do not overlap.""" - clipped = clip(pointsoutside_nooverlap_gdf, single_rectangle_gdf) - assert len(clipped) == 0 - - -def test_clip_box_overlap(pointsoutside_overlap_gdf, single_rectangle_gdf): - """Test clip when intersection is empty and boxes do overlap.""" - clipped = clip(pointsoutside_overlap_gdf, single_rectangle_gdf) - assert len(clipped) == 0 - - -def test_warning_extra_geoms_mixed(single_rectangle_gdf, mixed_gdf): - """Test the correct warnings are raised if keep_geom_type is - called on a mixed GDF""" - with pytest.warns(UserWarning): - clip(mixed_gdf, single_rectangle_gdf, keep_geom_type=True) - - -def test_warning_geomcoll(single_rectangle_gdf, geomcol_gdf): - """Test the correct warnings are raised if keep_geom_type is - called on a GDF with GeometryCollection""" - with pytest.warns(UserWarning): - clip(geomcol_gdf, single_rectangle_gdf, keep_geom_type=True) - - -def test_warning_crs_mismatch(point_gdf, single_rectangle_gdf): - with pytest.warns(UserWarning, match="CRS mismatch between the CRS"): - clip(point_gdf, single_rectangle_gdf.to_crs(4326)) +@pytest.mark.parametrize( + "mask_fixture_name", + mask_variants_large_rectangle, +) +def test_clip_single_multipoly_no_extra_geoms( + buffered_locations, mask_fixture_name, request +): + """When clipping a multi-polygon feature, no additional geom types + should be returned.""" + masks = request.getfixturevalue(mask_fixture_name) + multi = buffered_locations.dissolve(by="type").reset_index() + clipped = clip(multi, masks) + assert clipped.geom_type[0] == "Polygon"