PyGeoFetch 1.9.0__tar.gz → 1.9.2__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 (117) hide show
  1. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/CHANGELOG.md +2 -2
  2. {pygeofetch-1.9.0/pygeofetch.egg-info → pygeofetch-1.9.2}/PKG-INFO +3 -3
  3. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/__init__.py +1 -1
  4. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/core/engine.py +32 -8
  5. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/__init__.py +4 -1
  6. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/annotation.py +33 -2
  7. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/atmosphere.py +32 -7
  8. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/coregister.py +83 -6
  9. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/extraction.py +182 -4
  10. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/geolocation.py +55 -1
  11. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/interferogram.py +69 -11
  12. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/unwrap.py +120 -1
  13. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/meta.yaml +1 -1
  14. {pygeofetch-1.9.0 → pygeofetch-1.9.2/pygeofetch.egg-info}/PKG-INFO +3 -3
  15. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch.egg-info/requires.txt +2 -2
  16. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pyproject.toml +4 -4
  17. pygeofetch-1.9.2/tests/test_insar.py +578 -0
  18. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/tests/test_pgv_integration_contract.py +2 -2
  19. pygeofetch-1.9.0/tests/test_insar.py +0 -465
  20. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/LICENSE +0 -0
  21. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/MANIFEST.in +0 -0
  22. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/README.md +0 -0
  23. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/__init__.py +0 -0
  24. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/auth_commands.py +0 -0
  25. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/config_commands.py +0 -0
  26. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/download_commands.py +0 -0
  27. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/index_commands.py +0 -0
  28. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/main.py +0 -0
  29. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  30. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/postprocess_commands.py +0 -0
  31. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/preprocess_commands.py +0 -0
  32. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/sar_commands.py +0 -0
  33. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/cli/search_commands.py +0 -0
  34. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/config/__init__.py +0 -0
  35. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/config/defaults.yaml +0 -0
  36. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/config/settings.py +0 -0
  37. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/core/__init__.py +0 -0
  38. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/core/authenticator.py +0 -0
  39. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/core/cache_manager.py +0 -0
  40. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/core/downloader.py +0 -0
  41. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/core/logging.py +0 -0
  42. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/core/orbits.py +0 -0
  43. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/core/scheduler.py +0 -0
  44. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/core/searcher.py +0 -0
  45. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/gpu.py +0 -0
  46. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/timeseries.py +0 -0
  47. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/validate.py +0 -0
  48. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/insar/visualize.py +0 -0
  49. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/models/__init__.py +0 -0
  50. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/models/download_task.py +0 -0
  51. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/models/satellite_data.py +0 -0
  52. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/models/search_query.py +0 -0
  53. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/models/user_auth.py +0 -0
  54. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processing/__init__.py +0 -0
  55. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processing/base.py +0 -0
  56. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processing/batch.py +0 -0
  57. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processing/indices.py +0 -0
  58. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processing/pipeline.py +0 -0
  59. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processing/postprocessor.py +0 -0
  60. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processing/preprocessor.py +0 -0
  61. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processing/sar.py +0 -0
  62. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processor/__init__.py +0 -0
  63. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processor/indices.py +0 -0
  64. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processor/landsat.py +0 -0
  65. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processor/loader.py +0 -0
  66. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processor/stacker.py +0 -0
  67. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/processor/timeseries.py +0 -0
  68. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/__init__.py +0 -0
  69. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/airbus_oneatlas.py +0 -0
  70. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
  71. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/aws_earth.py +0 -0
  72. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/base.py +0 -0
  73. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/copernicus.py +0 -0
  74. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/digitalglobe.py +0 -0
  75. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/earth_explorer_additional.py +0 -0
  76. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/element84.py +0 -0
  77. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/eodag_provider.py +0 -0
  78. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/esa_scihub.py +0 -0
  79. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/geoserver_generic.py +0 -0
  80. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/google_earth_engine.py +0 -0
  81. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/inpe_cbers.py +0 -0
  82. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/isro_bhuvan.py +0 -0
  83. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/jaxa_earth.py +0 -0
  84. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/maxar_gbdx.py +0 -0
  85. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/nasa_earthdata.py +0 -0
  86. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  87. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/noaa_big_data.py +0 -0
  88. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/opentopography.py +0 -0
  89. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/planet.py +0 -0
  90. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/planetary_computer.py +0 -0
  91. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/sentinel_hub.py +0 -0
  92. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/terrabotics.py +0 -0
  93. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/providers/usgs.py +0 -0
  94. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/sar/__init__.py +0 -0
  95. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/sar/_native.py +0 -0
  96. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/sar/_ost.py +0 -0
  97. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/sar/_sarxarray.py +0 -0
  98. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/sar/extraction.py +0 -0
  99. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/sar/processor.py +0 -0
  100. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/utils/__init__.py +0 -0
  101. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/utils/file_utils.py +0 -0
  102. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/utils/gcp_recovery.py +0 -0
  103. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/utils/geo_utils.py +0 -0
  104. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/utils/logging_setup.py +0 -0
  105. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/utils/retry_handler.py +0 -0
  106. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/utils/validators.py +0 -0
  107. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/viz/__init__.py +0 -0
  108. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/viz/map.py +0 -0
  109. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch/viz/plot.py +0 -0
  110. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch.egg-info/SOURCES.txt +0 -0
  111. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch.egg-info/dependency_links.txt +0 -0
  112. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch.egg-info/entry_points.txt +0 -0
  113. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch.egg-info/not-zip-safe +0 -0
  114. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/pygeofetch.egg-info/top_level.txt +0 -0
  115. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/setup.cfg +0 -0
  116. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/tests/test_extraction.py +0 -0
  117. {pygeofetch-1.9.0 → pygeofetch-1.9.2}/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.0/).
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.9.2/).
5
5
 
