PyGeoFetch 1.6.0__tar.gz → 1.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/CHANGELOG.md +2 -2
  2. {pygeofetch-1.6.0/pygeofetch.egg-info → pygeofetch-1.8.0}/PKG-INFO +3 -3
  3. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/__init__.py +1 -1
  4. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/__init__.py +46 -1
  5. pygeofetch-1.8.0/pygeofetch/insar/annotation.py +153 -0
  6. pygeofetch-1.8.0/pygeofetch/insar/coregister.py +339 -0
  7. pygeofetch-1.8.0/pygeofetch/insar/geolocation.py +478 -0
  8. pygeofetch-1.8.0/pygeofetch/insar/gpu.py +106 -0
  9. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/interferogram.py +174 -19
  10. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/timeseries.py +57 -5
  11. pygeofetch-1.8.0/pygeofetch/insar/validate.py +251 -0
  12. pygeofetch-1.8.0/pygeofetch/insar/visualize.py +226 -0
  13. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/meta.yaml +1 -1
  14. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/airbus_oneatlas.py +2 -2
  15. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/alaska_satellite_facility.py +2 -2
  16. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/digitalglobe.py +2 -2
  17. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/earth_explorer_additional.py +2 -2
  18. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/esa_scihub.py +2 -2
  19. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/geoserver_generic.py +2 -2
  20. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/google_earth_engine.py +2 -2
  21. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/inpe_cbers.py +2 -2
  22. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/jaxa_earth.py +2 -2
  23. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/maxar_gbdx.py +2 -2
  24. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/noaa_big_data.py +2 -2
  25. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/planet.py +2 -2
  26. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/terrabotics.py +2 -2
  27. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/viz/plot.py +381 -1
  28. {pygeofetch-1.6.0 → pygeofetch-1.8.0/pygeofetch.egg-info}/PKG-INFO +3 -3
  29. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/SOURCES.txt +6 -0
  30. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/requires.txt +2 -2
  31. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pyproject.toml +3 -3
  32. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/LICENSE +0 -0
  33. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/MANIFEST.in +0 -0
  34. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/README.md +0 -0
  35. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/__init__.py +0 -0
  36. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/auth_commands.py +0 -0
  37. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/config_commands.py +0 -0
  38. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/download_commands.py +0 -0
  39. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/index_commands.py +0 -0
  40. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/main.py +0 -0
  41. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/pipeline_process_commands.py +0 -0
  42. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/postprocess_commands.py +0 -0
  43. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/preprocess_commands.py +0 -0
  44. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/sar_commands.py +0 -0
  45. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/search_commands.py +0 -0
  46. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/config/__init__.py +0 -0
  47. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/config/defaults.yaml +0 -0
  48. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/config/settings.py +0 -0
  49. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/__init__.py +0 -0
  50. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/authenticator.py +0 -0
  51. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/cache_manager.py +0 -0
  52. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/downloader.py +0 -0
  53. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/engine.py +0 -0
  54. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/logging.py +0 -0
  55. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/orbits.py +0 -0
  56. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/scheduler.py +0 -0
  57. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/searcher.py +0 -0
  58. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/atmosphere.py +0 -0
  59. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/extraction.py +0 -0
  60. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/unwrap.py +0 -0
  61. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/__init__.py +0 -0
  62. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/download_task.py +0 -0
  63. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/satellite_data.py +0 -0
  64. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/search_query.py +0 -0
  65. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/user_auth.py +0 -0
  66. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/__init__.py +0 -0
  67. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/base.py +0 -0
  68. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/batch.py +0 -0
  69. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/indices.py +0 -0
  70. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/pipeline.py +0 -0
  71. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/postprocessor.py +0 -0
  72. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/preprocessor.py +0 -0
  73. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/sar.py +0 -0
  74. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/__init__.py +0 -0
  75. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/indices.py +0 -0
  76. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/landsat.py +0 -0
  77. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/loader.py +0 -0
  78. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/stacker.py +0 -0
  79. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/timeseries.py +0 -0
  80. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/__init__.py +0 -0
  81. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/aws_earth.py +0 -0
  82. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/base.py +0 -0
  83. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/copernicus.py +0 -0
  84. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/element84.py +0 -0
  85. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/eodag_provider.py +0 -0
  86. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/isro_bhuvan.py +0 -0
  87. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/nasa_earthdata.py +0 -0
  88. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
  89. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/opentopography.py +0 -0
  90. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/planetary_computer.py +0 -0
  91. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/sentinel_hub.py +0 -0
  92. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/usgs.py +0 -0
  93. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/__init__.py +0 -0
  94. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/_native.py +0 -0
  95. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/_ost.py +0 -0
  96. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/_sarxarray.py +0 -0
  97. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/extraction.py +0 -0
  98. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/processor.py +0 -0
  99. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/__init__.py +0 -0
  100. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/file_utils.py +0 -0
  101. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/gcp_recovery.py +0 -0
  102. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/geo_utils.py +0 -0
  103. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/logging_setup.py +0 -0
  104. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/retry_handler.py +0 -0
  105. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/validators.py +0 -0
  106. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/viz/__init__.py +0 -0
  107. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/viz/map.py +0 -0
  108. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/dependency_links.txt +0 -0
  109. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/entry_points.txt +0 -0
  110. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/not-zip-safe +0 -0
  111. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/top_level.txt +0 -0
  112. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/setup.cfg +0 -0
  113. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/tests/test_extraction.py +0 -0
  114. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/tests/test_insar.py +0 -0
  115. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/tests/test_pgv_integration_contract.py +0 -0
  116. {pygeofetch-1.6.0 → pygeofetch-1.8.0}/tests/test_viz_plot.py +0 -0
