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.
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/PKG-INFO +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/__init__.py +2 -2
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/main.py +2 -2
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/satellite_data.py +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/copernicus.py +223 -74
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/sentinel_hub.py +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/PKG-INFO +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pyproject.toml +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/LICENSE +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/README.md +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/auth_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/config_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/download_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/index_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/pipeline_process_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/postprocess_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/preprocess_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/sar_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/cli/search_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/config/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/config/defaults.yaml +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/config/settings.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/authenticator.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/cache_manager.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/downloader.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/engine.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/logging.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/orbits.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/scheduler.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/core/searcher.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/download_task.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/search_query.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/models/user_auth.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/base.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/batch.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/indices.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/pipeline.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/postprocessor.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/preprocessor.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/processing/sar.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/airbus_oneatlas.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/aws_earth.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/base.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/digitalglobe.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/earth_explorer_additional.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/element84.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/esa_scihub.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/geoserver_generic.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/google_earth_engine.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/inpe_cbers.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/isro_bhuvan.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/jaxa_earth.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/maxar_gbdx.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/nasa_earthdata.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/noaa_big_data.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/opentopography.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/planet.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/planetary_computer.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/terrabotics.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/providers/usgs.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/file_utils.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/geo_utils.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/logging_setup.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/retry_handler.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch/utils/validators.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/SOURCES.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/dependency_links.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/entry_points.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/not-zip-safe +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/requires.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/pygeofetch.egg-info/top_level.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/setup.cfg +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.6}/tests/test_pgv_integration_contract.py +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
PyGeoFetch v1.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
36
|
-
cloud_cover_max=
|
|
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
|
-
#
|
|
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(
|
|
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(
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
#
|
|
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"]
|
|
241
|
-
return ["SENTINEL-2"]
|
|
352
|
+
return ["SENTINEL-1"], None
|
|
353
|
+
return ["SENTINEL-2"], None
|
|
242
354
|
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
+
resolved_from_sat.add("SENTINEL-1")
|
|
259
370
|
elif "sentinel-2" in sat_orig or sat_orig.startswith("s2"):
|
|
260
|
-
|
|
371
|
+
resolved_from_sat.add("SENTINEL-2")
|
|
261
372
|
else:
|
|
262
|
-
|
|
263
|
-
collections.add(sat.upper())
|
|
373
|
+
resolved_from_sat.add(sat.upper())
|
|
264
374
|
|
|
265
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
collections.add("SENTINEL-2")
|
|
378
|
+
return ["SENTINEL-1"], None
|
|
379
|
+
return ["SENTINEL-2"], None
|
|
272
380
|
|
|
273
|
-
return list(
|
|
381
|
+
return list(resolved_from_sat), inferred_product_type
|
|
274
382
|
|
|
275
|
-
def _build_odata_filter(
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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(
|
|
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
|
|
310
|
-
|
|
311
|
-
if
|
|
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 '{
|
|
441
|
+
f"att/OData.CSC.StringAttribute/Value eq '{effective_pt.upper()}')"
|
|
316
442
|
)
|
|
317
443
|
|
|
318
|
-
#
|
|
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 (
|
|
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(
|
|
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":
|
|
488
|
+
"$filter": self._build_odata_filter(collection, query, product_type),
|
|
344
489
|
"$orderby": "ContentDate/Start desc",
|
|
345
|
-
"$top":
|
|
346
|
-
"$expand":
|
|
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
|
|
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
|
|
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
|
-
#
|
|
423
|
-
#
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
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()
|
|
582
|
+
pass_direction = pass_direction.lower()
|
|
438
583
|
|
|
439
|
-
# Parse
|
|
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":
|
|
641
|
+
"name": name,
|
|
497
642
|
"s3_path": s3_path,
|
|
498
643
|
**attributes,
|
|
499
644
|
},
|
|
500
|
-
# SAR / platform fields
|
|
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:
|
|
666
|
+
data: SatelliteData to download.
|
|
522
667
|
destination: Output directory.
|
|
523
|
-
options:
|
|
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(
|
|
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
|
|
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=[
|
|
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.
|
|
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
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|