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