zero2route3d-sdk 0.2.0__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.
- zero2route3d/__init__.py +212 -0
- zero2route3d/accessibility_equity.py +214 -0
- zero2route3d/ahp_engine.py +117 -0
- zero2route3d/api.py +244 -0
- zero2route3d/basemap.py +44 -0
- zero2route3d/cli.py +251 -0
- zero2route3d/copernicus_dem.py +76 -0
- zero2route3d/copernicus_eo_suite.py +443 -0
- zero2route3d/dem_fetcher.py +223 -0
- zero2route3d/environmental_raster.py +411 -0
- zero2route3d/evacuation.py +121 -0
- zero2route3d/html_bundler.py +603 -0
- zero2route3d/input_validation.py +100 -0
- zero2route3d/integrations.py +260 -0
- zero2route3d/isochrone_engine.py +220 -0
- zero2route3d/kinematics.py +383 -0
- zero2route3d/local_server.py +116 -0
- zero2route3d/map_matching_3d.py +374 -0
- zero2route3d/micro_elevation.py +264 -0
- zero2route3d/mobility_profiles.py +517 -0
- zero2route3d/multimodal.py +172 -0
- zero2route3d/network_source.py +512 -0
- zero2route3d/osm_downloader.py +328 -0
- zero2route3d/osm_styling.py +351 -0
- zero2route3d/pareto_router.py +331 -0
- zero2route3d/plotting.py +292 -0
- zero2route3d/profile_dxf.py +231 -0
- zero2route3d/profile_stats.py +648 -0
- zero2route3d/py.typed +1 -0
- zero2route3d/qml_generator.py +189 -0
- zero2route3d/raster_source.py +206 -0
- zero2route3d/report_generator.py +154 -0
- zero2route3d/route_corridor_3d.py +479 -0
- zero2route3d/routing_engine.py +747 -0
- zero2route3d/solar_shadow.py +157 -0
- zero2route3d/tsp_solver.py +78 -0
- zero2route3d_sdk-0.2.0.dist-info/METADATA +251 -0
- zero2route3d_sdk-0.2.0.dist-info/RECORD +42 -0
- zero2route3d_sdk-0.2.0.dist-info/WHEEL +5 -0
- zero2route3d_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- zero2route3d_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- zero2route3d_sdk-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Corridor elevation raster acquisition.
|
|
2
|
+
|
|
3
|
+
Acquires a real elevation grid for an extent and clips it to the route corridor
|
|
4
|
+
buffer, ready for QGIS loading and Multi-Criteria Decision Analysis.
|
|
5
|
+
|
|
6
|
+
Elevation is queried from the Open-Elevation API (https://open-elevation.com);
|
|
7
|
+
values that the service does not resolve are written as NoData, never as zero.
|
|
8
|
+
|
|
9
|
+
This module deliberately produces **only** elevation. Earlier revisions also emitted
|
|
10
|
+
NDVI, LST and NDBI grids that were synthesised from a sine/cosine hash of the pixel
|
|
11
|
+
indices and labelled as Copernicus Sentinel-2 products. No satellite imagery was
|
|
12
|
+
ever fetched, so those three surfaces were fabricated data wearing a real
|
|
13
|
+
provider's name, and they were fed into the routing cost. They have been removed.
|
|
14
|
+
Supply real NDVI / LST rasters through the plugin's own raster selectors instead.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import math
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, List, Optional, Sequence, Tuple
|
|
26
|
+
|
|
27
|
+
from .dem_fetcher import NODATA, GlobalDemFetcher
|
|
28
|
+
from .kinematics import haversine_distance_2d
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class EnvironmentalLayerResult:
|
|
33
|
+
"""Descriptor for a generated and corridor-clipped environmental raster."""
|
|
34
|
+
|
|
35
|
+
key: str # 'dem', 'ndvi', 'lst', 'ndbi'
|
|
36
|
+
name: str
|
|
37
|
+
file_path: Path
|
|
38
|
+
min_val: float
|
|
39
|
+
max_val: float
|
|
40
|
+
unit: str
|
|
41
|
+
color_palette: str # 'terrain', 'ndvi_greens', 'lst_thermal', 'ndbi_urban'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CopernicusEOSuite:
|
|
45
|
+
"""Acquires and corridor-clips multi-spectral Earth Observation rasters in one pass."""
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def get_output_dir(cls) -> Path:
|
|
49
|
+
out_dir = Path(tempfile.gettempdir()) / "zero2route3d_eo_cache"
|
|
50
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
return out_dir
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def fetch_and_clip_multispectral_stack(
|
|
55
|
+
cls,
|
|
56
|
+
bbox: Sequence[float],
|
|
57
|
+
corridor_coords: Optional[Sequence[Sequence[float]]] = None,
|
|
58
|
+
buffer_meters: float = 30.0,
|
|
59
|
+
resolution_deg: float = 0.000277777777778, # ~30m at equator
|
|
60
|
+
) -> List[EnvironmentalLayerResult]:
|
|
61
|
+
"""Acquire and corridor-clip a real elevation raster for the given extent."""
|
|
62
|
+
min_lon, min_lat, max_lon, max_lat = (float(v) for v in bbox[:4])
|
|
63
|
+
if min_lon > max_lon:
|
|
64
|
+
min_lon, max_lon = max_lon, min_lon
|
|
65
|
+
if min_lat > max_lat:
|
|
66
|
+
min_lat, max_lat = max_lat, min_lat
|
|
67
|
+
|
|
68
|
+
out_dir = cls.get_output_dir()
|
|
69
|
+
|
|
70
|
+
# Grid dimensions
|
|
71
|
+
res = max(0.00005, float(resolution_deg))
|
|
72
|
+
width = max(8, min(800, int(math.ceil((max_lon - min_lon) / res))))
|
|
73
|
+
height = max(8, min(800, int(math.ceil((max_lat - min_lat) / res))))
|
|
74
|
+
actual_max_lon = min_lon + width * res
|
|
75
|
+
actual_max_lat = min_lat + height * res
|
|
76
|
+
|
|
77
|
+
# 1. Fetch / derive DEM grid
|
|
78
|
+
dem_grid = cls._acquire_dem_grid(
|
|
79
|
+
min_lon, min_lat, actual_max_lon, actual_max_lat, width, height, res
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# 2. Apply the corridor buffer mask if a corridor line is provided
|
|
83
|
+
if corridor_coords and len(corridor_coords) >= 2:
|
|
84
|
+
cls._apply_corridor_mask(
|
|
85
|
+
[dem_grid],
|
|
86
|
+
min_lon,
|
|
87
|
+
min_lat,
|
|
88
|
+
res,
|
|
89
|
+
width,
|
|
90
|
+
height,
|
|
91
|
+
corridor_coords,
|
|
92
|
+
buffer_meters=buffer_meters,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# 3. Write the GeoTIFF
|
|
96
|
+
geotransform = (min_lon, res, 0.0, actual_max_lat, 0.0, -res)
|
|
97
|
+
results: List[EnvironmentalLayerResult] = []
|
|
98
|
+
|
|
99
|
+
dem_name = (
|
|
100
|
+
"Corridor Elevation (Open-Elevation 30m)"
|
|
101
|
+
if (corridor_coords and len(corridor_coords) >= 2)
|
|
102
|
+
else "Elevation DEM (Open-Elevation 30m)"
|
|
103
|
+
)
|
|
104
|
+
specs = [
|
|
105
|
+
("dem", dem_name, dem_grid, "m", "terrain"),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# tempfile has no `time` attribute, so the old expression always took the
|
|
109
|
+
# else-branch and every run reused one filename, overwriting rasters that
|
|
110
|
+
# were still open in the project.
|
|
111
|
+
timestamp = int(time.time())
|
|
112
|
+
for key, name, grid, unit, palette in specs:
|
|
113
|
+
file_path = out_dir / f"corridor_{key}_{timestamp}.tif"
|
|
114
|
+
min_v, max_v = cls._write_geotiff(
|
|
115
|
+
file_path, grid, width, height, geotransform, nodata_val=NODATA
|
|
116
|
+
)
|
|
117
|
+
results.append(
|
|
118
|
+
EnvironmentalLayerResult(
|
|
119
|
+
key=key,
|
|
120
|
+
name=name,
|
|
121
|
+
file_path=file_path,
|
|
122
|
+
min_val=min_v,
|
|
123
|
+
max_val=max_v,
|
|
124
|
+
unit=unit,
|
|
125
|
+
color_palette=palette,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return results
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def _acquire_dem_grid(
|
|
133
|
+
cls,
|
|
134
|
+
min_lon: float,
|
|
135
|
+
min_lat: float,
|
|
136
|
+
max_lon: float,
|
|
137
|
+
max_lat: float,
|
|
138
|
+
width: int,
|
|
139
|
+
height: int,
|
|
140
|
+
res: float,
|
|
141
|
+
) -> List[List[float]]:
|
|
142
|
+
"""Populate the elevation grid from the Open-Elevation API and its cache.
|
|
143
|
+
|
|
144
|
+
Unresolved samples stay as NoData. Filling them with 0.0 would put a
|
|
145
|
+
sea-level plateau into the middle of real terrain, indistinguishable from
|
|
146
|
+
a genuine measurement.
|
|
147
|
+
"""
|
|
148
|
+
grid: List[List[float]] = [[NODATA] * width for _ in range(height)]
|
|
149
|
+
coords_to_sample: List[Tuple[float, float]] = []
|
|
150
|
+
|
|
151
|
+
for r in range(height):
|
|
152
|
+
lat = max_lat - (r + 0.5) * res
|
|
153
|
+
for c in range(width):
|
|
154
|
+
lon = min_lon + (c + 0.5) * res
|
|
155
|
+
coords_to_sample.append((lon, lat))
|
|
156
|
+
|
|
157
|
+
elevations = GlobalDemFetcher.fetch_elevations_for_coords(coords_to_sample)
|
|
158
|
+
idx = 0
|
|
159
|
+
for r in range(height):
|
|
160
|
+
for c in range(width):
|
|
161
|
+
value = elevations[idx] if idx < len(elevations) else None
|
|
162
|
+
grid[r][c] = NODATA if value is None else float(value)
|
|
163
|
+
idx += 1
|
|
164
|
+
|
|
165
|
+
return grid
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def _apply_corridor_mask(
|
|
169
|
+
cls,
|
|
170
|
+
grids: List[List[List[float]]],
|
|
171
|
+
min_lon: float,
|
|
172
|
+
min_lat: float,
|
|
173
|
+
res: float,
|
|
174
|
+
width: int,
|
|
175
|
+
height: int,
|
|
176
|
+
corridor_coords: Sequence[Sequence[float]],
|
|
177
|
+
buffer_meters: float = 30.0,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Mask out raster cells that fall outside the corridor buffer (set to NoData)."""
|
|
180
|
+
line_2d = [
|
|
181
|
+
(float(c[0]), float(c[1]))
|
|
182
|
+
for c in corridor_coords
|
|
183
|
+
if len(c) >= 2 and math.isfinite(c[0]) and math.isfinite(c[1])
|
|
184
|
+
]
|
|
185
|
+
if len(line_2d) < 2:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
buf_m = float(buffer_meters) if math.isfinite(buffer_meters) and buffer_meters > 0 else 30.0
|
|
189
|
+
# Add 10m soft boundary margin
|
|
190
|
+
max_dist_m = buf_m + 10.0
|
|
191
|
+
|
|
192
|
+
for r in range(height):
|
|
193
|
+
cell_lat = min_lat + (height - 1 - r + 0.5) * res
|
|
194
|
+
for c in range(width):
|
|
195
|
+
cell_lon = min_lon + (c + 0.5) * res
|
|
196
|
+
|
|
197
|
+
min_d = float("inf")
|
|
198
|
+
for i in range(len(line_2d) - 1):
|
|
199
|
+
p1 = line_2d[i]
|
|
200
|
+
p2 = line_2d[i + 1]
|
|
201
|
+
d = haversine_distance_2d((cell_lon, cell_lat), p1)
|
|
202
|
+
if d < min_d:
|
|
203
|
+
min_d = d
|
|
204
|
+
d2 = haversine_distance_2d((cell_lon, cell_lat), p2)
|
|
205
|
+
if d2 < min_d:
|
|
206
|
+
min_d = d2
|
|
207
|
+
|
|
208
|
+
if min_d > max_dist_m:
|
|
209
|
+
for grid in grids:
|
|
210
|
+
grid[r][c] = NODATA
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def _write_geotiff(
|
|
214
|
+
cls,
|
|
215
|
+
file_path: Path,
|
|
216
|
+
grid: List[List[float]],
|
|
217
|
+
width: int,
|
|
218
|
+
height: int,
|
|
219
|
+
geotransform: Tuple[float, float, float, float, float, float],
|
|
220
|
+
nodata_val: float = NODATA,
|
|
221
|
+
) -> Tuple[float, float]:
|
|
222
|
+
"""Write 2D float matrix to GeoTIFF using GDAL if available, or pure-Python GeoTIFF writer fallback."""
|
|
223
|
+
try:
|
|
224
|
+
from osgeo import gdal, osr
|
|
225
|
+
|
|
226
|
+
driver = gdal.GetDriverByName("GTiff")
|
|
227
|
+
dataset = driver.Create(
|
|
228
|
+
str(file_path),
|
|
229
|
+
width,
|
|
230
|
+
height,
|
|
231
|
+
1,
|
|
232
|
+
gdal.GDT_Float32,
|
|
233
|
+
["TILED=YES", "COMPRESS=DEFLATE"],
|
|
234
|
+
)
|
|
235
|
+
dataset.SetGeoTransform(geotransform)
|
|
236
|
+
|
|
237
|
+
srs = osr.SpatialReference()
|
|
238
|
+
srs.ImportFromEPSG(4326)
|
|
239
|
+
dataset.SetProjection(srs.ExportToWkt())
|
|
240
|
+
|
|
241
|
+
band = dataset.GetRasterBand(1)
|
|
242
|
+
band.SetNoDataValue(nodata_val)
|
|
243
|
+
|
|
244
|
+
# Flatten grid
|
|
245
|
+
import struct
|
|
246
|
+
|
|
247
|
+
valid_vals = []
|
|
248
|
+
flat_data = bytearray()
|
|
249
|
+
for r in range(height):
|
|
250
|
+
for c in range(width):
|
|
251
|
+
val = float(grid[r][c])
|
|
252
|
+
if val != nodata_val and math.isfinite(val):
|
|
253
|
+
valid_vals.append(val)
|
|
254
|
+
flat_data.extend(struct.pack("f", val))
|
|
255
|
+
|
|
256
|
+
band.WriteRaster(0, 0, width, height, bytes(flat_data))
|
|
257
|
+
band.FlushCache()
|
|
258
|
+
dataset.FlushCache()
|
|
259
|
+
dataset = None
|
|
260
|
+
|
|
261
|
+
min_val = min(valid_vals) if valid_vals else 0.0
|
|
262
|
+
max_val = max(valid_vals) if valid_vals else 1.0
|
|
263
|
+
return min_val, max_val
|
|
264
|
+
except (ImportError, ModuleNotFoundError):
|
|
265
|
+
return cls._write_pure_python_geotiff(
|
|
266
|
+
file_path, grid, width, height, geotransform, nodata_val
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def _write_pure_python_geotiff(
|
|
271
|
+
cls,
|
|
272
|
+
file_path: Path,
|
|
273
|
+
grid: List[List[float]],
|
|
274
|
+
width: int,
|
|
275
|
+
height: int,
|
|
276
|
+
geotransform: Tuple[float, float, float, float, float, float],
|
|
277
|
+
nodata_val: float = NODATA,
|
|
278
|
+
) -> Tuple[float, float]:
|
|
279
|
+
"""Pure-Python standard GeoTIFF binary generator for non-GDAL testing environments."""
|
|
280
|
+
import struct
|
|
281
|
+
|
|
282
|
+
valid_vals = []
|
|
283
|
+
raw_pixels = bytearray()
|
|
284
|
+
for r in range(height):
|
|
285
|
+
for c in range(width):
|
|
286
|
+
val = float(grid[r][c])
|
|
287
|
+
if val != nodata_val and math.isfinite(val):
|
|
288
|
+
valid_vals.append(val)
|
|
289
|
+
raw_pixels.extend(struct.pack("<f", val))
|
|
290
|
+
|
|
291
|
+
min_val = min(valid_vals) if valid_vals else 0.0
|
|
292
|
+
max_val = max(valid_vals) if valid_vals else 1.0
|
|
293
|
+
|
|
294
|
+
min_lon, res_x, _, max_lat, _, res_y = geotransform
|
|
295
|
+
res_x = abs(res_x)
|
|
296
|
+
res_y = abs(res_y)
|
|
297
|
+
|
|
298
|
+
scale_bytes = struct.pack("<3d", res_x, res_y, 0.0)
|
|
299
|
+
tiepoint_bytes = struct.pack("<6d", 0.0, 0.0, 0.0, min_lon, max_lat, 0.0)
|
|
300
|
+
geokey_bytes = struct.pack(
|
|
301
|
+
"<16H", 1, 1, 0, 3, 1024, 0, 1, 2, 1025, 0, 1, 1, 2048, 0, 1, 4326
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
header = b"II\x2a\x00\x08\x00\x00\x00"
|
|
305
|
+
num_tags = 11
|
|
306
|
+
ifd_offset = 8
|
|
307
|
+
extra_offset = ifd_offset + 2 + num_tags * 12 + 4
|
|
308
|
+
|
|
309
|
+
scale_offset = extra_offset
|
|
310
|
+
tiepoint_offset = scale_offset + len(scale_bytes)
|
|
311
|
+
geokey_offset = tiepoint_offset + len(tiepoint_bytes)
|
|
312
|
+
pixel_offset = geokey_offset + len(geokey_bytes)
|
|
313
|
+
|
|
314
|
+
tags = [
|
|
315
|
+
struct.pack("<HHII", 256, 4, 1, width),
|
|
316
|
+
struct.pack("<HHII", 257, 4, 1, height),
|
|
317
|
+
struct.pack("<HHII", 258, 3, 1, 32),
|
|
318
|
+
struct.pack("<HHII", 259, 3, 1, 1),
|
|
319
|
+
struct.pack("<HHII", 262, 3, 1, 1),
|
|
320
|
+
struct.pack("<HHII", 273, 4, 1, pixel_offset),
|
|
321
|
+
struct.pack("<HHII", 277, 3, 1, 1),
|
|
322
|
+
struct.pack("<HHII", 278, 4, 1, height),
|
|
323
|
+
struct.pack("<HHII", 279, 4, 1, len(raw_pixels)),
|
|
324
|
+
struct.pack("<HHII", 33550, 12, 3, scale_offset),
|
|
325
|
+
struct.pack("<HHII", 33922, 12, 6, tiepoint_offset),
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
ifd = struct.pack("<H", num_tags) + b"".join(tags) + struct.pack("<I", 0)
|
|
329
|
+
file_bytes = header + ifd + scale_bytes + tiepoint_bytes + geokey_bytes + raw_pixels
|
|
330
|
+
|
|
331
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
file_path.write_bytes(file_bytes)
|
|
333
|
+
return min_val, max_val
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def apply_environmental_raster_symbology(
|
|
337
|
+
layer: Any,
|
|
338
|
+
palette_type: str = "terrain",
|
|
339
|
+
min_val: Optional[float] = None,
|
|
340
|
+
max_val: Optional[float] = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Apply professional single-band pseudocolor ramp to environmental raster."""
|
|
343
|
+
with contextlib.suppress(Exception):
|
|
344
|
+
from qgis.core import (
|
|
345
|
+
QgsColorRampShader,
|
|
346
|
+
QgsRasterShader,
|
|
347
|
+
QgsSingleBandPseudoColorRenderer,
|
|
348
|
+
)
|
|
349
|
+
from qgis.PyQt.QtGui import QColor
|
|
350
|
+
|
|
351
|
+
stats = layer.dataProvider().bandStatistics(1)
|
|
352
|
+
v_min = (
|
|
353
|
+
float(min_val)
|
|
354
|
+
if min_val is not None and math.isfinite(min_val)
|
|
355
|
+
else float(stats.minimumValue)
|
|
356
|
+
)
|
|
357
|
+
v_max = (
|
|
358
|
+
float(max_val)
|
|
359
|
+
if max_val is not None and math.isfinite(max_val)
|
|
360
|
+
else float(stats.maximumValue)
|
|
361
|
+
)
|
|
362
|
+
if v_max <= v_min:
|
|
363
|
+
v_max = v_min + 1.0
|
|
364
|
+
|
|
365
|
+
fnc = QgsColorRampShader()
|
|
366
|
+
fnc.setColorRampType(QgsColorRampShader.Interpolated)
|
|
367
|
+
|
|
368
|
+
if palette_type == "ndvi_greens":
|
|
369
|
+
items = [
|
|
370
|
+
QgsColorRampShader.ColorRampItem(
|
|
371
|
+
v_min, QColor("#d97706"), f"{v_min:.2f} (Soil/Barren)"
|
|
372
|
+
),
|
|
373
|
+
QgsColorRampShader.ColorRampItem(
|
|
374
|
+
v_min + (v_max - v_min) * 0.35,
|
|
375
|
+
QColor("#fef08a"),
|
|
376
|
+
f"{v_min + (v_max - v_min) * 0.35:.2f} (Low Grass)",
|
|
377
|
+
),
|
|
378
|
+
QgsColorRampShader.ColorRampItem(
|
|
379
|
+
v_min + (v_max - v_min) * 0.70,
|
|
380
|
+
QColor("#22c55e"),
|
|
381
|
+
f"{v_min + (v_max - v_min) * 0.70:.2f} (Greenery)",
|
|
382
|
+
),
|
|
383
|
+
QgsColorRampShader.ColorRampItem(
|
|
384
|
+
v_max, QColor("#065f46"), f"{v_max:.2f} (Dense Canopy)"
|
|
385
|
+
),
|
|
386
|
+
]
|
|
387
|
+
elif palette_type == "lst_thermal":
|
|
388
|
+
items = [
|
|
389
|
+
QgsColorRampShader.ColorRampItem(
|
|
390
|
+
v_min, QColor("#3b82f6"), f"{v_min:.1f} °C (Cool)"
|
|
391
|
+
),
|
|
392
|
+
QgsColorRampShader.ColorRampItem(
|
|
393
|
+
v_min + (v_max - v_min) * 0.33,
|
|
394
|
+
QColor("#facc15"),
|
|
395
|
+
f"{v_min + (v_max - v_min) * 0.33:.1f} °C (Moderate)",
|
|
396
|
+
),
|
|
397
|
+
QgsColorRampShader.ColorRampItem(
|
|
398
|
+
v_min + (v_max - v_min) * 0.66,
|
|
399
|
+
QColor("#f97316"),
|
|
400
|
+
f"{v_min + (v_max - v_min) * 0.66:.1f} °C (Warm)",
|
|
401
|
+
),
|
|
402
|
+
QgsColorRampShader.ColorRampItem(
|
|
403
|
+
v_max, QColor("#b91c1c"), f"{v_max:.1f} °C (Severe Heat)"
|
|
404
|
+
),
|
|
405
|
+
]
|
|
406
|
+
elif palette_type == "ndbi_urban":
|
|
407
|
+
items = [
|
|
408
|
+
QgsColorRampShader.ColorRampItem(
|
|
409
|
+
v_min, QColor("#f1f5f9"), f"{v_min:.2f} (Open Natural)"
|
|
410
|
+
),
|
|
411
|
+
QgsColorRampShader.ColorRampItem(
|
|
412
|
+
v_min + (v_max - v_min) * 0.50,
|
|
413
|
+
QColor("#94a3b8"),
|
|
414
|
+
f"{v_min + (v_max - v_min) * 0.50:.2f} (Suburban)",
|
|
415
|
+
),
|
|
416
|
+
QgsColorRampShader.ColorRampItem(
|
|
417
|
+
v_max, QColor("#475569"), f"{v_max:.2f} (Dense Built-up)"
|
|
418
|
+
),
|
|
419
|
+
]
|
|
420
|
+
else: # terrain
|
|
421
|
+
items = [
|
|
422
|
+
QgsColorRampShader.ColorRampItem(
|
|
423
|
+
v_min, QColor("#15803d"), f"{v_min:.1f} m (Lowland)"
|
|
424
|
+
),
|
|
425
|
+
QgsColorRampShader.ColorRampItem(
|
|
426
|
+
v_min + (v_max - v_min) * 0.35,
|
|
427
|
+
QColor("#ca8a04"),
|
|
428
|
+
f"{v_min + (v_max - v_min) * 0.35:.1f} m (Hills)",
|
|
429
|
+
),
|
|
430
|
+
QgsColorRampShader.ColorRampItem(
|
|
431
|
+
v_min + (v_max - v_min) * 0.70,
|
|
432
|
+
QColor("#854d0e"),
|
|
433
|
+
f"{v_min + (v_max - v_min) * 0.70:.1f} m (Ridge)",
|
|
434
|
+
),
|
|
435
|
+
QgsColorRampShader.ColorRampItem(v_max, QColor("#ffffff"), f"{v_max:.1f} m (Peak)"),
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
fnc.setColorRampItemList(items)
|
|
439
|
+
shader = QgsRasterShader()
|
|
440
|
+
shader.setRasterShaderFunction(fnc)
|
|
441
|
+
renderer = QgsSingleBandPseudoColorRenderer(layer.dataProvider(), 1, shader)
|
|
442
|
+
layer.setRenderer(renderer)
|
|
443
|
+
layer.triggerRepaint()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Global Digital Elevation Model (DEM) acquisition & real-world elevation sampler.
|
|
2
|
+
|
|
3
|
+
Provides automated fetching from the Open-Elevation API with local disk caching.
|
|
4
|
+
When the service is unavailable, unresolved values remain marked as missing
|
|
5
|
+
instead of being replaced with invented topography.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
16
|
+
|
|
17
|
+
# Raster NoData value. Distinct from 0.0, which is a legal elevation: filling
|
|
18
|
+
# unresolved samples with zero produces a flat sea-level plateau that downstream
|
|
19
|
+
# slope code cannot tell apart from real terrain.
|
|
20
|
+
NODATA = -9999.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GlobalDemFetcher:
|
|
24
|
+
"""Acquires and caches real elevation samples from the Open-Elevation API.
|
|
25
|
+
|
|
26
|
+
The service (https://open-elevation.com) is the only elevation source used
|
|
27
|
+
here. No Copernicus or SRTM product is queried directly.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_CACHE_FILE = Path(tempfile.gettempdir()) / "zero2route3d_cache" / "elevation_cache.json"
|
|
31
|
+
_MEMORY_CACHE: Dict[str, float] = {}
|
|
32
|
+
_MAX_CACHE_ENTRIES = 20_000
|
|
33
|
+
_DISK_CACHE_LOADED = False
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def _cache_key(cls, lon: float, lat: float) -> str:
|
|
37
|
+
return f"{round(lon, 5):.5f},{round(lat, 5):.5f}"
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def _load_disk_cache(cls) -> None:
|
|
41
|
+
if cls._DISK_CACHE_LOADED:
|
|
42
|
+
return
|
|
43
|
+
cls._DISK_CACHE_LOADED = True
|
|
44
|
+
if not cls._CACHE_FILE.exists():
|
|
45
|
+
return
|
|
46
|
+
with contextlib.suppress(Exception):
|
|
47
|
+
data = json.loads(cls._CACHE_FILE.read_text(encoding="utf-8"))
|
|
48
|
+
if isinstance(data, dict):
|
|
49
|
+
for k, v in data.items():
|
|
50
|
+
try:
|
|
51
|
+
val = float(v)
|
|
52
|
+
except (ValueError, TypeError):
|
|
53
|
+
continue
|
|
54
|
+
if math.isfinite(val):
|
|
55
|
+
cls._MEMORY_CACHE[str(k)] = val
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def _save_disk_cache(cls) -> None:
|
|
59
|
+
with contextlib.suppress(Exception):
|
|
60
|
+
if len(cls._MEMORY_CACHE) > cls._MAX_CACHE_ENTRIES:
|
|
61
|
+
keep = list(cls._MEMORY_CACHE.items())[-cls._MAX_CACHE_ENTRIES :]
|
|
62
|
+
cls._MEMORY_CACHE = dict(keep)
|
|
63
|
+
cls._CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
temp_file = cls._CACHE_FILE.with_suffix(".tmp")
|
|
65
|
+
temp_file.write_text(json.dumps(cls._MEMORY_CACHE, indent=2), encoding="utf-8")
|
|
66
|
+
temp_file.replace(cls._CACHE_FILE)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def clear_cache(cls, remove_disk_file: bool = False) -> None:
|
|
70
|
+
"""Drop every cached elevation sample; optionally delete the on-disk cache too."""
|
|
71
|
+
cls._MEMORY_CACHE = {}
|
|
72
|
+
cls._DISK_CACHE_LOADED = False
|
|
73
|
+
if remove_disk_file:
|
|
74
|
+
with contextlib.suppress(Exception):
|
|
75
|
+
cls._CACHE_FILE.unlink()
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def cached_sample_count(cls) -> int:
|
|
79
|
+
"""Number of elevation samples currently held in memory."""
|
|
80
|
+
cls._load_disk_cache()
|
|
81
|
+
return len(cls._MEMORY_CACHE)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def seed_cache(cls, samples: Dict[Tuple[float, float], float]) -> int:
|
|
85
|
+
"""Pre-populate the cache with known ``(lon, lat) -> elevation_m`` samples.
|
|
86
|
+
|
|
87
|
+
Useful for offline and reproducible runs: seeded points are then served by
|
|
88
|
+
:meth:`get_fast_elevation` without any network access. Returns the number
|
|
89
|
+
of samples accepted.
|
|
90
|
+
"""
|
|
91
|
+
cls._load_disk_cache()
|
|
92
|
+
accepted = 0
|
|
93
|
+
for coord, value in samples.items():
|
|
94
|
+
try:
|
|
95
|
+
lon, lat, elevation = float(coord[0]), float(coord[1]), float(value)
|
|
96
|
+
except (TypeError, ValueError, IndexError, OverflowError):
|
|
97
|
+
continue
|
|
98
|
+
if not (math.isfinite(lon) and math.isfinite(lat) and math.isfinite(elevation)):
|
|
99
|
+
continue
|
|
100
|
+
cls._MEMORY_CACHE[cls._cache_key(lon, lat)] = elevation
|
|
101
|
+
accepted += 1
|
|
102
|
+
return accepted
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def fetch_elevations_for_coords(
|
|
106
|
+
cls, coords: Sequence[Tuple[float, float]], timeout_sec: float = 3.0
|
|
107
|
+
) -> List[Optional[float]]:
|
|
108
|
+
"""Fetch real elevations in metres for a sequence of (lon, lat) WGS84 points.
|
|
109
|
+
|
|
110
|
+
Every input gets exactly one output slot. A slot is None when the elevation
|
|
111
|
+
could not be resolved -- 0.0 is a real elevation and must never stand in for
|
|
112
|
+
a failed lookup.
|
|
113
|
+
"""
|
|
114
|
+
cls._load_disk_cache()
|
|
115
|
+
results: List[Optional[float]] = [None] * len(coords)
|
|
116
|
+
missing_pairs: List[Tuple[int, float, float]] = []
|
|
117
|
+
|
|
118
|
+
for idx, pt in enumerate(coords):
|
|
119
|
+
if not pt or len(pt) < 2:
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
lon, lat = float(pt[0]), float(pt[1])
|
|
123
|
+
except (TypeError, ValueError, OverflowError):
|
|
124
|
+
continue
|
|
125
|
+
if not math.isfinite(lon) or not math.isfinite(lat):
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
key = cls._cache_key(lon, lat)
|
|
129
|
+
if key in cls._MEMORY_CACHE:
|
|
130
|
+
results[idx] = cls._MEMORY_CACHE[key]
|
|
131
|
+
else:
|
|
132
|
+
missing_pairs.append((idx, lon, lat))
|
|
133
|
+
|
|
134
|
+
if not missing_pairs:
|
|
135
|
+
return results
|
|
136
|
+
|
|
137
|
+
# Batch fetch from the Open-Elevation API in chunks of up to 150 items.
|
|
138
|
+
chunk_size = 150
|
|
139
|
+
timeout_val = (
|
|
140
|
+
max(1.0, float(timeout_sec)) if math.isfinite(timeout_sec) and timeout_sec > 0 else 3.0
|
|
141
|
+
)
|
|
142
|
+
any_success = False
|
|
143
|
+
|
|
144
|
+
for i in range(0, len(missing_pairs), chunk_size):
|
|
145
|
+
chunk = missing_pairs[i : i + chunk_size]
|
|
146
|
+
chunk_locations = [
|
|
147
|
+
{"latitude": round(lat, 6), "longitude": round(lon, 6)} for _, lon, lat in chunk
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
import http.client
|
|
152
|
+
|
|
153
|
+
payload = json.dumps({"locations": chunk_locations})
|
|
154
|
+
conn = http.client.HTTPSConnection("api.open-elevation.com", timeout=timeout_val)
|
|
155
|
+
try:
|
|
156
|
+
headers = {
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
"User-Agent": "zero2route3d-sdk",
|
|
159
|
+
}
|
|
160
|
+
conn.request("POST", "/api/v1/lookup", body=payload, headers=headers)
|
|
161
|
+
resp = conn.getresponse()
|
|
162
|
+
if resp.status == 200:
|
|
163
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
164
|
+
ele_list = data.get("results", [])
|
|
165
|
+
if len(ele_list) == len(chunk):
|
|
166
|
+
parsed_values: List[float] = []
|
|
167
|
+
for item in ele_list:
|
|
168
|
+
if not isinstance(item, dict) or "elevation" not in item:
|
|
169
|
+
break
|
|
170
|
+
try:
|
|
171
|
+
ele_val = float(item["elevation"])
|
|
172
|
+
except (TypeError, ValueError, OverflowError):
|
|
173
|
+
break
|
|
174
|
+
if not math.isfinite(ele_val):
|
|
175
|
+
break
|
|
176
|
+
parsed_values.append(ele_val)
|
|
177
|
+
if len(parsed_values) == len(chunk):
|
|
178
|
+
for (m_idx, lon_val, lat_val), ele_val in zip(chunk, parsed_values):
|
|
179
|
+
results[m_idx] = ele_val
|
|
180
|
+
cls._MEMORY_CACHE[cls._cache_key(lon_val, lat_val)] = ele_val
|
|
181
|
+
any_success = True
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
184
|
+
except Exception:
|
|
185
|
+
# Unresolved coordinates keep their None slot. Never synthesize
|
|
186
|
+
# terrain from coordinate math, and never fall back to 0.0 --
|
|
187
|
+
# which is a real elevation somewhere.
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if any_success:
|
|
191
|
+
cls._save_disk_cache()
|
|
192
|
+
|
|
193
|
+
return results
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def get_fast_elevation(cls, lon: float, lat: float) -> Optional[float]:
|
|
197
|
+
"""Elevation for a coordinate from the cache only, or None if unknown.
|
|
198
|
+
|
|
199
|
+
Returns None rather than 0.0 so that "no elevation data here" stays
|
|
200
|
+
distinguishable from "this point is at sea level". Never touches the
|
|
201
|
+
network, so it is safe to call inside a routing hot loop.
|
|
202
|
+
"""
|
|
203
|
+
if not math.isfinite(lon) or not math.isfinite(lat):
|
|
204
|
+
return None
|
|
205
|
+
cls._load_disk_cache()
|
|
206
|
+
return cls._MEMORY_CACHE.get(cls._cache_key(lon, lat))
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def get_elevation_single(
|
|
210
|
+
cls, lon: float, lat: float, timeout_sec: float = 3.0
|
|
211
|
+
) -> Optional[float]:
|
|
212
|
+
"""Elevation for one coordinate, querying Open-Elevation on a cache miss.
|
|
213
|
+
|
|
214
|
+
Returns None when the point cannot be resolved. Prefer
|
|
215
|
+
:meth:`fetch_elevations_for_coords` for more than a handful of points --
|
|
216
|
+
it batches them into a single request.
|
|
217
|
+
"""
|
|
218
|
+
if not math.isfinite(lon) or not math.isfinite(lat):
|
|
219
|
+
return None
|
|
220
|
+
cached = cls.get_fast_elevation(lon, lat)
|
|
221
|
+
if cached is not None:
|
|
222
|
+
return cached
|
|
223
|
+
return cls.fetch_elevations_for_coords([(lon, lat)], timeout_sec=timeout_sec)[0]
|