6
- ## [1.9.0] — 2026-07-12
6
+ ## [1.9.2] — 2026-07-12
7
7
 
8
8
  ### Added
9
9
  - Federated search across 22+ satellite data providers
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 1.9.0
3
+ Version: 1.9.2
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
@@ -94,8 +94,8 @@ Requires-Dist: plotly>=5.14.0; extra == "viz"
94
94
  Requires-Dist: whitebox>=2.3.0; extra == "viz"
95
95
  Requires-Dist: whiteboxgui>=2.3.0; extra == "viz"
96
96
  Requires-Dist: geemap>=0.28.0; extra == "viz"
97
- Requires-Dist: osmnx>=1.9.0; extra == "viz"
98
- Requires-Dist: contextily>=1.9.0; extra == "viz"
97
+ Requires-Dist: osmnx>=1.9.2; extra == "viz"
98
+ Requires-Dist: contextily>=1.9.2; extra == "viz"
99
99
  Requires-Dist: xyzservices>=2023.10.0; extra == "viz"
100
100
  Requires-Dist: mapclassify>=2.5.0; extra == "viz"
101
101
  Requires-Dist: geoplot>=0.5.0; extra == "viz"
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "1.9.0"
33
+ __version__ = "1.9.2"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -176,7 +176,7 @@ class PyGeoFetch:
176
176
  if _product.upper() == "SLC" and _effective:
177
177
  _effective = self._route_slc_providers(_effective)
178
178
  results = self.searcher.search(query, providers=_effective, use_cache=use_cache)
179
- self._warn_if_outdated_constellation(results)
179
+ self._warn_if_outdated_constellation(results, query)
180
180
  return results
181
181
 
182
182
  def search_and_save(
@@ -217,19 +217,43 @@ class PyGeoFetch:
217
217
  }
218
218
  _GRD_ONLY = {"planetary_computer", "aws_earth", "element84"}
219
219
 
220
- def _warn_if_outdated_constellation(self, results: list) -> None:
221
- """Log a warning if results contain only decommissioned satellites after S1A decomm."""
222
- from datetime import date
220
+ def _warn_if_outdated_constellation(self, results: list, query) -> None:
221
+ """Log a warning if results contain only decommissioned satellites
222
+ for a search whose OWN requested date range extends past S1A's
223
+ real decommissioning date — not just because today happens to be
224
+ past it. A search for genuine historical data (e.g. November
225
+ 2024, well before S1A's actual 1 July 2026 end of mission) must
226
+ never trigger this, regardless of what today's date is; S1A
227
+ results from before decommissioning are completely legitimate."""
228
+ from datetime import date, datetime
223
229
 
224
230
  S1A_DECOM = date(2026, 7, 1)
