Skip to content

Commit 591f10c

Browse files
Merge pull request #4 from DACCS-Climate/collapse-geojsons
convert geojson
2 parents 9f80213 + 60e61cb commit 591f10c

File tree

6 files changed

+379
-37
lines changed

6 files changed

+379
-37
lines changed

marble_api/utils/geojson.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
from collections.abc import Iterable
22
from itertools import zip_longest
33

4-
from geojson_pydantic import LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon
4+
from geojson_pydantic import (
5+
Feature,
6+
FeatureCollection,
7+
GeometryCollection,
8+
LineString,
9+
MultiLineString,
10+
MultiPoint,
11+
MultiPolygon,
12+
Point,
13+
Polygon,
14+
)
515
from geojson_pydantic.types import (
616
BBox,
717
LineStringCoords,
@@ -12,7 +22,9 @@
1222
Position,
1323
)
1424

25+
# Note: STAC Geometry differs from the GeoJSON Geometry definition (GeometryCollection not included)
1526
type Geometry = LineString | MultiLineString | MultiPoint | MultiPolygon | Point | Polygon
27+
type GeoJSON = Geometry | FeatureCollection | Feature | GeometryCollection
1628
type Coordinates = (
1729
LineStringCoords | MultiLineStringCoords | MultiPointCoords | MultiPolygonCoords | PolygonCoords | Position
1830
)
@@ -33,3 +45,66 @@ def bbox_from_coordinates(coordinates: Coordinates) -> BBox:
3345
real_values = [v or 0 for v in values] # coordinates without elevation are considered to be at elevation 0
3446
min_max.append((min(real_values), max(real_values)))
3547
return [v for val in min_max for v in val]
48+
49+
50+
def _validate_geometries(geometries: list[Geometry], geojson_type: str) -> None:
51+
geometry_types = frozenset({geo.type for geo in geometries})
52+
if len(geometry_types) != 1 and geometry_types not in {
53+
frozenset(),
54+
frozenset(("Point", "MultiPoint")),
55+
frozenset(("LineString", "MultiLineString")),
56+
frozenset(("Polygon", "MultiPolygon")),
57+
}:
58+
raise ValueError(f"GeoJSON of type '{geojson_type}' is not convertable to a STAC compliant geometry.")
59+
60+
61+
def _extract_geometries(geojson: GeoJSON | None) -> list[Geometry]:
62+
"""Return all geometries present in the geojson as a flat list."""
63+
if geojson.type == "FeatureCollection":
64+
return [geo for feature in geojson.features for geo in _extract_geometries(feature.geometry) if geo]
65+
if geojson.type == "GeometryCollection":
66+
return geojson.geometries
67+
if geojson.type == "Feature":
68+
return _extract_geometries(geojson.geometry)
69+
if geojson is None:
70+
return []
71+
return [geojson]
72+
73+
74+
def validate_collapsible(geojson: GeoJSON) -> None:
75+
"""Raise a ValueError if the geojson cannot be collapsed to a STAC compatible geometry."""
76+
_validate_geometries(_extract_geometries(geojson), geojson.type)
77+
78+
79+
def collapse_geometries(geojson: GeoJSON, check: bool = True) -> Geometry | None:
80+
"""
81+
Return a single geometry that represents the same geo-spatial data as the geojson.
82+
83+
This will collapse Features, FeatureCollections, and GeometryCollections into other
84+
geometry types that represent the same points, lines, or polygons. The converted geometries
85+
are compatible with STAC.
86+
87+
If check is False, this will not validate that the geojson can be collapsed before attempting
88+
to collapse it. This may result in undefined behaviour. It is strongly recommended that you
89+
call validate_collapsible(geojson) prior to calling this function with check=False.
90+
"""
91+
geometries = _extract_geometries(geojson)
92+
if check:
93+
_validate_geometries(geometries, geojson.type)
94+
if not geometries:
95+
return None
96+
if len(geometries) == 1:
97+
return geometries[0]
98+
coordinates = []
99+
for geo in geometries:
100+
if geo.type in ("Point", "LineString", "Polygon"):
101+
coordinates.append(geo.coordinates)
102+
else:
103+
coordinates.extend(geo.coordinates)
104+
if geo.type in ("Point", "MultiPoint"):
105+
geo_type = MultiPoint
106+
elif geo.type in ("LineString", "MultiLineString"):
107+
geo_type = MultiLineString
108+
else:
109+
geo_type = MultiPolygon
110+
return geo_type(coordinates=coordinates, type=geo_type.__name__)

