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,517 @@
|
|
|
1
|
+
"""Mobility profiles catalog and parameter specifications for 3D route planning.
|
|
2
|
+
|
|
3
|
+
Provides 15 specialized profiles across pedestrian, accessibility, micromobility,
|
|
4
|
+
and vehicular transport modes, with custom profile building and JSON preset export.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Union
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class MobilityProfile:
|
|
18
|
+
"""Configuration and physiological/kinematic constraints for a mobility mode."""
|
|
19
|
+
|
|
20
|
+
key: str
|
|
21
|
+
name: str
|
|
22
|
+
category: str # 'pedestrian', 'micromobility', 'vehicle'
|
|
23
|
+
base_speed_kmh: float
|
|
24
|
+
max_slope_pct: float # Slope beyond which path is strictly impassable or heavily penalised
|
|
25
|
+
stair_allowed: bool
|
|
26
|
+
stair_penalty: float # Resistance multiplier for stairs (1.0 = normal, 100.0+ = forbidden)
|
|
27
|
+
slope_sensitivity: float # Exponent/multiplier for slope resistance
|
|
28
|
+
heat_sensitivity: float # 0.0 to 1.0: priority on avoiding high surface temperature (LST)
|
|
29
|
+
green_preference: float # 0.0 to 1.0: bonus for tree shade and greenery
|
|
30
|
+
surface_smoothness_req: float # 0.0 (all-terrain) to 1.0 (requires smooth paved surface)
|
|
31
|
+
hierarchy_weights: Dict[int, float] = field(
|
|
32
|
+
default_factory=dict
|
|
33
|
+
) # Hierarchy rank (1-5) multipliers
|
|
34
|
+
description: str = ""
|
|
35
|
+
icon_name: str = "adult"
|
|
36
|
+
|
|
37
|
+
def calculate_edge_resistance(
|
|
38
|
+
self,
|
|
39
|
+
length_m: float,
|
|
40
|
+
slope_pct: float,
|
|
41
|
+
is_steps: bool = False,
|
|
42
|
+
surface_quality: float = 0.8,
|
|
43
|
+
hierarchy_rank: int = 4,
|
|
44
|
+
lst_normalized: Optional[float] = None,
|
|
45
|
+
green_normalized: Optional[float] = None,
|
|
46
|
+
custom_weights: Optional[Dict[str, float]] = None,
|
|
47
|
+
) -> float:
|
|
48
|
+
"""Calculate generalized impedance (cost) for traversing a segment under this profile."""
|
|
49
|
+
len_m = float(length_m) if math.isfinite(length_m) and length_m > 0 else 0.01
|
|
50
|
+
s_pct = float(slope_pct) if math.isfinite(slope_pct) else 0.0
|
|
51
|
+
sq = max(0.0, min(1.0, float(surface_quality))) if math.isfinite(surface_quality) else 0.8
|
|
52
|
+
# A missing environmental raster is *absent*, not average. Substituting a
|
|
53
|
+
# mid-range constant would silently shift every edge cost, so None keeps the
|
|
54
|
+
# thermal term exactly neutral instead.
|
|
55
|
+
lst = (
|
|
56
|
+
max(0.0, min(1.0, float(lst_normalized)))
|
|
57
|
+
if lst_normalized is not None and math.isfinite(lst_normalized)
|
|
58
|
+
else None
|
|
59
|
+
)
|
|
60
|
+
grn = (
|
|
61
|
+
max(0.0, min(1.0, float(green_normalized)))
|
|
62
|
+
if green_normalized is not None and math.isfinite(green_normalized)
|
|
63
|
+
else None
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
weights = custom_weights or {}
|
|
67
|
+
w_slope = weights.get("slope", self.slope_sensitivity)
|
|
68
|
+
w_heat = weights.get("heat", self.heat_sensitivity)
|
|
69
|
+
w_green = weights.get("green", self.green_preference)
|
|
70
|
+
|
|
71
|
+
w_slope = (
|
|
72
|
+
float(w_slope) if math.isfinite(w_slope) and w_slope >= 0 else self.slope_sensitivity
|
|
73
|
+
)
|
|
74
|
+
w_heat = float(w_heat) if math.isfinite(w_heat) and w_heat >= 0 else self.heat_sensitivity
|
|
75
|
+
w_green = (
|
|
76
|
+
float(w_green) if math.isfinite(w_green) and w_green >= 0 else self.green_preference
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 1. Slope Penalty
|
|
80
|
+
abs_slope = abs(s_pct)
|
|
81
|
+
max_s = max(1.0, self.max_slope_pct) if math.isfinite(self.max_slope_pct) else 25.0
|
|
82
|
+
if abs_slope > max_s:
|
|
83
|
+
if self.category != "pedestrian":
|
|
84
|
+
return float("inf")
|
|
85
|
+
slope_mult = 1.0 + (abs_slope / max_s) ** 3 * 20.0 * w_slope
|
|
86
|
+
else:
|
|
87
|
+
slope_mult = 1.0 + (abs_slope / 10.0) * w_slope * 1.5
|
|
88
|
+
|
|
89
|
+
# 2. Steps / Stairs Penalty
|
|
90
|
+
if is_steps:
|
|
91
|
+
if not self.stair_allowed:
|
|
92
|
+
return float("inf")
|
|
93
|
+
stair_mult = max(1.0, float(self.stair_penalty))
|
|
94
|
+
else:
|
|
95
|
+
stair_mult = 1.0
|
|
96
|
+
|
|
97
|
+
# 3. Surface Smoothness Penalty
|
|
98
|
+
if sq < self.surface_smoothness_req:
|
|
99
|
+
roughness_gap = self.surface_smoothness_req - sq
|
|
100
|
+
smooth_mult = 1.0 + roughness_gap * 4.0
|
|
101
|
+
else:
|
|
102
|
+
smooth_mult = 1.0
|
|
103
|
+
|
|
104
|
+
# 4. Thermal & Environmental Microclimate Resistance
|
|
105
|
+
if lst is None and grn is None:
|
|
106
|
+
thermal_mult = 1.0
|
|
107
|
+
else:
|
|
108
|
+
thermal_cost = 1.0
|
|
109
|
+
if lst is not None:
|
|
110
|
+
thermal_cost += lst * w_heat * 1.8
|
|
111
|
+
if grn is not None:
|
|
112
|
+
thermal_cost -= grn * w_green * 0.4
|
|
113
|
+
thermal_mult = max(0.6, thermal_cost)
|
|
114
|
+
|
|
115
|
+
# 5. Road Hierarchy Multiplier
|
|
116
|
+
hier_mult = self.hierarchy_weights.get(hierarchy_rank, 1.0)
|
|
117
|
+
if not math.isfinite(hier_mult) or hier_mult <= 0:
|
|
118
|
+
hier_mult = 1.0
|
|
119
|
+
|
|
120
|
+
cost = len_m * slope_mult * stair_mult * smooth_mult * thermal_mult * hier_mult
|
|
121
|
+
if math.isinf(cost):
|
|
122
|
+
return float("inf")
|
|
123
|
+
if not math.isfinite(cost) or cost <= 0:
|
|
124
|
+
return 0.01
|
|
125
|
+
return max(0.01, cost)
|
|
126
|
+
|
|
127
|
+
def travel_time_seconds(
|
|
128
|
+
self,
|
|
129
|
+
length_m: float,
|
|
130
|
+
slope_pct: float = 0.0,
|
|
131
|
+
hierarchy_rank: int = 4,
|
|
132
|
+
) -> float:
|
|
133
|
+
"""Estimate physically consistent travel time for one network edge."""
|
|
134
|
+
from .kinematics import (
|
|
135
|
+
cyclist_speed,
|
|
136
|
+
scooter_speed,
|
|
137
|
+
tobler_walking_speed,
|
|
138
|
+
vehicle_free_flow_speed,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
length = float(length_m) if math.isfinite(length_m) and length_m >= 0 else 0.0
|
|
142
|
+
slope = float(slope_pct) if math.isfinite(slope_pct) else 0.0
|
|
143
|
+
slope_fraction = slope / 100.0
|
|
144
|
+
if self.category == "pedestrian":
|
|
145
|
+
speed_kmh = tobler_walking_speed(slope_fraction, self.base_speed_kmh)
|
|
146
|
+
elif self.key in {"bicycle", "mtb"}:
|
|
147
|
+
speed_kmh = cyclist_speed(slope_fraction, self.base_speed_kmh)
|
|
148
|
+
elif self.key == "scooter":
|
|
149
|
+
speed_kmh = scooter_speed(slope_fraction, self.base_speed_kmh)
|
|
150
|
+
else:
|
|
151
|
+
speed_kmh = vehicle_free_flow_speed(
|
|
152
|
+
hierarchy_rank, slope_pct=slope, base_vehicle_speed_kmh=self.base_speed_kmh
|
|
153
|
+
)
|
|
154
|
+
speed_mps = max(0.2, speed_kmh * 1000.0 / 3600.0)
|
|
155
|
+
return length / speed_mps
|
|
156
|
+
|
|
157
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
158
|
+
return asdict(self)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_dict(cls, data: Dict[str, Any]) -> MobilityProfile:
|
|
162
|
+
return cls(**data)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# -------------------------------------------------------------------------
|
|
166
|
+
# 15 Standard & Specialized Mobility Profiles
|
|
167
|
+
# -------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
PROFILES: Dict[str, MobilityProfile] = {
|
|
170
|
+
"adult": MobilityProfile(
|
|
171
|
+
key="adult",
|
|
172
|
+
name="Standard Adult",
|
|
173
|
+
category="pedestrian",
|
|
174
|
+
base_speed_kmh=5.0,
|
|
175
|
+
max_slope_pct=25.0,
|
|
176
|
+
stair_allowed=True,
|
|
177
|
+
stair_penalty=1.2,
|
|
178
|
+
slope_sensitivity=1.0,
|
|
179
|
+
heat_sensitivity=0.4,
|
|
180
|
+
green_preference=0.4,
|
|
181
|
+
surface_smoothness_req=0.2,
|
|
182
|
+
hierarchy_weights={1: 2.5, 2: 1.8, 3: 1.2, 4: 1.0, 5: 0.9},
|
|
183
|
+
description="Standard adult pedestrian with normal walking speed and moderate slope tolerance.",
|
|
184
|
+
icon_name="adult",
|
|
185
|
+
),
|
|
186
|
+
"senior": MobilityProfile(
|
|
187
|
+
key="senior",
|
|
188
|
+
name="Senior / Elderly",
|
|
189
|
+
category="pedestrian",
|
|
190
|
+
base_speed_kmh=3.2,
|
|
191
|
+
max_slope_pct=10.0,
|
|
192
|
+
stair_allowed=True,
|
|
193
|
+
stair_penalty=8.0,
|
|
194
|
+
slope_sensitivity=2.8,
|
|
195
|
+
heat_sensitivity=0.85,
|
|
196
|
+
green_preference=0.9,
|
|
197
|
+
surface_smoothness_req=0.6,
|
|
198
|
+
hierarchy_weights={1: 4.0, 2: 2.5, 3: 1.5, 4: 1.0, 5: 0.8},
|
|
199
|
+
description="Reduced walking pace, high slope avoidance, strong preference for shaded and cool paths.",
|
|
200
|
+
icon_name="senior",
|
|
201
|
+
),
|
|
202
|
+
"child": MobilityProfile(
|
|
203
|
+
key="child",
|
|
204
|
+
name="Child / Safe Walk",
|
|
205
|
+
category="pedestrian",
|
|
206
|
+
base_speed_kmh=3.5,
|
|
207
|
+
max_slope_pct=12.0,
|
|
208
|
+
stair_allowed=True,
|
|
209
|
+
stair_penalty=2.0,
|
|
210
|
+
slope_sensitivity=1.5,
|
|
211
|
+
heat_sensitivity=0.7,
|
|
212
|
+
green_preference=0.8,
|
|
213
|
+
surface_smoothness_req=0.4,
|
|
214
|
+
hierarchy_weights={1: 8.0, 2: 4.0, 3: 1.8, 4: 1.0, 5: 0.7},
|
|
215
|
+
description="Prioritizes quiet residential streets, green parks, and avoids heavy arterial traffic.",
|
|
216
|
+
icon_name="child",
|
|
217
|
+
),
|
|
218
|
+
"stroller": MobilityProfile(
|
|
219
|
+
key="stroller",
|
|
220
|
+
name="Stroller / Pram",
|
|
221
|
+
category="pedestrian",
|
|
222
|
+
base_speed_kmh=4.0,
|
|
223
|
+
max_slope_pct=6.0,
|
|
224
|
+
stair_allowed=False,
|
|
225
|
+
stair_penalty=100.0,
|
|
226
|
+
slope_sensitivity=3.0,
|
|
227
|
+
heat_sensitivity=0.6,
|
|
228
|
+
green_preference=0.7,
|
|
229
|
+
surface_smoothness_req=0.8,
|
|
230
|
+
hierarchy_weights={1: 3.5, 2: 2.0, 3: 1.3, 4: 1.0, 5: 0.9},
|
|
231
|
+
description="Strictly avoids steps; enforces smooth pavement and gentle ramps (max 6% grade).",
|
|
232
|
+
icon_name="stroller",
|
|
233
|
+
),
|
|
234
|
+
"wheelchair": MobilityProfile(
|
|
235
|
+
key="wheelchair",
|
|
236
|
+
name="Wheelchair / Barrier-Free",
|
|
237
|
+
category="pedestrian",
|
|
238
|
+
base_speed_kmh=3.8,
|
|
239
|
+
max_slope_pct=5.0,
|
|
240
|
+
stair_allowed=False,
|
|
241
|
+
stair_penalty=1000.0,
|
|
242
|
+
slope_sensitivity=4.0,
|
|
243
|
+
heat_sensitivity=0.6,
|
|
244
|
+
green_preference=0.6,
|
|
245
|
+
surface_smoothness_req=0.9,
|
|
246
|
+
hierarchy_weights={1: 3.0, 2: 1.8, 3: 1.2, 4: 1.0, 5: 0.9},
|
|
247
|
+
description="Strict universal accessibility: zero stairs, max 5% slope tolerance, smooth continuous surfaces.",
|
|
248
|
+
icon_name="wheelchair",
|
|
249
|
+
),
|
|
250
|
+
"jogger": MobilityProfile(
|
|
251
|
+
key="jogger",
|
|
252
|
+
name="Runner / Fitness Jogger",
|
|
253
|
+
category="pedestrian",
|
|
254
|
+
base_speed_kmh=9.5,
|
|
255
|
+
max_slope_pct=20.0,
|
|
256
|
+
stair_allowed=True,
|
|
257
|
+
stair_penalty=1.5,
|
|
258
|
+
slope_sensitivity=0.8,
|
|
259
|
+
heat_sensitivity=0.6,
|
|
260
|
+
green_preference=0.85,
|
|
261
|
+
surface_smoothness_req=0.3,
|
|
262
|
+
hierarchy_weights={1: 6.0, 2: 3.0, 3: 1.5, 4: 1.0, 5: 0.7},
|
|
263
|
+
description="Running pace, seeks park pathways, tree shade, and undulating terrain for exercise.",
|
|
264
|
+
icon_name="jogger",
|
|
265
|
+
),
|
|
266
|
+
"bicycle": MobilityProfile(
|
|
267
|
+
key="bicycle",
|
|
268
|
+
name="City & Commuter Bicycle",
|
|
269
|
+
category="micromobility",
|
|
270
|
+
base_speed_kmh=18.0,
|
|
271
|
+
max_slope_pct=15.0,
|
|
272
|
+
stair_allowed=False,
|
|
273
|
+
stair_penalty=50.0,
|
|
274
|
+
slope_sensitivity=1.8,
|
|
275
|
+
heat_sensitivity=0.3,
|
|
276
|
+
green_preference=0.5,
|
|
277
|
+
surface_smoothness_req=0.6,
|
|
278
|
+
hierarchy_weights={1: 10.0, 2: 1.6, 3: 1.1, 4: 0.9, 5: 1.0},
|
|
279
|
+
description="Grade-aware cycling routing prioritizing dedicated paths, secondary streets, and moderate slopes.",
|
|
280
|
+
icon_name="bicycle",
|
|
281
|
+
),
|
|
282
|
+
"mtb": MobilityProfile(
|
|
283
|
+
key="mtb",
|
|
284
|
+
name="Mountain / Gravel Bike",
|
|
285
|
+
category="micromobility",
|
|
286
|
+
base_speed_kmh=16.0,
|
|
287
|
+
max_slope_pct=28.0,
|
|
288
|
+
stair_allowed=True,
|
|
289
|
+
stair_penalty=5.0,
|
|
290
|
+
slope_sensitivity=0.9,
|
|
291
|
+
heat_sensitivity=0.2,
|
|
292
|
+
green_preference=0.8,
|
|
293
|
+
surface_smoothness_req=0.1,
|
|
294
|
+
hierarchy_weights={1: 15.0, 2: 2.0, 3: 1.2, 4: 0.9, 5: 0.6},
|
|
295
|
+
description="All-terrain offroad cycling, tolerates steep trails, dirt paths, and steps.",
|
|
296
|
+
icon_name="mtb",
|
|
297
|
+
),
|
|
298
|
+
"scooter": MobilityProfile(
|
|
299
|
+
key="scooter",
|
|
300
|
+
name="E-Scooter / Micromobility",
|
|
301
|
+
category="micromobility",
|
|
302
|
+
base_speed_kmh=20.0,
|
|
303
|
+
max_slope_pct=12.0,
|
|
304
|
+
stair_allowed=False,
|
|
305
|
+
stair_penalty=100.0,
|
|
306
|
+
slope_sensitivity=2.5,
|
|
307
|
+
heat_sensitivity=0.3,
|
|
308
|
+
green_preference=0.3,
|
|
309
|
+
surface_smoothness_req=0.85,
|
|
310
|
+
hierarchy_weights={1: 10.0, 2: 2.0, 3: 1.2, 4: 0.9, 5: 1.0},
|
|
311
|
+
description="Requires smooth pavement and low curb transitions; limits climbing angles to motor torque bounds.",
|
|
312
|
+
icon_name="scooter",
|
|
313
|
+
),
|
|
314
|
+
"car": MobilityProfile(
|
|
315
|
+
key="car",
|
|
316
|
+
name="Passenger Car",
|
|
317
|
+
category="vehicle",
|
|
318
|
+
base_speed_kmh=50.0,
|
|
319
|
+
max_slope_pct=25.0,
|
|
320
|
+
stair_allowed=False,
|
|
321
|
+
stair_penalty=1000.0,
|
|
322
|
+
slope_sensitivity=0.5,
|
|
323
|
+
heat_sensitivity=0.0,
|
|
324
|
+
green_preference=0.0,
|
|
325
|
+
surface_smoothness_req=0.5,
|
|
326
|
+
hierarchy_weights={1: 0.6, 2: 0.8, 3: 1.0, 4: 1.5, 5: 2.5},
|
|
327
|
+
description="Vehicular routing prioritizing high-capacity arterials and motorways with realistic speed hierarchy.",
|
|
328
|
+
icon_name="car",
|
|
329
|
+
),
|
|
330
|
+
"delivery_van": MobilityProfile(
|
|
331
|
+
key="delivery_van",
|
|
332
|
+
name="Delivery Courier Van",
|
|
333
|
+
category="vehicle",
|
|
334
|
+
base_speed_kmh=42.0,
|
|
335
|
+
max_slope_pct=18.0,
|
|
336
|
+
stair_allowed=False,
|
|
337
|
+
stair_penalty=1000.0,
|
|
338
|
+
slope_sensitivity=0.9,
|
|
339
|
+
heat_sensitivity=0.0,
|
|
340
|
+
green_preference=0.0,
|
|
341
|
+
surface_smoothness_req=0.6,
|
|
342
|
+
hierarchy_weights={1: 0.7, 2: 0.8, 3: 1.0, 4: 1.2, 5: 2.0},
|
|
343
|
+
description="Last-mile courier and delivery logistics prioritizing navigable streets and efficient access.",
|
|
344
|
+
icon_name="delivery_van",
|
|
345
|
+
),
|
|
346
|
+
"truck": MobilityProfile(
|
|
347
|
+
key="truck",
|
|
348
|
+
name="Heavy Goods / Logistics Truck",
|
|
349
|
+
category="vehicle",
|
|
350
|
+
base_speed_kmh=40.0,
|
|
351
|
+
max_slope_pct=7.0,
|
|
352
|
+
stair_allowed=False,
|
|
353
|
+
stair_penalty=1000.0,
|
|
354
|
+
slope_sensitivity=3.5,
|
|
355
|
+
heat_sensitivity=0.0,
|
|
356
|
+
green_preference=0.0,
|
|
357
|
+
surface_smoothness_req=0.7,
|
|
358
|
+
hierarchy_weights={1: 0.5, 2: 0.7, 3: 1.2, 4: 3.0, 5: 10.0},
|
|
359
|
+
description="Heavy commercial vehicles: strict 7% grade limit, wide turning radii, avoids narrow urban corridors.",
|
|
360
|
+
icon_name="truck",
|
|
361
|
+
),
|
|
362
|
+
"paramedic": MobilityProfile(
|
|
363
|
+
key="paramedic",
|
|
364
|
+
name="Emergency / First Responder",
|
|
365
|
+
category="vehicle",
|
|
366
|
+
base_speed_kmh=65.0,
|
|
367
|
+
max_slope_pct=22.0,
|
|
368
|
+
stair_allowed=False,
|
|
369
|
+
stair_penalty=1000.0,
|
|
370
|
+
slope_sensitivity=0.4,
|
|
371
|
+
heat_sensitivity=0.0,
|
|
372
|
+
green_preference=0.0,
|
|
373
|
+
surface_smoothness_req=0.4,
|
|
374
|
+
hierarchy_weights={1: 0.5, 2: 0.6, 3: 0.8, 4: 1.0, 5: 1.5},
|
|
375
|
+
description="Fastest emergency response routing across major corridors with priority access.",
|
|
376
|
+
icon_name="paramedic",
|
|
377
|
+
),
|
|
378
|
+
"sightseer": MobilityProfile(
|
|
379
|
+
key="sightseer",
|
|
380
|
+
name="Scenic & Panoramic Walk",
|
|
381
|
+
category="pedestrian",
|
|
382
|
+
base_speed_kmh=4.2,
|
|
383
|
+
max_slope_pct=20.0,
|
|
384
|
+
stair_allowed=True,
|
|
385
|
+
stair_penalty=1.0,
|
|
386
|
+
slope_sensitivity=0.5,
|
|
387
|
+
heat_sensitivity=0.4,
|
|
388
|
+
green_preference=1.0,
|
|
389
|
+
surface_smoothness_req=0.3,
|
|
390
|
+
hierarchy_weights={1: 8.0, 2: 4.0, 3: 2.0, 4: 1.0, 5: 0.6},
|
|
391
|
+
description="Tourist and leisure walking prioritizing viewpoint ridges, historic alleys, and parks.",
|
|
392
|
+
icon_name="sightseer",
|
|
393
|
+
),
|
|
394
|
+
"night_walk": MobilityProfile(
|
|
395
|
+
key="night_walk",
|
|
396
|
+
name="Safe & Illuminated Night Walk",
|
|
397
|
+
category="pedestrian",
|
|
398
|
+
base_speed_kmh=4.8,
|
|
399
|
+
max_slope_pct=15.0,
|
|
400
|
+
stair_allowed=True,
|
|
401
|
+
stair_penalty=3.0,
|
|
402
|
+
slope_sensitivity=1.2,
|
|
403
|
+
heat_sensitivity=0.0,
|
|
404
|
+
green_preference=0.2,
|
|
405
|
+
surface_smoothness_req=0.7,
|
|
406
|
+
hierarchy_weights={1: 1.2, 2: 1.0, 3: 1.0, 4: 1.4, 5: 4.0},
|
|
407
|
+
description="Night safety profile staying on lit, active main urban streets and avoiding isolated dark paths.",
|
|
408
|
+
icon_name="night_walk",
|
|
409
|
+
),
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
PROFILE_CATEGORIES: Dict[str, Dict[str, Any]] = {
|
|
414
|
+
"pedestrian": {
|
|
415
|
+
"title": "Pedestrian & Active Walking",
|
|
416
|
+
"icon": "🚶",
|
|
417
|
+
"keys": ["adult", "senior", "child", "jogger", "sightseer", "night_walk"],
|
|
418
|
+
},
|
|
419
|
+
"accessibility": {
|
|
420
|
+
"title": "Barrier-Free & Universal Access",
|
|
421
|
+
"icon": "♿",
|
|
422
|
+
"keys": ["wheelchair", "stroller"],
|
|
423
|
+
},
|
|
424
|
+
"micromobility": {
|
|
425
|
+
"title": "Micromobility & Cycling",
|
|
426
|
+
"icon": "🚲",
|
|
427
|
+
"keys": ["bicycle", "mtb", "scooter"],
|
|
428
|
+
},
|
|
429
|
+
"vehicle": {
|
|
430
|
+
"title": "Vehicular & Logistics",
|
|
431
|
+
"icon": "🚗",
|
|
432
|
+
"keys": ["car", "delivery_van", "truck", "paramedic"],
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
PROFILE_COLORS: Dict[str, str] = {
|
|
437
|
+
"adult": "#0284c7",
|
|
438
|
+
"senior": "#059669",
|
|
439
|
+
"child": "#f59e0b",
|
|
440
|
+
"jogger": "#10b981",
|
|
441
|
+
"sightseer": "#84cc16",
|
|
442
|
+
"night_walk": "#6366f1",
|
|
443
|
+
"wheelchair": "#8b5cf6",
|
|
444
|
+
"stroller": "#d946ef",
|
|
445
|
+
"bicycle": "#06b6d4",
|
|
446
|
+
"mtb": "#14b8a6",
|
|
447
|
+
"scooter": "#3b82f6",
|
|
448
|
+
"car": "#ef4444",
|
|
449
|
+
"delivery_van": "#f97316",
|
|
450
|
+
"truck": "#78716c",
|
|
451
|
+
"paramedic": "#e11d48",
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class UnknownProfileError(KeyError):
|
|
456
|
+
"""Raised when a profile key does not match any registered mobility profile."""
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def get_profile(key: str, strict: bool = False) -> MobilityProfile:
|
|
460
|
+
"""Retrieve a profile by key.
|
|
461
|
+
|
|
462
|
+
With ``strict=False`` (the default, kept for backwards compatibility) an
|
|
463
|
+
unknown key falls back to 'adult'. With ``strict=True`` it raises
|
|
464
|
+
:class:`UnknownProfileError` instead -- which is what the high-level API uses,
|
|
465
|
+
so that a typo like ``profile="bicycle "`` cannot silently route a truck as a
|
|
466
|
+
pedestrian.
|
|
467
|
+
"""
|
|
468
|
+
resolved = PROFILES.get(str(key).strip().lower())
|
|
469
|
+
if resolved is not None:
|
|
470
|
+
return resolved
|
|
471
|
+
if strict:
|
|
472
|
+
raise UnknownProfileError(
|
|
473
|
+
f"Unknown mobility profile {key!r}. Valid keys: {', '.join(list_profile_keys())}"
|
|
474
|
+
)
|
|
475
|
+
return PROFILES["adult"]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def resolve_profile(profile: Union[str, MobilityProfile], strict: bool = False) -> MobilityProfile:
|
|
479
|
+
"""Accept either a profile key or a ready-made :class:`MobilityProfile`.
|
|
480
|
+
|
|
481
|
+
Custom profiles built in user code are returned untouched, so they are no
|
|
482
|
+
longer silently replaced by the built-in 'adult' profile when passed to an
|
|
483
|
+
engine that used to take only a key.
|
|
484
|
+
"""
|
|
485
|
+
if isinstance(profile, MobilityProfile):
|
|
486
|
+
return profile
|
|
487
|
+
return get_profile(str(profile), strict=strict)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def get_profile_color(key: str) -> str:
|
|
491
|
+
"""Return the designated HEX color for a profile key."""
|
|
492
|
+
return PROFILE_COLORS.get(key.lower(), "#0ea5e9")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def list_profile_keys() -> List[str]:
|
|
496
|
+
"""Return all registered profile keys."""
|
|
497
|
+
return list(PROFILES.keys())
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def list_profile_keys_for_group(group_key: str) -> List[str]:
|
|
501
|
+
"""Return profile keys belonging to a category group ('all', 'pedestrian', 'accessibility', 'micromobility', 'vehicle')."""
|
|
502
|
+
if group_key.lower() == "all":
|
|
503
|
+
return list_profile_keys()
|
|
504
|
+
if group_key.lower() in PROFILE_CATEGORIES:
|
|
505
|
+
return PROFILE_CATEGORIES[group_key.lower()]["keys"]
|
|
506
|
+
return [group_key.lower()] if group_key.lower() in PROFILES else ["adult"]
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def save_custom_profile_json(profile: MobilityProfile, target_path: Path) -> None:
|
|
510
|
+
"""Save custom profile to JSON preset file."""
|
|
511
|
+
target_path.write_text(json.dumps(profile.to_dict(), indent=2), encoding="utf-8")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def load_custom_profile_json(source_path: Path) -> MobilityProfile:
|
|
515
|
+
"""Load custom profile from JSON preset file."""
|
|
516
|
+
data = json.loads(source_path.read_text(encoding="utf-8"))
|
|
517
|
+
return MobilityProfile.from_dict(data)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Multi-modal trip planning and intermodal route chaining engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, List, Sequence, Union
|
|
7
|
+
|
|
8
|
+
from .kinematics import haversine_distance_2d
|
|
9
|
+
from .mobility_profiles import MobilityProfile, resolve_profile
|
|
10
|
+
from .routing_engine import RouteResult3D, RoutingEngine3D, Waypoint
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MultiModalLeg:
|
|
15
|
+
"""Individual segment in a multi-modal journey (e.g. walk -> transit -> walk)."""
|
|
16
|
+
|
|
17
|
+
leg_index: int
|
|
18
|
+
mode_name: str
|
|
19
|
+
profile: MobilityProfile
|
|
20
|
+
start_point: Waypoint
|
|
21
|
+
end_point: Waypoint
|
|
22
|
+
route_result: RouteResult3D
|
|
23
|
+
transfer_penalty_min: float = 3.0
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"leg_index": self.leg_index,
|
|
28
|
+
"mode_name": self.mode_name,
|
|
29
|
+
"profile_name": self.profile.name,
|
|
30
|
+
"distance_km": self.route_result.statistics.total_distance_km,
|
|
31
|
+
"duration_min": self.route_result.statistics.total_duration_min,
|
|
32
|
+
"transfer_penalty_min": self.transfer_penalty_min,
|
|
33
|
+
"total_leg_time_min": round(
|
|
34
|
+
self.route_result.statistics.total_duration_min + self.transfer_penalty_min, 1
|
|
35
|
+
),
|
|
36
|
+
"climb_m": self.route_result.statistics.elevation_gain_m,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class MultiModalJourney:
|
|
42
|
+
"""Complete multi-modal chained trip with multiple transport modes."""
|
|
43
|
+
|
|
44
|
+
legs: List[MultiModalLeg]
|
|
45
|
+
total_distance_km: float
|
|
46
|
+
total_duration_min: float
|
|
47
|
+
total_climb_m: float
|
|
48
|
+
total_calories_kcal: float
|
|
49
|
+
transfer_count: int
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"total_distance_km": round(self.total_distance_km, 2),
|
|
54
|
+
"total_duration_min": round(self.total_duration_min, 1),
|
|
55
|
+
"total_climb_m": round(self.total_climb_m, 1),
|
|
56
|
+
"total_calories_kcal": round(self.total_calories_kcal, 0),
|
|
57
|
+
"transfer_count": self.transfer_count,
|
|
58
|
+
"legs": [leg.to_dict() for leg in self.legs],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MultiModalRouter:
|
|
63
|
+
"""Chains multiple transport modes across intermediate hubs and transit stops."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, routing_engine: RoutingEngine3D) -> None:
|
|
66
|
+
self.engine = routing_engine
|
|
67
|
+
|
|
68
|
+
def calculate_multimodal_trip(
|
|
69
|
+
self,
|
|
70
|
+
origin: Waypoint,
|
|
71
|
+
destination: Waypoint,
|
|
72
|
+
transit_hubs: Sequence[Waypoint],
|
|
73
|
+
access_mode: Union[str, MobilityProfile] = "adult",
|
|
74
|
+
main_mode: Union[str, MobilityProfile] = "bicycle",
|
|
75
|
+
egress_mode: Union[str, MobilityProfile] = "adult",
|
|
76
|
+
) -> MultiModalJourney:
|
|
77
|
+
"""Calculate a 3-leg journey: Access -> Main Transit/Bike -> Egress."""
|
|
78
|
+
if not transit_hubs or len(transit_hubs) < 2:
|
|
79
|
+
# Fallback to direct routing if no hubs
|
|
80
|
+
res = self.engine.calculate_route([origin, destination], profile_key=access_mode)
|
|
81
|
+
single_leg = MultiModalLeg(
|
|
82
|
+
leg_index=1,
|
|
83
|
+
mode_name="Direct Access",
|
|
84
|
+
profile=resolve_profile(access_mode),
|
|
85
|
+
start_point=origin,
|
|
86
|
+
end_point=destination,
|
|
87
|
+
route_result=res,
|
|
88
|
+
transfer_penalty_min=0.0,
|
|
89
|
+
)
|
|
90
|
+
return MultiModalJourney(
|
|
91
|
+
legs=[single_leg],
|
|
92
|
+
total_distance_km=res.statistics.total_distance_km,
|
|
93
|
+
total_duration_min=res.statistics.total_duration_min,
|
|
94
|
+
total_climb_m=res.statistics.elevation_gain_m,
|
|
95
|
+
total_calories_kcal=res.statistics.total_calories_kcal,
|
|
96
|
+
transfer_count=0,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Select the closest transit hub to the origin, then the closest *other*
|
|
100
|
+
# hub to the destination. Picking the nearest hub to each end independently
|
|
101
|
+
# can return the same hub twice, which produced a zero-length main leg that
|
|
102
|
+
# still charged a transfer penalty.
|
|
103
|
+
hub_access = min(
|
|
104
|
+
transit_hubs,
|
|
105
|
+
key=lambda h: haversine_distance_2d((origin.lon, origin.lat), (h.lon, h.lat)),
|
|
106
|
+
)
|
|
107
|
+
remaining_hubs = [h for h in transit_hubs if h is not hub_access]
|
|
108
|
+
hub_egress = min(
|
|
109
|
+
remaining_hubs,
|
|
110
|
+
key=lambda h: haversine_distance_2d((destination.lon, destination.lat), (h.lon, h.lat)),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
legs: List[MultiModalLeg] = []
|
|
114
|
+
|
|
115
|
+
# Leg 1: Walk to Hub
|
|
116
|
+
res1 = self.engine.calculate_route([origin, hub_access], profile_key=access_mode)
|
|
117
|
+
legs.append(
|
|
118
|
+
MultiModalLeg(
|
|
119
|
+
1,
|
|
120
|
+
"Access Walk",
|
|
121
|
+
resolve_profile(access_mode),
|
|
122
|
+
origin,
|
|
123
|
+
hub_access,
|
|
124
|
+
res1,
|
|
125
|
+
transfer_penalty_min=2.0,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Leg 2: Main Transit / Bike Leg
|
|
130
|
+
res2 = self.engine.calculate_route([hub_access, hub_egress], profile_key=main_mode)
|
|
131
|
+
legs.append(
|
|
132
|
+
MultiModalLeg(
|
|
133
|
+
2,
|
|
134
|
+
"Transit / Bike Ride",
|
|
135
|
+
resolve_profile(main_mode),
|
|
136
|
+
hub_access,
|
|
137
|
+
hub_egress,
|
|
138
|
+
res2,
|
|
139
|
+
transfer_penalty_min=3.0,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Leg 3: Egress Walk to Final Destination
|
|
144
|
+
res3 = self.engine.calculate_route([hub_egress, destination], profile_key=egress_mode)
|
|
145
|
+
legs.append(
|
|
146
|
+
MultiModalLeg(
|
|
147
|
+
3,
|
|
148
|
+
"Egress Walk",
|
|
149
|
+
resolve_profile(egress_mode),
|
|
150
|
+
hub_egress,
|
|
151
|
+
destination,
|
|
152
|
+
res3,
|
|
153
|
+
transfer_penalty_min=0.0,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
tot_dist = sum(leg.route_result.statistics.total_distance_km for leg in legs)
|
|
158
|
+
tot_time = sum(
|
|
159
|
+
leg.route_result.statistics.total_duration_min + leg.transfer_penalty_min
|
|
160
|
+
for leg in legs
|
|
161
|
+
)
|
|
162
|
+
tot_climb = sum(leg.route_result.statistics.elevation_gain_m for leg in legs)
|
|
163
|
+
tot_kcal = sum(leg.route_result.statistics.total_calories_kcal for leg in legs)
|
|
164
|
+
|
|
165
|
+
return MultiModalJourney(
|
|
166
|
+
legs=legs,
|
|
167
|
+
total_distance_km=tot_dist,
|
|
168
|
+
total_duration_min=tot_time,
|
|
169
|
+
total_climb_m=tot_climb,
|
|
170
|
+
total_calories_kcal=tot_kcal,
|
|
171
|
+
transfer_count=len(legs) - 1,
|
|
172
|
+
)
|