PyGeoFetch 1.0.3__tar.gz → 1.0.5__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.3 → pygeofetch-1.0.5}/PKG-INFO +1 -1
  2. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/__init__.py +2 -2
  3. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/main.py +2 -2
  4. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/satellite_data.py +1 -1
  5. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/copernicus.py +302 -47
  6. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/sentinel_hub.py +1 -1
  7. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/PKG-INFO +1 -1
  8. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pyproject.toml +1 -1
  9. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/LICENSE +0 -0
  10. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/README.md +0 -0
  11. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/__init__.py +0 -0
  12. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/auth_commands.py +0 -0
  13. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/config_commands.py +0 -0
  14. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/download_commands.py +0 -0
  15. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/index_commands.py +0 -0
  16. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  17. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/postprocess_commands.py +0 -0
  18. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/preprocess_commands.py +0 -0
  19. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/sar_commands.py +0 -0
  20. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/search_commands.py +0 -0
  21. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/config/__init__.py +0 -0
  22. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/config/defaults.yaml +0 -0
  23. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/config/settings.py +0 -0
  24. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/__init__.py +0 -0
  25. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/authenticator.py +0 -0
  26. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/cache_manager.py +0 -0
  27. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/downloader.py +0 -0
  28. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/engine.py +0 -0
  29. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/logging.py +0 -0
  30. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/orbits.py +0 -0
  31. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/scheduler.py +0 -0
  32. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/searcher.py +0 -0
  33. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/__init__.py +0 -0
  34. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/download_task.py +0 -0
  35. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/search_query.py +0 -0
  36. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/user_auth.py +0 -0
  37. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/__init__.py +0 -0
  38. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/base.py +0 -0
  39. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/batch.py +0 -0
  40. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/indices.py +0 -0
  41. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/pipeline.py +0 -0
  42. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/postprocessor.py +0 -0
  43. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/preprocessor.py +0 -0
  44. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/sar.py +0 -0
  45. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/__init__.py +0 -0
  46. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/airbus_oneatlas.py +0 -0
  47. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
  48. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/aws_earth.py +0 -0
  49. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/base.py +0 -0
  50. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/digitalglobe.py +0 -0
  51. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/earth_explorer_additional.py +0 -0
  52. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/element84.py +0 -0
  53. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/esa_scihub.py +0 -0
  54. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/geoserver_generic.py +0 -0
  55. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/google_earth_engine.py +0 -0
  56. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/inpe_cbers.py +0 -0
  57. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/isro_bhuvan.py +0 -0
  58. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/jaxa_earth.py +0 -0
  59. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/maxar_gbdx.py +0 -0
  60. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/nasa_earthdata.py +0 -0
  61. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  62. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/noaa_big_data.py +0 -0
  63. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/opentopography.py +0 -0
  64. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/planet.py +0 -0
  65. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/planetary_computer.py +0 -0
  66. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/terrabotics.py +0 -0
  67. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/usgs.py +0 -0
  68. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/__init__.py +0 -0
  69. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/file_utils.py +0 -0
  70. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/geo_utils.py +0 -0
  71. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/logging_setup.py +0 -0
  72. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/retry_handler.py +0 -0
  73. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/validators.py +0 -0
  74. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/SOURCES.txt +0 -0
  75. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/dependency_links.txt +0 -0
  76. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/entry_points.txt +0 -0
  77. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/not-zip-safe +0 -0
  78. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/requires.txt +0 -0
  79. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/top_level.txt +0 -0
  80. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/setup.cfg +0 -0
  81. {pygeofetch-1.0.3 → pygeofetch-1.0.5}/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.3