marble_api/versions/v1/data_request/models.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@
1818
from stac_pydantic.links import Links
1919
from typing_extensions import Annotated
2020

21-
from marble_api.utils.geojson import Geometry, bbox_from_coordinates
21+
from marble_api.utils.geojson import (
22+
GeoJSON,
23+
bbox_from_coordinates,
24+
collapse_geometries,
25+
validate_collapsible,
26+
)
2227
from marble_api.utils.models import partial_model
2328

2429
PyObjectId = Annotated[str, BeforeValidator(str)]
@@ -45,7 +50,7 @@ class DataRequest(BaseModel):
4550
title: str
4651
description: str | None = None
4752
authors: list[Author]
48-
geometry: Geometry | None
53+
geometry: GeoJSON | None
4954
temporal: Temporal
5055
links: Links
5156
path: str
@@ -62,6 +67,14 @@ def min_length_if_set(cls, value: Sized | None, info: ValidationInfo) -> Sized |
6267
assert value is None or len(value), f"{info.field_name} must be None or non-empty"
6368
return value
6469

70+
@field_validator("geometry")
71+
@classmethod
72+
def validate_geometries(cls, value: GeoJSON | None) -> dict | None:
73+
"""Check whether a GeoJSON can be collapsed to a STAC compliant geometry."""
74+
if value is not None:
75+
validate_collapsible(value)
76+
return value
77+
6578

6679
@partial_model
6780
class DataRequestUpdate(DataRequest):
@@ -91,7 +104,7 @@ def stac_item(self) -> Item:
91104
item = {
92105
"type": "Feature",
93106
"stac_version": "1.1.0",
94-
"geometry": self.geometry and self.geometry.model_dump(),
107+
"geometry": self.geometry and collapse_geometries(self.geometry, check=False).model_dump(),
95108
"stac_extensions": [], # TODO
96109
"id": self.id, # TODO
97110
"bbox": None,
@@ -110,7 +123,7 @@ def stac_item(self) -> Item:
110123
]
111124

112125
if self.geometry:
113-
item["bbox"] = item["geometry"].get("bbox") or bbox_from_coordinates(self.geometry.coordinates)
126+
item["bbox"] = item["geometry"].get("bbox") or bbox_from_coordinates(item["geometry"]["coordinates"])
114127
return item
115128

116129

test/faker_providers.py

Lines changed: 131 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
import pytest
55
from faker import Faker
66
from faker.providers import BaseProvider
7+
from geojson_pydantic import (
8+
Feature,
9+
FeatureCollection,
10+
GeometryCollection,
11+
LineString,
12+
MultiLineString,
13+
MultiPoint,
14+
MultiPolygon,
15+
Point,
16+
Polygon,
17+
)
718

819
from marble_api.versions.v1.data_request.models import DataRequest, DataRequestPublic, DataRequestUpdate
920

@@ -35,50 +46,141 @@ def _geo_base(self):
3546
return base
3647

3748
def geo_point(self, dimensions=None):
38-
return {**self._geo_base(), "type": "Point", "coordinates": self.point(dimensions)}
49+
return Point(type="Point", coordinates=self.point(dimensions), **self._geo_base())
3950

4051
def geo_multipoint(self, dimensions=None):
41-
return {
52+
return MultiPoint(
53+
type="MultiPoint",
54+
coordinates=[self.point(dimensions) for _ in range(self.generator.pyint(min_value=1, max_value=12))],
4255
**self._geo_base(),
43-
"type": "MultiPoint",
44-
"coordinates": [self.point(dimensions) for _ in range(self.generator.pyint(min_value=1, max_value=12))],
45-
}
56+
)
4657

4758
def geo_linestring(self, dimensions=None):
48-
return {**self._geo_base(), "type": "LineString", "coordinates": self.line(dimensions)}
59+
return LineString(type="LineString", coordinates=self.line(dimensions), **self._geo_base())
4960

5061
def geo_multilinestring(self, dimensions=None):
51-
return {
62+
return MultiLineString(
63+
type="MultiLineString",
64+
coordinates=[self.line(dimensions) for _ in range(self.generator.pyint(min_value=1, max_value=12))],
5265
**self._geo_base(),
53-
"type": "MultiLineString",
54-
"coordinates": [self.line(dimensions) for _ in range(self.generator.pyint(min_value=1, max_value=12))],
55-
}
66+
)
5667

