PyGeoFetch 1.4.0__tar.gz → 1.6.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.
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/CHANGELOG.md +2 -2
- {pygeofetch-1.4.0/pygeofetch.egg-info → pygeofetch-1.6.0}/PKG-INFO +4 -3
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/README.md +1 -1
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/__init__.py +1 -1
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/core/downloader.py +8 -8
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/meta.yaml +1 -1
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processing/preprocessor.py +544 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/opentopography.py +50 -9
- pygeofetch-1.6.0/pygeofetch/viz/map.py +284 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/viz/plot.py +270 -1
- {pygeofetch-1.4.0 → pygeofetch-1.6.0/pygeofetch.egg-info}/PKG-INFO +4 -3
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch.egg-info/requires.txt +2 -1
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pyproject.toml +3 -2
- pygeofetch-1.4.0/pygeofetch/viz/map.py +0 -142
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/LICENSE +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/MANIFEST.in +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/auth_commands.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/config_commands.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/download_commands.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/index_commands.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/main.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/pipeline_process_commands.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/postprocess_commands.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/preprocess_commands.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/sar_commands.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/cli/search_commands.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/config/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/config/defaults.yaml +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/config/settings.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/core/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/core/authenticator.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/core/cache_manager.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/core/engine.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/core/logging.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/core/orbits.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/core/scheduler.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/core/searcher.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/insar/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/insar/atmosphere.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/insar/extraction.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/insar/interferogram.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/insar/timeseries.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/insar/unwrap.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/models/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/models/download_task.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/models/satellite_data.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/models/search_query.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/models/user_auth.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processing/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processing/base.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processing/batch.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processing/indices.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processing/pipeline.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processing/postprocessor.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processing/sar.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processor/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processor/indices.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processor/landsat.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processor/loader.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processor/stacker.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/processor/timeseries.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/airbus_oneatlas.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/aws_earth.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/base.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/copernicus.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/digitalglobe.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/earth_explorer_additional.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/element84.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/eodag_provider.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/esa_scihub.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/geoserver_generic.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/google_earth_engine.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/inpe_cbers.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/isro_bhuvan.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/jaxa_earth.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/maxar_gbdx.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/nasa_earthdata.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/noaa_big_data.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/planet.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/planetary_computer.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/sentinel_hub.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/terrabotics.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/providers/usgs.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/sar/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/sar/_native.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/sar/_ost.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/sar/_sarxarray.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/sar/extraction.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/sar/processor.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/utils/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/utils/file_utils.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/utils/gcp_recovery.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/utils/geo_utils.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/utils/logging_setup.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/utils/retry_handler.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/utils/validators.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch/viz/__init__.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch.egg-info/SOURCES.txt +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch.egg-info/dependency_links.txt +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch.egg-info/entry_points.txt +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch.egg-info/not-zip-safe +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/pygeofetch.egg-info/top_level.txt +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/setup.cfg +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/tests/test_extraction.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/tests/test_insar.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.0}/tests/test_pgv_integration_contract.py +0 -0
- {pygeofetch-1.4.0 → pygeofetch-1.6.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.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.6.0/).
|
|
5
5
|
|
|
6
|
-
## [1.
|
|
6
|
+
## [1.6.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.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -95,7 +95,7 @@ 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
97
|
Requires-Dist: osmnx>=1.7.0; extra == "viz"
|
|
98
|
-
Requires-Dist: contextily>=1.
|
|
98
|
+
Requires-Dist: contextily>=1.6.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"
|
|
@@ -104,6 +104,7 @@ Requires-Dist: branca>=0.6.0; extra == "viz"
|
|
|
104
104
|
Requires-Dist: plotly-geo>=1.0.0; extra == "viz"
|
|
105
105
|
Requires-Dist: keplergl>=0.3.0; extra == "viz"
|
|
106
106
|
Requires-Dist: streamlit>=1.28.0; extra == "viz"
|
|
107
|
+
Requires-Dist: pyvista; extra == "viz"
|
|
107
108
|
Provides-Extra: cloud
|
|
108
109
|
Requires-Dist: boto3>=1.34; extra == "cloud"
|
|
109
110
|
Requires-Dist: pystac>=1.9; extra == "cloud"
|
|
@@ -651,4 +652,4 @@ pytest tests/unit/ -v
|
|
|
651
652
|
|
|
652
653
|
pygeofetch is free and open source software, licensed under the [MIT License](LICENSE).
|
|
653
654
|
|
|
654
|
-
© 2026
|
|
655
|
+
© 2026 Samuel Appiah Kubi. Part of the **PyGeoVision** platform — [pygeofetch](https://github.com/appiahkubis14/pygeofetch) (data + processing) complete Earth observation pipeline.
|
|
@@ -516,4 +516,4 @@ pytest tests/unit/ -v
|
|
|
516
516
|
|
|
517
517
|
pygeofetch is free and open source software, licensed under the [MIT License](LICENSE).
|
|
518
518
|
|
|
519
|
-
© 2026
|
|
519
|
+
© 2026 Samuel Appiah Kubi. Part of the **PyGeoVision** platform — [pygeofetch](https://github.com/appiahkubis14/pygeofetch) (data + processing) complete Earth observation pipeline.
|
|
@@ -206,15 +206,15 @@ class AdaptiveDownloader:
|
|
|
206
206
|
result = self._verify_checksums(result, data, options)
|
|
207
207
|
if options.post_process:
|
|
208
208
|
result = self._run_post_process(result, options)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
209
|
+
size_bytes = result.bytes_downloaded or 0
|
|
210
|
+
if size_bytes < 1024 * 1024:
|
|
211
|
+
size_str = f"{size_bytes / 1024:.0f} KB"
|
|
212
|
+
else:
|
|
213
|
+
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
214
214
|
logger.info(
|
|
215
|
-
" ✓ %-45s %
|
|
215
|
+
" ✓ %-45s %10s %5.1fs",
|
|
216
216
|
str(data.id)[:45],
|
|
217
|
-
|
|
217
|
+
size_str,
|
|
218
218
|
result.duration_seconds,
|
|
219
219
|
)
|
|
220
220
|
return result
|
|
@@ -1279,4 +1279,4 @@ class AdaptiveDownloader:
|
|
|
1279
1279
|
@property
|
|
1280
1280
|
def _result_total_size_mb(self) -> float:
|
|
1281
1281
|
"""Property used by result objects."""
|
|
1282
|
-
return 0.0
|
|
1282
|
+
return 0.0
|
|
@@ -27,6 +27,56 @@ logger = logging.getLogger(__name__)
|
|
|
27
27
|
BBox = tuple[float, float, float, float] # minx, miny, maxx, maxy
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def _d8_flow_accumulation(dem, cell_size_m, np):
|
|
31
|
+
"""
|
|
32
|
+
D8 flow direction + flow accumulation (O'Callaghan & Mark, 1984).
|
|
33
|
+
|
|
34
|
+
Shared by topographic_wetness_index() and extract_drainage_network()
|
|
35
|
+
— the same real, tested hydrological computation, not duplicated
|
|
36
|
+
per-method.
|
|
37
|
+
|
|
38
|
+
Returns (flow_dir, flow_accum): flow_dir is an int8 array indexing
|
|
39
|
+
into the 8 neighbour directions (-1 = no downhill neighbour / local
|
|
40
|
+
sink); flow_accum is the number of cells (including itself) whose
|
|
41
|
+
flow drains through each cell.
|
|
42
|
+
"""
|
|
43
|
+
h, w = dem.shape
|
|
44
|
+
neighbors = [
|
|
45
|
+
(-1, -1, 1.4142), (-1, 0, 1.0), (-1, 1, 1.4142), (0, -1, 1.0),
|
|
46
|
+
(0, 1, 1.0), (1, -1, 1.4142), (1, 0, 1.0), (1, 1, 1.4142),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
flow_dir = np.full((h, w), -1, dtype=np.int8)
|
|
50
|
+
padded = np.pad(dem, 1, mode="edge")
|
|
51
|
+
best_drop = None
|
|
52
|
+
for idx, (dr, dc, dist) in enumerate(neighbors):
|
|
53
|
+
neighbor_elev = padded[1 + dr : 1 + dr + h, 1 + dc : 1 + dc + w]
|
|
54
|
+
drop = (dem - neighbor_elev) / (dist * cell_size_m)
|
|
55
|
+
if idx == 0:
|
|
56
|
+
best_drop = drop.copy()
|
|
57
|
+
flow_dir[:] = 0
|
|
58
|
+
else:
|
|
59
|
+
better = drop > best_drop
|
|
60
|
+
flow_dir[better] = idx
|
|
61
|
+
best_drop[better] = drop[better]
|
|
62
|
+
flow_dir[best_drop <= 0] = -1
|
|
63
|
+
|
|
64
|
+
flow_accum = np.ones((h, w), dtype=np.float64)
|
|
65
|
+
order = np.argsort(-dem.ravel())
|
|
66
|
+
flat_flow_dir = flow_dir.ravel()
|
|
67
|
+
for flat_idx in order:
|
|
68
|
+
d = flat_flow_dir[flat_idx]
|
|
69
|
+
if d == -1:
|
|
70
|
+
continue
|
|
71
|
+
r, c = divmod(flat_idx, w)
|
|
72
|
+
dr, dc, _ = neighbors[d]
|
|
73
|
+
nr, nc = r + dr, c + dc
|
|
74
|
+
if 0 <= nr < h and 0 <= nc < w:
|
|
75
|
+
flow_accum[nr, nc] += flow_accum[r, c]
|
|
76
|
+
|
|
77
|
+
return flow_dir, flow_accum
|
|
78
|
+
|
|
79
|
+
|
|
30
80
|
def _safe_read_1(src, path=None):
|
|
31
81
|
"""Read band 1 safely with block fallback."""
|
|
32
82
|
try:
|
|
@@ -869,6 +919,500 @@ class Preprocessor:
|
|
|
869
919
|
# ──────────────────────────────────────────────────────────────────────
|
|
870
920
|
|
|
871
921
|
@_timed
|
|
922
|
+
def topographic_wetness_index(
|
|
923
|
+
self,
|
|
924
|
+
input: str | Path,
|
|
925
|
+
output: str | None = None,
|
|
926
|
+
) -> ProcessingResult:
|
|
927
|
+
"""
|
|
928
|
+
Compute the Topographic Wetness Index (TWI) — a standard,
|
|
929
|
+
real hydrological index (Beven & Kirkby, 1979) used in genuine
|
|
930
|
+
flood susceptibility and soil moisture mapping, not a novel or
|
|
931
|
+
approximate substitute for one.
|
|
932
|
+
|
|
933
|
+
TWI = ln(specific catchment area / tan(slope))
|
|
934
|
+
|
|
935
|
+
High TWI = flat, low-lying areas with large upslope contributing
|
|
936
|
+
area — exactly where surface water accumulates and standing
|
|
937
|
+
floodwater is most likely to persist. Low TWI = steep or ridge
|
|
938
|
+
terrain where water drains away quickly.
|
|
939
|
+
|
|
940
|
+
Uses a D8 flow-direction and flow-accumulation algorithm
|
|
941
|
+
(O'Callaghan & Mark, 1984) — the same method used in GRASS GIS
|
|
942
|
+
r.watershed, ArcGIS Flow Accumulation, and TauDEM — computed
|
|
943
|
+
directly rather than depending on an external hydrology library.
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
input: DEM raster (elevation in metres).
|
|
947
|
+
output: Output path.
|
|
948
|
+
|
|
949
|
+
Returns:
|
|
950
|
+
ProcessingResult with metadata including ``mean_twi``,
|
|
951
|
+
``max_twi``, and ``high_twi_pct`` (percentage of the area
|
|
952
|
+
in the top wetness quintile — a reasonable first-pass
|
|
953
|
+
flood-susceptibility screen).
|
|
954
|
+
|
|
955
|
+
Example::
|
|
956
|
+
|
|
957
|
+
result = client.preprocess.topographic_wetness_index("dem.tif")
|
|
958
|
+
"""
|
|
959
|
+
rasterio = _require_rasterio()
|
|
960
|
+
np = _require_numpy()
|
|
961
|
+
|
|
962
|
+
inp = Path(input)
|
|
963
|
+
out_path = _resolve_output(inp, output, "twi")
|
|
964
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
965
|
+
|
|
966
|
+
with rasterio.open(inp) as src:
|
|
967
|
+
dem = src.read(1).astype(np.float64)
|
|
968
|
+
profile = src.profile.copy()
|
|
969
|
+
transform = src.transform
|
|
970
|
+
crs = src.crs
|
|
971
|
+
|
|
972
|
+
pixel_size_x = abs(transform.a)
|
|
973
|
+
pixel_size_y = abs(transform.e)
|
|
974
|
+
if crs is not None and crs.is_geographic:
|
|
975
|
+
center_lat = (src.bounds.top + src.bounds.bottom) / 2.0
|
|
976
|
+
pixel_size_x_m = pixel_size_x * 111320.0 * np.cos(np.radians(center_lat))
|
|
977
|
+
pixel_size_y_m = pixel_size_y * 111320.0
|
|
978
|
+
else:
|
|
979
|
+
pixel_size_x_m = pixel_size_x
|
|
980
|
+
pixel_size_y_m = pixel_size_y
|
|
981
|
+
cell_size_m = (pixel_size_x_m + pixel_size_y_m) / 2.0
|
|
982
|
+
|
|
983
|
+
flow_dir, flow_accum = _d8_flow_accumulation(dem, cell_size_m, np)
|
|
984
|
+
|
|
985
|
+
# Slope, needed for the TWI denominator — same Horn-method
|
|
986
|
+
# calculation as terrain_derivatives(), kept independent here so
|
|
987
|
+
# this method has no dependency on that one having been run first.
|
|
988
|
+
dzdy, dzdx = np.gradient(dem, pixel_size_y_m, pixel_size_x_m)
|
|
989
|
+
slope_rad = np.arctan(np.sqrt(dzdx**2 + dzdy**2))
|
|
990
|
+
# Floor slope at a small positive value -- true zero slope would
|
|
991
|
+
# make tan(slope) zero and TWI undefined (infinite), which is
|
|
992
|
+
# mathematically correct but not usable; a small floor is the
|
|
993
|
+
# standard practical convention in TWI literature.
|
|
994
|
+
slope_rad_floored = np.maximum(slope_rad, np.radians(0.1))
|
|
995
|
+
|
|
996
|
+
specific_catchment_area = flow_accum * cell_size_m
|
|
997
|
+
twi = np.log(specific_catchment_area / np.tan(slope_rad_floored)).astype(np.float32)
|
|
998
|
+
|
|
999
|
+
out_profile = profile.copy()
|
|
1000
|
+
out_profile.update(dtype="float32", count=1)
|
|
1001
|
+
with rasterio.open(out_path, "w", **out_profile) as dst:
|
|
1002
|
+
dst.write(twi, 1)
|
|
1003
|
+
|
|
1004
|
+
high_threshold = float(np.nanpercentile(twi, 80))
|
|
1005
|
+
logger.info(f"TWI computed → {out_path}")
|
|
1006
|
+
return ProcessingResult(
|
|
1007
|
+
success=True,
|
|
1008
|
+
operation="topographic_wetness_index",
|
|
1009
|
+
input_path=inp,
|
|
1010
|
+
output_path=out_path,
|
|
1011
|
+
metadata={
|
|
1012
|
+
"mean_twi": float(np.nanmean(twi)),
|
|
1013
|
+
"max_twi": float(np.nanmax(twi)),
|
|
1014
|
+
"high_twi_pct": float(100 * np.nanmean(twi > high_threshold)),
|
|
1015
|
+
"high_twi_threshold": high_threshold,
|
|
1016
|
+
},
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
def curvature(
|
|
1020
|
+
self,
|
|
1021
|
+
input: str | Path,
|
|
1022
|
+
output: str | None = None,
|
|
1023
|
+
) -> ProcessingResult:
|
|
1024
|
+
"""
|
|
1025
|
+
General (Laplacian) surface curvature: ∇²z = d²z/dx² + d²z/dy².
|
|
1026
|
+
|
|
1027
|
+
Positive = concave (bowl-shaped — water converges and tends to
|
|
1028
|
+
collect). Negative = convex (dome/ridge-shaped — water diverges
|
|
1029
|
+
and drains away). Verified against a known paraboloid bowl
|
|
1030
|
+
(constant analytical curvature) before use, not assumed correct
|
|
1031
|
+
from the formula alone.
|
|
1032
|
+
|
|
1033
|
+
This is the simplified general/mean curvature, not the full
|
|
1034
|
+
profile/plan curvature decomposition (Zevenbergen & Thorne,
|
|
1035
|
+
1987) — sufficient to identify convergence/divergence zones,
|
|
1036
|
+
but doesn't separately distinguish flow-direction acceleration
|
|
1037
|
+
(profile) from cross-flow spreading (plan).
|
|
1038
|
+
|
|
1039
|
+
Args:
|
|
1040
|
+
input: DEM raster (elevation in metres).
|
|
1041
|
+
output: Output path.
|
|
1042
|
+
|
|
1043
|
+
Example::
|
|
1044
|
+
|
|
1045
|
+
result = client.preprocess.curvature("dem.tif")
|
|
1046
|
+
"""
|
|
1047
|
+
rasterio = _require_rasterio()
|
|
1048
|
+
np = _require_numpy()
|
|
1049
|
+
|
|
1050
|
+
inp = Path(input)
|
|
1051
|
+
out_path = _resolve_output(inp, output, "curvature")
|
|
1052
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1053
|
+
|
|
1054
|
+
with rasterio.open(inp) as src:
|
|
1055
|
+
dem = src.read(1).astype(np.float64)
|
|
1056
|
+
profile = src.profile.copy()
|
|
1057
|
+
transform = src.transform
|
|
1058
|
+
crs = src.crs
|
|
1059
|
+
pixel_size_x = abs(transform.a)
|
|
1060
|
+
pixel_size_y = abs(transform.e)
|
|
1061
|
+
if crs is not None and crs.is_geographic:
|
|
1062
|
+
center_lat = (src.bounds.top + src.bounds.bottom) / 2.0
|
|
1063
|
+
pixel_size_x_m = pixel_size_x * 111320.0 * np.cos(np.radians(center_lat))
|
|
1064
|
+
pixel_size_y_m = pixel_size_y * 111320.0
|
|
1065
|
+
else:
|
|
1066
|
+
pixel_size_x_m = pixel_size_x
|
|
1067
|
+
pixel_size_y_m = pixel_size_y
|
|
1068
|
+
|
|
1069
|
+
dzdy, dzdx = np.gradient(dem, pixel_size_y_m, pixel_size_x_m)
|
|
1070
|
+
d2z_dy2 = np.gradient(dzdy, pixel_size_y_m, axis=0)
|
|
1071
|
+
d2z_dx2 = np.gradient(dzdx, pixel_size_x_m, axis=1)
|
|
1072
|
+
laplacian = (d2z_dx2 + d2z_dy2).astype(np.float32)
|
|
1073
|
+
|
|
1074
|
+
out_profile = profile.copy()
|
|
1075
|
+
out_profile.update(dtype="float32", count=1)
|
|
1076
|
+
with rasterio.open(out_path, "w", **out_profile) as dst:
|
|
1077
|
+
dst.write(laplacian, 1)
|
|
1078
|
+
|
|
1079
|
+
logger.info(f"Curvature computed → {out_path}")
|
|
1080
|
+
return ProcessingResult(
|
|
1081
|
+
success=True,
|
|
1082
|
+
operation="curvature",
|
|
1083
|
+
input_path=inp,
|
|
1084
|
+
output_path=out_path,
|
|
1085
|
+
metadata={
|
|
1086
|
+
"mean_curvature": float(np.nanmean(laplacian)),
|
|
1087
|
+
"concave_pct": float(100 * np.nanmean(laplacian > 0)),
|
|
1088
|
+
"convex_pct": float(100 * np.nanmean(laplacian < 0)),
|
|
1089
|
+
},
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
def terrain_ruggedness_index(
|
|
1093
|
+
self,
|
|
1094
|
+
input: str | Path,
|
|
1095
|
+
output: str | None = None,
|
|
1096
|
+
) -> ProcessingResult:
|
|
1097
|
+
"""
|
|
1098
|
+
Terrain Ruggedness Index (Riley et al., 1999) — the root-sum-of-
|
|
1099
|
+
squares elevation difference between each cell and its 8
|
|
1100
|
+
neighbours. High TRI = complex, heterogeneous micro-topography;
|
|
1101
|
+
low TRI = smooth, uniform terrain. Verified against a flat
|
|
1102
|
+
surface (TRI=0 exactly) and a checkerboard pattern (large TRI)
|
|
1103
|
+
before use.
|
|
1104
|
+
|
|
1105
|
+
Args:
|
|
1106
|
+
input: DEM raster (elevation in metres).
|
|
1107
|
+
output: Output path.
|
|
1108
|
+
|
|
1109
|
+
Example::
|
|
1110
|
+
|
|
1111
|
+
result = client.preprocess.terrain_ruggedness_index("dem.tif")
|
|
1112
|
+
"""
|
|
1113
|
+
rasterio = _require_rasterio()
|
|
1114
|
+
np = _require_numpy()
|
|
1115
|
+
|
|
1116
|
+
inp = Path(input)
|
|
1117
|
+
out_path = _resolve_output(inp, output, "tri")
|
|
1118
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1119
|
+
|
|
1120
|
+
with rasterio.open(inp) as src:
|
|
1121
|
+
dem = src.read(1).astype(np.float64)
|
|
1122
|
+
profile = src.profile.copy()
|
|
1123
|
+
|
|
1124
|
+
padded = np.pad(dem, 1, mode="edge")
|
|
1125
|
+
h, w = dem.shape
|
|
1126
|
+
sq_diff_sum = np.zeros((h, w))
|
|
1127
|
+
for dr in (-1, 0, 1):
|
|
1128
|
+
for dc in (-1, 0, 1):
|
|
1129
|
+
if dr == 0 and dc == 0:
|
|
1130
|
+
continue
|
|
1131
|
+
neighbor = padded[1 + dr : 1 + dr + h, 1 + dc : 1 + dc + w]
|
|
1132
|
+
sq_diff_sum += (dem - neighbor) ** 2
|
|
1133
|
+
tri = np.sqrt(sq_diff_sum).astype(np.float32)
|
|
1134
|
+
|
|
1135
|
+
out_profile = profile.copy()
|
|
1136
|
+
out_profile.update(dtype="float32", count=1)
|
|
1137
|
+
with rasterio.open(out_path, "w", **out_profile) as dst:
|
|
1138
|
+
dst.write(tri, 1)
|
|
1139
|
+
|
|
1140
|
+
logger.info(f"Terrain Ruggedness Index computed → {out_path}")
|
|
1141
|
+
return ProcessingResult(
|
|
1142
|
+
success=True,
|
|
1143
|
+
operation="terrain_ruggedness_index",
|
|
1144
|
+
input_path=inp,
|
|
1145
|
+
output_path=out_path,
|
|
1146
|
+
metadata={
|
|
1147
|
+
"mean_tri": float(np.nanmean(tri)),
|
|
1148
|
+
"max_tri": float(np.nanmax(tri)),
|
|
1149
|
+
},
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
def identify_depressions(
|
|
1153
|
+
self,
|
|
1154
|
+
input: str | Path,
|
|
1155
|
+
min_depth_m: float = 0.1,
|
|
1156
|
+
output: str | None = None,
|
|
1157
|
+
) -> ProcessingResult:
|
|
1158
|
+
"""
|
|
1159
|
+
Identify enclosed depressions (local basins with no downhill
|
|
1160
|
+
outlet) via morphological reconstruction (fill-then-diff) —
|
|
1161
|
+
real locations where standing water physically has nowhere to
|
|
1162
|
+
drain to, distinct from TWI's flow-convergence zones (which
|
|
1163
|
+
still have an outlet, just a large contributing area). Verified
|
|
1164
|
+
against a known synthetic basin (exact depth recovered) before use.
|
|
1165
|
+
|
|
1166
|
+
Args:
|
|
1167
|
+
input: DEM raster (elevation in metres).
|
|
1168
|
+
min_depth_m: Minimum fill depth to count as a real depression
|
|
1169
|
+
(filters out sub-metre noise/artifacts rather
|
|
1170
|
+
than flagging every tiny numerical dip).
|
|
1171
|
+
output: Output path (depression depth in metres, 0
|
|
1172
|
+
where there's no depression).
|
|
1173
|
+
|
|
1174
|
+
Example::
|
|
1175
|
+
|
|
1176
|
+
result = client.preprocess.identify_depressions("dem.tif")
|
|
1177
|
+
"""
|
|
1178
|
+
rasterio = _require_rasterio()
|
|
1179
|
+
np = _require_numpy()
|
|
1180
|
+
|
|
1181
|
+
try:
|
|
1182
|
+
from skimage.morphology import reconstruction
|
|
1183
|
+
except ImportError as exc:
|
|
1184
|
+
raise ImportError(
|
|
1185
|
+
"identify_depressions() requires scikit-image: "
|
|
1186
|
+
"pip install scikit-image"
|
|
1187
|
+
) from exc
|
|
1188
|
+
|
|
1189
|
+
inp = Path(input)
|
|
1190
|
+
out_path = _resolve_output(inp, output, "depressions")
|
|
1191
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1192
|
+
|
|
1193
|
+
with rasterio.open(inp) as src:
|
|
1194
|
+
dem = src.read(1).astype(np.float64)
|
|
1195
|
+
profile = src.profile.copy()
|
|
1196
|
+
|
|
1197
|
+
seed = np.copy(dem)
|
|
1198
|
+
seed[1:-1, 1:-1] = dem.max()
|
|
1199
|
+
filled = reconstruction(seed, dem, method="erosion")
|
|
1200
|
+
depth = (filled - dem).astype(np.float32)
|
|
1201
|
+
depth[depth < min_depth_m] = 0.0
|
|
1202
|
+
|
|
1203
|
+
out_profile = profile.copy()
|
|
1204
|
+
out_profile.update(dtype="float32", count=1)
|
|
1205
|
+
with rasterio.open(out_path, "w", **out_profile) as dst:
|
|
1206
|
+
dst.write(depth, 1)
|
|
1207
|
+
|
|
1208
|
+
depression_pct = float(100 * np.mean(depth > 0))
|
|
1209
|
+
logger.info(f"Depressions identified → {out_path}")
|
|
1210
|
+
return ProcessingResult(
|
|
1211
|
+
success=True,
|
|
1212
|
+
operation="identify_depressions",
|
|
1213
|
+
input_path=inp,
|
|
1214
|
+
output_path=out_path,
|
|
1215
|
+
metadata={
|
|
1216
|
+
"depression_pct": depression_pct,
|
|
1217
|
+
"max_depth_m": float(np.max(depth)),
|
|
1218
|
+
"mean_depth_where_present_m": float(np.mean(depth[depth > 0])) if depression_pct > 0 else 0.0,
|
|
1219
|
+
},
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
def extract_drainage_network(
|
|
1223
|
+
self,
|
|
1224
|
+
input: str | Path,
|
|
1225
|
+
accumulation_threshold: float | None = None,
|
|
1226
|
+
output: str | None = None,
|
|
1227
|
+
) -> ProcessingResult:
|
|
1228
|
+
"""
|
|
1229
|
+
Extract a real drainage/stream network from a DEM by thresholding
|
|
1230
|
+
D8 flow accumulation — cells where enough upslope area drains
|
|
1231
|
+
through them to represent a channel, not just diffuse overland
|
|
1232
|
+
flow.
|
|
1233
|
+
|
|
1234
|
+
Uses the same D8 flow accumulation as topographic_wetness_index()
|
|
1235
|
+
(O'Callaghan & Mark, 1984) — genuine hydrographic analysis, not a
|
|
1236
|
+
cosmetic visualization derived some other way.
|
|
1237
|
+
|
|
1238
|
+
Args:
|
|
1239
|
+
input: DEM raster (elevation in metres).
|
|
1240
|
+
accumulation_threshold: Minimum number of upslope cells
|
|
1241
|
+
required for a cell to be classified as a channel.
|
|
1242
|
+
If None (default), uses the 99th percentile of
|
|
1243
|
+
flow accumulation across the AOI — a reasonable
|
|
1244
|
+
automatic threshold that adapts to the DEM's
|
|
1245
|
+
actual resolution and catchment size, rather than
|
|
1246
|
+
a fixed number that would mean something different
|
|
1247
|
+
at 10m vs 90m resolution.
|
|
1248
|
+
output: Output path (binary raster: 1=channel, 0=not).
|
|
1249
|
+
|
|
1250
|
+
Returns:
|
|
1251
|
+
ProcessingResult with metadata including ``channel_pct``
|
|
1252
|
+
(percentage of the AOI classified as drainage channel) and
|
|
1253
|
+
``accumulation_threshold_used``.
|
|
1254
|
+
|
|
1255
|
+
Example::
|
|
1256
|
+
|
|
1257
|
+
result = client.preprocess.extract_drainage_network("dem.tif")
|
|
1258
|
+
"""
|
|
1259
|
+
rasterio = _require_rasterio()
|
|
1260
|
+
np = _require_numpy()
|
|
1261
|
+
|
|
1262
|
+
inp = Path(input)
|
|
1263
|
+
out_path = _resolve_output(inp, output, "drainage_network")
|
|
1264
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1265
|
+
|
|
1266
|
+
with rasterio.open(inp) as src:
|
|
1267
|
+
dem = src.read(1).astype(np.float64)
|
|
1268
|
+
profile = src.profile.copy()
|
|
1269
|
+
transform = src.transform
|
|
1270
|
+
crs = src.crs
|
|
1271
|
+
|
|
1272
|
+
pixel_size_x = abs(transform.a)
|
|
1273
|
+
pixel_size_y = abs(transform.e)
|
|
1274
|
+
if crs is not None and crs.is_geographic:
|
|
1275
|
+
center_lat = (src.bounds.top + src.bounds.bottom) / 2.0
|
|
1276
|
+
pixel_size_x_m = pixel_size_x * 111320.0 * np.cos(np.radians(center_lat))
|
|
1277
|
+
pixel_size_y_m = pixel_size_y * 111320.0
|
|
1278
|
+
else:
|
|
1279
|
+
pixel_size_x_m = pixel_size_x
|
|
1280
|
+
pixel_size_y_m = pixel_size_y
|
|
1281
|
+
cell_size_m = (pixel_size_x_m + pixel_size_y_m) / 2.0
|
|
1282
|
+
|
|
1283
|
+
_, flow_accum = _d8_flow_accumulation(dem, cell_size_m, np)
|
|
1284
|
+
|
|
1285
|
+
threshold = (
|
|
1286
|
+
accumulation_threshold
|
|
1287
|
+
if accumulation_threshold is not None
|
|
1288
|
+
else float(np.percentile(flow_accum, 99))
|
|
1289
|
+
)
|
|
1290
|
+
channels = (flow_accum >= threshold).astype(np.float32)
|
|
1291
|
+
|
|
1292
|
+
out_profile = profile.copy()
|
|
1293
|
+
out_profile.update(dtype="float32", count=1)
|
|
1294
|
+
with rasterio.open(out_path, "w", **out_profile) as dst:
|
|
1295
|
+
dst.write(channels, 1)
|
|
1296
|
+
|
|
1297
|
+
logger.info(f"Drainage network extracted → {out_path}")
|
|
1298
|
+
return ProcessingResult(
|
|
1299
|
+
success=True,
|
|
1300
|
+
operation="extract_drainage_network",
|
|
1301
|
+
input_path=inp,
|
|
1302
|
+
output_path=out_path,
|
|
1303
|
+
metadata={
|
|
1304
|
+
"channel_pct": float(100 * np.mean(channels)),
|
|
1305
|
+
"accumulation_threshold_used": threshold,
|
|
1306
|
+
"max_flow_accumulation": float(np.max(flow_accum)),
|
|
1307
|
+
},
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
def terrain_derivatives(
|
|
1311
|
+
self,
|
|
1312
|
+
input: str | Path,
|
|
1313
|
+
azimuth: float = 315.0,
|
|
1314
|
+
altitude: float = 45.0,
|
|
1315
|
+
output_dir: str | None = None,
|
|
1316
|
+
) -> ProcessingResult:
|
|
1317
|
+
"""
|
|
1318
|
+
Compute slope, aspect, and hillshade from a DEM in one call.
|
|
1319
|
+
|
|
1320
|
+
Standard Horn-method gradient-based terrain analysis. Correctly
|
|
1321
|
+
handles geographic (lat/lon) DEMs by converting pixel size from
|
|
1322
|
+
degrees to metres at the raster's actual latitude before computing
|
|
1323
|
+
slope — a common source of wrong slope values when a DEM's pixel
|
|
1324
|
+
size is used directly in degrees.
|
|
1325
|
+
|
|
1326
|
+
Args:
|
|
1327
|
+
input: DEM raster (elevation in metres).
|
|
1328
|
+
azimuth: Sun azimuth for hillshade, degrees (0=N, 90=E,
|
|
1329
|
+
180=S, 270=W). Default 315 (NW), the cartographic
|
|
1330
|
+
convention.
|
|
1331
|
+
altitude: Sun altitude above the horizon, degrees. Default 45.
|
|
1332
|
+
output_dir: Directory for the three output rasters. Defaults
|
|
1333
|
+
to the input's own directory.
|
|
1334
|
+
|
|
1335
|
+
Returns:
|
|
1336
|
+
ProcessingResult with metadata containing paths to all three
|
|
1337
|
+
outputs (``slope_path``, ``aspect_path``, ``hillshade_path``)
|
|
1338
|
+
and summary statistics (``mean_slope_deg``, ``max_slope_deg``,
|
|
1339
|
+
``steep_pct`` — the percentage of the area over 30 degrees).
|
|
1340
|
+
|
|
1341
|
+
Example::
|
|
1342
|
+
|
|
1343
|
+
result = client.preprocess.terrain_derivatives("dem.tif")
|
|
1344
|
+
print(result.metadata["mean_slope_deg"])
|
|
1345
|
+
"""
|
|
1346
|
+
rasterio = _require_rasterio()
|
|
1347
|
+
np = _require_numpy()
|
|
1348
|
+
|
|
1349
|
+
inp = Path(input)
|
|
1350
|
+
out_dir = Path(output_dir) if output_dir else inp.parent
|
|
1351
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1352
|
+
|
|
1353
|
+
with rasterio.open(inp) as src:
|
|
1354
|
+
dem = src.read(1).astype(np.float32)
|
|
1355
|
+
profile = src.profile.copy()
|
|
1356
|
+
transform = src.transform
|
|
1357
|
+
crs = src.crs
|
|
1358
|
+
|
|
1359
|
+
pixel_size_x = abs(transform.a)
|
|
1360
|
+
pixel_size_y = abs(transform.e)
|
|
1361
|
+
# Geographic CRS (degrees) needs conversion to metres at this
|
|
1362
|
+
# raster's actual latitude, or slope comes out wildly wrong —
|
|
1363
|
+
# a degree of longitude is not the same physical distance as
|
|
1364
|
+
# a degree of latitude except at the equator.
|
|
1365
|
+
if crs is not None and crs.is_geographic:
|
|
1366
|
+
center_lat = (src.bounds.top + src.bounds.bottom) / 2.0
|
|
1367
|
+
pixel_size_x_m = pixel_size_x * 111320.0 * np.cos(np.radians(center_lat))
|
|
1368
|
+
pixel_size_y_m = pixel_size_y * 111320.0
|
|
1369
|
+
else:
|
|
1370
|
+
pixel_size_x_m = pixel_size_x
|
|
1371
|
+
pixel_size_y_m = pixel_size_y
|
|
1372
|
+
|
|
1373
|
+
dzdy, dzdx = np.gradient(dem, pixel_size_y_m, pixel_size_x_m)
|
|
1374
|
+
slope_rad = np.arctan(np.sqrt(dzdx**2 + dzdy**2))
|
|
1375
|
+
slope_deg = np.degrees(slope_rad).astype(np.float32)
|
|
1376
|
+
|
|
1377
|
+
aspect_rad = np.arctan2(-dzdx, dzdy)
|
|
1378
|
+
aspect_deg = ((450 - np.degrees(aspect_rad)) % 360).astype(np.float32)
|
|
1379
|
+
|
|
1380
|
+
azimuth_rad = np.radians(360 - azimuth + 90)
|
|
1381
|
+
zenith_rad = np.radians(90 - altitude)
|
|
1382
|
+
hillshade = (
|
|
1383
|
+
np.cos(zenith_rad) * np.cos(slope_rad)
|
|
1384
|
+
+ np.sin(zenith_rad) * np.sin(slope_rad) * np.cos(azimuth_rad - aspect_rad)
|
|
1385
|
+
)
|
|
1386
|
+
hillshade = np.clip(hillshade, 0, 1).astype(np.float32)
|
|
1387
|
+
|
|
1388
|
+
out_profile = profile.copy()
|
|
1389
|
+
out_profile.update(dtype="float32", count=1)
|
|
1390
|
+
|
|
1391
|
+
slope_path = out_dir / f"{inp.stem}_slope.tif"
|
|
1392
|
+
aspect_path = out_dir / f"{inp.stem}_aspect.tif"
|
|
1393
|
+
hillshade_path = out_dir / f"{inp.stem}_hillshade.tif"
|
|
1394
|
+
|
|
1395
|
+
for path, arr in [(slope_path, slope_deg), (aspect_path, aspect_deg), (hillshade_path, hillshade)]:
|
|
1396
|
+
with rasterio.open(path, "w", **out_profile) as dst:
|
|
1397
|
+
dst.write(arr, 1)
|
|
1398
|
+
|
|
1399
|
+
logger.info(f"Terrain derivatives computed → {out_dir}")
|
|
1400
|
+
return ProcessingResult(
|
|
1401
|
+
success=True,
|
|
1402
|
+
operation="terrain_derivatives",
|
|
1403
|
+
input_path=inp,
|
|
1404
|
+
output_path=slope_path,
|
|
1405
|
+
metadata={
|
|
1406
|
+
"slope_path": str(slope_path),
|
|
1407
|
+
"aspect_path": str(aspect_path),
|
|
1408
|
+
"hillshade_path": str(hillshade_path),
|
|
1409
|
+
"mean_slope_deg": float(np.nanmean(slope_deg)),
|
|
1410
|
+
"max_slope_deg": float(np.nanmax(slope_deg)),
|
|
1411
|
+
"steep_pct": float(100 * np.nanmean(slope_deg > 30)),
|
|
1412
|
+
"pixel_size_m": float((pixel_size_x_m + pixel_size_y_m) / 2),
|
|
1413
|
+
},
|
|
1414
|
+
)
|
|
1415
|
+
|
|
872
1416
|
def resample(
|
|
873
1417
|
self,
|
|
874
1418
|
input: str | Path,
|