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.
@@ -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
+