225
- if date.today() >= S1A_DECOM:
231
+
232
+ query_end = getattr(query, "end_date", None)
233
+ if query_end is None:
234
+ # No end date specified -- fall back to whether the search
235
+ # was actually run after decommissioning (the old behaviour,
236
+ # but only as a last resort when there's no query date to
237
+ # check against).
238
+ query_end_date = date.today()
239
+ elif isinstance(query_end, str):
240
+ query_end_date = datetime.fromisoformat(query_end.replace("Z", "")).date()
241
+ elif isinstance(query_end, datetime):
242
+ query_end_date = query_end.date()
243
+ elif isinstance(query_end, date):
244
+ query_end_date = query_end
245
+ else:
246
+ query_end_date = date.today()
247
+
248
+ if query_end_date >= S1A_DECOM:
226
249
  platforms = {getattr(r, "satellite", "") or "" for r in results}
227
250
  normalised = {_normalise_satellite_name(p) for p in platforms}
228
251
  if any("S1" in p for p in normalised): # only check if S1 results
229
252
  if not any(p in {"S1C", "S1D"} for p in normalised):
230
253
  logger.warning(
231
- "Search returned only S1A/S1B results after Sentinel-1A "
232
- "decommissioning date. This may indicate a provider collection "
254
+ "Search returned only S1A/S1B results for a date range "
255
+ "extending past Sentinel-1A's real decommissioning date "
256
+ "(1 July 2026). This may indicate a provider collection "
233
257
  "routing issue. Check platform filter settings."
234
258
  )
235
259
 
@@ -460,4 +484,4 @@ class PyGeoFetch:
460
484
 
461
485
  def __repr__(self) -> str:
462
486
  authed = [item["provider"] for item in self.auth.list()]
463
- return f"PyGeoFetch(authenticated={authed}, cache_entries={len(self.searcher.cache._cache)})"
487
+ return f"PyGeoFetch(authenticated={authed}, cache_entries={len(self.searcher.cache._cache)})"
@@ -55,7 +55,7 @@ from pygeofetch.insar.extraction import SLCExtractor
55
55
  from pygeofetch.insar.gpu import gpu_available
56
56
  from pygeofetch.insar.interferogram import InterferogramGenerator, InterferogramResult
57
57
  from pygeofetch.insar.timeseries import SBASTimeSeries
58
- from pygeofetch.insar.unwrap import PhaseUnwrapper
58
+ from pygeofetch.insar.unwrap import PhaseUnwrapper, multilook
59
59
  from pygeofetch.insar.validate import DataValidator, ValidationResult
