Skip to content

Commit

Permalink
Bugfix/56/support multipolygon (#68)
Browse files Browse the repository at this point in the history
* 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 <zachary.deziel@gmail.com>
  • Loading branch information
alukach and zacdezgeo authored Sep 25, 2024
1 parent 056f685 commit 2de0600
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 48 deletions.
18 changes: 11 additions & 7 deletions space2stats_api/src/space2stats/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)):
Expand Down
22 changes: 20 additions & 2 deletions space2stats_api/src/space2stats/h3_utils.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions space2stats_api/src/space2stats/types.py
Original file line number Diff line number Diff line change
@@ -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]
2 changes: 1 addition & 1 deletion space2stats_api/src/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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);
"""
)

Expand Down
63 changes: 63 additions & 0 deletions space2stats_api/src/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
aoi = {
"type": "Feature",
"geometry": {
# This polygon intersects with the test data
"type": "Polygon",
"coordinates": [
[
Expand Down Expand Up @@ -34,13 +35,72 @@ 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"]:
assert field in summary
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,
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
73 changes: 38 additions & 35 deletions space2stats_api/src/tests/test_h3_utils.py
Original file line number Diff line number Diff line change
@@ -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__":
Expand Down

0 comments on commit 2de0600

Please sign in to comment.