From 2de0600ed2e12d1296000e96040e2d400b938edd Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 25 Sep 2024 12:25:02 -0700 Subject: [PATCH] Bugfix/56/support multipolygon (#68) * Add failing test * Handle multipolygons closes #56 * Add test on h3_utils.py for multipolygons * In progress: exand tests * Use real hex_ids in tests * Properly handle bad fields * Pre-commit * Rm test failure --------- Co-authored-by: Zachary Deziel --- space2stats_api/src/space2stats/api/app.py | 18 +++-- space2stats_api/src/space2stats/h3_utils.py | 22 ++++++- space2stats_api/src/space2stats/types.py | 6 +- space2stats_api/src/tests/conftest.py | 2 +- space2stats_api/src/tests/test_api.py | 63 ++++++++++++++++++ space2stats_api/src/tests/test_h3_utils.py | 73 +++++++++++---------- 6 files changed, 136 insertions(+), 48 deletions(-) diff --git a/space2stats_api/src/space2stats/api/app.py b/space2stats_api/src/space2stats/api/app.py index aeeb422..7511ea6 100644 --- a/space2stats_api/src/space2stats/api/app.py +++ b/space2stats_api/src/space2stats/api/app.py @@ -2,8 +2,9 @@ from typing import Any, Dict, List, Optional import boto3 +import psycopg as pg from asgi_s3_response_middleware import S3ResponseMiddleware -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import ORJSONResponse from starlette.requests import Request @@ -55,12 +56,15 @@ def stats_table(request: Request): @app.post("/summary", response_model=List[Dict[str, Any]]) def get_summary(body: SummaryRequest, table: StatsTable = Depends(stats_table)): - return table.summaries( - body.aoi, - body.spatial_join_method, - body.fields, - body.geometry, - ) + try: + return table.summaries( + body.aoi, + body.spatial_join_method, + body.fields, + body.geometry, + ) + except pg.errors.UndefinedColumn as e: + raise HTTPException(status_code=400, detail=e.diag.message_primary) from e @app.get("/fields", response_model=List[str]) def fields(table: StatsTable = Depends(stats_table)): diff --git a/space2stats_api/src/space2stats/h3_utils.py b/space2stats_api/src/space2stats/h3_utils.py index 7362b80..639d27b 100644 --- a/space2stats_api/src/space2stats/h3_utils.py +++ b/space2stats_api/src/space2stats/h3_utils.py @@ -1,7 +1,8 @@ +from itertools import chain from typing import Any, Dict, List, Optional import h3 -from shapely.geometry import Point, Polygon, mapping, shape +from shapely.geometry import MultiPolygon, Point, Polygon, mapping, shape def generate_h3_ids( @@ -25,7 +26,24 @@ def generate_h3_ids( aoi_shape = shape(aoi_geojson) # Generate H3 hexagons covering the AOI - h3_ids = h3.polyfill(aoi_geojson, resolution, geo_json_conformant=True) + geoms = ( + [mapping(geom) for geom in aoi_shape.geoms] + if isinstance(aoi_shape, MultiPolygon) + else [aoi_geojson] + ) + h3_ids = list( + # Use set to remove duplicates + set( + # Treat list of sets as single iterable + chain( + *[ + # Generate H3 hexagons for each geometry + h3.polyfill(geom, resolution, geo_json_conformant=True) + for geom in geoms + ] + ) + ) + ) # Filter hexagons based on spatial join method # Touches method returns plain h3_ids diff --git a/space2stats_api/src/space2stats/types.py b/space2stats_api/src/space2stats/types.py index a358eb2..3636a56 100644 --- a/space2stats_api/src/space2stats/types.py +++ b/space2stats_api/src/space2stats/types.py @@ -1,6 +1,6 @@ -from typing import Dict +from typing import Dict, Union -from geojson_pydantic import Feature, Polygon +from geojson_pydantic import Feature, MultiPolygon, Polygon from typing_extensions import TypeAlias -AoiModel: TypeAlias = Feature[Polygon, Dict] +AoiModel: TypeAlias = Feature[Union[Polygon, MultiPolygon], Dict] diff --git a/space2stats_api/src/tests/conftest.py b/space2stats_api/src/tests/conftest.py index c5fdfb7..d84cfa9 100644 --- a/space2stats_api/src/tests/conftest.py +++ b/space2stats_api/src/tests/conftest.py @@ -72,7 +72,7 @@ def database(postgresql_proc): cur.execute( """ INSERT INTO space2stats (hex_id, sum_pop_2020, sum_pop_f_10_2020) - VALUES ('hex_1', 100, 200), ('hex_2', 150, 250); + VALUES ('862a1070fffffff', 100, 200), ('862a10767ffffff', 150, 250); """ ) diff --git a/space2stats_api/src/tests/test_api.py b/space2stats_api/src/tests/test_api.py index 07568b0..d636fc9 100644 --- a/space2stats_api/src/tests/test_api.py +++ b/space2stats_api/src/tests/test_api.py @@ -1,6 +1,7 @@ aoi = { "type": "Feature", "geometry": { + # This polygon intersects with the test data "type": "Polygon", "coordinates": [ [ @@ -34,6 +35,7 @@ def test_get_summary(client): response_json = response.json() assert isinstance(response_json, list) + assert len(response_json) > 0, "Test query failed to return any summaries" for summary in response_json: assert "hex_id" in summary for field in request_payload["fields"]: @@ -41,6 +43,64 @@ def test_get_summary(client): assert len(summary) == len(request_payload["fields"]) + 1 +def test_bad_fields_validated(client): + request_payload = { + "aoi": aoi, + "spatial_join_method": "touches", + "fields": ["sum_pop_2020", "sum_pop_f_10_2020", "a_non_existent_field"], + } + + response = client.post("/summary", json=request_payload) + assert response.status_code == 400 + assert response.json() == {"error": 'column "a_non_existent_field" does not exist'} + + +def test_get_summary_with_geometry_multipolygon(client): + request_payload = { + "aoi": { + **aoi, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + # Ensure at least one multipolygon interacts with test data + aoi["geometry"]["coordinates"], + [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ], + [ + [100.2, 0.2], + [100.8, 0.2], + [100.8, 0.8], + [100.2, 0.8], + [100.2, 0.2], + ], + ], + ], + }, + }, + "spatial_join_method": "touches", + "fields": ["sum_pop_2020", "sum_pop_f_10_2020"], + "geometry": "polygon", + } + + response = client.post("/summary", json=request_payload) + assert response.status_code == 200 + response_json = response.json() + assert len(response_json) > 0, "Test query failed to return any summaries" + assert isinstance(response_json, list) + + for summary in response_json: + assert "hex_id" in summary + assert "geometry" in summary + assert summary["geometry"]["type"] == "Polygon" + assert len(summary) == len(request_payload["fields"]) + 2 + + def test_get_summary_with_geometry_polygon(client): request_payload = { "aoi": aoi, @@ -52,6 +112,7 @@ def test_get_summary_with_geometry_polygon(client): response = client.post("/summary", json=request_payload) assert response.status_code == 200 response_json = response.json() + assert len(response_json) > 0, "Test query failed to return any summaries" assert isinstance(response_json, list) for summary in response_json: @@ -72,6 +133,7 @@ def test_get_summary_with_geometry_point(client): response = client.post("/summary", json=request_payload) assert response.status_code == 200 response_json = response.json() + assert len(response_json) > 0, "Test query failed to return any summaries" assert isinstance(response_json, list) for summary in response_json: @@ -85,6 +147,7 @@ def test_get_fields(client): response = client.get("/fields") assert response.status_code == 200 response_json = response.json() + assert len(response_json) > 0, "Test query failed to return any summaries" expected_fields = ["sum_pop_2020", "sum_pop_f_10_2020"] for field in expected_fields: diff --git a/space2stats_api/src/tests/test_h3_utils.py b/space2stats_api/src/tests/test_h3_utils.py index 04881d9..50d03eb 100644 --- a/space2stats_api/src/tests/test_h3_utils.py +++ b/space2stats_api/src/tests/test_h3_utils.py @@ -1,66 +1,69 @@ import pytest -from shapely.geometry import Polygon, mapping +from shapely.geometry import MultiPolygon, Polygon, mapping from space2stats.h3_utils import generate_h3_geometries, generate_h3_ids -polygon_coords = [ +polygon_coords_1 = [ [-74.3, 40.5], - [-73.7, 40.5], - [-73.7, 40.9], - [-74.3, 40.9], + [-74.2, 40.5], + [-74.2, 40.6], + [-74.3, 40.6], [-74.3, 40.5], ] -polygon = Polygon(polygon_coords) -aoi_geojson = mapping(polygon) -resolution = 6 +# Coordinates for another polygon in a different, non-overlapping area +polygon_coords_2 = [ + [-74.1, 40.7], + [-74.0, 40.7], + [-74.0, 40.8], + [-74.1, 40.8], + [-74.1, 40.7], +] -def test_generate_h3_ids_within(): - h3_ids = generate_h3_ids(aoi_geojson, resolution, "within") - print(f"Test 'within' - Generated H3 IDs: {h3_ids}") - assert len(h3_ids) > 0, "Expected at least one H3 ID" +# Create a MultiPolygon object +multi_polygon = MultiPolygon([Polygon(polygon_coords_1), Polygon(polygon_coords_2)]) +aoi_geojson_multi = mapping(multi_polygon) +resolution = 6 -def test_generate_h3_ids_touches(): - h3_ids = generate_h3_ids(aoi_geojson, resolution, "touches") - print(f"Test 'touches' - Generated H3 IDs: {h3_ids}") - assert len(h3_ids) > 0, "Expected at least one H3 ID" +def test_generate_h3_ids_within_multipolygon(): + h3_ids = generate_h3_ids(aoi_geojson_multi, resolution, "within") + print(h3_ids) + print(f"Test 'within' MultiPolygon - Generated H3 IDs: {h3_ids}") + assert len(h3_ids) > 0, "Expected at least one H3 ID for MultiPolygon" -def test_generate_h3_ids_centroid(): - h3_ids = generate_h3_ids(aoi_geojson, resolution, "centroid") - print(f"Test 'centroid' - Generated H3 IDs: {h3_ids}") - assert len(h3_ids) > 0, "Expected at least one H3 ID for centroid" +def test_generate_h3_ids_touches_multipolygon(): + h3_ids = generate_h3_ids(aoi_geojson_multi, resolution, "touches") + print(h3_ids) + print(f"Test 'touches' MultiPolygon - Generated H3 IDs: {h3_ids}") + assert len(h3_ids) > 0, "Expected at least one H3 ID for MultiPolygon" -def test_generate_h3_ids_invalid_method(): - with pytest.raises(ValueError, match="Invalid spatial join method"): - generate_h3_ids(aoi_geojson, resolution, "invalid_method") +def test_generate_h3_ids_centroid_multipolygon(): + h3_ids = generate_h3_ids(aoi_geojson_multi, resolution, "centroid") + print(h3_ids) + print(f"Test 'centroid' MultiPolygon - Generated H3 IDs: {h3_ids}") + assert len(h3_ids) > 0, "Expected at least one H3 ID for centroid with MultiPolygon" -def test_generate_h3_geometries_polygon(): - h3_ids = generate_h3_ids(aoi_geojson, resolution, "touches") +def test_generate_h3_geometries_polygon_multipolygon(): + h3_ids = generate_h3_ids(aoi_geojson_multi, resolution, "touches") geometries = generate_h3_geometries(h3_ids, "polygon") assert len(geometries) == len( h3_ids ), "Expected the same number of geometries as H3 IDs" for geom in geometries: - assert geom["type"] == "Polygon", "Expected Polygon geometry" + assert geom["type"] == "Polygon", "Expected Polygon geometry for MultiPolygon" -def test_generate_h3_geometries_point(): - h3_ids = generate_h3_ids(aoi_geojson, resolution, "touches") +def test_generate_h3_geometries_point_multipolygon(): + h3_ids = generate_h3_ids(aoi_geojson_multi, resolution, "touches") geometries = generate_h3_geometries(h3_ids, "point") assert len(geometries) == len( h3_ids ), "Expected the same number of geometries as H3 IDs" for geom in geometries: - assert geom["type"] == "Point", "Expected Point geometry" - - -def test_generate_h3_geometries_invalid_type(): - h3_ids = generate_h3_ids(aoi_geojson, resolution, "touches") - with pytest.raises(ValueError, match="Invalid geometry type"): - generate_h3_geometries(h3_ids, "invalid_type") + assert geom["type"] == "Point", "Expected Point geometry for MultiPolygon" if __name__ == "__main__":