@@ -1,9 +1,9 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to PyGeoFetch are documented here.
4
- Format follows [Keep a Changelog](https://keepachangelog.com/en/1.6.0/).
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.8.0/).
5
5
 
6
- ## [1.6.0] — 2026-07-12
6
+ ## [1.8.0] — 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.6.0
3
+ Version: 1.8.0
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.7.0; extra == "viz"
98
- Requires-Dist: contextily>=1.6.0; extra == "viz"
97
+ Requires-Dist: osmnx>=1.8.0; extra == "viz"
98
+ Requires-Dist: contextily>=1.8.0; 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.6.0"
33
+ __version__ = "1.8.0"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
@@ -52,9 +52,39 @@ Usage::
52
52
 
53
53
  from pygeofetch.insar.atmosphere import AtmosphericCorrector
54
54
  from pygeofetch.insar.extraction import SLCExtractor
55
+ from pygeofetch.insar.gpu import gpu_available
55
56
  from pygeofetch.insar.interferogram import InterferogramGenerator, InterferogramResult
56
57
  from pygeofetch.insar.timeseries import SBASTimeSeries
57
58
  from pygeofetch.insar.unwrap import PhaseUnwrapper
59
+ from pygeofetch.insar.validate import DataValidator, ValidationResult
60
+ from pygeofetch.insar.visualize import (
61
+ visualize_interferogram,
62
+ visualize_timeseries,
63
+ visualize_unwrapped,
64
+ )
65
+
66
+ # Real, orbit-based coregistration components. All exported here are
67
+ # individually verified against known ground truth (see each module's
68
+ # docstring for specifics): annotation.py against ESA's own field-path
69
+ # spec; orbit parsing/interpolation against exact reference values;
70
+ # find_zero_doppler_time against known times from starting guesses up
71
+ # to 30 seconds off; geodetic_to_ecef via round-trip verification.
72
+ # solve_ground_point (in geolocation.py) is deliberately NOT exported
73
+ # here — it has a known, unresolved reliability gap and is not
74
+ # recommended as a primary coregistration path; use
75
+ # compute_offset_field_from_dem() instead, which avoids it entirely.
76
+ from pygeofetch.insar.annotation import SLCGeometry, parse_slc_geometry
77
+ from pygeofetch.insar.geolocation import (
78
+ geodetic_to_ecef,
79
+ find_zero_doppler_time,
80
+ parse_orbit_file,
81
+ interpolate_orbit_state,
82
+ )
83
+ from pygeofetch.insar.coregister import (
84
+ compute_offset_field_from_dem,
85
+ fit_offset_polynomial,
86
+ resample_with_offset_field,
87
+ )
58
88
 
