PyGeoFetch 1.0.4__tar.gz → 1.0.6__tar.gz

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 (81) hide show
  1. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/PKG-INFO +1 -1
  2. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/__init__.py +2 -2
  3. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/main.py +2 -2
  4. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/satellite_data.py +1 -1
  5. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/copernicus.py +223 -74
  6. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/sentinel_hub.py +1 -1
  7. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/PKG-INFO +1 -1
  8. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pyproject.toml +1 -1
  9. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/LICENSE +0 -0
  10. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/README.md +0 -0
  11. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/__init__.py +0 -0
  12. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/auth_commands.py +0 -0
  13. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/config_commands.py +0 -0
  14. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/download_commands.py +0 -0
  15. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/index_commands.py +0 -0
  16. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  17. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/postprocess_commands.py +0 -0
  18. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/preprocess_commands.py +0 -0
  19. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/sar_commands.py +0 -0
  20. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/search_commands.py +0 -0
  21. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/config/__init__.py +0 -0
  22. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/config/defaults.yaml +0 -0
  23. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/config/settings.py +0 -0
  24. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/__init__.py +0 -0
  25. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/authenticator.py +0 -0
  26. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/cache_manager.py +0 -0
  27. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/downloader.py +0 -0
  28. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/engine.py +0 -0
  29. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/logging.py +0 -0
  30. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/orbits.py +0 -0
  31. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/scheduler.py +0 -0
  32. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/searcher.py +0 -0
  33. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/__init__.py +0 -0
  34. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/download_task.py +0 -0
  35. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/search_query.py +0 -0
  36. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/user_auth.py +0 -0
  37. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/__init__.py +0 -0
  38. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/base.py +0 -0
  39. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/batch.py +0 -0
  40. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/indices.py +0 -0
  41. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/pipeline.py +0 -0
  42. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/postprocessor.py +0 -0
  43. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/preprocessor.py +0 -0
  44. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/sar.py +0 -0
  45. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/__init__.py +0 -0
  46. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/airbus_oneatlas.py +0 -0
  47. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
  48. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/aws_earth.py +0 -0
  49. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/base.py +0 -0
  50. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/digitalglobe.py +0 -0
  51. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/earth_explorer_additional.py +0 -0
  52. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/element84.py +0 -0
  53. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/esa_scihub.py +0 -0
  54. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/geoserver_generic.py +0 -0
  55. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/google_earth_engine.py +0 -0
  56. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/inpe_cbers.py +0 -0
  57. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/isro_bhuvan.py +0 -0
  58. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/jaxa_earth.py +0 -0
  59. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/maxar_gbdx.py +0 -0
  60. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/nasa_earthdata.py +0 -0
  61. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  62. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/noaa_big_data.py +0 -0
  63. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/opentopography.py +0 -0
  64. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/planet.py +0 -0
  65. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/planetary_computer.py +0 -0
  66. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/terrabotics.py +0 -0
  67. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/usgs.py +0 -0
  68. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/__init__.py +0 -0
  69. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/file_utils.py +0 -0
  70. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/geo_utils.py +0 -0
  71. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/logging_setup.py +0 -0
  72. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/retry_handler.py +0 -0
  73. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/validators.py +0 -0
  74. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/SOURCES.txt +0 -0
  75. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/dependency_links.txt +0 -0
  76. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/entry_points.txt +0 -0
  77. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/not-zip-safe +0 -0
  78. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/requires.txt +0 -0
  79. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/top_level.txt +0 -0
  80. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/setup.cfg +0 -0
  81. {pygeofetch-1.0.4 → pygeofetch-1.0.6}/tests/test_pgv_integration_contract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Universal satellite data pipeline — unified access to 22+ providers
5
5
  Author: PyGeoFetch Contributors
6
6
  License: MIT License
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch v1.0.4 — Universal Satellite Data Pipeline.
2
+ PyGeoFetch v1.0.6 — Universal Satellite Data Pipeline.
3
3
 
