PyGeoFetch 1.0.4__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.
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/PKG-INFO +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/__init__.py +2 -2
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/main.py +2 -2
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/models/satellite_data.py +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/copernicus.py +211 -74
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/sentinel_hub.py +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch.egg-info/PKG-INFO +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pyproject.toml +1 -1
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/LICENSE +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/README.md +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/auth_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/config_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/download_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/index_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/pipeline_process_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/postprocess_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/preprocess_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/sar_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/cli/search_commands.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/config/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/config/defaults.yaml +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/config/settings.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/core/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/core/authenticator.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/core/cache_manager.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/core/downloader.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/core/engine.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/core/logging.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/core/orbits.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/core/scheduler.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/core/searcher.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/models/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/models/download_task.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/models/search_query.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/models/user_auth.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/processing/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/processing/base.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/processing/batch.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/processing/indices.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/processing/pipeline.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/processing/postprocessor.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/processing/preprocessor.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/processing/sar.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/airbus_oneatlas.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/aws_earth.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/base.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/digitalglobe.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/earth_explorer_additional.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/element84.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/esa_scihub.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/geoserver_generic.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/google_earth_engine.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/inpe_cbers.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/isro_bhuvan.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/jaxa_earth.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/maxar_gbdx.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/nasa_earthdata.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/noaa_big_data.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/opentopography.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/planet.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/planetary_computer.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/terrabotics.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/providers/usgs.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/utils/__init__.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/utils/file_utils.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/utils/geo_utils.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/utils/logging_setup.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/utils/retry_handler.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch/utils/validators.py +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch.egg-info/SOURCES.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch.egg-info/dependency_links.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch.egg-info/entry_points.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch.egg-info/not-zip-safe +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch.egg-info/requires.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/pygeofetch.egg-info/top_level.txt +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/setup.cfg +0 -0
- {pygeofetch-1.0.4 → pygeofetch-1.0.5}/tests/test_pgv_integration_contract.py +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
PyGeoFetch v1.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
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,44 @@ 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
|
-
|
|
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:
|
|
312
440
|
filters.append(
|
|
313
441
|
f"Attributes/OData.CSC.StringAttribute/any("
|
|
314
442
|
f"att:att/Name eq 'productType' and "
|
|
315
|
-
f"att/OData.CSC.StringAttribute/Value eq '{
|
|
443
|
+
f"att/OData.CSC.StringAttribute/Value eq '{effective_pt.upper()}')"
|
|
316
444
|
)
|
|
317
445
|
|
|
318
|
-
# Polarisation filter (VV
|
|
446
|
+
# Polarisation filter (VV+VH, HH, etc.)
|
|
319
447
|
pol = getattr(query, "polarisation", None)
|
|
320
448
|
if pol:
|
|
321
449
|
filters.append(
|
|
@@ -324,7 +452,7 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
324
452
|
f"att/OData.CSC.StringAttribute/Value eq '{pol.upper()}')"
|
|
325
453
|
)
|
|
326
454
|
|
|
327
|
-
# Pass direction filter (
|
|
455
|
+
# Pass direction filter (ASCENDING / DESCENDING)
|
|
328
456
|
pass_dir = getattr(query, "pass_direction", None)
|
|
329
457
|
if pass_dir:
|
|
330
458
|
filters.append(
|
|
@@ -335,15 +463,20 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
335
463
|
|
|
336
464
|
return " and ".join(filters)
|
|
337
465
|
|
|
338
|
-
def _search_collection(
|
|
466
|
+
def _search_collection(
|
|
467
|
+
self,
|
|
468
|
+
collection: str,
|
|
469
|
+
query: SearchQuery,
|
|
470
|
+
product_type: Optional[str] = None,
|
|
471
|
+
) -> List[SatelliteData]:
|
|
339
472
|
"""Perform OData search for a specific collection."""
|
|
340
473
|
import httpx
|
|
341
474
|
|
|
342
475
|
params: Dict[str, Any] = {
|
|
343
|
-
"$filter":
|
|
476
|
+
"$filter": self._build_odata_filter(collection, query, product_type),
|
|
344
477
|
"$orderby": "ContentDate/Start desc",
|
|
345
|
-
"$top":
|
|
346
|
-
"$expand":
|
|
478
|
+
"$top": min(query.max_results, 1000),
|
|
479
|
+
"$expand": "Attributes",
|
|
347
480
|
}
|
|
348
481
|
|
|
349
482
|
response = httpx.get(
|
|
@@ -359,7 +492,7 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
359
492
|
def _product_to_satellite_data(self, product: Dict) -> SatelliteData:
|
|
360
493
|
"""Convert a Copernicus OData product dict to SatelliteData."""
|
|
361
494
|
product_id = product.get("Id", "")
|
|
362
|
-
name
|
|
495
|
+
name = product.get("Name", "")
|
|
363
496
|
collection = product.get("S3Path", "").split("/")[2] if product.get("S3Path") else ""
|
|
364
497
|
|
|
365
498
|
# Parse footprint
|
|
@@ -387,7 +520,7 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
387
520
|
except Exception:
|
|
388
521
|
pass
|
|
389
522
|
|
|
390
|
-
# Extract attributes
|
|
523
|
+
# Extract attributes from OData $expand=Attributes
|
|
391
524
|
attributes = {
|
|
392
525
|
a.get("Name"): a.get("Value")
|
|
393
526
|
for a in (product.get("Attributes") or [])
|
|
@@ -399,7 +532,8 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
399
532
|
except (ValueError, TypeError):
|
|
400
533
|
cloud_cover = None
|
|
401
534
|
|
|
402
|
-
# Determine satellite platform from product name
|
|
535
|
+
# Determine satellite platform from product name
|
|
536
|
+
# (e.g. S1C_IW_SLC → Sentinel-1C)
|
|
403
537
|
satellite = "Sentinel"
|
|
404
538
|
platform = attributes.get("platform") or attributes.get("platformShortName", "")
|
|
405
539
|
if name.startswith("S1C") or "SENTINEL-1C" in platform.upper():
|
|
@@ -419,24 +553,23 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
419
553
|
elif name.startswith("S5P"):
|
|
420
554
|
satellite = "Sentinel-5P"
|
|
421
555
|
|
|
422
|
-
#
|
|
423
|
-
#
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
abs_orbit = attributes.get("absoluteOrbitNumber")
|
|
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")
|
|
429
562
|
incidence_angle = attributes.get("incidenceAngleMax")
|
|
430
563
|
|
|
431
|
-
# Normalise polarisation
|
|
564
|
+
# Normalise polarisation string: "VV VH" → "VV+VH"
|
|
432
565
|
if polarisation:
|
|
433
566
|
polarisation = polarisation.strip().replace(" ", "+")
|
|
434
567
|
|
|
435
|
-
# Normalise pass direction to lowercase
|
|
568
|
+
# Normalise pass direction to lowercase: "ASCENDING" → "ascending"
|
|
436
569
|
if pass_direction:
|
|
437
|
-
pass_direction = pass_direction.lower()
|
|
570
|
+
pass_direction = pass_direction.lower()
|
|
438
571
|
|
|
439
|
-
# Parse
|
|
572
|
+
# Parse numeric fields
|
|
440
573
|
try:
|
|
441
574
|
rel_orbit = int(rel_orbit) if rel_orbit is not None else None
|
|
442
575
|
except (ValueError, TypeError):
|
|
@@ -454,7 +587,7 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
454
587
|
gsd_map = {"SLC": 5.0, "GRD": 10.0, "GRD-COG": 10.0, "OCN": None}
|
|
455
588
|
gsd_m = gsd_map.get(product_type or "", None)
|
|
456
589
|
|
|
457
|
-
# Build assets
|
|
590
|
+
# Build assets dict
|
|
458
591
|
assets: Dict[str, SatelliteAsset] = {}
|
|
459
592
|
s3_path = product.get("S3Path")
|
|
460
593
|
if s3_path:
|
|
@@ -493,19 +626,19 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
493
626
|
processing_level=proc_level,
|
|
494
627
|
assets=assets,
|
|
495
628
|
properties={
|
|
496
|
-
"name":
|
|
629
|
+
"name": name,
|
|
497
630
|
"s3_path": s3_path,
|
|
498
631
|
**attributes,
|
|
499
632
|
},
|
|
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,
|
|
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,
|
|
509
642
|
)
|
|
510
643
|
|
|
511
644
|
def download(
|
|
@@ -518,9 +651,9 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
518
651
|
Download a Copernicus product via authenticated HTTPS.
|
|
519
652
|
|
|
520
653
|
Args:
|
|
521
|
-
data:
|
|
654
|
+
data: SatelliteData to download.
|
|
522
655
|
destination: Output directory.
|
|
523
|
-
options:
|
|
656
|
+
options: Download configuration.
|
|
524
657
|
|
|
525
658
|
Returns:
|
|
526
659
|
DownloadResult.
|
|
@@ -556,10 +689,12 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
556
689
|
) as resp:
|
|
557
690
|
self._handle_http_error(resp)
|
|
558
691
|
with open(output_file, "wb") as f:
|
|
559
|
-
for chunk in resp.iter_bytes(
|
|
692
|
+
for chunk in resp.iter_bytes(
|
|
693
|
+
chunk_size=int(options.chunk_size_mb * 1024 * 1024)
|
|
694
|
+
):
|
|
560
695
|
f.write(chunk)
|
|
561
696
|
|
|
562
|
-
duration
|
|
697
|
+
duration = time.time() - start_time
|
|
563
698
|
file_size = output_file.stat().st_size
|
|
564
699
|
|
|
565
700
|
return DownloadResult(
|
|
@@ -590,7 +725,9 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
590
725
|
streaming=True,
|
|
591
726
|
stac=False,
|
|
592
727
|
requires_auth=True,
|
|
593
|
-
supported_satellites=[
|
|
728
|
+
supported_satellites=[
|
|
729
|
+
"Sentinel-1", "Sentinel-2", "Sentinel-3", "Sentinel-5P", "Sentinel-6"
|
|
730
|
+
],
|
|
594
731
|
supported_formats=[DataFormat.SAFE, DataFormat.GEOTIFF, DataFormat.ZIP],
|
|
595
732
|
supports_cloud_filter=True,
|
|
596
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.
|
|
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
|
)
|
|
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
|