5768
def geo_polygon(self, dimensions=None):
58-
return {**self._geo_base(), "type": "Polygon", "coordinates": [self.linear_ring(dimensions)]}
69+
return Polygon(type="Polygon", coordinates=[self.linear_ring(dimensions)], **self._geo_base())
5970

6071
def geo_multipolygon(self, dimensions=None):
61-
return {
62-
**self._geo_base(),
63-
"type": "MultiPolygon",
64-
"coordinates": [
72+
return MultiPolygon(
73+
type="MultiPolygon",
74+
coordinates=[
6575
[self.linear_ring(dimensions) for _ in range(self.generator.pyint(min_value=1, max_value=12))]
6676
],
67-
}
77+
**self._geo_base(),
78+
)
79+
80+
def stac_geometries(self, dimensions=None):
81+
return [
82+
self.geo_point(dimensions=dimensions),
83+
self.geo_multipoint(dimensions=dimensions),
84+
self.geo_linestring(dimensions=dimensions),
85+
self.geo_multilinestring(dimensions=dimensions),
86+
self.geo_polygon(dimensions=dimensions),
87+
self.geo_multipolygon(dimensions=dimensions),
88+
]
89+
90+
def collapsible_geometry_combos(self, dimensions=None):
91+
stac_geometries = self.stac_geometries(dimensions=dimensions)
92+
return [
93+
combo
94+
for i in range(0, len(stac_geometries), 2)
95+
for combo in ([stac_geometries[i]], [stac_geometries[i + 1]], stac_geometries[i : i + 2])
96+
]
97+
98+
def uncollapsible_geometry_combos(self, dimensions=None):
99+
stac_geometries = self.stac_geometries(dimensions=dimensions)
100+
combos = []
101+
for i in range(0, len(stac_geometries), 2):
102+
for j in range(i + 2, len(stac_geometries)):
103+
combos.append([stac_geometries[i], stac_geometries[j]])
104+
combos.append([stac_geometries[i + 1], stac_geometries[j]])
105+
return combos
106+
107+
def collapsible_geometry_collections(self, dimensions=None):
108+
collapsible_geometry_combos = self.collapsible_geometry_combos(dimensions=dimensions)
109+
return [
110+
GeometryCollection(type="GeometryCollection", geometries=geos)
111+
for geos in collapsible_geometry_combos
112+
if len(geos) > 1
113+
]
114+
115+
def uncollapsible_geometry_collections(self, dimensions=None):
116+
uncollapsible_geometry_combos = self.uncollapsible_geometry_combos(dimensions=dimensions)
117+
return [
118+
GeometryCollection(type="GeometryCollection", geometries=geos) for geos in uncollapsible_geometry_combos
119+
]
120+
121+
def collapsible_features(self, dimensions=None):
122+
stac_geometries = self.stac_geometries(dimensions=dimensions)
123+
collapsible_geometry_collections = self.collapsible_geometry_collections(dimensions=dimensions)
124+
return [
125+
Feature(type="Feature", geometry=geo, properties={})
126+
for geo in stac_geometries + collapsible_geometry_collections
127+
]
128+
129+
def uncollapsible_features(self, dimensions=None):
130+
uncollapsible_geometry_collections = self.uncollapsible_geometry_collections(dimensions=dimensions)
131+
return [Feature(type="Feature", geometry=geo, properties={}) for geo in uncollapsible_geometry_collections]
132+
133+
def collapsible_feature_collections(self, dimensions=None):
134+
collapsible_geometry_combos = self.collapsible_geometry_combos(dimensions=dimensions)
135+
collapsible_features = self.collapsible_features(dimensions=dimensions)
136+
collections = []
137+
for combo in collapsible_geometry_combos:
138+
collections.append(
139+
FeatureCollection(
140+
type="FeatureCollection",
141+
features=[Feature(type="Feature", geometry=geo, properties={}) for geo in combo],
142+
)
143+
)
144+
for feature in collapsible_features:
145+
collections.append(FeatureCollection(type="FeatureCollection", features=[feature]))
146+
return collections
147+
148+
def uncollapsible_feature_collections(self, dimensions=None):
149+
uncollapsible_geometry_combos = self.uncollapsible_geometry_combos(dimensions=dimensions)
150+
uncollapsible_features = self.uncollapsible_features(dimensions=dimensions)
151+
collections = []
152+
for combo in uncollapsible_geometry_combos:
153+
collections.append(
154+
FeatureCollection(
155+
type="FeatureCollection",
156+
features=[Feature(type="Feature", geometry=geo, properties={}) for geo in combo],
157+
)
158+
)
159+
for feature in uncollapsible_features:
160+
collections.append(FeatureCollection(type="FeatureCollection", features=[feature]))
161+
return collections
162+
163+
def collapsible_geojsons(self, dimensions=None):
164+
return (
165+
self.stac_geometries(dimensions=dimensions)
166+
+ self.collapsible_geometry_collections(dimensions=dimensions)
167+
+ self.collapsible_feature_collections(dimensions=dimensions)
168+
)
169+
170+
def uncollapsible_geojsons(self, dimensions=None):
171+
return self.uncollapsible_geometry_collections(dimensions=dimensions) + self.uncollapsible_feature_collections(
172+
dimensions=dimensions
173+
)
174+
175+
def collapsible_geojson(self, dimensions=None):
176+
if dimensions is None:
177+
dimensions = self.generator.random.choice([3, 2])
178+
return self.generator.random.choice(self.collapsible_geojsons(dimensions))
68179

69-
def geometry(self, dimensions=None):
180+
def uncollapsible_geojson(self, dimensions=None):
70181
if dimensions is None:
71-
dimensions = self.generator.random.choice([3, 2, None])
72-
return self.generator.random.choice(
73-
[
74-
self.geo_point,
75-
self.geo_multipoint,
76-
self.geo_linestring,
77-
self.geo_multilinestring,
78-
self.geo_polygon,
79-
self.geo_multipolygon,
80-
]
81-
)(dimensions)
182+
dimensions = self.generator.random.choice([3, 2])
183+
return self.generator.random.choice(self.uncollapsible_geojsons(dimensions))
82184

83185

84186
class DataRequestProvider(GeoJsonProvider):
@@ -117,7 +219,7 @@ def _data_request_inputs(self, unset=None):
117219
title=self.generator.sentence(),
118220
description=(None if self.generator.pybool(30) else self.generator.paragraph()),
119221
authors=[self.author() for _ in range(self.generator.random.randint(1, 10))],
120-
geometry=self.geometry(),
222+
geometry=self.collapsible_geojson(),
121223
temporal=self.temporal(),
122224
links=[self.link() for _ in range(self.generator.random.randint(0, 10))],
123225
path=self.generator.file_path(),

test/integration/versions/v1/data_request/test_routes.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,20 @@ async def test_valid(self, fake, async_client):
142142
bson.ObjectId(id_) # check that the id is a valid object id
143143
assert json.loads(data) == json.loads(DataRequest(**response.json()).model_dump_json())
144144

145-
async def test_invalid(self, fake, async_client):
145+
async def test_invalid_authors(self, fake, async_client):
146146
data = json.loads(fake.data_request().model_dump_json())
147147
data["authors"] = []
148148
response = await async_client.post("/v1/data-requests/", json=data)
149149
assert response.status_code == 422
150150

151+
async def test_invalid_uncollapsible_geometry(self, fake, async_client):
152+
data = {
153+
**json.loads(fake.data_request().model_dump_json()),
154+
"geometry": json.loads(fake.uncollapsible_geojson().model_dump_json()),
155+
}
156+
response = await async_client.post("/v1/data-requests/", json=data)
157+
assert response.status_code == 422
158+
151159

152160
class _TestUpdate:
153161
@pytest.fixture
@@ -205,6 +213,13 @@ async def test_invalid_bad_type(self, loaded_data, async_client):
205213
response = await async_client.patch(f"/v1/data-requests/{loaded_data['id']}", json={"title": 10})
206214
assert response.status_code == 422
207215

216+
async def test_invalid_uncollapsible_geometry(self, fake, loaded_data, async_client):
217+
response = await async_client.patch(
218+
f"/v1/data-requests/{loaded_data['id']}",
219+
json={"geometry": json.loads(fake.uncollapsible_geojson().model_dump_json())},
220+
)
221+
assert response.status_code == 422
222+
208223
async def test_bad_id(self, async_client):
209224
resp = await async_client.patch("/v1/data-requests/id-does-not-exist", json={})
210225
assert resp.status_code == 404, resp.json()

0 commit comments

Comments
 (0)