PyGeoFetch 1.3.0__tar.gz → 1.4.0__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.
Files changed (111) hide show
  1. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/CHANGELOG.md +2 -2
  2. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/LICENSE +1 -1
  3. {pygeofetch-1.3.0/pygeofetch.egg-info → pygeofetch-1.4.0}/PKG-INFO +5 -5
  4. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/README.md +2 -2
  5. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/__init__.py +1 -1
  6. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/meta.yaml +1 -1
  7. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/preprocessor.py +117 -7
  8. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/sar.py +45 -8
  9. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/timeseries.py +66 -30
  10. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/copernicus.py +89 -9
  11. pygeofetch-1.4.0/pygeofetch/sar/__init__.py +22 -0
  12. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/sar/_native.py +5 -4
  13. pygeofetch-1.4.0/pygeofetch/sar/extraction.py +189 -0
  14. {pygeofetch-1.3.0 → pygeofetch-1.4.0/pygeofetch.egg-info}/PKG-INFO +5 -5
  15. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/SOURCES.txt +1 -0
  16. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pyproject.toml +4 -2
  17. pygeofetch-1.3.0/pygeofetch/sar/__init__.py +0 -18
  18. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/MANIFEST.in +0 -0
  19. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/__init__.py +0 -0
  20. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/auth_commands.py +0 -0
  21. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/config_commands.py +0 -0
  22. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/download_commands.py +0 -0
  23. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/index_commands.py +0 -0
  24. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/main.py +0 -0
  25. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  26. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/postprocess_commands.py +0 -0
  27. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/preprocess_commands.py +0 -0
  28. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/sar_commands.py +0 -0
  29. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/search_commands.py +0 -0
  30. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/config/__init__.py +0 -0
  31. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/config/defaults.yaml +0 -0
  32. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/config/settings.py +0 -0
  33. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/__init__.py +0 -0
  34. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/authenticator.py +0 -0
  35. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/cache_manager.py +0 -0
  36. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/downloader.py +0 -0
  37. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/engine.py +0 -0
  38. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/logging.py +0 -0
  39. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/orbits.py +0 -0
  40. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/scheduler.py +0 -0
  41. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/searcher.py +0 -0
  42. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/__init__.py +0 -0
  43. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/atmosphere.py +0 -0
  44. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/extraction.py +0 -0
  45. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/interferogram.py +0 -0
  46. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/timeseries.py +0 -0
  47. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/unwrap.py +0 -0
  48. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/__init__.py +0 -0
  49. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/download_task.py +0 -0
  50. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/satellite_data.py +0 -0
  51. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/search_query.py +0 -0
  52. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/user_auth.py +0 -0
  53. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/__init__.py +0 -0
  54. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/base.py +0 -0
  55. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/batch.py +0 -0
  56. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/indices.py +0 -0
  57. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/pipeline.py +0 -0
  58. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/postprocessor.py +0 -0
  59. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/__init__.py +0 -0
  60. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/indices.py +0 -0
  61. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/landsat.py +0 -0
  62. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/loader.py +0 -0
  63. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/stacker.py +0 -0
  64. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/__init__.py +0 -0
  65. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/airbus_oneatlas.py +0 -0
  66. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
  67. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/aws_earth.py +0 -0
  68. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/base.py +0 -0
  69. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/digitalglobe.py +0 -0
  70. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/earth_explorer_additional.py +0 -0
  71. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/element84.py +0 -0
  72. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/eodag_provider.py +0 -0
  73. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/esa_scihub.py +0 -0
  74. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/geoserver_generic.py +0 -0
  75. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/google_earth_engine.py +0 -0
  76. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/inpe_cbers.py +0 -0
  77. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/isro_bhuvan.py +0 -0
  78. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/jaxa_earth.py +0 -0
  79. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/maxar_gbdx.py +0 -0
  80. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/nasa_earthdata.py +0 -0
  81. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  82. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/noaa_big_data.py +0 -0
  83. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/opentopography.py +0 -0
  84. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/planet.py +0 -0
  85. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/planetary_computer.py +0 -0
  86. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/sentinel_hub.py +0 -0
  87. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/terrabotics.py +0 -0
  88. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/usgs.py +0 -0
  89. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/sar/_ost.py +0 -0
  90. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/sar/_sarxarray.py +0 -0
  91. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/sar/processor.py +0 -0
  92. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/__init__.py +0 -0
  93. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/file_utils.py +0 -0
  94. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/gcp_recovery.py +0 -0
  95. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/geo_utils.py +0 -0
  96. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/logging_setup.py +0 -0
  97. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/retry_handler.py +0 -0
  98. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/validators.py +0 -0
  99. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/viz/__init__.py +0 -0
  100. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/viz/map.py +0 -0
  101. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/viz/plot.py +0 -0
  102. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/dependency_links.txt +0 -0
  103. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/entry_points.txt +0 -0
  104. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/not-zip-safe +0 -0
  105. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/requires.txt +0 -0
  106. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/top_level.txt +0 -0
  107. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/setup.cfg +0 -0
  108. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/tests/test_extraction.py +0 -0
  109. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/tests/test_insar.py +0 -0
  110. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/tests/test_pgv_integration_contract.py +0 -0
  111. {pygeofetch-1.3.0 → pygeofetch-1.4.0}/tests/test_viz_plot.py +0 -0
