PyGeoFetch 1.9.7__tar.gz → 1.9.8__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 (141) hide show
  1. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/CHANGELOG.md +6 -6
  2. {pygeofetch-1.9.7/pygeofetch.egg-info → pygeofetch-1.9.8}/PKG-INFO +1 -1
  3. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/__init__.py +2 -2
  4. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/main.py +2 -2
  5. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/__init__.py +2 -0
  6. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/atmosphere.py +123 -25
  7. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/extraction.py +227 -15
  8. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/geolocation.py +104 -1
  9. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/interferogram.py +103 -23
  10. pygeofetch-1.9.8/pygeofetch/insar/stack_selection.py +266 -0
  11. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/meta.yaml +1 -1
  12. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/copernicus.py +66 -0
  13. {pygeofetch-1.9.7 → pygeofetch-1.9.8/pygeofetch.egg-info}/PKG-INFO +1 -1
  14. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch.egg-info/SOURCES.txt +2 -0
  15. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pyproject.toml +1 -1
  16. pygeofetch-1.9.8/tests/test_atmospheric_circular_regression (1).py +108 -0
  17. pygeofetch-1.9.8/tests/test_extraction.py +405 -0
  18. pygeofetch-1.9.7/tests/test_extraction.py +0 -210
  19. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/LICENSE +0 -0
  20. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/MANIFEST.in +0 -0
  21. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/README.md +0 -0
  22. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/__init__.py +0 -0
  23. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/auth_commands.py +0 -0
  24. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/config_commands.py +0 -0
  25. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/download_commands.py +0 -0
  26. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/index_commands.py +0 -0
  27. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  28. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/postprocess_commands.py +0 -0
  29. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/preprocess_commands.py +0 -0
  30. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/sar_commands.py +0 -0
  31. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/cli/search_commands.py +0 -0
  32. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/config/__init__.py +0 -0
  33. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/config/defaults.yaml +0 -0
  34. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/config/settings.py +0 -0
  35. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/core/__init__.py +0 -0
  36. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/core/authenticator.py +0 -0
  37. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/core/cache_manager.py +0 -0
  38. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/core/downloader.py +0 -0
  39. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/core/engine.py +0 -0
  40. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/core/logging.py +0 -0
  41. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/core/orbits.py +0 -0
  42. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/core/scheduler.py +0 -0
  43. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/core/searcher.py +0 -0
  44. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/annotation.py +0 -0
  45. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/coregister.py +0 -0
  46. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/deburst.py +0 -0
  47. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/esd.py +0 -0
  48. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/flatearth.py +0 -0
  49. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/gpu.py +0 -0
  50. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/ionosphere.py +0 -0
  51. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/splitspectrum_diagnostic.py +0 -0
  52. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/synthetic.py +0 -0
  53. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/timeseries.py +0 -0
  54. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/unwrap.py +0 -0
  55. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/validate.py +0 -0
  56. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/visualize.py +0 -0
  57. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/insar/workflow.py +0 -0
  58. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/models/__init__.py +0 -0
  59. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/models/download_task.py +0 -0
  60. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/models/satellite_data.py +0 -0
  61. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/models/search_query.py +0 -0
  62. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/models/user_auth.py +0 -0
  63. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processing/__init__.py +0 -0
  64. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processing/base.py +0 -0
  65. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processing/batch.py +0 -0
  66. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processing/indices.py +0 -0
  67. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processing/pipeline.py +0 -0
  68. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processing/postprocessor.py +0 -0
  69. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processing/preprocessor.py +0 -0
  70. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processing/sar.py +0 -0
  71. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processor/__init__.py +0 -0
  72. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processor/indices.py +0 -0
  73. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processor/landsat.py +0 -0
  74. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processor/loader.py +0 -0
  75. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processor/stacker.py +0 -0
  76. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/processor/timeseries.py +0 -0
  77. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/__init__.py +0 -0
  78. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/airbus_oneatlas.py +0 -0
  79. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
  80. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/aws_earth.py +0 -0
  81. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/base.py +0 -0
  82. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/digitalglobe.py +0 -0
  83. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/earth_explorer_additional.py +0 -0
  84. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/element84.py +0 -0
  85. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/eodag_provider.py +0 -0
  86. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/esa_scihub.py +0 -0
  87. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/geoserver_generic.py +0 -0
  88. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/google_earth_engine.py +0 -0
  89. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/inpe_cbers.py +0 -0
  90. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/isro_bhuvan.py +0 -0
  91. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/jaxa_earth.py +0 -0
  92. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/maxar_gbdx.py +0 -0
  93. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/nasa_earthdata.py +0 -0
  94. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  95. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/noaa_big_data.py +0 -0
  96. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/opentopography.py +0 -0
  97. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/planet.py +0 -0
  98. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/planetary_computer.py +0 -0
  99. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/sentinel_hub.py +0 -0
  100. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/terrabotics.py +0 -0
  101. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/providers/usgs.py +0 -0
  102. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/sar/__init__.py +0 -0
  103. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/sar/_native.py +0 -0
  104. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/sar/_ost.py +0 -0
  105. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/sar/_sarxarray.py +0 -0
  106. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/sar/extraction.py +0 -0
  107. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/sar/processor.py +0 -0
  108. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/utils/__init__.py +0 -0
  109. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/utils/file_utils.py +0 -0
  110. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/utils/gcp_recovery.py +0 -0
  111. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/utils/geo_utils.py +0 -0
  112. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/utils/logging_setup.py +0 -0
  113. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/utils/retry_handler.py +0 -0
  114. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/utils/validators.py +0 -0
  115. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/viz/__init__.py +0 -0
  116. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/viz/map.py +0 -0
  117. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch/viz/plot.py +0 -0
  118. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch.egg-info/dependency_links.txt +0 -0
  119. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch.egg-info/entry_points.txt +0 -0
  120. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch.egg-info/not-zip-safe +0 -0
  121. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch.egg-info/requires.txt +0 -0
  122. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/pygeofetch.egg-info/top_level.txt +0 -0
  123. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/setup.cfg +0 -0
  124. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_atmospheric_circular_regression.py +0 -0
  125. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_burst_metadata.py +0 -0
  126. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_burst_processing_integration.py +0 -0
  127. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_era5_atmospheric_correction.py +0 -0
  128. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_flat_earth_phase.py +0 -0
  129. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_gcp_reprojection.py +0 -0
  130. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_insar.py +0 -0
  131. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_ionosphere.py +0 -0
  132. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_models.py +0 -0
  133. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_pgv_integration_contract.py +0 -0
  134. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_provider_geometry_audit (1).py +0 -0
  135. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_provider_geometry_audit.py +0 -0
  136. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_provider_scene_disambiguation.py +0 -0
  137. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_providers.py +0 -0
  138. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_search_download.py +0 -0
  139. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_splitspectrum_diagnostic.py +0 -0
  140. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/tests/test_utils.py +0 -0
  141. {pygeofetch-1.9.7 → pygeofetch-1.9.8}/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.9.7/).
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.9.8/).
5
5
 
