Skip to content

Commit caf39a5

Browse files
Merge pull request #178 from stac-utils/feature/validate-spatial-intervals
add spatial intervals validation
2 parents f5c9033 + e53e32a commit caf39a5

File tree

5 files changed

+160
-4
lines changed

5 files changed

+160
-4
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11

22
## Unreleased
33

4-
- Add datetime validation for collection's time intervals (Must follow [`RFC 3339, section 5.6.`](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6))
4+
- Add validation for collection's spatial intervals
5+
- Add validation for collection's time intervals
56

67
## 3.2.0 (2025-03-20)
78

stac_pydantic/collection.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
Provider,
1212
StacBaseModel,
1313
UtcDatetime,
14+
validate_bbox,
1415
)
1516

1617
if TYPE_CHECKING:
@@ -21,6 +22,94 @@
2122
TInterval = conlist(StartEndTime, min_length=1)
2223

2324

25+
def validate_bbox_interval(v: List[BBox]) -> List[BBox]: # noqa: C901
26+
ivalues = iter(v)
27+
28+
# The first time interval always describes the overall spatial extent of the data.
29+
overall_bbox = next(ivalues, None)
30+
if not overall_bbox:
31+
return v
32+
33+
assert validate_bbox(overall_bbox)
34+
35+
if len(overall_bbox) == 4:
36+
xmin, ymin, xmax, ymax = overall_bbox
37+
else:
38+
xmin, ymin, _, xmax, ymax, _ = overall_bbox
39+
40+
crossing_antimeridian = xmin > xmax
41+
for bbox in ivalues:
42+
error_msg = ValueError(
43+
f"`BBOX` {bbox} not fully contained in `Overall BBOX` {overall_bbox}"
44+
)
45+
_ = validate_bbox(bbox)
46+
47+
if len(bbox) == 4:
48+
xmin_sub, ymin_sub, xmax_sub, ymax_sub = bbox
49+
else:
50+
xmin_sub, ymin_sub, _, xmax_sub, ymax_sub, _ = bbox
51+
52+
if not ((ymin_sub >= ymin) and (ymax_sub <= ymax)):
53+
raise error_msg
54+
55+
sub_crossing_antimeridian = xmin_sub > xmax_sub
56+
if not crossing_antimeridian and sub_crossing_antimeridian:
57+
raise error_msg
58+
59+
elif crossing_antimeridian:
60+
# Antimeridian
61+
# 0 + 180 │ - 180 0
62+
# │ [176,1,179,3] │ │
63+
# │ │ │ │
64+
# │ │ │ │
65+
# │ │ │ │ [-178,1,-176,3]
66+
# │ │ ┌─────────────────────────────────────────┐ │ │
67+
# │ │ │ xmax_sub │ xmax_sub │ │ │
68+
# │ │ │ ┌──────| │ ┌─────────| │ │ │
69+
# │ └──│──► 2 │ │ │ 3 │ │ │ │
70+
# | │ │ │ │ │ │◄────│─────────┼───────────┘
71+
# │ │ |──────┘ │ |─────────┘ │ │
72+
# │ │xmin_sub │ xmin_sub │ │ 0
73+
# ──┼──────────│────────────────┼────────────────────────│─────────┼──────────
74+
# │ │ │ xmax_sub(-179) │ │
75+
# │ │ ┌──────────────| │ │
76+
# │ │ │ │ │ │ │
77+
# │ │ │ │ 1 │ │ │
78+
# | │ │ │ │◄────────┐ │◄────────┼─────── [175,-3,-174,5]
79+
# │ │ │ │ │ │ │ │
80+
# │ │ |──────────────┘ │ │ │
81+
# │ │ xmin_sub(179)│ │ │ │
82+
# │ |──────────────────────────────────┼──────| │
83+
# │ xmin(174) │ │ xmax(-174) │
84+
# │ │ │ │
85+
# │ │ │ │
86+
# │ │ │ │
87+
# │ │ [179,-2,-179,-1] │
88+
89+
# Case 1
90+
if sub_crossing_antimeridian:
91+
if not (xmin_sub > xmin and xmax_sub < xmax):
92+
raise error_msg
93+
94+
# Case 2: if sub-sequent has lon > 0 (0 -> 180 side), then we must check if
95+
# its min lon is < to the western lon (xmin for bbox crossing antimeridian limit)
96+
# of the overall bbox (on 0 -> +180 side)
97+
elif xmin_sub >= 0 and xmin_sub < xmin:
98+
raise error_msg
99+
100+
# Case 3: if sub-sequent has lon < 0 (-180 -> 0 side), then we must check if
101+
# its max lon is > to the eastern lon (xmax for bbox crossing antimeridian limit)
102+
# of the overall bbox (on -180 -> 0 side)
103+
elif xmin_sub <= 0 and xmax_sub > xmax:
104+
raise error_msg
105+
106+
else:
107+
if not ((xmin_sub >= xmin) and (xmax_sub <= xmax)):
108+
raise error_msg
109+
110+
return v
111+
112+
24113
def validate_time_interval(v: TInterval) -> TInterval: # noqa: C901
25114
ivalues = iter(v)
26115

