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.
Files changed (42) hide show
  1. zero2route3d/__init__.py +212 -0
  2. zero2route3d/accessibility_equity.py +214 -0
  3. zero2route3d/ahp_engine.py +117 -0
  4. zero2route3d/api.py +244 -0
  5. zero2route3d/basemap.py +44 -0
  6. zero2route3d/cli.py +251 -0
  7. zero2route3d/copernicus_dem.py +76 -0
  8. zero2route3d/copernicus_eo_suite.py +443 -0
  9. zero2route3d/dem_fetcher.py +223 -0
  10. zero2route3d/environmental_raster.py +411 -0
  11. zero2route3d/evacuation.py +121 -0
  12. zero2route3d/html_bundler.py +603 -0
  13. zero2route3d/input_validation.py +100 -0
  14. zero2route3d/integrations.py +260 -0
  15. zero2route3d/isochrone_engine.py +220 -0
  16. zero2route3d/kinematics.py +383 -0
  17. zero2route3d/local_server.py +116 -0
  18. zero2route3d/map_matching_3d.py +374 -0
  19. zero2route3d/micro_elevation.py +264 -0
  20. zero2route3d/mobility_profiles.py +517 -0
  21. zero2route3d/multimodal.py +172 -0
  22. zero2route3d/network_source.py +512 -0
  23. zero2route3d/osm_downloader.py +328 -0
  24. zero2route3d/osm_styling.py +351 -0
  25. zero2route3d/pareto_router.py +331 -0
  26. zero2route3d/plotting.py +292 -0
  27. zero2route3d/profile_dxf.py +231 -0
  28. zero2route3d/profile_stats.py +648 -0
  29. zero2route3d/py.typed +1 -0
  30. zero2route3d/qml_generator.py +189 -0
  31. zero2route3d/raster_source.py +206 -0
  32. zero2route3d/report_generator.py +154 -0
  33. zero2route3d/route_corridor_3d.py +479 -0
  34. zero2route3d/routing_engine.py +747 -0
  35. zero2route3d/solar_shadow.py +157 -0
  36. zero2route3d/tsp_solver.py +78 -0
  37. zero2route3d_sdk-0.2.0.dist-info/METADATA +251 -0
  38. zero2route3d_sdk-0.2.0.dist-info/RECORD +42 -0
  39. zero2route3d_sdk-0.2.0.dist-info/WHEEL +5 -0
  40. zero2route3d_sdk-0.2.0.dist-info/entry_points.txt +2 -0
  41. zero2route3d_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
  42. 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]