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
zero2route3d/api.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""High-level, single-call functional API for zero2route3d.
|
|
2
|
+
|
|
3
|
+
Enables fast, one-line spatial mobility calculations directly from coordinates,
|
|
4
|
+
GeoJSON files, or GeoPandas DataFrames without manual engine instantiation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
12
|
+
|
|
13
|
+
from .environmental_raster import EnvironmentalSurfaceSampler, MCDAWeights
|
|
14
|
+
from .isochrone_engine import IsochroneEngine3D, IsochroneResult
|
|
15
|
+
from .map_matching_3d import GPXPoint, HMMMapMatcher3D, MapMatching3DResult
|
|
16
|
+
from .mobility_profiles import MobilityProfile, resolve_profile
|
|
17
|
+
from .network_source import NetworkSourceManager, RoadSegment
|
|
18
|
+
from .pareto_router import ParetoFrontierResult, ParetoMultiObjectiveRouter
|
|
19
|
+
from .routing_engine import RouteResult3D, RoutingEngine3D, Waypoint
|
|
20
|
+
|
|
21
|
+
#: Anything the helpers below accept as a road network.
|
|
22
|
+
NetworkInput = Union[str, Path, Sequence[RoadSegment], Dict[str, Any], Any]
|
|
23
|
+
#: A registered profile key, or a ready-made (possibly custom) MobilityProfile.
|
|
24
|
+
ProfileInput = Union[str, MobilityProfile]
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"NetworkInput",
|
|
28
|
+
"ProfileInput",
|
|
29
|
+
"match_gps_track_3d",
|
|
30
|
+
"solve_3d_isochrones",
|
|
31
|
+
"solve_3d_route",
|
|
32
|
+
"solve_4d_pareto_frontier",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_network(network_input: NetworkInput) -> List[RoadSegment]:
|
|
37
|
+
"""Parse road segments from a filepath, GeoJSON dict, segment list or GeoDataFrame.
|
|
38
|
+
|
|
39
|
+
Raises ValueError / FileNotFoundError for input that cannot be interpreted.
|
|
40
|
+
Returning an empty network instead would push the failure downstream, where
|
|
41
|
+
it resurfaces as a misleading "could not snap origin to network graph".
|
|
42
|
+
"""
|
|
43
|
+
if isinstance(network_input, (list, tuple)) and network_input:
|
|
44
|
+
if all(isinstance(item, RoadSegment) for item in network_input):
|
|
45
|
+
return list(network_input)
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"A network given as a sequence must contain RoadSegment objects; got "
|
|
48
|
+
f"{type(network_input[0]).__name__}."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
mgr = NetworkSourceManager()
|
|
52
|
+
if isinstance(network_input, (str, Path)):
|
|
53
|
+
path = Path(network_input)
|
|
54
|
+
if not path.exists():
|
|
55
|
+
raise FileNotFoundError(f"Network file not found: {path}")
|
|
56
|
+
if path.suffix.lower() not in (".json", ".geojson"):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Unsupported network file type '{path.suffix}'. Provide a "
|
|
59
|
+
".geojson/.json file, a GeoJSON dict, a GeoDataFrame, or a list "
|
|
60
|
+
"of RoadSegment objects."
|
|
61
|
+
)
|
|
62
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
63
|
+
data = json.load(handle)
|
|
64
|
+
return mgr.extract_from_geojson(data)
|
|
65
|
+
|
|
66
|
+
if isinstance(network_input, dict):
|
|
67
|
+
return mgr.extract_from_geojson(network_input)
|
|
68
|
+
|
|
69
|
+
# geopandas.GeoDataFrame, duck-typed so geopandas stays an optional import.
|
|
70
|
+
if hasattr(network_input, "geometry") and hasattr(network_input, "iterfeatures"):
|
|
71
|
+
from .integrations import from_geodataframe
|
|
72
|
+
|
|
73
|
+
return from_geodataframe(network_input)
|
|
74
|
+
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Unsupported network input of type {type(network_input).__name__}. Provide "
|
|
77
|
+
"a GeoJSON path or dict, a geopandas.GeoDataFrame, or a list of RoadSegment "
|
|
78
|
+
"objects."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_engine(
|
|
83
|
+
network: NetworkInput,
|
|
84
|
+
dem_sampler: Optional[EnvironmentalSurfaceSampler] = None,
|
|
85
|
+
mcda_weights: Optional[MCDAWeights] = None,
|
|
86
|
+
fetch_online_dem: bool = False,
|
|
87
|
+
) -> RoutingEngine3D:
|
|
88
|
+
"""Load a network into a routing engine, optionally warming the elevation cache."""
|
|
89
|
+
segments = _load_network(network)
|
|
90
|
+
if not segments:
|
|
91
|
+
raise ValueError("The supplied network contains no usable road segments.")
|
|
92
|
+
|
|
93
|
+
sampler = dem_sampler or EnvironmentalSurfaceSampler()
|
|
94
|
+
if fetch_online_dem and not sampler.has_elevation_source:
|
|
95
|
+
coords = sorted(
|
|
96
|
+
{(round(pt[0], 5), round(pt[1], 5)) for seg in segments for pt in (seg.p1, seg.p2)}
|
|
97
|
+
)
|
|
98
|
+
sampler.prefetch_elevations(coords)
|
|
99
|
+
|
|
100
|
+
engine = RoutingEngine3D(sampler=sampler, weights=mcda_weights)
|
|
101
|
+
engine.build_graph(segments)
|
|
102
|
+
return engine
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _to_waypoint(pt: Any, name: str = "") -> Waypoint:
|
|
106
|
+
"""Coerce a (lon, lat[, elev]) tuple or a Waypoint into a Waypoint."""
|
|
107
|
+
if isinstance(pt, Waypoint):
|
|
108
|
+
return pt
|
|
109
|
+
if isinstance(pt, (tuple, list)) and len(pt) >= 2:
|
|
110
|
+
return Waypoint(
|
|
111
|
+
lon=float(pt[0]),
|
|
112
|
+
lat=float(pt[1]),
|
|
113
|
+
elevation_m=float(pt[2]) if len(pt) > 2 else 0.0,
|
|
114
|
+
name=name,
|
|
115
|
+
)
|
|
116
|
+
raise ValueError(f"Invalid waypoint {pt!r}: expected (lon, lat[, elevation]) or a Waypoint.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def solve_3d_route(
|
|
120
|
+
origin: Union[Tuple[float, float], Tuple[float, float, float], Waypoint],
|
|
121
|
+
destination: Union[Tuple[float, float], Tuple[float, float, float], Waypoint],
|
|
122
|
+
network: NetworkInput,
|
|
123
|
+
profile: ProfileInput = "adult",
|
|
124
|
+
dem_sampler: Optional[EnvironmentalSurfaceSampler] = None,
|
|
125
|
+
mcda_weights: Optional[MCDAWeights] = None,
|
|
126
|
+
intermediate_stops: Optional[Sequence[Union[Tuple[float, float], Waypoint]]] = None,
|
|
127
|
+
optimize_stops_order: bool = False,
|
|
128
|
+
fetch_online_dem: bool = False,
|
|
129
|
+
) -> RouteResult3D:
|
|
130
|
+
"""Solve a multi-criteria 3D least-cost route in a single call.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
origin: Start (lon, lat) or (lon, lat, elev) or Waypoint.
|
|
134
|
+
destination: End (lon, lat) or (lon, lat, elev) or Waypoint.
|
|
135
|
+
network: Road network GeoJSON filepath, dictionary, GeoDataFrame, or RoadSegment list.
|
|
136
|
+
profile: Profile key (e.g. 'adult', 'wheelchair', 'bicycle') or a MobilityProfile
|
|
137
|
+
instance -- including a custom one, which is now honoured rather than
|
|
138
|
+
silently replaced by the default profile.
|
|
139
|
+
dem_sampler: Optional environmental/elevation raster sampler.
|
|
140
|
+
mcda_weights: Optional AHP multi-criteria weighting configuration.
|
|
141
|
+
intermediate_stops: Optional intermediate waypoints.
|
|
142
|
+
optimize_stops_order: Solve TSP tour over intermediate stops if True.
|
|
143
|
+
fetch_online_dem: Query Open-Elevation once for every network node when no
|
|
144
|
+
DEM raster is configured. Needs network access; without it (and without
|
|
145
|
+
a DEM) the route is computed on flat terrain.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
RouteResult3D containing 3D coordinates, elevation profile, and rich kinematic statistics.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: if the network or the profile cannot be interpreted.
|
|
152
|
+
"""
|
|
153
|
+
resolved_profile = resolve_profile(profile, strict=True)
|
|
154
|
+
engine = _build_engine(network, dem_sampler, mcda_weights, fetch_online_dem)
|
|
155
|
+
|
|
156
|
+
waypoints = [_to_waypoint(origin, "Origin")]
|
|
157
|
+
if intermediate_stops:
|
|
158
|
+
for idx, stop in enumerate(intermediate_stops, start=1):
|
|
159
|
+
waypoints.append(_to_waypoint(stop, f"Stop {idx}"))
|
|
160
|
+
waypoints.append(_to_waypoint(destination, "Destination"))
|
|
161
|
+
|
|
162
|
+
return engine.calculate_route(
|
|
163
|
+
waypoints, profile_key=resolved_profile, optimize_tsp=optimize_stops_order
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def solve_3d_isochrones(
|
|
168
|
+
center: Union[Tuple[float, float], Waypoint],
|
|
169
|
+
network: NetworkInput,
|
|
170
|
+
profile: ProfileInput = "adult",
|
|
171
|
+
time_intervals_min: Sequence[float] = (5.0, 10.0, 15.0),
|
|
172
|
+
dem_sampler: Optional[EnvironmentalSurfaceSampler] = None,
|
|
173
|
+
fetch_online_dem: bool = False,
|
|
174
|
+
) -> IsochroneResult:
|
|
175
|
+
"""Compute anisotropic 3D isochrone travel-time wavefront bands from a center point."""
|
|
176
|
+
resolved_profile = resolve_profile(profile, strict=True)
|
|
177
|
+
engine = _build_engine(network, dem_sampler, None, fetch_online_dem)
|
|
178
|
+
|
|
179
|
+
origin_wp = _to_waypoint(center, "Center")
|
|
180
|
+
|
|
181
|
+
iso_engine = IsochroneEngine3D(engine)
|
|
182
|
+
return iso_engine.compute_isochrones(
|
|
183
|
+
origin_wp, profile_key=resolved_profile, time_intervals_min=time_intervals_min
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def solve_4d_pareto_frontier(
|
|
188
|
+
origin: Union[Tuple[float, float], Waypoint],
|
|
189
|
+
destination: Union[Tuple[float, float], Waypoint],
|
|
190
|
+
network: NetworkInput,
|
|
191
|
+
profile: ProfileInput = "bicycle",
|
|
192
|
+
dem_sampler: Optional[EnvironmentalSurfaceSampler] = None,
|
|
193
|
+
fetch_online_dem: bool = False,
|
|
194
|
+
) -> ParetoFrontierResult:
|
|
195
|
+
"""Compute non-dominated 4D Pareto frontier trade-offs across Time, Climb, Heat, and Calories."""
|
|
196
|
+
resolved_profile = resolve_profile(profile, strict=True)
|
|
197
|
+
engine = _build_engine(network, dem_sampler, None, fetch_online_dem)
|
|
198
|
+
|
|
199
|
+
start_wp = _to_waypoint(origin, "Origin")
|
|
200
|
+
end_wp = _to_waypoint(destination, "Destination")
|
|
201
|
+
|
|
202
|
+
start_node = engine.find_nearest_node((start_wp.lon, start_wp.lat))
|
|
203
|
+
end_node = engine.find_nearest_node((end_wp.lon, end_wp.lat))
|
|
204
|
+
|
|
205
|
+
if start_node is None or end_node is None:
|
|
206
|
+
raise ValueError("Could not snap origin or destination to network graph.")
|
|
207
|
+
|
|
208
|
+
pareto_router = ParetoMultiObjectiveRouter(engine.nodes, engine.adj, engine.sampler)
|
|
209
|
+
return pareto_router.solve_pareto_frontier(start_node, end_node, profile_key=resolved_profile)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def match_gps_track_3d(
|
|
213
|
+
gpx_points: Sequence[Union[GPXPoint, Tuple[float, float], Tuple[float, float, float]]],
|
|
214
|
+
network: NetworkInput,
|
|
215
|
+
sigma_z: float = 4.07,
|
|
216
|
+
beta: float = 3.0,
|
|
217
|
+
) -> MapMatching3DResult:
|
|
218
|
+
"""Match noisy GPS/GPX track coordinates onto the 3D road network via HMM Viterbi decoding.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
gpx_points: GPXPoint objects or (lon, lat[, elevation]) tuples.
|
|
222
|
+
network: Road network, in any form accepted by the other helpers.
|
|
223
|
+
sigma_z: GPS emission standard deviation in metres.
|
|
224
|
+
beta: Transition-probability scale for route-vs-crow-fly distance.
|
|
225
|
+
"""
|
|
226
|
+
engine = _build_engine(network)
|
|
227
|
+
|
|
228
|
+
pts: List[GPXPoint] = []
|
|
229
|
+
for p in gpx_points:
|
|
230
|
+
if isinstance(p, GPXPoint):
|
|
231
|
+
pts.append(p)
|
|
232
|
+
elif isinstance(p, (tuple, list)) and len(p) >= 2:
|
|
233
|
+
pts.append(
|
|
234
|
+
GPXPoint(
|
|
235
|
+
lon=float(p[0]),
|
|
236
|
+
lat=float(p[1]),
|
|
237
|
+
elevation_raw_m=float(p[2]) if len(p) > 2 else 0.0,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
raise ValueError(f"Invalid GPS point {p!r}: expected (lon, lat[, elevation]).")
|
|
242
|
+
|
|
243
|
+
matcher = HMMMapMatcher3D(engine.nodes, engine.adj, sigma_z=sigma_z, beta=beta)
|
|
244
|
+
return matcher.match_gps_track(pts)
|
zero2route3d/basemap.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""OpenStreetMap XYZ tile basemap loader for 02Route 3D."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Tuple
|
|
6
|
+
|
|
7
|
+
OSM_BASEMAP_NAME = "OpenStreetMap Standard"
|
|
8
|
+
OSM_BASEMAP_PROPERTY = "zero2route3d/osm_basemap"
|
|
9
|
+
OSM_BASEMAP_URI = (
|
|
10
|
+
"type=xyz&url=https://tile.openstreetmap.org/{z}/{x}/{y}.png&zmin=0&zmax=19&crs=EPSG3857"
|
|
11
|
+
)
|
|
12
|
+
OSM_ATTRIBUTION = "© OpenStreetMap contributors"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_osm_basemap(project: Any = None) -> Tuple[Any, bool]:
|
|
16
|
+
"""Add or reveal the standard OSM XYZ layer.
|
|
17
|
+
|
|
18
|
+
Returns the layer and a boolean indicating whether a new layer was created.
|
|
19
|
+
"""
|
|
20
|
+
from qgis.core import QgsProject, QgsRasterLayer
|
|
21
|
+
|
|
22
|
+
target = project or QgsProject.instance()
|
|
23
|
+
for layer in target.mapLayers().values():
|
|
24
|
+
if layer.customProperty(OSM_BASEMAP_PROPERTY, False):
|
|
25
|
+
layer_tree = target.layerTreeRoot().findLayer(layer.id())
|
|
26
|
+
if layer_tree is not None:
|
|
27
|
+
layer_tree.setItemVisibilityChecked(True)
|
|
28
|
+
return layer, False
|
|
29
|
+
|
|
30
|
+
layer = QgsRasterLayer(OSM_BASEMAP_URI, OSM_BASEMAP_NAME, "wms")
|
|
31
|
+
if not layer.isValid():
|
|
32
|
+
raise ValueError("QGIS could not create the OpenStreetMap basemap.")
|
|
33
|
+
layer.setCustomProperty(OSM_BASEMAP_PROPERTY, True)
|
|
34
|
+
layer.setCustomProperty("zero2route3d/attribution", OSM_ATTRIBUTION)
|
|
35
|
+
if hasattr(layer, "serverProperties") and layer.serverProperties() is not None:
|
|
36
|
+
layer.serverProperties().setAttribution(OSM_ATTRIBUTION)
|
|
37
|
+
layer.serverProperties().setAttributionUrl("https://www.openstreetmap.org/copyright")
|
|
38
|
+
else:
|
|
39
|
+
layer.setAttribution(OSM_ATTRIBUTION)
|
|
40
|
+
layer.setAttributionUrl("https://www.openstreetmap.org/copyright")
|
|
41
|
+
target.addMapLayer(layer, False)
|
|
42
|
+
# Insert at the bottom of the layer tree
|
|
43
|
+
target.layerTreeRoot().insertLayer(len(target.layerTreeRoot().children()), layer)
|
|
44
|
+
return layer, True
|
zero2route3d/cli.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Command Line Interface (CLI) for zero2route3d.
|
|
2
|
+
|
|
3
|
+
Provides standalone terminal commands for routing, isochrone generation,
|
|
4
|
+
mobility profile inspection, and 3D format export.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .api import solve_3d_isochrones, solve_3d_route
|
|
17
|
+
from .mobility_profiles import PROFILE_CATEGORIES, PROFILES, list_profile_keys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_coords(coord_str: str) -> Tuple[float, float]:
|
|
21
|
+
"""Parse a 'lon,lat' argument, raising an argparse-friendly error on bad input."""
|
|
22
|
+
parts = [chunk.strip() for chunk in str(coord_str).split(",")]
|
|
23
|
+
if len(parts) < 2:
|
|
24
|
+
raise argparse.ArgumentTypeError(f"Expected 'lon,lat', got '{coord_str}'.")
|
|
25
|
+
try:
|
|
26
|
+
lon, lat = float(parts[0]), float(parts[1])
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise argparse.ArgumentTypeError(
|
|
29
|
+
f"Expected numeric 'lon,lat', got '{coord_str}'."
|
|
30
|
+
) from None
|
|
31
|
+
if not (-180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0):
|
|
32
|
+
raise argparse.ArgumentTypeError(
|
|
33
|
+
f"Coordinates out of range: lon={lon}, lat={lat} (expected WGS84 lon,lat)."
|
|
34
|
+
)
|
|
35
|
+
return (lon, lat)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_intervals(value: str) -> List[float]:
|
|
39
|
+
"""Parse a comma-separated list of positive minute values."""
|
|
40
|
+
try:
|
|
41
|
+
intervals = [float(chunk.strip()) for chunk in str(value).split(",") if chunk.strip()]
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise argparse.ArgumentTypeError(
|
|
44
|
+
f"Expected comma-separated minutes, got '{value}'."
|
|
45
|
+
) from None
|
|
46
|
+
if not intervals or any(minutes <= 0 for minutes in intervals):
|
|
47
|
+
raise argparse.ArgumentTypeError("Isochrone intervals must be positive minute values.")
|
|
48
|
+
return intervals
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
52
|
+
parser = argparse.ArgumentParser(
|
|
53
|
+
prog="zero2route3d",
|
|
54
|
+
description="02Route 3D - Headless 3D Spatial Mobility & Kinematic Routing Engine",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
57
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
58
|
+
|
|
59
|
+
p_profiles = subparsers.add_parser(
|
|
60
|
+
"profiles", help=f"List all {len(PROFILES)} calibrated mobility profiles"
|
|
61
|
+
)
|
|
62
|
+
p_profiles.add_argument(
|
|
63
|
+
"--category",
|
|
64
|
+
choices=["all", *sorted(PROFILE_CATEGORIES)],
|
|
65
|
+
default="all",
|
|
66
|
+
help="Restrict the listing to one profile category.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
p_route = subparsers.add_parser(
|
|
70
|
+
"route", help="Compute 3D least-cost kinematic route between two points"
|
|
71
|
+
)
|
|
72
|
+
p_route.add_argument(
|
|
73
|
+
"--origin", required=True, type=_parse_coords, help="Origin coordinates as 'lon,lat'"
|
|
74
|
+
)
|
|
75
|
+
p_route.add_argument(
|
|
76
|
+
"--dest", required=True, type=_parse_coords, help="Destination coordinates as 'lon,lat'"
|
|
77
|
+
)
|
|
78
|
+
p_route.add_argument("--network", required=True, help="Path to road network GeoJSON file")
|
|
79
|
+
p_route.add_argument(
|
|
80
|
+
"--profile", default="adult", choices=list_profile_keys(), help="Mobility profile key"
|
|
81
|
+
)
|
|
82
|
+
p_route.add_argument(
|
|
83
|
+
"--dem", help="Optional DEM GeoTIFF used to sample real elevations along the route"
|
|
84
|
+
)
|
|
85
|
+
p_route.add_argument(
|
|
86
|
+
"--fetch-dem",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="Query Open-Elevation for network elevations when no --dem is given (needs network).",
|
|
89
|
+
)
|
|
90
|
+
p_route.add_argument("--out-geojson", help="Output GeoJSON filepath")
|
|
91
|
+
p_route.add_argument("--out-gpx", help="Output GPX 1.1 track filepath")
|
|
92
|
+
p_route.add_argument("--out-dxf", help="Output AutoCAD DXF 3D Polyline filepath")
|
|
93
|
+
p_route.add_argument("--out-html", help="Output self-contained interactive HTML report")
|
|
94
|
+
|
|
95
|
+
p_iso = subparsers.add_parser(
|
|
96
|
+
"isochrone", help="Compute 3D anisotropic isochrone wavefront bands"
|
|
97
|
+
)
|
|
98
|
+
p_iso.add_argument(
|
|
99
|
+
"--center", required=True, type=_parse_coords, help="Center coordinates as 'lon,lat'"
|
|
100
|
+
)
|
|
101
|
+
p_iso.add_argument("--network", required=True, help="Path to road network GeoJSON file")
|
|
102
|
+
p_iso.add_argument(
|
|
103
|
+
"--intervals",
|
|
104
|
+
default="5,10,15",
|
|
105
|
+
type=_parse_intervals,
|
|
106
|
+
help="Comma-separated time intervals in minutes",
|
|
107
|
+
)
|
|
108
|
+
p_iso.add_argument("--profile", default="adult", choices=list_profile_keys())
|
|
109
|
+
p_iso.add_argument("--dem", help="Optional DEM GeoTIFF for real elevation sampling")
|
|
110
|
+
p_iso.add_argument(
|
|
111
|
+
"--fetch-dem",
|
|
112
|
+
action="store_true",
|
|
113
|
+
help="Query Open-Elevation for network elevations when no --dem is given (needs network).",
|
|
114
|
+
)
|
|
115
|
+
p_iso.add_argument("--out-geojson", help="Output GeoJSON filepath")
|
|
116
|
+
|
|
117
|
+
return parser
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _make_sampler(dem_path: Optional[str]) -> Optional[object]:
|
|
121
|
+
if not dem_path:
|
|
122
|
+
return None
|
|
123
|
+
from .environmental_raster import EnvironmentalSurfaceSampler
|
|
124
|
+
|
|
125
|
+
return EnvironmentalSurfaceSampler(dem=dem_path)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _cmd_profiles(args: argparse.Namespace) -> int:
|
|
129
|
+
if args.category == "all":
|
|
130
|
+
keys = list_profile_keys()
|
|
131
|
+
heading = "all categories"
|
|
132
|
+
else:
|
|
133
|
+
keys = list(PROFILE_CATEGORIES[args.category]["keys"])
|
|
134
|
+
heading = str(PROFILE_CATEGORIES[args.category]["title"])
|
|
135
|
+
|
|
136
|
+
print(f"=== 02Route 3D Mobility Profiles - {heading} (v{__version__}) ===")
|
|
137
|
+
header = (
|
|
138
|
+
f"{'Key':<16} {'Name':<24} {'Category':<15} "
|
|
139
|
+
f"{'Speed':<12} {'Max Slope':<11} {'Stairs Policy'}"
|
|
140
|
+
)
|
|
141
|
+
print(header)
|
|
142
|
+
print("-" * len(header))
|
|
143
|
+
for key in keys:
|
|
144
|
+
prof = PROFILES.get(key)
|
|
145
|
+
if prof is None:
|
|
146
|
+
continue
|
|
147
|
+
stairs = "Allowed" if prof.stair_allowed else "Forbidden"
|
|
148
|
+
print(
|
|
149
|
+
f"{prof.key:<16} {prof.name:<24} {prof.category:<15} "
|
|
150
|
+
f"{prof.base_speed_kmh:>5.1f} km/h {prof.max_slope_pct:>8.1f}% {stairs}"
|
|
151
|
+
)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _cmd_route(args: argparse.Namespace) -> int:
|
|
156
|
+
print(f"Calculating 3D route from {args.origin} to {args.dest} [Profile: {args.profile}]...")
|
|
157
|
+
result = solve_3d_route(
|
|
158
|
+
args.origin,
|
|
159
|
+
args.dest,
|
|
160
|
+
network=args.network,
|
|
161
|
+
profile=args.profile,
|
|
162
|
+
dem_sampler=_make_sampler(args.dem), # type: ignore[arg-type]
|
|
163
|
+
fetch_online_dem=args.fetch_dem,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if not result.is_network_matched:
|
|
167
|
+
print(f"Error: {result.status_message}", file=sys.stderr)
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
stats = result.statistics
|
|
171
|
+
print("Success!")
|
|
172
|
+
print(f" Distance: {stats.total_distance_km:.2f} km")
|
|
173
|
+
print(f" Duration: {stats.total_duration_min:.1f} min")
|
|
174
|
+
print(f" Elevation Gain: +{stats.elevation_gain_m:.1f} m")
|
|
175
|
+
print(f" Elevation Loss: -{stats.elevation_loss_m:.1f} m")
|
|
176
|
+
print(f" Max Slope: {stats.max_slope_pct:.1f}%")
|
|
177
|
+
print(f" Calories: {stats.total_calories_kcal:.0f} kcal")
|
|
178
|
+
|
|
179
|
+
if args.out_geojson:
|
|
180
|
+
Path(args.out_geojson).write_text(
|
|
181
|
+
json.dumps(result.to_geojson_feature(), indent=2), encoding="utf-8"
|
|
182
|
+
)
|
|
183
|
+
print(f" Saved GeoJSON: {args.out_geojson}")
|
|
184
|
+
|
|
185
|
+
if args.out_gpx:
|
|
186
|
+
Path(args.out_gpx).write_text(result.to_gpx(), encoding="utf-8")
|
|
187
|
+
print(f" Saved GPX: {args.out_gpx}")
|
|
188
|
+
|
|
189
|
+
if args.out_dxf:
|
|
190
|
+
result.to_dxf(args.out_dxf)
|
|
191
|
+
print(f" Saved DXF 3D: {args.out_dxf}")
|
|
192
|
+
|
|
193
|
+
if args.out_html:
|
|
194
|
+
result.to_html(args.out_html)
|
|
195
|
+
print(f" Saved report: {args.out_html}")
|
|
196
|
+
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cmd_isochrone(args: argparse.Namespace) -> int:
|
|
201
|
+
print(f"Computing 3D isochrones from {args.center} with intervals {args.intervals} min...")
|
|
202
|
+
iso_res = solve_3d_isochrones(
|
|
203
|
+
args.center,
|
|
204
|
+
network=args.network,
|
|
205
|
+
profile=args.profile,
|
|
206
|
+
time_intervals_min=args.intervals,
|
|
207
|
+
dem_sampler=_make_sampler(args.dem), # type: ignore[arg-type]
|
|
208
|
+
fetch_online_dem=args.fetch_dem,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
print(f"Reachable nodes: {iso_res.total_reachable_nodes}")
|
|
212
|
+
for band in iso_res.bands:
|
|
213
|
+
print(
|
|
214
|
+
f" Band {band.time_cutoff_min:5.1f} min: "
|
|
215
|
+
f"{band.reachable_node_count:6d} nodes, "
|
|
216
|
+
f"{len(band.boundary_points):4d} hull vertices, "
|
|
217
|
+
f"area {band.area_sqkm:.2f} km2"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if args.out_geojson:
|
|
221
|
+
Path(args.out_geojson).write_text(
|
|
222
|
+
json.dumps(iso_res.to_geojson(), indent=2), encoding="utf-8"
|
|
223
|
+
)
|
|
224
|
+
print(f" Saved GeoJSON: {args.out_geojson}")
|
|
225
|
+
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
230
|
+
parser = _build_parser()
|
|
231
|
+
args = parser.parse_args(argv)
|
|
232
|
+
|
|
233
|
+
handlers = {
|
|
234
|
+
"profiles": _cmd_profiles,
|
|
235
|
+
"route": _cmd_route,
|
|
236
|
+
"isochrone": _cmd_isochrone,
|
|
237
|
+
}
|
|
238
|
+
handler = handlers.get(args.command or "")
|
|
239
|
+
if handler is None:
|
|
240
|
+
parser.print_help()
|
|
241
|
+
return 2
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
return handler(args)
|
|
245
|
+
except (FileNotFoundError, ValueError, KeyError) as exc:
|
|
246
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Public Copernicus DEM GLO-30 tile URL and extent helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import List, Sequence, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CopernicusDemError(ValueError):
|
|
10
|
+
"""Raised when a Copernicus DEM request extent is invalid or too large."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CopernicusDemTileSource:
|
|
14
|
+
"""Build URLs for the public one-degree Copernicus DEM COG tiles."""
|
|
15
|
+
|
|
16
|
+
BASE_URL = "https://copernicus-dem-30m.s3.amazonaws.com"
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def tile_id(cls, lon: float, lat: float) -> str:
|
|
20
|
+
try:
|
|
21
|
+
lon_value = float(lon)
|
|
22
|
+
lat_value = float(lat)
|
|
23
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
24
|
+
raise CopernicusDemError("Copernicus tile coordinates must be numeric.") from exc
|
|
25
|
+
if not (math.isfinite(lon_value) and math.isfinite(lat_value)):
|
|
26
|
+
raise CopernicusDemError("Copernicus tile coordinates must be finite.")
|
|
27
|
+
if not (-180.0 <= lon_value <= 180.0 and -90.0 <= lat_value <= 90.0):
|
|
28
|
+
raise CopernicusDemError("Copernicus tile coordinates are outside WGS84 bounds.")
|
|
29
|
+
|
|
30
|
+
lat_index = math.floor(lat_value)
|
|
31
|
+
lon_index = math.floor(lon_value)
|
|
32
|
+
north_south = "N" if lat_index >= 0 else "S"
|
|
33
|
+
east_west = "E" if lon_index >= 0 else "W"
|
|
34
|
+
return f"{north_south}{abs(lat_index):02d}_00_{east_west}{abs(lon_index):03d}_00"
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def tile_url(cls, lon: float, lat: float) -> str:
|
|
38
|
+
tile = cls.tile_id(lon, lat)
|
|
39
|
+
prefix = f"Copernicus_DSM_COG_10_{tile}_DEM"
|
|
40
|
+
return f"{cls.BASE_URL}/{prefix}/{prefix}.tif"
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def tiles_for_bbox(
|
|
44
|
+
cls,
|
|
45
|
+
bbox: Sequence[float],
|
|
46
|
+
max_tiles: int = 16,
|
|
47
|
+
) -> List[Tuple[int, int, str]]:
|
|
48
|
+
if not bbox or len(bbox) < 4:
|
|
49
|
+
raise CopernicusDemError("A four-coordinate extent is required for Copernicus DEM.")
|
|
50
|
+
min_lon, min_lat, max_lon, max_lat = (float(value) for value in bbox[:4])
|
|
51
|
+
if not all(math.isfinite(value) for value in (min_lon, min_lat, max_lon, max_lat)):
|
|
52
|
+
raise CopernicusDemError("The Copernicus DEM extent contains invalid coordinates.")
|
|
53
|
+
if min_lon > max_lon:
|
|
54
|
+
min_lon, max_lon = max_lon, min_lon
|
|
55
|
+
if min_lat > max_lat:
|
|
56
|
+
min_lat, max_lat = max_lat, min_lat
|
|
57
|
+
min_lon = max(-180.0, min(180.0, min_lon))
|
|
58
|
+
max_lon = max(-180.0, min(180.0, max_lon))
|
|
59
|
+
min_lat = max(-90.0, min(90.0, min_lat))
|
|
60
|
+
max_lat = max(-90.0, min(90.0, max_lat))
|
|
61
|
+
|
|
62
|
+
lon_start = math.floor(min_lon)
|
|
63
|
+
lon_end = math.floor(max_lon if max_lon < 180.0 else 179.999999)
|
|
64
|
+
lat_start = math.floor(min_lat)
|
|
65
|
+
lat_end = math.floor(max_lat if max_lat < 90.0 else 89.999999)
|
|
66
|
+
tiles = []
|
|
67
|
+
for lat_index in range(lat_start, lat_end + 1):
|
|
68
|
+
for lon_index in range(lon_start, lon_end + 1):
|
|
69
|
+
url = cls.tile_url(lon_index + 0.1, lat_index + 0.1)
|
|
70
|
+
tiles.append((lon_index, lat_index, url))
|
|
71
|
+
if len(tiles) > max(1, int(max_tiles)):
|
|
72
|
+
raise CopernicusDemError(
|
|
73
|
+
f"The extent spans {len(tiles)} Copernicus DEM tiles; zoom to a smaller area "
|
|
74
|
+
f"(maximum {max_tiles})."
|
|
75
|
+
)
|
|
76
|
+
return tiles
|