@@ -1,9 +1,9 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to PyGeoFetch are documented here.
4
- Format follows [Keep a Changelog](https://keepachangelog.com/en/1.3.0/).
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.4.0/).
5
5
 
6
- ## [1.3.0] — 2026-07-12
6
+ ## [1.4.0] — 2026-07-12
7
7
 
8
8
  ### Added
9
9
  - Federated search across 22+ satellite data providers
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 SatelliteBridge Contributors
3
+ Copyright (c) 2026 Samuel Appiah Kubi
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Universal satellite data pipeline + geospatial processing platform
5
- Author-email: pygeofetch Contributors <pygeofetch@example.com>
5
+ Author-email: Samuel Appiah Kubi <appiahkubis14@gmail.com>
6
6
  License: MIT License
7
7
 
8
- Copyright (c) 2024 SatelliteBridge Contributors
8
+ Copyright (c) 2026 Samuel Appiah Kubi
9
9
 
10
10
  Permission is hereby granted, free of charge, to any person obtaining a copy
11
11
  of this software and associated documentation files (the "Software"), to deal
@@ -606,8 +606,8 @@ jupyter lab
606
606
  ---
607
607
 
608
608
  <p align="center">
609
- <img src="https://raw.githubusercontent.com/EOCoreINT/pygeofetch/refs/heads/main/docs/images/trend_map.png?token=GHSAT0AAAAAAD4555HVK7XABUW65FV6B3N22S7TNMA" width="48%" />
610
- <img src="https://raw.githubusercontent.com/EOCoreINT/pygeofetch/refs/heads/main/docs/images/trend_classification.png?token=GHSAT0AAAAAAD4555HVX7RLHLSCQSBSGCIY2S7TMFQ" width="48%" />
609
+ <img src="docs/images/trend_map.png" width="48%" />
610
+ <img src="docs/images/trend_classification.png" width="48%" />
611
611
  </p>
612
612
  <p align="center"><em>NDVI trend (2018–2024) and severity classification for the Obuasi Municipal District, computed end-to-end with PyGeoFetch — real USGS Landsat data, boundary-clipped before processing.</em></p>
613
613
 
@@ -471,8 +471,8 @@ jupyter lab
471
471
  ---
472
472
 
473
473
  <p align="center">
474
- <img src="https://raw.githubusercontent.com/EOCoreINT/pygeofetch/refs/heads/main/docs/images/trend_map.png?token=GHSAT0AAAAAAD4555HVK7XABUW65FV6B3N22S7TNMA" width="48%" />
475
- <img src="https://raw.githubusercontent.com/EOCoreINT/pygeofetch/refs/heads/main/docs/images/trend_classification.png?token=GHSAT0AAAAAAD4555HVX7RLHLSCQSBSGCIY2S7TMFQ" width="48%" />
474
+ <img src="docs/images/trend_map.png" width="48%" />
475
+ <img src="docs/images/trend_classification.png" width="48%" />
476
476
  </p>
477
477
  <p align="center"><em>NDVI trend (2018–2024) and severity classification for the Obuasi Municipal District, computed end-to-end with PyGeoFetch — real USGS Landsat data, boundary-clipped before processing.</em></p>
478
478
 
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "1.3.0"
33
+ __version__ = "1.4.0"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -1,4 +1,4 @@
1
- {% set version = "1.3.0" %}
1
+ {% set version = "1.4.0" %}
2
2
 
3
3
  package:
4
4
  name: pygeofetch
@@ -707,15 +707,63 @@ class Preprocessor:
707
707
  transform_geom(geometry_crs, src.crs, shp) for shp in shapes
708
708
  ]
709
709
 
