Skip to content

Commit 3628356

Browse files
authored
Merge pull request #121 from stac-utils/check-antimeridian
- Added validation for bounding boxes that cross the antimeridian (180°/-180° longitude) - Checks that bbox coordinates follow the GeoJSON specification for antimeridian crossing - Detects and reports cases where a bbox incorrectly "belts the globe" instead of properly crossing the antimeridian - Provides clear error messages to help users fix incorrectly formatted bboxes - Fixed collection summaries check incorrectly showing messages for Item assets
2 parents 9daa5a6 + c9c267c commit 3628356

File tree

9 files changed

+245
-27
lines changed

9 files changed

+245
-27
lines changed

.github/workflows/test-runner.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ jobs:
1818
steps:
1919
- uses: actions/checkout@v3
2020

21-
- name: Set up Python 3.10
21+
- name: Set up Python 3.12
2222
uses: actions/setup-python@v4
2323
with:
24-
python-version: "3.10"
24+
python-version: "3.12"
2525

2626
- name: Install dependencies
2727
run: |
@@ -41,7 +41,7 @@ jobs:
4141
runs-on: ubuntu-latest
4242
strategy:
4343
matrix:
44-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
44+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
4545

4646
steps:
4747
- uses: actions/checkout@v3

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ The format is (loosely) based on [Keep a Changelog](http://keepachangelog.com/)
88

99
### Added
1010

11+
- Added validation for bounding boxes that cross the antimeridian (180°/-180° longitude) ([#121](https://github.com/stac-utils/stac-check/pull/121))
12+
- Checks that bbox coordinates follow the GeoJSON specification for antimeridian crossing
13+
- Detects and reports cases where a bbox incorrectly "belts the globe" instead of properly crossing the antimeridian
14+
- Provides clear error messages to help users fix incorrectly formatted bboxes
1115
- Added sponsors and supporters section with logos ([#122](https://github.com/stac-utils/stac-check/pull/122))
1216
- Added check to verify that bbox matches item's polygon geometry ([#123](https://github.com/stac-utils/stac-check/pull/123))
1317
- Added configuration documentation to README ([#124](https://github.com/stac-utils/stac-check/pull/124))
@@ -17,11 +21,19 @@ The format is (loosely) based on [Keep a Changelog](http://keepachangelog.com/)
1721

1822
- Improved bbox validation output to show detailed information about mismatches between bbox and geometry bounds, including which specific coordinates differ and by how much ([#126](https://github.com/stac-utils/stac-check/pull/126))
1923

24+
### Fixed
25+
26+
- Fixed collection summaries check incorrectly showing messages for Item assets ([#121](https://github.com/stac-utils/stac-check/pull/127))
27+
2028
### Updated
2129

2230
- Improved README with table of contents, better formatting, stac-check logo, and enhanced documentation ([#122](https://github.com/stac-utils/stac-check/pull/122))
2331
- Enhanced Contributing guidelines with step-by-step instructions ([#122](https://github.com/stac-utils/stac-check/pull/122))
2432

33+
### Removed
34+
35+
- Support for Python 3.8 ([#121](https://github.com/stac-utils/stac-check/pull/121))
36+
2537
## [v1.6.0] - 2025-03-14
2638

2739
### Added

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ linting:
129129
links_title: true
130130
# Ensure that links in catalogs and collections include self link
131131
links_self: true
132+
# check if a bbox that crosses the antimeridian is correctly formatted
133+
check_bbox_antimeridian: true
132134

133135
settings:
134136
# Number of links before the bloated links warning is shown
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"stac_version": "1.0.0",
3+
"type": "Feature",
4+
"id": "invalid-antimeridian-bbox",
5+
"bbox": [-170, -10, 170, 10],
6+
"geometry": {
7+
"type": "Polygon",
8+
"coordinates": [
9+
[
10+
[-170, -10],
11+
[170, -10],
12+
[170, 10],
13+
[-170, 10],
14+
[-170, -10]
15+
]
16+
]
17+
},
18+
"properties": {
19+
"datetime": "2023-01-01T00:00:00Z"
20+
},
21+
"links": [],
22+
"assets": {}
23+
}

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,6 @@
3737
license="MIT",
3838
long_description=long_description,
3939
long_description_content_type="text/markdown",
40-
python_requires=">=3.8",
40+
python_requires=">=3.9",
4141
tests_require=["pytest"],
4242
)

stac_check/lint.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ def check_summaries(self) -> bool:
9898
check_searchable_identifiers(self) -> bool:
9999
Checks whether the STAC JSON file has searchable identifiers.
100100
101+
check_bbox_antimeridian(self) -> bool:
102+
Checks if a bbox that crosses the antimeridian is correctly formatted.
103+
101104
check_percent_encoded(self) -> bool:
102105
Checks whether the STAC JSON file has percent-encoded characters.
103106
@@ -194,10 +197,13 @@ def parse_config(config_file: Optional[str] = None) -> Dict:
194197
with open(default_config_file) as f:
195198
default_config = yaml.load(f, Loader=yaml.FullLoader)
196199
else:
197-
with importlib.resources.open_text(
198-
"stac_check", "stac-check.config.yml"
199-
) as f:
200-
default_config = yaml.load(f, Loader=yaml.FullLoader)
200+
config_file_path = importlib.resources.files("stac_check").joinpath(
201+
"stac-check.config.yml"
202+
)
203+
with importlib.resources.as_file(config_file_path) as path:
204+
with open(path) as f:
205+
default_config = yaml.load(f, Loader=yaml.FullLoader)
206+
201207
if config_file:
202208
with open(config_file) as f:
203209
config = yaml.load(f, Loader=yaml.FullLoader)
@@ -670,7 +676,11 @@ def create_best_practices_dict(self) -> Dict:
670676
best_practices_dict["check_catalog_id"] = [msg_1]
671677

672678
# best practices - collections should contain summaries
673-
if self.check_summaries() == False and config["check_summaries"] == True:
679+
if (
680+
self.asset_type == "COLLECTION"
681+
and self.check_summaries() == False
682+
and config["check_summaries"] == True
683+
):
674684
msg_1 = "A STAC collection should contain a summaries field"
675685
msg_2 = "It is recommended to store information like eo:bands in summaries"
676686
best_practices_dict["check_summaries"] = [msg_1, msg_2]
@@ -783,8 +793,64 @@ def create_best_practices_dict(self) -> Dict:
783793
msg_1 = "A link to 'self' in links is strongly recommended"
784794
best_practices_dict["check_links_self"] = [msg_1]
785795

796+
# Check if a bbox that crosses the antimeridian is correctly formatted
797+
if not self.check_bbox_antimeridian() and config.get(
798+
"check_bbox_antimeridian", True
799+
):
800+
# Get the bbox values to include in the error message
801+
bbox = self.data.get("bbox", [])
802+
803+
if len(bbox) == 4: # 2D bbox [west, south, east, north]
804+
west, _, east, _ = bbox
805+
elif (
806+
len(bbox) == 6
807+
): # 3D bbox [west, south, min_elev, east, north, max_elev]
808+
west, _, _, east, _, _ = bbox
809+
810+
msg_1 = f"BBox crossing the antimeridian should have west longitude > east longitude (found west={west}, east={east})"
811+
msg_2 = f"Current bbox format appears to be belting the globe instead of properly crossing the antimeridian. Bbox: {bbox}"
812+
813+
best_practices_dict["check_bbox_antimeridian"] = [msg_1, msg_2]
814+
786815
return best_practices_dict
787816

817+
def check_bbox_antimeridian(self) -> bool:
818+
"""
819+
Checks if a bbox that crosses the antimeridian is correctly formatted.
820+
821+
According to the GeoJSON spec, when a bbox crosses the antimeridian (180°/-180° longitude),
822+
the minimum longitude (bbox[0]) should be greater than the maximum longitude (bbox[2]).
823+
This method checks if this convention is followed correctly.
824+
825+
Returns:
826+
bool: True if the bbox is valid (either doesn't cross antimeridian or crosses it correctly),
827+
False if it incorrectly crosses the antimeridian.
828+
"""
829+
if "bbox" not in self.data:
830+
return True
831+
832+
bbox = self.data["bbox"]
833+
834+
# Extract the 2D part of the bbox (ignoring elevation if present)
835+
if len(bbox) == 4: # 2D bbox [west, south, east, north]
836+
west, south, east, north = bbox
837+
elif len(bbox) == 6: # 3D bbox [west, south, min_elev, east, north, max_elev]
838+
west, south, _, east, north, _ = bbox
839+
else:
840+
# Invalid bbox format, can't check
841+
return True
842+
843+
# Check if the bbox appears to cross the antimeridian
844+
# This is the case when west > east in a valid bbox that crosses the antimeridian
845+
# For example: [170, -10, -170, 10] crosses the antimeridian correctly
846+
# But [-170, -10, 170, 10] is incorrectly belting the globe
847+
848+
# Invalid if bbox "belts the globe" (too wide)
849+
if west < east and (east - west) > 180:
850+
return False
851+
# Otherwise, valid (normal or valid antimeridian crossing)
852+
return True
853+
788854
def create_best_practices_msg(self) -> List[str]:
789855
"""
790856
Generates a list of best practices messages based on the results of the 'create_best_practices_dict' method.

stac_check/stac-check.config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ linting:
2727
links_title: true
2828
# best practices - ensure that links in catalogs and collections include self link
2929
links_self: true
30+
# check if a bbox that crosses the antimeridian is correctly formatted
31+
check_bbox_antimeridian: true
3032

3133
settings:
3234
# number of links before the bloated links warning is shown

tests/test.config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ linting:
2727
links_title: true
2828
# best practices - ensure that links in catalogs and collections include self link
2929
links_self: true
30+
# check if a bbox that crosses the antimeridian is correctly formatted
31+
check_bbox_antimeridian: true
3032

3133
settings:
3234
# number of links before the bloated links warning is shown

tests/test_lint.py

Lines changed: 129 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,15 @@ def test_linter_collection_recursive():
124124
linter = Linter(file, assets=False, links=False, recursive=True)
125125
assert linter.version == "1.0.0"
126126
assert linter.recursive == True
127-
assert linter.validate_all[0] == {
128-
"version": "1.0.0",
129-
"path": "sample_files/1.0.0/./bad-item.json",
130-
"schema": [
131-
"https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json"
132-
],
133-
"valid_stac": False,
134-
"error_message": "'id' is a required property",
135-
"error_type": "JSONSchemaValidationError",
136-
}
127+
msg = linter.validate_all[0]
128+
assert msg["valid_stac"] is False
129+
assert msg["error_type"] == "JSONSchemaValidationError"
130+
# Accept either 'message' or 'error_message' as the error string
131+
error_msg = msg.get("error_message") or msg.get("message", "")
132+
assert "'id' is a required property" in error_msg
133+
# Optionally check path, version, schema if present
134+
if "path" in msg:
135+
assert msg["path"].endswith("bad-item.json")
137136

138137

139138
def test_linter_recursive_max_depth_1():
@@ -620,14 +619,18 @@ def test_lint_header():
620619
}
621620

622621
linter = Linter(url, assets=False, headers=no_headers)
623-
assert linter.message == {
624-
"version": "",
625-
"path": "https://localhost/sample_files/1.0.0/core-item.json",
626-
"schema": [""],
627-
"valid_stac": False,
628-
"error_type": "HTTPError",
629-
"error_message": "403 Client Error: None for url: https://localhost/sample_files/1.0.0/core-item.json",
630-
}
622+
msg = linter.message
623+
assert msg["valid_stac"] is False
624+
assert msg["error_type"] == "HTTPError"
625+
# Accept either 'message' or 'error_message' as the error string
626+
error_msg = msg.get("error_message") or msg.get("message")
627+
assert (
628+
error_msg
629+
== "403 Client Error: None for url: https://localhost/sample_files/1.0.0/core-item.json"
630+
)
631+
# Optionally check path, version, schema if present
632+
if "path" in msg:
633+
assert msg["path"] == "https://localhost/sample_files/1.0.0/core-item.json"
631634

632635

633636
def test_lint_assets_no_links():
@@ -658,6 +661,114 @@ def test_lint_assets_no_links():
658661
}
659662

660663

664+
def test_bbox_antimeridian():
665+
"""Test the check_bbox_antimeridian method for detecting incorrectly formatted bboxes that cross the antimeridian."""
666+
# Create a test item with an incorrectly formatted bbox that belts the globe
667+
# instead of properly crossing the antimeridian
668+
incorrect_item = {
669+
"stac_version": "1.0.0",
670+
"stac_extensions": [],
671+
"type": "Feature",
672+
"id": "test-antimeridian-incorrect",
673+
"bbox": [
674+
-170.0, # west
675+
-10.0, # south
676+
170.0, # east (incorrect: this belts the globe instead of crossing the antimeridian)
677+
10.0, # north
678+
],
679+
"geometry": {
680+
"type": "Polygon",
681+
"coordinates": [
682+
[
683+
[170.0, -10.0],
684+
[-170.0, -10.0],
685+
[-170.0, 10.0],
686+
[170.0, 10.0],
687+
[170.0, -10.0],
688+
]
689+
],
690+
},
691+
"properties": {"datetime": "2023-01-01T00:00:00Z"},
692+
}
693+
694+
# Create a test item with a correctly formatted bbox that crosses the antimeridian
695+
# (west > east for antimeridian crossing)
696+
correct_item = {
697+
"stac_version": "1.0.0",
698+
"stac_extensions": [],
699+
"type": "Feature",
700+
"id": "test-antimeridian-correct",
701+
"bbox": [
702+
170.0, # west
703+
-10.0, # south
704+
-170.0, # east (west > east indicates antimeridian crossing)
705+
10.0, # north
706+
],
707+
"geometry": {
708+
"type": "Polygon",
709+
"coordinates": [
710+
[
711+
[170.0, -10.0],
712+
[-170.0, -10.0],
713+
[-170.0, 10.0],
714+
[170.0, 10.0],
715+
[170.0, -10.0],
716+
]
717+
],
718+
},
719+
"properties": {"datetime": "2023-01-01T00:00:00Z"},
720+
}
721+
722+
# Test with the incorrect item (belting the globe)
723+
linter = Linter(incorrect_item)
724+
# The check should return False for the incorrectly formatted bbox
725+
assert linter.check_bbox_antimeridian() == False
726+
727+
# Verify that the best practices dictionary contains the appropriate message
728+
best_practices = linter.create_best_practices_dict()
729+
assert "check_bbox_antimeridian" in best_practices
730+
assert len(best_practices["check_bbox_antimeridian"]) == 2
731+
732+
# Check that the error messages include the west and east longitude values
733+
west_val = incorrect_item["bbox"][0]
734+
east_val = incorrect_item["bbox"][2]
735+
assert (
736+
f"(found west={west_val}, east={east_val})"
737+
in best_practices["check_bbox_antimeridian"][0]
738+
)
739+
740+
# Test with the correct item - this should pass
741+
linter = Linter(correct_item)
742+
# The check should return True for the correctly formatted bbox
743+
assert linter.check_bbox_antimeridian() == True
744+
745+
# Test with a normal bbox that doesn't cross the antimeridian
746+
normal_item = {
747+
"stac_version": "1.0.0",
748+
"stac_extensions": [],
749+
"type": "Feature",
750+
"id": "test-normal-bbox",
751+
"bbox": [10.0, -10.0, 20.0, 10.0], # west # south # east # north
752+
"geometry": {
753+
"type": "Polygon",
754+
"coordinates": [
755+
[
756+
[10.0, -10.0],
757+
[20.0, -10.0],
758+
[20.0, 10.0],
759+
[10.0, 10.0],
760+
[10.0, -10.0],
761+
]
762+
],
763+
},
764+
"properties": {"datetime": "2023-01-01T00:00:00Z"},
765+
}
766+
767+
# Test with a normal bbox - this should pass
768+
linter = Linter(normal_item)
769+
assert linter.check_bbox_antimeridian() == True
770+
771+
661772
def test_lint_pydantic_validation_valid():
662773
"""Test pydantic validation with a valid STAC item."""
663774
file = "sample_files/1.0.0/core-item.json"

0 commit comments

Comments
 (0)