4
4
  Unified access to 22+ satellite data providers with a consistent
5
5
  Python API and CLI. Supports authentication management, federated search,
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "1.0.4"
33
+ __version__ = "1.0.6"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch CLI v1.0.4 main entry point.
2
+ PyGeoFetch CLI v1.0.6 main entry point.
3
3
 
4
4
  Full command reference:
5
5
  pygeofetch auth — credential management
@@ -59,7 +59,7 @@ def cli(ctx: click.Context, log_level: str, log_file: str, log_format: str,
59
59
  config_file: str) -> None:
60
60
  """
61
61
  \b
62
- PyGeoFetch v1.0.4 — Universal Satellite Data Pipeline
62
+ PyGeoFetch v1.0.6 — Universal Satellite Data Pipeline
63
63
  ===========================================================
64
64
  Unified access to 22+ satellite data providers.
65
65
 
@@ -290,7 +290,7 @@ class SatelliteData(BaseModel):
290
290
  """
291
291
  item: Dict[str, Any] = {
292
292
  "type": "Feature",
293
- "stac_version": "1.0.4",
293
+ "stac_version": "1.0.6",
294
294
  "stac_extensions": self.stac_extensions,
295
295
  "id": self.id,
296
296
  "geometry": self.geometry or self._bbox_to_geometry(),
@@ -9,7 +9,7 @@ Authentication:
9
9
  Free registration at https://dataspace.copernicus.eu
10
10
 
11
11
  Supported datasets:
12
- - Sentinel-1 (SAR C-band)
12
+ - Sentinel-1 (SAR C-band, GRD + SLC)
13
13
  - Sentinel-2 (Multispectral, 10-60m)
14
14
  - Sentinel-3 (Ocean/Land colour, OLCI/SLSTR)
15
15
  - Sentinel-5P (Atmospheric composition)
@@ -32,8 +32,8 @@ Example::
32
32
  bbox=(-74.1, 40.6, -73.7, 40.9),
33
33
  start_date="2024-01-01",
34
34
  end_date="2024-03-01",
35
- satellites=["Sentinel-2"],
36
- cloud_cover_max=15,
35
+ satellites=["Sentinel-1"],
36
+ cloud_cover_max=100,
37
37
  ))
