sameer-graph-lib 0.1.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.
- sameer_graph_lib/__init__.py +28 -0
- sameer_graph_lib/_h3.py +123 -0
- sameer_graph_lib/affinity_graph.py +775 -0
- sameer_graph_lib/corridor_extractor.py +82 -0
- sameer_graph_lib/geometry.py +75 -0
- sameer_graph_lib/hex_graph.py +15 -0
- sameer_graph_lib/plotting.py +211 -0
- sameer_graph_lib/spatial_ingestor.py +170 -0
- sameer_graph_lib/topology_analyzer.py +174 -0
- sameer_graph_lib-0.1.0.dist-info/METADATA +197 -0
- sameer_graph_lib-0.1.0.dist-info/RECORD +14 -0
- sameer_graph_lib-0.1.0.dist-info/WHEEL +5 -0
- sameer_graph_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
- sameer_graph_lib-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Dominant route corridor extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Sequence
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CorridorExtractor:
|
|
11
|
+
"""Extract compact Dijkstra clusters that cover a target metric share."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, graph_or_affinity) -> None:
|
|
14
|
+
self.graph = graph_or_affinity if isinstance(graph_or_affinity, nx.Graph) else graph_or_affinity.graph
|
|
15
|
+
|
|
16
|
+
def extract_x_percent_corridor(
|
|
17
|
+
self,
|
|
18
|
+
target_pct: float = 0.8,
|
|
19
|
+
weight_attr: str = "count",
|
|
20
|
+
seed_hexes: Sequence[str] | None = None,
|
|
21
|
+
) -> List[str]:
|
|
22
|
+
del seed_hexes
|
|
23
|
+
|
|
24
|
+
if self.graph.number_of_nodes() == 0:
|
|
25
|
+
return []
|
|
26
|
+
if self.graph.number_of_nodes() <= 2:
|
|
27
|
+
return list(self.graph.nodes)
|
|
28
|
+
|
|
29
|
+
pct = self._normalize_pct(target_pct)
|
|
30
|
+
total = self._total_metric(weight_attr)
|
|
31
|
+
if total <= 0:
|
|
32
|
+
return list(self.graph.nodes)
|
|
33
|
+
|
|
34
|
+
target = total * pct
|
|
35
|
+
best_hexes = None
|
|
36
|
+
best_score = float("inf")
|
|
37
|
+
best_accumulated = 0.0
|
|
38
|
+
|
|
39
|
+
for center in self.graph.nodes:
|
|
40
|
+
try:
|
|
41
|
+
distances = nx.single_source_dijkstra_path_length(
|
|
42
|
+
self.graph,
|
|
43
|
+
center,
|
|
44
|
+
weight="weight",
|
|
45
|
+
)
|
|
46
|
+
except Exception:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
selected = []
|
|
50
|
+
accumulated = 0.0
|
|
51
|
+
path_sum = 0.0
|
|
52
|
+
for node, distance in sorted(distances.items(), key=lambda item: item[1]):
|
|
53
|
+
selected.append(node)
|
|
54
|
+
accumulated += self._metric(node, weight_attr)
|
|
55
|
+
path_sum += float(distance or 0)
|
|
56
|
+
if accumulated >= target:
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
if accumulated >= target and (
|
|
60
|
+
path_sum < best_score
|
|
61
|
+
or (path_sum == best_score and accumulated > best_accumulated)
|
|
62
|
+
):
|
|
63
|
+
best_score = path_sum
|
|
64
|
+
best_accumulated = accumulated
|
|
65
|
+
best_hexes = selected[:]
|
|
66
|
+
|
|
67
|
+
return best_hexes if best_hexes else list(self.graph.nodes)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _normalize_pct(target_pct: float) -> float:
|
|
71
|
+
pct = float(target_pct)
|
|
72
|
+
if pct > 1:
|
|
73
|
+
pct = pct / 100.0
|
|
74
|
+
if pct <= 0 or pct > 1:
|
|
75
|
+
raise ValueError("target_pct must be in the range (0, 1] or (0, 100].")
|
|
76
|
+
return pct
|
|
77
|
+
|
|
78
|
+
def _total_metric(self, weight_attr: str) -> float:
|
|
79
|
+
return sum(self._metric(node, weight_attr) for node in self.graph.nodes)
|
|
80
|
+
|
|
81
|
+
def _metric(self, node: str, weight_attr: str) -> float:
|
|
82
|
+
return float(self.graph.nodes[node].get(weight_attr, 0) or 0)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Geometry helpers for H3 cell collections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from ._h3 import cell_to_latlng, is_valid_cell
|
|
8
|
+
from .plotting import normalize_cells
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_latlng(cells: str | Iterable[str]) -> list[tuple[float, float]]:
|
|
12
|
+
"""Return H3 cell centers as ``(lat, lng)`` tuples."""
|
|
13
|
+
hexes = normalize_cells(cells)
|
|
14
|
+
invalid = [cell for cell in hexes if not is_valid_cell(cell)]
|
|
15
|
+
if invalid:
|
|
16
|
+
raise ValueError(f"Invalid H3 cell(s): {', '.join(invalid[:3])}")
|
|
17
|
+
return [cell_to_latlng(cell) for cell in hexes]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_lnglat(cells: str | Iterable[str]) -> list[tuple[float, float]]:
|
|
21
|
+
"""Return H3 cell centers as Shapely-friendly ``(lng, lat)`` tuples."""
|
|
22
|
+
return [(lng, lat) for lat, lng in get_latlng(cells)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def making_hull(points: Iterable[tuple[float, float]]):
|
|
26
|
+
"""Return the convex hull for input points.
|
|
27
|
+
|
|
28
|
+
The expected coordinate order is ``(lat, lng)`` to match your current
|
|
29
|
+
helper. The returned Shapely geometry uses standard ``(lng, lat)`` order.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
from shapely.geometry import MultiPoint, Point
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"Convex hull helpers require the geo extra: "
|
|
36
|
+
"pip install 'sameer-graph-lib[geo]'"
|
|
37
|
+
) from exc
|
|
38
|
+
|
|
39
|
+
coords = list(points)
|
|
40
|
+
if not coords:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
lnglat = [(lng, lat) for lat, lng in coords]
|
|
44
|
+
if len(lnglat) == 1:
|
|
45
|
+
return Point(lnglat[0])
|
|
46
|
+
|
|
47
|
+
return MultiPoint(lnglat).convex_hull
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def h3_convex_hull(cells: str | Iterable[str]):
|
|
51
|
+
"""Return a Shapely convex hull around H3 cell centers."""
|
|
52
|
+
return making_hull(get_latlng(cells))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def h3_convex_hull_geojson(cells: str | Iterable[str]) -> dict | None:
|
|
56
|
+
"""Return the H3 center convex hull as a GeoJSON-like mapping."""
|
|
57
|
+
hull = h3_convex_hull(cells)
|
|
58
|
+
if hull is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
from shapely.geometry import mapping
|
|
63
|
+
except ImportError as exc:
|
|
64
|
+
raise ImportError(
|
|
65
|
+
"Convex hull helpers require the geo extra: "
|
|
66
|
+
"pip install 'sameer-graph-lib[geo]'"
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
return mapping(hull)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Backwards-compatible aliases matching the user's current helper names.
|
|
73
|
+
getLatLng = get_latlng
|
|
74
|
+
makingHull = making_hull
|
|
75
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Backwards-compatible HexGraph facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .affinity_graph import AffinityGraph
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HexGraph(AffinityGraph):
|
|
9
|
+
"""Compatibility wrapper around :class:`AffinityGraph`.
|
|
10
|
+
|
|
11
|
+
The original single-class API is still available through this name, with
|
|
12
|
+
additional route ingestion, corridor extraction, branch decomposition,
|
|
13
|
+
editing helpers, and JSON persistence inherited from ``AffinityGraph``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Geospatial plotting helpers for H3 cells."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from ._h3 import cell_to_boundary, cell_to_latlng, is_valid_cell
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_cells(cells: str | Iterable[str]) -> list[str]:
|
|
12
|
+
if isinstance(cells, str):
|
|
13
|
+
if "," in cells:
|
|
14
|
+
return [cell.strip() for cell in cells.split(",") if cell.strip()]
|
|
15
|
+
return [cells]
|
|
16
|
+
return [str(cell).strip() for cell in cells if str(cell).strip()]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def plot_h3_cells(
|
|
20
|
+
cells: str | Iterable[str],
|
|
21
|
+
*,
|
|
22
|
+
title: str = "H3 Cells",
|
|
23
|
+
ax=None,
|
|
24
|
+
figsize: tuple[float, float] = (10, 8),
|
|
25
|
+
face_color: str = "#3498db",
|
|
26
|
+
edge_color: str = "#1f2d3d",
|
|
27
|
+
selected_cells: str | Iterable[str] | None = None,
|
|
28
|
+
selected_face_color: str = "#2ecc71",
|
|
29
|
+
alpha: float = 0.45,
|
|
30
|
+
linewidth: float = 1.2,
|
|
31
|
+
show_labels: bool = True,
|
|
32
|
+
label_full_hex: bool = False,
|
|
33
|
+
show_centers: bool = False,
|
|
34
|
+
equal_aspect: bool = True,
|
|
35
|
+
):
|
|
36
|
+
"""Plot one H3 cell or an iterable/comma string of H3 cells as polygons.
|
|
37
|
+
|
|
38
|
+
Returns the Matplotlib ``Figure`` object. The axes use longitude on X and
|
|
39
|
+
latitude on Y, so the plot can be inspected as a geospatial footprint.
|
|
40
|
+
"""
|
|
41
|
+
import matplotlib.pyplot as plt
|
|
42
|
+
from matplotlib.patches import Polygon
|
|
43
|
+
|
|
44
|
+
hexes = normalize_cells(cells)
|
|
45
|
+
invalid = [cell for cell in hexes if not is_valid_cell(cell)]
|
|
46
|
+
if invalid:
|
|
47
|
+
raise ValueError(f"Invalid H3 cell(s): {', '.join(invalid[:3])}")
|
|
48
|
+
|
|
49
|
+
if ax is None:
|
|
50
|
+
fig, ax = plt.subplots(1, 1, figsize=figsize)
|
|
51
|
+
else:
|
|
52
|
+
fig = ax.figure
|
|
53
|
+
|
|
54
|
+
selected = set(normalize_cells(selected_cells) if selected_cells is not None else [])
|
|
55
|
+
all_lngs: list[float] = []
|
|
56
|
+
all_lats: list[float] = []
|
|
57
|
+
|
|
58
|
+
for cell in hexes:
|
|
59
|
+
boundary = cell_to_boundary(cell)
|
|
60
|
+
polygon_points = [(lng, lat) for lat, lng in boundary]
|
|
61
|
+
all_lngs.extend(lng for lng, _ in polygon_points)
|
|
62
|
+
all_lats.extend(lat for _, lat in polygon_points)
|
|
63
|
+
|
|
64
|
+
patch = Polygon(
|
|
65
|
+
polygon_points,
|
|
66
|
+
closed=True,
|
|
67
|
+
facecolor=selected_face_color if cell in selected else face_color,
|
|
68
|
+
edgecolor=edge_color,
|
|
69
|
+
alpha=alpha,
|
|
70
|
+
linewidth=linewidth,
|
|
71
|
+
)
|
|
72
|
+
ax.add_patch(patch)
|
|
73
|
+
|
|
74
|
+
center_lat, center_lng = cell_to_latlng(cell)
|
|
75
|
+
if show_centers:
|
|
76
|
+
ax.scatter([center_lng], [center_lat], s=18, c=edge_color, zorder=3)
|
|
77
|
+
|
|
78
|
+
if show_labels:
|
|
79
|
+
label = cell if label_full_hex else cell[-6:]
|
|
80
|
+
ax.text(
|
|
81
|
+
center_lng,
|
|
82
|
+
center_lat,
|
|
83
|
+
label,
|
|
84
|
+
ha="center",
|
|
85
|
+
va="center",
|
|
86
|
+
fontsize=7,
|
|
87
|
+
color="#111111",
|
|
88
|
+
zorder=4,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if all_lngs and all_lats:
|
|
92
|
+
lng_margin = max((max(all_lngs) - min(all_lngs)) * 0.08, 0.001)
|
|
93
|
+
lat_margin = max((max(all_lats) - min(all_lats)) * 0.08, 0.001)
|
|
94
|
+
ax.set_xlim(min(all_lngs) - lng_margin, max(all_lngs) + lng_margin)
|
|
95
|
+
ax.set_ylim(min(all_lats) - lat_margin, max(all_lats) + lat_margin)
|
|
96
|
+
|
|
97
|
+
if equal_aspect:
|
|
98
|
+
ax.set_aspect("equal", adjustable="box")
|
|
99
|
+
|
|
100
|
+
ax.set_title(title, fontsize=13, fontweight="bold")
|
|
101
|
+
ax.set_xlabel("Longitude")
|
|
102
|
+
ax.set_ylabel("Latitude")
|
|
103
|
+
ax.grid(True, linewidth=0.4, alpha=0.35)
|
|
104
|
+
fig.tight_layout()
|
|
105
|
+
return fig
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cells_to_geodataframe(
|
|
109
|
+
cells: str | Iterable[str],
|
|
110
|
+
*,
|
|
111
|
+
selected_cells: str | Iterable[str] | None = None,
|
|
112
|
+
):
|
|
113
|
+
"""Convert one or more H3 cells to a GeoPandas dataframe of polygons."""
|
|
114
|
+
try:
|
|
115
|
+
import geopandas as gpd
|
|
116
|
+
from shapely.geometry import Polygon
|
|
117
|
+
except ImportError as exc:
|
|
118
|
+
raise ImportError(
|
|
119
|
+
"GeoPandas plotting requires the geo extra: "
|
|
120
|
+
"pip install 'sameer-graph-lib[geo]'"
|
|
121
|
+
) from exc
|
|
122
|
+
|
|
123
|
+
hexes = normalize_cells(cells)
|
|
124
|
+
invalid = [cell for cell in hexes if not is_valid_cell(cell)]
|
|
125
|
+
if invalid:
|
|
126
|
+
raise ValueError(f"Invalid H3 cell(s): {', '.join(invalid[:3])}")
|
|
127
|
+
|
|
128
|
+
selected = set(normalize_cells(selected_cells) if selected_cells is not None else [])
|
|
129
|
+
rows = []
|
|
130
|
+
for cell in hexes:
|
|
131
|
+
boundary = cell_to_boundary(cell)
|
|
132
|
+
polygon = Polygon([(lng, lat) for lat, lng in boundary])
|
|
133
|
+
center_lat, center_lng = cell_to_latlng(cell)
|
|
134
|
+
rows.append(
|
|
135
|
+
{
|
|
136
|
+
"h3_cell": cell,
|
|
137
|
+
"selected": cell in selected,
|
|
138
|
+
"center_lat": center_lat,
|
|
139
|
+
"center_lng": center_lng,
|
|
140
|
+
"geometry": polygon,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return gpd.GeoDataFrame(rows, geometry="geometry", crs="EPSG:4326")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def plot_h3_cells_map(
|
|
148
|
+
cells: str | Iterable[str],
|
|
149
|
+
*,
|
|
150
|
+
selected_cells: str | Iterable[str] | None = None,
|
|
151
|
+
column: str | None = "selected",
|
|
152
|
+
title: str = "H3 Cells Map",
|
|
153
|
+
ax=None,
|
|
154
|
+
figsize: tuple[float, float] = (16, 16),
|
|
155
|
+
alpha: float = 0.35,
|
|
156
|
+
edge_color: str = "black",
|
|
157
|
+
linewidth: float = 1.0,
|
|
158
|
+
basemap: bool = True,
|
|
159
|
+
basemap_source=None,
|
|
160
|
+
legend: bool = True,
|
|
161
|
+
hide_axes: bool = True,
|
|
162
|
+
):
|
|
163
|
+
"""Plot H3 cells with GeoPandas and an optional Contextily basemap.
|
|
164
|
+
|
|
165
|
+
This mirrors the GeoPandas workflow:
|
|
166
|
+
convert H3 polygons to ``EPSG:3857``, plot them, then add a web basemap.
|
|
167
|
+
Returns the Matplotlib ``Figure`` object.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
import contextily as cx
|
|
171
|
+
import matplotlib.pyplot as plt
|
|
172
|
+
except ImportError as exc:
|
|
173
|
+
raise ImportError(
|
|
174
|
+
"Basemap plotting requires the geo extra: "
|
|
175
|
+
"pip install 'sameer-graph-lib[geo]'"
|
|
176
|
+
) from exc
|
|
177
|
+
|
|
178
|
+
gdf = cells_to_geodataframe(cells, selected_cells=selected_cells).to_crs(epsg=3857)
|
|
179
|
+
|
|
180
|
+
if ax is None:
|
|
181
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
182
|
+
else:
|
|
183
|
+
fig = ax.figure
|
|
184
|
+
|
|
185
|
+
if hide_axes:
|
|
186
|
+
ax.get_xaxis().set_visible(False)
|
|
187
|
+
ax.get_yaxis().set_visible(False)
|
|
188
|
+
|
|
189
|
+
plot_kwargs = {
|
|
190
|
+
"ax": ax,
|
|
191
|
+
"alpha": alpha,
|
|
192
|
+
"edgecolor": edge_color,
|
|
193
|
+
"linewidth": linewidth,
|
|
194
|
+
"legend": legend,
|
|
195
|
+
}
|
|
196
|
+
if column and column in gdf.columns:
|
|
197
|
+
plot_kwargs.update({"column": column, "categorical": True})
|
|
198
|
+
if legend:
|
|
199
|
+
plot_kwargs["legend_kwds"] = {"loc": "upper left"}
|
|
200
|
+
else:
|
|
201
|
+
plot_kwargs["color"] = "#3498db"
|
|
202
|
+
|
|
203
|
+
gdf.plot(**plot_kwargs)
|
|
204
|
+
|
|
205
|
+
if basemap:
|
|
206
|
+
source = basemap_source or cx.providers.CartoDB.Positron
|
|
207
|
+
cx.add_basemap(ax, crs=gdf.crs, source=source)
|
|
208
|
+
|
|
209
|
+
ax.set_title(title, fontsize=13, fontweight="bold")
|
|
210
|
+
fig.tight_layout()
|
|
211
|
+
return fig
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Input normalization utilities for H3 route data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterable, List, Sequence, Tuple
|
|
6
|
+
|
|
7
|
+
from ._h3 import (
|
|
8
|
+
cell_to_latlng,
|
|
9
|
+
get_resolution,
|
|
10
|
+
grid_distance,
|
|
11
|
+
grid_path_cells,
|
|
12
|
+
is_valid_cell,
|
|
13
|
+
latlng_to_cell,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
Coordinate = Tuple[float, float]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SpatialIngestor:
|
|
20
|
+
"""Convert raw spatial inputs into contiguous H3 chains."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, resolution: int = 9, strict: bool = True) -> None:
|
|
23
|
+
self.resolution = int(resolution)
|
|
24
|
+
self.strict = strict
|
|
25
|
+
|
|
26
|
+
def ingest_h3_array(self, hex_list: Iterable[str]) -> List[str]:
|
|
27
|
+
"""Validate and gap-fill an explicit list of H3 cells."""
|
|
28
|
+
return self.normalize_h3_chain(list(hex_list))
|
|
29
|
+
|
|
30
|
+
def ingest_latlng_sequence(
|
|
31
|
+
self,
|
|
32
|
+
coords: Sequence[Coordinate],
|
|
33
|
+
resolution: int | None = None,
|
|
34
|
+
) -> List[str]:
|
|
35
|
+
"""Convert a lat/lng sequence into a contiguous H3 route."""
|
|
36
|
+
res = self.resolution if resolution is None else int(resolution)
|
|
37
|
+
cells = [latlng_to_cell(float(lat), float(lng), res) for lat, lng in coords]
|
|
38
|
+
return self.normalize_h3_chain(cells)
|
|
39
|
+
|
|
40
|
+
def ingest_encoded_polyline(
|
|
41
|
+
self,
|
|
42
|
+
polyline_str: str,
|
|
43
|
+
resolution: int | None = None,
|
|
44
|
+
precision: int = 5,
|
|
45
|
+
) -> List[str]:
|
|
46
|
+
"""Decode a Google encoded polyline and convert it to H3 cells."""
|
|
47
|
+
return self.ingest_latlng_sequence(
|
|
48
|
+
decode_polyline(polyline_str, precision=precision),
|
|
49
|
+
resolution=resolution,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def normalize_h3_chain(self, hexes: Sequence[str]) -> List[str]:
|
|
53
|
+
"""Remove consecutive duplicates and fill gaps between adjacent samples."""
|
|
54
|
+
cells = self._dedupe_consecutive([str(h) for h in hexes if h])
|
|
55
|
+
if not cells:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
self._validate_cells(cells)
|
|
59
|
+
if len(cells) == 1:
|
|
60
|
+
return cells
|
|
61
|
+
|
|
62
|
+
normalized = [cells[0]]
|
|
63
|
+
for cell in cells[1:]:
|
|
64
|
+
if cell == normalized[-1]:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
bridge = self._bridge_cells(normalized[-1], cell)
|
|
68
|
+
if not bridge:
|
|
69
|
+
normalized.append(cell)
|
|
70
|
+
elif bridge[0] == normalized[-1]:
|
|
71
|
+
normalized.extend(bridge[1:])
|
|
72
|
+
else:
|
|
73
|
+
normalized.extend(bridge)
|
|
74
|
+
|
|
75
|
+
return self._dedupe_consecutive(normalized)
|
|
76
|
+
|
|
77
|
+
def _validate_cells(self, cells: Sequence[str]) -> None:
|
|
78
|
+
invalid = [cell for cell in cells if not is_valid_cell(cell)]
|
|
79
|
+
if invalid and self.strict:
|
|
80
|
+
sample = ", ".join(invalid[:3])
|
|
81
|
+
raise ValueError(f"Invalid H3 cell(s): {sample}")
|
|
82
|
+
|
|
83
|
+
resolutions = {get_resolution(cell) for cell in cells if is_valid_cell(cell)}
|
|
84
|
+
if len(resolutions) > 1 and self.strict:
|
|
85
|
+
raise ValueError("All H3 cells in one route must use the same resolution.")
|
|
86
|
+
|
|
87
|
+
def _bridge_cells(self, start: str, end: str) -> List[str]:
|
|
88
|
+
try:
|
|
89
|
+
path = grid_path_cells(start, end)
|
|
90
|
+
return path if path else [start, end]
|
|
91
|
+
except Exception:
|
|
92
|
+
return self._fallback_bridge_cells(start, end)
|
|
93
|
+
|
|
94
|
+
def _fallback_bridge_cells(self, start: str, end: str) -> List[str]:
|
|
95
|
+
"""Approximate a bridge when h3 cannot produce an exact grid path."""
|
|
96
|
+
if start == end:
|
|
97
|
+
return [start]
|
|
98
|
+
|
|
99
|
+
resolution = get_resolution(start)
|
|
100
|
+
steps = max(1, grid_distance(start, end))
|
|
101
|
+
lat1, lng1 = cell_to_latlng(start)
|
|
102
|
+
lat2, lng2 = cell_to_latlng(end)
|
|
103
|
+
|
|
104
|
+
sampled = []
|
|
105
|
+
for idx in range(steps + 1):
|
|
106
|
+
ratio = idx / steps
|
|
107
|
+
lat = lat1 + (lat2 - lat1) * ratio
|
|
108
|
+
lng = lng1 + (lng2 - lng1) * ratio
|
|
109
|
+
sampled.append(latlng_to_cell(lat, lng, resolution))
|
|
110
|
+
|
|
111
|
+
sampled[0] = start
|
|
112
|
+
sampled[-1] = end
|
|
113
|
+
sampled = self._dedupe_consecutive(sampled)
|
|
114
|
+
|
|
115
|
+
expanded = [sampled[0]]
|
|
116
|
+
for cell in sampled[1:]:
|
|
117
|
+
if cell == expanded[-1]:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
bridge = grid_path_cells(expanded[-1], cell)
|
|
121
|
+
expanded.extend(bridge[1:] if bridge and bridge[0] == expanded[-1] else bridge)
|
|
122
|
+
except Exception:
|
|
123
|
+
expanded.append(cell)
|
|
124
|
+
|
|
125
|
+
return self._dedupe_consecutive(expanded)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _dedupe_consecutive(items: Sequence[str]) -> List[str]:
|
|
129
|
+
deduped: List[str] = []
|
|
130
|
+
for item in items:
|
|
131
|
+
if not deduped or deduped[-1] != item:
|
|
132
|
+
deduped.append(item)
|
|
133
|
+
return deduped
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def decode_polyline(polyline_str: str, precision: int = 5) -> List[Coordinate]:
|
|
137
|
+
"""Decode a Google encoded polyline string without extra dependencies."""
|
|
138
|
+
coordinates: List[Coordinate] = []
|
|
139
|
+
index = 0
|
|
140
|
+
lat = 0
|
|
141
|
+
lng = 0
|
|
142
|
+
factor = 10**precision
|
|
143
|
+
|
|
144
|
+
while index < len(polyline_str):
|
|
145
|
+
lat_delta, index = _decode_polyline_value(polyline_str, index)
|
|
146
|
+
lng_delta, index = _decode_polyline_value(polyline_str, index)
|
|
147
|
+
lat += lat_delta
|
|
148
|
+
lng += lng_delta
|
|
149
|
+
coordinates.append((lat / factor, lng / factor))
|
|
150
|
+
|
|
151
|
+
return coordinates
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _decode_polyline_value(polyline_str: str, index: int) -> tuple[int, int]:
|
|
155
|
+
result = 0
|
|
156
|
+
shift = 0
|
|
157
|
+
|
|
158
|
+
while True:
|
|
159
|
+
if index >= len(polyline_str):
|
|
160
|
+
raise ValueError("Invalid encoded polyline: truncated value.")
|
|
161
|
+
byte = ord(polyline_str[index]) - 63
|
|
162
|
+
index += 1
|
|
163
|
+
result |= (byte & 0x1F) << shift
|
|
164
|
+
shift += 5
|
|
165
|
+
if byte < 0x20:
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
value = ~(result >> 1) if result & 1 else result >> 1
|
|
169
|
+
return value, index
|
|
170
|
+
|