PyGeoFetch 2.0.0__tar.gz → 2.0.1__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 (145) hide show
  1. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/CHANGELOG.md +6 -6
  2. {pygeofetch-2.0.0/pygeofetch.egg-info → pygeofetch-2.0.1}/PKG-INFO +2 -2
  3. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/__init__.py +2 -2
  4. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/main.py +2 -2
  5. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/coregister.py +140 -52
  6. pygeofetch-2.0.1/pygeofetch/insar/esd.py +361 -0
  7. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/interferogram.py +117 -31
  8. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/meta.yaml +1 -1
  9. {pygeofetch-2.0.0 → pygeofetch-2.0.1/pygeofetch.egg-info}/PKG-INFO +2 -2
  10. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch.egg-info/requires.txt +1 -1
  11. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pyproject.toml +2 -2
  12. pygeofetch-2.0.0/pygeofetch/insar/esd.py +0 -333
  13. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/LICENSE +0 -0
  14. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/MANIFEST.in +0 -0
  15. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/README.md +0 -0
  16. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/__init__.py +0 -0
  17. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/auth_commands.py +0 -0
  18. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/config_commands.py +0 -0
  19. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/download_commands.py +0 -0
  20. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/index_commands.py +0 -0
  21. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  22. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/postprocess_commands.py +0 -0
  23. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/preprocess_commands.py +0 -0
  24. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/sar_commands.py +0 -0
  25. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/cli/search_commands.py +0 -0
  26. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/config/__init__.py +0 -0
  27. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/config/defaults.yaml +0 -0
  28. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/config/settings.py +0 -0
  29. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/core/__init__.py +0 -0
  30. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/core/authenticator.py +0 -0
  31. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/core/cache_manager.py +0 -0
  32. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/core/downloader.py +0 -0
  33. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/core/engine.py +0 -0
  34. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/core/logging.py +0 -0
  35. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/core/orbits.py +0 -0
  36. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/core/scheduler.py +0 -0
  37. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/core/searcher.py +0 -0
  38. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/__init__.py +0 -0
  39. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/annotation.py +0 -0
  40. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/atmosphere.py +0 -0
  41. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/deburst.py +0 -0
  42. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/extraction.py +0 -0
  43. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/flatearth.py +0 -0
  44. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/geolocation.py +0 -0
  45. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/gpu.py +0 -0
  46. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/ionosphere.py +0 -0
  47. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/splitspectrum_diagnostic.py +0 -0
  48. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/stack_selection.py +0 -0
  49. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/synthetic.py +0 -0
  50. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/timeseries.py +0 -0
  51. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/unwrap.py +0 -0
  52. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/validate.py +0 -0
  53. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/visualize.py +0 -0
  54. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/insar/workflow.py +0 -0
  55. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/models/__init__.py +0 -0
  56. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/models/download_task.py +0 -0
  57. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/models/satellite_data.py +0 -0
  58. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/models/search_query.py +0 -0
  59. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/models/user_auth.py +0 -0
  60. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processing/__init__.py +0 -0
  61. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processing/base.py +0 -0
  62. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processing/batch.py +0 -0
  63. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processing/indices.py +0 -0
  64. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processing/pipeline.py +0 -0
  65. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processing/postprocessor.py +0 -0
  66. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processing/preprocessor.py +0 -0
  67. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processing/sar.py +0 -0
  68. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processor/__init__.py +0 -0
  69. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processor/indices.py +0 -0
  70. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processor/landsat.py +0 -0
  71. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processor/loader.py +0 -0
  72. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processor/stacker.py +0 -0
  73. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/processor/timeseries.py +0 -0
  74. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/__init__.py +0 -0
  75. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/airbus_oneatlas.py +0 -0
  76. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
  77. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/aws_earth.py +0 -0
  78. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/base.py +0 -0
  79. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/copernicus.py +0 -0
  80. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/digitalglobe.py +0 -0
  81. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/earth_explorer_additional.py +0 -0
  82. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/element84.py +0 -0
  83. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/eodag_provider.py +0 -0
  84. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/esa_scihub.py +0 -0
  85. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/geoserver_generic.py +0 -0
  86. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/google_earth_engine.py +0 -0
  87. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/inpe_cbers.py +0 -0
  88. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/isro_bhuvan.py +0 -0
  89. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/jaxa_earth.py +0 -0
  90. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/maxar_gbdx.py +0 -0
  91. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/nasa_earthdata.py +0 -0
  92. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  93. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/noaa_big_data.py +0 -0
  94. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/opentopography.py +0 -0
  95. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/planet.py +0 -0
  96. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/planetary_computer.py +0 -0
  97. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/sentinel_hub.py +0 -0
  98. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/terrabotics.py +0 -0
  99. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/providers/usgs.py +0 -0
  100. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/sar/__init__.py +0 -0
  101. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/sar/_native.py +0 -0
  102. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/sar/_ost.py +0 -0
  103. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/sar/_sarxarray.py +0 -0
  104. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/sar/extraction.py +0 -0
  105. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/sar/processor.py +0 -0
  106. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/utils/__init__.py +0 -0
  107. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/utils/file_utils.py +0 -0
  108. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/utils/gcp_recovery.py +0 -0
  109. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/utils/geo_utils.py +0 -0
  110. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/utils/logging_setup.py +0 -0
  111. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/utils/retry_handler.py +0 -0
  112. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/utils/validators.py +0 -0
  113. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/viz/__init__.py +0 -0
  114. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/viz/map.py +0 -0
  115. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch/viz/plot.py +0 -0
  116. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch.egg-info/SOURCES.txt +0 -0
  117. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch.egg-info/dependency_links.txt +0 -0
  118. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch.egg-info/entry_points.txt +0 -0
  119. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch.egg-info/not-zip-safe +0 -0
  120. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/pygeofetch.egg-info/top_level.txt +0 -0
  121. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/setup.cfg +0 -0
  122. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_atmospheric_circular_regression (1).py +0 -0
  123. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_atmospheric_circular_regression.py +0 -0
  124. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_build_sbas_network.py +0 -0
  125. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_burst_metadata.py +0 -0
  126. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_burst_processing_integration.py +0 -0
  127. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_coregister_integration.py +0 -0
  128. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_coregister_upgrades.py +0 -0
  129. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_era5_atmospheric_correction.py +0 -0
  130. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_extraction.py +0 -0
  131. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_flat_earth_phase.py +0 -0
  132. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_gcp_reprojection.py +0 -0
  133. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_insar.py +0 -0
  134. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_ionosphere.py +0 -0
  135. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_models.py +0 -0
  136. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_pgv_integration_contract.py +0 -0
  137. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_provider_geometry_audit (1).py +0 -0
  138. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_provider_geometry_audit.py +0 -0
  139. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_provider_scene_disambiguation.py +0 -0
  140. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_providers.py +0 -0
  141. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_raster_collocation_integration.py +0 -0
  142. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_search_download.py +0 -0
  143. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_splitspectrum_diagnostic.py +0 -0
  144. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/tests/test_utils.py +0 -0
  145. {pygeofetch-2.0.0 → pygeofetch-2.0.1}/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/2.0.0/).
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/2.0.1/).
5
5
 
