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.
- pygeofetch/__init__.py +1 -1
- pygeofetch/cli/main.py +3 -1
- pygeofetch/cli/multisensor_commands.py +243 -0
- pygeofetch/core/engine.py +1 -2
- pygeofetch/insar/advanced_safeguards.py +370 -0
- pygeofetch/meta.yaml +1 -1
- pygeofetch/multisensor/dem_differencing.py +201 -0
- pygeofetch/multisensor/flood_mapping.py +215 -0
- pygeofetch/multisensor/insar_optical_fusion.py +269 -0
- pygeofetch/multisensor/multisensor_init.py +55 -0
- pygeofetch/multisensor/terrain_corrected_change.py +285 -0
- pygeofetch/multisensor/vegetation_disturbance.py +220 -0
- pygeofetch/optical/__init__.py +9 -1
- pygeofetch/optical/offset_tracking.py +307 -4
- pygeofetch/providers/__init__.py +2 -4
- pygeofetch/providers/nasa_earthdata.py +26 -6
- pygeofetch/providers/nasa_earthdata_cloud.py +114 -38
- pygeofetch/providers/opentopography.py +203 -7
- pygeofetch/providers/planet.py +23 -2
- pygeofetch/providers/sentinel_hub.py +50 -25
- pygeofetch/sar/__init__.py +28 -1
- pygeofetch/sar/math.py +100 -0
- pygeofetch/sar/pipelines.py +573 -0
- pygeofetch/sar/processor.py +371 -97
- pygeofetch/utils/sar_math.py +100 -0
- {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/METADATA +4 -9
- {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/RECORD +31 -20
- {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/WHEEL +0 -0
- {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/entry_points.txt +0 -0
- {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/licenses/LICENSE +0 -0
- {pygeofetch-2.6.2.4.dist-info → pygeofetch-2.6.2.5.dist-info}/top_level.txt +0 -0
pygeofetch/__init__.py
CHANGED
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