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.
Files changed (44) hide show
  1. pygeofetch/__init__.py +2 -2
  2. pygeofetch/cli/main.py +4 -5
  3. pygeofetch/core/authenticator.py +30 -1
  4. pygeofetch/core/downloader.py +367 -45
  5. pygeofetch/core/engine.py +116 -11
  6. pygeofetch/core/logging.py +199 -0
  7. pygeofetch/core/orbits.py +241 -0
  8. pygeofetch/core/searcher.py +3 -8
  9. pygeofetch/models/satellite_data.py +220 -9
  10. pygeofetch/models/search_query.py +19 -0
  11. pygeofetch/processing/pipeline.py +63 -0
  12. pygeofetch/providers/airbus_oneatlas.py +4 -0
  13. pygeofetch/providers/alaska_satellite_facility.py +4 -0
  14. pygeofetch/providers/aws_earth.py +9 -0
  15. pygeofetch/providers/copernicus.py +137 -15
  16. pygeofetch/providers/digitalglobe.py +4 -0
  17. pygeofetch/providers/earth_explorer_additional.py +4 -0
  18. pygeofetch/providers/element84.py +4 -0
  19. pygeofetch/providers/esa_scihub.py +4 -0
  20. pygeofetch/providers/geoserver_generic.py +4 -0
  21. pygeofetch/providers/google_earth_engine.py +4 -0
  22. pygeofetch/providers/inpe_cbers.py +4 -0
  23. pygeofetch/providers/isro_bhuvan.py +4 -0
  24. pygeofetch/providers/jaxa_earth.py +4 -0
  25. pygeofetch/providers/maxar_gbdx.py +4 -0
  26. pygeofetch/providers/nasa_earthdata.py +6 -2
  27. pygeofetch/providers/nasa_earthdata_cloud.py +4 -0
  28. pygeofetch/providers/noaa_big_data.py +4 -0
  29. pygeofetch/providers/opentopography.py +4 -0
  30. pygeofetch/providers/planet.py +6 -2
  31. pygeofetch/providers/planetary_computer.py +11 -12
  32. pygeofetch/providers/sentinel_hub.py +5 -1
  33. pygeofetch/providers/terrabotics.py +4 -0
  34. pygeofetch/providers/usgs.py +42 -15
  35. pygeofetch/utils/geo_utils.py +26 -0
  36. pygeofetch/utils/logging_setup.py +34 -96
  37. {pygeofetch-1.0.2.dist-info → pygeofetch-1.0.4.dist-info}/METADATA +35 -8
  38. pygeofetch-1.0.4.dist-info/RECORD +74 -0
  39. {pygeofetch-1.0.2.dist-info → pygeofetch-1.0.4.dist-info}/WHEEL +1 -1
  40. pygeofetch-1.0.4.dist-info/entry_points.txt +2 -0
  41. pygeofetch-1.0.2.dist-info/RECORD +0 -72
  42. pygeofetch-1.0.2.dist-info/entry_points.txt +0 -2
  43. {pygeofetch-1.0.2.dist-info → pygeofetch-1.0.4.dist-info}/licenses/LICENSE +0 -0
  44. {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 — Universal Satellite Data Pipeline.
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.2"
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 main entry point.
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.2 — Universal Satellite Data Pipeline
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()
@@ -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.utils.logging_setup import get_logger
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.
@@ -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.utils.logging_setup import get_logger
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
- if options.verify_checksum:
106
- result = self._verify_checksums(result, data, options)
107
- if options.post_process:
108
- result = self._run_post_process(result, options)
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
- f"Downloaded {data.id!r}: {size_mb:.1f} MB "
113
- f"in {result.duration_seconds:.1f}s"
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
- completed = sum(1 for r in results if r and r.success)
197
- failed = sum(1 for r in results if r and not r.success)
198
- logger.info(f"Downloads complete: {completed} succeeded, {failed} failed")
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
- return results # type: ignore
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 session from auth manager."""
204
- if provider_id not in self._provider_cache:
205
- from pygeofetch.providers import get_provider
206
- prov = get_provider(provider_id)
207
- if self.auth_manager and prov.REQUIRES_AUTH:
208
- try:
209
- session = self.auth_manager.authenticate(provider_id)
210
- prov.set_session(session)
211
- except Exception as exc:
212
- logger.warning(f"Auth for {provider_id} failed: {exc}")
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
- with rasterio.open(path) as src:
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
- logger.debug(f"Compress action {action.params} noted (requires rasterio)")
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