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,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