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.
Files changed (50) hide show
  1. earthcatalog/__init__.py +164 -0
  2. earthcatalog/async_http_client.py +1006 -0
  3. earthcatalog/config.py +97 -0
  4. earthcatalog/engines/__init__.py +308 -0
  5. earthcatalog/engines/rustac_engine.py +142 -0
  6. earthcatalog/engines/stac_geoparquet_engine.py +126 -0
  7. earthcatalog/exceptions.py +471 -0
  8. earthcatalog/grid_systems.py +1114 -0
  9. earthcatalog/ingestion_pipeline.py +2281 -0
  10. earthcatalog/input_readers.py +603 -0
  11. earthcatalog/job_tracking.py +485 -0
  12. earthcatalog/pipeline.py +606 -0
  13. earthcatalog/schema_generator.py +911 -0
  14. earthcatalog/spatial_resolver.py +1207 -0
  15. earthcatalog/stac_hooks.py +754 -0
  16. earthcatalog/statistics.py +677 -0
  17. earthcatalog/storage_backends.py +548 -0
  18. earthcatalog/tests/__init__.py +1 -0
  19. earthcatalog/tests/conftest.py +76 -0
  20. earthcatalog/tests/test_all_grids.py +793 -0
  21. earthcatalog/tests/test_async_http.py +700 -0
  22. earthcatalog/tests/test_cli_and_storage.py +230 -0
  23. earthcatalog/tests/test_config.py +245 -0
  24. earthcatalog/tests/test_dask_integration.py +580 -0
  25. earthcatalog/tests/test_e2e_synthetic.py +1624 -0
  26. earthcatalog/tests/test_engines.py +272 -0
  27. earthcatalog/tests/test_exceptions.py +346 -0
  28. earthcatalog/tests/test_file_structure.py +245 -0
  29. earthcatalog/tests/test_input_readers.py +666 -0
  30. earthcatalog/tests/test_integration.py +200 -0
  31. earthcatalog/tests/test_integration_async.py +283 -0
  32. earthcatalog/tests/test_job_tracking.py +603 -0
  33. earthcatalog/tests/test_multi_file_input.py +336 -0
  34. earthcatalog/tests/test_passthrough_hook.py +196 -0
  35. earthcatalog/tests/test_pipeline.py +684 -0
  36. earthcatalog/tests/test_pipeline_components.py +665 -0
  37. earthcatalog/tests/test_schema_generator.py +506 -0
  38. earthcatalog/tests/test_spatial_resolver.py +413 -0
  39. earthcatalog/tests/test_stac_hooks.py +776 -0
  40. earthcatalog/tests/test_statistics.py +477 -0
  41. earthcatalog/tests/test_storage_backends.py +236 -0
  42. earthcatalog/tests/test_validation.py +435 -0
  43. earthcatalog/tests/test_workers.py +653 -0
  44. earthcatalog/validation.py +921 -0
  45. earthcatalog/workers.py +682 -0
  46. earthcatalog-0.2.0.dist-info/METADATA +333 -0
  47. earthcatalog-0.2.0.dist-info/RECORD +50 -0
  48. earthcatalog-0.2.0.dist-info/WHEEL +5 -0
  49. earthcatalog-0.2.0.dist-info/entry_points.txt +3 -0
  50. 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__])