earthcatalog 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- earthcatalog/__init__.py +164 -0
- earthcatalog/async_http_client.py +1006 -0
- earthcatalog/config.py +97 -0
- earthcatalog/engines/__init__.py +308 -0
- earthcatalog/engines/rustac_engine.py +142 -0
- earthcatalog/engines/stac_geoparquet_engine.py +126 -0
- earthcatalog/exceptions.py +471 -0
- earthcatalog/grid_systems.py +1114 -0
- earthcatalog/ingestion_pipeline.py +2281 -0
- earthcatalog/input_readers.py +603 -0
- earthcatalog/job_tracking.py +485 -0
- earthcatalog/pipeline.py +606 -0
- earthcatalog/schema_generator.py +911 -0
- earthcatalog/spatial_resolver.py +1207 -0
- earthcatalog/stac_hooks.py +754 -0
- earthcatalog/statistics.py +677 -0
- earthcatalog/storage_backends.py +548 -0
- earthcatalog/tests/__init__.py +1 -0
- earthcatalog/tests/conftest.py +76 -0
- earthcatalog/tests/test_all_grids.py +793 -0
- earthcatalog/tests/test_async_http.py +700 -0
- earthcatalog/tests/test_cli_and_storage.py +230 -0
- earthcatalog/tests/test_config.py +245 -0
- earthcatalog/tests/test_dask_integration.py +580 -0
- earthcatalog/tests/test_e2e_synthetic.py +1624 -0
- earthcatalog/tests/test_engines.py +272 -0
- earthcatalog/tests/test_exceptions.py +346 -0
- earthcatalog/tests/test_file_structure.py +245 -0
- earthcatalog/tests/test_input_readers.py +666 -0
- earthcatalog/tests/test_integration.py +200 -0
- earthcatalog/tests/test_integration_async.py +283 -0
- earthcatalog/tests/test_job_tracking.py +603 -0
- earthcatalog/tests/test_multi_file_input.py +336 -0
- earthcatalog/tests/test_passthrough_hook.py +196 -0
- earthcatalog/tests/test_pipeline.py +684 -0
- earthcatalog/tests/test_pipeline_components.py +665 -0
- earthcatalog/tests/test_schema_generator.py +506 -0
- earthcatalog/tests/test_spatial_resolver.py +413 -0
- earthcatalog/tests/test_stac_hooks.py +776 -0
- earthcatalog/tests/test_statistics.py +477 -0
- earthcatalog/tests/test_storage_backends.py +236 -0
- earthcatalog/tests/test_validation.py +435 -0
- earthcatalog/tests/test_workers.py +653 -0
- earthcatalog/validation.py +921 -0
- earthcatalog/workers.py +682 -0
- earthcatalog-0.2.0.dist-info/METADATA +333 -0
- earthcatalog-0.2.0.dist-info/RECORD +50 -0
- earthcatalog-0.2.0.dist-info/WHEEL +5 -0
- earthcatalog-0.2.0.dist-info/entry_points.txt +3 -0
- earthcatalog-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
"""Base test class and utilities for all grid systems."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from earthcatalog.grid_systems import (
|
|
13
|
+
GeoJSONGridSystem,
|
|
14
|
+
GridSystem,
|
|
15
|
+
H3GridSystem,
|
|
16
|
+
ITSLiveGridSystem,
|
|
17
|
+
MGRSGridSystem,
|
|
18
|
+
S2GridSystem,
|
|
19
|
+
SimpleLatLonGrid,
|
|
20
|
+
UTMGridSystem,
|
|
21
|
+
get_grid_system,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseGridSystemTest(ABC):
|
|
26
|
+
"""Base test class for all grid systems."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def get_grid_system(self) -> GridSystem:
|
|
30
|
+
"""Return an instance of the grid system to test."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def get_test_point_in_tile(self) -> dict[str, Any]:
|
|
35
|
+
"""Return a point geometry that should intersect with a tile."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def get_test_point_outside_coverage(self) -> dict[str, Any]:
|
|
40
|
+
"""Return a point geometry outside the grid's normal coverage."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def get_test_polygon_spanning_multiple(self) -> dict[str, Any]:
|
|
45
|
+
"""Return a polygon that should span multiple tiles."""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def get_test_polygon_single_tile(self) -> dict[str, Any]:
|
|
50
|
+
"""Return a polygon contained in a single tile."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def test_grid_initialization(self):
|
|
54
|
+
"""Test grid system initialization."""
|
|
55
|
+
grid = self.get_grid_system()
|
|
56
|
+
assert grid is not None
|
|
57
|
+
assert hasattr(grid, "tiles_for_geometry")
|
|
58
|
+
assert hasattr(grid, "tiles_for_geometry_with_spanning_detection")
|
|
59
|
+
assert hasattr(grid, "get_global_partition_threshold")
|
|
60
|
+
|
|
61
|
+
def test_point_in_tile(self):
|
|
62
|
+
"""Test point geometry intersection."""
|
|
63
|
+
grid = self.get_grid_system()
|
|
64
|
+
point_geom = self.get_test_point_in_tile()
|
|
65
|
+
|
|
66
|
+
tiles = grid.tiles_for_geometry(point_geom)
|
|
67
|
+
assert len(tiles) >= 1
|
|
68
|
+
assert all(isinstance(tile, str) for tile in tiles)
|
|
69
|
+
|
|
70
|
+
def test_point_with_spanning_detection(self):
|
|
71
|
+
"""Test point geometry with spanning detection."""
|
|
72
|
+
grid = self.get_grid_system()
|
|
73
|
+
point_geom = self.get_test_point_in_tile()
|
|
74
|
+
|
|
75
|
+
tiles, is_spanning = grid.tiles_for_geometry_with_spanning_detection(point_geom)
|
|
76
|
+
assert len(tiles) >= 1
|
|
77
|
+
assert is_spanning is False # Points should never span multiple tiles
|
|
78
|
+
|
|
79
|
+
def test_polygon_spanning_multiple_tiles(self):
|
|
80
|
+
"""Test polygon spanning multiple tiles."""
|
|
81
|
+
grid = self.get_grid_system()
|
|
82
|
+
polygon_geom = self.get_test_polygon_spanning_multiple()
|
|
83
|
+
|
|
84
|
+
tiles, is_spanning = grid.tiles_for_geometry_with_spanning_detection(polygon_geom)
|
|
85
|
+
assert len(tiles) >= 1
|
|
86
|
+
# Note: Some grid systems might return single tile for small polygons
|
|
87
|
+
|
|
88
|
+
def test_polygon_single_tile(self):
|
|
89
|
+
"""Test polygon contained in single tile."""
|
|
90
|
+
grid = self.get_grid_system()
|
|
91
|
+
polygon_geom = self.get_test_polygon_single_tile()
|
|
92
|
+
|
|
93
|
+
tiles, is_spanning = grid.tiles_for_geometry_with_spanning_detection(polygon_geom)
|
|
94
|
+
assert len(tiles) >= 1
|
|
95
|
+
# Most grid systems should return single tile for small polygons
|
|
96
|
+
|
|
97
|
+
def test_global_partition_threshold(self):
|
|
98
|
+
"""Test global partition threshold method."""
|
|
99
|
+
grid = self.get_grid_system()
|
|
100
|
+
|
|
101
|
+
# Test default threshold
|
|
102
|
+
threshold = grid.get_global_partition_threshold(100)
|
|
103
|
+
assert threshold == 100
|
|
104
|
+
|
|
105
|
+
# Test with custom thresholds (should return default for most grids)
|
|
106
|
+
custom_thresholds = {"h3": {6: 10}}
|
|
107
|
+
threshold = grid.get_global_partition_threshold(50, custom_thresholds)
|
|
108
|
+
assert isinstance(threshold, int)
|
|
109
|
+
|
|
110
|
+
def test_tiles_for_geometry_consistency(self):
|
|
111
|
+
"""Test that tiles_for_geometry and tiles_for_geometry_with_spanning_detection are consistent."""
|
|
112
|
+
grid = self.get_grid_system()
|
|
113
|
+
point_geom = self.get_test_point_in_tile()
|
|
114
|
+
|
|
115
|
+
tiles_simple = grid.tiles_for_geometry(point_geom)
|
|
116
|
+
tiles_with_spanning, _ = grid.tiles_for_geometry_with_spanning_detection(point_geom)
|
|
117
|
+
|
|
118
|
+
assert set(tiles_simple) == set(tiles_with_spanning)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestH3GridSystem(BaseGridSystemTest):
|
|
122
|
+
"""Test H3 grid system."""
|
|
123
|
+
|
|
124
|
+
def get_grid_system(self) -> GridSystem:
|
|
125
|
+
return H3GridSystem(resolution=6)
|
|
126
|
+
|
|
127
|
+
def get_test_point_in_tile(self) -> dict[str, Any]:
|
|
128
|
+
return {
|
|
129
|
+
"type": "Point",
|
|
130
|
+
"coordinates": [-122.4194, 37.7749], # San Francisco
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def get_test_point_outside_coverage(self) -> dict[str, Any]:
|
|
134
|
+
return {
|
|
135
|
+
"type": "Point",
|
|
136
|
+
"coordinates": [0.0, 0.0], # Null Island
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def get_test_polygon_spanning_multiple(self) -> dict[str, Any]:
|
|
140
|
+
# Large polygon covering multiple H3 cells
|
|
141
|
+
return {
|
|
142
|
+
"type": "Polygon",
|
|
143
|
+
"coordinates": [[[-123.0, 37.0], [-121.0, 37.0], [-121.0, 38.0], [-123.0, 38.0], [-123.0, 37.0]]],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def get_test_polygon_single_tile(self) -> dict[str, Any]:
|
|
147
|
+
# Small polygon within single H3 cell
|
|
148
|
+
return {
|
|
149
|
+
"type": "Polygon",
|
|
150
|
+
"coordinates": [[[-122.42, 37.77], [-122.41, 37.77], [-122.41, 37.78], [-122.42, 37.78], [-122.42, 37.77]]],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def test_h3_specific_features(self):
|
|
154
|
+
"""Test H3-specific features."""
|
|
155
|
+
grid = self.get_grid_system()
|
|
156
|
+
assert isinstance(grid, H3GridSystem)
|
|
157
|
+
assert grid.resolution == 6
|
|
158
|
+
|
|
159
|
+
# Test that different resolutions work
|
|
160
|
+
fine_grid = H3GridSystem(resolution=8)
|
|
161
|
+
assert fine_grid.resolution == 8
|
|
162
|
+
|
|
163
|
+
def test_h3_import_error(self):
|
|
164
|
+
"""Test H3 import error handling."""
|
|
165
|
+
# Mock the import at the module level where it's used
|
|
166
|
+
with patch("earthcatalog.grid_systems.h3", None):
|
|
167
|
+
with pytest.raises(ImportError, match="h3 required"):
|
|
168
|
+
H3GridSystem(resolution=6)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestS2GridSystem(BaseGridSystemTest):
|
|
172
|
+
"""Test S2 grid system."""
|
|
173
|
+
|
|
174
|
+
def get_grid_system(self) -> GridSystem:
|
|
175
|
+
return S2GridSystem(resolution=13)
|
|
176
|
+
|
|
177
|
+
def get_test_point_in_tile(self) -> dict[str, Any]:
|
|
178
|
+
return {
|
|
179
|
+
"type": "Point",
|
|
180
|
+
"coordinates": [-122.4194, 37.7749], # San Francisco
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
def get_test_point_outside_coverage(self) -> dict[str, Any]:
|
|
184
|
+
return {
|
|
185
|
+
"type": "Point",
|
|
186
|
+
"coordinates": [0.0, 0.0], # Null Island
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def get_test_polygon_spanning_multiple(self) -> dict[str, Any]:
|
|
190
|
+
return {
|
|
191
|
+
"type": "Polygon",
|
|
192
|
+
"coordinates": [[[-123.0, 37.0], [-121.0, 37.0], [-121.0, 38.0], [-123.0, 38.0], [-123.0, 37.0]]],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def get_test_polygon_single_tile(self) -> dict[str, Any]:
|
|
196
|
+
return {
|
|
197
|
+
"type": "Polygon",
|
|
198
|
+
"coordinates": [[[-122.42, 37.77], [-122.41, 37.77], [-122.41, 37.78], [-122.42, 37.78], [-122.42, 37.77]]],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def test_s2_specific_features(self):
|
|
202
|
+
"""Test S2-specific features."""
|
|
203
|
+
grid = self.get_grid_system()
|
|
204
|
+
assert isinstance(grid, S2GridSystem)
|
|
205
|
+
assert grid.resolution == 13
|
|
206
|
+
|
|
207
|
+
# Test that different resolutions work
|
|
208
|
+
fine_grid = S2GridSystem(resolution=15)
|
|
209
|
+
assert fine_grid.resolution == 15
|
|
210
|
+
|
|
211
|
+
@pytest.mark.skipif(
|
|
212
|
+
not pytest.importorskip("s2sphere", reason="s2sphere not available"), reason="s2sphere library required"
|
|
213
|
+
)
|
|
214
|
+
def test_s2_import_error(self):
|
|
215
|
+
"""Test S2 import error handling."""
|
|
216
|
+
with patch.dict("sys.modules", {"s2sphere": None}):
|
|
217
|
+
with pytest.raises(ImportError, match="s2sphere required"):
|
|
218
|
+
S2GridSystem(resolution=13)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TestMGRSGridSystem(BaseGridSystemTest):
|
|
222
|
+
"""Test MGRS grid system."""
|
|
223
|
+
|
|
224
|
+
def get_grid_system(self) -> GridSystem:
|
|
225
|
+
return MGRSGridSystem(resolution=5)
|
|
226
|
+
|
|
227
|
+
def get_test_point_in_tile(self) -> dict[str, Any]:
|
|
228
|
+
return {
|
|
229
|
+
"type": "Point",
|
|
230
|
+
"coordinates": [-122.4194, 37.7749], # San Francisco
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def get_test_point_outside_coverage(self) -> dict[str, Any]:
|
|
234
|
+
return {
|
|
235
|
+
"type": "Point",
|
|
236
|
+
"coordinates": [0.0, 0.0], # Null Island
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
def get_test_polygon_spanning_multiple(self) -> dict[str, Any]:
|
|
240
|
+
return {
|
|
241
|
+
"type": "Polygon",
|
|
242
|
+
"coordinates": [[[-123.0, 37.0], [-121.0, 37.0], [-121.0, 38.0], [-123.0, 38.0], [-123.0, 37.0]]],
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
def get_test_polygon_single_tile(self) -> dict[str, Any]:
|
|
246
|
+
return {
|
|
247
|
+
"type": "Polygon",
|
|
248
|
+
"coordinates": [[[-122.42, 37.77], [-122.41, 37.77], [-122.41, 37.78], [-122.42, 37.78], [-122.42, 37.77]]],
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
def test_mgrs_specific_features(self):
|
|
252
|
+
"""Test MGRS-specific features."""
|
|
253
|
+
grid = self.get_grid_system()
|
|
254
|
+
assert isinstance(grid, MGRSGridSystem)
|
|
255
|
+
assert grid.resolution == 5
|
|
256
|
+
|
|
257
|
+
# Test that different valid precisions work (1-5)
|
|
258
|
+
low_precision_grid = MGRSGridSystem(resolution=1)
|
|
259
|
+
assert low_precision_grid.resolution == 1
|
|
260
|
+
|
|
261
|
+
# Test that invalid resolution raises ValueError
|
|
262
|
+
with pytest.raises(ValueError, match="MGRS resolution must be 1-5"):
|
|
263
|
+
MGRSGridSystem(resolution=6)
|
|
264
|
+
with pytest.raises(ValueError, match="MGRS resolution must be 1-5"):
|
|
265
|
+
MGRSGridSystem(resolution=0)
|
|
266
|
+
|
|
267
|
+
@pytest.mark.skipif(not pytest.importorskip("mgrs", reason="mgrs not available"), reason="mgrs library required")
|
|
268
|
+
def test_mgrs_import_error(self):
|
|
269
|
+
"""Test MGRS import error handling."""
|
|
270
|
+
with patch.dict("sys.modules", {"mgrs": None}):
|
|
271
|
+
with pytest.raises(ImportError, match="mgrs required"):
|
|
272
|
+
MGRSGridSystem(resolution=5)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class TestUTMGridSystem(BaseGridSystemTest):
|
|
276
|
+
"""Test UTM grid system."""
|
|
277
|
+
|
|
278
|
+
def get_grid_system(self) -> GridSystem:
|
|
279
|
+
return UTMGridSystem(resolution=1)
|
|
280
|
+
|
|
281
|
+
def get_test_point_in_tile(self) -> dict[str, Any]:
|
|
282
|
+
return {
|
|
283
|
+
"type": "Point",
|
|
284
|
+
"coordinates": [-122.4194, 37.7749], # San Francisco (Zone 10N)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
def get_test_point_outside_coverage(self) -> dict[str, Any]:
|
|
288
|
+
return {
|
|
289
|
+
"type": "Point",
|
|
290
|
+
"coordinates": [0.0, 0.0], # Null Island (Zone 31N)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
def get_test_polygon_spanning_multiple(self) -> dict[str, Any]:
|
|
294
|
+
# Polygon spanning multiple UTM zones
|
|
295
|
+
return {
|
|
296
|
+
"type": "Polygon",
|
|
297
|
+
"coordinates": [
|
|
298
|
+
[
|
|
299
|
+
[-12.0, 0.0], # Zone 1N
|
|
300
|
+
[12.0, 0.0], # Zone 33N
|
|
301
|
+
[12.0, 10.0],
|
|
302
|
+
[-12.0, 10.0],
|
|
303
|
+
[-12.0, 0.0],
|
|
304
|
+
]
|
|
305
|
+
],
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
def get_test_polygon_single_tile(self) -> dict[str, Any]:
|
|
309
|
+
return {
|
|
310
|
+
"type": "Polygon",
|
|
311
|
+
"coordinates": [[[-122.5, 37.5], [-122.0, 37.5], [-122.0, 38.0], [-122.5, 38.0], [-122.5, 37.5]]],
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
def test_utm_specific_features(self):
|
|
315
|
+
"""Test UTM-specific features."""
|
|
316
|
+
grid = self.get_grid_system()
|
|
317
|
+
assert isinstance(grid, UTMGridSystem)
|
|
318
|
+
assert grid.resolution == 1 # UTM doesn't really use resolution
|
|
319
|
+
|
|
320
|
+
# Test different zones
|
|
321
|
+
point_pacific = {"type": "Point", "coordinates": [-170.0, 0.0]}
|
|
322
|
+
tiles = grid.tiles_for_geometry(point_pacific)
|
|
323
|
+
assert len(tiles) == 1
|
|
324
|
+
|
|
325
|
+
point_europe = {"type": "Point", "coordinates": [10.0, 50.0]}
|
|
326
|
+
tiles = grid.tiles_for_geometry(point_europe)
|
|
327
|
+
assert len(tiles) == 1
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class TestSimpleLatLonGrid(BaseGridSystemTest):
|
|
331
|
+
"""Test Simple Lat/Lon grid system."""
|
|
332
|
+
|
|
333
|
+
def get_grid_system(self) -> GridSystem:
|
|
334
|
+
return SimpleLatLonGrid(resolution=1)
|
|
335
|
+
|
|
336
|
+
def get_test_point_in_tile(self) -> dict[str, Any]:
|
|
337
|
+
return {
|
|
338
|
+
"type": "Point",
|
|
339
|
+
"coordinates": [-122.4, 37.7], # Should be in lat+37_lon-123
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
def get_test_point_outside_coverage(self) -> dict[str, Any]:
|
|
343
|
+
return {
|
|
344
|
+
"type": "Point",
|
|
345
|
+
"coordinates": [0.0, 0.0], # Should be in lat+0_lon+0
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
def get_test_polygon_spanning_multiple(self) -> dict[str, Any]:
|
|
349
|
+
# Polygon spanning multiple degree cells
|
|
350
|
+
return {
|
|
351
|
+
"type": "Polygon",
|
|
352
|
+
"coordinates": [[[-122.5, 37.5], [-120.5, 37.5], [-120.5, 38.5], [-122.5, 38.5], [-122.5, 37.5]]],
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
def get_test_polygon_single_tile(self) -> dict[str, Any]:
|
|
356
|
+
return {
|
|
357
|
+
"type": "Polygon",
|
|
358
|
+
"coordinates": [[[-122.1, 37.1], [-122.0, 37.1], [-122.0, 37.2], [-122.1, 37.2], [-122.1, 37.1]]],
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
def test_latlon_specific_features(self):
|
|
362
|
+
"""Test Lat/Lon-specific features."""
|
|
363
|
+
grid = self.get_grid_system()
|
|
364
|
+
assert isinstance(grid, SimpleLatLonGrid)
|
|
365
|
+
assert grid.resolution == 1
|
|
366
|
+
|
|
367
|
+
# Test different resolutions
|
|
368
|
+
fine_grid = SimpleLatLonGrid(resolution=1) # Use int to match type hint
|
|
369
|
+
assert fine_grid.resolution == 1
|
|
370
|
+
|
|
371
|
+
# Test tile naming convention
|
|
372
|
+
# For a point at (-122.4, 37.7), with floor division:
|
|
373
|
+
# floor(37.7/1)*1 = 37, floor(-122.4/1)*1 = -123
|
|
374
|
+
# So the cell is lat+037_lon-123 (cell spans -123 to -122 for lon)
|
|
375
|
+
point = {"type": "Point", "coordinates": [-122.4, 37.7]}
|
|
376
|
+
tiles = grid.tiles_for_geometry(point)
|
|
377
|
+
assert len(tiles) == 1
|
|
378
|
+
assert tiles[0].startswith("lat+037_") # Padded format
|
|
379
|
+
assert tiles[0].endswith("_lon-123") # floor(-122.4) = -123
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class TestITSLiveGridSystem(BaseGridSystemTest):
|
|
383
|
+
"""Test ITS_LIVE grid system."""
|
|
384
|
+
|
|
385
|
+
def get_grid_system(self) -> GridSystem:
|
|
386
|
+
return ITSLiveGridSystem()
|
|
387
|
+
|
|
388
|
+
def get_test_point_in_tile(self) -> dict[str, Any]:
|
|
389
|
+
return {
|
|
390
|
+
"type": "Point",
|
|
391
|
+
"coordinates": [-40.0, 60.0], # Should be in N60W040
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
def get_test_point_outside_coverage(self) -> dict[str, Any]:
|
|
395
|
+
return {
|
|
396
|
+
"type": "Point",
|
|
397
|
+
"coordinates": [0.0, 0.0], # Should be in N00E000
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
def get_test_polygon_spanning_multiple(self) -> dict[str, Any]:
|
|
401
|
+
# Polygon spanning multiple 10-degree cells
|
|
402
|
+
return {
|
|
403
|
+
"type": "Polygon",
|
|
404
|
+
"coordinates": [[[-45.0, 55.0], [-25.0, 55.0], [-25.0, 65.0], [-45.0, 65.0], [-45.0, 55.0]]],
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
def get_test_polygon_single_tile(self) -> dict[str, Any]:
|
|
408
|
+
# Small polygon within single ITS_LIVE cell
|
|
409
|
+
return {
|
|
410
|
+
"type": "Polygon",
|
|
411
|
+
"coordinates": [[[-42.0, 58.0], [-38.0, 58.0], [-38.0, 62.0], [-42.0, 62.0], [-42.0, 58.0]]],
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
def test_itslive_specific_features(self):
|
|
415
|
+
"""Test ITS_LIVE-specific features."""
|
|
416
|
+
grid = self.get_grid_system()
|
|
417
|
+
assert isinstance(grid, ITSLiveGridSystem)
|
|
418
|
+
assert grid.resolution == 10
|
|
419
|
+
|
|
420
|
+
# Test specific naming convention
|
|
421
|
+
point = {"type": "Point", "coordinates": [-40.0, 60.0]}
|
|
422
|
+
tiles = grid.tiles_for_geometry(point)
|
|
423
|
+
assert len(tiles) == 1
|
|
424
|
+
assert tiles[0] == "N60W040"
|
|
425
|
+
|
|
426
|
+
# Test different quadrants
|
|
427
|
+
test_cases = [
|
|
428
|
+
({"type": "Point", "coordinates": [40.0, 60.0]}, "N60E040"), # NE quadrant
|
|
429
|
+
({"type": "Point", "coordinates": [-40.0, 60.0]}, "N60W040"), # NW quadrant
|
|
430
|
+
({"type": "Point", "coordinates": [40.0, -60.0]}, "S60E040"), # SE quadrant
|
|
431
|
+
({"type": "Point", "coordinates": [-40.0, -60.0]}, "S60W040"), # SW quadrant
|
|
432
|
+
({"type": "Point", "coordinates": [0.0, 0.0]}, "N00E000"), # Origin (center of cell)
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
for point_geom, expected_tile in test_cases:
|
|
436
|
+
tiles = grid.tiles_for_geometry(point_geom)
|
|
437
|
+
assert len(tiles) == 1
|
|
438
|
+
assert tiles[0] == expected_tile, (
|
|
439
|
+
f"Expected {expected_tile}, got {tiles[0]} for point {point_geom['coordinates']}"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def test_itslive_center_based_logic(self):
|
|
443
|
+
"""Test that ITS_LIVE uses center-based grid cells."""
|
|
444
|
+
grid = self.get_grid_system()
|
|
445
|
+
|
|
446
|
+
# Test points at different positions within a cell
|
|
447
|
+
# All these points should map to the same cell (N60W040)
|
|
448
|
+
test_points = [
|
|
449
|
+
[-40.0, 60.0], # Center
|
|
450
|
+
[-36.0, 56.0], # Bottom-right corner of cell
|
|
451
|
+
[-44.0, 64.0], # Top-left corner of cell
|
|
452
|
+
[-42.0, 58.0], # Various other points in cell
|
|
453
|
+
[-38.0, 62.0],
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
for lon, lat in test_points:
|
|
457
|
+
point = {"type": "Point", "coordinates": [lon, lat]}
|
|
458
|
+
tiles = grid.tiles_for_geometry(point)
|
|
459
|
+
assert len(tiles) == 1
|
|
460
|
+
assert tiles[0] == "N60W040", f"Point [{lon}, {lat}] should be in N60W040, got {tiles[0]}"
|
|
461
|
+
|
|
462
|
+
def test_itslive_polygon_intersection(self):
|
|
463
|
+
"""Test polygon intersection logic matches expected behavior."""
|
|
464
|
+
grid = self.get_grid_system()
|
|
465
|
+
|
|
466
|
+
# Large polygon that should intersect multiple cells
|
|
467
|
+
large_polygon = {
|
|
468
|
+
"type": "Polygon",
|
|
469
|
+
"coordinates": [[[-75.0, 70.0], [-25.0, 70.0], [-25.0, 80.0], [-75.0, 80.0], [-75.0, 70.0]]],
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
tiles, is_spanning = grid.tiles_for_geometry_with_spanning_detection(large_polygon)
|
|
473
|
+
assert len(tiles) > 1
|
|
474
|
+
assert is_spanning is True
|
|
475
|
+
|
|
476
|
+
# Verify correct tiles are included (center-based 10x10 degree logic)
|
|
477
|
+
# Polygon spans lon -75 to -25 (centers at -70, -60, -50, -40, -30)
|
|
478
|
+
# and lat 70 to 80 (centers at 70, 80)
|
|
479
|
+
expected_tiles = {
|
|
480
|
+
"N70W070",
|
|
481
|
+
"N70W060",
|
|
482
|
+
"N70W050",
|
|
483
|
+
"N70W040",
|
|
484
|
+
"N70W030",
|
|
485
|
+
"N80W070",
|
|
486
|
+
"N80W060",
|
|
487
|
+
"N80W050",
|
|
488
|
+
"N80W040",
|
|
489
|
+
"N80W030",
|
|
490
|
+
}
|
|
491
|
+
actual_tiles = set(tiles)
|
|
492
|
+
|
|
493
|
+
# Should match exactly for this clean polygon
|
|
494
|
+
assert actual_tiles == expected_tiles, (
|
|
495
|
+
f"Expected exact match. Expected: {expected_tiles}, Actual: {actual_tiles}"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class TestGeoJSONGridSystem(BaseGridSystemTest):
|
|
500
|
+
"""Test GeoJSON grid system."""
|
|
501
|
+
|
|
502
|
+
def setup_method(self):
|
|
503
|
+
"""Set up test fixtures."""
|
|
504
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
505
|
+
|
|
506
|
+
# Sample GeoJSON data for testing
|
|
507
|
+
self.sample_geojson = {
|
|
508
|
+
"type": "FeatureCollection",
|
|
509
|
+
"features": [
|
|
510
|
+
{
|
|
511
|
+
"type": "Feature",
|
|
512
|
+
"properties": {"id": "tile_001"},
|
|
513
|
+
"geometry": {
|
|
514
|
+
"type": "Polygon",
|
|
515
|
+
"coordinates": [[[-10.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0], [-10.0, -10.0]]],
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
"type": "Feature",
|
|
520
|
+
"properties": {"id": "tile_002"},
|
|
521
|
+
"geometry": {
|
|
522
|
+
"type": "Polygon",
|
|
523
|
+
"coordinates": [[[10.0, -10.0], [30.0, -10.0], [30.0, 10.0], [10.0, 10.0], [10.0, -10.0]]],
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# Create temporary GeoJSON file
|
|
530
|
+
self.geojson_path = Path(self.temp_dir) / "test_tiles.geojson"
|
|
531
|
+
with open(self.geojson_path, "w") as f:
|
|
532
|
+
json.dump(self.sample_geojson, f)
|
|
533
|
+
|
|
534
|
+
def teardown_method(self):
|
|
535
|
+
"""Clean up test fixtures."""
|
|
536
|
+
import shutil
|
|
537
|
+
|
|
538
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
539
|
+
|
|
540
|
+
def get_grid_system(self) -> GridSystem:
|
|
541
|
+
return GeoJSONGridSystem(str(self.geojson_path))
|
|
542
|
+
|
|
543
|
+
def get_test_point_in_tile(self) -> dict[str, Any]:
|
|
544
|
+
return {
|
|
545
|
+
"type": "Point",
|
|
546
|
+
"coordinates": [0.0, 0.0], # Should be in tile_001
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
def get_test_point_outside_coverage(self) -> dict[str, Any]:
|
|
550
|
+
return {
|
|
551
|
+
"type": "Point",
|
|
552
|
+
"coordinates": [100.0, 100.0], # Far outside all tiles
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
def get_test_polygon_spanning_multiple(self) -> dict[str, Any]:
|
|
556
|
+
return {"type": "Polygon", "coordinates": [[[5.0, -5.0], [25.0, -5.0], [25.0, 5.0], [5.0, 5.0], [5.0, -5.0]]]}
|
|
557
|
+
|
|
558
|
+
def get_test_polygon_single_tile(self) -> dict[str, Any]:
|
|
559
|
+
return {"type": "Polygon", "coordinates": [[[-5.0, -5.0], [5.0, -5.0], [5.0, 5.0], [-5.0, 5.0], [-5.0, -5.0]]]}
|
|
560
|
+
|
|
561
|
+
def test_geojson_specific_features(self):
|
|
562
|
+
"""Test GeoJSON-specific features."""
|
|
563
|
+
grid = self.get_grid_system()
|
|
564
|
+
assert isinstance(grid, GeoJSONGridSystem)
|
|
565
|
+
assert len(grid.tiles) == 2
|
|
566
|
+
assert "tile_001" in grid.tiles
|
|
567
|
+
assert "tile_002" in grid.tiles
|
|
568
|
+
|
|
569
|
+
# Test point in specific tile
|
|
570
|
+
point_tile1 = {"type": "Point", "coordinates": [0.0, 0.0]}
|
|
571
|
+
tiles = grid.tiles_for_geometry(point_tile1)
|
|
572
|
+
assert tiles == ["tile_001"]
|
|
573
|
+
|
|
574
|
+
point_tile2 = {"type": "Point", "coordinates": [20.0, 0.0]}
|
|
575
|
+
tiles = grid.tiles_for_geometry(point_tile2)
|
|
576
|
+
assert tiles == ["tile_002"]
|
|
577
|
+
|
|
578
|
+
def test_geojson_error_handling(self):
|
|
579
|
+
"""Test GeoJSON error handling."""
|
|
580
|
+
# Test missing file
|
|
581
|
+
with pytest.raises(FileNotFoundError):
|
|
582
|
+
GeoJSONGridSystem("/nonexistent/file.geojson")
|
|
583
|
+
|
|
584
|
+
# Test invalid JSON
|
|
585
|
+
invalid_path = Path(self.temp_dir) / "invalid.json"
|
|
586
|
+
with open(invalid_path, "w") as f:
|
|
587
|
+
f.write("invalid json")
|
|
588
|
+
|
|
589
|
+
with pytest.raises(ValueError, match="Invalid GeoJSON file"):
|
|
590
|
+
GeoJSONGridSystem(str(invalid_path))
|
|
591
|
+
|
|
592
|
+
def test_geojson_multipolygon(self):
|
|
593
|
+
"""Test MultiPolygon support."""
|
|
594
|
+
multipolygon_geojson = {
|
|
595
|
+
"type": "FeatureCollection",
|
|
596
|
+
"features": [
|
|
597
|
+
{
|
|
598
|
+
"type": "Feature",
|
|
599
|
+
"properties": {"id": "multi_tile"},
|
|
600
|
+
"geometry": {
|
|
601
|
+
"type": "MultiPolygon",
|
|
602
|
+
"coordinates": [
|
|
603
|
+
[[[-10.0, -10.0], [0.0, -10.0], [0.0, 0.0], [-10.0, 0.0], [-10.0, -10.0]]],
|
|
604
|
+
[[[10.0, 10.0], [20.0, 10.0], [20.0, 20.0], [10.0, 20.0], [10.0, 10.0]]],
|
|
605
|
+
],
|
|
606
|
+
},
|
|
607
|
+
}
|
|
608
|
+
],
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
multipolygon_path = Path(self.temp_dir) / "multi.geojson"
|
|
612
|
+
with open(multipolygon_path, "w") as f:
|
|
613
|
+
json.dump(multipolygon_geojson, f)
|
|
614
|
+
|
|
615
|
+
grid = GeoJSONGridSystem(str(multipolygon_path))
|
|
616
|
+
assert len(grid.tiles) == 2
|
|
617
|
+
assert "multi_tile_0" in grid.tiles
|
|
618
|
+
assert "multi_tile_1" in grid.tiles
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
class TestGridSystemFactory:
|
|
622
|
+
"""Test grid system factory function."""
|
|
623
|
+
|
|
624
|
+
def test_factory_all_grid_types(self):
|
|
625
|
+
"""Test creating all grid types through factory."""
|
|
626
|
+
# Test H3
|
|
627
|
+
h3_grid = get_grid_system("h3", resolution=6)
|
|
628
|
+
assert isinstance(h3_grid, H3GridSystem)
|
|
629
|
+
assert h3_grid.resolution == 6
|
|
630
|
+
|
|
631
|
+
# Test S2
|
|
632
|
+
s2_grid = get_grid_system("s2", resolution=13)
|
|
633
|
+
assert isinstance(s2_grid, S2GridSystem)
|
|
634
|
+
assert s2_grid.resolution == 13
|
|
635
|
+
|
|
636
|
+
# Test MGRS
|
|
637
|
+
mgrs_grid = get_grid_system("mgrs", resolution=5)
|
|
638
|
+
assert isinstance(mgrs_grid, MGRSGridSystem)
|
|
639
|
+
assert mgrs_grid.resolution == 5
|
|
640
|
+
|
|
641
|
+
# Test UTM
|
|
642
|
+
utm_grid = get_grid_system("utm", resolution=1)
|
|
643
|
+
assert isinstance(utm_grid, UTMGridSystem)
|
|
644
|
+
assert utm_grid.resolution == 1
|
|
645
|
+
|
|
646
|
+
# Test Lat/Lon
|
|
647
|
+
latlon_grid = get_grid_system("latlon", resolution=1)
|
|
648
|
+
assert isinstance(latlon_grid, SimpleLatLonGrid)
|
|
649
|
+
assert latlon_grid.resolution == 1
|
|
650
|
+
|
|
651
|
+
# Test ITS_LIVE
|
|
652
|
+
itslive_grid = get_grid_system("itslive")
|
|
653
|
+
assert isinstance(itslive_grid, ITSLiveGridSystem)
|
|
654
|
+
assert itslive_grid.resolution == 10
|
|
655
|
+
|
|
656
|
+
def test_factory_geojson_with_path(self):
|
|
657
|
+
"""Test creating GeoJSON grid through factory."""
|
|
658
|
+
temp_dir = tempfile.mkdtemp()
|
|
659
|
+
|
|
660
|
+
try:
|
|
661
|
+
# Create test GeoJSON
|
|
662
|
+
test_geojson = {
|
|
663
|
+
"type": "FeatureCollection",
|
|
664
|
+
"features": [
|
|
665
|
+
{
|
|
666
|
+
"type": "Feature",
|
|
667
|
+
"properties": {"id": "test_tile"},
|
|
668
|
+
"geometry": {
|
|
669
|
+
"type": "Polygon",
|
|
670
|
+
"coordinates": [
|
|
671
|
+
[[-10.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0], [-10.0, -10.0]]
|
|
672
|
+
],
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
],
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
geojson_path = Path(temp_dir) / "test.geojson"
|
|
679
|
+
with open(geojson_path, "w") as f:
|
|
680
|
+
json.dump(test_geojson, f)
|
|
681
|
+
|
|
682
|
+
grid = get_grid_system("geojson", geojson_path=str(geojson_path))
|
|
683
|
+
assert isinstance(grid, GeoJSONGridSystem)
|
|
684
|
+
assert len(grid.tiles) == 1
|
|
685
|
+
assert "test_tile" in grid.tiles
|
|
686
|
+
|
|
687
|
+
finally:
|
|
688
|
+
import shutil
|
|
689
|
+
|
|
690
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
691
|
+
|
|
692
|
+
def test_factory_geojson_missing_path(self):
|
|
693
|
+
"""Test factory error when geojson_path is missing."""
|
|
694
|
+
with pytest.raises(ValueError, match="geojson_path is required for GeoJSON grid system"):
|
|
695
|
+
get_grid_system("geojson")
|
|
696
|
+
|
|
697
|
+
def test_factory_invalid_grid_name(self):
|
|
698
|
+
"""Test factory error with invalid grid name."""
|
|
699
|
+
with pytest.raises(ValueError, match="Unknown grid system"):
|
|
700
|
+
get_grid_system("invalid_grid")
|
|
701
|
+
|
|
702
|
+
def test_factory_case_insensitive(self):
|
|
703
|
+
"""Test that grid names are case insensitive."""
|
|
704
|
+
h3_grid = get_grid_system("H3", resolution=6)
|
|
705
|
+
assert isinstance(h3_grid, H3GridSystem)
|
|
706
|
+
|
|
707
|
+
latlon_grid = get_grid_system("LATLON", resolution=1)
|
|
708
|
+
assert isinstance(latlon_grid, SimpleLatLonGrid)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class TestGridSystemComparison:
|
|
712
|
+
"""Compare behavior across different grid systems."""
|
|
713
|
+
|
|
714
|
+
def test_same_point_different_grids(self):
|
|
715
|
+
"""Test same point across different grid systems."""
|
|
716
|
+
point_geom = {
|
|
717
|
+
"type": "Point",
|
|
718
|
+
"coordinates": [-122.4194, 37.7749], # San Francisco
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
# Test grids that should work without additional dependencies
|
|
722
|
+
utm_grid = UTMGridSystem()
|
|
723
|
+
latlon_grid = SimpleLatLonGrid(resolution=1)
|
|
724
|
+
itslive_grid = ITSLiveGridSystem()
|
|
725
|
+
|
|
726
|
+
utm_tiles = utm_grid.tiles_for_geometry(point_geom)
|
|
727
|
+
latlon_tiles = latlon_grid.tiles_for_geometry(point_geom)
|
|
728
|
+
itslive_tiles = itslive_grid.tiles_for_geometry(point_geom)
|
|
729
|
+
|
|
730
|
+
assert len(utm_tiles) == 1
|
|
731
|
+
assert len(latlon_tiles) == 1
|
|
732
|
+
assert len(itslive_tiles) == 1
|
|
733
|
+
assert all(isinstance(tile, str) for tile in utm_tiles)
|
|
734
|
+
assert all(isinstance(tile, str) for tile in latlon_tiles)
|
|
735
|
+
assert all(isinstance(tile, str) for tile in itslive_tiles)
|
|
736
|
+
|
|
737
|
+
def test_polygon_spanning_detection_consistency(self):
|
|
738
|
+
"""Test spanning detection across different grid systems."""
|
|
739
|
+
# Large polygon that should span multiple tiles in most systems
|
|
740
|
+
large_polygon = {
|
|
741
|
+
"type": "Polygon",
|
|
742
|
+
"coordinates": [
|
|
743
|
+
[
|
|
744
|
+
[-125.0, 35.0],
|
|
745
|
+
[-110.0, 35.0], # Wide polygon
|
|
746
|
+
[-110.0, 45.0], # And tall
|
|
747
|
+
[-125.0, 45.0],
|
|
748
|
+
[-125.0, 35.0],
|
|
749
|
+
]
|
|
750
|
+
],
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
utm_grid = UTMGridSystem()
|
|
754
|
+
latlon_grid = SimpleLatLonGrid(resolution=2)
|
|
755
|
+
itslive_grid = ITSLiveGridSystem()
|
|
756
|
+
|
|
757
|
+
utm_tiles, utm_spanning = utm_grid.tiles_for_geometry_with_spanning_detection(large_polygon)
|
|
758
|
+
latlon_tiles, latlon_spanning = latlon_grid.tiles_for_geometry_with_spanning_detection(large_polygon)
|
|
759
|
+
itslive_tiles, itslive_spanning = itslive_grid.tiles_for_geometry_with_spanning_detection(large_polygon)
|
|
760
|
+
|
|
761
|
+
# All should detect spanning for this large polygon
|
|
762
|
+
assert len(utm_tiles) >= 1
|
|
763
|
+
assert len(latlon_tiles) >= 1
|
|
764
|
+
assert len(itslive_tiles) >= 1
|
|
765
|
+
|
|
766
|
+
# Test that spanning detection works (may be True or False depending on implementation)
|
|
767
|
+
# The important thing is that the method works and returns a boolean
|
|
768
|
+
assert isinstance(latlon_spanning, bool)
|
|
769
|
+
assert isinstance(utm_spanning, bool)
|
|
770
|
+
assert isinstance(itslive_spanning, bool)
|
|
771
|
+
|
|
772
|
+
def test_global_partition_thresholds(self):
|
|
773
|
+
"""Test global partition thresholds across grid systems."""
|
|
774
|
+
grids = [
|
|
775
|
+
UTMGridSystem(),
|
|
776
|
+
SimpleLatLonGrid(resolution=1),
|
|
777
|
+
ITSLiveGridSystem(),
|
|
778
|
+
]
|
|
779
|
+
|
|
780
|
+
custom_thresholds = {"utm": {"1": 5}, "latlon": {"1": 10}}
|
|
781
|
+
|
|
782
|
+
for grid in grids:
|
|
783
|
+
# Test default threshold
|
|
784
|
+
default_threshold = grid.get_global_partition_threshold(100)
|
|
785
|
+
assert default_threshold == 100
|
|
786
|
+
|
|
787
|
+
# Test with custom thresholds (implementation dependent)
|
|
788
|
+
custom_threshold = grid.get_global_partition_threshold(50, custom_thresholds)
|
|
789
|
+
assert isinstance(custom_threshold, int)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
if __name__ == "__main__":
|
|
793
|
+
pytest.main([__file__])
|