Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
86 changes: 85 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,89 @@
TInterval = conlist(StartEndTime, min_length=1)


def validate_bbox_interval(v: List[BBox]) -> List[BBox]:
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:
_ = 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 ValueError(
f"`BBOX` {bbox} not fully contained in `Overall BBOX` {overall_bbox}"
)

sub_crossing_antimeridian = xmin_sub > xmax_sub
if not crossing_antimeridian and sub_crossing_antimeridian:
raise ValueError(
f"`BBOX` {bbox} not fully contained in `Overall BBOX` {overall_bbox}"
)

elif crossing_antimeridian:
# TODO:
# here we need to check for 4 cases
# 1. sub-sequent within the right part of the overall bbox
# 2. sub-sequent within the left part of the overall bbox
# 3. if sub-sequent also cross the antimeridian we need to check both part with both overall part
#
# │
# │
# [176,1,179,3] │
# │ │
# │ │ [1,1,3,3]
# │ │
# │ ┌─────────────────────────────────────────┐ │
# │ │ │ │ │
# │ │ ┌──────┐ │ ┌─────────┐ │ │
# └──│──► 2 │ │ │ 1 │ │ │
# │ │ │ │ │ │◄────│────────┘
# │ └──────┘ │ └─────────┘ │
Copy link
Member

Choose a reason for hiding this comment

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

The left case is:

xmax < xmin_sub and xmax < xmax_sub

Right case is:

xmin > xmin_xub and xmin > xmax_sub

Middle case is:

xmax < xmax_sub and xmin < xmin_sub

# │ │ │
# ────────────│────────────────┼────────────────────────│────────────────
# │ │ │
# │ ┌──────────────┐ │
# │ │ │ │ │
# │ │ │ 3 │ │
# │ │ │ │◄────────┐ │◄──────────── [5,-3,-174,5]
# │ │ │ │ │ │
# │ └──────────────┘ │ │
# │ │ │ │
# └──────────────────────────────────┼──────┘
# │ │
# │ │
# │ │
# │ │
# │ [1,-2,-179,-1]
# │

pass

else:
if not ((xmin_sub >= xmin) and (xmax_sub <= xmax)):
raise ValueError(
f"`BBOX` {bbox} not fully contained in `Overall BBOX` {overall_bbox}"
)

return v


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

Expand Down Expand Up @@ -61,7 +145,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:
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
47 changes: 46 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,48 @@ 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]],
# 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]],
],
)
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]],
# overall crossing Antimeridian, sub-sequent bbox not crossing (but within overall right part)
[[2, 0, -178, 2], [0, 0, 1, 1]],
# overall crossing Antimeridian, sub-sequent bbox not crossing (but within overall left part)
[[2, 0, -178, 2], [179, 0, 180, 1]],
# overall and sub-sequent crossing Antimeridian
[[2, 0, -178, 2], [1, 0, -179, 1]],
],
)
def test_spatial_intervals_valid(bboxes) -> None:
"""Check Spatial Interval model."""
assert SpatialExtent(bbox=bboxes)
Loading