6
- ## [2.0.0] — 2026-07-29
6
+ ## [2.0.1] — 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/2.0.0/).
51
51
 
52
52
  ### Migration Guide
53
53
  ```python
54
- # Before (v2.0.0)
54
+ # Before (v2.0.1)
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
- ## [2.0.0] — 2026-07-12
75
+ ## [2.0.1] — 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
- | **2.0.0** | 2026-07-29 | SNAPHU unwrapping fixes, 87.1% reliable pixels |
192
- | **2.0.0** | 2026-07-12 | 22 providers, 17 indices, Sentinel-1C/D |
191
+ | **2.0.1** | 2026-07-29 | SNAPHU unwrapping fixes, 87.1% reliable pixels |
192
+ | **2.0.1** | 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: 2.0.0
3
+ Version: 2.0.1
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
@@ -85,7 +85,7 @@ Requires-Dist: localtileserver>=1.0.0; extra == "viz"
85
85
  Requires-Dist: ipysheet>=0.6.0; extra == "viz"
86
86
  Requires-Dist: ipyleaflet>=0.17.0; extra == "viz"
87
87
  Requires-Dist: ipywidgets>=8.0.0; extra == "viz"
88
- Requires-Dist: ipyevents>=2.0.0; extra == "viz"
88
+ Requires-Dist: ipyevents>=2.0.1; extra == "viz"
89
89
  Requires-Dist: ipyfilechooser>=0.6.0; extra == "viz"
90
90
  Requires-Dist: sidecar>=0.6.0; extra == "viz"
91
91
  Requires-Dist: bokeh>=3.0.0; extra == "viz"
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch v2.0.0 — Universal Satellite Data Pipeline.
2
+ PyGeoFetch v2.0.1 — 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__ = "2.0.0"
33
+ __version__ = "2.0.1"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch CLI v2.0.0 main entry point.
2
+ PyGeoFetch CLI v2.0.1 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 v2.0.0 — Universal Satellite Data Pipeline
74
+ PyGeoFetch v2.0.1 — Universal Satellite Data Pipeline
75
75
  ===========================================================
76
76
  Unified access to 22+ satellite data providers.
77
77
 
@@ -665,23 +665,67 @@ def _sample_complex_imagette(data, center_row: float, center_col: float, size: i
665
665
  return (real + 1j * imag).astype(np.complex64)
666
666
 
667
667
 
668
- def _imagette_coherence(ref_imagette, sec_imagette) -> float:
668
+ # def _imagette_coherence(ref_imagette, sec_imagette) -> float:
669
+ # """
670
+ # Single scalar complex coherence between two same-shape imagettes —
671
+ # SNAP CrossCorrelationOp's "Method 1" (no internal sliding window;
672
+ # the whole imagette is treated as one coherence estimation window):
673
+ # https://step.esa.int/main/wp-content/help/versions/13.0.0/snap-toolboxes/eu.esa.microwavetbx.sar.op.insar.ui/operators/CrossCorrelationOp.html
674
+ # """
675
+ # import numpy as np
676
+
677
+ # num = abs(np.mean(ref_imagette * np.conj(sec_imagette)))
678
+ # denom = np.sqrt(
679
+ # np.mean(np.abs(ref_imagette) ** 2) * np.mean(np.abs(sec_imagette) ** 2)
680
+ # )
681
+ # if denom < 1e-12:
682
+ # return 0.0
683
+ # return float(np.clip(num / denom, 0.0, 1.0))
684
+
685
+
686
+ # def _imagette_coherence(ref_imagette, sec_imagette) -> float:
687
+
688
+ # import numpy as np
689
+
690
+ # num = abs(np.mean(ref_imagette * np.conj(sec_imagette)))
691
+ # denom = np.sqrt(np.mean(np.abs(ref_imagette) ** 2) * np.mean(np.abs(sec_imagette) ** 2))
692
+ # if denom < 1e-12:
693
+ # return 0.0
694
+ # return float(np.clip(num / denom, 0.0, 1.0))
695
+
696
+ def _imagette_coherence(ref_imagette, sec_imagette, window_size: int = 5) -> float:
669
697
  """
670
- Single scalar complex coherence between two same-shape imagettes —
671
- SNAP CrossCorrelationOp's "Method 1" (no internal sliding window;
672
- the whole imagette is treated as one coherence estimation window):
673
- https://step.esa.int/main/wp-content/help/versions/13.0.0/snap-toolboxes/eu.esa.microwavetbx.sar.op.insar.ui/operators/CrossCorrelationOp.html
698
+ Calculate coherence using windowed averaging (standard ESD approach).
699
+
700
+ Args:
701
+ ref_imagette: Reference imagette (complex array)
702
+ sec_imagette: Secondary imagette (complex array)
703
+ window_size: Window size for coherence calculation (default: 5)
704
+
705
+ Returns:
706
+ Average coherence value
674
707
  """
675
708
  import numpy as np
676
-
677
- num = abs(np.mean(ref_imagette * np.conj(sec_imagette)))
678
- denom = np.sqrt(
679
- np.mean(np.abs(ref_imagette) ** 2) * np.mean(np.abs(sec_imagette) ** 2)
680
- )
681
- if denom < 1e-12:
682
- return 0.0
683
- return float(np.clip(num / denom, 0.0, 1.0))
684
-
709
+ from scipy.ndimage import uniform_filter
710
+
711
+ # Calculate complex cross-correlation
712
+ cross_corr = ref_imagette * np.conj(sec_imagette)
713
+
714
+ # Calculate windowed averages
715
+ num_real = uniform_filter(cross_corr.real, size=window_size)
716
+ num_imag = uniform_filter(cross_corr.imag, size=window_size)
717
+ num = np.sqrt(num_real**2 + num_imag**2)
718
+
719
+ denom_ref = uniform_filter(np.abs(ref_imagette)**2, size=window_size)
720
+ denom_sec = uniform_filter(np.abs(sec_imagette)**2, size=window_size)
721
+ denom = np.sqrt(denom_ref * denom_sec)
722
+
723
+ # Calculate coherence
724
+ coherence = np.where(denom > 1e-12, num / denom, 0.0)
725
+ coherence = np.clip(coherence, 0.0, 1.0)
726
+
727
+ # Return average coherence
728
+ return float(np.mean(coherence))
685
729
 
686
730
  def refine_offsets_by_coherence(
687
731
  ref_complex,
@@ -1124,11 +1168,11 @@ def resample_with_offset_field(
1124
1168
  import numpy as np
1125
1169
  from scipy.ndimage import map_coordinates
1126
1170
 
1127
- h, w = data.shape
1128
- out_real = np.empty((h, w), dtype=np.float32)
1129
- out_imag = np.empty((h, w), dtype=np.float32)
1130
- data_real = data.real
1131
- data_imag = data.imag
1171
+ # h, w = data.shape
1172
+ # out_real = np.empty((h, w), dtype=np.float32)
1173
+ # out_imag = np.empty((h, w), dtype=np.float32)
1174
+ # data_real = data.real
1175
+ # data_imag = data.imag
1132
1176
 
1133
1177
  # Process in row chunks rather than building ~10 full-resolution
1134
1178
  # float64 intermediate arrays (row_idx, col_idx, ref_global_row/col,
@@ -1142,41 +1186,85 @@ def resample_with_offset_field(
1142
1186
  # more than sufficient precision for sub-pixel offset resampling and
1143
1187
  # halves the footprint on its own; chunking bounds peak memory to
1144
1188
  # one row-block regardless of total crop size.
1145
- chunk_rows = max(1, min(h, 2000))
1146
- for row_start in range(0, h, chunk_rows):
1147
- row_end = min(row_start + chunk_rows, h)
1148
- row_idx, col_idx = np.mgrid[row_start:row_end, 0:w].astype(np.float32)
1149
-
1150
- # row_idx/col_idx are the reference grid's local (0-based)
1151
- # coordinates. Convert to full-scene coordinates to correctly
1152
- # evaluate the fitted offset functions, which were built on real
1153
- # annotation-derived full-scene coordinates, not any particular
1154
- # crop's local ones.
1155
- ref_global_row = row_idx + ref_row_offset
1156
- ref_global_col = col_idx + ref_col_offset
1157
-
1158
- offset_row = row_offset_fn(ref_global_row, ref_global_col)
1159
- offset_col = col_offset_fn(ref_global_row, ref_global_col)
1160
-
1161
- # Where this pixel's secondary counterpart is, in full-scene coordinates
1162
- sec_global_row = ref_global_row + offset_row
1163
- sec_global_col = ref_global_col + offset_col
1164
-
1165
- # Convert to the SECONDARY array's own local coordinates for
1166
- # actually indexing into `data` -- the secondary crop can have a
1167
- # different offset than the reference crop, so this is not the
1168
- # same subtraction as ref_row_offset/ref_col_offset above.
1169
- sample_rows = sec_global_row - sec_row_offset
1170
- sample_cols = sec_global_col - sec_col_offset
1171
-
1172
- out_real[row_start:row_end] = map_coordinates(
1173
- data_real, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0
1174
- )
1175
- out_imag[row_start:row_end] = map_coordinates(
1176
- data_imag, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0
1177
- )
1189
+
1190
+
1191
+ h, w = data.shape
1192
+ out_real = np.empty((h, w), dtype=np.float32)
1193
+ out_imag = np.empty((h, w), dtype=np.float32)
1194
+ data_real = data.real
1195
+ data_imag = data.imag
1196
+
1197
+ # Process in 2D tiles for cache locality and strict memory bounding
1198
+ tile_h, tile_w = 1024, 1024
1199
+
1200
+ for r_start in range(0, h, tile_h):
1201
+ r_end = min(r_start + tile_h, h)
1202
+ for c_start in range(0, w, tile_w):
1203
+ c_end = min(c_start + tile_w, w)
1204
+
1205
+ # Use ogrid to create broadcastable 1D arrays instead of full 2D mgrid matrices
1206
+ row_idx = np.arange(r_start, r_end, dtype=np.float32)[:, None]
1207
+ col_idx = np.arange(c_start, c_end, dtype=np.float32)[None, :]
1208
+
1209
+ ref_global_row = row_idx + ref_row_offset
1210
+ ref_global_col = col_idx + ref_col_offset
1211
+
1212
+ # Evaluate polynomial (broadcasts automatically, minimal memory)
1213
+ offset_row = row_offset_fn(ref_global_row, ref_global_col)
1214
+ offset_col = col_offset_fn(ref_global_row, ref_global_col)
1215
+
1216
+ sample_rows = ref_global_row + offset_row - sec_row_offset
1217
+ sample_cols = ref_global_col + offset_col - sec_col_offset
1218
+
1219
+ # Broadcast to 2D for map_coordinates
1220
+ sample_rows_2d = np.broadcast_to(sample_rows, (r_end - r_start, c_end - c_start))
1221
+ sample_cols_2d = np.broadcast_to(sample_cols, (r_end - r_start, c_end - c_start))
1222
+
1223
+ out_real[r_start:r_end, c_start:c_end] = map_coordinates(
1224
+ data_real, [sample_rows_2d, sample_cols_2d], order=1, mode="constant", cval=0.0
1225
+ )
1226
+ out_imag[r_start:r_end, c_start:c_end] = map_coordinates(
1227
+ data_imag, [sample_rows_2d, sample_cols_2d], order=1, mode="constant", cval=0.0
1228
+ )
1229
+
1230
+ del sample_rows_2d, sample_cols_2d, offset_row, offset_col
1178
1231
 
1179
1232
  return (out_real + 1j * out_imag).astype(np.complex64)
1233
+ # chunk_rows = max(1, min(h, 2000))
1234
+ # for row_start in range(0, h, chunk_rows):
1235
+ # row_end = min(row_start + chunk_rows, h)
1236
+ # row_idx, col_idx = np.mgrid[row_start:row_end, 0:w].astype(np.float32)
1237
+
1238
+ # # row_idx/col_idx are the reference grid's local (0-based)
1239
+ # # coordinates. Convert to full-scene coordinates to correctly
1240
+ # # evaluate the fitted offset functions, which were built on real
1241
+ # # annotation-derived full-scene coordinates, not any particular
1242
+ # # crop's local ones.
1243
+ # ref_global_row = row_idx + ref_row_offset
1244
+ # ref_global_col = col_idx + ref_col_offset
1245
+
1246
+ # offset_row = row_offset_fn(ref_global_row, ref_global_col)
1247
+ # offset_col = col_offset_fn(ref_global_row, ref_global_col)
1248
+
1249
+ # # Where this pixel's secondary counterpart is, in full-scene coordinates
1250
+ # sec_global_row = ref_global_row + offset_row
1251
+ # sec_global_col = ref_global_col + offset_col
1252
+
1253
+ # # Convert to the SECONDARY array's own local coordinates for
1254
+ # # actually indexing into `data` -- the secondary crop can have a
1255
+ # # different offset than the reference crop, so this is not the
1256
+ # # same subtraction as ref_row_offset/ref_col_offset above.
1257
+ # sample_rows = sec_global_row - sec_row_offset
1258
+ # sample_cols = sec_global_col - sec_col_offset
1259
+
1260
+ # out_real[row_start:row_end] = map_coordinates(
1261
+ # data_real, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0
1262
+ # )
1263
+ # out_imag[row_start:row_end] = map_coordinates(
1264
+ # data_imag, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0
1265
+ # )
1266
+
1267
+ # return (out_real + 1j * out_imag).astype(np.complex64)
1180
1268
 
1181
1269
 
1182
1270
  def _interpolate(orbit, t):
@@ -0,0 +1,361 @@
1
+ """
2
+ Real per-burst-overlap Enhanced Spectral Diversity (ESD), replacing the
3
+ previous whole-image approximation.
4
+
5
+ Formula confirmed against two independent, real academic sources:
6
+ Prats-Iraola et al. (2012), "Interferometric Processing of Sentinel-1
7
+ TOPS Data", IEEE TGRS.
8
+ Scheiber, R. & Moreira, A. (2000), "Coregistration of interferometric
9
+ SAR images using spectral diversity", IEEE TGRS.
10
+
11
+ The azimuth shift (in seconds) is:
12
+ delta_t = delta_phi / (2 * pi * delta_f_ovl)
13
+ where delta_phi is the double-difference phase between the forward-
14
+ and backward-looking interferograms in the overlap, and delta_f_ovl
15
+ is the Doppler centroid difference between the two looks (~1480 Hz
16
+ for IW mode).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import List, Optional, Tuple
23
+
24
+ from pygeofetch.insar.annotation import SwathTiming
25
+
26
+ logger = logging.getLogger("pygeofetch.insar.esd")
27
+
28
+ # Real Sentinel-1 IW Doppler centroid frequency difference between
29
+ # forward and backward looks in the burst overlap region.
30
+ # Confirmed: Prats-Iraola et al. (2012), Table I.
31
+ SENTINEL1_IW_DELTA_F_OVL_HZ = 1480.0
32
+
33
+
34
+ def compute_overlap_row_ranges(
35
+ swath_timing: SwathTiming,
36
+ azimuth_time_interval_s: float,
37
+ ) -> List[Tuple[Tuple[int, int], Tuple[int, int]]]:
38
+ """
39
+ Compute the row ranges for each burst-overlap region.
40
+
41
+ Returns a list of ((bw_row_start, bw_row_end), (fw_row_start, fw_row_end))
42
+ tuples, one per adjacent burst pair. bw = backward-looking (end of
43
+ burst i), fw = forward-looking (start of burst i+1).
44
+ """
45
+ overlaps = []
46
+ lines_per_burst = swath_timing.lines_per_burst
47
+ bursts = swath_timing.bursts
48
+
49
+ for i in range(len(bursts) - 1):
50
+ # Backward-looking: last rows of burst i
51
+ bw_row_end = (i + 1) * lines_per_burst - 1
52
+ bw_row_start = i * lines_per_burst
53
+
54
+ # Forward-looking: first rows of burst i+1
55
+ fw_row_start = (i + 1) * lines_per_burst
56
+ fw_row_end = (i + 2) * lines_per_burst - 1
57
+
58
+ # Compute overlap extent from timing
59
+ burst_i_end_time = (
60
+ bursts[i].azimuth_time.timestamp()
61
+ + (lines_per_burst - 1) * azimuth_time_interval_s
62
+ )
63
+ burst_next_start_time = bursts[i + 1].azimuth_time.timestamp()
64
+
65
+ overlap_seconds = burst_i_end_time - burst_next_start_time
66
+ if overlap_seconds <= 0:
67
+ continue
68
+
69
+ overlap_lines = int(round(overlap_seconds / azimuth_time_interval_s))
70
+ if overlap_lines < 4:
71
+ continue
72
+
73
+ # Backward view: last `overlap_lines` rows of burst i
74
+ bw_row_start = bw_row_end - overlap_lines + 1
75
+ # Forward view: first `overlap_lines` rows of burst i+1
76
+ fw_row_end = fw_row_start + overlap_lines - 1
77
+
78
+ n_bw = bw_row_end - bw_row_start + 1
79
+ n_fw = fw_row_end - fw_row_start + 1
80
+
81
+ if n_bw != n_fw:
82
+ logger.warning(
83
+ "Overlap %d<->%d: backward-view row count (%d) does not "
84
+ "match forward-view row count (%d) -- unusual burst "
85
+ "timing; using the shorter of the two.",
86
+ i, i + 1, n_bw, n_fw,
87
+ )
88
+ n_common = min(n_bw, n_fw)
89
+ bw_row_start = bw_row_end - n_common + 1
90
+ fw_row_end = fw_row_start + n_common - 1
91
+
92
+ overlaps.append(((bw_row_start, bw_row_end), (fw_row_start, fw_row_end)))
93
+
94
+ return overlaps
95
+
96
+
97
+ def _windowed_coherence(
98
+ ref_imagette, sec_imagette, window: int = 7
99
+ ) -> "np.ndarray":
100
+ """
101
+ Compute windowed coherence map (standard InSAR coherence estimation).
102
+
103
+ Unlike the previous single-scalar approach, this returns a 2D array
104
+ of local coherence values, allowing us to:
105
+ 1. Reject low-coherence pixels (water, vegetation, shadow)
106
+ 2. Use only high-coherence pixels for the ESD phase estimate
107
+ 3. Report a robust MEDIAN coherence for the overlap quality check
108
+
109
+ Args:
110
+ ref_imagette: Complex reference overlap region (n_rows x n_cols)
111
+ sec_imagette: Complex secondary overlap region (same shape)
112
+ window: Coherence estimation window size (default 7)
113
+
114
+ Returns:
115
+ 2D float32 array of local coherence values [0, 1]
116
+ """
117
+ import numpy as np
118
+ from scipy.ndimage import uniform_filter
119
+
120
+ cross = ref_imagette * np.conj(sec_imagette)
121
+
122
+ num_real = uniform_filter(cross.real, size=window)
123
+ num_imag = uniform_filter(cross.imag, size=window)
124
+ num = np.sqrt(num_real**2 + num_imag**2)
125
+
126
+ denom = np.sqrt(
127
+ uniform_filter(np.abs(ref_imagette) ** 2, size=window)
128
+ * uniform_filter(np.abs(sec_imagette) ** 2, size=window)
129
+ + 1e-10
130
+ )
131
+
132
+ coherence = np.clip(num / denom, 0.0, 1.0).astype(np.float32)
133
+ return coherence
134
+
135
+
136
+ def _estimate_shift_from_overlap(
137
+ ref_bw, sec_bw, ref_fw, sec_fw,
138
+ delta_f_ovl_hz: float,
139
+ coherence_threshold: float = 0.3,
140
+ max_range_samples: int = 4096,
141
+ ) -> Tuple[Optional[float], float]:
142
+ """
143
+ Estimate azimuth shift from a single burst overlap using the
144
+ double-difference interferometric phase.
145
+
146
+ Memory optimization: subsamples in range if the overlap is wider
147
+ than max_range_samples (full scene = 23,674 cols; we only need
148
+ ~4096 for a statistically robust estimate).
149
+
150
+ Returns:
151
+ (shift_seconds, median_coherence)
152
+ """
153
+ import numpy as np
154
+
155
+ n_rows_bw, n_cols = ref_bw.shape
156
+ n_rows_fw, _ = ref_fw.shape
157
+
158
+ # Range subsampling for memory efficiency on full scenes
159
+ if n_cols > max_range_samples:
160
+ step = n_cols // max_range_samples
161
+ col_idx = np.arange(0, n_cols, step)
162
+ ref_bw = ref_bw[:, col_idx]
163
+ sec_bw = sec_bw[:, col_idx]
164
+ ref_fw = ref_fw[:, col_idx]
165
+ sec_fw = sec_fw[:, col_idx]
166
+ n_cols = len(col_idx)
167
+
168
+ # Ensure matching row counts
169
+ n_common = min(n_rows_bw, n_rows_fw)
170
+ ref_bw = ref_bw[:n_common]
171
+ sec_bw = sec_bw[:n_common]
172
+ ref_fw = ref_fw[:n_common]
173
+ sec_fw = sec_fw[:n_common]
174
+
175
+ # Compute windowed coherence on the backward-looking interferogram
176
+ coh_map = _windowed_coherence(ref_bw, sec_bw, window=7)
177
+ median_coh = float(np.median(coh_map))
178
+
179
+ if median_coh < coherence_threshold:
180
+ return None, median_coh
181
+
182
+ # Form forward and backward interferograms
183
+ ifg_bw = ref_bw * np.conj(sec_bw) # backward-looking
184
+ ifg_fw = ref_fw * np.conj(sec_fw) # forward-looking
185
+
186
+ # Double-difference phase: the key ESD quantity
187
+ # delta_phi = angle(ifg_bw) - angle(ifg_fw)
188
+ # Only use pixels where local coherence > threshold
189
+ mask = coh_map > coherence_threshold
190
+
191
+ if mask.sum() < 100:
192
+ return None, median_coh
193
+
194
+ # Compute double-difference phase only at high-coherence pixels
195
+ phase_bw = np.angle(ifg_bw)
196
+ phase_fw = np.angle(ifg_fw)
197
+ delta_phase = phase_bw[mask] - phase_fw[mask]
198
+
199
+ # Robust phase estimate: circular mean (not arithmetic mean)
200
+ mean_delta_phi = np.angle(np.mean(np.exp(1j * delta_phase)))
201
+
202
+ # Convert to time shift
203
+ shift_seconds = mean_delta_phi / (2.0 * np.pi * delta_f_ovl_hz)
204
+
205
+ return float(shift_seconds), median_coh
206
+
207
+
208
+ def estimate_esd_shift_per_burst_overlap(
209
+ ref_complex,
210
+ sec_complex,
211
+ swath_timing: SwathTiming,
212
+ azimuth_time_interval_s: float,
213
+ row_offset: int = 0,
214
+ delta_f_ovl_hz: float = SENTINEL1_IW_DELTA_F_OVL_HZ,
215
+ coherence_threshold: float = 0.3,
216
+ ) -> Tuple[Optional[float], List[Optional[float]]]:
217
+ """
218
+ Real per-burst-overlap ESD: estimate azimuth misregistration from
219
+ the actual burst overlap regions, replacing the previous whole-image
220
+ single-shift approximation.
221
+
222
+ Uses WINDOWED coherence estimation (7x7 window) and MEDIAN coherence
223
+ for the quality check, matching SNAP's approach. This is critical for
224
+ full-scene processing where the overlap spans the entire range width
225
+ (~23,000 pixels) including ocean, vegetation, and other low-coherence
226
+ terrain that would drag a global-mean coherence below threshold.
227
+
228
+ Memory optimization: subsamples in range to max 4096 columns per
229
+ overlap, reducing peak memory from ~600 MB to ~100 MB per overlap
230
+ while maintaining statistical robustness.
231
+
232
+ Args:
233
+ ref_complex, sec_complex: Real, coregistered (at least coarsely)
234
+ complex SLC arrays, same shape -- full burst stack, or an
235
+ already-cropped extract (see row_offset).
236
+ swath_timing: Real burst metadata from annotation.parse_burst_info().
237
+ azimuth_time_interval_s: Real per-line time spacing.
238
+ row_offset: Real full-scene row that ref_complex/sec_complex's
239
+ row 0 corresponds to. Default 0 assumes the arrays start at
240
+ the full-scene row 0.
241
+ delta_f_ovl_hz: Doppler centroid difference in the overlap.
242
+ coherence_threshold: Minimum MEDIAN windowed coherence for an
243
+ overlap to be considered usable.
244
+
245
+ Returns:
246
+ (shift_seconds, per_overlap_shifts): shift_seconds is the real,
247
+ median azimuth timing shift (seconds) across all valid burst
248
+ overlaps (None if no overlap gave a usable estimate);
249
+ per_overlap_shifts is one entry per adjacent burst pair (None
250
+ where that overlap's coherence was too low or fell entirely
251
+ outside the given arrays).
252
+ """
253
+ import numpy as np
254
+
255
+ if not isinstance(swath_timing, SwathTiming):
256
+ raise TypeError(
257
+ f"estimate_esd_shift_per_burst_overlap expects a SwathTiming "
258
+ f"object, got {type(swath_timing).__name__}. Pass the result of "
259
+ f"parse_burst_info(), not a raw bursts list."
260
+ )
261
+
262
+ overlaps = compute_overlap_row_ranges(swath_timing, azimuth_time_interval_s)
263
+ per_overlap_shifts: List[Optional[float]] = []
264
+
265
+ n_outside = 0
266
+ n_below_coh = 0
267
+ n_usable = 0
268
+
269
+ h, w = ref_complex.shape
270
+
271
+ for i, ((bw_start, bw_end), (fw_start, fw_end)) in enumerate(overlaps):
272
+ # Map full-scene rows to local array rows
273
+ local_bw_start = bw_start - row_offset
274
+ local_bw_end = bw_end - row_offset
275
+ local_fw_start = fw_start - row_offset
276
+ local_fw_end = fw_end - row_offset
277
+
278
+ # Check bounds
279
+ if (local_bw_start < 0 or local_bw_end >= h
280
+ or local_fw_start < 0 or local_fw_end >= h):
281
+ n_outside += 1
282
+ per_overlap_shifts.append(None)
283
+ logger.debug(
284
+ "Overlap %d<->%d: outside array bounds "
285
+ "(local rows bw=[%d,%d], fw=[%d,%d], array height=%d)",
286
+ i, i + 1, local_bw_start, local_bw_end,
287
+ local_fw_start, local_fw_end, h,
288
+ )
289
+ continue
290
+
291
+ # Extract overlap regions
292
+ ref_bw = ref_complex[local_bw_start:local_bw_end + 1, :]
293
+ sec_bw = sec_complex[local_bw_start:local_bw_end + 1, :]
294
+ ref_fw = ref_complex[local_fw_start:local_fw_end + 1, :]
295
+ sec_fw = sec_complex[local_fw_start:local_fw_end + 1, :]
296
+
297
+ # Estimate shift with windowed coherence
298
+ shift_s, median_coh = _estimate_shift_from_overlap(
299
+ ref_bw, sec_bw, ref_fw, sec_fw,
300
+ delta_f_ovl_hz=delta_f_ovl_hz,
301
+ coherence_threshold=coherence_threshold,
302
+ )
303
+
304
+ if shift_s is None:
305
+ n_below_coh += 1
306
+ per_overlap_shifts.append(None)
307
+ logger.debug(
308
+ "Overlap %d<->%d: median windowed coherence %.3f < threshold %.2f",
309
+ i, i + 1, median_coh, coherence_threshold,
310
+ )
311
+ else:
312
+ n_usable += 1
313
+ per_overlap_shifts.append(shift_s)
314
+ logger.debug(
315
+ "Overlap %d<->%d: shift=%.6f ms, median_coh=%.3f",
316
+ i, i + 1, shift_s * 1000, median_coh,
317
+ )
318
+
319
+ # Report diagnostics
320
+ if n_usable == 0:
321
+ if n_outside > 0 and n_below_coh > 0:
322
+ logger.warning(
323
+ "Real per-burst-overlap ESD found no usable burst overlaps "
324
+ "out of %d total: %d fell outside the given (possibly "
325
+ "cropped) arrays, %d were below coherence_threshold=%.2f. "
326
+ "A mix of both -- see the per-overlap detail at DEBUG level.",
327
+ len(overlaps), n_outside, n_below_coh, coherence_threshold,
328
+ )
329
+ elif n_outside > 0:
330
+ logger.warning(
331
+ "Real per-burst-overlap ESD found no usable burst overlaps "
332
+ "out of %d total: all %d fell outside the given (possibly "
333
+ "cropped) arrays.",
334
+ len(overlaps), n_outside,
335
+ )
336
+ else:
337
+ logger.warning(
338
+ "Real per-burst-overlap ESD found no usable burst overlaps "
339
+ "out of %d total: 0 fell outside the given (possibly "
340
+ "cropped) arrays, %d were below coherence_threshold=%.2f. "
341
+ "Every real, in-bounds overlap was tested and found "
342
+ "genuinely below the coherence threshold -- this reflects "
343
+ "real decorrelation in the data, not something a larger "
344
+ "AOI would fix.",
345
+ len(overlaps), n_below_coh, coherence_threshold,
346
+ )
347
+ return None, per_overlap_shifts
348
+
349
+ # Combine valid shifts via median (robust to outliers)
350
+ valid_shifts = [s for s in per_overlap_shifts if s is not None]
351
+ combined_shift = float(np.median(valid_shifts))
352
+
353
+ logger.info(
354
+ "Real per-burst-overlap ESD: %d/%d overlaps usable, "
355
+ "combined shift=%.6f ms (%.4f px)",
356
+ n_usable, len(overlaps),
357
+ combined_shift * 1000,
358
+ combined_shift / azimuth_time_interval_s,
359
+ )
360
+
361
+ return combined_shift, per_overlap_shifts