@@ -61,7 +150,7 @@ class SpatialExtent(StacBaseModel):
61150
https://github.com/radiantearth/stac-spec/blob/v1.0.0/collection-spec/collection-spec.md#spatial-extent-object
62151
"""
63152

64-
bbox: List[BBox]
153+
bbox: Annotated[List[BBox], AfterValidator(validate_bbox_interval)]
65154

66155

67156
class TimeInterval(StacBaseModel):

stac_pydantic/shared.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,14 @@ def validate_bbox(v: Optional[BBox]) -> Optional[BBox]:
266266
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
267267
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")
268268

269+
if xmax < xmin and (xmax > 0 or xmin < 0):
270+
raise ValueError(
271+
f"Maximum longitude ({xmax}) must be greater than minimum ({xmin}) longitude when not crossing the Antimeridian"
272+
)
273+
269274
if ymax < ymin:
270-
raise ValueError("Maximum latitude must be greater than minimum latitude")
275+
raise ValueError(
276+
f"Maximum latitude ({ymax}) must be greater than minimum latitude ({ymin})"
277+
)
271278

272279
return v

tests/api/test_search.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ def test_search_geometry_bbox():
150150
(100.0, 1.0, 105.0, 0.0), # ymin greater than ymax
151151
(100.0, 0.0, 5.0, 105.0, 1.0, 4.0), # min elev greater than max elev
152152
(-200.0, 0.0, 105.0, 1.0), # xmin is invalid WGS84
153+
(
154+
105.0,
155+
0.0,
156+
100.0,
157+
1.0,
158+
), # xmin greater than xmax but not crossing Antimeridian
153159
(100.0, -100, 105.0, 1.0), # ymin is invalid WGS84
154160
(100.0, 0.0, 190.0, 1.0), # xmax is invalid WGS84
155161
(100.0, 0.0, 190.0, 100.0), # ymax is invalid WGS84

tests/test_models.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from shapely.geometry import shape
77

88
from stac_pydantic import Collection, Item, ItemProperties
9-
from stac_pydantic.collection import TimeInterval
9+
from stac_pydantic.collection import SpatialExtent, TimeInterval
1010
from stac_pydantic.extensions import _fetch_and_cache_schema, validate_extensions
1111
from stac_pydantic.links import Link, Links
1212
from stac_pydantic.shared import MimeTypes, StacCommonMetadata
@@ -407,3 +407,56 @@ def test_time_intervals_invalid(interval) -> None:
407407
def test_time_intervals_valid(interval) -> None:
408408
"""Check Time Interval model."""
409409
assert TimeInterval(interval=interval)
410+
411+
412+
@pytest.mark.parametrize(
413+
"bboxes",
414+
[
415+
# invalid Y order
416+
[[0, 1, 1, 0]],
417+
# invalid X order (if crossing Antimeridian limit, xmin > 0)
418+
[[-169, 0, -170, 1]],
419+
# invalid X order (if crossing Antimeridian limit, xmax < 0)
420+
[[170, 0, 169, 1]],
421+
# sub-sequent crossing Y
422+
[[0, 0, 2, 2], [0.5, 0.5, 2.0, 2.5]],
423+
# sub-sequent crossing X
424+
[[0, 0, 2, 2], [0.5, 0.5, 2.5, 2.0]],
425+
# sub-sequent crossing Antimeridian limit
426+
[[0, 0, 2, 2], [1, 0, -179, 1]],
427+
# both crossing Antimeridian limit but sub-sequent cross has min lat -176 > -178
428+
[[2, 0, -178, 2], [1, 0, -176, 1]],
429+
# sub-sequent cross Antimeridian but not the overall
430+
[[0, 0, 2, 2], [1, 0, -176, 1]],
431+
# overall crossing and sub-sequent not within bounds
432+
[[2, 0, -178, 2], [-179, 0, -176, 1]],
433+
# overall crossing and sub-sequent not within bounds
434+
[[2, 0, -178, 2], [1, 0, 3, 1]],
435+
],
436+
)
437+
def test_spatial_intervals_invalid(bboxes) -> None:
438+
"""Check invalid Spatial Interval model."""
439+
with pytest.raises(ValidationError):
440+
SpatialExtent(bbox=bboxes)
441+
442+
443+
@pytest.mark.parametrize(
444+
"bboxes",
445+
[
446+
[[0, 0, 1, 1]],
447+
# Same on both side
448+
[[0, 0, 2, 2], [0, 0, 2, 2]],
449+
[[0, 0, 2, 2], [0.5, 0.5, 1.5, 1.5]],
450+
# crossing Antimeridian limit
451+
[[2, 0, -178, 2]],
452+
# Case 1: overall crossing Antimeridian, sub-sequent bbox not crossing (but within overall right part)
453+
[[2, 0, -178, 2], [-179, 0, -178, 1]],
454+
# Case 2: overall crossing Antimeridian, sub-sequent bbox not crossing (but within overall left part)
455+
[[2, 0, -178, 2], [179, 0, 180, 1]],
456+
# Case 3: overall and sub-sequent crossing Antimeridian
457+
[[2, 0, -178, 2], [3, 0, -179, 1]],
458+
],
459+
)
460+
def test_spatial_intervals_valid(bboxes) -> None:
461+
"""Check Spatial Interval model."""
462+
assert SpatialExtent(bbox=bboxes)

0 commit comments

Comments
 (0)