Skip to content

add spatial intervals validation #178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@

## Unreleased

- 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))
- Add validation for collection's spatial intervals
- Add validation for collection's time intervals

## 3.2.0 (2025-03-20)

Expand Down
91 changes: 90 additions & 1 deletion stac_pydantic/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Provider,
StacBaseModel,
UtcDatetime,
validate_bbox,
)

if TYPE_CHECKING:
Expand All @@ -21,6 +22,94 @@
TInterval = conlist(StartEndTime, min_length=1)


def validate_bbox_interval(v: List[BBox]) -> List[BBox]: # noqa: C901
ivalues = iter(v)

# The first time interval always describes the overall spatial extent of the data.
overall_bbox = next(ivalues, None)
if not overall_bbox:
return v

assert validate_bbox(overall_bbox)

if len(overall_bbox) == 4:
xmin, ymin, xmax, ymax = overall_bbox
else:
xmin, ymin, _, xmax, ymax, _ = overall_bbox

crossing_antimeridian = xmin > xmax
for bbox in ivalues:
error_msg = ValueError(
f"`BBOX` {bbox} not fully contained in `Overall BBOX` {overall_bbox}"
)
_ = validate_bbox(bbox)

if len(bbox) == 4:
xmin_sub, ymin_sub, xmax_sub, ymax_sub = bbox
else:
xmin_sub, ymin_sub, _, xmax_sub, ymax_sub, _ = bbox

if not ((ymin_sub >= ymin) and (ymax_sub <= ymax)):
raise error_msg

sub_crossing_antimeridian = xmin_sub > xmax_sub
if not crossing_antimeridian and sub_crossing_antimeridian:
raise error_msg

elif crossing_antimeridian:
# Antimeridian
# 0 + 180 │ - 180 0
# │ [176,1,179,3] │ │
# │ │ │ │
# │ │ │ │
# │ │ │ │ [-178,1,-176,3]
# │ │ ┌─────────────────────────────────────────┐ │ │
# │ │ │ xmax_sub │ xmax_sub │ │ │
# │ │ │ ┌──────| │ ┌─────────| │ │ │
# │ └──│──► 2 │ │ │ 3 │ │ │ │
# | │ │ │ │ │ │◄────│─────────┼───────────┘
# │ │ |──────┘ │ |─────────┘ │ │
# │ │xmin_sub │ xmin_sub │ │ 0
# ──┼──────────│────────────────┼────────────────────────│─────────┼──────────
# │ │ │ xmax_sub(-179) │ │
# │ │ ┌──────────────| │ │
# │ │ │ │ │ │ │
# │ │ │ │ 1 │ │ │
# | │ │ │ │◄────────┐ │◄────────┼─────── [175,-3,-174,5]
# │ │ │ │ │ │ │ │
# │ │ |──────────────┘ │ │ │
# │ │ xmin_sub(179)│ │ │ │
# │ |──────────────────────────────────┼──────| │
# │ xmin(174) │ │ xmax(-174) │
# │ │ │ │
# │ │ │ │
# │ │ │ │
# │ │ [179,-2,-179,-1] │

# Case 1
if sub_crossing_antimeridian:
if not (xmin_sub > xmin and xmax_sub < xmax):
raise error_msg

# Case 2: if sub-sequent has lon > 0 (0 -> 180 side), then we must check if
# its min lon is < to the western lon (xmin for bbox crossing antimeridian limit)
# of the overall bbox (on 0 -> +180 side)
elif xmin_sub >= 0 and xmin_sub < xmin:
raise error_msg

# Case 3: if sub-sequent has lon < 0 (-180 -> 0 side), then we must check if
# its max lon is > to the eastern lon (xmax for bbox crossing antimeridian limit)
# of the overall bbox (on -180 -> 0 side)
elif xmin_sub <= 0 and xmax_sub > xmax:
raise error_msg

else:
if not ((xmin_sub >= xmin) and (xmax_sub <= xmax)):
raise error_msg

return v


def validate_time_interval(v: TInterval) -> TInterval: # noqa: C901
ivalues = iter(v)

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

bbox: List[BBox]
bbox: Annotated[List[BBox], AfterValidator(validate_bbox_interval)]


class TimeInterval(StacBaseModel):
Expand Down
9 changes: 8 additions & 1 deletion stac_pydantic/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,14 @@ def validate_bbox(v: Optional[BBox]) -> Optional[BBox]:
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")

