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.
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/PKG-INFO +1 -1
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/__init__.py +2 -2
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/main.py +2 -2
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/satellite_data.py +1 -1
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/copernicus.py +302 -47
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/sentinel_hub.py +1 -1
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/PKG-INFO +1 -1
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pyproject.toml +1 -1
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/LICENSE +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/README.md +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/__init__.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/auth_commands.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/config_commands.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/download_commands.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/index_commands.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/pipeline_process_commands.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/postprocess_commands.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/preprocess_commands.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/sar_commands.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/cli/search_commands.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/config/__init__.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/config/defaults.yaml +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/config/settings.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/__init__.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/authenticator.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/cache_manager.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/downloader.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/engine.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/logging.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/orbits.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/scheduler.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/core/searcher.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/__init__.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/download_task.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/search_query.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/models/user_auth.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/__init__.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/base.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/batch.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/indices.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/pipeline.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/postprocessor.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/preprocessor.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/processing/sar.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/__init__.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/airbus_oneatlas.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/aws_earth.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/base.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/digitalglobe.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/earth_explorer_additional.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/element84.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/esa_scihub.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/geoserver_generic.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/google_earth_engine.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/inpe_cbers.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/isro_bhuvan.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/jaxa_earth.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/maxar_gbdx.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/nasa_earthdata.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/noaa_big_data.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/opentopography.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/planet.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/planetary_computer.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/terrabotics.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/providers/usgs.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/__init__.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/file_utils.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/geo_utils.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/logging_setup.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/retry_handler.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch/utils/validators.py +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/SOURCES.txt +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/dependency_links.txt +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/entry_points.txt +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/not-zip-safe +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/requires.txt +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/pygeofetch.egg-info/top_level.txt +0 -0
- {pygeofetch-1.0.3 → pygeofetch-1.0.5}/setup.cfg +0 -0
- {pygeofetch-1.0.3 → 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,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
|
-
#
|
|
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
|
-
|
|
95
|
-
"sentinel-
|
|
96
|
-
"sentinel-
|
|
97
|
-
"sentinel-
|
|
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":
|
|
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(
|
|
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(
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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(
|
|
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(
|
|
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":
|
|
476
|
+
"$filter": self._build_odata_filter(collection, query, product_type),
|
|
279
477
|
"$orderby": "ContentDate/Start desc",
|
|
280
|
-
"$top":
|
|
281
|
-
"$expand":
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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":
|
|
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:
|
|
654
|
+
data: SatelliteData to download.
|
|
404
655
|
destination: Output directory.
|
|
405
|
-
options:
|
|
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(
|
|
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
|
|
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=[
|
|
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.
|
|
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
|