3
+ Version: 1.0.5
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.3 — Universal Satellite Data Pipeline.
2
+ PyGeoFetch v1.0.5 — 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.3"
33
+ __version__ = "1.0.5"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch CLI v1.0.3 main entry point.
2
+ PyGeoFetch CLI v1.0.5 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.3 — Universal Satellite Data Pipeline
62
+ PyGeoFetch v1.0.5 — 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.3",
293
+ "stac_version": "1.0.5",
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,14 +89,75 @@ 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": "SENTINEL-1",
95
- "sentinel-2": "SENTINEL-2",
96
- "sentinel-3": "SENTINEL-3",
97
- "sentinel-5": "SENTINEL-5P",
141
+ # Sentinel-1 — all platform variants map to the same OData collection
142
+ "sentinel-1": "SENTINEL-1",
143
+ "sentinel-1a": "SENTINEL-1",
144
+ "sentinel-1b": "SENTINEL-1",
145
+ "sentinel-1c": "SENTINEL-1", # active constellation (May 2025+)
146
+ "sentinel-1d": "SENTINEL-1", # active constellation (Apr 2026+)
147
+ "s1a": "SENTINEL-1",
148
+ "s1b": "SENTINEL-1",
149
+ "s1c": "SENTINEL-1",
150
+ "s1d": "SENTINEL-1",
151
+ # Sentinel-2
152
+ "sentinel-2": "SENTINEL-2",
153
+ "sentinel-2a": "SENTINEL-2",
154
+ "sentinel-2b": "SENTINEL-2",
155
+ "sentinel-2c": "SENTINEL-2",
156
+ # Sentinel-3, 5P, 6
157
+ "sentinel-3": "SENTINEL-3",
158
+ "sentinel-5": "SENTINEL-5P",
98
159
  "sentinel-5p": "SENTINEL-5P",
99
- "sentinel-6": "SENTINEL-6",
160
+ "sentinel-6": "SENTINEL-6",
100
161
  }
101
162
 
102
163
  def authenticate(self, credentials: Credentials) -> AuthSession:
@@ -200,12 +261,18 @@ class CopernicusProvider(AbstractBaseProvider):
200
261
  self.require_auth()
201
262
  self._refresh_token_if_needed()
202
263
 
203
- collections = self._resolve_collections(query)
264
+ collections, inferred_product_type = self._resolve_collections(query)
204
265
  results: List[SatelliteData] = []
205
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
+
206
271
  for collection in collections:
207
272
  try:
208
- items = self._search_collection(collection, query)
273
+ items = self._search_collection(
274
+ collection, query, product_type=effective_product_type
275
+ )
209
276
  results.extend(items)
210
277
  if len(results) >= query.max_results:
211
278
  break
@@ -214,27 +281,111 @@ class CopernicusProvider(AbstractBaseProvider):
214
281
 
215
282
  return results[: query.max_results]
216
283
 
217
- def _resolve_collections(self, query: SearchQuery) -> List[str]:
218
- """Map query satellite names to Copernicus collection codes."""
219
- 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:
220
295
  return [c.upper() for c in query.collections]
221
296
 
222
- if not query.satellites:
223
- return ["SENTINEL-2"] # sensible default
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
224
347
 
225
- collections = set()
348
+ if not query.satellites:
349
+ # No collection, no satellite — use product_type to pick a default
350
+ pt = getattr(query, "product_type", None) or ""
351
+ if pt.upper() in ("SLC", "GRD", "GRD-COG", "OCN"):
352
+ return ["SENTINEL-1"], None
353
+ return ["SENTINEL-2"], None
354
+
355
+ # Resolve from satellite names
356
+ resolved_from_sat: set[str] = set()
226
357
  for sat in query.satellites:
227
- sat_lower = sat.lower()
358
+ sat_lower = sat.lower().replace("-", "").replace("_", "").replace(" ", "")
359
+ sat_orig = sat.lower()
360
+ matched = False
228
361
  for key, col in self.SATELLITE_COLLECTION_MAP.items():
229
- if key in sat_lower:
230
- collections.add(col)
362
+ key_norm = key.replace("-", "").replace("_", "")
363
+ if key_norm == sat_lower or key in sat_orig or sat_orig.startswith(key):
364
+ resolved_from_sat.add(col)
365
+ matched = True
231
366
  break