38
38
  """
39
39
 
@@ -89,9 +89,56 @@ class CopernicusProvider(AbstractBaseProvider):
89
89
  AUTH_URL = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token"
90
90
  S3_ENDPOINT = "https://eodata.dataspace.copernicus.eu"
91
91
 
92
- # Map user-friendly satellite names to CDSE collection names
92
+ # ── STAC collection ID → OData collection name + product type ─────────────
93
+ #
94
+ # ROOT CAUSE OF BUG: The original code did [c.upper() for c in query.collections]
95
+ # which turned "sentinel-1-grd" into "SENTINEL-1-GRD". The Copernicus OData API
96
+ # only accepts TOP-LEVEL collection names ("SENTINEL-1", "SENTINEL-2" etc.).
97
+ # The product type (GRD, SLC) is a separate OData attribute filter.
98
+ #
99
+ # STAC collection IDs (planetary_computer / dataspace STAC):
100
+ # "sentinel-1-grd", "sentinel-1-slc"
101
+ # OData collection names:
102
+ # "SENTINEL-1" (plus productType attribute filter "GRD" or "SLC")
103
+ #
104
+ STAC_TO_ODATA_COLLECTION: Dict[str, str] = {
105
+ # Sentinel-1 — all product types map to the same OData collection
106
+ "sentinel-1-grd": "SENTINEL-1",
107
+ "sentinel-1-slc": "SENTINEL-1",
108
+ "sentinel-1-grd-cog": "SENTINEL-1",
109
+ "sentinel-1-ocn": "SENTINEL-1",
110
+ "sentinel-1-rtc": "SENTINEL-1",
111
+ "sentinel-1": "SENTINEL-1",
112
+ # Sentinel-2
113
+ "sentinel-2-l2a": "SENTINEL-2",
114
+ "sentinel-2-l1c": "SENTINEL-2",
115
+ "sentinel-2": "SENTINEL-2",
116
+ # Sentinel-3
117
+ "sentinel-3-olci": "SENTINEL-3",
118
+ "sentinel-3-slstr": "SENTINEL-3",
119
+ "sentinel-3": "SENTINEL-3",
120
+ # Sentinel-5P
121
+ "sentinel-5p-tropomi": "SENTINEL-5P",
122
+ "sentinel-5p": "SENTINEL-5P",
123
+ # Sentinel-6
124
+ "sentinel-6": "SENTINEL-6",
125
+ }
126
+
127
+ # Infer OData productType from STAC collection ID.
128
+ # These are passed to the productType attribute filter automatically.
129
+ STAC_TO_PRODUCT_TYPE: Dict[str, Optional[str]] = {
130
+ "sentinel-1-grd": "GRD",
131
+ "sentinel-1-slc": "SLC",
132
+ "sentinel-1-grd-cog": "GRD-COG",
133
+ "sentinel-1-ocn": "OCN",
134
+ "sentinel-1-rtc": "GRD", # RTC is derived from GRD
135
+ "sentinel-2-l2a": None, # Sentinel-2 uses processingLevel, not productType
136
+ "sentinel-2-l1c": None,
137
+ }
138
+
139
+ # Map user-friendly satellite names to Copernicus OData collection codes
93
140
  SATELLITE_COLLECTION_MAP = {
94
- # Sentinel-1 — all platform variants map to the same collection
141
+ # Sentinel-1 — all platform variants map to the same OData collection
95
142
  "sentinel-1": "SENTINEL-1",
96
143
  "sentinel-1a": "SENTINEL-1",
97
144
  "sentinel-1b": "SENTINEL-1",
@@ -214,12 +261,18 @@ class CopernicusProvider(AbstractBaseProvider):
214
261
  self.require_auth()
215
262
  self._refresh_token_if_needed()
216
263
 
217
- collections = self._resolve_collections(query)
264
+ collections, inferred_product_type = self._resolve_collections(query)
218
265
  results: List[SatelliteData] = []
219
266
 
267
+ # If a product type was inferred from the STAC collection ID and the
268
+ # query doesn't already have one set, apply it as the search filter.
269
+ effective_product_type = getattr(query, "product_type", None) or inferred_product_type
270
+
220
271
  for collection in collections:
221
272
  try:
222
- items = self._search_collection(collection, query)
273
+ items = self._search_collection(
274
+ collection, query, product_type=effective_product_type
275
+ )
223
276
  results.extend(items)
224
277
  if len(results) >= query.max_results:
225
278
  break
@@ -228,51 +281,111 @@ class CopernicusProvider(AbstractBaseProvider):
228
281
 
229
282
  return results[: query.max_results]
230
283
 
231
- def _resolve_collections(self, query: SearchQuery) -> List[str]:
232
- """Map query satellite names to Copernicus collection codes."""
233
- if query.collections:
284
+ def _resolve_collections(
285
+ self, query: SearchQuery
286
+ ) -> tuple[List[str], Optional[str]]:
287
+ """
288
+ Map query satellite names or STAC collection IDs to OData collection names.
289
+
290
+ Returns:
291
+ (odata_collection_names, inferred_product_type)
292
+
293
+ ── Key fix ────────────────────────────────────────────────────────────
294
+ The original implementation did:
234
295
  return [c.upper() for c in query.collections]
235
296
 
