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,512 @@
1
+ """Road network data acquisition, vector layer ingestion, and geocoding engine.
2
+
3
+ Supports fetching OpenStreetMap networks via Overpass API with local bounding
4
+ box caching, reverse geocoding via Photon API, and extracting topology from
5
+ active QGIS vector layers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import hashlib
12
+ import http.client
13
+ import json
14
+ import math
15
+ import tempfile
16
+ import urllib.parse
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+ from .input_validation import normalize_bbox
22
+ from .kinematics import haversine_distance_2d
23
+
24
+
25
+ @dataclass
26
+ class RoadSegment:
27
+ """Directed edge segment representing a traversable line between two vertices."""
28
+
29
+ p1: Tuple[float, float, float]
30
+ p2: Tuple[float, float, float]
31
+ length_m: float
32
+ highway_type: str = "residential"
33
+ hierarchy_rank: int = (
34
+ 4 # 1: Motorway, 2: Primary, 3: Secondary/Tertiary, 4: Residential, 5: Path/Service
35
+ )
36
+ lanes: int = 2
37
+ is_steps: bool = False
38
+ surface: str = "asphalt"
39
+ is_oneway: bool = False
40
+ # Real OSM "name" tag. Left empty when the way is unnamed -- an unnamed street
41
+ # must stay unnamed rather than be given a placeholder like "Urban Path".
42
+ name: str = ""
43
+
44
+
45
+ class NetworkSourceError(RuntimeError):
46
+ """Raised when no usable real network can be loaded for an operation."""
47
+
48
+
49
+ class NetworkSourceManager:
50
+ """Acquires, caches, geocodes, and parses topological road networks."""
51
+
52
+ CACHE_DIR = Path(tempfile.gettempdir()) / "zero2route3d_cache"
53
+
54
+ def __init__(self) -> None:
55
+ self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
56
+
57
+ @staticmethod
58
+ def _highway_to_hierarchy(highway: str) -> int:
59
+ """Map OSM highway tag to functional hierarchy rank (1..5)."""
60
+ h = str(highway).lower()
61
+ if h in {"motorway", "motorway_link", "trunk", "trunk_link"}:
62
+ return 1
63
+ if h in {"primary", "primary_link"}:
64
+ return 2
65
+ if h in {"secondary", "secondary_link", "tertiary", "tertiary_link"}:
66
+ return 3
67
+ if h in {"residential", "living_street", "unclassified"}:
68
+ return 4
69
+ return 5
70
+
71
+ def search_place_photon(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
72
+ """Geocode place name using Photon API (HTTPS)."""
73
+ if not query or len(query.strip()) < 2:
74
+ return []
75
+
76
+ results = []
77
+ with contextlib.suppress(Exception):
78
+ encoded = urllib.parse.quote(query.strip())
79
+ try:
80
+ lim = max(1, min(50, int(limit)))
81
+ except (TypeError, ValueError, OverflowError):
82
+ lim = 5
83
+ conn = http.client.HTTPSConnection("photon.komoot.io", timeout=8)
84
+ try:
85
+ conn.request(
86
+ "GET",
87
+ f"/api/?q={encoded}&limit={lim}",
88
+ headers={"User-Agent": "zero2route3d-sdk"},
89
+ )
90
+ resp = conn.getresponse()
91
+ if resp.status == 200:
92
+ data = json.loads(resp.read().decode("utf-8"))
93
+ if isinstance(data, dict):
94
+ for feat in data.get("features", []):
95
+ geom = feat.get("geometry", {})
96
+ props = feat.get("properties", {})
97
+ coords = geom.get("coordinates", [])
98
+ if len(coords) >= 2:
99
+ try:
100
+ lon_val = float(coords[0])
101
+ lat_val = float(coords[1])
102
+ if math.isfinite(lon_val) and math.isfinite(lat_val):
103
+ name = props.get("name") or props.get("street") or query
104
+ city = (
105
+ props.get("city")
106
+ or props.get("state")
107
+ or props.get("country")
108
+ or ""
109
+ )
110
+ label = f"{name} ({city})" if city else name
111
+ results.append(
112
+ {
113
+ "label": str(label),
114
+ "lon": lon_val,
115
+ "lat": lat_val,
116
+ }
117
+ )
118
+ except (ValueError, TypeError):
119
+ continue
120
+ finally:
121
+ conn.close()
122
+ return results
123
+
124
+ def fetch_osm_network_bbox(
125
+ self,
126
+ bbox: Tuple[float, float, float, float],
127
+ buffer_ratio: float = 0.15,
128
+ ) -> List[RoadSegment]:
129
+ """Fetch OSM highway ways within bbox using Overpass API with disk caching.
130
+
131
+ A missing/invalid response is an input error, not a reason to invent a
132
+ network. Callers can surface :class:`NetworkSourceError` directly to
133
+ QGIS users and let them choose a valid layer or retry the download.
134
+ """
135
+ try:
136
+ min_lon, min_lat, max_lon, max_lat = normalize_bbox(bbox)
137
+ except ValueError as exc:
138
+ raise NetworkSourceError(str(exc)) from exc
139
+
140
+ d_lon = max(0.005, max_lon - min_lon)
141
+ d_lat = max(0.005, max_lat - min_lat)
142
+
143
+ buf_r = float(buffer_ratio) if math.isfinite(buffer_ratio) and buffer_ratio >= 0 else 0.15
144
+ buf_r = min(1.0, buf_r)
145
+ buf_lon = d_lon * buf_r
146
+ buf_lat = d_lat * buf_r
147
+ s = max(-90.0, min_lat - buf_lat)
148
+ w = max(-180.0, min_lon - buf_lon)
149
+ n = min(90.0, max_lat + buf_lat)
150
+ e = min(180.0, max_lon + buf_lon)
151
+
152
+ cache_key = hashlib.sha256(f"{s:.4f}_{w:.4f}_{n:.4f}_{e:.4f}".encode("utf-8")).hexdigest()
153
+ cache_file = self.CACHE_DIR / f"osm_{cache_key}.json"
154
+
155
+ data = None
156
+ if cache_file.exists():
157
+ with contextlib.suppress(Exception):
158
+ with open(cache_file, "r", encoding="utf-8") as f:
159
+ loaded = json.load(f)
160
+ if isinstance(loaded, dict) and "elements" in loaded:
161
+ data = loaded
162
+
163
+ if not data:
164
+ query = f"""[out:json][timeout:25];(way["highway"]({s:.5f},{w:.5f},{n:.5f},{e:.5f}););out body;>;out skel qt;"""
165
+ data = self._query_overpass(query)
166
+ if data and isinstance(data, dict) and "elements" in data:
167
+ with contextlib.suppress(Exception):
168
+ temp_cache = cache_file.with_suffix(".tmp")
169
+ with open(temp_cache, "w", encoding="utf-8") as f:
170
+ json.dump(data, f)
171
+ temp_cache.replace(cache_file)
172
+
173
+ if not data or not isinstance(data, dict) or "elements" not in data:
174
+ raise NetworkSourceError(
175
+ "OpenStreetMap network download failed. Check the internet connection "
176
+ "or select a QGIS line network layer."
177
+ )
178
+
179
+ segments = self._parse_osm_json(data)
180
+ if not segments:
181
+ raise NetworkSourceError(
182
+ "OpenStreetMap returned no usable road segments for this extent. "
183
+ "Try a larger extent or select a QGIS line network layer."
184
+ )
185
+ return segments
186
+
187
+ def require_segments(
188
+ self,
189
+ vector_layer: Any = None,
190
+ bbox: Optional[Tuple[float, float, float, float]] = None,
191
+ ) -> List[RoadSegment]:
192
+ """Load a real network from a QGIS line layer or OSM, never fabricated data."""
193
+ if vector_layer is not None:
194
+ segments = self.extract_from_qgis_layer(vector_layer)
195
+ if not segments:
196
+ raise NetworkSourceError(
197
+ "The selected network layer contains no usable line segments. "
198
+ "Select a non-empty line layer with a valid CRS."
199
+ )
200
+ return segments
201
+ if bbox is None:
202
+ raise NetworkSourceError(
203
+ "No network source was provided. Select a QGIS line layer or enable OSM download."
204
+ )
205
+ return self.fetch_osm_network_bbox(bbox)
206
+
207
+ def extract_from_geojson(self, data: Any) -> List[RoadSegment]:
208
+ """Extract road segments from GeoJSON, the SDK's primary network input.
209
+
210
+ Accepts a FeatureCollection, a single Feature, a bare geometry, or a list
211
+ of any of those. LineString and MultiLineString geometries are split into
212
+ directed vertex-to-vertex segments; Z coordinates are carried through when
213
+ present. Non-line geometries are skipped.
214
+
215
+ OSM-style properties are honoured where available: ``highway``, ``surface``,
216
+ ``oneway`` (including ``-1``/``reverse``), ``junction=roundabout``, ``lanes``
217
+ and ``name``.
218
+ """
219
+ features = self._iter_geojson_features(data)
220
+ segments: List[RoadSegment] = []
221
+
222
+ for geometry, properties in features:
223
+ geom_type = str(geometry.get("type", ""))
224
+ if geom_type == "LineString":
225
+ lines = [geometry.get("coordinates") or []]
226
+ elif geom_type == "MultiLineString":
227
+ lines = list(geometry.get("coordinates") or [])
228
+ else:
229
+ continue
230
+
231
+ highway = str(properties.get("highway", "residential") or "residential").strip()
232
+ hierarchy = self._highway_to_hierarchy(highway)
233
+ is_steps = highway == "steps"
234
+ surface = str(properties.get("surface", "asphalt") or "asphalt").strip()
235
+ lanes = 1
236
+ with contextlib.suppress(Exception):
237
+ lanes = max(1, int(properties.get("lanes", 1)))
238
+
239
+ oneway_raw = properties.get("oneway", False)
240
+ oneway_tag = str(oneway_raw).strip().lower()
241
+ # oneway=-1 means one-way *against* the digitisation order; storing it
242
+ # unreversed routes traffic the wrong way down the street.
243
+ reversed_oneway = oneway_tag in {"-1", "reverse"}
244
+ oneway = (
245
+ oneway_raw is True
246
+ or oneway_tag in {"yes", "1", "true"}
247
+ or reversed_oneway
248
+ or str(properties.get("junction", "") or "").strip().lower() == "roundabout"
249
+ )
250
+ street_name = str(properties.get("name", "") or "").strip()
251
+
252
+ for line in lines:
253
+ vertices = self._clean_line_coordinates(line)
254
+ for c1, c2 in zip(vertices, vertices[1:]):
255
+ dist = haversine_distance_2d(c1, c2)
256
+ if not math.isfinite(dist) or dist < 0.1:
257
+ continue
258
+ p1, p2 = (c2, c1) if reversed_oneway else (c1, c2)
259
+ segments.append(
260
+ RoadSegment(
261
+ p1=p1,
262
+ p2=p2,
263
+ length_m=dist,
264
+ highway_type=highway,
265
+ hierarchy_rank=hierarchy,
266
+ lanes=lanes,
267
+ is_steps=is_steps,
268
+ surface=surface,
269
+ is_oneway=oneway,
270
+ name=street_name,
271
+ )
272
+ )
273
+
274
+ return segments
275
+
276
+ @classmethod
277
+ def _iter_geojson_features(cls, data: Any) -> List[Tuple[Dict[str, Any], Dict[str, Any]]]:
278
+ """Flatten any GeoJSON container into (geometry, properties) pairs."""
279
+ pairs: List[Tuple[Dict[str, Any], Dict[str, Any]]] = []
280
+
281
+ if isinstance(data, (list, tuple)):
282
+ for item in data:
283
+ pairs.extend(cls._iter_geojson_features(item))
284
+ return pairs
285
+
286
+ if not isinstance(data, dict):
287
+ return pairs
288
+
289
+ node_type = str(data.get("type", ""))
290
+ if node_type == "FeatureCollection":
291
+ for feature in data.get("features") or []:
292
+ pairs.extend(cls._iter_geojson_features(feature))
293
+ elif node_type == "Feature":
294
+ geometry = data.get("geometry")
295
+ properties = data.get("properties") or {}
296
+ if isinstance(geometry, dict):
297
+ if str(geometry.get("type", "")) == "GeometryCollection":
298
+ for sub in geometry.get("geometries") or []:
299
+ if isinstance(sub, dict):
300
+ pairs.append((sub, properties))
301
+ else:
302
+ pairs.append((geometry, properties))
303
+ elif node_type == "GeometryCollection":
304
+ for sub in data.get("geometries") or []:
305
+ if isinstance(sub, dict):
306
+ pairs.append((sub, {}))
307
+ elif node_type:
308
+ pairs.append((data, {}))
309
+
310
+ return pairs
311
+
312
+ @staticmethod
313
+ def _clean_line_coordinates(line: Any) -> List[Tuple[float, float, float]]:
314
+ """Coerce a GeoJSON coordinate array into finite (lon, lat, z) tuples."""
315
+ vertices: List[Tuple[float, float, float]] = []
316
+ if not isinstance(line, (list, tuple)):
317
+ return vertices
318
+ for pt in line:
319
+ if not isinstance(pt, (list, tuple)) or len(pt) < 2:
320
+ continue
321
+ try:
322
+ lon = float(pt[0])
323
+ lat = float(pt[1])
324
+ z = float(pt[2]) if len(pt) > 2 else 0.0
325
+ except (TypeError, ValueError, OverflowError):
326
+ continue
327
+ if not (math.isfinite(lon) and math.isfinite(lat)):
328
+ continue
329
+ vertices.append((lon, lat, z if math.isfinite(z) else 0.0))
330
+ return vertices
331
+
332
+ def _query_overpass(self, query: str) -> Dict[str, Any] | None:
333
+ """Safe HTTPS POST to Overpass API without generic urlopen."""
334
+ endpoints = [
335
+ ("overpass-api.de", "/api/interpreter"),
336
+ ("maps.mail.ru", "/osm/tools/overpass/api/interpreter"),
337
+ ]
338
+ body = urllib.parse.urlencode({"data": query}).encode("utf-8")
339
+ headers = {
340
+ "Content-Type": "application/x-www-form-urlencoded",
341
+ "User-Agent": "zero2route3d-sdk",
342
+ }
343
+
344
+ for host, path in endpoints:
345
+ with contextlib.suppress(Exception):
346
+ conn = http.client.HTTPSConnection(host, timeout=12)
347
+ try:
348
+ conn.request("POST", path, body=body, headers=headers)
349
+ resp = conn.getresponse()
350
+ if resp.status == 200:
351
+ raw = resp.read().decode("utf-8")
352
+ parsed = json.loads(raw)
353
+ if isinstance(parsed, dict):
354
+ return parsed
355
+ finally:
356
+ conn.close()
357
+ return None
358
+
359
+ def _parse_osm_json(self, data: Dict[str, Any]) -> List[RoadSegment]:
360
+ """Convert OSM JSON response elements into list of RoadSegments."""
361
+ nodes: Dict[int, Tuple[float, float]] = {}
362
+ segments: List[RoadSegment] = []
363
+
364
+ for el in data.get("elements", []):
365
+ if el.get("type") == "node":
366
+ try:
367
+ nid = int(el["id"])
368
+ nlon = float(el["lon"])
369
+ nlat = float(el["lat"])
370
+ if math.isfinite(nlon) and math.isfinite(nlat):
371
+ nodes[nid] = (nlon, nlat)
372
+ except (KeyError, ValueError, TypeError):
373
+ continue
374
+
375
+ for el in data.get("elements", []):
376
+ if el.get("type") == "way":
377
+ tags = el.get("tags") or {}
378
+ highway = str(tags.get("highway", "residential"))
379
+ hierarchy = self._highway_to_hierarchy(highway)
380
+ is_steps = highway == "steps"
381
+ surface = str(tags.get("surface", "asphalt"))
382
+ lanes = 1
383
+ with contextlib.suppress(Exception):
384
+ lanes = max(1, int(tags.get("lanes", 1)))
385
+ oneway_tag = str(tags.get("oneway", "")).strip().lower()
386
+ # oneway=-1 means the way is one-way *against* its digitisation order.
387
+ # Treating it as bidirectional routed vehicles the wrong way down it.
388
+ reversed_oneway = oneway_tag in {"-1", "reverse"}
389
+ oneway = (
390
+ oneway_tag in {"yes", "1", "true"}
391
+ or reversed_oneway
392
+ or str(tags.get("junction", "")).strip().lower() == "roundabout"
393
+ )
394
+ street_name = str(tags.get("name", "") or "").strip()
395
+
396
+ way_nodes = el.get("nodes") or []
397
+ for i in range(len(way_nodes) - 1):
398
+ n1 = way_nodes[i]
399
+ n2 = way_nodes[i + 1]
400
+ if n1 in nodes and n2 in nodes:
401
+ c1 = nodes[n1]
402
+ c2 = nodes[n2]
403
+ dist = haversine_distance_2d(c1, c2)
404
+ if dist >= 0.1 and math.isfinite(dist):
405
+ seg = RoadSegment(
406
+ p1=(c1[0], c1[1], 0.0),
407
+ p2=(c2[0], c2[1], 0.0),
408
+ length_m=dist,
409
+ highway_type=highway,
410
+ hierarchy_rank=hierarchy,
411
+ lanes=lanes,
412
+ is_steps=is_steps,
413
+ surface=surface,
414
+ is_oneway=oneway,
415
+ name=street_name,
416
+ )
417
+ if reversed_oneway:
418
+ # Store it in its true travel direction.
419
+ seg.p1, seg.p2 = seg.p2, seg.p1
420
+ segments.append(seg)
421
+ return segments
422
+
423
+ def extract_from_qgis_layer(self, vector_layer: Any) -> List[RoadSegment]:
424
+ """Extract road network edges from an active QGIS line vector layer."""
425
+ if vector_layer is None:
426
+ return []
427
+ segments: List[RoadSegment] = []
428
+
429
+ def point_coordinate(point: Any, name: str, default: float = 0.0) -> float:
430
+ """Read QgsPoint or QgsPointXY coordinates across QGIS versions."""
431
+ value = getattr(point, name, None)
432
+ if callable(value):
433
+ value = value()
434
+ try:
435
+ return float(value) if value is not None else default
436
+ except (TypeError, ValueError):
437
+ return default
438
+
439
+ with contextlib.suppress(Exception):
440
+ from qgis.core import (
441
+ QgsCoordinateReferenceSystem,
442
+ QgsCoordinateTransform,
443
+ QgsProject,
444
+ )
445
+
446
+ crs_wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
447
+ crs_layer = vector_layer.crs()
448
+ needs_transform = crs_layer != crs_wgs84
449
+ transform = (
450
+ QgsCoordinateTransform(crs_layer, crs_wgs84, QgsProject.instance())
451
+ if needs_transform
452
+ else None
453
+ )
454
+
455
+ for feat in vector_layer.getFeatures():
456
+ geom = feat.geometry()
457
+ if geom.isNull() or geom.isEmpty():
458
+ continue
459
+
460
+ if needs_transform and transform is not None:
461
+ geom.transform(transform)
462
+
463
+ lines = []
464
+ if geom.isMultipart():
465
+ lines = geom.asMultiPolyline()
466
+ else:
467
+ lines = [geom.asPolyline()]
468
+
469
+ fields = feat.fields().names()
470
+ highway = "residential"
471
+ for h_field in ["highway", "type", "road_type", "class", "KIND"]:
472
+ if h_field in fields and feat[h_field] is not None:
473
+ highway = str(feat[h_field])
474
+ break
475
+
476
+ hierarchy = self._highway_to_hierarchy(highway)
477
+ is_steps = "step" in highway.lower() or "merdiven" in highway.lower()
478
+
479
+ for line in lines:
480
+ for i in range(len(line) - 1):
481
+ p1 = line[i]
482
+ p2 = line[i + 1]
483
+ c1 = (
484
+ point_coordinate(p1, "x"),
485
+ point_coordinate(p1, "y"),
486
+ point_coordinate(p1, "z"),
487
+ )
488
+ c2 = (
489
+ point_coordinate(p2, "x"),
490
+ point_coordinate(p2, "y"),
491
+ point_coordinate(p2, "z"),
492
+ )
493
+ if not (
494
+ math.isfinite(c1[0])
495
+ and math.isfinite(c1[1])
496
+ and math.isfinite(c2[0])
497
+ and math.isfinite(c2[1])
498
+ ):
499
+ continue
500
+ dist = haversine_distance_2d(c1, c2)
501
+ if dist >= 0.1 and math.isfinite(dist):
502
+ segments.append(
503
+ RoadSegment(
504
+ p1=c1,
505
+ p2=c2,
506
+ length_m=dist,
507
+ highway_type=highway,
508
+ hierarchy_rank=hierarchy,
509
+ is_steps=is_steps,
510
+ )
511
+ )
512
+ return segments