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,479 @@
1
+ """3D Route Corridor extractor: buffers multi-route paths by 30m and extracts real 3D OSM buildings and 3D volumetric trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import math
7
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
8
+
9
+ from .kinematics import haversine_distance_2d
10
+ from .osm_downloader import OsmBuilding, OsmPark, OsmTree
11
+
12
+
13
+ def point_to_segment_distance_meters(
14
+ px: float, py: float, ax: float, ay: float, bx: float, by: float
15
+ ) -> float:
16
+ """Distance in meters from point P(px,py) to segment AB in WGS84."""
17
+ if not (
18
+ math.isfinite(px)
19
+ and math.isfinite(py)
20
+ and math.isfinite(ax)
21
+ and math.isfinite(ay)
22
+ and math.isfinite(bx)
23
+ and math.isfinite(by)
24
+ ):
25
+ return float("inf")
26
+
27
+ # Convert local dx, dy to approximate meters
28
+ cos_lat = math.cos(math.radians((ay + by) * 0.5))
29
+ dx = (bx - ax) * 111320.0 * cos_lat
30
+ dy = (by - ay) * 110540.0
31
+ seg_len_sq = dx * dx + dy * dy
32
+ if not math.isfinite(seg_len_sq) or seg_len_sq <= 1e-9:
33
+ return haversine_distance_2d((px, py), (ax, ay))
34
+
35
+ p_dx = (px - ax) * 111320.0 * cos_lat
36
+ p_dy = (py - ay) * 110540.0
37
+ num = p_dx * dx + p_dy * dy
38
+ if not math.isfinite(num):
39
+ return haversine_distance_2d((px, py), (ax, ay))
40
+
41
+ t = max(0.0, min(1.0, num / seg_len_sq))
42
+
43
+ proj_x = ax + t * (bx - ax)
44
+ proj_y = ay + t * (by - ay)
45
+ return haversine_distance_2d((px, py), (proj_x, proj_y))
46
+
47
+
48
+ def point_to_linestring_distance_meters(
49
+ px: float, py: float, linestring: Sequence[Tuple[float, float]]
50
+ ) -> float:
51
+ """Minimum distance in meters from point P to a polyline."""
52
+ if not linestring:
53
+ return float("inf")
54
+ if len(linestring) == 1:
55
+ return haversine_distance_2d((px, py), linestring[0])
56
+
57
+ min_d = float("inf")
58
+ for i in range(len(linestring) - 1):
59
+ d = point_to_segment_distance_meters(
60
+ px, py, linestring[i][0], linestring[i][1], linestring[i + 1][0], linestring[i + 1][1]
61
+ )
62
+ if d < min_d:
63
+ min_d = d
64
+ return min_d
65
+
66
+
67
+ def point_to_multi_linestrings_distance_meters(
68
+ px: float, py: float, linestrings: Sequence[Sequence[Tuple[float, float]]]
69
+ ) -> float:
70
+ """Minimum distance in meters from point P to any polyline in the collection."""
71
+ if not linestrings:
72
+ return float("inf")
73
+ min_d = float("inf")
74
+ for line in linestrings:
75
+ d = point_to_linestring_distance_meters(px, py, line)
76
+ if d < min_d:
77
+ min_d = d
78
+ return min_d
79
+
80
+
81
+ def get_closest_route_elevation(
82
+ px: float, py: float, route_coords: Sequence[Sequence[float]]
83
+ ) -> float:
84
+ """Find the ground elevation of the closest point along a 3D route polyline."""
85
+ if not route_coords:
86
+ return 0.0
87
+ if len(route_coords) == 1:
88
+ return (
89
+ float(route_coords[0][2])
90
+ if len(route_coords[0]) > 2 and math.isfinite(float(route_coords[0][2]))
91
+ else 0.0
92
+ )
93
+
94
+ min_d = float("inf")
95
+ closest_z = (
96
+ float(route_coords[0][2])
97
+ if len(route_coords[0]) > 2 and math.isfinite(float(route_coords[0][2]))
98
+ else 0.0
99
+ )
100
+
101
+ for i in range(len(route_coords) - 1):
102
+ p1 = route_coords[i]
103
+ p2 = route_coords[i + 1]
104
+ z1 = float(p1[2]) if len(p1) > 2 and math.isfinite(float(p1[2])) else 0.0
105
+ z2 = float(p2[2]) if len(p2) > 2 and math.isfinite(float(p2[2])) else 0.0
106
+ d = point_to_segment_distance_meters(px, py, p1[0], p1[1], p2[0], p2[1])
107
+ if d < min_d:
108
+ min_d = d
109
+ closest_z = (z1 + z2) * 0.5
110
+ return closest_z
111
+
112
+
113
+ def get_closest_multi_route_elevation(
114
+ px: float,
115
+ py: float,
116
+ all_routes: Sequence[Sequence[Sequence[float]]],
117
+ green_sampler: Optional[Any] = None,
118
+ ) -> float:
119
+ """Find ground elevation using the green_sampler DEM if available, or closest multi-route elevation."""
120
+ if green_sampler is not None and hasattr(green_sampler, "sample_elevation"):
121
+ with contextlib.suppress(Exception):
122
+ elev = green_sampler.sample_elevation(px, py)
123
+ if elev is not None and math.isfinite(elev) and elev != 0.0:
124
+ return float(elev)
125
+
126
+ if not all_routes:
127
+ return 0.0
128
+
129
+ min_d = float("inf")
130
+ best_z = 0.0
131
+ for route in all_routes:
132
+ if not route:
133
+ continue
134
+ for i in range(len(route) - 1):
135
+ p1 = route[i]
136
+ p2 = route[i + 1]
137
+ z1 = float(p1[2]) if len(p1) > 2 and math.isfinite(float(p1[2])) else 0.0
138
+ z2 = float(p2[2]) if len(p2) > 2 and math.isfinite(float(p2[2])) else 0.0
139
+ d = point_to_segment_distance_meters(px, py, p1[0], p1[1], p2[0], p2[1])
140
+ if d < min_d:
141
+ min_d = d
142
+ best_z = (z1 + z2) * 0.5
143
+ return best_z
144
+
145
+
146
+ def filter_corridor_assets_multi_route(
147
+ routes_coords: Sequence[Any],
148
+ buildings: Sequence[OsmBuilding],
149
+ buffer_meters: float = 30.0,
150
+ green_sampler: Optional[Any] = None,
151
+ osm_trees: Optional[Sequence[OsmTree]] = None,
152
+ osm_parks: Optional[Sequence[OsmPark]] = None,
153
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
154
+ """Multi-route corridor asset extractor.
155
+
156
+ Buffers across ALL active route paths (30m buffer default) to extract:
157
+ 1. Real 3D OSM buildings within the multi-route buffer with calculated base elevations and levels.
158
+ 2. Real & volumetric 3D trees placed along greenery / park / corridor zones within the 30m buffer.
159
+
160
+ Returns:
161
+ (corridor_buildings, corridor_trees)
162
+ """
163
+ if not routes_coords:
164
+ return [], []
165
+
166
+ # Handle input flexibility: routes_coords can be a list of routes or a single route
167
+ cleaned_routes: List[List[Tuple[float, float, float]]] = []
168
+ if len(routes_coords) > 0:
169
+ first_elem = routes_coords[0]
170
+ if (
171
+ isinstance(first_elem, (list, tuple))
172
+ and len(first_elem) > 0
173
+ and isinstance(first_elem[0], (int, float))
174
+ ):
175
+ route_list = [routes_coords]
176
+ else:
177
+ route_list = list(routes_coords)
178
+ else:
179
+ return [], []
180
+
181
+ for r in route_list:
182
+ if not r or len(r) < 2:
183
+ continue
184
+ valid_pts = []
185
+ for c in r:
186
+ if isinstance(c, (list, tuple)) and len(c) >= 2:
187
+ lon_v = float(c[0])
188
+ lat_v = float(c[1])
189
+ ele_v = float(c[2]) if len(c) > 2 and math.isfinite(float(c[2])) else 0.0
190
+ if math.isfinite(lon_v) and math.isfinite(lat_v):
191
+ valid_pts.append((lon_v, lat_v, ele_v))
192
+ if len(valid_pts) >= 2:
193
+ cleaned_routes.append(valid_pts)
194
+
195
+ if not cleaned_routes:
196
+ return [], []
197
+
198
+ buf_m = float(buffer_meters) if math.isfinite(buffer_meters) and buffer_meters >= 0 else 30.0
199
+ all_lines_2d = [[(p[0], p[1]) for p in r] for r in cleaned_routes]
200
+
201
+ # Global bounding box across all routes
202
+ all_lons = [p[0] for line in all_lines_2d for p in line]
203
+ all_lats = [p[1] for line in all_lines_2d for p in line]
204
+ margin_deg = (buf_m + 35.0) / 75000.0
205
+ min_rx = min(all_lons) - margin_deg
206
+ max_rx = max(all_lons) + margin_deg
207
+ min_ry = min(all_lats) - margin_deg
208
+ max_ry = max(all_lats) + margin_deg
209
+
210
+ # -------------------------------------------------------------
211
+ # 1. Extract 3D Buildings within 30m multi-route buffer
212
+ # -------------------------------------------------------------
213
+ corridor_buildings: List[Dict[str, Any]] = []
214
+ building_centroids: List[Tuple[float, float, float]] = [] # (lon, lat, radius_m)
215
+ seen_building_ids = set()
216
+
217
+ for b in buildings or []:
218
+ if not b.polygon or len(b.polygon) < 3:
219
+ continue
220
+
221
+ b_id = str(b.building_id)
222
+ if b_id in seen_building_ids:
223
+ continue
224
+
225
+ valid_poly = [
226
+ (float(p[0]), float(p[1]))
227
+ for p in b.polygon
228
+ if len(p) >= 2 and math.isfinite(float(p[0])) and math.isfinite(float(p[1]))
229
+ ]
230
+ if len(valid_poly) < 3:
231
+ continue
232
+
233
+ c_lon = sum(p[0] for p in valid_poly) / len(valid_poly)
234
+ c_lat = sum(p[1] for p in valid_poly) / len(valid_poly)
235
+
236
+ # Fast AABB skip
237
+ if c_lon < min_rx or c_lon > max_rx or c_lat < min_ry or c_lat > max_ry:
238
+ continue
239
+
240
+ d = point_to_multi_linestrings_distance_meters(c_lon, c_lat, all_lines_2d)
241
+ if d <= buf_m + 15.0: # Include footprint extent
242
+ seen_building_ids.add(b_id)
243
+ base_z = get_closest_multi_route_elevation(c_lon, c_lat, cleaned_routes, green_sampler)
244
+ h_m = float(b.height_m) if math.isfinite(b.height_m) and b.height_m > 0 else 12.0
245
+ lvls = int(b.levels) if b.levels > 0 else 4
246
+
247
+ # Calculate approximate footprint radius for tree collision avoidance
248
+ max_poly_rad = max(haversine_distance_2d((c_lon, c_lat), p) for p in valid_poly)
249
+ building_centroids.append((c_lon, c_lat, max_poly_rad + 3.0))
250
+
251
+ corridor_buildings.append(
252
+ {
253
+ "id": b_id,
254
+ "coordinates": [[round(p[0], 6), round(p[1], 6)] for p in valid_poly],
255
+ "height_m": round(h_m, 1),
256
+ "levels": lvls,
257
+ "type": b.building_type,
258
+ "base_elevation_m": round(base_z, 2),
259
+ }
260
+ )
261
+
262
+ # -------------------------------------------------------------
263
+ # 2. Extract Real & Volumetric 3D Trees in 30m corridor buffer
264
+ # -------------------------------------------------------------
265
+ corridor_trees: List[Dict[str, Any]] = []
266
+ placed_tree_positions: List[Tuple[float, float]] = []
267
+
268
+ # 2.1 First place real OSM trees & tree rows
269
+ if osm_trees:
270
+ for t in osm_trees:
271
+ if not (math.isfinite(t.lon) and math.isfinite(t.lat)):
272
+ continue
273
+ d = point_to_multi_linestrings_distance_meters(t.lon, t.lat, all_lines_2d)
274
+ if d > buf_m + 5.0 or d < 1.0:
275
+ continue
276
+
277
+ # Check building collision
278
+ collides_bld = any(
279
+ haversine_distance_2d((t.lon, t.lat), (b_lon, b_lat)) < b_rad
280
+ for b_lon, b_lat, b_rad in building_centroids
281
+ )
282
+ if collides_bld:
283
+ continue
284
+
285
+ # Check tree-to-tree spacing
286
+ if any(
287
+ haversine_distance_2d((t.lon, t.lat), pos) < 5.0 for pos in placed_tree_positions
288
+ ):
289
+ continue
290
+
291
+ tree_z = get_closest_multi_route_elevation(t.lon, t.lat, cleaned_routes, green_sampler)
292
+ corridor_trees.append(
293
+ {
294
+ "id": f"osm_tree_{t.tree_id}",
295
+ "coordinates": [round(t.lon, 6), round(t.lat, 6)],
296
+ "base_elevation_m": round(tree_z, 2),
297
+ "height_m": round(t.height_m, 1),
298
+ "canopy_radius_m": round(t.canopy_radius_m, 1),
299
+ "trunk_height_m": round(t.height_m * 0.3, 1),
300
+ "trunk_radius_m": round(max(0.18, t.canopy_radius_m * 0.08), 2),
301
+ "tree_type": t.tree_type,
302
+ "species": t.species,
303
+ "greenery_index": 0.85,
304
+ }
305
+ )
306
+ placed_tree_positions.append((t.lon, t.lat))
307
+
308
+ # 2.2 Place trees inside real OSM park/greenery polygons in corridor
309
+ if osm_parks:
310
+ for p in osm_parks:
311
+ if not p.polygon or len(p.polygon) < 3:
312
+ continue
313
+ poly_lons = [pt[0] for pt in p.polygon]
314
+ poly_lats = [pt[1] for pt in p.polygon]
315
+ c_lon = sum(poly_lons) / len(poly_lons)
316
+ c_lat = sum(poly_lats) / len(poly_lats)
317
+ d = point_to_multi_linestrings_distance_meters(c_lon, c_lat, all_lines_2d)
318
+ if d <= buf_m + 10.0:
319
+ # Add park trees
320
+ for pt in p.polygon:
321
+ pt_lon, pt_lat = pt[0], pt[1]
322
+ dist_to_route = point_to_multi_linestrings_distance_meters(
323
+ pt_lon, pt_lat, all_lines_2d
324
+ )
325
+ if 4.0 <= dist_to_route <= buf_m:
326
+ if not any(
327
+ haversine_distance_2d((pt_lon, pt_lat), pos) < 8.0
328
+ for pos in placed_tree_positions
329
+ ):
330
+ if not any(
331
+ haversine_distance_2d((pt_lon, pt_lat), (b_lon, b_lat)) < b_rad
332
+ for b_lon, b_lat, b_rad in building_centroids
333
+ ):
334
+ tree_z = get_closest_multi_route_elevation(
335
+ pt_lon, pt_lat, cleaned_routes, green_sampler
336
+ )
337
+ corridor_trees.append(
338
+ {
339
+ "id": f"park_tree_{len(corridor_trees) + 1}",
340
+ "coordinates": [round(pt_lon, 6), round(pt_lat, 6)],
341
+ "base_elevation_m": round(tree_z, 2),
342
+ "height_m": 9.0,
343
+ "canopy_radius_m": 3.8,
344
+ "trunk_height_m": 2.2,
345
+ "trunk_radius_m": 0.32,
346
+ "tree_type": "broadleaf",
347
+ "species": p.name or "park",
348
+ "greenery_index": 0.9,
349
+ }
350
+ )
351
+ placed_tree_positions.append((pt_lon, pt_lat))
352
+
353
+ # 2.3 Lateral procedural corridor greenery where trees are sparse
354
+ lateral_offsets = [-24.0, -16.0, -9.0, 9.0, 16.0, 24.0]
355
+ step_interval_m = 18.0
356
+
357
+ for route in cleaned_routes:
358
+ for i in range(len(route) - 1):
359
+ p1 = route[i]
360
+ p2 = route[i + 1]
361
+ seg_dist = haversine_distance_2d((p1[0], p1[1]), (p2[0], p2[1]))
362
+ if seg_dist < 2.0:
363
+ continue
364
+
365
+ num_steps = max(1, int(round(seg_dist / step_interval_m)))
366
+ cos_mid_lat = math.cos(math.radians((p1[1] + p2[1]) * 0.5))
367
+
368
+ # Direction vector in approximate meters
369
+ dx_m = (p2[0] - p1[0]) * 111320.0 * cos_mid_lat
370
+ dy_m = (p2[1] - p1[1]) * 110540.0
371
+ seg_len = math.sqrt(dx_m * dx_m + dy_m * dy_m)
372
+ if seg_len < 1e-6:
373
+ continue
374
+
375
+ # Unit normal vector (perpendicular to road direction)
376
+ nx = -dy_m / seg_len
377
+ ny = dx_m / seg_len
378
+
379
+ for step in range(num_steps):
380
+ # Named `fraction`, not `t`: `t` is the OsmTree loop variable above,
381
+ # and rebinding it here made the two impossible to tell apart.
382
+ fraction = (step + 0.5) / num_steps
383
+ center_lon = p1[0] + fraction * (p2[0] - p1[0])
384
+ center_lat = p1[1] + fraction * (p2[1] - p1[1])
385
+
386
+ for offset_m in lateral_offsets:
387
+ if abs(offset_m) > buf_m:
388
+ continue
389
+
390
+ # Offset in degrees
391
+ cand_lon = center_lon + (nx * offset_m) / (
392
+ 111320.0 * math.cos(math.radians(center_lat))
393
+ )
394
+ cand_lat = center_lat + (ny * offset_m) / 110540.0
395
+
396
+ # 1. Check distance to route paths: must be >= 4.5m and <= 30m
397
+ dist_to_route = point_to_multi_linestrings_distance_meters(
398
+ cand_lon, cand_lat, all_lines_2d
399
+ )
400
+ if dist_to_route < 4.5 or dist_to_route > buf_m + 2.0:
401
+ continue
402
+
403
+ # 2. Check collision with building footprints
404
+ collides_building = False
405
+ for b_lon, b_lat, b_rad in building_centroids:
406
+ if haversine_distance_2d((cand_lon, cand_lat), (b_lon, b_lat)) < b_rad:
407
+ collides_building = True
408
+ break
409
+ if collides_building:
410
+ continue
411
+
412
+ # 3. Check spacing against already placed trees (min 9m spacing)
413
+ collides_tree = False
414
+ for t_pos in placed_tree_positions:
415
+ if haversine_distance_2d((cand_lon, cand_lat), t_pos) < 9.0:
416
+ collides_tree = True
417
+ break
418
+ if collides_tree:
419
+ continue
420
+
421
+ # 4. Greenery sampling (if green_sampler is available)
422
+ green_val = 0.55
423
+ if green_sampler is not None and hasattr(green_sampler, "sample_greenery"):
424
+ try:
425
+ sampled_green = green_sampler.sample_greenery(cand_lon, cand_lat)
426
+ except Exception:
427
+ sampled_green = None
428
+ if sampled_green is not None and math.isfinite(sampled_green):
429
+ green_val = float(sampled_green)
430
+
431
+ if green_val < 0.15:
432
+ continue
433
+
434
+ # 5. Deterministic hash variation for realistic tree morphology
435
+ coord_key = (round(cand_lon, 5), round(cand_lat, 5))
436
+ h_val = abs(hash(coord_key))
437
+
438
+ # Tree dimensions modulated by greenery and hash
439
+ height_m = round(5.5 + (h_val % 45) * 0.12 + green_val * 2.8, 1)
440
+ canopy_radius_m = round(2.0 + (h_val % 25) * 0.09 + green_val * 1.3, 1)
441
+ trunk_height_m = round(1.5 + (h_val % 18) * 0.08, 1)
442
+ trunk_radius_m = round(0.20 + (h_val % 12) * 0.015, 2)
443
+ tree_types = ["deciduous", "broadleaf", "conifer", "pine"]
444
+ tree_type = tree_types[h_val % len(tree_types)]
445
+
446
+ tree_z = get_closest_multi_route_elevation(
447
+ cand_lon, cand_lat, cleaned_routes, green_sampler
448
+ )
449
+
450
+ tree_id = f"tree_{len(corridor_trees) + 1}"
451
+ placed_tree_positions.append((cand_lon, cand_lat))
452
+
453
+ corridor_trees.append(
454
+ {
455
+ "id": tree_id,
456
+ "coordinates": [round(cand_lon, 6), round(cand_lat, 6)],
457
+ "base_elevation_m": round(tree_z, 2),
458
+ "height_m": height_m,
459
+ "canopy_radius_m": canopy_radius_m,
460
+ "trunk_height_m": trunk_height_m,
461
+ "trunk_radius_m": trunk_radius_m,
462
+ "tree_type": tree_type,
463
+ "greenery_index": round(green_val, 2),
464
+ }
465
+ )
466
+
467
+ return corridor_buildings, corridor_trees
468
+
469
+
470
+ def filter_buildings_in_corridor(
471
+ route_coords: Sequence[Tuple[float, float, float]],
472
+ buildings: Sequence[OsmBuilding],
473
+ buffer_meters: float = 30.0,
474
+ ) -> List[Dict[str, Any]]:
475
+ """Extract real OSM buildings that fall within the specified corridor buffer around the route."""
476
+ blds, _ = filter_corridor_assets_multi_route(
477
+ [route_coords], buildings, buffer_meters=buffer_meters
478
+ )
479
+ return blds