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
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)
@@ -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