PyGeoFetch 1.0.2__py3-none-any.whl → 1.0.4__py3-none-any.whl
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/__init__.py +2 -2
- pygeofetch/cli/main.py +4 -5
- pygeofetch/core/authenticator.py +30 -1
- pygeofetch/core/downloader.py +367 -45
- pygeofetch/core/engine.py +116 -11
- pygeofetch/core/logging.py +199 -0
- pygeofetch/core/orbits.py +241 -0
- pygeofetch/core/searcher.py +3 -8
- pygeofetch/models/satellite_data.py +220 -9
- pygeofetch/models/search_query.py +19 -0
- pygeofetch/processing/pipeline.py +63 -0
- pygeofetch/providers/airbus_oneatlas.py +4 -0
- pygeofetch/providers/alaska_satellite_facility.py +4 -0
- pygeofetch/providers/aws_earth.py +9 -0
- pygeofetch/providers/copernicus.py +137 -15
- pygeofetch/providers/digitalglobe.py +4 -0
- pygeofetch/providers/earth_explorer_additional.py +4 -0
- pygeofetch/providers/element84.py +4 -0
- pygeofetch/providers/esa_scihub.py +4 -0
- pygeofetch/providers/geoserver_generic.py +4 -0
- pygeofetch/providers/google_earth_engine.py +4 -0
- pygeofetch/providers/inpe_cbers.py +4 -0
- pygeofetch/providers/isro_bhuvan.py +4 -0
- pygeofetch/providers/jaxa_earth.py +4 -0
- pygeofetch/providers/maxar_gbdx.py +4 -0
- pygeofetch/providers/nasa_earthdata.py +6 -2
- pygeofetch/providers/nasa_earthdata_cloud.py +4 -0
- pygeofetch/providers/noaa_big_data.py +4 -0
- pygeofetch/providers/opentopography.py +4 -0
- pygeofetch/providers/planet.py +6 -2
- pygeofetch/providers/planetary_computer.py +11 -12
- pygeofetch/providers/sentinel_hub.py +5 -1
- pygeofetch/providers/terrabotics.py +4 -0
- pygeofetch/providers/usgs.py +42 -15
- pygeofetch/utils/geo_utils.py +26 -0
- pygeofetch/utils/logging_setup.py +34 -96
- {pygeofetch-1.0.2.dist-info → pygeofetch-1.0.4.dist-info}/METADATA +35 -8
- pygeofetch-1.0.4.dist-info/RECORD +74 -0
- {pygeofetch-1.0.2.dist-info → pygeofetch-1.0.4.dist-info}/WHEEL +1 -1
- pygeofetch-1.0.4.dist-info/entry_points.txt +2 -0
- pygeofetch-1.0.2.dist-info/RECORD +0 -72
- pygeofetch-1.0.2.dist-info/entry_points.txt +0 -2
- {pygeofetch-1.0.2.dist-info → pygeofetch-1.0.4.dist-info}/licenses/LICENSE +0 -0
- {pygeofetch-1.0.2.dist-info → pygeofetch-1.0.4.dist-info}/top_level.txt +0 -0
pygeofetch/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
PyGeoFetch v1.0.
|
|
2
|
+
PyGeoFetch v1.0.4 — 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.4"
|
|
34
34
|
__author__ = "PyGeoFetch Contributors"
|
|
35
35
|
__license__ = "MIT"
|
|
36
36
|
|
pygeofetch/cli/main.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
PyGeoFetch CLI v1.0.
|
|
2
|
+
PyGeoFetch CLI v1.0.4 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.4 — Universal Satellite Data Pipeline
|
|
63
63
|
===========================================================
|
|
64
64
|
Unified access to 22+ satellite data providers.
|
|
65
65
|
|
|
@@ -74,8 +74,7 @@ def cli(ctx: click.Context, log_level: str, log_file: str, log_format: str,
|
|
|
74
74
|
from pathlib import Path
|
|
75
75
|
setup_logging(
|
|
76
76
|
level=log_level,
|
|
77
|
-
log_file=Path(log_file) if log_file else None,
|
|
78
|
-
use_rich=(log_format == "console"),
|
|
77
|
+
log_file=str(Path(log_file)) if log_file else None,
|
|
79
78
|
)
|
|
80
79
|
ctx.ensure_object(dict)
|
|
81
80
|
ctx.obj["log_level"] = log_level
|
|
@@ -777,4 +776,4 @@ def main() -> None:
|
|
|
777
776
|
|
|
778
777
|
|
|
779
778
|
if __name__ == "__main__":
|
|
780
|
-
main()
|
|
779
|
+
main()
|
pygeofetch/core/authenticator.py
CHANGED
|
@@ -27,7 +27,7 @@ from pathlib import Path
|
|
|
27
27
|
from typing import Any, Dict, List, Optional
|
|
28
28
|
|
|
29
29
|
from pygeofetch.models.user_auth import AuthSession, AuthType, Credentials
|
|
30
|
-
from pygeofetch.
|
|
30
|
+
from pygeofetch.core.logging import get_logger
|
|
31
31
|
|
|
32
32
|
logger = get_logger(__name__)
|
|
33
33
|
|
|
@@ -295,6 +295,35 @@ class AuthManager:
|
|
|
295
295
|
self._sessions.pop(provider, None)
|
|
296
296
|
logger.info(f"Credentials saved for provider {provider!r}")
|
|
297
297
|
|
|
298
|
+
|
|
299
|
+
def add_credentials(
|
|
300
|
+
self,
|
|
301
|
+
provider: str,
|
|
302
|
+
creds: Dict[str, Any],
|
|
303
|
+
) -> None:
|
|
304
|
+
"""
|
|
305
|
+
Store credentials for the given provider.
|
|
306
|
+
|
|
307
|
+
Accepts a dict (not keyword arguments — the engine passes a dict).
|
|
308
|
+
Idempotent: calling twice with same provider updates, does not duplicate.
|
|
309
|
+
Not raise for unknown provider names.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
provider: Provider identifier e.g. "usgs", "planetary_computer"
|
|
313
|
+
creds: Dict with any of: username, password, api_key,
|
|
314
|
+
client_id, client_secret, token, access_key, secret_key.
|
|
315
|
+
Only non-None values are stored.
|
|
316
|
+
"""
|
|
317
|
+
# Filter out None values and the redundant "provider" key
|
|
318
|
+
clean_creds = {k: v for k, v in creds.items()
|
|
319
|
+
if k != "provider" and v is not None}
|
|
320
|
+
if not clean_creds:
|
|
321
|
+
logger.warning(f"No credentials provided for {provider!r} — skipping")
|
|
322
|
+
return
|
|
323
|
+
# Delegate to add() which handles storage, session invalidation, type inference
|
|
324
|
+
self.add(provider, **clean_creds)
|
|
325
|
+
logger.debug(f"Stored credentials for provider: {provider}")
|
|
326
|
+
|
|
298
327
|
def get_credentials(self, provider: str) -> Optional[Credentials]:
|
|
299
328
|
"""
|
|
300
329
|
Load stored credentials for a provider.
|
pygeofetch/core/downloader.py
CHANGED
|
@@ -36,7 +36,7 @@ from pygeofetch.models.download_task import (
|
|
|
36
36
|
DownloadTask,
|
|
37
37
|
)
|
|
38
38
|
from pygeofetch.models.satellite_data import SatelliteData
|
|
39
|
-
from pygeofetch.
|
|
39
|
+
from pygeofetch.core.logging import get_logger, _render_progress_bar
|
|
40
40
|
|
|
41
41
|
logger = get_logger(__name__)
|
|
42
42
|
|
|
@@ -102,16 +102,30 @@ class AdaptiveDownloader:
|
|
|
102
102
|
if result.duration_seconds == 0.0 and elapsed > 0:
|
|
103
103
|
result = result.model_copy(update={"duration_seconds": elapsed})
|
|
104
104
|
if result.success:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
# BUG 3: validate file integrity before claiming success
|
|
106
|
+
for out_p in (result.output_paths or [result.output_path]):
|
|
107
|
+
if out_p and out_p.exists():
|
|
108
|
+
is_valid, err_msg = self._validate_downloaded_file(out_p)
|
|
109
|
+
if not is_valid:
|
|
110
|
+
result = result.model_copy(update={
|
|
111
|
+
"status": DownloadStatus.FAILED,
|
|
112
|
+
"error": f"Download appeared complete but file validation failed: {err_msg}",
|
|
113
|
+
"output_path": None,
|
|
114
|
+
})
|
|
115
|
+
logger.warning(
|
|
116
|
+
f"File validation failed for {data.id!r}: {err_msg}"
|
|
117
|
+
)
|
|
118
|
+
break
|
|
119
|
+
if result.success:
|
|
120
|
+
if options.verify_checksum:
|
|
121
|
+
result = self._verify_checksums(result, data, options)
|
|
122
|
+
if options.post_process:
|
|
123
|
+
result = self._run_post_process(result, options)
|
|
109
124
|
size_mb = result.bytes_downloaded / (1024 * 1024) if result.bytes_downloaded else 0
|
|
110
125
|
speed = (size_mb / result.duration_seconds) if result.duration_seconds > 0 else 0
|
|
111
126
|
logger.info(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
+ (f" ({speed:.1f} MB/s)" if speed > 0 else "")
|
|
127
|
+
" ✓ %-45s %6.0f MB %5.1fs",
|
|
128
|
+
str(data.id)[:45], size_mb, result.duration_seconds
|
|
115
129
|
)
|
|
116
130
|
return result
|
|
117
131
|
except Exception as exc:
|
|
@@ -165,9 +179,7 @@ class AdaptiveDownloader:
|
|
|
165
179
|
completed_count = 0
|
|
166
180
|
total = len(data_list)
|
|
167
181
|
|
|
168
|
-
logger.info(
|
|
169
|
-
f"Downloading {total} items with {options.parallel} parallel workers..."
|
|
170
|
-
)
|
|
182
|
+
logger.info("Downloading %d scene(s) to: %s", total, str(destination))
|
|
171
183
|
|
|
172
184
|
with ThreadPoolExecutor(max_workers=options.parallel) as executor:
|
|
173
185
|
future_to_idx = {
|
|
@@ -193,25 +205,44 @@ class AdaptiveDownloader:
|
|
|
193
205
|
if item_done_callback:
|
|
194
206
|
item_done_callback(completed_count, total, result)
|
|
195
207
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
208
|
+
# BUG 2: guarantee length == len(data_list) and order is preserved
|
|
209
|
+
# Fill any None slots (unexpected future crashes) with failure results
|
|
210
|
+
for i, item in enumerate(data_list):
|
|
211
|
+
if results[i] is None:
|
|
212
|
+
results[i] = DownloadResult(
|
|
213
|
+
status=DownloadStatus.FAILED,
|
|
214
|
+
data_id=item.id,
|
|
215
|
+
provider=item.provider,
|
|
216
|
+
error="Download future returned no result (internal error)",
|
|
217
|
+
)
|
|
199
218
|
|
|
200
|
-
|
|
219
|
+
completed = sum(1 for r in results if r and r.success)
|
|
220
|
+
failed = sum(1 for r in results if r and not r.success)
|
|
221
|
+
assert len(results) == len(data_list), (
|
|
222
|
+
f"BUG: result count {len(results)} != input count {len(data_list)}"
|
|
223
|
+
)
|
|
224
|
+
total_mb = sum(
|
|
225
|
+
r.bytes_downloaded / (1024*1024)
|
|
226
|
+
for r in results if r and r.success and r.bytes_downloaded
|
|
227
|
+
)
|
|
228
|
+
import time as _t
|
|
229
|
+
logger.info(
|
|
230
|
+
"Download complete: %d/%d succeeded %.0f MB total",
|
|
231
|
+
completed, len(data_list), total_mb
|
|
232
|
+
)
|
|
233
|
+
return results # type: ignore — all slots filled, never None
|
|
201
234
|
|
|
202
235
|
def _get_provider(self, provider_id: str) -> Any:
|
|
203
|
-
"""Get a provider instance with
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
self._provider_cache[provider_id] = prov
|
|
214
|
-
return self._provider_cache[provider_id]
|
|
236
|
+
"""Get a provider instance with a fresh authenticated session."""
|
|
237
|
+
from pygeofetch.providers import get_provider
|
|
238
|
+
prov = get_provider(provider_id)
|
|
239
|
+
if self.auth_manager and prov.REQUIRES_AUTH:
|
|
240
|
+
try:
|
|
241
|
+
session = self.auth_manager.authenticate(provider_id)
|
|
242
|
+
prov.set_session(session)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
logger.warning(f"Auth for {provider_id} failed: {exc}")
|
|
245
|
+
return prov
|
|
215
246
|
|
|
216
247
|
def _verify_checksums(
|
|
217
248
|
self, result: DownloadResult, data: SatelliteData, options: DownloadOptions
|
|
@@ -285,23 +316,7 @@ class AdaptiveDownloader:
|
|
|
285
316
|
for path in result.output_paths:
|
|
286
317
|
if path.suffix.lower() in (".tif", ".tiff"):
|
|
287
318
|
out_path = path.with_stem(f"{path.stem}_{target_crs.replace(':', '_')}")
|
|
288
|
-
|
|
289
|
-
transform, width, height = calculate_default_transform(
|
|
290
|
-
src.crs, target_crs, src.width, src.height, *src.bounds
|
|
291
|
-
)
|
|
292
|
-
meta = src.meta.copy()
|
|
293
|
-
meta.update(crs=target_crs, transform=transform, width=width, height=height)
|
|
294
|
-
with rasterio.open(out_path, "w", **meta) as dst:
|
|
295
|
-
for i in range(1, src.count + 1):
|
|
296
|
-
reproject(
|
|
297
|
-
source=rasterio.band(src, i),
|
|
298
|
-
destination=rasterio.band(dst, i),
|
|
299
|
-
src_transform=src.transform,
|
|
300
|
-
src_crs=src.crs,
|
|
301
|
-
dst_transform=transform,
|
|
302
|
-
dst_crs=target_crs,
|
|
303
|
-
resampling=Resampling.nearest,
|
|
304
|
-
)
|
|
319
|
+
self._reproject_with_validation(path, out_path, target_crs)
|
|
305
320
|
new_paths.append(out_path)
|
|
306
321
|
else:
|
|
307
322
|
new_paths.append(path)
|
|
@@ -310,10 +325,317 @@ class AdaptiveDownloader:
|
|
|
310
325
|
logger.warning("rasterio not installed; skipping reproject action")
|
|
311
326
|
|
|
312
327
|
elif action.action == "compress":
|
|
313
|
-
|
|
328
|
+
method = action.params.get("value", "lzw")
|
|
329
|
+
try:
|
|
330
|
+
import rasterio
|
|
331
|
+
new_paths = []
|
|
332
|
+
for path in result.output_paths:
|
|
333
|
+
if path.suffix.lower() in (".tif", ".tiff"):
|
|
334
|
+
out_path = path.with_stem(f"{path.stem}_{method}")
|
|
335
|
+
with rasterio.open(path) as src:
|
|
336
|
+
profile = src.profile.copy()
|
|
337
|
+
profile.update(compress=method, tiled=True,
|
|
338
|
+
blockxsize=512, blockysize=512)
|
|
339
|
+
with rasterio.open(out_path, "w", **profile) as dst:
|
|
340
|
+
dst.write(src.read())
|
|
341
|
+
new_paths.append(out_path)
|
|
342
|
+
else:
|
|
343
|
+
new_paths.append(path)
|
|
344
|
+
result.output_paths = new_paths
|
|
345
|
+
if new_paths:
|
|
346
|
+
result.output_path = new_paths[0]
|
|
347
|
+
except ImportError:
|
|
348
|
+
logger.warning("rasterio not installed; skipping compress action")
|
|
349
|
+
|
|
350
|
+
elif action.action == "cog":
|
|
351
|
+
compress = action.params.get("value", "deflate")
|
|
352
|
+
try:
|
|
353
|
+
import rasterio, tempfile
|
|
354
|
+
from rasterio.enums import Resampling as RS
|
|
355
|
+
new_paths = []
|
|
356
|
+
for path in result.output_paths:
|
|
357
|
+
if path.suffix.lower() in (".tif", ".tiff"):
|
|
358
|
+
out_path = path.with_stem(f"{path.stem}_cog")
|
|
359
|
+
with rasterio.open(path) as src:
|
|
360
|
+
profile = src.profile.copy()
|
|
361
|
+
profile.update(compress=compress, tiled=True,
|
|
362
|
+
blockxsize=512, blockysize=512,
|
|
363
|
+
interleave="band", driver="GTiff")
|
|
364
|
+
data = src.read()
|
|
365
|
+
with tempfile.NamedTemporaryFile(suffix=".tif", delete=False) as tmp:
|
|
366
|
+
tmp_path = Path(tmp.name)
|
|
367
|
+
with rasterio.open(tmp_path, "w", **profile) as tmp_dst:
|
|
368
|
+
tmp_dst.write(data)
|
|
369
|
+
tmp_dst.build_overviews([2, 4, 8, 16, 32], RS.average)
|
|
370
|
+
tmp_dst.update_tags(ns="rio_overview", resampling="average")
|
|
371
|
+
profile.update(copy_src_overviews=True)
|
|
372
|
+
with rasterio.open(tmp_path) as tmp_src:
|
|
373
|
+
with rasterio.open(out_path, "w", **profile) as cog_dst:
|
|
374
|
+
cog_dst.write(tmp_src.read())
|
|
375
|
+
tmp_path.unlink(missing_ok=True)
|
|
376
|
+
new_paths.append(out_path)
|
|
377
|
+
else:
|
|
378
|
+
new_paths.append(path)
|
|
379
|
+
result.output_paths = new_paths
|
|
380
|
+
if new_paths:
|
|
381
|
+
result.output_path = new_paths[0]
|
|
382
|
+
except ImportError:
|
|
383
|
+
logger.warning("rasterio not installed; skipping cog action")
|
|
384
|
+
|
|
385
|
+
elif action.action == "clip":
|
|
386
|
+
import json
|
|
387
|
+
geom_src = action.params.get("value", "")
|
|
388
|
+
try:
|
|
389
|
+
import rasterio
|
|
390
|
+
from rasterio.mask import mask as rasterio_mask
|
|
391
|
+
shapes = []
|
|
392
|
+
if geom_src.endswith(".geojson") or geom_src.endswith(".json"):
|
|
393
|
+
with open(geom_src) as f:
|
|
394
|
+
gj = json.load(f)
|
|
395
|
+
if gj.get("type") == "FeatureCollection":
|
|
396
|
+
shapes = [feat["geometry"] for feat in gj["features"]]
|
|
397
|
+
elif gj.get("type") == "Feature":
|
|
398
|
+
shapes = [gj["geometry"]]
|
|
399
|
+
else:
|
|
400
|
+
shapes = [gj]
|
|
401
|
+
elif geom_src:
|
|
402
|
+
# bbox string "minx,miny,maxx,maxy"
|
|
403
|
+
try:
|
|
404
|
+
parts = [float(x) for x in geom_src.split(",")]
|
|
405
|
+
from shapely.geometry import box as sbox
|
|
406
|
+
shapes = [sbox(*parts).__geo_interface__]
|
|
407
|
+
except Exception:
|
|
408
|
+
pass
|
|
409
|
+
if shapes:
|
|
410
|
+
new_paths = []
|
|
411
|
+
for path in result.output_paths:
|
|
412
|
+
if path.suffix.lower() in (".tif", ".tiff"):
|
|
413
|
+
out_path = path.with_stem(f"{path.stem}_clip")
|
|
414
|
+
with rasterio.open(path) as src:
|
|
415
|
+
out_img, out_transform = rasterio_mask(src, shapes, crop=True)
|
|
416
|
+
profile = src.profile.copy()
|
|
417
|
+
profile.update(height=out_img.shape[1], width=out_img.shape[2],
|
|
418
|
+
transform=out_transform)
|
|
419
|
+
with rasterio.open(out_path, "w", **profile) as dst:
|
|
420
|
+
dst.write(out_img)
|
|
421
|
+
new_paths.append(out_path)
|
|
422
|
+
else:
|
|
423
|
+
new_paths.append(path)
|
|
424
|
+
result.output_paths = new_paths
|
|
425
|
+
if new_paths:
|
|
426
|
+
result.output_path = new_paths[0]
|
|
427
|
+
except ImportError:
|
|
428
|
+
logger.warning("rasterio/shapely not installed; skipping clip action")
|
|
429
|
+
|
|
430
|
+
elif action.action == "resample":
|
|
431
|
+
val = action.params.get("value", "")
|
|
432
|
+
try:
|
|
433
|
+
import rasterio
|
|
434
|
+
from rasterio.enums import Resampling as RS
|
|
435
|
+
new_paths = []
|
|
436
|
+
for path in result.output_paths:
|
|
437
|
+
if path.suffix.lower() in (".tif", ".tiff"):
|
|
438
|
+
with rasterio.open(path) as src:
|
|
439
|
+
# value is target resolution in CRS units
|
|
440
|
+
try:
|
|
441
|
+
target_res = float(val)
|
|
442
|
+
scale_x = src.res[0] / target_res
|
|
443
|
+
scale_y = src.res[1] / target_res
|
|
444
|
+
except (ValueError, ZeroDivisionError):
|
|
445
|
+
scale_x = scale_y = 0.5
|
|
446
|
+
new_h = max(1, int(src.height * scale_y))
|
|
447
|
+
new_w = max(1, int(src.width * scale_x))
|
|
448
|
+
data = src.read(out_shape=(src.count, new_h, new_w),
|
|
449
|
+
resampling=RS.bilinear)
|
|
450
|
+
transform = src.transform * src.transform.scale(
|
|
451
|
+
src.width / new_w, src.height / new_h)
|
|
452
|
+
profile = src.profile.copy()
|
|
453
|
+
profile.update(height=new_h, width=new_w, transform=transform)
|
|
454
|
+
out_path = path.with_stem(f"{path.stem}_resamp")
|
|
455
|
+
with rasterio.open(out_path, "w", **profile) as dst:
|
|
456
|
+
dst.write(data)
|
|
457
|
+
new_paths.append(out_path)
|
|
458
|
+
else:
|
|
459
|
+
new_paths.append(path)
|
|
460
|
+
result.output_paths = new_paths
|
|
461
|
+
if new_paths:
|
|
462
|
+
result.output_path = new_paths[0]
|
|
463
|
+
except ImportError:
|
|
464
|
+
logger.warning("rasterio not installed; skipping resample action")
|
|
465
|
+
|
|
466
|
+
elif action.action == "ndvi":
|
|
467
|
+
# Compute NDVI from downloaded bands — expects B04 (Red) and B08 (NIR)
|
|
468
|
+
import numpy as np
|
|
469
|
+
try:
|
|
470
|
+
import rasterio
|
|
471
|
+
red_path = next((p for p in result.output_paths if "B04" in p.name or "red" in p.name.lower()), None)
|
|
472
|
+
nir_path = next((p for p in result.output_paths if "B08" in p.name or "nir" in p.name.lower()), None)
|
|
473
|
+
if red_path and nir_path:
|
|
474
|
+
with rasterio.open(red_path) as rs:
|
|
475
|
+
red = rs.read(1).astype(np.float32)
|
|
476
|
+
profile = rs.profile.copy()
|
|
477
|
+
with rasterio.open(nir_path) as ns:
|
|
478
|
+
nir = ns.read(1, out_shape=red.shape,
|
|
479
|
+
resampling=rasterio.enums.Resampling.bilinear).astype(np.float32)
|
|
480
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
481
|
+
ndvi = np.where(nir + red > 0, (nir - red) / (nir + red), -9999.0)
|
|
482
|
+
ndvi_path = red_path.parent / "ndvi.tif"
|
|
483
|
+
profile.update(count=1, dtype="float32", nodata=-9999.0)
|
|
484
|
+
with rasterio.open(ndvi_path, "w", **profile) as dst:
|
|
485
|
+
dst.write(ndvi[np.newaxis, :, :])
|
|
486
|
+
result.output_paths.append(ndvi_path)
|
|
487
|
+
result.output_path = ndvi_path
|
|
488
|
+
logger.info(f"NDVI computed → {ndvi_path}")
|
|
489
|
+
else:
|
|
490
|
+
logger.warning("ndvi action: could not find B04 (Red) and B08 (NIR) in output paths")
|
|
491
|
+
except ImportError:
|
|
492
|
+
logger.warning("rasterio/numpy not installed; skipping ndvi action")
|
|
493
|
+
|
|
494
|
+
elif action.action in ("pan-sharpen", "pan_sharpen"):
|
|
495
|
+
logger.info("pan-sharpen action: use PyGeoFetch preprocess pansharpen for this operation")
|
|
496
|
+
|
|
497
|
+
elif action.action == "merge":
|
|
498
|
+
try:
|
|
499
|
+
import rasterio
|
|
500
|
+
from rasterio.merge import merge
|
|
501
|
+
tifs = [p for p in result.output_paths if p.suffix.lower() in (".tif", ".tiff")]
|
|
502
|
+
if len(tifs) > 1:
|
|
503
|
+
src_files = [rasterio.open(p) for p in tifs]
|
|
504
|
+
mosaic, transform = merge(src_files)
|
|
505
|
+
profile = src_files[0].profile.copy()
|
|
506
|
+
profile.update(height=mosaic.shape[1], width=mosaic.shape[2],
|
|
507
|
+
transform=transform)
|
|
508
|
+
merge_path = tifs[0].parent / "merged.tif"
|
|
509
|
+
with rasterio.open(merge_path, "w", **profile) as dst:
|
|
510
|
+
dst.write(mosaic)
|
|
511
|
+
for s in src_files:
|
|
512
|
+
s.close()
|
|
513
|
+
result.output_paths = [merge_path]
|
|
514
|
+
result.output_path = merge_path
|
|
515
|
+
except ImportError:
|
|
516
|
+
logger.warning("rasterio not installed; skipping merge action")
|
|
314
517
|
|
|
315
518
|
return result
|
|
316
519
|
|
|
520
|
+
def _validate_downloaded_file(self, path) -> tuple:
|
|
521
|
+
"""
|
|
522
|
+
Validate a downloaded raster file by reading a sample tile.
|
|
523
|
+
|
|
524
|
+
For non-raster formats (JSON, XML, CSV), checks file size > 0.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
(is_valid: bool, error_message: str)
|
|
528
|
+
"""
|
|
529
|
+
import os
|
|
530
|
+
path = str(path)
|
|
531
|
+
suffix = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
532
|
+
|
|
533
|
+
# Non-raster: just check readable and non-empty
|
|
534
|
+
NON_RASTER = {"json", "xml", "csv", "txt", "html", "md", "yaml", "yml"}
|
|
535
|
+
if suffix in NON_RASTER:
|
|
536
|
+
try:
|
|
537
|
+
size = os.path.getsize(path)
|
|
538
|
+
if size == 0:
|
|
539
|
+
return False, "File is empty (0 bytes)"
|
|
540
|
+
return True, ""
|
|
541
|
+
except OSError as exc:
|
|
542
|
+
return False, f"Cannot stat file: {exc}"
|
|
543
|
+
|
|
544
|
+
# Raster: try rasterio
|
|
545
|
+
try:
|
|
546
|
+
import rasterio
|
|
547
|
+
with rasterio.open(path) as src:
|
|
548
|
+
if src.count == 0:
|
|
549
|
+
return False, "File has zero bands"
|
|
550
|
+
if src.width == 0 or src.height == 0:
|
|
551
|
+
return False, f"File has zero dimensions: {src.width}x{src.height}"
|
|
552
|
+
windows = list(src.block_windows(1))
|
|
553
|
+
if not windows:
|
|
554
|
+
return False, "File has no readable blocks"
|
|
555
|
+
_, window = windows[0]
|
|
556
|
+
sample = src.read(1, window=window)
|
|
557
|
+
if sample is None or sample.size == 0:
|
|
558
|
+
return False, "First tile returned empty array"
|
|
559
|
+
return True, ""
|
|
560
|
+
except ImportError:
|
|
561
|
+
# rasterio not installed — fall back to size check
|
|
562
|
+
try:
|
|
563
|
+
size = os.path.getsize(path)
|
|
564
|
+
if size == 0:
|
|
565
|
+
return False, "File is empty (0 bytes)"
|
|
566
|
+
return True, ""
|
|
567
|
+
except OSError as exc:
|
|
568
|
+
return False, f"Cannot stat file: {exc}"
|
|
569
|
+
except Exception as exc:
|
|
570
|
+
return False, f"File read validation failed: {exc}"
|
|
571
|
+
|
|
572
|
+
def _has_identity_transform(self, path) -> bool:
|
|
573
|
+
"""
|
|
574
|
+
Detect a reprojection that produced an identity (garbage) transform.
|
|
575
|
+
|
|
576
|
+
Returns True if the file has pixel_width=1, pixel_height=1,
|
|
577
|
+
origin=(0,0) in a projected (metre-unit) CRS — which indicates
|
|
578
|
+
the warp produced no real geographic transformation.
|
|
579
|
+
"""
|
|
580
|
+
try:
|
|
581
|
+
import rasterio
|
|
582
|
+
with rasterio.open(str(path)) as src:
|
|
583
|
+
t = src.transform
|
|
584
|
+
return (
|
|
585
|
+
abs(t.a) == 1.0
|
|
586
|
+
and abs(t.e) == 1.0
|
|
587
|
+
and t.c == 0.0
|
|
588
|
+
and t.f == 0.0
|
|
589
|
+
and src.crs is not None
|
|
590
|
+
and src.crs.is_projected
|
|
591
|
+
)
|
|
592
|
+
except Exception:
|
|
593
|
+
return False
|
|
594
|
+
|
|
595
|
+
def _reproject_with_validation(self, source_path, target_path, target_crs) -> None:
|
|
596
|
+
"""
|
|
597
|
+
Reproject source to target CRS and validate the output transform.
|
|
598
|
+
Raises RuntimeError if reprojection produces an identity transform.
|
|
599
|
+
"""
|
|
600
|
+
import rasterio
|
|
601
|
+
from rasterio.warp import calculate_default_transform, reproject, Resampling
|
|
602
|
+
from rasterio.enums import Resampling as RS
|
|
603
|
+
|
|
604
|
+
with rasterio.open(str(source_path)) as src:
|
|
605
|
+
src_crs = src.crs
|
|
606
|
+
src_transform = src.transform
|
|
607
|
+
src_width = src.width
|
|
608
|
+
src_height = src.height
|
|
609
|
+
|
|
610
|
+
dst_transform, dst_width, dst_height = calculate_default_transform(
|
|
611
|
+
src_crs, target_crs, src_width, src_height, *src.bounds
|
|
612
|
+
)
|
|
613
|
+
kwargs = src.meta.copy()
|
|
614
|
+
kwargs.update({
|
|
615
|
+
"crs": target_crs,
|
|
616
|
+
"transform": dst_transform,
|
|
617
|
+
"width": dst_width,
|
|
618
|
+
"height": dst_height,
|
|
619
|
+
})
|
|
620
|
+
with rasterio.open(str(target_path), "w", **kwargs) as dst:
|
|
621
|
+
for band_idx in range(1, src.count + 1):
|
|
622
|
+
reproject(
|
|
623
|
+
source=rasterio.band(src, band_idx),
|
|
624
|
+
destination=rasterio.band(dst, band_idx),
|
|
625
|
+
src_transform=src_transform,
|
|
626
|
+
src_crs=src_crs,
|
|
627
|
+
dst_transform=dst_transform,
|
|
628
|
+
dst_crs=target_crs,
|
|
629
|
+
resampling=RS.bilinear,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
if self._has_identity_transform(target_path):
|
|
633
|
+
raise RuntimeError(
|
|
634
|
+
f"Reprojection produced identity transform in {target_path}. "
|
|
635
|
+
f"Source: {source_path}, Target CRS: {target_crs}. "
|
|
636
|
+
"This is a known rasterio/GDAL edge case with certain input CRS."
|
|
637
|
+
)
|
|
638
|
+
|
|
317
639
|
def _backoff(self, attempt: int, options: DownloadOptions) -> float:
|
|
318
640
|
"""Calculate retry delay based on strategy."""
|
|
319
641
|
import random
|