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,100 @@
1
+ """Small, dependency-free validation helpers shared by routing workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any, Iterable, List, Optional, Sequence, Tuple
7
+
8
+
9
+ def finite_number(value: Any, default: float = 0.0) -> float:
10
+ """Return a finite float or a safe default."""
11
+ try:
12
+ number = float(value)
13
+ except (TypeError, ValueError, OverflowError):
14
+ return default
15
+ return number if math.isfinite(number) else default
16
+
17
+
18
+ def normalize_bbox(
19
+ bbox: Sequence[Any],
20
+ minimum_span: float = 0.005,
21
+ ) -> Tuple[float, float, float, float]:
22
+ """Validate and normalize ``(min_lon, min_lat, max_lon, max_lat)``."""
23
+ if not bbox or len(bbox) < 4:
24
+ raise ValueError("A bounding box needs four coordinates.")
25
+ values = [finite_number(value, float("nan")) for value in bbox[:4]]
26
+ if not all(math.isfinite(value) for value in values):
27
+ raise ValueError("The bounding box contains invalid coordinates.")
28
+ min_lon, min_lat, max_lon, max_lat = values
29
+ if not (-180.0 <= min_lon <= 180.0 and -180.0 <= max_lon <= 180.0):
30
+ raise ValueError("Longitude must be between -180 and 180 degrees.")
31
+ if not (-90.0 <= min_lat <= 90.0 and -90.0 <= max_lat <= 90.0):
32
+ raise ValueError("Latitude must be between -90 and 90 degrees.")
33
+ if min_lon > max_lon:
34
+ min_lon, max_lon = max_lon, min_lon
35
+ if min_lat > max_lat:
36
+ min_lat, max_lat = max_lat, min_lat
37
+ span = max(0.0001, finite_number(minimum_span, 0.005))
38
+ if max_lon - min_lon < span:
39
+ center = (min_lon + max_lon) / 2.0
40
+ min_lon, max_lon = center - span / 2.0, center + span / 2.0
41
+ if max_lat - min_lat < span:
42
+ center = (min_lat + max_lat) / 2.0
43
+ min_lat, max_lat = center - span / 2.0, center + span / 2.0
44
+ return min_lon, min_lat, max_lon, max_lat
45
+
46
+
47
+ def normalize_time_intervals(
48
+ intervals: Iterable[Any],
49
+ max_intervals: int = 12,
50
+ max_minutes: float = 24.0 * 60.0,
51
+ ) -> List[float]:
52
+ """Return sorted, unique, positive time cutoffs within a safe bound."""
53
+ cleaned = {
54
+ round(value, 3)
55
+ for raw in intervals
56
+ for value in [finite_number(raw, -1.0)]
57
+ if 0.0 < value <= max_minutes
58
+ }
59
+ return sorted(cleaned)[: max(1, int(max_intervals))]
60
+
61
+
62
+ def validate_waypoint_coordinates(waypoints: Sequence[Any]) -> Optional[str]:
63
+ """Validate waypoint count and WGS84 coordinate ranges."""
64
+ if not waypoints:
65
+ return "At least two waypoints are required."
66
+ if len(waypoints) > 500:
67
+ return "A route can contain at most 500 waypoints."
68
+ for index, waypoint in enumerate(waypoints, start=1):
69
+ lon = finite_number(getattr(waypoint, "lon", float("nan")), float("nan"))
70
+ lat = finite_number(getattr(waypoint, "lat", float("nan")), float("nan"))
71
+ if not (math.isfinite(lon) and math.isfinite(lat)):
72
+ return f"Waypoint {index} has invalid coordinates."
73
+ if not (-180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0):
74
+ return f"Waypoint {index} is outside the WGS84 coordinate range."
75
+ return None
76
+
77
+
78
+ def deduplicate_adjacent_coordinates(
79
+ coords: Sequence[Sequence[float]],
80
+ tolerance_m: float = 0.05,
81
+ ) -> List[Tuple[float, float, float]]:
82
+ """Remove consecutive coordinates that represent the same map position."""
83
+ from .kinematics import haversine_distance_2d
84
+
85
+ result: List[Tuple[float, float, float]] = []
86
+ tolerance = max(0.0, finite_number(tolerance_m, 0.05))
87
+ for coord in coords:
88
+ if not coord or len(coord) < 2:
89
+ continue
90
+ lon = finite_number(coord[0], float("nan"))
91
+ lat = finite_number(coord[1], float("nan"))
92
+ if not (math.isfinite(lon) and math.isfinite(lat)):
93
+ continue
94
+ z = finite_number(coord[2], 0.0) if len(coord) > 2 else 0.0
95
+ point = (lon, lat, z)
96
+ if not result or haversine_distance_2d(result[-1], point) > tolerance:
97
+ result.append(point)
98
+ elif abs(z - result[-1][2]) > 0.01:
99
+ result[-1] = point
100
+ return result
@@ -0,0 +1,260 @@
1
+ """Geospatial ecosystem integrations: GeoPandas, Shapely, NetworkX, and Rasterio.
2
+
3
+ Allows seamless conversion between 02Route 3D objects and standard Python GIS libraries.
4
+ All dependencies are lazily loaded with graceful fallbacks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
12
+
13
+ from .kinematics import haversine_distance_2d
14
+ from .network_source import RoadSegment
15
+ from .raster_source import GeoTiffRasterSource
16
+
17
+
18
+ def to_shapely_linestring(coordinates_3d: Sequence[Sequence[float]]) -> Any:
19
+ """Convert 3D coordinate sequence to a shapely.geometry.LineString (Z-aware)."""
20
+ try:
21
+ import shapely.geometry
22
+
23
+ return shapely.geometry.LineString([c[:3] for c in coordinates_3d])
24
+ except ImportError as exc:
25
+ raise ImportError(
26
+ "shapely is required for geometric conversion. Install via 'pip install shapely'."
27
+ ) from exc
28
+
29
+
30
+ def from_geodataframe(
31
+ gdf: Any,
32
+ id_column: Optional[str] = None,
33
+ highway_column: Optional[str] = "highway",
34
+ surface_column: Optional[str] = "surface",
35
+ oneway_column: Optional[str] = "oneway",
36
+ ) -> List[RoadSegment]:
37
+ """Extract 02Route 3D RoadSegment objects from a geopandas.GeoDataFrame of LineStrings."""
38
+ try:
39
+ import geopandas as gpd
40
+ except ImportError as exc:
41
+ raise ImportError("geopandas is required. Install via 'pip install geopandas'.") from exc
42
+
43
+ if not isinstance(gdf, gpd.GeoDataFrame):
44
+ raise TypeError("Expected geopandas.GeoDataFrame instance.")
45
+
46
+ segments: List[RoadSegment] = []
47
+
48
+ for _idx, row in gdf.iterrows():
49
+ geom = row.geometry
50
+ if geom is None or geom.is_empty:
51
+ continue
52
+
53
+ lines = (
54
+ [geom]
55
+ if geom.geom_type == "LineString"
56
+ else (geom.geoms if geom.geom_type == "MultiLineString" else [])
57
+ )
58
+ for line in lines:
59
+ coords = list(line.coords)
60
+ if len(coords) < 2:
61
+ continue
62
+
63
+ highway_val = (
64
+ str(row.get(highway_column, "residential"))
65
+ if highway_column in row
66
+ else "residential"
67
+ )
68
+ surface_val = (
69
+ str(row.get(surface_column, "asphalt")) if surface_column in row else "asphalt"
70
+ )
71
+ oneway_raw = row.get(oneway_column, False) if oneway_column in row else False
72
+ oneway_tag = str(oneway_raw).strip().lower()
73
+ # oneway=-1 means one-way *against* the digitisation order. Treating it
74
+ # as a plain one-way stores the segment pointing the wrong way, so the
75
+ # router sends traffic the wrong direction down it.
76
+ reversed_oneway = oneway_tag in ("-1", "reverse")
77
+ is_oneway = bool(
78
+ oneway_raw is True or oneway_tag in ("yes", "true", "1") or reversed_oneway
79
+ )
80
+ is_steps = "step" in highway_val.lower()
81
+
82
+ for i in range(len(coords) - 1):
83
+ p1_raw = coords[i]
84
+ p2_raw = coords[i + 1]
85
+
86
+ p1: Tuple[float, float, float] = (
87
+ float(p1_raw[0]),
88
+ float(p1_raw[1]),
89
+ float(p1_raw[2]) if len(p1_raw) > 2 and math.isfinite(p1_raw[2]) else 0.0,
90
+ )
91
+ p2: Tuple[float, float, float] = (
92
+ float(p2_raw[0]),
93
+ float(p2_raw[1]),
94
+ float(p2_raw[2]) if len(p2_raw) > 2 and math.isfinite(p2_raw[2]) else 0.0,
95
+ )
96
+
97
+ length_m = haversine_distance_2d(p1, p2)
98
+
99
+ seg = RoadSegment(
100
+ p1=p2 if reversed_oneway else p1,
101
+ p2=p1 if reversed_oneway else p2,
102
+ length_m=length_m,
103
+ highway_type=highway_val,
104
+ surface=surface_val,
105
+ is_oneway=is_oneway,
106
+ is_steps=is_steps,
107
+ )
108
+ segments.append(seg)
109
+
110
+ return segments
111
+
112
+
113
+ def to_geodataframe(
114
+ items: Union[List[RoadSegment], Any],
115
+ crs: str = "EPSG:4326",
116
+ ) -> Any:
117
+ """Convert RoadSegment list or RouteResult3D into a geopandas.GeoDataFrame with 3D LineStrings."""
118
+ try:
119
+ import geopandas as gpd
120
+ import shapely.geometry
121
+ except ImportError as exc:
122
+ raise ImportError("geopandas and shapely are required for GeoDataFrame creation.") from exc
123
+
124
+ records: List[Dict[str, Any]] = []
125
+
126
+ # Check if input is RouteResult3D
127
+ if hasattr(items, "coordinates_3d") and hasattr(items, "statistics"):
128
+ coords = items.coordinates_3d
129
+ if len(coords) >= 2:
130
+ geom = shapely.geometry.LineString(coords)
131
+ records.append(
132
+ {
133
+ "geometry": geom,
134
+ "distance_m": items.statistics.total_distance_m,
135
+ "duration_min": items.statistics.total_duration_min,
136
+ "climb_m": items.statistics.elevation_gain_m,
137
+ "descent_m": items.statistics.elevation_loss_m,
138
+ "max_slope_pct": items.statistics.max_slope_pct,
139
+ "profile": getattr(
140
+ getattr(items, "profile", None),
141
+ "name",
142
+ getattr(items, "profile_name", "3D Route"),
143
+ ),
144
+ }
145
+ )
146
+ elif isinstance(items, list):
147
+ for seg in items:
148
+ if isinstance(seg, RoadSegment):
149
+ geom = shapely.geometry.LineString([seg.p1, seg.p2])
150
+ records.append(
151
+ {
152
+ "geometry": geom,
153
+ "length_m": seg.length_m,
154
+ "highway_type": seg.highway_type,
155
+ "surface": seg.surface,
156
+ "is_oneway": seg.is_oneway,
157
+ "is_steps": seg.is_steps,
158
+ }
159
+ )
160
+
161
+ if not records:
162
+ # GeoDataFrame([]) has no geometry column, so every downstream .geometry
163
+ # access raises instead of yielding an empty result set.
164
+ return gpd.GeoDataFrame({"geometry": []}, geometry="geometry", crs=crs)
165
+
166
+ return gpd.GeoDataFrame(records, geometry="geometry", crs=crs)
167
+
168
+
169
+ def to_networkx_digraph(engine_or_segments: Any) -> Any:
170
+ """Convert 02Route 3D graph or segment list into a networkx.DiGraph with kinematic edge weights."""
171
+ try:
172
+ import networkx as nx
173
+ except ImportError as exc:
174
+ raise ImportError("networkx is required. Install via 'pip install networkx'.") from exc
175
+
176
+ g = nx.DiGraph()
177
+
178
+ if hasattr(engine_or_segments, "nodes") and hasattr(engine_or_segments, "adj"):
179
+ for node_id, coord in engine_or_segments.nodes.items():
180
+ g.add_node(
181
+ node_id, lon=coord[0], lat=coord[1], elevation_m=coord[2] if len(coord) > 2 else 0.0
182
+ )
183
+
184
+ for u, neighbors in engine_or_segments.adj.items():
185
+ for v, length_m, slope_pct, meta in neighbors:
186
+ g.add_edge(
187
+ u,
188
+ v,
189
+ length_m=length_m,
190
+ slope_pct=slope_pct,
191
+ highway_type=meta.get("highway_type", "residential"),
192
+ surface=meta.get("surface", "asphalt"),
193
+ is_steps=meta.get("is_steps", False),
194
+ )
195
+ elif isinstance(engine_or_segments, list):
196
+ for seg in engine_or_segments:
197
+ if isinstance(seg, RoadSegment):
198
+ n1 = f"n_{round(seg.p1[0], 5)}_{round(seg.p1[1], 5)}"
199
+ n2 = f"n_{round(seg.p2[0], 5)}_{round(seg.p2[1], 5)}"
200
+ g.add_node(
201
+ n1,
202
+ lon=seg.p1[0],
203
+ lat=seg.p1[1],
204
+ elevation_m=seg.p1[2] if len(seg.p1) > 2 else 0.0,
205
+ )
206
+ g.add_node(
207
+ n2,
208
+ lon=seg.p2[0],
209
+ lat=seg.p2[1],
210
+ elevation_m=seg.p2[2] if len(seg.p2) > 2 else 0.0,
211
+ )
212
+ slope = (
213
+ ((seg.p2[2] - seg.p1[2]) / seg.length_m) * 100.0
214
+ if seg.length_m > 0 and len(seg.p1) > 2 and len(seg.p2) > 2
215
+ else 0.0
216
+ )
217
+
218
+ g.add_edge(
219
+ n1,
220
+ n2,
221
+ length_m=seg.length_m,
222
+ slope_pct=slope,
223
+ highway_type=seg.highway_type,
224
+ surface=seg.surface,
225
+ is_steps=seg.is_steps,
226
+ )
227
+ if not seg.is_oneway:
228
+ g.add_edge(
229
+ n2,
230
+ n1,
231
+ length_m=seg.length_m,
232
+ slope_pct=-slope,
233
+ highway_type=seg.highway_type,
234
+ surface=seg.surface,
235
+ is_steps=seg.is_steps,
236
+ )
237
+
238
+ return g
239
+
240
+
241
+ def sample_rasterio_dem(
242
+ tif_path: Union[str, Path],
243
+ coords: Sequence[Tuple[float, float]],
244
+ band: int = 1,
245
+ ) -> List[Optional[float]]:
246
+ """Sample elevations from a GeoTIFF at WGS84 (lon, lat) coordinates.
247
+
248
+ Coordinates are reprojected into the raster's own CRS first, so a UTM or
249
+ national-grid DEM works without the caller converting anything -- previously
250
+ every sample of such a raster silently fell outside the grid.
251
+
252
+ NoData and out-of-coverage points come back as None rather than 0.0, which is
253
+ a real elevation and must not stand in for missing data. All points are read
254
+ in a single pass.
255
+ """
256
+ source = GeoTiffRasterSource(tif_path, band=band)
257
+ try:
258
+ return source.sample_many(coords)
259
+ finally:
260
+ source.close()
@@ -0,0 +1,220 @@
1
+ """3D Network-based Isochrone and Accessibility Catchment Engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import math
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, List, Sequence, Tuple, Union
9
+
10
+ from .input_validation import normalize_time_intervals
11
+ from .mobility_profiles import MobilityProfile, resolve_profile
12
+ from .routing_engine import RoutingEngine3D, Waypoint
13
+
14
+ _EARTH_RADIUS_M = 6371000.0
15
+
16
+
17
+ def convex_hull_2d(points: Sequence[Tuple[float, float]]) -> List[Tuple[float, float]]:
18
+ """Monotone-chain convex hull of (lon, lat) points, counter-clockwise, no repeat.
19
+
20
+ Dependency-free so isochrone geometry does not require shapely.
21
+ """
22
+ unique = sorted({(float(x), float(y)) for x, y in points})
23
+ if len(unique) < 3:
24
+ return unique
25
+
26
+ def cross(o: Tuple[float, float], a: Tuple[float, float], b: Tuple[float, float]) -> float:
27
+ return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
28
+
29
+ lower: List[Tuple[float, float]] = []
30
+ for pt in unique:
31
+ while len(lower) >= 2 and cross(lower[-2], lower[-1], pt) <= 0:
32
+ lower.pop()
33
+ lower.append(pt)
34
+
35
+ upper: List[Tuple[float, float]] = []
36
+ for pt in reversed(unique):
37
+ while len(upper) >= 2 and cross(upper[-2], upper[-1], pt) <= 0:
38
+ upper.pop()
39
+ upper.append(pt)
40
+
41
+ return lower[:-1] + upper[:-1]
42
+
43
+
44
+ def _ring_area_m2(ring: Sequence[Tuple[float, float]]) -> float:
45
+ """Shoelace area of a lon/lat ring, on a local equirectangular projection."""
46
+ if len(ring) < 3:
47
+ return 0.0
48
+ mean_lat_rad = math.radians(sum(lat for _lon, lat in ring) / len(ring))
49
+ scale_x = _EARTH_RADIUS_M * math.cos(mean_lat_rad) * math.pi / 180.0
50
+ scale_y = _EARTH_RADIUS_M * math.pi / 180.0
51
+
52
+ total = 0.0
53
+ for (lon1, lat1), (lon2, lat2) in zip(ring, list(ring[1:]) + [ring[0]]):
54
+ total += (lon1 * scale_x) * (lat2 * scale_y) - (lon2 * scale_x) * (lat1 * scale_y)
55
+ return abs(total) * 0.5
56
+
57
+
58
+ @dataclass
59
+ class IsochroneBand:
60
+ """Individual time contour band (e.g. 0-5 min, 5-10 min)."""
61
+
62
+ time_cutoff_min: float
63
+ reachable_node_count: int
64
+ approx_area_ha: float
65
+ #: Convex hull of the reachable nodes, counter-clockwise, first point not repeated.
66
+ boundary_points: List[Tuple[float, float]] = field(default_factory=list)
67
+
68
+ @property
69
+ def area_sqkm(self) -> float:
70
+ """Catchment area in square kilometres."""
71
+ return self.approx_area_ha / 100.0
72
+
73
+ def to_dict(self) -> Dict[str, Any]:
74
+ return {
75
+ "time_cutoff_min": self.time_cutoff_min,
76
+ "reachable_node_count": self.reachable_node_count,
77
+ "approx_area_ha": round(self.approx_area_ha, 2),
78
+ "area_sqkm": round(self.area_sqkm, 4),
79
+ "boundary_points_count": len(self.boundary_points),
80
+ }
81
+
82
+ def to_geojson_feature(self) -> Dict[str, Any]:
83
+ """Convex-hull catchment as a GeoJSON Polygon Feature (empty geometry if degenerate)."""
84
+ ring = list(self.boundary_points)
85
+ geometry: Any = None
86
+ if len(ring) >= 3:
87
+ closed = ring + [ring[0]]
88
+ geometry = {
89
+ "type": "Polygon",
90
+ "coordinates": [[[round(lon, 6), round(lat, 6)] for lon, lat in closed]],
91
+ }
92
+ return {"type": "Feature", "geometry": geometry, "properties": self.to_dict()}
93
+
94
+
95
+ @dataclass
96
+ class IsochroneResult:
97
+ """Complete multi-tier 3D accessibility service area result."""
98
+
99
+ origin: Waypoint
100
+ profile: MobilityProfile
101
+ bands: List[IsochroneBand]
102
+ total_reachable_nodes: int
103
+
104
+ def to_dict(self) -> Dict[str, Any]:
105
+ return {
106
+ "origin": {"lon": self.origin.lon, "lat": self.origin.lat, "name": self.origin.name},
107
+ "profile_name": self.profile.name,
108
+ "total_reachable_nodes": self.total_reachable_nodes,
109
+ "bands": [b.to_dict() for b in self.bands],
110
+ }
111
+
112
+ def to_geojson(self) -> Dict[str, Any]:
113
+ """All catchment bands as one GeoJSON FeatureCollection, widest band first.
114
+
115
+ Ordering matters for rendering: drawing the widest polygon first lets the
116
+ tighter bands paint on top of it instead of being hidden underneath.
117
+ """
118
+ ordered = sorted(self.bands, key=lambda b: b.time_cutoff_min, reverse=True)
119
+ return {
120
+ "type": "FeatureCollection",
121
+ "properties": {
122
+ "origin": {"lon": self.origin.lon, "lat": self.origin.lat},
123
+ "profile_key": self.profile.key,
124
+ "profile_name": self.profile.name,
125
+ "total_reachable_nodes": self.total_reachable_nodes,
126
+ },
127
+ "features": [band.to_geojson_feature() for band in ordered],
128
+ }
129
+
130
+
131
+ class IsochroneEngine3D:
132
+ """Generates 3D multi-interval travel-time catchment zones from a topological graph."""
133
+
134
+ def __init__(self, routing_engine: RoutingEngine3D) -> None:
135
+ self.engine = routing_engine
136
+
137
+ def compute_isochrones(
138
+ self,
139
+ origin: Waypoint,
140
+ profile_key: Union[str, MobilityProfile] = "adult",
141
+ time_intervals_min: Sequence[float] = (5.0, 10.0, 15.0, 20.0, 30.0),
142
+ ) -> IsochroneResult:
143
+ """Propagate Dijkstra wavefront along 3D graph up to maximum time interval."""
144
+ profile = resolve_profile(profile_key)
145
+ intervals_min = normalize_time_intervals(time_intervals_min)
146
+ if not intervals_min:
147
+ return IsochroneResult(origin, profile, [], 0)
148
+ start_node = self.engine.find_nearest_node((origin.lon, origin.lat))
149
+
150
+ if start_node is None or not self.engine.nodes:
151
+ return IsochroneResult(origin, profile, [], 0)
152
+
153
+ max_cutoff_s = max(intervals_min) * 60.0
154
+ sorted_intervals_s = [t * 60.0 for t in intervals_min]
155
+
156
+ # Dijkstra queue: (travel_time_seconds, node_id)
157
+ pq: List[Tuple[float, int]] = [(0.0, start_node)]
158
+ min_times: Dict[int, float] = {start_node: 0.0}
159
+ visited = set()
160
+
161
+ while pq:
162
+ t_curr, u = heapq.heappop(pq)
163
+ if u in visited:
164
+ continue
165
+ visited.add(u)
166
+
167
+ if t_curr > max_cutoff_s:
168
+ continue
169
+
170
+ for v, seg_len, slope_pct, meta in self.engine.adj.get(u, []):
171
+ if v in visited:
172
+ continue
173
+
174
+ cost = profile.calculate_edge_resistance(
175
+ length_m=seg_len,
176
+ slope_pct=slope_pct,
177
+ is_steps=meta.get("is_steps", False),
178
+ hierarchy_rank=meta.get("hierarchy", 4),
179
+ )
180
+ if not math.isfinite(cost):
181
+ continue
182
+
183
+ seg_time_s = profile.travel_time_seconds(
184
+ seg_len,
185
+ slope_pct=slope_pct,
186
+ hierarchy_rank=meta.get("hierarchy", 4),
187
+ )
188
+ tentative_t = t_curr + seg_time_s
189
+
190
+ if tentative_t < min_times.get(v, float("inf")) and tentative_t <= max_cutoff_s:
191
+ min_times[v] = tentative_t
192
+ heapq.heappush(pq, (tentative_t, v))
193
+
194
+ bands: List[IsochroneBand] = []
195
+ for cutoff_s in sorted_intervals_s:
196
+ cutoff_min = cutoff_s / 60.0
197
+ nodes_in_band = [nid for nid, t in min_times.items() if t <= cutoff_s]
198
+ pts = [(self.engine.nodes[nid][0], self.engine.nodes[nid][1]) for nid in nodes_in_band]
199
+
200
+ # A real convex hull of the reachable nodes, and its real area. The
201
+ # previous version stored the first 50 nodes in dictionary order and
202
+ # called them a boundary, then reported pi*r^2 times a fudge factor.
203
+ hull = convex_hull_2d(pts)
204
+ area_ha = _ring_area_m2(hull) / 10000.0
205
+
206
+ bands.append(
207
+ IsochroneBand(
208
+ time_cutoff_min=cutoff_min,
209
+ reachable_node_count=len(nodes_in_band),
210
+ approx_area_ha=area_ha,
211
+ boundary_points=hull,
212
+ )
213
+ )
214
+
215
+ return IsochroneResult(
216
+ origin=origin,
217
+ profile=profile,
218
+ bands=bands,
219
+ total_reachable_nodes=len(min_times),
220
+ )