232
- else:
233
- collections.add(sat.upper())
234
-
235
- return list(collections)
236
-
237
- def _build_odata_filter(self, collection: str, query: SearchQuery) -> str:
367
+ if not matched:
368
+ if "sentinel-1" in sat_orig or sat_orig.startswith("s1"):
369
+ resolved_from_sat.add("SENTINEL-1")
370
+ elif "sentinel-2" in sat_orig or sat_orig.startswith("s2"):
371
+ resolved_from_sat.add("SENTINEL-2")
372
+ else:
373
+ resolved_from_sat.add(sat.upper())
374
+
375
+ if not resolved_from_sat:
376
+ pt = getattr(query, "product_type", None) or ""
377
+ if pt.upper() in ("SLC", "GRD", "GRD-COG", "OCN"):
378
+ return ["SENTINEL-1"], None
379
+ return ["SENTINEL-2"], None
380
+
381
+ return list(resolved_from_sat), inferred_product_type
382
+
383
+ def _build_odata_filter(
384
+ self,
385
+ collection: str,
386
+ query: SearchQuery,
387
+ product_type: Optional[str] = None,
388
+ ) -> str:
238
389
  """Build OData $filter string for the given collection and query."""
239
390
  filters = [f"Collection/Name eq '{collection}'"]
240
391
 
@@ -255,30 +406,77 @@ class CopernicusProvider(AbstractBaseProvider):
255
406
  )
256
407
  filters.append(f"OData.CSC.Intersects(area=geography'SRID=4326;{polygon}')")
257
408
 
258
- if (query.cloud_cover_max or 100) < 100:
259
- filters.append(f"Attributes/OData.CSC.DoubleAttribute/any(att:att/Name eq 'cloudCover' and att/OData.CSC.DoubleAttribute/Value le {query.cloud_cover_max})")
260
- if getattr(query, 'cloud_cover_min', 0) > 0:
261
- 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
+ )
262
424
 
263
- # Processing level filter
425
+ # Processing level filter (Sentinel-2: L1C, L2A)
264
426
  if query.processing_levels:
265
427
  level_filter = " or ".join(
266
- 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}')"
267
431
  for level in query.processing_levels
268
432
  )
269
433
  filters.append(f"({level_filter})")
270
434
 
435
+ # Product type filter — GRD, SLC, GRD-COG, OCN (Sentinel-1)
436
+ # product_type comes from either the query directly or was inferred
437
+ # from the STAC collection ID (e.g. "sentinel-1-grd" → "GRD")
438
+ effective_pt = product_type or getattr(query, "product_type", None)
439
+ if effective_pt:
440
+ filters.append(
441
+ f"Attributes/OData.CSC.StringAttribute/any("
442
+ f"att:att/Name eq 'productType' and "
443
+ f"att/OData.CSC.StringAttribute/Value eq '{effective_pt.upper()}')"
444
+ )
445
+
446
+ # Polarisation filter (VV+VH, HH, etc.)
447
+ pol = getattr(query, "polarisation", None)
448
+ if pol:
449
+ filters.append(
450
+ f"Attributes/OData.CSC.StringAttribute/any("
451
+ f"att:att/Name eq 'polarisationChannels' and "
452
+ f"att/OData.CSC.StringAttribute/Value eq '{pol.upper()}')"
453
+ )
454
+
455
+ # Pass direction filter (ASCENDING / DESCENDING)
456
+ pass_dir = getattr(query, "pass_direction", None)
457
+ if pass_dir:
458
+ filters.append(
459
+ f"Attributes/OData.CSC.StringAttribute/any("
460
+ f"att:att/Name eq 'orbitDirection' and "
461
+ f"att/OData.CSC.StringAttribute/Value eq '{pass_dir.upper()}')"
462
+ )
463
+
271
464
  return " and ".join(filters)
272
465
 