710
- out_image, out_transform = rasterio_mask(
711
- src, shapes, crop=True, all_touched=all_touched
712
- )
710
+ try:
711
+ out_image, out_transform = rasterio_mask(
712
+ src, shapes, crop=True, all_touched=all_touched, nodata=0
713
+ )
714
+ except ValueError as exc:
715
+ if "do not overlap" not in str(exc):
716
+ raise
717
+ # A near-miss (off by a pixel or two), not a gross CRS
718
+ # mismatch — the geometry and raster ARE in the same CRS
719
+ # and roughly the same place, but floating-point precision
720
+ # in the reprojection, or the AOI sitting right at the
721
+ # edge of the raster's real coverage, can produce a
722
+ # bounding window that misses by a sliver. Retry once with
723
+ # a small buffer (a few pixels' worth, computed from the
724
+ # raster's own resolution) before giving up — a standard,
725
+ # safe technique for exactly this class of edge case.
726
+ from shapely.geometry import shape as _shp_shape
727
+
728
+ px_size = abs(src.transform[0])
729
+ buffer_dist = px_size * 3
730
+ buffered_shapes = [
731
+ _shp_shape(s).buffer(buffer_dist).__geo_interface__
732
+ for s in shapes
733
+ ]
734
+ try:
735
+ out_image, out_transform = rasterio_mask(
736
+ src, buffered_shapes, crop=True, all_touched=all_touched, nodata=0
737
+ )
738
+ logger.warning(
739
+ f"clip(): initial geometry missed the raster by a "
740
+ f"sliver — succeeded after a {buffer_dist:.1f}m "
741
+ f"buffer retry. Raster bounds: {tuple(src.bounds)}, "
742
+ f"CRS: {src.crs}."
743
+ )
744
+ except ValueError:
745
+ # Genuinely doesn't overlap even with a buffer — surface
746
+ # the actual bounds so this is diagnosable directly from
747
+ # the error, instead of a bare "do not overlap" message.
748
+ geom_bounds = _shp_shape(shapes[0]).bounds
749
+ raise ValueError(
750
+ f"Input shapes do not overlap raster, even after a "
751
+ f"buffer retry. Raster bounds: {tuple(src.bounds)} "
752
+ f"(CRS: {src.crs}). Geometry bounds (after any CRS "
753
+ f"reprojection): {geom_bounds}. If these are wildly "
754
+ f"different, the raster's real coverage genuinely "
755
+ f"doesn't include this AOI (check you have the "
756
+ f"right scene/tile) — if they're close, this may be "
757
+ f"a genuine precision edge case worth reporting."
758
+ ) from exc
759
+
713
760
  profile = src.profile.copy()
714
761
  profile.update(
715
762
  dtype=out_image.dtype,
716
763
  height=out_image.shape[1],
717
764
  width=out_image.shape[2],
718
765
  transform=out_transform,
766
+ nodata=0,
719
767
  )
720
768
  with rasterio.open(out_path, "w", **profile) as dst:
721
769
  dst.write(out_image)
@@ -826,11 +874,13 @@ class Preprocessor:
826
874
  input: str | Path,
827
875
  resolution: float | None = None,
828
876
  scale_factor: float | None = None,
877
+ reference: str | Path | None = None,
829
878
  method: str = "bilinear",
830
879
  output: str | None = None,
831
880
  ) -> ProcessingResult:
832
881
  """
833
- Resample raster to a different spatial resolution.
882
+ Resample raster to a different spatial resolution, or align it to
883
+ exactly match another raster's grid.
834
884
 
835
885
  Args:
836
886
  input: Input raster.
@@ -838,8 +888,23 @@ class Preprocessor:
838
888
  For geographic CRS, use degrees; for projected, metres.
839
889
  scale_factor: Alternatively, a scale factor (0.5 = half resolution,
840
890
  2.0 = double resolution). Cannot combine with resolution.
891
+ reference: Alternatively, another raster to match exactly —
892
+ same shape, transform, and CRS as this file, not
893
+ just the same resolution. Two rasters independently
894
+ resampled to "the same resolution" can still have
895
+ different origins/extents and fail to line up
896
+ pixel-for-pixel; `reference` guarantees they do.
897
+ The real, common case this solves: combining
898
+ results from two different sensors (e.g. Sentinel-1
899
+ SAR at ~10m native resolution and Landsat optical
900
+ at 30m) that need to sit on identical pixels before
901
+ being compared or combined. Cannot combine with
902
+ resolution or scale_factor.
841
903
  method: ``"nearest"``, ``"bilinear"``, ``"cubic"``, ``"lanczos"``,
842
- ``"average"``.
904
+ ``"average"``. Use ``"nearest"`` for categorical/
905
+ classified data (class codes, masks) — bilinear
906
+ and the other continuous-data methods will blend
907
+ adjacent classes into meaningless fractional values.
843
908
  output: Output path.
844
909
 
845
910
  Example::
@@ -848,8 +913,13 @@ class Preprocessor:
848
913
  result = client.preprocess.resample("B02_10m.tif", resolution=30)
849
914
  # Downsample by factor of 2
850
915
  result = client.preprocess.resample("scene.tif", scale_factor=0.5)
916
+ # Align a SAR classification mask onto an optical raster's exact grid
917
+ result = client.preprocess.resample(
918
+ "sar_mask.tif", reference="optical_classified.tif", method="nearest",
919
+ )
851
920
  """
