diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1d34c..d8f3698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/stac_pydantic/collection.py b/stac_pydantic/collection.py index a5f7b14..7fcf023 100644 --- a/stac_pydantic/collection.py +++ b/stac_pydantic/collection.py @@ -11,6 +11,7 @@ Provider, StacBaseModel, UtcDatetime, + validate_bbox, ) if TYPE_CHECKING: @@ -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) @@ -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): diff --git a/stac_pydantic/shared.py b/stac_pydantic/shared.py index df55bac..57d1b47 100644 --- a/stac_pydantic/shared.py +++ b/stac_pydantic/shared.py @@ -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 diff --git a/tests/api/test_search.py b/tests/api/test_search.py index adc7dfc..6bcec16 100644 --- a/tests/api/test_search.py +++ b/tests/api/test_search.py @@ -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 diff --git a/tests/test_models.py b/tests/test_models.py index 78d923d..837313b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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 @@ -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)