273
- def _search_collection(self, collection: str, query: SearchQuery) -> List[SatelliteData]:
466
+ def _search_collection(
467
+ self,
468
+ collection: str,
469
+ query: SearchQuery,
470
+ product_type: Optional[str] = None,
471
+ ) -> List[SatelliteData]:
274
472
  """Perform OData search for a specific collection."""
275
473
  import httpx
276
474
 
277
475
  params: Dict[str, Any] = {
278
- "$filter": self._build_odata_filter(collection, query),
476
+ "$filter": self._build_odata_filter(collection, query, product_type),
279
477
  "$orderby": "ContentDate/Start desc",
280
- "$top": min(query.max_results, 1000),
281
- "$expand": "Attributes",
478
+ "$top": min(query.max_results, 1000),
479
+ "$expand": "Attributes",
282
480
  }
283
481
 
284
482
  response = httpx.get(
@@ -294,7 +492,7 @@ class CopernicusProvider(AbstractBaseProvider):
294
492
  def _product_to_satellite_data(self, product: Dict) -> SatelliteData:
295
493
  """Convert a Copernicus OData product dict to SatelliteData."""
296
494
  product_id = product.get("Id", "")
297
- name = product.get("Name", "")
495
+ name = product.get("Name", "")
298
496
  collection = product.get("S3Path", "").split("/")[2] if product.get("S3Path") else ""
299
497
 
300
498
  # Parse footprint
@@ -322,7 +520,7 @@ class CopernicusProvider(AbstractBaseProvider):
322
520
  except Exception:
323
521
  pass
324
522
 
325
- # Extract attributes
523
+ # Extract attributes from OData $expand=Attributes
326
524
  attributes = {
327
525
  a.get("Name"): a.get("Value")
328
526
  for a in (product.get("Attributes") or [])
@@ -334,9 +532,19 @@ class CopernicusProvider(AbstractBaseProvider):
334
532
  except (ValueError, TypeError):
335
533
  cloud_cover = None
336
534
 
337
- # Determine satellite from name
535
+ # Determine satellite platform from product name
536
+ # (e.g. S1C_IW_SLC → Sentinel-1C)
338
537
  satellite = "Sentinel"
339
- if name.startswith("S1"):
538
+ platform = attributes.get("platform") or attributes.get("platformShortName", "")
539
+ if name.startswith("S1C") or "SENTINEL-1C" in platform.upper():
540
+ satellite = "SENTINEL-1C"
541
+ elif name.startswith("S1D") or "SENTINEL-1D" in platform.upper():
542
+ satellite = "SENTINEL-1D"
543
+ elif name.startswith("S1A") or "SENTINEL-1A" in platform.upper():
544
+ satellite = "SENTINEL-1A"
545
+ elif name.startswith("S1B") or "SENTINEL-1B" in platform.upper():
546
+ satellite = "SENTINEL-1B"
547
+ elif name.startswith("S1"):
340
548
  satellite = "Sentinel-1"
341
549
  elif name.startswith("S2"):
342
550
  satellite = "Sentinel-2"
@@ -345,7 +553,41 @@ class CopernicusProvider(AbstractBaseProvider):
345
553
  elif name.startswith("S5P"):
346
554
  satellite = "Sentinel-5P"
347
555
 
348
- # Build assets
556
+ # SAR-specific attributes (all from OData $expand=Attributes)
557
+ product_type = attributes.get("productType") # "SLC", "GRD", "GRD-COG"
558
+ polarisation = attributes.get("polarisationChannels") # "VV VH", "HH", etc.
559
+ pass_direction = attributes.get("orbitDirection") # "ASCENDING", "DESCENDING"
560
+ rel_orbit = attributes.get("relativeOrbitNumber")
561
+ abs_orbit = attributes.get("absoluteOrbitNumber")
562
+ incidence_angle = attributes.get("incidenceAngleMax")
563
+
564
+ # Normalise polarisation string: "VV VH" → "VV+VH"
565
+ if polarisation:
566
+ polarisation = polarisation.strip().replace(" ", "+")
567
+
568
+ # Normalise pass direction to lowercase: "ASCENDING" → "ascending"
569
+ if pass_direction:
570
+ pass_direction = pass_direction.lower()
571
+
572
+ # Parse numeric fields
573
+ try:
574
+ rel_orbit = int(rel_orbit) if rel_orbit is not None else None
575
+ except (ValueError, TypeError):
576
+ rel_orbit = None
577
+ try:
578
+ abs_orbit = int(abs_orbit) if abs_orbit is not None else None
579
+ except (ValueError, TypeError):
580
+ abs_orbit = None
581
+ try:
582
+ incidence_angle = float(incidence_angle) if incidence_angle is not None else None
583
+ except (ValueError, TypeError):
584
+ incidence_angle = None
585
+
586
+ # GSD from product type
587
+ gsd_map = {"SLC": 5.0, "GRD": 10.0, "GRD-COG": 10.0, "OCN": None}
588
+ gsd_m = gsd_map.get(product_type or "", None)
589
+
590
+ # Build assets dict
349
591
  assets: Dict[str, SatelliteAsset] = {}
350
592
  s3_path = product.get("S3Path")
351
593
  if s3_path:
@@ -384,10 +626,19 @@ class CopernicusProvider(AbstractBaseProvider):
384
626
  processing_level=proc_level,
385
627
  assets=assets,
386
628
  properties={
387
- "name": name,
629
+ "name": name,
388
630
  "s3_path": s3_path,
389
631
  **attributes,
390
632
  },
633
+ # SAR / platform metadata fields
634
+ product_type = product_type,
635
+ polarisation = polarisation,
636
+ pass_direction = pass_direction,
637
+ relative_orbit = rel_orbit,
638
+ orbit_number = abs_orbit,
639
+ incidence_angle = incidence_angle,
640
+ gsd_m = gsd_m,
641
+ resolution_m = gsd_m,
391
642
  )
392
643
 
393
644
  def download(
@@ -400,9 +651,9 @@ class CopernicusProvider(AbstractBaseProvider):
400
651
  Download a Copernicus product via authenticated HTTPS.
401
652
 
402
653
  Args:
403
- data: SatelliteData to download.
654
+ data: SatelliteData to download.
404
655
  destination: Output directory.
405
- options: Download configuration.
656
+ options: Download configuration.
406
657
 
407
658
  Returns:
408
659
  DownloadResult.
@@ -438,10 +689,12 @@ class CopernicusProvider(AbstractBaseProvider):
438
689
  ) as resp:
439
690
  self._handle_http_error(resp)
440
691
  with open(output_file, "wb") as f:
441
- for chunk in resp.iter_bytes(chunk_size=int(options.chunk_size_mb * 1024 * 1024)):
692
+ for chunk in resp.iter_bytes(
693
+ chunk_size=int(options.chunk_size_mb * 1024 * 1024)
694
+ ):
442
695
  f.write(chunk)
443
696
 
444
- duration = time.time() - start_time
697
+ duration = time.time() - start_time
445
698
  file_size = output_file.stat().st_size
446
699
 
447
700
  return DownloadResult(
@@ -472,7 +725,9 @@ class CopernicusProvider(AbstractBaseProvider):
472
725
  streaming=True,
473
726
  stac=False,
474
727
  requires_auth=True,
475
- supported_satellites=["Sentinel-1", "Sentinel-2", "Sentinel-3", "Sentinel-5P", "Sentinel-6"],
728
+ supported_satellites=[
729
+ "Sentinel-1", "Sentinel-2", "Sentinel-3", "Sentinel-5P", "Sentinel-6"
730
+ ],
476
731
  supported_formats=[DataFormat.SAFE, DataFormat.GEOTIFF, DataFormat.ZIP],
477
732
  supports_cloud_filter=True,
478
733
  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.3/search",
110
+ f"{self.BASE_URL}/api/v1/catalog/1.0.5/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.3
3
+ Version: 1.0.5
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.3"
7
+ version = "1.0.5"
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