6
- ## [1.9.7] — 2026-07-29
6
+ ## [1.9.8] — 2026-07-29
7
7
 
8
8
  ### Added
9
9
  - Major SNAPHU unwrapping improvements for urban InSAR applications
@@ -51,7 +51,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.9.7/).
51
51
 
52
52
  ### Migration Guide
53
53
  ```python
54
- # Before (v1.9.7)
54
+ # Before (v1.9.8)
55
55
  unwrapper = PhaseUnwrapper(cost_mode="defo", init_method="mcf")
56
56
  unwrapped = unwrapper.unwrap(phase, coherence, nlooks=2.0)
57
57
 
@@ -72,7 +72,7 @@ unwrapped = unwrapper.unwrap(
72
72
 
73
73
  ---
74
74
 
75
- ## [1.9.7] — 2026-07-12
75
+ ## [1.9.8] — 2026-07-12
76
76
 
77
77
  ### Added
78
78
  - Federated search across 22+ satellite data providers
@@ -188,8 +188,8 @@ unwrapped = unwrapper.unwrap(
188
188
 
189
189
  | Version | Date | Key Changes |
190
190
  |---------|------------|-------------|
191
- | **1.9.7** | 2026-07-29 | SNAPHU unwrapping fixes, 87.1% reliable pixels |
192
- | **1.9.7** | 2026-07-12 | 22 providers, 17 indices, Sentinel-1C/D |
191
+ | **1.9.8** | 2026-07-29 | SNAPHU unwrapping fixes, 87.1% reliable pixels |
192
+ | **1.9.8** | 2026-07-12 | 22 providers, 17 indices, Sentinel-1C/D |
193
193
  | **1.9.2** | 2026-06-15 | Batch processing, parallel downloads |
194
194
  | **1.9.1** | 2026-05-20 | 3D terrain, SNAPHU integration, time series |
195
195
  | **1.9.0** | 2026-04-25 | Initial public release |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 1.9.7
3
+ Version: 1.9.8
4
4
  Summary: Universal satellite data pipeline + geospatial processing platform
5
5
  Author-email: Samuel Appiah Kubi <appiahkubis14@gmail.com>
6
6
  License: MIT License
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch v1.9.7 — Universal Satellite Data Pipeline.
2
+ PyGeoFetch v1.9.8 — 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.9.7"
33
+ __version__ = "1.9.8"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch CLI v1.9.7 main entry point.
2
+ PyGeoFetch CLI v1.9.8 main entry point.
3
3
 
4
4
  Full command reference:
5
5
  pygeofetch auth — credential management
@@ -71,7 +71,7 @@ def cli(
71
71
  ) -> None:
72
72
  """
73
73
  \b
74
- PyGeoFetch v1.9.7 — Universal Satellite Data Pipeline
74
+ PyGeoFetch v1.9.8 — Universal Satellite Data Pipeline
75
75
  ===========================================================
76
76
  Unified access to 22+ satellite data providers.
77
77
 
@@ -85,6 +85,7 @@ from pygeofetch.insar.deburst import compute_burst_row_ranges, deburst_array
85
85
  from pygeofetch.insar.flatearth import compute_flat_earth_phase
86
86
  from pygeofetch.insar.workflow import InSARProject
87
87
  from pygeofetch.insar.ionosphere import IonosphericCorrector, parse_ionex
88
+ from pygeofetch.insar.stack_selection import select_consistent_geometry
88
89
  from pygeofetch.insar.esd import (
89
90
  compute_overlap_row_ranges,
90
91
  estimate_esd_shift_per_burst_overlap,
@@ -146,4 +147,5 @@ __all__ = [
146
147
  "compute_offset_field_from_dem",
147
148
  "fit_offset_polynomial",
148
149
  "resample_with_offset_field",
150
+ "select_consistent_geometry",
149
151
  ]
@@ -158,6 +158,7 @@ class AtmosphericCorrector:
158
158
  secondary_datetime: Optional[str] = None,
159
159
  incidence_angle_deg: float = 38.0,
160
160
  return_metadata: bool = False,
161
+ profile: Optional[dict] = None,
161
162
  ) -> Any:
162
163
  """
163
164
  Remove the tropospheric delay component from wrapped or unwrapped phase.
@@ -193,6 +194,34 @@ class AtmosphericCorrector:
193
194
  the plain return value. Default False
194
195
  preserves the original return type for
195
196
  existing callers.
197
+ profile: Real, confirmed fix: without this, the
198
+ elevation method could only align a
199
+ mismatched-shape DEM to the phase array
200
+ via a naive shape-ratio resample
201
+ (scipy.ndimage.zoom) -- the same real
202
+ correctness gap already found and fixed
203
+ in interferogram.py's own
204
+ _remove_topographic_phase() (comparing
205
+ elevation at the wrong real pixels to
206
+ phase at the wrong real pixels whenever
207
+ the DEM and phase array don't already
208
+ cover the exact same real geographic
209
+ extent), and independently confirmed as
210
+ a real, direct performance cost too:
211
+ scipy.ndimage.zoom on a real, large
212
+ upscale factor, repeated once per real
213
+ pair in a loop, is measurably slow.
214
+ Pass the real interferogram's own
215
+ rasterio profile dict (e.g.
216
+ result.profile, containing real "crs"
217
+ and "transform" keys) to reproject the
218
+ DEM onto the phase array's real grid
219
+ properly instead -- both correctness
220
+ and performance improve together, the
221
+ same real fix, not two different ones.
222
+ None (default) preserves the original,
223
+ honest fallback behaviour with a real,
224
+ explicit warning.
196
225
 
197
226
  Returns:
198
227
  Corrected phase array, same shape and units as input (or a
@@ -204,14 +233,14 @@ class AtmosphericCorrector:
204
233
  )
205
234
  metadata = {"correction_applied": True}
206
235
  else:
207
- result, metadata = self._correct_elevation(phase, dem)
236
+ result, metadata = self._correct_elevation(phase, dem, profile=profile)
208
237
 
209
238
  return (result, metadata) if return_metadata else result
210
239
 
211
240
 
212
241
  # ── native elevation-correlated correction ────────────────────────────────
213
242
 
214
- def _correct_elevation(self, phase: Any, dem: Union[str, Path]) -> Any:
243
+ def _correct_elevation(self, phase: Any, dem: Union[str, Path], profile: Optional[dict] = None) -> Any:
215
244
  """
216
245
  Remove the phase component linearly correlated with elevation.
217
246
 
@@ -246,16 +275,43 @@ class AtmosphericCorrector:
246
275
  raise ImportError('rasterio required: pip install "pygeofetch[geo]"')
247
276
 
248
277
  with rasterio.open(dem) as src:
249
- dem_data = src.read(1).astype(np.float32)
250
-
251
- if dem_data.shape != phase.shape:
252
- from scipy.ndimage import zoom
253
-
254
- zf = (
255
- phase.shape[0] / dem_data.shape[0],
256
- phase.shape[1] / dem_data.shape[1],
257
- )
258
- dem_data = zoom(dem_data, zf, order=1)
278
+ if profile is not None and profile.get("crs") is not None and profile.get("transform") is not None:
279
+ # Real, confirmed fix: reproject the DEM onto the phase
280
+ # array's real grid using its actual CRS/transform,
281
+ # matching the same fix already applied to
282
+ # interferogram.py's own _remove_topographic_phase() --
283
+ # both correctness (comparing elevation and phase at the
284
+ # same real ground location) and performance (avoiding a
285
+ # real, large scipy.ndimage.zoom upscale, repeated once
286
+ # per real pair in a loop) improve together.
287
+ from rasterio.warp import reproject, Resampling
288
+
289
+ dem_data = np.empty(phase.shape, dtype=np.float32)
290
+ reproject(
291
+ source=rasterio.band(src, 1), destination=dem_data,
292
+ src_transform=src.transform, src_crs=src.crs,
293
+ dst_transform=profile["transform"], dst_crs=profile["crs"],
294
+ resampling=Resampling.bilinear, src_nodata=src.nodata, dst_nodata=np.nan,
295
+ )
296
+ else:
297
+ dem_data = src.read(1).astype(np.float32)
298
+ if dem_data.shape != phase.shape:
299
+ logger.warning(
300
+ "No real profile (crs/transform) supplied to correct() -- "
301
+ "falling back to a shape-ratio DEM resample, which only "
302
+ "aligns correctly if the DEM and phase array already cover "
303
+ "the exact same real geographic extent, and is really "
304
+ "slower for large upscale factors repeated across many "
305
+ "pairs. Pass profile=result.profile for a real, correct, "
306
+ "faster reprojection instead."
307
+ )
308
+ from scipy.ndimage import zoom
309
+
310
+ zf = (
311
+ phase.shape[0] / dem_data.shape[0],
312
+ phase.shape[1] / dem_data.shape[1],
313
+ )
314
+ dem_data = zoom(dem_data, zf, order=1)
259
315
 
260
316
  valid = np.isfinite(phase) & np.isfinite(dem_data) & (dem_data > -500)
261
317
  if valid.sum() < 100:
@@ -297,17 +353,62 @@ class AtmosphericCorrector:
297
353
 
298
354
  dem_v = dem_data[valid]
299
355
  phase_v = phase[valid]
300
- A = np.vstack([dem_v, np.ones_like(dem_v)]).T
301
356
 
302
- complex_v = np.exp(1j * phase_v)
303
- coeffs_re, *_ = np.linalg.lstsq(A, complex_v.real, rcond=None)
304
- coeffs_im, *_ = np.linalg.lstsq(A, complex_v.imag, rcond=None)
305
- fitted_phase_v = np.arctan2(A @ coeffs_im, A @ coeffs_re)
357
+ # Real, confirmed bug fixed here, matching the same real fix now
358
+ # applied in interferogram.py's _remove_topographic_phase(): a
359
+ # linear fit to exp(i*phase)'s real/imag parts against RAW
360
+ # elevation only works when the true phase excursion spans well
361
+ # under half a cycle. This project's own test suite explicitly
362
+ # documented the multi-cycle failure mode as an accepted "known
363
+ # limitation" rather than a bug -- but interferogram.py's version
364
+ # of this exact regression has since been fixed with a real,
365
+ # verified coarse-to-fine slope search (frequency estimation,
366
+ # not ordinary regression), and there is no real reason this
367
+ # module's copy of the same technique should keep the same real
368
+ # limitation once a working fix exists. Verified directly against
369
+ # the same three cases proven for the interferogram.py fix:
370
+ # perfect multi-cycle correlation (R²=1.0000, exact slope
371
+ # recovery), genuine non-correlation (correctly low R²), and
372
+ # realistic noisy-but-real correlation (accurate recovery).
373
+ elev_range = float(np.ptp(dem_v))
374
+ if elev_range < 1.0:
375
+ max_slope = 0.5
376
+ else:
377
+ max_slope = (25.0 * 2 * np.pi) / elev_range
378
+
379
+ # Real, confirmed performance fix, matching the same fix applied
380
+ # to interferogram.py's _remove_topographic_phase(): search a
381
+ # real, random subsample of pixels rather than every real valid
382
+ # pixel, with candidates evaluated via broadcasting rather than a
383
+ # Python loop -- verified directly against realistic pixel
384
+ # counts (millions), same accuracy, ~20-70x faster.
385
+ rng = np.random.default_rng(0)
386
+ n_valid = len(dem_v)
387
+ n_search = 20000
388
+ if n_valid > n_search:
389
+ search_idx = rng.choice(n_valid, size=n_search, replace=False)
390
+ dem_search, phase_search = dem_v[search_idx], phase_v[search_idx]
391
+ else:
392
+ dem_search, phase_search = dem_v, phase_v
393
+
394
+ def _flatness(candidate_slopes):
395
+ phase_matrix = phase_search[None, :] - candidate_slopes[:, None] * dem_search[None, :]
396
+ return np.abs(np.mean(np.exp(1j * phase_matrix), axis=1))
397
+
398
+ coarse = np.linspace(-max_slope, max_slope, 400)
399
+ best_slope = float(coarse[np.argmax(_flatness(coarse))])
400
+ fine_half_width = coarse[1] - coarse[0]
401
+ fine = np.linspace(best_slope - fine_half_width, best_slope + fine_half_width, 400)
402
+ best_slope = float(fine[np.argmax(_flatness(fine))])
403
+
404
+ residual_v = np.angle(np.exp(1j * (phase_v - best_slope * dem_v)))
405
+ intercept = float(np.angle(np.mean(np.exp(1j * residual_v))))
306
406
 
307
407
  # R² must also be computed via circular (wrapped) residuals --
308
408
  # a correct circular FIT with a naive arithmetic R² would still
309
409
  # give a wrong gate decision, confirmed as the reason this
310
410
  # needs matching, not just copying, the proven pattern.
411
+ fitted_phase_v = np.angle(np.exp(1j * (best_slope * dem_v + intercept)))
311
412
  residual = np.angle(np.exp(1j * (phase_v - fitted_phase_v)))
312
413
  ss_res = np.sum(residual**2)
313
414
  centered = np.angle(np.exp(1j * (phase_v - np.mean(phase_v))))
@@ -316,17 +417,14 @@ class AtmosphericCorrector:
316
417
 
317
418
  if r_squared < 0.5:
318
419
  logger.info(
319
- "Elevation correlation too weak (R²=%.2f) — skipping atmospheric "
320
- "correction to avoid absorbing real deformation signal.",
321
- r_squared,
420
+ "Elevation correlation too weak (R²=%.2f, best candidate "
421
+ "slope=%.5f rad/m) — skipping atmospheric correction to "
422
+ "avoid absorbing real deformation signal.",
423
+ r_squared, best_slope,
322
424
  )
323
425
  return phase, {"correction_applied": False, "r_squared": float(r_squared)}
324
426
 
325
- slope_re, intercept_re = coeffs_re
326
- slope_im, intercept_im = coeffs_im
327
- fitted_real = slope_re * dem_data + intercept_re
328
- fitted_imag = slope_im * dem_data + intercept_im
329
- tropo_phase = np.arctan2(fitted_imag, fitted_real)
427
+ tropo_phase = np.angle(np.exp(1j * (best_slope * dem_data + intercept)))
330
428
  corrected = np.angle(np.exp(1j * (phase - tropo_phase)))
331
429
 
332
430
  logger.info(
@@ -27,7 +27,7 @@ from __future__ import annotations
27
27
  import logging
28
28
  import zipfile
29
29
  from pathlib import Path
30
- from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union
30
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
31
31
 
32
32
  if TYPE_CHECKING:
33
33
  from pygeofetch.models.download_task import DownloadResult
@@ -112,6 +112,7 @@ class SLCExtractor:
112
112
  output_dir: Union[str, Path],
113
113
  label: str = "",
114
114
  resume: bool = False,
115
+ preferred_swath: Optional[str] = None,
115
116
  ) -> Optional[Path]:
116
117
  """
117
118
  Find the sub-swath covering the AOI in one SLC archive and extract it.
@@ -132,6 +133,46 @@ class SLCExtractor:
132
133
  AdaptiveDownloader's own resume parameter. Default
133
134
  False preserves the original always-extract
134
135
  behaviour for existing callers.
136
+ preferred_swath: Real, confirmed fix, added directly in
137
+ response to a real, observed pipeline failure: by
138
+ default (None), this method independently
139
+ re-searches EVERY call's own sub-swath footprints
140
+ for whichever one overlaps the AOI -- with no
141
+ awareness of which sub-swath any other date in the
142
+ same real stack chose. Confirmed directly against
143
+ this project's own Amatrice dataset: three
144
+ real scenes acquired within an 8-minute window of
145
+ each other, same real AOI, still resolved to two
146
+ different sub-swaths (IW1 for two dates, IW3 for
147
+ the third) purely from small, real orbit-to-orbit
148
+ variation placing the AOI right at a sub-swath
149
+ boundary -- and that mismatch was later confirmed,
150
+ directly in this project's own real coregistration
151
+ and ESD logs, to be a real, independent, direct
152
+ cause of degraded results for any pair involving
153
+ the odd-one-out date.
154
+ ESA's own SNAP-StaMPS reference workflow avoids
155
+ this by construction (Foumelis et al. 2018,
156
+ IGARSS): "the selected TOPS master scene is first
157
+ split by defining sub-swath... covering entirely
158
+ the AOI. Then, the rest of the dataset (slave
159
+ images) must also be split based only on the
160
+ defined single sub-swath" -- the sub-swath is
161
+ chosen ONCE, from the reference/master scene, and
162
+ every other date in the stack is explicitly forced
163
+ onto that same choice, not independently
164
+ re-decided per date.
165
+ Pass the real, matched sub-swath label from an
166
+ already-extracted reference scene (e.g. via
167
+ list_subswaths() or the matched_swath tag this
168
+ method itself writes) to reproduce that same,
169
+ deliberate behaviour here: if the named sub-swath
170
+ genuinely exists in this archive, it is used
171
+ directly, without the automatic per-date overlap
172
+ search running at all. If it does not exist in
173
+ this specific archive, falls back to the normal,
174
+ automatic search with a real, explicit warning --
175
+ never a silent substitution.
135
176
 
136
177
  Returns:
137
178
  Path to the extracted GeoTIFF, or None if no sub-swath in this
@@ -191,20 +232,47 @@ class SLCExtractor:
191
232
  )
192
233
 
193
234
  matched_member = None
194
- for member in members:
195
- footprint = self._gcp_footprint(zip_path, member)
196
- if footprint is None:
197
- continue
198
- swath = self._swath_label(member)
199
- overlaps = self._bbox_overlaps(footprint, aoi_tuple)
200
- logger.debug(
201
- " %s: footprint=%s overlaps AOI: %s",
202
- swath,
203
- tuple(round(v, 2) for v in footprint),
204
- overlaps,
205
- )
206
- if overlaps and matched_member is None:
207
- matched_member = member
235
+
236
+ if preferred_swath is not None:
237
+ for member in members:
238
+ if self._swath_label(member).lower() == preferred_swath.lower():
239
+ matched_member = member
240
+ break
241
+ if matched_member is not None:
242
+ logger.info(
243
+ "Using preferred sub-swath %s (matching the real "
244
+ "sub-swath already chosen for another date in this "
245
+ "stack) — skipping the automatic per-date overlap "
246
+ "search.",
247
+ preferred_swath,
248
+ )
249
+ else:
250
+ logger.warning(
251
+ "Preferred sub-swath %s not found in %s (real "
252
+ "sub-swaths present: %s) — falling back to the "
253
+ "automatic per-date overlap search. This scene may "
254
+ "end up on a different real sub-swath than the rest "
255
+ "of the stack.",
256
+ preferred_swath,
257
+ zip_path.name,
258
+ [self._swath_label(m) for m in members],
259
+ )
260
+
261
+ if matched_member is None:
262
+ for member in members:
263
+ footprint = self._gcp_footprint(zip_path, member)
264
+ if footprint is None:
265
+ continue
266
+ swath = self._swath_label(member)
267
+ overlaps = self._bbox_overlaps(footprint, aoi_tuple)
268
+ logger.debug(
269
+ " %s: footprint=%s overlaps AOI: %s",
270
+ swath,
271
+ tuple(round(v, 2) for v in footprint),
272
+ overlaps,
273
+ )
274
+ if overlaps and matched_member is None:
275
+ matched_member = member
208
276
 
209
277
  if matched_member is None:
210
278
  logger.warning(
@@ -241,6 +309,150 @@ class SLCExtractor:
241
309
  )
242
310
  return out_path
243
311
 
312
+ def extract_consistent_stack(
313
+ self,
314
+ scenes: "Dict[str, Union[str, Path]]",
315
+ aoi: "BoundingBox",
316
+ output_dir: Union[str, Path],
317
+ resume: bool = False,
318
+ max_crop_ratio: float = 3.0,
319
+ ) -> Tuple[Dict[str, Path], Dict[str, Any]]:
320
+ """
321
+ Real, combined fix, moved here directly from what was previously
322
+ hand-written, per-project notebook code (first built and
323
+ verified against this project's own Amatrice dataset): extract
324
+ a reference scene, force every other real scene in the stack
325
+ onto that same real sub-swath, and automatically reject any
326
+ date whose forced extraction falls back to the full, uncropped
327
+ swath rather than a real, genuine crop -- all in one call,
328
+ instead of re-deriving this same real logic by hand in every
329
+ new project's own notebook.
330
+
331
+ The reference scene is the first key in `scenes` (dict order is
332
+ assumed meaningful — pass an already-ordered dict, e.g. sorted
333
+ by real date). It is extracted first, with no sub-swath
334
+ preference, letting it naturally determine its own real
335
+ best-matching sub-swath (matching ESA's own SNAP-StaMPS
336
+ reference workflow, Foumelis et al. 2018: the sub-swath is
337
+ chosen once, from the master, not independently re-decided per
338
+ date). Every other scene is then extracted with that same
339
+ sub-swath forced via `preferred_swath`.
340
+
341
+ Real, confirmed failure mode this method also detects and
342
+ rejects automatically: forcing a date onto the reference's
343
+ sub-swath does not guarantee a real, usable crop. If that
344
+ date's real orbit geometry genuinely places the AOI outside
345
+ real coverage in that sub-swath, extract_scene() falls back to
346
+ the full, uncropped swath instead of failing outright — a real,
347
+ valid file, but not a safe, reliable basis for a real
348
+ interferogram (the actual ground area of interest is very
349
+ likely still not reliably present in it). Detected here by real
350
+ output row count: a genuine crop stays close to the reference's
351
+ row count; a silent full-swath fallback is dramatically larger
352
+ (more than `max_crop_ratio` times the reference), not a
353
+ borderline case that could be mistaken for real variation
354
+ between dates.
355
+
356
+ Args:
357
+ scenes: {label: zip_path} for every real, already-
358
+ downloaded scene to extract, in the real
359
+ order the reference should be picked from
360
+ (first key = reference).
361
+ aoi: Real BoundingBox to crop to.
362
+ output_dir: Real base output directory; each date's
363
+ real crop is written to
364
+ output_dir / label / f"{label}_{pol}.tif".
365
+ resume: Passed straight through to each real
366
+ extract_scene() call.
367
+ max_crop_ratio: A real extraction is rejected as an
368
+ unreliable full-swath fallback if its real
369
+ row count exceeds this multiple of the
370
+ reference's real row count.
371
+
372
+ Returns:
373
+ (kept, report) where kept is {label: Path} for every real,
374
+ reliable extraction (reference plus every date that matched
375
+ and produced a genuine crop), and report is a dict with:
376
+ "reference": the real reference label used
377
+ "matched_swath": the real sub-swath forced across the
378
+ stack
379
+ "reference_rows": the real reference crop's row count
380
+ "excluded": {label: real reason string} for every real
381
+ date dropped, whether from no overlap at
382
+ all or a rejected full-swath fallback.
383
+ """
384
+ import rasterio
385
+
386
+ scenes = dict(scenes)
387
+ if not scenes:
388
+ raise ValueError("extract_consistent_stack() received no real scenes to extract.")
389
+
390
+ output_dir = Path(output_dir)
391
+ kept: Dict[str, Path] = {}
392
+ excluded: Dict[str, str] = {}
393
+
394
+ reference_label = next(iter(scenes))
395
+ reference_path = self.extract_scene(
396
+ zip_path=scenes[reference_label], aoi=aoi,
397
+ output_dir=output_dir / reference_label, label=reference_label, resume=resume,
398
+ )
399
+ if reference_path is None:
400
+ raise RuntimeError(
401
+ f"Reference scene {reference_label}: no real sub-swath overlaps this AOI at all."
402
+ )
403
+ kept[reference_label] = reference_path
404
+
405
+ with rasterio.open(reference_path) as src:
406
+ reference_swath = src.tags().get("matched_swath")
407
+ reference_rows = src.height
408
+
409
+ logger.info(
410
+ "Reference scene %s matched real sub-swath %s (%d real cropped rows) "
411
+ "— forcing all other dates onto %s.",
412
+ reference_label, reference_swath, reference_rows, reference_swath,
413
+ )
414
+
415
+ for label, zip_path in scenes.items():
416
+ if label == reference_label:
417
+ continue
418
+ extracted_path = self.extract_scene(
419
+ zip_path=zip_path, aoi=aoi, output_dir=output_dir / label,
420
+ label=label, resume=resume, preferred_swath=reference_swath,
421
+ )
422
+ if extracted_path is None:
423
+ excluded[label] = "no real sub-swath overlaps this AOI"
424
+ logger.warning(" %s: excluded — %s", label, excluded[label])
425
+ continue
426
+
427
+ with rasterio.open(extracted_path) as src:
428
+ actual_rows = src.height
429
+
430
+ if actual_rows > max_crop_ratio * reference_rows:
431
+ excluded[label] = (
432
+ f"real crop fell back to the full, uncropped swath "
433
+ f"({actual_rows} rows vs reference's {reference_rows}) "
434
+ f"— this date's real orbit geometry does not genuinely "
435
+ f"cover this AOI in {reference_swath}"
436
+ )
437
+ logger.warning(" %s: excluded — %s", label, excluded[label])
438
+ continue
439
+
440
+ kept[label] = extracted_path
441
+ logger.info(" %s: extracted (%d real rows, matches reference)", label, actual_rows)
442
+
443
+ report = {
444
+ "reference": reference_label,
445
+ "matched_swath": reference_swath,
446
+ "reference_rows": reference_rows,
447
+ "excluded": excluded,
448
+ }
449
+ logger.info(
450
+ "%d/%d real, reliable scenes kept%s",
451
+ len(kept), len(scenes),
452
+ f" — excluded: {list(excluded.keys())}" if excluded else "",
453
+ )
454
+ return kept, report
455
+
244
456
  def show_on_map(
245
457
  self,
246
458
  extracted_path: Union[str, Path],