60
60
  from pygeofetch.insar.visualize import (
61
61
  visualize_interferogram,
@@ -79,6 +79,7 @@ from pygeofetch.insar.geolocation import (
79
79
  find_zero_doppler_time,
80
80
  parse_orbit_file,
81
81
  interpolate_orbit_state,
82
+ los_to_vertical_displacement,
82
83
  )
83
84
  from pygeofetch.insar.coregister import (
84
85
  compute_offset_field_from_dem,
@@ -90,6 +91,7 @@ __all__ = [
90
91
  "InterferogramGenerator",
91
92
  "InterferogramResult",
92
93
  "PhaseUnwrapper",
94
+ "multilook",
93
95
  "SBASTimeSeries",
94
96
  "AtmosphericCorrector",
95
97
  "SLCExtractor",
@@ -105,6 +107,7 @@ __all__ = [
105
107
  "find_zero_doppler_time",
106
108
  "parse_orbit_file",
107
109
  "interpolate_orbit_state",
110
+ "los_to_vertical_displacement",
108
111
  "compute_offset_field_from_dem",
109
112
  "fit_offset_polynomial",
110
113
  "resample_with_offset_field",
@@ -15,6 +15,19 @@ MPC-0392):
15
15
  /product/imageAnnotation/imageInformation/azimuthTimeInterval
16
16
  /product/imageAnnotation/imageInformation/slantRangeTime
17
17
  /product/generalAnnotation/productInformation/rangeSamplingRate
18
+
19
+ Dimension field paths confirmed separately, against ESA's Sentinel-1
20
+ Product Specification (MPC-0240), which documents renaming
21
+ numberOfAzimuthLines -> numberOfLines and numberOfRangeSamples ->
22
+ numberOfSamples:
23
+
24
+ /product/imageAnnotation/imageInformation/numberOfLines
25
+ /product/imageAnnotation/imageInformation/numberOfSamples
26
+
27
+ ("numberOfColumns" is not a real field in this schema at all -- an
28
+ earlier, unverified assumption here that only surfaced against real
29
+ Sentinel-1 data, since synthetic tests built on the same assumption
30
+ couldn't have caught their own premise being wrong.)
18
31
  """
19
32
 
20
33
  from __future__ import annotations
@@ -103,6 +116,17 @@ def parse_slc_geometry(
103
116
  n for n in zf.namelist()
104
117
  if "/annotation/" in n and n.lower().endswith(".xml")
105
118
  and "/calibration/" not in n.lower()
119
+ # Exclude RFI (radio frequency interference) report files --
120
+ # confirmed real, present in Sentinel-1 products since IPF
121
+ # 3.40, living in this same annotation tree. Their filenames
122
+ # are prefixed "rfi-" (not the satellite-platform prefix
123
+ # "s1a-"/"s1b-" real per-swath annotation files use), and
124
+ # critically can ALSO contain "-slc-" for SLC products
125
+ # (confirmed by testing: an earlier "-slc-" based filter was
126
+ # insufficient for exactly this reason), so the prefix is
127
+ # the only reliable differentiator, not substring content.
128
+ and not Path(n).name.lower().startswith("rfi-")
129
+ and "/rfi/" not in n.lower()
106
130
  ]
107
131
  if member_hint:
108
132
  filtered = [n for n in candidates if member_hint.lower() in n.lower()]
@@ -142,7 +166,14 @@ def parse_slc_geometry(
142
166
  get_text(".//generalAnnotation/productInformation/rangeSamplingRate")
143
167
  ),
144
168
  n_lines=int(get_text(".//imageAnnotation/imageInformation/numberOfLines")),
145
- n_columns=int(get_text(".//imageAnnotation/imageInformation/numberOfColumns")),
169
+ # Real field name confirmed against ESA's own Product
170
+ # Specification (MPC-0240): "Renamed numberOfRangeSamples to
171
+ # numberOfSamples". "numberOfColumns" was never a real field in
172
+ # this schema at all -- an incorrect assumption in earlier
173
+ # research here, caught only against real Sentinel-1 data,
174
+ # since the synthetic tests built on that same assumption
175
+ # couldn't have caught their own premise being wrong.
176
+ n_columns=int(get_text(".//imageAnnotation/imageInformation/numberOfSamples")),
146
177
  )
147
178
 
148
179
  logger.info(
@@ -150,4 +181,4 @@ def parse_slc_geometry(
150
181
  Path(safe_zip_path).name, geometry.n_lines, geometry.n_columns,
151
182
  geometry.first_line_time,
152
183
  )
153
- return geometry
184
+ return geometry
@@ -74,6 +74,7 @@ class AtmosphericCorrector:
74
74
  dem: Union[str, Path],
75
75
  acquisition_datetime: Optional[str] = None,
76
76
  incidence_angle_deg: float = 38.0,
77
+ return_metadata: bool = False,
77
78
  ) -> Any:
78
79
  """
79
80
  Remove the tropospheric delay component from wrapped or unwrapped phase.
@@ -88,15 +89,33 @@ class AtmosphericCorrector:
88
89
  incidence_angle_deg: Radar incidence angle for LOS projection
89
90
  of zenith delay (Sentinel-1 IW ≈ 30-46°,
90
91
  default 38° is the mid-swath average).
92
+ return_metadata: If True, returns (phase, metadata) instead
93
+ of just phase. metadata includes
94
+ "correction_applied" (bool) and, for the
95
+ elevation method, "r_squared" (float) --
96
+ this is the only reliable way to know
97
+ whether a correction actually happened,
98
+ since the elevation method's internal R²
99
+ gate can legitimately skip correction
100
+ (returning the input unchanged) without
101
+ raising or otherwise signalling that in
102
+ the plain return value. Default False
103
+ preserves the original return type for
104
+ existing callers.
91
105
 
92
106
  Returns:
93
- Corrected phase array, same shape and units as input.
107
+ Corrected phase array, same shape and units as input (or a
108
+ (phase, metadata) tuple if return_metadata=True).
94
109
  """
95
110
  if self._method == "era5":
96
- return self._correct_era5(
111
+ result = self._correct_era5(
97
112
  phase, dem, acquisition_datetime, incidence_angle_deg
98
113
  )
99
- return self._correct_elevation(phase, dem)
114
+ metadata = {"correction_applied": True}
115
+ else:
116
+ result, metadata = self._correct_elevation(phase, dem)
117
+
118
+ return (result, metadata) if return_metadata else result
100
119
 
101
120
  # ── native elevation-correlated correction ────────────────────────────────
102
121
 
@@ -121,6 +140,12 @@ class AtmosphericCorrector:
121
140
  the phase variance (R² > 0.5); otherwise it is skipped and logged,
122
141
  since removing a low-confidence trend risks deleting real
123
142
  deformation signal rather than atmospheric noise.
143
+
144
+ Returns:
145
+ (phase_or_corrected, metadata) -- metadata always includes
146
+ "correction_applied" (bool) and, whenever the R² gate was
147
+ actually evaluated, "r_squared" (float), so callers can tell
148
+ a genuine correction apart from a same-shaped pass-through.
124
149
  """
125
150
  np = self._np()
126
151
  try:
@@ -145,7 +170,7 @@ class AtmosphericCorrector:
145
170
  logger.warning(
146
171
  "Insufficient valid pixels for elevation correction — returning uncorrected"
147
172
  )
148
- return phase
173
+ return phase, {"correction_applied": False, "reason": "insufficient_valid_pixels"}
149
174
 
150
175
  dem_v = dem_data[valid]
151
176
  phase_v = phase[valid]
@@ -164,7 +189,7 @@ class AtmosphericCorrector:
164
189
  "correction to avoid absorbing real deformation signal.",
165
190
  r_squared,
166
191
  )
167
- return phase
192
+ return phase, {"correction_applied": False, "r_squared": float(r_squared)}
168
193
 
169
194
  tropo_phase = slope_rad_per_m * dem_data
170
195
  corrected = phase - tropo_phase
@@ -174,7 +199,7 @@ class AtmosphericCorrector:
174
199
  slope_rad_per_m,
175
200
  int(valid.sum()),
176
201
  )
177
- return corrected.astype(np.float32)
202
+ return corrected.astype(np.float32), {"correction_applied": True, "r_squared": float(r_squared)}
178
203
 
179
204
  # ── ERA5/PyAPS-based correction ───────────────────────────────────────────
180
205
 
@@ -280,4 +305,4 @@ class AtmosphericCorrector:
280
305
  def _np(self):
281
306
  import numpy as np
282
307
 
283
- return np
308
+ return np
@@ -21,11 +21,49 @@ from __future__ import annotations
21
21
 
22
22
  import logging
23
23
  from pathlib import Path
24
- from typing import Tuple, Union
24
+ from typing import Optional, Tuple, Union
25
25
 
26
26
  logger = logging.getLogger("pygeofetch.insar.coregister")
27
27
 
28
28
 
29
+ def read_matched_swath(path: Union[str, Path]) -> Optional[str]:
30
+ """
31
+ Read back the matched sub-swath label (e.g. "iw3") recorded by
32
+ SLCExtractor.extract_scene(). Returns None if not present, so
33
+ callers can fall back to parse_slc_geometry()'s default (arbitrary
34
+ first-match) behaviour rather than fail outright.
35
+ """
36
+ import rasterio
37
+
38
+ try:
39
+ with rasterio.open(path) as src:
40
+ return src.tags().get("matched_swath") or None
41
+ except Exception:
42
+ return None
43
+
44
+
45
+ def read_crop_offset(path: Union[str, Path]) -> Tuple[float, float]:
46
+ """
47
+ Read back the crop offset recorded by SLCExtractor._crop_to_aoi() -
48
+ the local array's origin in the original, uncropped scene's pixel
49
+ coordinates. Returns (0.0, 0.0) if the file wasn't cropped (either
50
+ it's the raw fallback extraction, or a file this metadata predates),
51
+ which is the correct default: no crop means local and full-scene
52
+ coordinates are already the same thing.
53
+ """
54
+ import rasterio
55
+
56
+ try:
57
+ with rasterio.open(path) as src:
58
+ tags = src.tags()
59
+ return (
60
+ float(tags.get("crop_row_off", 0.0)),
61
+ float(tags.get("crop_col_off", 0.0)),
62
+ )
63
+ except Exception:
64
+ return (0.0, 0.0)
65
+
66
+
29
67
  def compute_offset_field_from_dem(
30
68
  ref_geometry,
31
69
  ref_orbit,
@@ -294,7 +332,11 @@ def fit_offset_polynomial(grid_rows, grid_cols, offsets, degree: int = 1):
294
332
  return evaluate
295
333
 
296
334
 
297
- def resample_with_offset_field(data, row_offset_fn, col_offset_fn):
335
+ def resample_with_offset_field(
336
+ data, row_offset_fn, col_offset_fn,
337
+ ref_row_offset: float = 0.0, ref_col_offset: float = 0.0,
338
+ sec_row_offset: float = 0.0, sec_col_offset: float = 0.0,
339
+ ):
298
340
  """
299
341
  Resample a complex array using a real, per-pixel offset field
300
342
  (from fit_offset_polynomial), rather than a naive shape-only zoom.
@@ -303,7 +345,24 @@ def resample_with_offset_field(data, row_offset_fn, col_offset_fn):
303
345
  data: Complex array to resample (the secondary SLC).
304
346
  row_offset_fn, col_offset_fn: Callables from fit_offset_polynomial(),
305
347
  giving the real (secondary - reference) offset
306
- at any (row, col) in the reference grid.
348
+ at any (row, col) expressed in REFERENCE
349
+ full-scene coordinates (that's what
350
+ compute_offset_field_from_dem() fits them on).
351
+ ref_row_offset, ref_col_offset: The reference array's origin in
352
+ full-scene coordinates (from read_crop_offset()
353
+ on the reference file, if cropped) -- needed to
354
+ correctly evaluate the offset functions, since
355
+ they were fit on full-scene coordinates, not the
356
+ reference crop's local 0-based ones.
357
+ sec_row_offset, sec_col_offset: The secondary array's (this
358
+ function's `data`) origin in full-scene
359
+ coordinates -- needed to convert the computed
360
+ full-scene secondary sample position back into
361
+ `data`'s own local coordinates for indexing,
362
+ since the secondary crop can have a different
363
+ offset than the reference crop.
364
+ All four default to 0 for already-full-scene
365
+ (uncropped) data.
307
366
 
308
367
  Returns:
309
368
  The resampled complex array, on the reference image's grid.
@@ -314,8 +373,26 @@ def resample_with_offset_field(data, row_offset_fn, col_offset_fn):
314
373
  h, w = data.shape
315
374
  row_idx, col_idx = np.mgrid[0:h, 0:w].astype(np.float64)
316
375
 
317
- sample_rows = row_idx + row_offset_fn(row_idx, col_idx)
318
- sample_cols = col_idx + col_offset_fn(row_idx, col_idx)
376
+ # row_idx/col_idx are the reference grid's local (0-based) coordinates.
377
+ # Convert to full-scene coordinates to correctly evaluate the fitted
378
+ # offset functions, which were built on real annotation-derived
379
+ # full-scene coordinates, not any particular crop's local ones.
380
+ ref_global_row = row_idx + ref_row_offset
381
+ ref_global_col = col_idx + ref_col_offset
382
+
383
+ offset_row = row_offset_fn(ref_global_row, ref_global_col)
384
+ offset_col = col_offset_fn(ref_global_row, ref_global_col)
385
+
386
+ # Where this pixel's secondary counterpart is, in full-scene coordinates
387
+ sec_global_row = ref_global_row + offset_row
388
+ sec_global_col = ref_global_col + offset_col
389
+
390
+ # Convert to the SECONDARY array's own local coordinates for actually
391
+ # indexing into `data` -- the secondary crop can have a different
392
+ # offset than the reference crop, so this is not the same subtraction
393
+ # as ref_row_offset/ref_col_offset above.
394
+ sample_rows = sec_global_row - sec_row_offset
395
+ sample_cols = sec_global_col - sec_col_offset
319
396
 
320
397
  real = map_coordinates(data.real, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0)
321
398
  imag = map_coordinates(data.imag, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0)
@@ -336,4 +413,4 @@ def _linspace(start, stop, n):
336
413
  if n == 1:
337
414
  return [start]
338
415
  step = (stop - start) / (n - 1)
339
- return [start + i * step for i in range(n)]
416
+ return [start + i * step for i in range(n)]
@@ -189,15 +189,193 @@ class SLCExtractor:
189
189
  else output_dir / f"{zip_path.stem}_{self._pol}.tif"
190
190
  )
191
191
 
192
- with zipfile.ZipFile(zip_path) as zf:
193
- with zf.open(matched_member) as src_f, open(out_path, "wb") as dst_f:
194
- dst_f.write(src_f.read())
192
+ cropped = self._crop_to_aoi(zip_path, matched_member, aoi_tuple, out_path)
193
+ if cropped is None:
194
+ logger.warning(
195
+ "Cropping %s to AOI failed — falling back to extracting the "
196
+ "full, uncropped sub-swath (this will be much larger and "
197
+ "slower for everything downstream).",
198
+ self._swath_label(matched_member),
199
+ )
200
+ with zipfile.ZipFile(zip_path) as zf:
201
+ with zf.open(matched_member) as src_f, open(out_path, "wb") as dst_f:
202
+ dst_f.write(src_f.read())
203
+ self._tag_matched_swath(out_path, matched_member)
195
204
 
196
205
  logger.info(
197
206
  "Extracted %s -> %s", self._swath_label(matched_member), out_path.name
198
207
  )
199
208
  return out_path
200
209
 
210
+ def _tag_matched_swath(self, out_path: Path, member_name: str) -> None:
211
+ """Tag an already-written GeoTIFF with its matched sub-swath label,
212
+ for the fallback (raw-copy) extraction path, which doesn't go
213
+ through rasterio for its initial write the way _crop_to_aoi()
214
+ does. Failure here is non-fatal -- the file itself is still
215
+ correct, just missing this one piece of discoverable metadata,
216
+ so downstream code falls back to arbitrary-first-match behaviour
217
+ rather than losing the extraction entirely."""
218
+ try:
219
+ import rasterio
220
+
221
+ with rasterio.open(out_path, "r+") as dst:
222
+ dst.update_tags(matched_swath=self._swath_label(member_name).lower())
223
+ except Exception as exc:
224
+ logger.warning(
225
+ "Could not tag %s with its matched sub-swath: %s", out_path.name, exc
226
+ )
227
+
228
+ def _crop_to_aoi(
229
+ self,
230
+ zip_path: Path,
231
+ member_name: str,
232
+ aoi_tuple: Tuple[float, float, float, float],
233
+ out_path: Path,
234
+ margin_frac: float = 0.15,
235
+ ) -> Optional[Path]:
236
+ """
237
+ Crop a zipped Sentinel-1 measurement TIFF to the AOI, reading
238
+ directly from inside the zip (no full-file extraction) and
239
+ writing only the windowed AOI region.
240
+
241
+ Sentinel-1 SLC TIFFs are GCP-georeferenced, not a simple affine
242
+ transform, so an exact pixel window can't be computed directly.
243
+ This fits an approximate affine transform from the real embedded
244
+ GCPs (rasterio.transform.from_gcps — the standard technique for
245
+ this), then adds a safety margin around the computed window to
246
+ protect against that approximation's error rather than risk
247
+ cutting off the real AOI.
248
+
249
+ Args:
250
+ margin_frac: Fractional padding added to the computed window
251
+ on each side (0.15 = 15%) — generous on purpose,
252
+ since padding too much only costs a little extra
253
+ size, while padding too little risks silently
254
+ losing part of the real area of interest.
255
+
256
+ Returns:
257
+ out_path on success, None if cropping wasn't possible for
258
+ any reason (missing GCPs, unreadable TIFF, degenerate
259
+ transform) — callers should fall back to full extraction
260
+ rather than fail outright.
261
+ """
262
+ import rasterio
263
+ from rasterio.transform import from_gcps
264
+ from rasterio.windows import Window
265
+ from rasterio.crs import CRS
266
+
267
+ vsi_path = f"/vsizip/{zip_path}/{member_name}"
268
+ try:
269
+ with rasterio.open(vsi_path) as src:
270
+ gcps, _gcp_crs = src.gcps
271
+ if not gcps or len(gcps) < 4:
272
+ logger.warning(
273
+ "%s: too few GCPs (%d) to fit a reliable crop transform",
274
+ member_name, len(gcps) if gcps else 0,
275
+ )
276
+ return None
277
+
278
+ approx_transform = from_gcps(gcps)
279
+
280
+ min_lon, min_lat, max_lon, max_lat = aoi_tuple
281
+ # Inverse-transform all 4 AOI corners and take the bounding
282
+ # box of the results, rather than rasterio.windows.from_bounds()
283
+ # -- that function assumes a standard north-up transform
284
+ # (row increases as latitude decreases) and raises "Bounds
285
+ # and transform are inconsistent" when it doesn't, which is
286
+ # exactly the orientation real ascending-pass Sentinel-1
287
+ # data has (confirmed directly against real product
288
+ # footprints). This corner-based approach makes no
289
+ # assumption about orientation at all.
290
+ inv_transform = ~approx_transform
291
+ corners = [
292
+ (min_lon, min_lat), (min_lon, max_lat),
293
+ (max_lon, min_lat), (max_lon, max_lat),
294
+ ]
295
+ cols, rows = [], []
296
+ for lon, lat in corners:
297
+ col, row = inv_transform * (lon, lat)
298
+ cols.append(col)
299
+ rows.append(row)
300
+ window = Window(
301
+ col_off=min(cols), row_off=min(rows),
302
+ width=max(cols) - min(cols), height=max(rows) - min(rows),
303
+ )
304
+
305
+ # Safety margin: the GCP-fitted transform is only an
306
+ # approximation, so pad the window generously rather than
307
+ # risk cropping out part of the real AOI.
308
+ pad_w = max(window.width * margin_frac, 50)
309
+ pad_h = max(window.height * margin_frac, 50)
310
+ col_off = window.col_off - pad_w
311
+ row_off = window.row_off - pad_h
312
+ width = window.width + 2 * pad_w
313
+ height = window.height + 2 * pad_h
314
+
315
+ # Clamp to the actual raster extent -- the padded window
316
+ # can legitimately extend past the real data bounds.
317
+ col_off_clamped = max(0, col_off)
318
+ row_off_clamped = max(0, row_off)
319
+ width_clamped = min(width - (col_off_clamped - col_off), src.width - col_off_clamped)
320
+ height_clamped = min(height - (row_off_clamped - row_off), src.height - row_off_clamped)
321
+
322
+ if width_clamped <= 0 or height_clamped <= 0:
323
+ logger.warning(
324
+ "%s: computed crop window falls entirely outside the "
325
+ "raster bounds -- GCP transform may be unreliable",
326
+ member_name,
327
+ )
328
+ return None
329
+
330
+ read_window = Window(
331
+ col_off_clamped, row_off_clamped, width_clamped, height_clamped
332
+ )
333
+ data = src.read(1, window=read_window)
334
+
335
+ if data.size == 0:
336
+ return None
337
+
338
+ # Real, locally-accurate transform for the cropped region,
339
+ # derived from the same GCP-fit transform used to locate it.
340
+ crop_transform = rasterio.windows.transform(read_window, approx_transform)
341
+
342
+ out_path.parent.mkdir(parents=True, exist_ok=True)
343
+ with rasterio.open(
344
+ out_path, "w", driver="GTiff",
345
+ dtype=data.dtype, count=1,
346
+ width=data.shape[1], height=data.shape[0],
347
+ crs=CRS.from_epsg(4326), transform=crop_transform,
348
+ compress="deflate",
349
+ ) as dst:
350
+ dst.write(data, 1)
351
+ # Record the crop's origin in the FULL, UNCROPPED
352
+ # scene's pixel coordinates. Real orbit-based
353
+ # coregistration computes offset fields using
354
+ # annotation.py's real acquisition timing, which
355
+ # describes the full, uncropped sub-swath -- without
356
+ # this offset recorded, downstream code has no way to
357
+ # know that local row/col 0,0 in this cropped file is
358
+ # actually row/col crop_row_off,crop_col_off in the
359
+ # scene the orbit math is expressed in, and would
360
+ # silently evaluate the fitted offset polynomial at
361
+ # the wrong coordinates.
362
+ dst.update_tags(
363
+ crop_row_off=str(row_off_clamped),
364
+ crop_col_off=str(col_off_clamped),
365
+ matched_swath=self._swath_label(member_name).lower(),
366
+ )
367
+
368
+ logger.info(
369
+ "Cropped %s: %dx%d -> %dx%d (%.1fx smaller)",
370
+ self._swath_label(member_name), src.width, src.height,
371
+ data.shape[1], data.shape[0],
372
+ (src.width * src.height) / max(data.size, 1),
373
+ )
374
+ return out_path
375
+ except Exception as exc:
376
+ logger.warning("Could not crop %s to AOI: %s", member_name, exc)
377
+ return None
378
+
201
379
  def list_subswaths(self, zip_path: Union[str, Path]) -> List[str]:
202
380
  """
203
381
  List the measurement TIFF entries for the configured polarisation
@@ -295,4 +473,4 @@ class SLCExtractor:
295
473
  for swath in ("iw1", "iw2", "iw3", "ew1", "ew2", "ew3", "ew4", "ew5"):
296
474
  if swath in lower:
297
475
  return swath.upper()
298
- return "?"
476
+ return "?"