Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
87 changes: 86 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,90 @@
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
# + 180 │ - 180 0
# [176,1,179,3] │ │
# │ │ │
# │ │ │
# │ │ │ [-178,1,-176,3]
# │ ┌─────────────────────────────────────────┐ │ │
# │ │ │ │ │ │
# │ │ ┌──────┐ │ ┌─────────┐ │ │ │
# └──│──► 2 │ │ │ 1 │ │ │ │
# │ │ │ │ │ │◄────│─────┼───────────┘
# │ └──────┘ │ └─────────┘ │ │
# │ │ │ │ 0
# ────────────│────────────────┼────────────────────────│─────┼──────────
# │ │ │ │
# │ ┌──────────────┐ │ │
# │ │ │ │ │ │
# │ │ │ 3 │ │ │
# │ │ │ │◄────────┐ │◄────┼─────── [175,-3,-174,5]
# │ │ │ │ │ │ │
# │ └──────────────┘ │ │ │
# │ │ │ │ │
# └──────────────────────────────────┼──────┘ │
# │ │ │
# │ │ │
# │ │ │
# │ │ │
# │ [179,-2,-179,-1] │

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

# case 1
elif xmax_sub <= 0 and xmax_sub > xmax:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
elif xmax_sub <= 0 and xmax_sub > xmax:
elif xmax_sub > xmax or xmin_sub > xmax:

raise error_msg

# case 2
elif xmin_sub >= 0 and xmin_sub < xmin:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
elif xmin_sub >= 0 and xmin_sub < xmin:
elif xmin_sub < xmin or xmax_sub < xmin:

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 +146,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)