297
+ This turned "sentinel-1-grd" into "SENTINEL-1-GRD" — which the
298
+ Copernicus OData API rejects with HTTP 400 because the valid OData
299
+ collection name is "SENTINEL-1" (top-level). The product type (GRD,
300
+ SLC) is passed separately as an OData attribute filter.
301
+
302
+ Now we map STAC collection IDs → OData collection names and also
303
+ extract the product type so it is applied as a filter automatically.
304
+ ───────────────────────────────────────────────────────────────────────
305
+ """
306
+ inferred_product_type: Optional[str] = None
307
+
308
+ if query.collections:
309
+ resolved: set[str] = set()
310
+ for c in query.collections:
311
+ c_lower = c.lower().strip()
312
+
313
+ # Try exact STAC collection ID match first
314
+ if c_lower in self.STAC_TO_ODATA_COLLECTION:
315
+ resolved.add(self.STAC_TO_ODATA_COLLECTION[c_lower])
316
+ # Infer product type from STAC collection if not set
317
+ pt = self.STAC_TO_PRODUCT_TYPE.get(c_lower)
318
+ if pt and inferred_product_type is None:
319
+ inferred_product_type = pt
320
+
321
+ # Already a valid OData collection name (user knew what they wanted)
322
+ elif c.upper() in ("SENTINEL-1", "SENTINEL-2", "SENTINEL-3",
323
+ "SENTINEL-5P", "SENTINEL-6"):
324
+ resolved.add(c.upper())
325
+
326
+ # Prefix fallback: "sentinel-1-*" → SENTINEL-1
327
+ elif c_lower.startswith("sentinel-1") or c_lower.startswith("s1"):
328
+ resolved.add("SENTINEL-1")
329
+ elif c_lower.startswith("sentinel-2") or c_lower.startswith("s2"):
330
+ resolved.add("SENTINEL-2")
331
+ elif c_lower.startswith("sentinel-3") or c_lower.startswith("s3"):
332
+ resolved.add("SENTINEL-3")
333
+ elif c_lower.startswith("sentinel-5") or c_lower.startswith("s5"):
334
+ resolved.add("SENTINEL-5P")
335
+
336
+ else:
337
+ # Unknown — pass through uppercased and log a warning
338
+ self._logger.warning(
339
+ "Unknown collection ID %r. Expected a STAC ID like "
340
+ "'sentinel-1-grd' or an OData name like 'SENTINEL-1'. "
341
+ "Passing through as-is — search may return 400.",
342
+ c
343
+ )
344
+ resolved.add(c.upper())
345
+
346
+ return list(resolved), inferred_product_type
347
+
236
348
  if not query.satellites:
237
- # Use product_type to pick a sensible default collection
349
+ # No collection, no satellite — use product_type to pick a default
238
350
  pt = getattr(query, "product_type", None) or ""
239
351
  if pt.upper() in ("SLC", "GRD", "GRD-COG", "OCN"):
240
- return ["SENTINEL-1"] # SAR product type → Sentinel-1
241
- return ["SENTINEL-2"] # default for optical
352
+ return ["SENTINEL-1"], None
353
+ return ["SENTINEL-2"], None
242
354
 
243
- collections = set()
355
+ # Resolve from satellite names
356
+ resolved_from_sat: set[str] = set()
244
357
  for sat in query.satellites:
245
358
  sat_lower = sat.lower().replace("-", "").replace("_", "").replace(" ", "")
246
359
  sat_orig = sat.lower()
247
360
  matched = False
248
- # Try exact match first, then prefix/substring
249
361
  for key, col in self.SATELLITE_COLLECTION_MAP.items():
250
362
  key_norm = key.replace("-", "").replace("_", "")
251
363
  if key_norm == sat_lower or key in sat_orig or sat_orig.startswith(key):
252
- collections.add(col)
364
+ resolved_from_sat.add(col)
253
365
  matched = True
254
366
  break
255
367
  if not matched:
256
- # Fall back: if it mentions "sentinel-1" anywhere, use SENTINEL-1
257
368
  if "sentinel-1" in sat_orig or sat_orig.startswith("s1"):
258
- collections.add("SENTINEL-1")
369
+ resolved_from_sat.add("SENTINEL-1")
259
370
  elif "sentinel-2" in sat_orig or sat_orig.startswith("s2"):
260
- collections.add("SENTINEL-2")
371
+ resolved_from_sat.add("SENTINEL-2")
261
372
  else:
262
- # Pass through as-is (user may know the collection name)
263
- collections.add(sat.upper())
373
+ resolved_from_sat.add(sat.upper())
264
374
 
265
- # If no satellite matched but product_type suggests SAR, use Sentinel-1
266
- if not collections:
375
+ if not resolved_from_sat:
267
376
  pt = getattr(query, "product_type", None) or ""
268
377
  if pt.upper() in ("SLC", "GRD", "GRD-COG", "OCN"):
269
- collections.add("SENTINEL-1")
270
- else:
271
- collections.add("SENTINEL-2")
378
+ return ["SENTINEL-1"], None
379
+ return ["SENTINEL-2"], None
272
380
 
273
- return list(collections)
381
+ return list(resolved_from_sat), inferred_product_type
274
382
 
275
- def _build_odata_filter(self, collection: str, query: SearchQuery) -> str:
383
+ def _build_odata_filter(
384
+ self,
385
+ collection: str,
386
+ query: SearchQuery,
387
+ product_type: Optional[str] = None,
388
+ ) -> str:
276
389
  """Build OData $filter string for the given collection and query."""
277
390
  filters = [f"Collection/Name eq '{collection}'"]
278
391
 
@@ -293,29 +406,56 @@ class CopernicusProvider(AbstractBaseProvider):
293
406
  )
294
407
  filters.append(f"OData.CSC.Intersects(area=geography'SRID=4326;{polygon}')")
295
408
 
296
- if (query.cloud_cover_max or 100) < 100:
297
- filters.append(f"Attributes/OData.CSC.DoubleAttribute/any(att:att/Name eq 'cloudCover' and att/OData.CSC.DoubleAttribute/Value le {query.cloud_cover_max})")
298
- if getattr(query, 'cloud_cover_min', 0) > 0:
299
- filters.append(f"Attributes/OData.CSC.DoubleAttribute/any(att:att/Name eq 'cloudCover' and att/OData.CSC.DoubleAttribute/Value ge {getattr(query, 'cloud_cover_min', 0)})")
409
+ # Cloud cover — only meaningful for optical (Sentinel-2/3)
410
+ # Sentinel-1 SAR has no cloud cover — skip filter to avoid empty results
411
+ is_sar = collection == "SENTINEL-1"
412
+ if not is_sar and (query.cloud_cover_max or 100) < 100:
413
+ filters.append(
414
+ f"Attributes/OData.CSC.DoubleAttribute/any("
415
+ f"att:att/Name eq 'cloudCover' and "
416
+ f"att/OData.CSC.DoubleAttribute/Value le {query.cloud_cover_max})"
417
+ )
418
+ if not is_sar and getattr(query, "cloud_cover_min", 0) > 0:
419
+ filters.append(
420
+ f"Attributes/OData.CSC.DoubleAttribute/any("
421
+ f"att:att/Name eq 'cloudCover' and "
422
+ f"att/OData.CSC.DoubleAttribute/Value ge {getattr(query, 'cloud_cover_min', 0)})"
423
+ )
300
424
 
301
- # Processing level filter
425
+ # Processing level filter (Sentinel-2: L1C, L2A)
302
426
  if query.processing_levels:
303
427
  level_filter = " or ".join(
304
- f"Attributes/OData.CSC.StringAttribute/any(att:att/Name eq 'processingLevel' and att/OData.CSC.StringAttribute/Value eq '{level}')"
428
+ f"Attributes/OData.CSC.StringAttribute/any("
429
+ f"att:att/Name eq 'processingLevel' and "
430
+ f"att/OData.CSC.StringAttribute/Value eq '{level}')"
305
431
  for level in query.processing_levels
306
432
  )
307
433
  filters.append(f"({level_filter})")
308
434
 
309
- # Product type filter (SLC, GRD, GRD-COG, OCN for Sentinel-1)
310
- pt = getattr(query, "product_type", None)
311
- if pt:
435
+ # Product type filter — GRD, SLC, GRD-COG, OCN (Sentinel-1)
436
+ effective_pt = product_type or getattr(query, "product_type", None)
437
+ if effective_pt:
312
438
  filters.append(
313
439
  f"Attributes/OData.CSC.StringAttribute/any("
314
440
  f"att:att/Name eq 'productType' and "
315
- f"att/OData.CSC.StringAttribute/Value eq '{pt.upper()}')"
441
+ f"att/OData.CSC.StringAttribute/Value eq '{effective_pt.upper()}')"
316
442
  )
317
443
 
318
- # Polarisation filter (VV, VH, VV+VH, HH etc.)
444
+ # ── Exclude COG (Cloud Optimized GeoTIFF) products ───────────────────
445
+ # Copernicus stores two variants of Sentinel-1 GRD:
446
+ # - Standard SAFE packages (S1A_IW_GRDH_..._SAFE)
447
+ # → downloadable via OData Products/$value with Bearer token
448
+ # - COG reformats (S1A_IW_GRDH_..._COG.SAFE)
449
+ # → stored on S3, NOT downloadable via OData → returns HTTP 422
450
+ #
451
+ # The STAC sentinel-1-grd collection includes BOTH. We must exclude
452
+ # COG products when we need direct OData download (GRD and SLC use cases).
453
+ # If the caller explicitly requested COG (product_type='GRD-COG'), skip.
454
+ if is_sar and (effective_pt or "").upper() not in ("GRD-COG", "OCN"):
455
+ filters.append("not endswith(Name, '_COG.SAFE')")
456
+ # ─────────────────────────────────────────────────────────────────────
457
+
458
+ # Polarisation filter (VV+VH, HH, etc.)
319
459
  pol = getattr(query, "polarisation", None)
320
460
  if pol:
321
461
  filters.append(
@@ -324,7 +464,7 @@ class CopernicusProvider(AbstractBaseProvider):
324
464
  f"att/OData.CSC.StringAttribute/Value eq '{pol.upper()}')"
325
465
  )
326
466
 
327
- # Pass direction filter (ascending / descending)
467
+ # Pass direction filter (ASCENDING / DESCENDING)
328
468
  pass_dir = getattr(query, "pass_direction", None)
329
469
  if pass_dir:
330
470
  filters.append(
@@ -335,15 +475,20 @@ class CopernicusProvider(AbstractBaseProvider):
335
475
 
336
476
  return " and ".join(filters)
337
477
 
338
- def _search_collection(self, collection: str, query: SearchQuery) -> List[SatelliteData]:
478
+ def _search_collection(
479
+ self,
480
+ collection: str,
481
+ query: SearchQuery,
482
+ product_type: Optional[str] = None,
483
+ ) -> List[SatelliteData]:
339
484
  """Perform OData search for a specific collection."""
340
485
  import httpx
341
486
 
342
487
  params: Dict[str, Any] = {
343
- "$filter": self._build_odata_filter(collection, query),
488
+ "$filter": self._build_odata_filter(collection, query, product_type),
344
489
  "$orderby": "ContentDate/Start desc",
345
- "$top": min(query.max_results, 1000),
346
- "$expand": "Attributes",
490
+ "$top": min(query.max_results, 1000),
491
+ "$expand": "Attributes",
347
492
  }
348
493
 
349
494
  response = httpx.get(
@@ -359,7 +504,7 @@ class CopernicusProvider(AbstractBaseProvider):
359
504
  def _product_to_satellite_data(self, product: Dict) -> SatelliteData:
360
505
  """Convert a Copernicus OData product dict to SatelliteData."""
361
506
  product_id = product.get("Id", "")
362
- name = product.get("Name", "")
507
+ name = product.get("Name", "")
363
508
  collection = product.get("S3Path", "").split("/")[2] if product.get("S3Path") else ""
364
509
 
365
510
  # Parse footprint
@@ -387,7 +532,7 @@ class CopernicusProvider(AbstractBaseProvider):
387
532
  except Exception:
388
533
  pass
389
534
 
390
- # Extract attributes
535
+ # Extract attributes from OData $expand=Attributes
391
536
  attributes = {
392
537
  a.get("Name"): a.get("Value")
393
538
  for a in (product.get("Attributes") or [])
@@ -399,7 +544,8 @@ class CopernicusProvider(AbstractBaseProvider):
399
544
  except (ValueError, TypeError):
400
545
  cloud_cover = None
401
546
 
402
- # Determine satellite platform from product name (e.g. S1C_IW_SLC...)
547
+ # Determine satellite platform from product name
548
+ # (e.g. S1C_IW_SLC → Sentinel-1C)
403
549
  satellite = "Sentinel"
404
550
  platform = attributes.get("platform") or attributes.get("platformShortName", "")
405
551
  if name.startswith("S1C") or "SENTINEL-1C" in platform.upper():
@@ -419,24 +565,23 @@ class CopernicusProvider(AbstractBaseProvider):
419
565
  elif name.startswith("S5P"):
420
566
  satellite = "Sentinel-5P"
421
567
 
422
- # Extract SAR-specific attributes
423
- # Copernicus OData attribute names (from $expand=Attributes):
424
- product_type = attributes.get("productType") # "SLC", "GRD", "GRD-COG"
425
- polarisation = attributes.get("polarisationChannels") # "VV VH", "HH", etc.
426
- pass_direction = attributes.get("orbitDirection") # "ASCENDING", "DESCENDING"
427
- rel_orbit = attributes.get("relativeOrbitNumber")
428
- abs_orbit = attributes.get("absoluteOrbitNumber")
568
+ # SAR-specific attributes (all from OData $expand=Attributes)
569
+ product_type = attributes.get("productType") # "SLC", "GRD", "GRD-COG"
570
+ polarisation = attributes.get("polarisationChannels") # "VV VH", "HH", etc.
571
+ pass_direction = attributes.get("orbitDirection") # "ASCENDING", "DESCENDING"
572
+ rel_orbit = attributes.get("relativeOrbitNumber")
573
+ abs_orbit = attributes.get("absoluteOrbitNumber")
429
574
  incidence_angle = attributes.get("incidenceAngleMax")
430
575
 
431
- # Normalise polarisation to "VV+VH" style
576
+ # Normalise polarisation string: "VV VH" → "VV+VH"
432
577
  if polarisation:
433
578
  polarisation = polarisation.strip().replace(" ", "+")
434
579
 
435
- # Normalise pass direction to lowercase
580
+ # Normalise pass direction to lowercase: "ASCENDING" → "ascending"
436
581
  if pass_direction:
437
- pass_direction = pass_direction.lower() # "ascending" / "descending"
582
+ pass_direction = pass_direction.lower()
438
583
 
439
- # Parse orbit numbers to int
584
+ # Parse numeric fields
440
585
  try:
441
586
  rel_orbit = int(rel_orbit) if rel_orbit is not None else None
442
587
  except (ValueError, TypeError):
@@ -454,7 +599,7 @@ class CopernicusProvider(AbstractBaseProvider):
454
599
  gsd_map = {"SLC": 5.0, "GRD": 10.0, "GRD-COG": 10.0, "OCN": None}
455
600
  gsd_m = gsd_map.get(product_type or "", None)
456
601
 
457
- # Build assets
602
+ # Build assets dict
458
603
  assets: Dict[str, SatelliteAsset] = {}
459
604
  s3_path = product.get("S3Path")
460
605
  if s3_path:
@@ -493,19 +638,19 @@ class CopernicusProvider(AbstractBaseProvider):
493
638
  processing_level=proc_level,
494
639
  assets=assets,
495
640
  properties={
496
- "name": name,
641
+ "name": name,
497
642
  "s3_path": s3_path,
498
643
  **attributes,
499
644
  },
500
- # SAR / platform fields (Capability 1 + 2)
501
- product_type=product_type,
502
- polarisation=polarisation,
503
- pass_direction=pass_direction,
504
- relative_orbit=rel_orbit,
505
- orbit_number=abs_orbit,
506
- incidence_angle=incidence_angle,
507
- gsd_m=gsd_m,
508
- resolution_m=gsd_m,
645
+ # SAR / platform metadata fields
646
+ product_type = product_type,
647
+ polarisation = polarisation,
648
+ pass_direction = pass_direction,
649
+ relative_orbit = rel_orbit,
650
+ orbit_number = abs_orbit,
651
+ incidence_angle = incidence_angle,
652
+ gsd_m = gsd_m,
653
+ resolution_m = gsd_m,
509
654
  )
510
655
 
511
656
  def download(
@@ -518,9 +663,9 @@ class CopernicusProvider(AbstractBaseProvider):
518
663
  Download a Copernicus product via authenticated HTTPS.
519
664
 
520
665
  Args:
521
- data: SatelliteData to download.
666
+ data: SatelliteData to download.
522
667
  destination: Output directory.
523
- options: Download configuration.
668
+ options: Download configuration.
524
669
 
525
670
  Returns:
526
671
  DownloadResult.
@@ -556,10 +701,12 @@ class CopernicusProvider(AbstractBaseProvider):
556
701
  ) as resp:
557
702
  self._handle_http_error(resp)
558
703
  with open(output_file, "wb") as f:
559
- for chunk in resp.iter_bytes(chunk_size=int(options.chunk_size_mb * 1024 * 1024)):
704
+ for chunk in resp.iter_bytes(
705
+ chunk_size=int(options.chunk_size_mb * 1024 * 1024)
706
+ ):
560
707
  f.write(chunk)
561
708
 
562
- duration = time.time() - start_time
709
+ duration = time.time() - start_time
563
710
  file_size = output_file.stat().st_size
564
711
 
565
712
  return DownloadResult(
@@ -590,7 +737,9 @@ class CopernicusProvider(AbstractBaseProvider):
590
737
  streaming=True,
591
738
  stac=False,
592
739
  requires_auth=True,
593
- supported_satellites=["Sentinel-1", "Sentinel-2", "Sentinel-3", "Sentinel-5P", "Sentinel-6"],
740
+ supported_satellites=[
741
+ "Sentinel-1", "Sentinel-2", "Sentinel-3", "Sentinel-5P", "Sentinel-6"
742
+ ],
594
743
  supported_formats=[DataFormat.SAFE, DataFormat.GEOTIFF, DataFormat.ZIP],
595
744
  supports_cloud_filter=True,
596
745
  supports_date_filter=True,
@@ -107,7 +107,7 @@ class SentinelHubProvider(AbstractBaseProvider):
107
107
  payload["filter"] = {"maxCloudCoverage": query.cloud_cover_max}
108
108
  try:
109
109
  resp = httpx.post(
110
- f"{self.BASE_URL}/api/v1/catalog/1.0.4/search",
110
+ f"{self.BASE_URL}/api/v1/catalog/1.0.6/search",
111
111
  json=payload, headers={"Authorization": f"Bearer {self._session.access_token}"},
112
112
  timeout=self.config.get("timeout", 60),
113
113
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Universal satellite data pipeline — unified access to 22+ providers
5
5
  Author: PyGeoFetch Contributors
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pygeofetch"
7
- version = "1.0.4"
7
+ version = "1.0.6"
8
8
  description = "Universal satellite data pipeline — unified access to 22+ providers"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
File without changes
File without changes
File without changes