852
921
  rasterio = _require_rasterio()
922
+ np = _require_numpy()
853
923
  from rasterio.enums import Resampling as RS
854
924
 
855
925
  rs_map = {
@@ -862,6 +932,46 @@ class Preprocessor:
862
932
  rs_method = rs_map.get(method, RS.bilinear)
863
933
 
864
934
  inp = Path(input)
935
+
936
+ if reference is not None:
937
+ if resolution is not None or scale_factor is not None:
938
+ msg = "Provide only one of resolution, scale_factor, or reference"
939
+ raise ValueError(msg)
940
+
941
+ from rasterio.warp import reproject as _warp_reproject
942
+
943
+ out_path = _resolve_output(inp, output, "aligned")
944
+ out_path.parent.mkdir(parents=True, exist_ok=True)
945
+
946
+ with rasterio.open(reference) as ref_src:
947
+ ref_transform, ref_crs = ref_src.transform, ref_src.crs
948
+ new_h, new_w = ref_src.height, ref_src.width
949
+
950
+ with rasterio.open(inp) as src:
951
+ profile = src.profile.copy()
952
+ profile.update(height=new_h, width=new_w, transform=ref_transform, crs=ref_crs)
953
+ data = np.zeros((src.count, new_h, new_w), dtype=src.dtypes[0])
954
+ for band_idx in range(1, src.count + 1):
955
+ _warp_reproject(
956
+ source=rasterio.band(src, band_idx),
957
+ destination=data[band_idx - 1],
958
+ src_transform=src.transform, src_crs=src.crs,
959
+ dst_transform=ref_transform, dst_crs=ref_crs,
960
+ resampling=rs_method,
961
+ )
962
+
963
+ with rasterio.open(out_path, "w", **profile) as dst:
964
+ dst.write(data)
965
+
966
+ logger.info(f"Aligned to reference grid → {new_w}×{new_h} → {out_path}")
967
+ return ProcessingResult(
968
+ success=True,
969
+ operation="resample",
970
+ input_path=inp,
971
+ output_path=out_path,
972
+ metadata={"method": method, "new_width": new_w, "new_height": new_h, "reference": str(reference)},
973
+ )
974
+
865
975
  res_label = str(resolution or scale_factor).replace(".", "p")
866
976
  out_path = _resolve_output(inp, output, f"resamp_{res_label}m")
867
977
  out_path.parent.mkdir(parents=True, exist_ok=True)
@@ -876,7 +986,7 @@ class Preprocessor:
876
986
  scale_x = scale_factor
877
987
  scale_y = scale_factor
878
988
  else:
879
- msg = "Provide either resolution or scale_factor"
989
+ msg = "Provide either resolution, scale_factor, or reference"
880
990
  raise ValueError(msg)
881
991
 
882
992
  new_h = max(1, int(src.height * scale_y))
@@ -1151,4 +1261,4 @@ class Preprocessor:
1151
1261
  operation=f"composite:{method}",
1152
1262
  output_path=out_path,
1153
1263
  metadata={"n_inputs": len(inputs), "method": method},
1154
- )
1264
+ )
@@ -223,6 +223,7 @@ class SARProcessor:
223
223
  threshold: float = -15.0,
224
224
  output: str | None = None,
225
225
  reference: str | Path | None = None,
226
+ detect_direction: str = "decrease",
226
227
  ) -> ProcessingResult:
227
228
  """
228
229
  Flood mapping from SAR backscatter via simple threshold or change detection.
@@ -230,17 +231,39 @@ class SARProcessor:
230
231
  Args:
231
232
  input: SAR raster in dB (VV or VH polarisation).
232
233
  threshold: Backscatter below this = water (typical -15 to -20 dB).
234
+ Also sets the change-detection sensitivity (see
235
+ detect_direction) as abs(threshold * 0.5).
233
236
  output: Output binary mask (1=water, 0=land, 255=nodata).
234
237
  reference: Optional pre-event reference for change-based detection.
238
+ detect_direction: For change detection only. "decrease" (default)
239
+ flags pixels where backscatter DROPPED — the correct
240
+ signature for open water (smooth surfaces are
241
+ near-specular reflectors). "increase" flags pixels
242
+ where backscatter ROSE — the signature of flooded
243
+ urban/built-up areas, where water at the base of
244
+ buildings creates a double-bounce reflection
245
+ (ground-wall-sensor) stronger than dry ground alone.
246
+ "both" flags either direction — the most robust
247
+ choice when an AOI mixes open water and dense urban
248
+ flooding (e.g. Accra), since a one-directional
249
+ threshold structurally cannot detect the other
250
+ pattern at all, not just detect it poorly.
235
251
 
236
252
  Example::
237
253
 
238
254
  result = client.sar.flood_map("post_event.tif",
239
- reference="pre_event.tif")
255
+ reference="pre_event.tif",
256
+ detect_direction="both")
240
257
  """
241
258
  np = _require_numpy()
242
259
  rasterio = _require_rasterio()
243
260
 
261
+ if detect_direction not in ("decrease", "increase", "both"):
262
+ raise ValueError(
263
+ f"detect_direction must be 'decrease', 'increase', or "
264
+ f"'both', got {detect_direction!r}"
265
+ )
266
+
244
267
  inp = Path(input)
245
268
  out_path = _resolve_output(inp, output, "flood_map")
246
269
 
@@ -251,8 +274,14 @@ class SARProcessor:
251
274
 
252
275
  if reference is not None:
253
276
  ref_d, _, _ = self._read(Path(reference), ref_shape=data.shape)
254
- change = ref_d - data
255
- water_mask = (change > abs(threshold * 0.5)) & valid
277
+ change = ref_d - data # positive = backscatter dropped (open water)
278
+ change_threshold = abs(threshold * 0.5)
279
+ if detect_direction == "decrease":
280
+ water_mask = (change > change_threshold) & valid
281
+ elif detect_direction == "increase":
282
+ water_mask = (change < -change_threshold) & valid
283
+ else: # "both"
284
+ water_mask = (np.abs(change) > change_threshold) & valid
256
285
  else:
257
286
  water_mask = (data < threshold) & valid
258
287
 
@@ -276,15 +305,23 @@ class SARProcessor:
276
305
  dst.write(result[None, :, :])
277
306
 
278
307
  logger.info("Flood map: %.1f%% water → %s", water_pct, out_path.name)
308
+ metadata = {
309
+ "threshold_db": threshold,
310
+ "water_pct": round(float(water_pct), 2),
311
+ }
312
+ if reference is not None:
313
+ metadata["detect_direction"] = detect_direction
314
+ decrease_pct = 100 * np.sum((change > change_threshold) & valid) / (np.sum(valid) + 1e-10)
315
+ increase_pct = 100 * np.sum((change < -change_threshold) & valid) / (np.sum(valid) + 1e-10)
316
+ metadata["decrease_pct"] = round(float(decrease_pct), 2)
317
+ metadata["increase_pct"] = round(float(increase_pct), 2)
318
+
279
319
  return ProcessingResult(
280
320
  success=True,
281
321
  operation="flood_map",
282
322
  input_path=inp,
283
323
  output_path=out_path,
284
- metadata={
285
- "threshold_db": threshold,
286
- "water_pct": round(float(water_pct), 2),
287
- },
324
+ metadata=metadata,
288
325
  )
289
326
 
290
327
  # ── Coherence ────────────────────────────────────────────────────────
@@ -366,4 +403,4 @@ class SARProcessor:
366
403
  operation="coherence",
367
404
  output_path=out_path,
368
405
  metadata={"mean_coherence": round(mean_coh, 4), "window": window},
369
- )
406
+ )
@@ -127,6 +127,7 @@ class TimeSeriesAnalyzer:
127
127
  self,
128
128
  date_bands: Dict[str, Dict[str, Union[str, Path]]],
129
129
  precomputed: bool = False,
130
+ align_grids: bool = True,
130
131
  ) -> IndexTimeStack:
131
132
  """
132
133
  Compute the configured index for every date and stack the results
@@ -141,14 +142,21 @@ class TimeSeriesAnalyzer:
141
142
  but without keeping the profile for zonal use).
142
143
  precomputed: Treat date_bands values as ready-made single-band
143
144
  index rasters instead of raw band dicts.
145
+ align_grids: If a date's raster doesn't share the first date's
146
+ grid (different shape, transform, or CRS), reproject
147
+ it onto the first date's grid automatically rather
148
+ than raising. Default True. This is a real, common
149
+ case: different acquisitions of the same AOI can
150
+ come from different satellite scene footprints
151
+ (tile boundaries, orbit tracks), especially for
152
+ elongated or irregularly-shaped AOIs, and end up
153
+ with slightly different pixel grids after clipping
154
+ even though they cover (nearly) the same area. Set
155
+ False to restore the old strict behaviour (raise on
156
+ any mismatch) if you specifically want that.
144
157
 
145
158
  Returns:
146
159
  IndexTimeStack — sorted by date, with .as_xarray() available.
147
-
148
- All dates must share the same grid (same CRS, transform, shape) —
149
- typically true for repeat acquisitions of the same AOI from the
150
- same sensor. Reproject/resample first if that's not the case for
151
- your data (see the reproject post-process action).
152
160
  """
153
161
  np = _require_numpy()
154
162
  rasterio = _require_rasterio()
@@ -159,40 +167,68 @@ class TimeSeriesAnalyzer:
159
167
  arrays = []
160
168
  profile = None
161
169
  ref_shape = None
170
+ ref_transform = None
171
+ ref_crs = None
162
172
 
163
173
  si = SpectralIndex(prefer_spyndex=False) if not precomputed else None
164
174
 
175
+ def _read_aligned(path, date, label):
176
+ nonlocal profile, ref_shape, ref_transform, ref_crs
177
+ with rasterio.open(path) as src:
178
+ arr = src.read(1).astype(np.float32)
179
+ if profile is None:
180
+ profile = src.profile.copy()
181
+ ref_shape = arr.shape
182
+ ref_transform = src.transform
183
+ ref_crs = src.crs
184
+ return arr
185
+
186
+ same_grid = (
187
+ arr.shape == ref_shape
188
+ and src.transform == ref_transform
189
+ and src.crs == ref_crs
190
+ )
191
+ if same_grid:
192
+ return arr
193
+
194
+ if not align_grids:
195
+ raise ValueError(
196
+ f"{date}/{label}: grid {arr.shape} doesn't match the "
197
+ f"first date's grid {ref_shape}. All dates must "
198
+ f"share the same grid, or call with align_grids=True "
199
+ f"(the default) to align automatically."
200
+ )
201
+
202
+ from rasterio.warp import reproject, Resampling
203
+
204
+ logger.info(
205
+ "%s/%s: grid %s differs from the first date's %s — "
206
+ "reprojecting onto the first date's grid automatically "
207
+ "(align_grids=True). This is expected when different "
208
+ "acquisitions come from different scene footprints over "
209
+ "the same AOI.",
210
+ date, label, arr.shape, ref_shape,
211
+ )
212
+ aligned = np.full(ref_shape, np.nan, dtype=np.float32)
213
+ reproject(
214
+ source=arr, destination=aligned,
215
+ src_transform=src.transform, src_crs=src.crs,
216
+ dst_transform=ref_transform, dst_crs=ref_crs,
217
+ resampling=Resampling.bilinear, src_nodata=np.nan, dst_nodata=np.nan,
218
+ )
219
+ return aligned
220
+
165
221
  for date in dates:
166
222
  entry = date_bands[date]
167
223
 
168
224
  if precomputed:
169
- path = Path(entry) # type: ignore[arg-type]
170
- with rasterio.open(path) as src:
171
- arr = src.read(1).astype(np.float32)
172
- if profile is None:
173
- profile = src.profile.copy()
174
- ref_shape = arr.shape
175
- elif arr.shape != ref_shape:
176
- raise ValueError(
177
- f"{date}: shape {arr.shape} doesn't match the "
178
- f"first date's shape {ref_shape}. All dates must "
179
- f"share the same grid — reproject/resample first."
180
- )
225
+ arr = _read_aligned(Path(entry), date, "index") # type: ignore[arg-type]
181
226
  else:
182
227
  band_arrays = {}
183
228
  for band_name, band_path in entry.items():
184
- with rasterio.open(Path(band_path)) as src:
185
- arr = src.read(1).astype(np.float32)
186
- if profile is None:
187
- profile = src.profile.copy()
188
- ref_shape = arr.shape
189
- elif arr.shape != ref_shape:
190
- raise ValueError(
191
- f"{date}/{band_name}: shape {arr.shape} doesn't "
192
- f"match the first date's shape {ref_shape}. All "
193
- f"bands/dates must share the same grid."
194
- )
195
- band_arrays[band_name.upper()] = arr
229
+ band_arrays[band_name.upper()] = _read_aligned(
230
+ Path(band_path), date, band_name
231
+ )
196
232
  arr = np.asarray(si.compute(self.index_name, **band_arrays), dtype=np.float32)
