PyGeoFetch 2.6.2.4__py3-none-any.whl → 2.6.2.5__py3-none-any.whl

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 (31) hide show
  1. pygeofetch/__init__.py +1 -1
  2. pygeofetch/cli/main.py +3 -1
  3. pygeofetch/cli/multisensor_commands.py +243 -0
  4. pygeofetch/core/engine.py +1 -2
  5. pygeofetch/insar/advanced_safeguards.py +370 -0
  6. pygeofetch/meta.yaml +1 -1
  7. pygeofetch/multisensor/dem_differencing.py +201 -0
  8. pygeofetch/multisensor/flood_mapping.py +215 -0
  9. pygeofetch/multisensor/insar_optical_fusion.py +269 -0
  10. pygeofetch/multisensor/multisensor_init.py +55 -0
  11. pygeofetch/multisensor/terrain_corrected_change.py +285 -0
  12. pygeofetch/multisensor/vegetation_disturbance.py +220 -0
  13. pygeofetch/optical/__init__.py +9 -1
  14. pygeofetch/optical/offset_tracking.py +307 -4
  15. pygeofetch/providers/__init__.py +2 -4
  16. pygeofetch/providers/nasa_earthdata.py +26 -6
  17. pygeofetch/providers/nasa_earthdata_cloud.py +114 -38
  18. pygeofetch/providers/opentopography.py +203 -7
  19. pygeofetch/providers/planet.py +23 -2
  20. pygeofetch/providers/sentinel_hub.py +50 -25
  21. pygeofetch/sar/__init__.py +28 -1
  22. pygeofetch/sar/math.py +100 -0
  23. pygeofetch/sar/pipelines.py +573 -0
  24. pygeofetch/sar/processor.py +371 -97
  25. pygeofetch/utils/sar_math.py +100 -0
  26. {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/METADATA +4 -9
  27. {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/RECORD +31 -20
  28. {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/WHEEL +0 -0
  29. {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/entry_points.txt +0 -0
  30. {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/licenses/LICENSE +0 -0
  31. {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/top_level.txt +0 -0
pygeofetch/__init__.py CHANGED
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "2.6.2.4.4"
33
+ __version__ = "2.6.2.5.4"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
pygeofetch/cli/main.py CHANGED
@@ -26,6 +26,7 @@ from pygeofetch.cli.auth_commands import auth
26
26
  from pygeofetch.cli.config_commands import config
27
27
  from pygeofetch.cli.download_commands import download
28
28
  from pygeofetch.cli.index_commands import index
29
+ from pygeofetch.cli.multisensor_commands import multisensor
29
30
  from pygeofetch.cli.optical_commands import optical
30
31
  from pygeofetch.cli.pipeline_process_commands import proc_pipeline
31
32
  from pygeofetch.cli.postprocess_commands import post
@@ -105,6 +106,7 @@ cli.add_command(post)
105
106
  cli.add_command(sar)
106
107
  cli.add_command(proc_pipeline)
107
108
  cli.add_command(optical)
109
+ cli.add_command(multisensor)
108
110
 
109
111
 
110
112
  # ---------------------------------------------------------------------------
@@ -921,4 +923,4 @@ def main() -> None:
921
923
 
922
924
 
923
925
  if __name__ == "__main__":
924
- main()
926
+ main()
@@ -0,0 +1,243 @@
1
+ """CLI access to pygeofetch's 5 real multi-sensor pipelines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+
10
+ @click.group()
11
+ def multisensor() -> None:
12
+ """Multi-sensor fusion pipelines: InSAR+optical, SAR+optical flood
13
+ mapping, terrain-corrected change, DEM differencing, vegetation
14
+ disturbance. See docs/processing/multi-sensor-pipelines.md."""
15
+
16
+
17
+ def _print_result(result) -> None:
18
+ if result.success:
19
+ click.echo(f"✓ Success -> {result.final_output}")
20
+ if result.metadata:
21
+ click.echo(
22
+ json.dumps(
23
+ {k: str(v) for k, v in result.metadata.items()},
24
+ indent=2,
25
+ )
26
+ )
27
+ else:
28
+ click.echo(f"✗ Failed: {result.error}", err=True)
29
+ raise SystemExit(1)
30
+
31
+
32
+ @multisensor.command("insar-optical-fusion")
33
+ @click.option("--insar-velocity", required=True, type=click.Path(exists=True))
34
+ @click.option("--insar-coherence", required=True, type=click.Path(exists=True))
35
+ @click.option("--optical-ref", required=True, type=click.Path(exists=True))
36
+ @click.option("--optical-sec", required=True, type=click.Path(exists=True))
37
+ @click.option("--output-dir", required=True, type=click.Path())
38
+ @click.option("--window-size", default=64, show_default=True, type=int)
39
+ @click.option("--step-size", default=16, show_default=True, type=int)
40
+ @click.option("--snr-threshold", default=3.0, show_default=True, type=float)
41
+ @click.option("--coherence-low", default=0.4, show_default=True, type=float)
42
+ @click.option("--coherence-high", default=0.6, show_default=True, type=float)
43
+ @click.option("--cloud-mask", default=None, type=click.Path(exists=True))
44
+ def insar_optical_fusion_cmd(
45
+ insar_velocity,
46
+ insar_coherence,
47
+ optical_ref,
48
+ optical_sec,
49
+ output_dir,
50
+ window_size,
51
+ step_size,
52
+ snr_threshold,
53
+ coherence_low,
54
+ coherence_high,
55
+ cloud_mask,
56
+ ):
57
+ """Fuse InSAR displacement with optical pixel offset tracking."""
58
+ from pygeofetch.multisensor import insar_optical_displacement_pipeline
59
+
60
+ result = insar_optical_displacement_pipeline(
61
+ insar_velocity,
62
+ insar_coherence,
63
+ optical_ref,
64
+ optical_sec,
65
+ output_dir,
66
+ cloud_mask_path=cloud_mask,
67
+ window_size=window_size,
68
+ step_size=step_size,
69
+ snr_threshold=snr_threshold,
70
+ coherence_low=coherence_low,
71
+ coherence_high=coherence_high,
72
+ )
73
+ _print_result(result)
74
+
75
+
76
+ @multisensor.command("flood-map")
77
+ @click.option("--sar", "sar_input", required=True, type=click.Path(exists=True))
78
+ @click.option("--optical-green", required=True, type=click.Path(exists=True))
79
+ @click.option("--optical-nir", required=True, type=click.Path(exists=True))
80
+ @click.option("--output-dir", required=True, type=click.Path())
81
+ @click.option("--sar-reference", default=None, type=click.Path(exists=True))
82
+ @click.option("--sar-threshold", default=-15.0, show_default=True, type=float)
83
+ @click.option("--ndwi-threshold", default=0.0, show_default=True, type=float)
84
+ @click.option("--cloud-mask", default=None, type=click.Path(exists=True))
85
+ @click.option(
86
+ "--fusion-mode",
87
+ default="cloud_aware",
88
+ show_default=True,
89
+ type=click.Choice(["cloud_aware", "union", "intersection"]),
90
+ )
91
+ def flood_map_cmd(
92
+ sar_input,
93
+ optical_green,
94
+ optical_nir,
95
+ output_dir,
96
+ sar_reference,
97
+ sar_threshold,
98
+ ndwi_threshold,
99
+ cloud_mask,
100
+ fusion_mode,
101
+ ):
102
+ """Fuse SAR and optical NDWI for flood/water extent mapping."""
103
+ from pygeofetch.multisensor import multi_sensor_flood_pipeline
104
+ from pygeofetch.sar import SARProcessor
105
+
106
+ result = multi_sensor_flood_pipeline(
107
+ SARProcessor(),
108
+ sar_input,
109
+ optical_green,
110
+ optical_nir,
111
+ output_dir,
112
+ sar_reference_path=sar_reference,
113
+ sar_threshold=sar_threshold,
114
+ optical_ndwi_threshold=ndwi_threshold,
115
+ cloud_mask_path=cloud_mask,
116
+ fusion_mode=fusion_mode,
117
+ )
118
+ _print_result(result)
119
+
120
+
121
+ @multisensor.command("terrain-change")
122
+ @click.option("--pre", "pre_path", required=True, type=click.Path(exists=True))
123
+ @click.option("--post", "post_path", required=True, type=click.Path(exists=True))
124
+ @click.option("--dem", "dem_path", required=True, type=click.Path(exists=True))
125
+ @click.option(
126
+ "--pre-datetime",
127
+ required=True,
128
+ help="Real UTC ISO datetime, e.g. 2024-01-15T10:30:00",
129
+ )
130
+ @click.option("--post-datetime", required=True, help="Real UTC ISO datetime")
131
+ @click.option("--lat", "latitude", required=True, type=float)
132
+ @click.option("--lon", "longitude", required=True, type=float)
133
+ @click.option("--output-dir", required=True, type=click.Path())
134
+ @click.option("--change-threshold", default=0.1, show_default=True, type=float)
135
+ def terrain_change_cmd(
136
+ pre_path,
137
+ post_path,
138
+ dem_path,
139
+ pre_datetime,
140
+ post_datetime,
141
+ latitude,
142
+ longitude,
143
+ output_dir,
144
+ change_threshold,
145
+ ):
146
+ """Terrain-corrected optical change detection (removes real
147
+ illumination artifacts using each date's own solar position)."""
148
+ from datetime import datetime, timezone
149
+
150
+ from pygeofetch.multisensor import terrain_corrected_change_pipeline
151
+
152
+ def _parse(dt_str):
153
+ dt = datetime.fromisoformat(dt_str)
154
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
155
+
156
+ result = terrain_corrected_change_pipeline(
157
+ pre_path,
158
+ post_path,
159
+ dem_path,
160
+ _parse(pre_datetime),
161
+ _parse(post_datetime),
162
+ latitude,
163
+ longitude,
164
+ output_dir,
165
+ change_threshold=change_threshold,
166
+ )
167
+ _print_result(result)
168
+
169
+
170
+ @multisensor.command("dem-diff")
171
+ @click.option("--dem-old", required=True, type=click.Path(exists=True))
172
+ @click.option("--dem-new", required=True, type=click.Path(exists=True))
173
+ @click.option("--output-dir", required=True, type=click.Path())
174
+ @click.option(
175
+ "--old-rmse",
176
+ default=1.0,
177
+ show_default=True,
178
+ type=float,
179
+ help="Real vertical RMSE of the old DEM, metres.",
180
+ )
181
+ @click.option(
182
+ "--new-rmse",
183
+ default=1.0,
184
+ show_default=True,
185
+ type=float,
186
+ help="Real vertical RMSE of the new DEM, metres.",
187
+ )
188
+ @click.option("--confidence-t", default=1.96, show_default=True, type=float)
189
+ def dem_diff_cmd(dem_old, dem_new, output_dir, old_rmse, new_rmse, confidence_t):
190
+ """DEM-of-Difference volumetric change analysis."""
191
+ from pygeofetch.multisensor import dem_differencing_pipeline
192
+
193
+ result = dem_differencing_pipeline(
194
+ dem_old,
195
+ dem_new,
196
+ output_dir,
197
+ dem_old_vertical_rmse_m=old_rmse,
198
+ dem_new_vertical_rmse_m=new_rmse,
199
+ confidence_t_value=confidence_t,
200
+ )
201
+ _print_result(result)
202
+
203
+
204
+ @multisensor.command("vegetation-disturbance")
205
+ @click.option("--red-pre", required=True, type=click.Path(exists=True))
206
+ @click.option("--nir-pre", required=True, type=click.Path(exists=True))
207
+ @click.option("--red-post", required=True, type=click.Path(exists=True))
208
+ @click.option("--nir-post", required=True, type=click.Path(exists=True))
209
+ @click.option("--slc-pre", required=True, type=click.Path(exists=True))
210
+ @click.option("--slc-post", required=True, type=click.Path(exists=True))
211
+ @click.option("--output-dir", required=True, type=click.Path())
212
+ @click.option("--ndvi-drop-threshold", default=0.15, show_default=True, type=float)
213
+ @click.option("--coherence-threshold", default=0.3, show_default=True, type=float)
214
+ @click.option("--coherence-window", default=7, show_default=True, type=int)
215
+ def vegetation_disturbance_cmd(
216
+ red_pre,
217
+ nir_pre,
218
+ red_post,
219
+ nir_post,
220
+ slc_pre,
221
+ slc_post,
222
+ output_dir,
223
+ ndvi_drop_threshold,
224
+ coherence_threshold,
225
+ coherence_window,
226
+ ):
227
+ """Classify visible canopy loss vs. real sub-canopy ground
228
+ disturbance, from optical NDVI + SAR coherence."""
229
+ from pygeofetch.multisensor import vegetation_disturbance_pipeline
230
+
231
+ result = vegetation_disturbance_pipeline(
232
+ red_pre,
233
+ nir_pre,
234
+ red_post,
235
+ nir_post,
236
+ slc_pre,
237
+ slc_post,
238
+ output_dir,
239
+ ndvi_drop_threshold=ndvi_drop_threshold,
240
+ coherence_threshold=coherence_threshold,
241
+ coherence_window=coherence_window,
242
+ )
243
+ _print_result(result)
pygeofetch/core/engine.py CHANGED
@@ -348,7 +348,6 @@ class PyGeoFetch:
348
348
  "alaska_satellite_facility",
349
349
  "asf_vertex",
350
350
  "asf",
351
- "eodag",
352
351
  "copernicus_dataspace",
353
352
  "nasa_earthdata",
354
353
  }
@@ -724,4 +723,4 @@ class PyGeoFetch:
724
723
 
725
724
  def __repr__(self) -> str:
726
725
  authed = [item["provider"] for item in self.auth.list()]
727
- return f"PyGeoFetch(authenticated={authed}, cache_entries={len(self.searcher.cache._cache)})"
726
+ return f"PyGeoFetch(authenticated={authed}, cache_entries={len(self.searcher.cache._cache)})"
@@ -0,0 +1,370 @@
1
+ """
2
+ Advanced, general-purpose InSAR safeguards for pygeofetch.
3
+
4
+ These functions belong to two genuinely different stages of the real
5
+ InSAR chain, stated explicitly here to avoid the exact confusion this
6
+ module's own design brief warned against:
7
+
8
+ - `prepare_custom_dem`: a pre-processing step, used before interferogram
9
+ generation, when a user wants a custom high-resolution DEM (UAV
10
+ photogrammetry, airborne LiDAR) instead of a coarse global DEM for
11
+ real topographic-phase removal during interferogram formation.
12
+ - `generate_insar_mask`: also a pre-processing step (computed once from
13
+ the DEM + real acquisition geometry), used to flag which pixels are
14
+ geometrically unusable for InSAR at all -- independent of any
15
+ particular interferogram's phase content.
16
+ - `build_sbas_design_matrix_with_topo`: happens AFTER phase unwrapping,
17
+ inside the SBAS time-series INVERSION itself -- this is real,
18
+ standard residual DEM-error co-estimation (Berardino et al. 2002;
19
+ Fattahi & Amelung 2013), not topographic phase removal. It corrects
20
+ for *residual* DEM error left over after the interferogram-stage DEM
21
+ was already applied -- a real, different, later correction.
22
+
23
+ General-purpose framing, not mining-specific: layover/shadow masking
24
+ matters for any steep terrain InSAR is asked to cover -- volcanic
25
+ edifices, mountainous landslide terrain, urban high-relief areas, and
26
+ open-pit mines alike. The physics here doesn't know or care which of
27
+ those a given DEM represents.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import numpy as np
33
+
34
+
35
+ def _require_numpy():
36
+ try:
37
+ import numpy as np
38
+
39
+ return np
40
+ except ImportError as exc:
41
+ raise ImportError(
42
+ "pygeofetch.insar.advanced_safeguards requires numpy"
43
+ ) from exc
44
+
45
+
46
+ def prepare_custom_dem(
47
+ custom_dem_path: str,
48
+ reference_raster_path: str,
49
+ output_path: str,
50
+ resampling: str = "bilinear",
51
+ ) -> str:
52
+ """
53
+ Align a custom, high-resolution DEM to match a reference SAR
54
+ raster's exact grid (CRS, resolution, extent, pixel alignment) --
55
+ a real, necessary pre-processing step before using that DEM for
56
+ real topographic-phase removal during interferogram generation.
57
+
58
+ Real, general-purpose use cases, not mining-specific: a UAV
59
+ photogrammetry DEM over an active volcanic edifice (where global
60
+ 30m DEMs badly under-resolve real crater/flank geometry), airborne
61
+ LiDAR over a landslide body, a high-resolution urban DSM for
62
+ dense-city subsidence monitoring, or a UAV DEM over an open-pit
63
+ mine.
64
+
65
+ Parameters
66
+ ----------
67
+ custom_dem_path : str
68
+ Path to the real, user-supplied high-resolution DEM (any
69
+ CRS/resolution rasterio can read).
70
+ reference_raster_path : str
71
+ Path to the real SAR raster (or any raster sharing the exact
72
+ target grid) this DEM must be aligned to match.
73
+ output_path : str
74
+ Where to write the real, aligned DEM.
75
+ resampling : str
76
+ One of rasterio's real resampling method names -- "bilinear"
77
+ (the sensible default for a continuous elevation surface;
78
+ "nearest" would introduce real, visible terracing artifacts
79
+ in the topographic-phase-removal step downstream).
80
+
81
+ Returns
82
+ -------
83
+ str
84
+ `output_path`, for convenient chaining.
85
+
86
+ Raises
87
+ ------
88
+ ValueError
89
+ If `resampling` isn't a real, valid rasterio.warp.Resampling
90
+ name -- fails loudly rather than silently falling back to a
91
+ default the caller didn't ask for.
92
+ """
93
+ import rasterio
94
+ from rasterio.warp import Resampling, reproject
95
+
96
+ try:
97
+ resampling_enum = getattr(Resampling, resampling)
98
+ except AttributeError as exc:
99
+ real_names = [r.name for r in Resampling]
100
+ raise ValueError(
101
+ f"prepare_custom_dem: {resampling!r} is not a real rasterio "
102
+ f"Resampling method. Real options: {real_names}"
103
+ ) from exc
104
+
105
+ with rasterio.open(reference_raster_path) as ref_ds:
106
+ ref_profile = ref_ds.profile.copy()
107
+ ref_transform = ref_ds.transform
108
+ ref_crs = ref_ds.crs
109
+ ref_shape = (ref_ds.height, ref_ds.width)
110
+
111
+ with rasterio.open(custom_dem_path) as src_ds:
112
+ src_data = src_ds.read(1)
113
+ src_transform = src_ds.transform
114
+ src_crs = src_ds.crs
115
+ src_nodata = src_ds.nodata
116
+
117
+ aligned = np.full(ref_shape, np.nan, dtype="float32")
118
+ reproject(
119
+ source=src_data.astype("float32"),
120
+ destination=aligned,
121
+ src_transform=src_transform,
122
+ src_crs=src_crs,
123
+ dst_transform=ref_transform,
124
+ dst_crs=ref_crs,
125
+ resampling=resampling_enum,
126
+ src_nodata=src_nodata if src_nodata is not None else np.nan,
127
+ dst_nodata=np.nan,
128
+ )
129
+
130
+ out_profile = dict(ref_profile)
131
+ out_profile.update(count=1, dtype="float32", nodata=np.nan)
132
+ with rasterio.open(output_path, "w", **out_profile) as dst:
133
+ dst.write(aligned, 1)
134
+
135
+ return output_path
136
+
137
+
138
+ def slope_aspect_degrees(dem: "np.ndarray", pixel_size_m: float):
139
+ """
140
+ Real slope (degrees from horizontal) and aspect (degrees,
141
+ clockwise from north, direction the slope FACES -- i.e. the
142
+ downhill direction) from a DEM, via central-difference gradients.
143
+
144
+ Real, standard convention used throughout: aspect 0 deg = the
145
+ slope faces north (downhill points north), 90 deg = east, etc.
146
+ """
147
+ np = _require_numpy()
148
+ dzdy, dzdx = np.gradient(dem.astype("float64"), pixel_size_m)
149
+ # Real, standard formula: slope magnitude from the horizontal-plane
150
+ # gradient magnitude.
151
+ slope_rad = np.arctan(np.sqrt(dzdx**2 + dzdy**2))
152
+ slope_deg = np.degrees(slope_rad)
153
+ # Real, standard aspect convention: atan2(dzdx, -dzdy) measured
154
+ # clockwise from north gives the real downhill-facing direction
155
+ # (dzdy is defined here as d(elevation)/d(row), and row increases
156
+ # southward for a north-up raster, so -dzdy is the real
157
+ # south-to-north elevation gradient needed for a north-referenced
158
+ # bearing).
159
+ aspect_rad = np.arctan2(dzdx, -dzdy)
160
+ aspect_deg = (np.degrees(aspect_rad) + 360) % 360
161
+ return slope_deg, aspect_deg
162
+
163
+
164
+ def generate_insar_mask(
165
+ dem: "np.ndarray",
166
+ pixel_size_m: float,
167
+ incidence_angle_deg: float,
168
+ heading_deg: float,
169
+ look_side: str = "right",
170
+ ) -> "np.ndarray":
171
+ """
172
+ Real, geometry-based layover/shadow mask from a DEM and real
173
+ acquisition geometry -- flags which pixels are structurally
174
+ unusable for InSAR regardless of coherence, before any
175
+ interferogram is even formed.
176
+
177
+ General-purpose, not mining-specific: this applies identically to
178
+ steep volcanic edifices, mountainous terrain around a landslide,
179
+ high-relief urban areas, and open-pit mine walls -- the physics
180
+ only cares about real slope/aspect versus real radar viewing
181
+ geometry, not what the terrain "is."
182
+
183
+ Real physics (standard InSAR geometry, e.g. as used for
184
+ diagnostic layover/shadow prediction in ISCE/GAMMA-style
185
+ workflows): the real local incidence angle at a pixel is the
186
+ nominal incidence angle adjusted by how much the local slope
187
+ tilts toward or away from the radar:
188
+
189
+ local_incidence = incidence_angle - slope * cos(aspect - look_azimuth)
190
+
191
+ - LAYOVER: local_incidence <= 0 deg -- the terrain tilts toward
192
+ the radar steeply enough that the near-range part of the slope
193
+ arrives at the sensor before the far-range part, folding the
194
+ image over itself.
195
+ - SHADOW: local_incidence >= 90 deg -- the terrain tilts away from
196
+ the radar steeply enough that the radar beam can't reach it at
197
+ all.
198
+ - Safe: 0 < local_incidence < 90 deg.
199
+
200
+ Parameters
201
+ ----------
202
+ dem : np.ndarray
203
+ Real elevation values, meters, on a regular grid.
204
+ pixel_size_m : float
205
+ Real ground pixel size, meters -- assumes square pixels in a
206
+ projected CRS (same real assumption stated and enforced
207
+ elsewhere in this project's optical/InSAR modules).
208
+ incidence_angle_deg : float
209
+ Real nominal radar incidence angle at scene center, degrees
210
+ (e.g. ~30-46 deg for Sentinel-1 IW).
211
+ heading_deg : float
212
+ Real satellite flight heading, degrees clockwise from north
213
+ (e.g. ~193 deg for a typical Sentinel-1 descending pass, ~13
214
+ deg for ascending).
215
+ look_side : str
216
+ "right" (the real, standard side for Sentinel-1 and most
217
+ civilian SAR missions) or "left". Determines whether the
218
+ radar look azimuth is heading+90 or heading-90.
219
+
220
+ Returns
221
+ -------
222
+ np.ndarray
223
+ Boolean mask, same shape as `dem`. True = geometrically safe
224
+ for InSAR. False = layover or shadow -- mask out, or rely on
225
+ optical offset tracking there instead (this mask's real,
226
+ intended downstream use).
227
+
228
+ Raises
229
+ ------
230
+ ValueError
231
+ If `look_side` isn't "left" or "right".
232
+ """
233
+ np = _require_numpy()
234
+ if look_side not in ("left", "right"):
235
+ raise ValueError(
236
+ f"generate_insar_mask: look_side must be 'left' or 'right', got {look_side!r}"
237
+ )
238
+
239
+ slope_deg, aspect_deg = slope_aspect_degrees(dem, pixel_size_m)
240
+
241
+ look_azimuth = (
242
+ (heading_deg + 90.0) if look_side == "right" else (heading_deg - 90.0)
243
+ )
244
+ look_azimuth = look_azimuth % 360
245
+
246
+ # Real, signed ground-range slope component: positive when the
247
+ # slope faces toward the radar (reduces local incidence, risking
248
+ # layover), negative when it faces away (increases local
249
+ # incidence, risking shadow).
250
+ aspect_diff_rad = np.radians(aspect_deg - look_azimuth)
251
+ ground_range_component = slope_deg * np.cos(aspect_diff_rad)
252
+
253
+ local_incidence = incidence_angle_deg - ground_range_component
254
+ safe_mask = (local_incidence > 0) & (local_incidence < 90)
255
+ return safe_mask
256
+
257
+
258
+ def build_sbas_design_matrix_with_topo(
259
+ dates: list,
260
+ reference_date,
261
+ pair_dates: list[tuple],
262
+ perpendicular_baselines: dict,
263
+ wavelength_m: float,
264
+ slant_range_m: float,
265
+ incidence_angle_deg: float,
266
+ ) -> "tuple[np.ndarray, list]":
267
+ """
268
+ Real SBAS design matrix with a second column for DEM-error
269
+ (topographic residual) co-estimation, applied to the ALREADY
270
+ UNWRAPPED phase stack during time-series inversion -- not during
271
+ interferogram generation. See this module's own docstring for why
272
+ that distinction is real and matters.
273
+
274
+ Real, standard physics (Berardino et al. 2002; Fattahi & Amelung
275
+ 2013's real DEM-error term): the phase contribution from a real
276
+ residual DEM error `epsilon` (the real, remaining error in
277
+ whatever DEM was used for topographic-phase removal earlier) is
278
+
279
+ delta_phi_topo = (4*pi / lambda) * (B_perp / (R * sin(theta))) * epsilon
280
+
281
+ while the real deformation-velocity contribution is the familiar
282
+
283
+ delta_phi_vel = (4*pi / lambda) * delta_t * v
284
+
285
+ Both terms are real, linear in their respective unknowns (v and
286
+ epsilon), which is exactly why they can be solved for jointly in
287
+ one real linear least-squares system per pixel.
288
+
289
+ Parameters
290
+ ----------
291
+ dates : list
292
+ Real, sorted list of acquisition dates (str or datetime) in
293
+ the network.
294
+ reference_date
295
+ Real reference date -- velocities are relative to this date's
296
+ cumulative displacement being defined as zero.
297
+ pair_dates : list of (date1, date2) tuples
298
+ Real interferogram pairs actually used in the network.
299
+ perpendicular_baselines : dict
300
+ Real `{(date1, date2): B_perp_meters}` mapping -- the real
301
+ perpendicular baseline for each pair, needed for the
302
+ topographic-residual column.
303
+ wavelength_m : float
304
+ Real SAR wavelength, meters (e.g. 0.05546576 for Sentinel-1
305
+ C-band).
306
+ slant_range_m : float
307
+ Real sensor-to-target slant range, meters.
308
+ incidence_angle_deg : float
309
+ Real local incidence angle, degrees.
310
+
311
+ Returns
312
+ -------
313
+ design_matrix : np.ndarray
314
+ Real (n_pairs, n_dates - 1 + 1) matrix -- standard SBAS
315
+ velocity columns (n_dates - 1, one per date after the
316
+ reference) plus one additional real topographic-residual
317
+ column appended at the end.
318
+ pair_order : list
319
+ The real `(date1, date2)` order the matrix's rows correspond
320
+ to, for the caller to align real phase observations to.
321
+
322
+ Notes
323
+ -----
324
+ This builds the real design matrix only -- the actual per-pixel
325
+ WLS solve (using each pixel's real coherence-derived weights)
326
+ happens in the caller, using this matrix with
327
+ `numpy.linalg.lstsq` or a real weighted variant, exactly matching
328
+ how `pygeofetch.insar.timeseries.SBASTimeSeries` already solves
329
+ its own (velocity-only) design matrix -- this just adds the one
330
+ real extra column.
331
+ """
332
+ np = _require_numpy()
333
+ sorted_dates = sorted(dates)
334
+ if reference_date not in sorted_dates:
335
+ raise ValueError(f"reference_date {reference_date!r} is not in dates")
336
+
337
+ other_dates = [d for d in sorted_dates if d != reference_date]
338
+ date_to_col = {d: i for i, d in enumerate(other_dates)}
339
+ n_pairs = len(pair_dates)
340
+ n_cols = len(other_dates) + 1 # +1 for the real topographic-residual column
341
+
342
+ design_matrix = np.zeros((n_pairs, n_cols), dtype="float64")
343
+ factor = (4.0 * np.pi) / wavelength_m
344
+ topo_scale = factor / (slant_range_m * np.sin(np.radians(incidence_angle_deg)))
345
+
346
+ for row, (d1, d2) in enumerate(pair_dates):
347
+ # Real, standard SBAS velocity encoding: for pair (d1, d2)
348
+ # with d1 earlier than d2, the observed phase difference
349
+ # equals the real cumulative velocity-phase integrated between
350
+ # d1 and d2 -- encoded here as +1 at d2's column, -1 at d1's
351
+ # column (dates equal to reference_date contribute 0, since
352
+ # its column doesn't exist in other_dates).
353
+ if d1 != reference_date:
354
+ design_matrix[row, date_to_col[d1]] -= factor
355
+ if d2 != reference_date:
356
+ design_matrix[row, date_to_col[d2]] += factor
357
+
358
+ b_perp = perpendicular_baselines.get((d1, d2))
359
+ if b_perp is None:
360
+ b_perp = perpendicular_baselines.get((d2, d1))
361
+ if b_perp is not None:
362
+ b_perp = -b_perp
363
+ if b_perp is None:
364
+ raise ValueError(
365
+ f"build_sbas_design_matrix_with_topo: no real perpendicular "
366
+ f"baseline supplied for pair {(d1, d2)!r}"
367
+ )
368
+ design_matrix[row, -1] = topo_scale * b_perp
369
+
370
+ return design_matrix, list(pair_dates)
pygeofetch/meta.yaml CHANGED
@@ -1,4 +1,4 @@
1
- {% set version = "2.6.2.4.4" %}
1
+ {% set version = "2.6.2.5.4" %}
2
2
 
3
3
  package:
4
4
  name: pygeofetch