59
89
  __all__ = [
60
90
  "InterferogramGenerator",
@@ -63,4 +93,19 @@ __all__ = [
63
93
  "SBASTimeSeries",
64
94
  "AtmosphericCorrector",
65
95
  "SLCExtractor",
66
- ]
96
+ "DataValidator",
97
+ "ValidationResult",
98
+ "gpu_available",
99
+ "visualize_interferogram",
100
+ "visualize_unwrapped",
101
+ "visualize_timeseries",
102
+ "SLCGeometry",
103
+ "parse_slc_geometry",
104
+ "geodetic_to_ecef",
105
+ "find_zero_doppler_time",
106
+ "parse_orbit_file",
107
+ "interpolate_orbit_state",
108
+ "compute_offset_field_from_dem",
109
+ "fit_offset_polynomial",
110
+ "resample_with_offset_field",
111
+ ]
@@ -0,0 +1,153 @@
1
+ """
2
+ Sentinel-1 annotation XML parsing — real per-pixel acquisition timing.
3
+
4
+ This is the missing link between "a pixel at (row, col) in an extracted
5
+ SLC GeoTIFF" and "the exact acquisition time and slant range needed to
6
+ feed geolocation.py's orbit-based solver" — that information does not
7
+ exist in the GeoTIFF itself (a general raster format); it lives in a
8
+ separate XML file inside the .SAFE archive.
9
+
10
+ Field paths confirmed against ESA's own Mission Performance Centre
11
+ documentation (Thermal Denoising of Products Generated by the S-1 IPF,
12
+ MPC-0392):
13
+
14
+ /product/imageAnnotation/imageInformation/productFirstLineUtcTime
15
+ /product/imageAnnotation/imageInformation/azimuthTimeInterval
16
+ /product/imageAnnotation/imageInformation/slantRangeTime
17
+ /product/generalAnnotation/productInformation/rangeSamplingRate
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import zipfile
24
+ from dataclasses import dataclass
25
+ from datetime import datetime, timedelta
26
+ from pathlib import Path
27
+ from typing import Optional, Union
28
+
29
+ logger = logging.getLogger("pygeofetch.insar.annotation")
30
+
31
+ SPEED_OF_LIGHT = 299792458.0
32
+
33
+
34
+ @dataclass
35
+ class SLCGeometry:
36
+ """
37
+ Real per-pixel acquisition geometry for one Sentinel-1 SLC measurement,
38
+ parsed from its annotation XML — everything needed to convert a pixel
39
+ (row, col) into an exact acquisition time and slant range.
40
+ """
41
+
42
+ first_line_time: datetime
43
+ azimuth_time_interval_s: float
44
+ near_range_time_s: float
45
+ range_sampling_rate_hz: float
46
+ n_lines: int
47
+ n_columns: int
48
+
49
+ def azimuth_time(self, row: float) -> datetime:
50
+ """Exact acquisition time of a given (possibly fractional) row."""
51
+ return self.first_line_time + timedelta(
52
+ seconds=row * self.azimuth_time_interval_s
53
+ )
54
+
55
+ def range_time(self, col: float) -> float:
56
+ """Two-way slant range time (seconds) of a given (possibly
57
+ fractional) column."""
58
+ return self.near_range_time_s + col / self.range_sampling_rate_hz
59
+
60
+ def slant_range_m(self, col: float) -> float:
61
+ """One-way slant range (metres) of a given column."""
62
+ return self.range_time(col) * SPEED_OF_LIGHT / 2.0
63
+
64
+ def row_for_azimuth_time(self, t: datetime) -> float:
65
+ """Inverse of azimuth_time(): which (fractional) row corresponds
66
+ to a given acquisition time."""
67
+ return (t - self.first_line_time).total_seconds() / self.azimuth_time_interval_s
68
+
69
+ def col_for_range_time(self, range_time_s: float) -> float:
70
+ """Inverse of range_time(): which (fractional) column corresponds
71
+ to a given two-way range time."""
72
+ return (range_time_s - self.near_range_time_s) * self.range_sampling_rate_hz
73
+
74
+
75
+ def parse_slc_geometry(
76
+ safe_zip_path: Union[str, Path], member_hint: Optional[str] = None
77
+ ) -> SLCGeometry:
78
+ """
79
+ Parse real per-pixel acquisition geometry from a Sentinel-1 SAFE
80
+ archive's annotation XML.
81
+
82
+ Args:
83
+ safe_zip_path: Path to the downloaded .SAFE.zip archive.
84
+ member_hint: Optional substring to disambiguate which
85
+ annotation file to use when multiple sub-swaths/
86
+ polarisations are present (e.g. "iw2-vv" or the
87
+ measurement filename this geometry corresponds
88
+ to). If None, uses the first annotation XML found.
89
+
90
+ Returns:
91
+ A real SLCGeometry with the exact timing needed to feed
92
+ geolocation.py's solve_ground_point().
93
+
94
+ Raises:
95
+ ValueError if no annotation XML is found, or a required field
96
+ is missing — surfaced clearly rather than returning a
97
+ geometry built from guessed/default values.
98
+ """
99
+ import xml.etree.ElementTree as ET
100
+
101
+ with zipfile.ZipFile(safe_zip_path) as zf:
102
+ candidates = [
103
+ n for n in zf.namelist()
104
+ if "/annotation/" in n and n.lower().endswith(".xml")
105
+ and "/calibration/" not in n.lower()
106
+ ]
107
+ if member_hint:
108
+ filtered = [n for n in candidates if member_hint.lower() in n.lower()]
109
+ if filtered:
110
+ candidates = filtered
111
+
112
+ if not candidates:
113
+ raise ValueError(
114
+ f"{safe_zip_path}: no annotation XML found — this doesn't "
115
+ f"look like a real Sentinel-1 SAFE archive, or the "
116
+ f"expected internal structure has changed."
117
+ )
118
+
119
+ with zf.open(candidates[0]) as f:
120
+ root = ET.parse(f).getroot()
121
+
122
+ def get_text(path: str) -> str:
123
+ elem = root.find(path)
124
+ if elem is None or elem.text is None:
125
+ raise ValueError(
126
+ f"{safe_zip_path}: required annotation field missing: {path}"
127
+ )
128
+ return elem.text
129
+
130
+ first_line_str = get_text(".//imageAnnotation/imageInformation/productFirstLineUtcTime")
131
+ first_line_time = datetime.fromisoformat(first_line_str)
132
+
133
+ geometry = SLCGeometry(
134
+ first_line_time=first_line_time,
135
+ azimuth_time_interval_s=float(
136
+ get_text(".//imageAnnotation/imageInformation/azimuthTimeInterval")
137
+ ),
138
+ near_range_time_s=float(
139
+ get_text(".//imageAnnotation/imageInformation/slantRangeTime")
140
+ ),
141
+ range_sampling_rate_hz=float(
142
+ get_text(".//generalAnnotation/productInformation/rangeSamplingRate")
143
+ ),
144
+ n_lines=int(get_text(".//imageAnnotation/imageInformation/numberOfLines")),
145
+ n_columns=int(get_text(".//imageAnnotation/imageInformation/numberOfColumns")),
146
+ )
147
+
148
+ logger.info(
149
+ "Parsed real SLC geometry from %s: %d x %d, starting %s",
150
+ Path(safe_zip_path).name, geometry.n_lines, geometry.n_columns,
151
+ geometry.first_line_time,
152
+ )
153
+ return geometry
@@ -0,0 +1,339 @@
1
+ """
2
+ Real orbit-based coregistration offset field.
3
+
4
+ Combines annotation.py (real per-pixel acquisition timing) and
5
+ geolocation.py (the orbit-based Doppler/Range/Ellipsoid solve) to
6
+ compute a genuine, physically-grounded offset field between a
7
+ reference and secondary SLC — replacing the naive shape-only resample
8
+ that was previously used regardless of whether the two images were
9
+ actually aligned.
10
+
11
+ Method (standard practice, e.g. Kampes, Hanssen & Perski 2003; matches
12
+ what the 2019 URSI paper and the current TOPS coregistration literature
13
+ both describe): compute the real offset at a sparse grid of points
14
+ across the image (not every pixel — solving per pixel would be far
15
+ too slow and offers no real accuracy benefit, since the offset field
16
+ is smooth), fit a low-degree 2D polynomial to that sparse grid, then
17
+ apply the fitted, dense field to resample the secondary image.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from pathlib import Path
24
+ from typing import Tuple, Union
25
+
26
+ logger = logging.getLogger("pygeofetch.insar.coregister")
27
+
28
+
29
+ def compute_offset_field_from_dem(
30
+ ref_geometry,
31
+ ref_orbit,
32
+ sec_geometry,
33
+ sec_orbit,
34
+ dem_path: Union[str, Path],
35
+ ref_scene_center_time,
36
+ sec_scene_center_time,
37
+ grid_points: int = 7,
38
+ ):
39
+ """
40
+ Compute a real, orbit-based offset field using ground points sampled
41
+ directly from a real DEM's own geographic coordinates — not solved
42
+ for via the unreliable solve_ground_point(). This is the
43
+ recommended path: it uses only components verified reliable
44
+ (geodetic_to_ecef, a closed-form conversion with no iteration, and
45
+ find_zero_doppler_time, confirmed robust even from starting guesses
46
+ tens of seconds off), never the 3-equation Doppler/Range/Ellipsoid
47
+ solve that has a known, unresolved reliability gap.
48
+
49
+ Args:
50
+ ref_geometry, sec_geometry: SLCGeometry for each image.
51
+ ref_orbit, sec_orbit: (times, positions, velocities) for each.
52
+ dem_path: A real DEM covering the scene, with real
53
+ geographic (lat/lon) referencing — the same DEM
54
+ already optionally supplied to process_pair()
55
+ for topographic phase removal.
56
+ ref_scene_center_time, sec_scene_center_time: Approximate
57
+ acquisition time for each image (e.g. each
58
+ geometry's azimuth_time() at its own centre row)
59
+ — used as the starting guess for
60
+ find_zero_doppler_time(), which only needs to be
61
+ roughly right (confirmed robust to a 30-second-
62
+ off starting guess in testing).
63
+ grid_points: Sample grid resolution per axis.
64
+
65
+ Returns:
66
+ (grid_rows, grid_cols, offset_rows, offset_cols) — same shape
67
+ as compute_offset_field(), ready for fit_offset_polynomial().
68
+
69
+ Raises:
70
+ RuntimeError if too many grid points fail (more than half) —
71
+ a real, actionable signal (e.g. the DEM doesn't actually
72
+ overlap the scene), not silently tolerated.
73
+ """
74
+ import numpy as np
75
+ import rasterio
76
+
77
+ from pygeofetch.insar.geolocation import geodetic_to_ecef, find_zero_doppler_time
78
+
79
+ with rasterio.open(dem_path) as src:
80
+ dem = src.read(1)
81
+ transform = src.transform
82
+ dem_h, dem_w = dem.shape
83
+
84
+ dem_row_samples = [int(r) for r in _linspace(0, dem_h - 1, grid_points)]
85
+ dem_col_samples = [int(c) for c in _linspace(0, dem_w - 1, grid_points)]
86
+
87
+ grid_rows, grid_cols, offset_rows, offset_cols = [], [], [], []
88
+ n_failed = 0
89
+ n_total = 0
90
+
91
+ for dem_row in dem_row_samples:
92
+ for dem_col in dem_col_samples:
93
+ n_total += 1
94
+ try:
95
+ lon, lat = transform * (dem_col + 0.5, dem_row + 0.5)
96
+ height = float(dem[dem_row, dem_col])
97
+ if not np.isfinite(height):
98
+ height = 0.0
99
+ ground_point = geodetic_to_ecef(lat, lon, height)
100
+
101
+ t_ref = find_zero_doppler_time(
102
+ ref_orbit[0], ref_orbit[1], ref_orbit[2],
103
+ ground_point, ref_scene_center_time,
104
+ )
105
+ t_sec = find_zero_doppler_time(
106
+ sec_orbit[0], sec_orbit[1], sec_orbit[2],
107
+ ground_point, sec_scene_center_time,
108
+ )
109
+
110
+ ref_row = ref_geometry.row_for_azimuth_time(t_ref)
111
+ sec_row = sec_geometry.row_for_azimuth_time(t_sec)
112
+
113
+ sat_pos_ref, _ = _interpolate(ref_orbit, t_ref)
114
+ sat_pos_sec, _ = _interpolate(sec_orbit, t_sec)
115
+ range_ref_time = 2 * _distance(sat_pos_ref, ground_point) / 299792458.0
116
+ range_sec_time = 2 * _distance(sat_pos_sec, ground_point) / 299792458.0
117
+ ref_col = ref_geometry.col_for_range_time(range_ref_time)
118
+ sec_col = sec_geometry.col_for_range_time(range_sec_time)
119
+
120
+ if not (0 <= ref_row < ref_geometry.n_lines and 0 <= ref_col < ref_geometry.n_columns):
121
+ continue # this DEM point falls outside the actual SLC extent
122
+
123
+ grid_rows.append(ref_row)
124
+ grid_cols.append(ref_col)
125
+ offset_rows.append(sec_row - ref_row)
126
+ offset_cols.append(sec_col - ref_col)
127
+ except RuntimeError as exc:
128
+ n_failed += 1
129
+ logger.debug("DEM-driven offset field: point (%d, %d) failed: %s", dem_row, dem_col, exc)
130
+
131
+ if n_failed > n_total / 2:
132
+ raise RuntimeError(
133
+ f"DEM-driven offset field: {n_failed}/{n_total} points failed "
134
+ f"— check that the DEM genuinely overlaps the scene and the "
135
+ f"orbit files' validity periods cover both acquisition times."
136
+ )
137
+ if not grid_rows:
138
+ raise RuntimeError(
139
+ "DEM-driven offset field: no valid grid points fell within "
140
+ "the actual SLC extent — check the DEM covers the scene."
141
+ )
142
+
143
+ logger.info(
144
+ "DEM-driven offset field: %d/%d points solved successfully",
145
+ len(grid_rows), n_total,
146
+ )
147
+ return grid_rows, grid_cols, offset_rows, offset_cols
148
+
149
+
150
+ def compute_offset_field(
151
+ ref_geometry,
152
+ ref_orbit,
153
+ sec_geometry,
154
+ sec_orbit,
155
+ image_shape: Tuple[int, int],
156
+ dem_height_m: float = 0.0,
157
+ grid_points: int = 7,
158
+ ):
159
+ """
160
+ Compute a real, orbit-based offset field between a reference and
161
+ secondary SLC image, sampled on a sparse grid and ready for
162
+ polynomial fitting.
163
+
164
+ Args:
165
+ ref_geometry, sec_geometry: SLCGeometry from
166
+ annotation.parse_slc_geometry() for each image.
167
+ ref_orbit, sec_orbit: (times, positions, velocities) from
168
+ geolocation.parse_orbit_file() for each image.
169
+ image_shape: (rows, cols) of the reference image.
170
+ dem_height_m: Constant height above the ellipsoid to use for
171
+ the geolocation solve (0.0 = pure ellipsoid). A
172
+ real per-pixel DEM lookup would be more accurate
173
+ over rugged terrain but isn't wired in here yet.
174
+ grid_points: Number of sample points per axis (grid_points^2
175
+ total ground-point solves) — 7 is a reasonable
176
+ default balancing accuracy and speed; the offset
177
+ field is smooth, so a sparse grid captures it well.
178
+
179
+ Returns:
180
+ (grid_rows, grid_cols, offset_rows, offset_cols): four lists of
181
+ equal length — the sample grid positions in the reference image
182
+ and the real, solved offset (secondary - reference) at each,
183
+ in pixels.
184
+
185
+ Raises:
186
+ RuntimeError if too many grid points fail to solve (more than
187
+ half) — a real, actionable signal that the orbit data or
188
+ geometry inputs are likely wrong, surfaced clearly rather than
189
+ silently fitting a polynomial to mostly-garbage points.
190
+ """
191
+ from pygeofetch.insar.geolocation import solve_ground_point, find_zero_doppler_time
192
+
193
+ rows, cols = image_shape
194
+ row_samples = [int(r) for r in _linspace(0, rows - 1, grid_points)]
195
+ col_samples = [int(c) for c in _linspace(0, cols - 1, grid_points)]
196
+
197
+ grid_rows, grid_cols, offset_rows, offset_cols = [], [], [], []
198
+ n_failed = 0
199
+ n_total = 0
200
+ last_ground_point = None # warm-start seed, carried across grid points
201
+
202
+ for row in row_samples:
203
+ for col in col_samples:
204
+ n_total += 1
205
+ try:
206
+ t_ref = ref_geometry.azimuth_time(row)
207
+ range_time = ref_geometry.range_time(col)
208
+
209
+ sat_pos, sat_vel = _interpolate(ref_orbit, t_ref)
210
+ ground_point = solve_ground_point(
211
+ sat_pos, sat_vel, range_time, dem_height_m=dem_height_m,
212
+ initial_guess=last_ground_point,
213
+ )
214
+ last_ground_point = ground_point # seed the next grid point
215
+
216
+ t_sec = find_zero_doppler_time(
217
+ sec_orbit[0], sec_orbit[1], sec_orbit[2], ground_point, t_ref
218
+ )
219
+ sec_row = sec_geometry.row_for_azimuth_time(t_sec)
220
+
221
+ sat_pos_sec, _ = _interpolate(sec_orbit, t_sec)
222
+ range_sec_m = _distance(sat_pos_sec, ground_point)
223
+ range_time_sec = 2 * range_sec_m / 299792458.0
224
+ sec_col = sec_geometry.col_for_range_time(range_time_sec)
225
+
226
+ grid_rows.append(row)
227
+ grid_cols.append(col)
228
+ offset_rows.append(sec_row - row)
229
+ offset_cols.append(sec_col - col)
230
+ except RuntimeError as exc:
231
+ n_failed += 1
232
+ logger.debug("Offset field: grid point (%d, %d) failed: %s", row, col, exc)
233
+ # Don't carry a failed point's (non-existent) result forward
234
+ # as the next warm-start seed -- last_ground_point simply
235
+ # stays at its last successful value, which is still a
236
+ # reasonable seed for the next point.
237
+
238
+ if n_failed > n_total / 2:
239
+ raise RuntimeError(
240
+ f"Offset field computation: {n_failed}/{n_total} grid points "
241
+ f"failed to solve — this usually means the orbit data doesn't "
242
+ f"actually correspond to this scene, or the annotation timing "
243
+ f"is wrong, not a transient numerical issue. Check that the "
244
+ f"orbit file's validity period actually covers this "
245
+ f"acquisition's real timestamp."
246
+ )
247
+ if n_failed > 0:
248
+ logger.warning(
249
+ "Offset field: %d/%d grid points failed to solve (proceeding "
250
+ "with the remaining %d) — the polynomial fit below is still "
251
+ "valid, but based on fewer points than requested.",
252
+ n_failed, n_total, n_total - n_failed,
253
+ )
254
+
255
+ return grid_rows, grid_cols, offset_rows, offset_cols
256
+
257
+
258
+ def fit_offset_polynomial(grid_rows, grid_cols, offsets, degree: int = 1):
259
+ """
260
+ Fit a low-degree 2D polynomial to a sparse offset grid — standard
261
+ practice (Kampes, Hanssen & Perski 2003; matches the 2019 URSI
262
+ paper's approach) rather than every-pixel solving, since the true
263
+ offset field varies smoothly across a scene.
264
+
265
+ Returns a callable f(row, col) -> offset, evaluable at any pixel.
266
+ """
267
+ import numpy as np
268
+
269
+ rows = np.asarray(grid_rows, dtype=np.float64)
270
+ cols = np.asarray(grid_cols, dtype=np.float64)
271
+ vals = np.asarray(offsets, dtype=np.float64)
272
+
273
+ if degree == 1:
274
+ A = np.column_stack([np.ones_like(rows), rows, cols])
275
+ elif degree == 2:
276
+ A = np.column_stack([
277
+ np.ones_like(rows), rows, cols, rows * cols, rows**2, cols**2,
278
+ ])
279
+ else:
280
+ raise ValueError(f"Unsupported polynomial degree: {degree} (use 1 or 2)")
281
+
282
+ coeffs, *_ = np.linalg.lstsq(A, vals, rcond=None)
283
+
284
+ def evaluate(row, col):
285
+ row = np.asarray(row, dtype=np.float64)
286
+ col = np.asarray(col, dtype=np.float64)
287
+ if degree == 1:
288
+ return coeffs[0] + coeffs[1] * row + coeffs[2] * col
289
+ return (
290
+ coeffs[0] + coeffs[1] * row + coeffs[2] * col
291
+ + coeffs[3] * row * col + coeffs[4] * row**2 + coeffs[5] * col**2
292
+ )
293
+
294
+ return evaluate
295
+
296
+
297
+ def resample_with_offset_field(data, row_offset_fn, col_offset_fn):
298
+ """
299
+ Resample a complex array using a real, per-pixel offset field
300
+ (from fit_offset_polynomial), rather than a naive shape-only zoom.
301
+
302
+ Args:
303
+ data: Complex array to resample (the secondary SLC).
304
+ row_offset_fn, col_offset_fn: Callables from fit_offset_polynomial(),
305
+ giving the real (secondary - reference) offset
306
+ at any (row, col) in the reference grid.
307
+
308
+ Returns:
309
+ The resampled complex array, on the reference image's grid.
310
+ """
311
+ import numpy as np
312
+ from scipy.ndimage import map_coordinates
313
+
314
+ h, w = data.shape
315
+ row_idx, col_idx = np.mgrid[0:h, 0:w].astype(np.float64)
316
+
317
+ sample_rows = row_idx + row_offset_fn(row_idx, col_idx)
318
+ sample_cols = col_idx + col_offset_fn(row_idx, col_idx)
319
+
320
+ real = map_coordinates(data.real, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0)
321
+ imag = map_coordinates(data.imag, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0)
322
+ return (real + 1j * imag).astype(np.complex64)
323
+
324
+
325
+ def _interpolate(orbit, t):
326
+ from pygeofetch.insar.geolocation import interpolate_orbit_state
327
+
328
+ return interpolate_orbit_state(orbit[0], orbit[1], orbit[2], t)
329
+
330
+
331
+ def _distance(a, b):
332
+ return sum((a[i] - b[i]) ** 2 for i in range(3)) ** 0.5
333
+
334
+
335
+ def _linspace(start, stop, n):
336
+ if n == 1:
337
+ return [start]
338
+ step = (stop - start) / (n - 1)
339
+ return [start + i * step for i in range(n)]