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,411 @@
|
|
|
1
|
+
"""Multi-criteria environmental raster cost surfaces and spatial sampling.
|
|
2
|
+
|
|
3
|
+
Supports sampling digital elevation models (DEM), slope/aspect derivation,
|
|
4
|
+
solar irradiance, Land Surface Temperature (LST), and tree canopy/greenery indices.
|
|
5
|
+
|
|
6
|
+
Rasters may be supplied either as QGIS raster layers (when running inside QGIS)
|
|
7
|
+
or, headless, as GeoTIFF file paths -- see :class:`EnvironmentalSurfaceSampler`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import math
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
17
|
+
|
|
18
|
+
from .kinematics import haversine_distance_2d, solar_irradiance_aspect_factor
|
|
19
|
+
from .raster_source import GeoTiffRasterSource, open_raster_source
|
|
20
|
+
|
|
21
|
+
RasterInput = Union[str, Path, GeoTiffRasterSource, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class MCDAWeights:
|
|
26
|
+
"""Weights configuration for Multi-Criteria Decision Analysis (AHP)."""
|
|
27
|
+
|
|
28
|
+
weight_distance: float = 1.0
|
|
29
|
+
weight_slope: float = 1.0
|
|
30
|
+
weight_heat: float = 0.5
|
|
31
|
+
weight_green: float = 0.5
|
|
32
|
+
weight_safety: float = 0.5
|
|
33
|
+
weight_solar: float = 0.3
|
|
34
|
+
weight_extra: float = 0.5
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
weight_distance: float = 1.0,
|
|
39
|
+
weight_slope: float = 1.0,
|
|
40
|
+
weight_heat: float = 0.5,
|
|
41
|
+
weight_green: float = 0.5,
|
|
42
|
+
weight_safety: float = 0.5,
|
|
43
|
+
weight_solar: float = 0.3,
|
|
44
|
+
weight_extra: float = 0.5,
|
|
45
|
+
**kwargs: Any,
|
|
46
|
+
) -> None:
|
|
47
|
+
def _clean_w(val: Any, default: float) -> float:
|
|
48
|
+
try:
|
|
49
|
+
f = float(val)
|
|
50
|
+
return max(0.0, f) if math.isfinite(f) else default
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
return default
|
|
53
|
+
|
|
54
|
+
self.weight_distance = _clean_w(kwargs.get("distance", weight_distance), 1.0)
|
|
55
|
+
self.weight_slope = _clean_w(kwargs.get("slope", weight_slope), 1.0)
|
|
56
|
+
self.weight_heat = _clean_w(kwargs.get("heat", weight_heat), 0.5)
|
|
57
|
+
self.weight_green = _clean_w(kwargs.get("green", weight_green), 0.5)
|
|
58
|
+
self.weight_safety = _clean_w(kwargs.get("safety", weight_safety), 0.5)
|
|
59
|
+
self.weight_solar = _clean_w(kwargs.get("solar", weight_solar), 0.3)
|
|
60
|
+
self.weight_extra = _clean_w(kwargs.get("extra", weight_extra), 0.5)
|
|
61
|
+
|
|
62
|
+
def normalized_dict(self) -> Dict[str, float]:
|
|
63
|
+
"""Return dictionary of normalized weight coefficients summing to 1.0."""
|
|
64
|
+
total = (
|
|
65
|
+
self.weight_distance
|
|
66
|
+
+ self.weight_slope
|
|
67
|
+
+ self.weight_heat
|
|
68
|
+
+ self.weight_green
|
|
69
|
+
+ self.weight_safety
|
|
70
|
+
+ self.weight_solar
|
|
71
|
+
)
|
|
72
|
+
if not math.isfinite(total) or total <= 0:
|
|
73
|
+
return {
|
|
74
|
+
"distance": 0.2,
|
|
75
|
+
"slope": 0.2,
|
|
76
|
+
"heat": 0.2,
|
|
77
|
+
"green": 0.2,
|
|
78
|
+
"safety": 0.1,
|
|
79
|
+
"solar": 0.1,
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
"distance": self.weight_distance / total,
|
|
83
|
+
"slope": self.weight_slope / total,
|
|
84
|
+
"heat": self.weight_heat / total,
|
|
85
|
+
"green": self.weight_green / total,
|
|
86
|
+
"safety": self.weight_safety / total,
|
|
87
|
+
"solar": self.weight_solar / total,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class EnvironmentalSurfaceSampler:
|
|
92
|
+
"""Samples environmental parameters from GeoTIFF files or QGIS raster layers.
|
|
93
|
+
|
|
94
|
+
Every raster argument accepts either a GeoTIFF path (headless, via rasterio)
|
|
95
|
+
or a live QGIS raster layer. Paths are the normal choice for SDK use::
|
|
96
|
+
|
|
97
|
+
sampler = EnvironmentalSurfaceSampler(dem="izmir_dem.tif", lst="lst.tif")
|
|
98
|
+
|
|
99
|
+
When no DEM is configured at all, :meth:`sample_elevation` falls back to the
|
|
100
|
+
:class:`~zero2route3d.dem_fetcher.GlobalDemFetcher` cache and returns None for
|
|
101
|
+
points it has never seen. Call :meth:`prefetch_elevations` to fill that cache
|
|
102
|
+
from Open-Elevation in one batched request.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
dem_layer: Optional[RasterInput] = None,
|
|
108
|
+
dem_layers: Optional[Sequence[RasterInput]] = None,
|
|
109
|
+
lst_layer: Optional[RasterInput] = None,
|
|
110
|
+
green_layer: Optional[RasterInput] = None,
|
|
111
|
+
additional_layers: Optional[Sequence[RasterInput]] = None,
|
|
112
|
+
sun_azimuth_deg: float = 180.0,
|
|
113
|
+
sun_elevation_deg: float = 55.0,
|
|
114
|
+
weights: Optional[MCDAWeights] = None,
|
|
115
|
+
dem: Optional[RasterInput] = None,
|
|
116
|
+
lst: Optional[RasterInput] = None,
|
|
117
|
+
green: Optional[RasterInput] = None,
|
|
118
|
+
lst_range_c: Tuple[float, float] = (20.0, 50.0),
|
|
119
|
+
) -> None:
|
|
120
|
+
dem_layer = dem if dem is not None else dem_layer
|
|
121
|
+
lst_layer = lst if lst is not None else lst_layer
|
|
122
|
+
green_layer = green if green is not None else green_layer
|
|
123
|
+
|
|
124
|
+
self.dem_layer = self._coerce_raster(dem_layer)
|
|
125
|
+
self.dem_layers: List[Any] = [
|
|
126
|
+
self._coerce_raster(layer) for layer in (dem_layers or []) if layer is not None
|
|
127
|
+
]
|
|
128
|
+
if self.dem_layer is not None and self.dem_layer not in self.dem_layers:
|
|
129
|
+
self.dem_layers.insert(0, self.dem_layer)
|
|
130
|
+
self.lst_layer = self._coerce_raster(lst_layer)
|
|
131
|
+
self.green_layer = self._coerce_raster(green_layer)
|
|
132
|
+
self.additional_layers: List[Any] = [
|
|
133
|
+
self._coerce_raster(layer) for layer in (additional_layers or []) if layer is not None
|
|
134
|
+
]
|
|
135
|
+
self.sun_azimuth_deg = sun_azimuth_deg if math.isfinite(sun_azimuth_deg) else 180.0
|
|
136
|
+
self.sun_elevation_deg = sun_elevation_deg if math.isfinite(sun_elevation_deg) else 55.0
|
|
137
|
+
self.weights = weights or MCDAWeights()
|
|
138
|
+
low_c, high_c = float(lst_range_c[0]), float(lst_range_c[1])
|
|
139
|
+
self.lst_range_c: Tuple[float, float] = (
|
|
140
|
+
(low_c, high_c)
|
|
141
|
+
if math.isfinite(low_c) and math.isfinite(high_c) and high_c > low_c
|
|
142
|
+
else (20.0, 50.0)
|
|
143
|
+
)
|
|
144
|
+
self._dem_cache: Dict[Tuple[float, float], Optional[float]] = {}
|
|
145
|
+
self._lst_cache: Dict[Tuple[float, float], Optional[float]] = {}
|
|
146
|
+
self._green_cache: Dict[Tuple[float, float], Optional[float]] = {}
|
|
147
|
+
self._additional_range_cache: Dict[str, Tuple[float, float]] = {}
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _coerce_raster(source: Optional[RasterInput]) -> Any:
|
|
151
|
+
"""Wrap GeoTIFF paths in a GeoTiffRasterSource; pass QGIS layers straight through."""
|
|
152
|
+
coerced = open_raster_source(source)
|
|
153
|
+
return coerced if coerced is not None else source
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _sample_raster(layer: Any, lon: float, lat: float) -> Optional[float]:
|
|
157
|
+
"""Sample one raster at a WGS84 coordinate, whichever kind of raster it is."""
|
|
158
|
+
if layer is None:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
if isinstance(layer, GeoTiffRasterSource):
|
|
162
|
+
try:
|
|
163
|
+
return layer.sample(lon, lat)
|
|
164
|
+
except Exception:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
with contextlib.suppress(Exception):
|
|
168
|
+
from qgis.core import (
|
|
169
|
+
QgsCoordinateReferenceSystem,
|
|
170
|
+
QgsCoordinateTransform,
|
|
171
|
+
QgsPointXY,
|
|
172
|
+
QgsProject,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
pt = QgsPointXY(lon, lat)
|
|
176
|
+
crs_src = QgsCoordinateReferenceSystem("EPSG:4326")
|
|
177
|
+
crs_dest = layer.crs()
|
|
178
|
+
if crs_src != crs_dest:
|
|
179
|
+
transform = QgsCoordinateTransform(crs_src, crs_dest, QgsProject.instance())
|
|
180
|
+
pt = transform.transform(pt)
|
|
181
|
+
|
|
182
|
+
val, success = layer.dataProvider().sample(pt, 1)
|
|
183
|
+
if success and val is not None and math.isfinite(val) and val > -9999:
|
|
184
|
+
return float(val)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def set_additional_layers(self, layers: Sequence[RasterInput]) -> None:
|
|
188
|
+
"""Replace the unlimited MCDA raster stack without rebuilding the sampler."""
|
|
189
|
+
self.additional_layers = [
|
|
190
|
+
self._coerce_raster(layer) for layer in layers if layer is not None
|
|
191
|
+
]
|
|
192
|
+
self._additional_range_cache.clear()
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def has_elevation_source(self) -> bool:
|
|
196
|
+
"""True when at least one DEM raster is configured on this sampler."""
|
|
197
|
+
return bool(self.dem_layers)
|
|
198
|
+
|
|
199
|
+
def prefetch_elevations(
|
|
200
|
+
self,
|
|
201
|
+
coords: Sequence[Tuple[float, float]],
|
|
202
|
+
timeout_sec: float = 10.0,
|
|
203
|
+
) -> int:
|
|
204
|
+
"""Fill the elevation cache for many coordinates in one Open-Elevation request.
|
|
205
|
+
|
|
206
|
+
Only meaningful when no DEM raster is configured. Returns the number of
|
|
207
|
+
points that were resolved. Requires network access; unresolved points stay
|
|
208
|
+
unknown rather than silently becoming sea level.
|
|
209
|
+
"""
|
|
210
|
+
if self.has_elevation_source or not coords:
|
|
211
|
+
return 0
|
|
212
|
+
from .dem_fetcher import GlobalDemFetcher
|
|
213
|
+
|
|
214
|
+
values = GlobalDemFetcher.fetch_elevations_for_coords(coords, timeout_sec=timeout_sec)
|
|
215
|
+
resolved = 0
|
|
216
|
+
for (lon, lat), value in zip(coords, values):
|
|
217
|
+
if value is None:
|
|
218
|
+
continue
|
|
219
|
+
self._dem_cache[(round(lon, 5), round(lat, 5))] = float(value)
|
|
220
|
+
resolved += 1
|
|
221
|
+
return resolved
|
|
222
|
+
|
|
223
|
+
def sample_elevation(self, lon: float, lat: float) -> Optional[float]:
|
|
224
|
+
"""Sample elevation in metres at a WGS84 coordinate, or None if unknown.
|
|
225
|
+
|
|
226
|
+
Returns None -- not 0.0 -- when no DEM covers the point. Zero is a valid
|
|
227
|
+
elevation, so substituting it silently flattens terrain: slopes computed
|
|
228
|
+
against real neighbours become cliffs, and a whole network with no DEM
|
|
229
|
+
looks perfectly flat and therefore fully ADA-compliant.
|
|
230
|
+
"""
|
|
231
|
+
if not math.isfinite(lon) or not math.isfinite(lat):
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
coord_key = (round(lon, 5), round(lat, 5))
|
|
235
|
+
if coord_key in self._dem_cache:
|
|
236
|
+
return self._dem_cache[coord_key]
|
|
237
|
+
|
|
238
|
+
for dem_layer in self.dem_layers:
|
|
239
|
+
elevation = self._sample_raster(dem_layer, lon, lat)
|
|
240
|
+
if elevation is not None:
|
|
241
|
+
self._dem_cache[coord_key] = elevation
|
|
242
|
+
return elevation
|
|
243
|
+
|
|
244
|
+
# Fall back to the cached Open-Elevation samples; None stays None.
|
|
245
|
+
from .dem_fetcher import GlobalDemFetcher
|
|
246
|
+
|
|
247
|
+
cached = GlobalDemFetcher.get_fast_elevation(lon, lat)
|
|
248
|
+
if cached is not None and not math.isfinite(cached):
|
|
249
|
+
cached = None
|
|
250
|
+
self._dem_cache[coord_key] = cached
|
|
251
|
+
return cached
|
|
252
|
+
|
|
253
|
+
def sample_slope_and_aspect(
|
|
254
|
+
self,
|
|
255
|
+
p1: Sequence[float],
|
|
256
|
+
p2: Sequence[float],
|
|
257
|
+
) -> Tuple[float, float, float]:
|
|
258
|
+
"""Compute directional slope (%), aspect angle (deg), and solar irradiance factor (0..1)."""
|
|
259
|
+
if not p1 or not p2 or len(p1) < 2 or len(p2) < 2:
|
|
260
|
+
return 0.0, 0.0, 0.5
|
|
261
|
+
|
|
262
|
+
lon1, lat1 = float(p1[0]), float(p1[1])
|
|
263
|
+
lon2, lat2 = float(p2[0]), float(p2[1])
|
|
264
|
+
if not (
|
|
265
|
+
math.isfinite(lon1)
|
|
266
|
+
and math.isfinite(lat1)
|
|
267
|
+
and math.isfinite(lon2)
|
|
268
|
+
and math.isfinite(lat2)
|
|
269
|
+
):
|
|
270
|
+
return 0.0, 0.0, 0.5
|
|
271
|
+
|
|
272
|
+
dist_2d = haversine_distance_2d(p1, p2)
|
|
273
|
+
if not math.isfinite(dist_2d) or dist_2d < 0.1:
|
|
274
|
+
return 0.0, 0.0, 0.5
|
|
275
|
+
|
|
276
|
+
z1 = (
|
|
277
|
+
float(p1[2])
|
|
278
|
+
if len(p1) > 2 and math.isfinite(float(p1[2]))
|
|
279
|
+
else self.sample_elevation(lon1, lat1)
|
|
280
|
+
)
|
|
281
|
+
z2 = (
|
|
282
|
+
float(p2[2])
|
|
283
|
+
if len(p2) > 2 and math.isfinite(float(p2[2]))
|
|
284
|
+
else self.sample_elevation(lon2, lat2)
|
|
285
|
+
)
|
|
286
|
+
if z1 is None or z2 is None:
|
|
287
|
+
# No elevation data here: report a flat, zero-slope segment with a
|
|
288
|
+
# neutral solar factor rather than inventing a gradient.
|
|
289
|
+
return 0.0, 0.0, 1.0
|
|
290
|
+
|
|
291
|
+
dz = z2 - z1
|
|
292
|
+
if not math.isfinite(dz):
|
|
293
|
+
dz = 0.0
|
|
294
|
+
|
|
295
|
+
slope_pct = (dz / dist_2d) * 100.0
|
|
296
|
+
if not math.isfinite(slope_pct):
|
|
297
|
+
slope_pct = 0.0
|
|
298
|
+
|
|
299
|
+
mean_lat = (lat1 + lat2) * 0.5
|
|
300
|
+
dx = (lon2 - lon1) * math.cos(math.radians(mean_lat))
|
|
301
|
+
dy = lat2 - lat1
|
|
302
|
+
bearing_rad = math.atan2(dx, dy)
|
|
303
|
+
aspect_deg = (math.degrees(bearing_rad) + 360.0) % 360.0
|
|
304
|
+
|
|
305
|
+
solar_factor = solar_irradiance_aspect_factor(
|
|
306
|
+
aspect_deg=aspect_deg,
|
|
307
|
+
slope_pct=slope_pct,
|
|
308
|
+
sun_azimuth_deg=self.sun_azimuth_deg,
|
|
309
|
+
sun_elevation_deg=self.sun_elevation_deg,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return slope_pct, aspect_deg, solar_factor
|
|
313
|
+
|
|
314
|
+
def sample_lst(self, lon: float, lat: float) -> Optional[float]:
|
|
315
|
+
"""Sample Land Surface Temperature (normalized 0.0 = cool, 1.0 = hot).
|
|
316
|
+
|
|
317
|
+
Raw raster values are read as degrees Celsius and rescaled with the
|
|
318
|
+
``lst_range_c`` window given to the constructor (20-50 C by default).
|
|
319
|
+
|
|
320
|
+
Returns None when no LST raster is configured or the point cannot be
|
|
321
|
+
sampled. Callers must treat None as "no data" and drop the criterion --
|
|
322
|
+
never as an average value, which would fabricate a thermal surface.
|
|
323
|
+
"""
|
|
324
|
+
if not math.isfinite(lon) or not math.isfinite(lat):
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
coord_key = (round(lon, 5), round(lat, 5))
|
|
328
|
+
if coord_key in self._lst_cache:
|
|
329
|
+
return self._lst_cache[coord_key]
|
|
330
|
+
|
|
331
|
+
normalized: Optional[float] = None
|
|
332
|
+
raw = self._sample_raster(self.lst_layer, lon, lat)
|
|
333
|
+
if raw is not None:
|
|
334
|
+
low_c, high_c = self.lst_range_c
|
|
335
|
+
normalized = max(0.0, min(1.0, (raw - low_c) / (high_c - low_c)))
|
|
336
|
+
|
|
337
|
+
self._lst_cache[coord_key] = normalized
|
|
338
|
+
return normalized
|
|
339
|
+
|
|
340
|
+
def sample_greenery(self, lon: float, lat: float) -> Optional[float]:
|
|
341
|
+
"""Sample green tree canopy / NDVI (0.0 = bare/concrete, 1.0 = lush canopy).
|
|
342
|
+
|
|
343
|
+
NDVI rasters in the native -1..1 range are rescaled to 0..1; rasters
|
|
344
|
+
already stored as 0..1 fractions pass through unchanged.
|
|
345
|
+
|
|
346
|
+
Returns None when no greenery raster is configured -- see sample_lst.
|
|
347
|
+
"""
|
|
348
|
+
if not math.isfinite(lon) or not math.isfinite(lat):
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
coord_key = (round(lon, 5), round(lat, 5))
|
|
352
|
+
if coord_key in self._green_cache:
|
|
353
|
+
return self._green_cache[coord_key]
|
|
354
|
+
|
|
355
|
+
normalized: Optional[float] = None
|
|
356
|
+
raw = self._sample_raster(self.green_layer, lon, lat)
|
|
357
|
+
if raw is not None:
|
|
358
|
+
# NDVI is defined on -1..1; anything below zero is water or bare rock.
|
|
359
|
+
normalized = max(0.0, min(1.0, raw))
|
|
360
|
+
|
|
361
|
+
self._green_cache[coord_key] = normalized
|
|
362
|
+
return normalized
|
|
363
|
+
|
|
364
|
+
def _additional_layer_range(self, layer: Any) -> Optional[Tuple[float, float]]:
|
|
365
|
+
"""Read a raster's real min/max statistics once for normalization."""
|
|
366
|
+
try:
|
|
367
|
+
layer_id = str(layer.id())
|
|
368
|
+
except Exception:
|
|
369
|
+
layer_id = str(id(layer))
|
|
370
|
+
if layer_id in self._additional_range_cache:
|
|
371
|
+
return self._additional_range_cache[layer_id]
|
|
372
|
+
|
|
373
|
+
if isinstance(layer, GeoTiffRasterSource):
|
|
374
|
+
with contextlib.suppress(Exception):
|
|
375
|
+
value_range = layer.value_range()
|
|
376
|
+
if value_range is not None:
|
|
377
|
+
self._additional_range_cache[layer_id] = value_range
|
|
378
|
+
return value_range
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
with contextlib.suppress(Exception):
|
|
382
|
+
from qgis.core import QgsRasterBandStats
|
|
383
|
+
|
|
384
|
+
flags = QgsRasterBandStats.Min | QgsRasterBandStats.Max
|
|
385
|
+
stats = layer.dataProvider().bandStatistics(1, flags, layer.extent(), 0)
|
|
386
|
+
minimum = float(stats.minimumValue)
|
|
387
|
+
maximum = float(stats.maximumValue)
|
|
388
|
+
if math.isfinite(minimum) and math.isfinite(maximum) and maximum > minimum:
|
|
389
|
+
self._additional_range_cache[layer_id] = (minimum, maximum)
|
|
390
|
+
return minimum, maximum
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
def sample_additional_resistance(self, lon: float, lat: float) -> List[float]:
|
|
394
|
+
"""Sample every configured MCDA raster and normalize each to 0..1.
|
|
395
|
+
|
|
396
|
+
Invalid or unavailable layers are skipped explicitly; they never become
|
|
397
|
+
an invented 0.5 surface and therefore cannot silently affect routing.
|
|
398
|
+
"""
|
|
399
|
+
if not (math.isfinite(lon) and math.isfinite(lat)):
|
|
400
|
+
return []
|
|
401
|
+
values: List[float] = []
|
|
402
|
+
for layer in self.additional_layers:
|
|
403
|
+
raw_value = self._sample_raster(layer, lon, lat)
|
|
404
|
+
if raw_value is None:
|
|
405
|
+
continue
|
|
406
|
+
value_range = self._additional_layer_range(layer)
|
|
407
|
+
if value_range is None:
|
|
408
|
+
continue
|
|
409
|
+
minimum, maximum = value_range
|
|
410
|
+
values.append(max(0.0, min(1.0, (raw_value - minimum) / (maximum - minimum))))
|
|
411
|
+
return values
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Emergency evacuation, hazard avoidance, and egress capacity routing engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from .kinematics import haversine_distance_2d
|
|
10
|
+
from .routing_engine import RouteResult3D, RoutingEngine3D, Waypoint
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EvacuationRoutingError(ValueError):
|
|
14
|
+
"""Raised when an evacuation request has no valid destination or route."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class HazardZone:
|
|
19
|
+
"""Geographic hazard polygon or circular danger zone with risk penalty."""
|
|
20
|
+
|
|
21
|
+
center_lon: float
|
|
22
|
+
center_lat: float
|
|
23
|
+
radius_m: float
|
|
24
|
+
severity_multiplier: float = 100.0 # Cost multiplier when inside hazard buffer
|
|
25
|
+
hazard_type: str = "Flood / Debris"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class EvacuationPlan:
|
|
30
|
+
"""Optimal emergency evacuation route to nearest muster zone."""
|
|
31
|
+
|
|
32
|
+
muster_point: Waypoint
|
|
33
|
+
route_result: RouteResult3D
|
|
34
|
+
hazard_clearance_dist_m: float
|
|
35
|
+
egress_time_min: float
|
|
36
|
+
risk_score: float
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"muster_point": {
|
|
41
|
+
"name": self.muster_point.name,
|
|
42
|
+
"lon": self.muster_point.lon,
|
|
43
|
+
"lat": self.muster_point.lat,
|
|
44
|
+
},
|
|
45
|
+
"distance_km": self.route_result.statistics.total_distance_km,
|
|
46
|
+
"egress_time_min": round(self.egress_time_min, 1),
|
|
47
|
+
"hazard_clearance_dist_m": round(self.hazard_clearance_dist_m, 1),
|
|
48
|
+
"risk_score": round(self.risk_score, 1),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EvacuationRouter:
|
|
53
|
+
"""Computes least-risk evacuation routes avoiding dynamic hazards to designated muster points."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, routing_engine: RoutingEngine3D) -> None:
|
|
56
|
+
self.engine = routing_engine
|
|
57
|
+
self.hazards: List[HazardZone] = []
|
|
58
|
+
|
|
59
|
+
def add_hazard_zone(
|
|
60
|
+
self, lon: float, lat: float, radius_m: float, hazard_type: str = "Debris"
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Register a dynamic hazard zone."""
|
|
63
|
+
if not math.isfinite(float(lon)) or not math.isfinite(float(lat)):
|
|
64
|
+
raise EvacuationRoutingError("Hazard coordinates must be finite numbers.")
|
|
65
|
+
radius = float(radius_m)
|
|
66
|
+
if not math.isfinite(radius) or radius <= 0:
|
|
67
|
+
raise EvacuationRoutingError("Hazard radius must be greater than zero.")
|
|
68
|
+
self.hazards.append(HazardZone(lon, lat, radius, hazard_type=hazard_type))
|
|
69
|
+
|
|
70
|
+
def calculate_evacuation_route(
|
|
71
|
+
self,
|
|
72
|
+
origin: Waypoint,
|
|
73
|
+
muster_points: Sequence[Waypoint],
|
|
74
|
+
profile_key: str = "adult",
|
|
75
|
+
) -> EvacuationPlan:
|
|
76
|
+
"""Find the safest evacuation path to the best accessible muster point."""
|
|
77
|
+
if not muster_points:
|
|
78
|
+
raise EvacuationRoutingError(
|
|
79
|
+
"At least one muster point is required for evacuation routing."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
best_plan: Optional[EvacuationPlan] = None
|
|
83
|
+
min_risk_cost = float("inf")
|
|
84
|
+
|
|
85
|
+
for muster in muster_points:
|
|
86
|
+
res = self.engine.calculate_route(
|
|
87
|
+
[origin, muster], profile_key=profile_key, compute_alternatives=False
|
|
88
|
+
)
|
|
89
|
+
if not res.coordinates_3d or not res.is_network_matched:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# Evaluate distance to hazards along the computed path
|
|
93
|
+
min_hazard_dist = float("inf")
|
|
94
|
+
risk_penalty = 0.0
|
|
95
|
+
|
|
96
|
+
for pt in res.coordinates_3d:
|
|
97
|
+
for h in self.hazards:
|
|
98
|
+
d = haversine_distance_2d((pt[0], pt[1]), (h.center_lon, h.center_lat))
|
|
99
|
+
min_hazard_dist = min(min_hazard_dist, d)
|
|
100
|
+
if d < h.radius_m:
|
|
101
|
+
risk_penalty += (h.radius_m - d) * h.severity_multiplier
|
|
102
|
+
|
|
103
|
+
total_cost = res.statistics.total_duration_min + (risk_penalty / 60.0)
|
|
104
|
+
|
|
105
|
+
if total_cost < min_risk_cost:
|
|
106
|
+
min_risk_cost = total_cost
|
|
107
|
+
best_plan = EvacuationPlan(
|
|
108
|
+
muster_point=muster,
|
|
109
|
+
route_result=res,
|
|
110
|
+
hazard_clearance_dist_m=min_hazard_dist
|
|
111
|
+
if min_hazard_dist != float("inf")
|
|
112
|
+
else 9999.0,
|
|
113
|
+
egress_time_min=res.statistics.total_duration_min,
|
|
114
|
+
risk_score=risk_penalty,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if best_plan is None:
|
|
118
|
+
raise EvacuationRoutingError(
|
|
119
|
+
"No connected evacuation route was found to any muster point."
|
|
120
|
+
)
|
|
121
|
+
return best_plan
|