197
233
 
198
234
  arrays.append(arr)
@@ -397,4 +433,4 @@ class TimeSeriesAnalyzer:
397
433
  with np.errstate(divide="ignore", invalid="ignore"):
398
434
  z = np.where(baseline_std > 0, (target_arr - baseline_mean) / baseline_std, np.nan)
399
435
 
400
- return z
436
+ return z
@@ -338,7 +338,35 @@ class CopernicusProvider(AbstractBaseProvider):
338
338
  if query.end_date:
339
339
  filters.append(f"ContentDate/Start le {query.end_date}T23:59:59.000Z")
340
340
 
341
- if query.bbox:
341
+ # Prefer a real polygon spatial filter over a bounding-box
342
+ # approximation when a geometry is given. Previously query.geometry
343
+ # was never checked here at all — only query.bbox — meaning any
344
+ # search using geometry= (not bbox=) had NO spatial constraint
345
+ # applied whatsoever: the query scanned Copernicus's entire global
346
+ # archive for the date/product-type/polarisation criteria, sorted
347
+ # by most-recent-first, with location playing no role at all. This
348
+ # is confirmed to be the actual explanation for a real search
349
+ # returning a Sentinel-1 scene over the Yucatán Peninsula, Mexico
350
+ # for a query intended for Accra, Ghana — not a GCP-reading or
351
+ # georeferencing bug downstream, which was independently tested
352
+ # and ruled out. This is the exact same bug class (a field never
353
+ # checked at all) found and fixed for USGS earlier — it just
354
+ # hadn't been checked for Copernicus specifically until now.
355
+ geometry = getattr(query, "geometry", None)
356
+ if geometry:
357
+ geom_type = geometry.get("type", "Polygon")
358
+ coordinates = geometry.get("coordinates")
359
+ if coordinates and geom_type == "Polygon":
360
+ # OData.CSC.Intersects expects a WKT POLYGON — build it
361
+ # directly from the GeoJSON ring, not a bbox approximation,
362
+ # so search results are constrained to the real AOI shape.
363
+ ring = coordinates[0]
364
+ coord_str = ",".join(f"{lon} {lat}" for lon, lat in ring)
365
+ polygon = f"POLYGON(({coord_str}))"
366
+ filters.append(
367
+ f"OData.CSC.Intersects(area=geography'SRID=4326;{polygon}')"
368
+ )
369
+ elif query.bbox:
342
370
  bbox = query.bbox
343
371
  polygon = (
344
372
  f"POLYGON(("
@@ -727,10 +755,24 @@ class CopernicusProvider(AbstractBaseProvider):
727
755
  for attempt in range(1, max_attempts + 1):
728
756
  try:
729
757
  start_time = time.time()
758
+
759
+ # Resume from where the previous attempt left off, rather
760
+ # than deleting the partial file and restarting the whole
761
+ # download from zero on every retry — confirmed this was
762
+ # happening: for a multi-GB Sentinel-1 product, a dropped
763
+ # connection at 1GB+ into the transfer meant every retry
764
+ # re-downloaded that same 1GB+ again before even reaching
765
+ # new data, on a connection that had just shown it
766
+ # couldn't sustain the transfer in the first place.
767
+ existing_bytes = output_file.stat().st_size if output_file.exists() else 0
768
+ headers = {"Authorization": f"Bearer {self._session.access_token}"} # type: ignore
769
+ if existing_bytes > 0:
770
+ headers["Range"] = f"bytes={existing_bytes}-"
771
+
730
772
  with httpx.stream(
731
773
  "GET",
732
774
  href,
733
- headers={"Authorization": f"Bearer {self._session.access_token}"}, # type: ignore
775
+ headers=headers,
734
776
  timeout=options.timeout_seconds,
735
777
  follow_redirects=True,
736
778
  ) as resp:
@@ -749,17 +791,33 @@ class CopernicusProvider(AbstractBaseProvider):
749
791
  response=resp,
750
792
  )
751
793
  self._handle_http_error(resp)
794
+
795
+ # CDSE is documented to sometimes ignore Range headers
796
+ # and return the full file (200) instead of resuming
797
+ # (206) — check which actually happened rather than
798
+ # assume the Range request was honoured.
799
+ resumed = existing_bytes > 0 and resp.status_code == 206
800
+ if existing_bytes > 0 and resp.status_code == 200:
801
+ # Server sent the whole file despite the Range
802
+ # request — start writing from scratch, not append,
803
+ # or the file would be corrupted with duplicated
804
+ # content at the front.
805
+ existing_bytes = 0
806
+
752
807
  total_bytes = int(resp.headers.get("content-length", 0))