if xmax < xmin and (xmax > 0 or xmin < 0):
raise ValueError(
f"Maximum longitude ({xmax}) must be greater than minimum ({xmin}) longitude when not crossing the Antimeridian"
)

if ymax < ymin:
raise ValueError("Maximum latitude must be greater than minimum latitude")
raise ValueError(
f"Maximum latitude ({ymax}) must be greater than minimum latitude ({ymin})"
)

return v
6 changes: 6 additions & 0 deletions tests/api/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ def test_search_geometry_bbox():
(100.0, 1.0, 105.0, 0.0), # ymin greater than ymax
(100.0, 0.0, 5.0, 105.0, 1.0, 4.0), # min elev greater than max elev
(-200.0, 0.0, 105.0, 1.0), # xmin is invalid WGS84
(
105.0,
0.0,
100.0,
1.0,
), # xmin greater than xmax but not crossing Antimeridian
(100.0, -100, 105.0, 1.0), # ymin is invalid WGS84
(100.0, 0.0, 190.0, 1.0), # xmax is invalid WGS84
(100.0, 0.0, 190.0, 100.0), # ymax is invalid WGS84
Expand Down
55 changes: 54 additions & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from shapely.geometry import shape

from stac_pydantic import Collection, Item, ItemProperties
from stac_pydantic.collection import TimeInterval
from stac_pydantic.collection import SpatialExtent, TimeInterval
from stac_pydantic.extensions import _fetch_and_cache_schema, validate_extensions
from stac_pydantic.links import Link, Links
from stac_pydantic.shared import MimeTypes, StacCommonMetadata
Expand Down Expand Up @@ -407,3 +407,56 @@ def test_time_intervals_invalid(interval) -> None:
def test_time_intervals_valid(interval) -> None:
"""Check Time Interval model."""
assert TimeInterval(interval=interval)


@pytest.mark.parametrize(
"bboxes",
[
# invalid Y order
[[0, 1, 1, 0]],
# invalid X order (if crossing Antimeridian limit, xmin > 0)
[[-169, 0, -170, 1]],
# invalid X order (if crossing Antimeridian limit, xmax < 0)
[[170, 0, 169, 1]],
# sub-sequent crossing Y
[[0, 0, 2, 2], [0.5, 0.5, 2.0, 2.5]],
# sub-sequent crossing X
[[0, 0, 2, 2], [0.5, 0.5, 2.5, 2.0]],
# sub-sequent crossing Antimeridian limit
[[0, 0, 2, 2], [1, 0, -179, 1]],
# both crossing Antimeridian limit but sub-sequent cross has min lat -176 > -178
[[2, 0, -178, 2], [1, 0, -176, 1]],
# sub-sequent cross Antimeridian but not the overall
[[0, 0, 2, 2], [1, 0, -176, 1]],
# overall crossing and sub-sequent not within bounds
[[2, 0, -178, 2], [-179, 0, -176, 1]],
# overall crossing and sub-sequent not within bounds
[[2, 0, -178, 2], [1, 0, 3, 1]],
],
)
def test_spatial_intervals_invalid(bboxes) -> None:
"""Check invalid Spatial Interval model."""
with pytest.raises(ValidationError):
SpatialExtent(bbox=bboxes)


@pytest.mark.parametrize(
"bboxes",
[
[[0, 0, 1, 1]],
# Same on both side
[[0, 0, 2, 2], [0, 0, 2, 2]],
[[0, 0, 2, 2], [0.5, 0.5, 1.5, 1.5]],
# crossing Antimeridian limit
[[2, 0, -178, 2]],
# Case 1: overall crossing Antimeridian, sub-sequent bbox not crossing (but within overall right part)
[[2, 0, -178, 2], [-179, 0, -178, 1]],
# Case 2: overall crossing Antimeridian, sub-sequent bbox not crossing (but within overall left part)
[[2, 0, -178, 2], [179, 0, 180, 1]],
# Case 3: overall and sub-sequent crossing Antimeridian
[[2, 0, -178, 2], [3, 0, -179, 1]],
],
)
def test_spatial_intervals_valid(bboxes) -> None:
"""Check Spatial Interval model."""
assert SpatialExtent(bbox=bboxes)