753
- bytes_written = 0
808
+ if resumed:
809
+ total_bytes += existing_bytes
810
+ bytes_written = existing_bytes
754
811
  chunk_t0 = time.time()
755
- with open(output_file, "wb") as f:
812
+ write_mode = "ab" if resumed else "wb"
813
+ with open(output_file, write_mode) as f:
756
814
  for chunk in resp.iter_bytes(
757
815
  chunk_size=int(options.chunk_size_mb * 1024 * 1024)
758
816
  ):
759
817
  f.write(chunk)
760
818
  bytes_written += len(chunk)
761
819
  elapsed = time.time() - chunk_t0
762
- speed = bytes_written / elapsed if elapsed > 0 else 0.0
820
+ speed = (bytes_written - existing_bytes) / elapsed if elapsed > 0 else 0.0
763
821
  report_download_progress(bytes_written, total_bytes, speed)
764
822
 
765
823
  duration = time.time() - start_time
@@ -782,15 +840,37 @@ class CopernicusProvider(AbstractBaseProvider):
782
840
  except Exception as exc:
783
841
  last_error = str(exc)
784
842
  last_error_type = type(exc).__name__
785
- output_file.unlink(missing_ok=True)
843
+ # No longer unconditionally deleting the partial file here —
844
+ # the next attempt (or the whole retry loop, if this was
845
+ # the final attempt) needs it intact to resume from.
786
846
  if attempt < max_attempts:
787
- backoff = min(2**attempt, 30)
847
+ # Base backoff scaled by how much has already been
848
+ # downloaded — a connection that just dropped a
849
+ # multi-GB transfer needs meaningfully more recovery
850
+ # time than the previous flat min(2**attempt, 30)
851
+ # gave it (as little as 1-2 seconds on early attempts,
852
+ # confirmed against a real 1GB+ download that had
853
+ # just timed out).
854
+ current_size_gb = (
855
+ output_file.stat().st_size / (1024**3)
856
+ if output_file.exists()
857
+ else 0
858
+ )
859
+ size_factor = max(1.0, current_size_gb * 10)
860
+ backoff = min(15 * size_factor * attempt, 120)
788
861
  self._logger.warning(
789
862
  f"Download attempt {attempt}/{max_attempts} failed for "
790
- f"{data.id}: {last_error}. Retrying in {backoff}s..."
863
+ f"{data.id}: {last_error}. Resuming from "
864
+ f"{output_file.stat().st_size / (1024**2):.0f}MB in "
865
+ f"{backoff:.0f}s..."
866
+ if output_file.exists()
867
+ else f"Download attempt {attempt}/{max_attempts} failed for "
868
+ f"{data.id}: {last_error}. Retrying in {backoff:.0f}s..."
791
869
  )
792
870
  time.sleep(backoff)
793
871
 
872
+ # Final failure — clean up only now, not on every intermediate retry
873
+ output_file.unlink(missing_ok=True)
794
874
  return DownloadResult(
795
875
  status=DownloadStatus.FAILED,
796
876
  data_id=data.id,
@@ -917,4 +997,4 @@ class CopernicusProvider(AbstractBaseProvider):
917
997
  "note": "Copernicus provides free access; rate limits may apply",
918
998
  "docs": "https://dataspace.copernicus.eu/terms-and-conditions",
919
999
  },
920
- )
1000
+ )
@@ -0,0 +1,22 @@
1
+ """
2
+ PyGeoFetch SAR — optional SAR processing layer.
3
+
4
+ Lightweight backend (sarxarray): pip install "pygeofetch[sar]"
5
+ Heavy backend (SNAP via OST): pip install "pygeofetch[ost]"
6
+
7
+ Usage::
8
+
9
+ from pygeofetch.sar import SARProcessor, GRDExtractor
10
+
11
+ extractor = GRDExtractor(polarisation="VV")
12
+ vv_path = extractor.extract_band(download_result, output_dir="./data")
13
+
14
+ proc = SARProcessor(backend="sarxarray") # lightweight
15
+ result = proc.calibrate(str(vv_path), output_type="sigma0")
16
+ result = proc.despeckle("calibrated.tif", filter="lee")
17
+ """
18
+
19
+ from pygeofetch.sar.extraction import GRDExtractor, georeference_via_gcps_if_needed
20
+ from pygeofetch.sar.processor import SARProcessor
21
+
22
+ __all__ = ["SARProcessor", "GRDExtractor", "georeference_via_gcps_if_needed"]