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,775 @@
|
|
|
1
|
+
"""Core H3 affinity graph implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from statistics import fmean
|
|
8
|
+
from typing import Iterable, List, Sequence
|
|
9
|
+
|
|
10
|
+
import networkx as nx
|
|
11
|
+
|
|
12
|
+
from ._h3 import cell_area, cell_to_latlng, grid_distance, is_valid_cell
|
|
13
|
+
from .spatial_ingestor import SpatialIngestor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AffinityGraph:
|
|
17
|
+
"""NetworkX-backed graph for H3 route affinity analysis."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
initial_hexes: dict[str, float] | Iterable[str] | None = None,
|
|
22
|
+
resolution: int = 9,
|
|
23
|
+
hex_resolution: int | None = None,
|
|
24
|
+
ingestor: SpatialIngestor | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.graph = nx.Graph()
|
|
27
|
+
self.node_add_count = 0
|
|
28
|
+
self.total_value_sum = 0.0
|
|
29
|
+
self.route_count = 0
|
|
30
|
+
self.insertion_log: list[dict] = []
|
|
31
|
+
self.resolution = int(hex_resolution if hex_resolution is not None else resolution)
|
|
32
|
+
self.ingestor = ingestor or SpatialIngestor(resolution=self.resolution)
|
|
33
|
+
|
|
34
|
+
if initial_hexes:
|
|
35
|
+
if isinstance(initial_hexes, dict):
|
|
36
|
+
for hex_id, value in initial_hexes.items():
|
|
37
|
+
self.add_hex(hex_id, value=value)
|
|
38
|
+
else:
|
|
39
|
+
for hex_id in initial_hexes:
|
|
40
|
+
self.add_hex(hex_id)
|
|
41
|
+
|
|
42
|
+
def _grid_dist(self, a: str, b: str) -> int:
|
|
43
|
+
return grid_distance(a, b)
|
|
44
|
+
|
|
45
|
+
def _find_all_nearest(
|
|
46
|
+
self,
|
|
47
|
+
new_hex: str,
|
|
48
|
+
candidates: Iterable[str] | None = None,
|
|
49
|
+
) -> tuple[list[str], float]:
|
|
50
|
+
candidate_nodes = list(self.graph.nodes if candidates is None else candidates)
|
|
51
|
+
distances = []
|
|
52
|
+
for existing in candidate_nodes:
|
|
53
|
+
if existing == new_hex:
|
|
54
|
+
continue
|
|
55
|
+
distances.append((existing, self._grid_dist(new_hex, existing)))
|
|
56
|
+
|
|
57
|
+
if not distances:
|
|
58
|
+
return [], float("inf")
|
|
59
|
+
|
|
60
|
+
distances.sort(key=lambda item: item[1])
|
|
61
|
+
min_dist = distances[0][1]
|
|
62
|
+
return [node for node, dist in distances if dist == min_dist], min_dist
|
|
63
|
+
|
|
64
|
+
def add_hex(self, h3_hex: str, value: float = 0.0, reroute_edges: bool = True) -> None:
|
|
65
|
+
"""Add one H3 cell and attach it to all nearest existing cells."""
|
|
66
|
+
self._validate_hex(h3_hex)
|
|
67
|
+
existing_nodes = list(self.graph.nodes)
|
|
68
|
+
was_new = self._upsert_node(h3_hex, value=value)
|
|
69
|
+
|
|
70
|
+
if not was_new:
|
|
71
|
+
self.insertion_log.append(
|
|
72
|
+
{
|
|
73
|
+
"action": "increment",
|
|
74
|
+
"node": h3_hex,
|
|
75
|
+
"new_count": self.graph.nodes[h3_hex]["count"],
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
if not existing_nodes:
|
|
81
|
+
self.insertion_log.append({"action": "first_node", "node": h3_hex})
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if len(existing_nodes) == 1:
|
|
85
|
+
other = existing_nodes[0]
|
|
86
|
+
distance = self._grid_dist(h3_hex, other)
|
|
87
|
+
self._add_or_update_edge(h3_hex, other, distance, count_increment=0, kind="attachment")
|
|
88
|
+
self.insertion_log.append(
|
|
89
|
+
{
|
|
90
|
+
"action": "second_node",
|
|
91
|
+
"node": h3_hex,
|
|
92
|
+
"connected_to": [other],
|
|
93
|
+
"distance": distance,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
nearest_nodes, min_dist = self._find_all_nearest(h3_hex, candidates=existing_nodes)
|
|
99
|
+
log = {
|
|
100
|
+
"action": "insert",
|
|
101
|
+
"node": h3_hex,
|
|
102
|
+
"nearest_nodes": nearest_nodes[:],
|
|
103
|
+
"nearest_dist": min_dist,
|
|
104
|
+
"edges_added": [],
|
|
105
|
+
"edges_removed": [],
|
|
106
|
+
"edges_rerouted": [],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for nearest in nearest_nodes:
|
|
110
|
+
distance = self._grid_dist(h3_hex, nearest)
|
|
111
|
+
self._add_or_update_edge(h3_hex, nearest, distance, count_increment=0, kind="attachment")
|
|
112
|
+
log["edges_added"].append((h3_hex, nearest, distance))
|
|
113
|
+
|
|
114
|
+
if reroute_edges:
|
|
115
|
+
self._reroute_attachment_edges(h3_hex, nearest_nodes, log)
|
|
116
|
+
|
|
117
|
+
self.insertion_log.append(log)
|
|
118
|
+
|
|
119
|
+
add_node = add_hex
|
|
120
|
+
|
|
121
|
+
def add_route(
|
|
122
|
+
self,
|
|
123
|
+
route_hexes: Sequence[str],
|
|
124
|
+
value: float = 0.0,
|
|
125
|
+
route_id: str | None = None,
|
|
126
|
+
) -> List[str]:
|
|
127
|
+
"""Normalize a route hex array, then insert each hex with ``add_hex``."""
|
|
128
|
+
return self.add_hex_array(route_hexes, value=value, route_id=route_id)
|
|
129
|
+
|
|
130
|
+
def add_hex_array(
|
|
131
|
+
self,
|
|
132
|
+
hex_array: Sequence[str],
|
|
133
|
+
value: float = 0.0,
|
|
134
|
+
route_id: str | None = None,
|
|
135
|
+
) -> List[str]:
|
|
136
|
+
"""Normalize an H3 array and add it using the original per-node procedure."""
|
|
137
|
+
route = self.ingestor.ingest_h3_array(hex_array)
|
|
138
|
+
if not route:
|
|
139
|
+
self.insertion_log.append({"action": "empty_hex_array", "route_id": route_id})
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
self.route_count += 1
|
|
143
|
+
current_route_id = route_id or self.route_count
|
|
144
|
+
for cell in route:
|
|
145
|
+
self.add_hex(cell, value=value)
|
|
146
|
+
|
|
147
|
+
self.insertion_log.append(
|
|
148
|
+
{
|
|
149
|
+
"action": "add_hex_array",
|
|
150
|
+
"route_id": current_route_id,
|
|
151
|
+
"route_length": len(route),
|
|
152
|
+
"hexes": route[:],
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
return route
|
|
156
|
+
|
|
157
|
+
def add_latlng_sequence(
|
|
158
|
+
self,
|
|
159
|
+
coords: Sequence[tuple[float, float]],
|
|
160
|
+
resolution: int | None = None,
|
|
161
|
+
value: float = 0.0,
|
|
162
|
+
route_id: str | None = None,
|
|
163
|
+
) -> List[str]:
|
|
164
|
+
route = self.ingestor.ingest_latlng_sequence(coords, resolution=resolution)
|
|
165
|
+
return self.add_route(route, value=value, route_id=route_id)
|
|
166
|
+
|
|
167
|
+
def add_encoded_polyline(
|
|
168
|
+
self,
|
|
169
|
+
polyline_str: str,
|
|
170
|
+
resolution: int | None = None,
|
|
171
|
+
value: float = 0.0,
|
|
172
|
+
route_id: str | None = None,
|
|
173
|
+
) -> List[str]:
|
|
174
|
+
route = self.ingestor.ingest_encoded_polyline(polyline_str, resolution=resolution)
|
|
175
|
+
return self.add_route(route, value=value, route_id=route_id)
|
|
176
|
+
|
|
177
|
+
add_polyline = add_encoded_polyline
|
|
178
|
+
|
|
179
|
+
def get_route_affinity_score(self, route_a: Sequence[str], route_b: Sequence[str]) -> float:
|
|
180
|
+
"""Return Jaccard similarity of two normalized H3 routes."""
|
|
181
|
+
set_a = set(self.ingestor.ingest_h3_array(route_a))
|
|
182
|
+
set_b = set(self.ingestor.ingest_h3_array(route_b))
|
|
183
|
+
union = set_a | set_b
|
|
184
|
+
if not union:
|
|
185
|
+
return 1.0
|
|
186
|
+
return round(len(set_a & set_b) / len(union), 6)
|
|
187
|
+
|
|
188
|
+
def extract_x_percent_corridor(
|
|
189
|
+
self,
|
|
190
|
+
target_pct: float = 0.8,
|
|
191
|
+
use_values: bool = False,
|
|
192
|
+
seed_hexes: Sequence[str] | None = None,
|
|
193
|
+
) -> List[str]:
|
|
194
|
+
from .corridor_extractor import CorridorExtractor
|
|
195
|
+
|
|
196
|
+
weight_attr = "value" if use_values else "count"
|
|
197
|
+
return CorridorExtractor(self).extract_x_percent_corridor(
|
|
198
|
+
target_pct=target_pct,
|
|
199
|
+
weight_attr=weight_attr,
|
|
200
|
+
seed_hexes=seed_hexes,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def decompose_branches(self, seed_hexes: Sequence[str] | None = None) -> dict:
|
|
204
|
+
from .topology_analyzer import TopologyAnalyzer
|
|
205
|
+
|
|
206
|
+
return TopologyAnalyzer(self).decompose_branches(seed_hexes=seed_hexes)
|
|
207
|
+
|
|
208
|
+
def get_appropriate_hexes(
|
|
209
|
+
self,
|
|
210
|
+
cutoff: float = 0.8,
|
|
211
|
+
use_values: bool = False,
|
|
212
|
+
top_k_centers: int | None = None,
|
|
213
|
+
) -> List[str]:
|
|
214
|
+
"""Return the most compact Dijkstra cluster covering the requested metric share.
|
|
215
|
+
|
|
216
|
+
By default this runs Dijkstra from every node and picks the center with
|
|
217
|
+
the smallest accumulated path cost. ``top_k_centers`` can be supplied
|
|
218
|
+
only when you intentionally want a faster approximate scan.
|
|
219
|
+
"""
|
|
220
|
+
if not self.graph.nodes:
|
|
221
|
+
return []
|
|
222
|
+
if len(self.graph.nodes) <= 2:
|
|
223
|
+
return list(self.graph.nodes)
|
|
224
|
+
|
|
225
|
+
metric_key = "value" if use_values else "count"
|
|
226
|
+
total_metric = self.total_value_sum if use_values else self.node_add_count
|
|
227
|
+
if total_metric == 0:
|
|
228
|
+
return list(self.graph.nodes)
|
|
229
|
+
|
|
230
|
+
cutoff = cutoff / 100.0 if cutoff > 1 else cutoff
|
|
231
|
+
if cutoff <= 0 or cutoff > 1:
|
|
232
|
+
raise ValueError("cutoff must be in the range (0, 1] or (0, 100].")
|
|
233
|
+
|
|
234
|
+
target = total_metric * cutoff
|
|
235
|
+
centers = list(self.graph.nodes)
|
|
236
|
+
if top_k_centers is not None:
|
|
237
|
+
centers = sorted(
|
|
238
|
+
centers,
|
|
239
|
+
key=lambda node: self.graph.nodes[node].get(metric_key, 0),
|
|
240
|
+
reverse=True,
|
|
241
|
+
)[:top_k_centers]
|
|
242
|
+
|
|
243
|
+
best_hexes = None
|
|
244
|
+
best_score = float("inf")
|
|
245
|
+
best_accumulated = 0.0
|
|
246
|
+
for center in centers:
|
|
247
|
+
try:
|
|
248
|
+
distances = nx.single_source_dijkstra_path_length(
|
|
249
|
+
self.graph,
|
|
250
|
+
center,
|
|
251
|
+
weight="weight",
|
|
252
|
+
)
|
|
253
|
+
except Exception:
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
selected = []
|
|
257
|
+
accumulated = 0.0
|
|
258
|
+
total_path_dist = 0.0
|
|
259
|
+
for node, distance in sorted(distances.items(), key=lambda item: item[1]):
|
|
260
|
+
selected.append(node)
|
|
261
|
+
accumulated += float(self.graph.nodes[node].get(metric_key, 0) or 0)
|
|
262
|
+
total_path_dist += float(distance or 0)
|
|
263
|
+
if accumulated >= target:
|
|
264
|
+
break
|
|
265
|
+
|
|
266
|
+
if accumulated >= target and (
|
|
267
|
+
total_path_dist < best_score
|
|
268
|
+
or (total_path_dist == best_score and accumulated > best_accumulated)
|
|
269
|
+
):
|
|
270
|
+
best_score = total_path_dist
|
|
271
|
+
best_accumulated = accumulated
|
|
272
|
+
best_hexes = selected[:]
|
|
273
|
+
|
|
274
|
+
return best_hexes if best_hexes else list(self.graph.nodes)
|
|
275
|
+
|
|
276
|
+
def get_appropriate_hexes_with_stats(
|
|
277
|
+
self,
|
|
278
|
+
cutoff: float = 0.8,
|
|
279
|
+
use_values: bool = False,
|
|
280
|
+
top_k_centers: int | None = None,
|
|
281
|
+
) -> dict:
|
|
282
|
+
hexes = self.get_appropriate_hexes(
|
|
283
|
+
cutoff=cutoff,
|
|
284
|
+
use_values=use_values,
|
|
285
|
+
top_k_centers=top_k_centers,
|
|
286
|
+
)
|
|
287
|
+
return self._stats_for_hexes(hexes)
|
|
288
|
+
|
|
289
|
+
def get_graph_stats(self) -> dict:
|
|
290
|
+
distances = [
|
|
291
|
+
float(data.get("distance", data.get("weight", 0)) or 0)
|
|
292
|
+
for _, _, data in self.graph.edges(data=True)
|
|
293
|
+
]
|
|
294
|
+
edge_counts = [float(data.get("count", 0) or 0) for _, _, data in self.graph.edges(data=True)]
|
|
295
|
+
return {
|
|
296
|
+
"num_nodes": self.graph.number_of_nodes(),
|
|
297
|
+
"num_edges": self.graph.number_of_edges(),
|
|
298
|
+
"total_count": self.node_add_count,
|
|
299
|
+
"total_value": round(self.total_value_sum, 2),
|
|
300
|
+
"route_count": self.route_count,
|
|
301
|
+
"is_connected": nx.is_connected(self.graph) if self.graph.number_of_nodes() > 1 else True,
|
|
302
|
+
"avg_edge_weight": round(fmean(distances), 2) if distances else 0,
|
|
303
|
+
"max_edge_weight": max(distances, default=0),
|
|
304
|
+
"total_edge_traversals": int(sum(edge_counts)),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def get_total_node_count_weight(self) -> int:
|
|
308
|
+
return sum(int(self.graph.nodes[node].get("count", 0) or 0) for node in self.graph.nodes)
|
|
309
|
+
|
|
310
|
+
def get_edge_distances_summary(self) -> tuple[list[float], float]:
|
|
311
|
+
distances = [
|
|
312
|
+
float(data.get("weight", data.get("distance", 0)) or 0)
|
|
313
|
+
for _, _, data in self.graph.edges(data=True)
|
|
314
|
+
]
|
|
315
|
+
return distances, sum(distances)
|
|
316
|
+
|
|
317
|
+
def remove_hex(self, h3_hex: str) -> bool:
|
|
318
|
+
"""Remove a node and adjust aggregate counters."""
|
|
319
|
+
if h3_hex not in self.graph:
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
data = self.graph.nodes[h3_hex]
|
|
323
|
+
self.node_add_count = max(0, self.node_add_count - int(data.get("count", 0) or 0))
|
|
324
|
+
self.total_value_sum = max(0.0, self.total_value_sum - float(data.get("value", 0) or 0))
|
|
325
|
+
self.graph.remove_node(h3_hex)
|
|
326
|
+
self.insertion_log.append({"action": "remove_hex", "node": h3_hex})
|
|
327
|
+
return True
|
|
328
|
+
|
|
329
|
+
def set_hex_metric(
|
|
330
|
+
self,
|
|
331
|
+
h3_hex: str,
|
|
332
|
+
count: int | None = None,
|
|
333
|
+
value: float | None = None,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Edit an existing node's count and/or value while keeping totals aligned."""
|
|
336
|
+
if h3_hex not in self.graph:
|
|
337
|
+
raise KeyError(f"Unknown H3 cell: {h3_hex}")
|
|
338
|
+
|
|
339
|
+
node = self.graph.nodes[h3_hex]
|
|
340
|
+
if count is not None:
|
|
341
|
+
new_count = int(count)
|
|
342
|
+
if new_count < 0:
|
|
343
|
+
raise ValueError("count cannot be negative.")
|
|
344
|
+
self.node_add_count += new_count - int(node.get("count", 0) or 0)
|
|
345
|
+
node["count"] = new_count
|
|
346
|
+
|
|
347
|
+
if value is not None:
|
|
348
|
+
new_value = float(value)
|
|
349
|
+
self.total_value_sum += new_value - float(node.get("value", 0) or 0)
|
|
350
|
+
node["value"] = new_value
|
|
351
|
+
|
|
352
|
+
self.insertion_log.append({"action": "set_hex_metric", "node": h3_hex})
|
|
353
|
+
|
|
354
|
+
def neighbors(self, h3_hex: str) -> list[str]:
|
|
355
|
+
return list(self.graph.neighbors(h3_hex))
|
|
356
|
+
|
|
357
|
+
def shortest_path(self, source: str, target: str) -> list[str]:
|
|
358
|
+
return nx.shortest_path(self.graph, source, target, weight="distance")
|
|
359
|
+
|
|
360
|
+
def to_dict(self) -> dict:
|
|
361
|
+
return {
|
|
362
|
+
"node_add_count": self.node_add_count,
|
|
363
|
+
"total_value_sum": self.total_value_sum,
|
|
364
|
+
"route_count": self.route_count,
|
|
365
|
+
"nodes": [
|
|
366
|
+
{"id": node, **dict(data)}
|
|
367
|
+
for node, data in self.graph.nodes(data=True)
|
|
368
|
+
],
|
|
369
|
+
"edges": [
|
|
370
|
+
{"source": u, "target": v, **dict(data)}
|
|
371
|
+
for u, v, data in self.graph.edges(data=True)
|
|
372
|
+
],
|
|
373
|
+
"insertion_log": self.insertion_log,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
@classmethod
|
|
377
|
+
def from_dict(cls, payload: dict) -> "AffinityGraph":
|
|
378
|
+
graph = cls()
|
|
379
|
+
graph.node_add_count = int(payload.get("node_add_count", 0) or 0)
|
|
380
|
+
graph.total_value_sum = float(payload.get("total_value_sum", 0) or 0)
|
|
381
|
+
graph.route_count = int(payload.get("route_count", 0) or 0)
|
|
382
|
+
graph.insertion_log = list(payload.get("insertion_log", []))
|
|
383
|
+
|
|
384
|
+
for node in payload.get("nodes", []):
|
|
385
|
+
data = dict(node)
|
|
386
|
+
node_id = data.pop("id")
|
|
387
|
+
graph.graph.add_node(node_id, **data)
|
|
388
|
+
|
|
389
|
+
for edge in payload.get("edges", []):
|
|
390
|
+
data = dict(edge)
|
|
391
|
+
source = data.pop("source")
|
|
392
|
+
target = data.pop("target")
|
|
393
|
+
graph.graph.add_edge(source, target, **data)
|
|
394
|
+
|
|
395
|
+
return graph
|
|
396
|
+
|
|
397
|
+
def save_json(self, path: str | Path) -> None:
|
|
398
|
+
Path(path).write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
|
|
399
|
+
|
|
400
|
+
@classmethod
|
|
401
|
+
def load_json(cls, path: str | Path) -> "AffinityGraph":
|
|
402
|
+
return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
403
|
+
|
|
404
|
+
def _get_geo_pos(self) -> dict[str, tuple[float, float]]:
|
|
405
|
+
return {
|
|
406
|
+
node: (cell_to_latlng(node)[1], cell_to_latlng(node)[0])
|
|
407
|
+
for node in self.graph.nodes
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
def visualize_graph(
|
|
411
|
+
self,
|
|
412
|
+
title: str = "H3 Hex Graph",
|
|
413
|
+
highlight_hexes: Sequence[str] | str | None = None,
|
|
414
|
+
figsize: tuple[float, float] = (14, 10),
|
|
415
|
+
show_edge_weights: bool = True,
|
|
416
|
+
show_labels: bool = True,
|
|
417
|
+
):
|
|
418
|
+
"""Plot the graph using H3 longitude/latitude positions for QC."""
|
|
419
|
+
import matplotlib.pyplot as plt
|
|
420
|
+
import matplotlib.patches as mpatches
|
|
421
|
+
|
|
422
|
+
fig, ax = plt.subplots(1, 1, figsize=figsize)
|
|
423
|
+
pos = self._get_geo_pos()
|
|
424
|
+
highlighted = set(highlight_hexes or [])
|
|
425
|
+
|
|
426
|
+
node_colors = ["#2ecc71" if node in highlighted else "#e74c3c" for node in self.graph.nodes]
|
|
427
|
+
node_sizes = [
|
|
428
|
+
200 + int(self.graph.nodes[node].get("count", 1) or 1) * 80
|
|
429
|
+
for node in self.graph.nodes
|
|
430
|
+
]
|
|
431
|
+
edge_colors = [
|
|
432
|
+
"#27ae60" if u in highlighted and v in highlighted else "#bdc3c7"
|
|
433
|
+
for u, v in self.graph.edges()
|
|
434
|
+
]
|
|
435
|
+
edge_widths = [
|
|
436
|
+
2.0 if u in highlighted and v in highlighted else 0.8
|
|
437
|
+
for u, v in self.graph.edges()
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
nx.draw_networkx_edges(self.graph, pos, edge_color=edge_colors, width=edge_widths, ax=ax)
|
|
441
|
+
nx.draw_networkx_nodes(
|
|
442
|
+
self.graph,
|
|
443
|
+
pos,
|
|
444
|
+
node_color=node_colors,
|
|
445
|
+
node_size=node_sizes,
|
|
446
|
+
ax=ax,
|
|
447
|
+
edgecolors="black",
|
|
448
|
+
linewidths=0.5,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if show_labels:
|
|
452
|
+
labels = {
|
|
453
|
+
node: f"{str(node)[-4:]}\nc:{self.graph.nodes[node].get('count', 0)}"
|
|
454
|
+
for node in self.graph.nodes
|
|
455
|
+
}
|
|
456
|
+
nx.draw_networkx_labels(self.graph, pos, labels=labels, font_size=7, ax=ax)
|
|
457
|
+
|
|
458
|
+
if show_edge_weights:
|
|
459
|
+
edge_labels = {
|
|
460
|
+
(u, v): f"{data.get('weight', data.get('distance', ''))}"
|
|
461
|
+
for u, v, data in self.graph.edges(data=True)
|
|
462
|
+
}
|
|
463
|
+
nx.draw_networkx_edge_labels(
|
|
464
|
+
self.graph,
|
|
465
|
+
pos,
|
|
466
|
+
edge_labels=edge_labels,
|
|
467
|
+
font_size=7,
|
|
468
|
+
font_color="red",
|
|
469
|
+
ax=ax,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if highlighted:
|
|
473
|
+
patches = [
|
|
474
|
+
mpatches.Patch(color="#2ecc71", label="Selected"),
|
|
475
|
+
mpatches.Patch(color="#e74c3c", label="Not selected"),
|
|
476
|
+
]
|
|
477
|
+
ax.legend(handles=patches, loc="upper left", fontsize=9)
|
|
478
|
+
|
|
479
|
+
ax.set_title(title, fontsize=13, fontweight="bold")
|
|
480
|
+
ax.set_xlabel("Longitude")
|
|
481
|
+
ax.set_ylabel("Latitude")
|
|
482
|
+
fig.tight_layout()
|
|
483
|
+
return fig
|
|
484
|
+
|
|
485
|
+
def visualize_step_by_step(
|
|
486
|
+
self,
|
|
487
|
+
hex_list: Sequence[str],
|
|
488
|
+
labels: Sequence[str] | None = None,
|
|
489
|
+
figsize: tuple[float, float] = (16, 5),
|
|
490
|
+
value: float = 0.0,
|
|
491
|
+
):
|
|
492
|
+
"""Visualize your original insertion and edge-restructuring procedure."""
|
|
493
|
+
import matplotlib.pyplot as plt
|
|
494
|
+
|
|
495
|
+
if not hex_list:
|
|
496
|
+
fig, ax = plt.subplots(1, 1, figsize=figsize)
|
|
497
|
+
ax.set_title("Step-by-Step Insertion")
|
|
498
|
+
ax.axis("off")
|
|
499
|
+
return fig
|
|
500
|
+
|
|
501
|
+
step_count = len(hex_list)
|
|
502
|
+
fig, axes = plt.subplots(1, step_count, figsize=figsize)
|
|
503
|
+
if step_count == 1:
|
|
504
|
+
axes = [axes]
|
|
505
|
+
|
|
506
|
+
temp = self.__class__(hex_resolution=self.resolution)
|
|
507
|
+
display_labels = list(labels or [])
|
|
508
|
+
if len(display_labels) < step_count:
|
|
509
|
+
display_labels.extend(chr(65 + idx) for idx in range(len(display_labels), step_count))
|
|
510
|
+
label_map: dict[str, str] = {}
|
|
511
|
+
|
|
512
|
+
for step, hex_id in enumerate(hex_list):
|
|
513
|
+
label = display_labels[step]
|
|
514
|
+
temp.add_node(hex_id, value=value)
|
|
515
|
+
label_map[hex_id] = label
|
|
516
|
+
ax = axes[step]
|
|
517
|
+
pos = temp._get_geo_pos()
|
|
518
|
+
log = temp.insertion_log[-1]
|
|
519
|
+
|
|
520
|
+
colors = [
|
|
521
|
+
"#e67e22" if node == hex_id and log["action"] != "increment" else "#3498db"
|
|
522
|
+
for node in temp.graph.nodes
|
|
523
|
+
]
|
|
524
|
+
|
|
525
|
+
nx.draw_networkx_edges(temp.graph, pos, edge_color="#2c3e50", width=2, ax=ax)
|
|
526
|
+
nx.draw_networkx_nodes(
|
|
527
|
+
temp.graph,
|
|
528
|
+
pos,
|
|
529
|
+
node_color=colors,
|
|
530
|
+
node_size=500,
|
|
531
|
+
ax=ax,
|
|
532
|
+
edgecolors="black",
|
|
533
|
+
linewidths=1.5,
|
|
534
|
+
)
|
|
535
|
+
nx.draw_networkx_labels(
|
|
536
|
+
temp.graph,
|
|
537
|
+
pos,
|
|
538
|
+
labels={node: label_map.get(node, str(node)[-4:]) for node in temp.graph.nodes},
|
|
539
|
+
font_size=12,
|
|
540
|
+
font_weight="bold",
|
|
541
|
+
ax=ax,
|
|
542
|
+
)
|
|
543
|
+
edge_labels = {
|
|
544
|
+
(u, v): f"{data.get('weight', data.get('distance', ''))}"
|
|
545
|
+
for u, v, data in temp.graph.edges(data=True)
|
|
546
|
+
}
|
|
547
|
+
nx.draw_networkx_edge_labels(
|
|
548
|
+
temp.graph,
|
|
549
|
+
pos,
|
|
550
|
+
edge_labels=edge_labels,
|
|
551
|
+
font_size=9,
|
|
552
|
+
font_color="red",
|
|
553
|
+
ax=ax,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
title_lines = [f"Step {step + 1}: Add {label}"]
|
|
557
|
+
if log["action"] == "insert":
|
|
558
|
+
nearest_labels = [label_map.get(node, str(node)[-4:]) for node in log["nearest_nodes"]]
|
|
559
|
+
title_lines.append(f"Near: {','.join(nearest_labels)} (d={log['nearest_dist']})")
|
|
560
|
+
for u, v, weight in log.get("edges_removed", []):
|
|
561
|
+
title_lines.append(f"CUT {label_map.get(u, str(u)[-4:])}-{label_map.get(v, str(v)[-4:])}(d={weight})")
|
|
562
|
+
for u, v, weight in log.get("edges_rerouted", []):
|
|
563
|
+
title_lines.append(f"ADD {label_map.get(u, str(u)[-4:])}-{label_map.get(v, str(v)[-4:])}(d={weight})")
|
|
564
|
+
elif log["action"] == "increment":
|
|
565
|
+
title_lines.append(f"EXISTS count={log['new_count']}")
|
|
566
|
+
elif log["action"] == "second_node":
|
|
567
|
+
connected = [label_map.get(node, str(node)[-4:]) for node in log["connected_to"]]
|
|
568
|
+
title_lines.append(f"Connect: {','.join(connected)} (d={log['distance']})")
|
|
569
|
+
|
|
570
|
+
ax.set_title("\n".join(title_lines), fontsize=8, fontweight="bold")
|
|
571
|
+
ax.axis("off")
|
|
572
|
+
|
|
573
|
+
fig.suptitle(
|
|
574
|
+
"Step-by-Step Insertion with Edge Restructuring",
|
|
575
|
+
fontsize=14,
|
|
576
|
+
fontweight="bold",
|
|
577
|
+
y=1.02,
|
|
578
|
+
)
|
|
579
|
+
fig.tight_layout()
|
|
580
|
+
return fig
|
|
581
|
+
|
|
582
|
+
def plot_graph(
|
|
583
|
+
self,
|
|
584
|
+
title: str = "H3 Hex Graph",
|
|
585
|
+
highlight_hexes: Sequence[str] | None = None,
|
|
586
|
+
figsize: tuple[float, float] = (14, 10),
|
|
587
|
+
show_edge_weights: bool = True,
|
|
588
|
+
):
|
|
589
|
+
"""Compatibility plotting method."""
|
|
590
|
+
return self.visualize_graph(
|
|
591
|
+
title=title,
|
|
592
|
+
highlight_hexes=highlight_hexes,
|
|
593
|
+
figsize=figsize,
|
|
594
|
+
show_edge_weights=show_edge_weights,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def plot_h3_cells(
|
|
598
|
+
self,
|
|
599
|
+
cells: Sequence[str] | str | None = None,
|
|
600
|
+
*,
|
|
601
|
+
title: str = "H3 Cell Footprint",
|
|
602
|
+
highlight_hexes: Sequence[str] | None = None,
|
|
603
|
+
figsize: tuple[float, float] = (10, 8),
|
|
604
|
+
show_labels: bool = True,
|
|
605
|
+
label_full_hex: bool = False,
|
|
606
|
+
):
|
|
607
|
+
"""Plot H3 cells as real geospatial hex polygons.
|
|
608
|
+
|
|
609
|
+
If ``cells`` is omitted, all graph nodes are plotted.
|
|
610
|
+
"""
|
|
611
|
+
from .plotting import plot_h3_cells
|
|
612
|
+
|
|
613
|
+
target_cells = list(self.graph.nodes) if cells is None else cells
|
|
614
|
+
return plot_h3_cells(
|
|
615
|
+
target_cells,
|
|
616
|
+
title=title,
|
|
617
|
+
selected_cells=highlight_hexes,
|
|
618
|
+
figsize=figsize,
|
|
619
|
+
show_labels=show_labels,
|
|
620
|
+
label_full_hex=label_full_hex,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def plot_h3_cells_map(
|
|
624
|
+
self,
|
|
625
|
+
cells: Sequence[str] | str | None = None,
|
|
626
|
+
*,
|
|
627
|
+
title: str = "H3 Cells Map",
|
|
628
|
+
highlight_hexes: Sequence[str] | str | None = None,
|
|
629
|
+
figsize: tuple[float, float] = (16, 16),
|
|
630
|
+
basemap: bool = True,
|
|
631
|
+
):
|
|
632
|
+
"""Plot graph H3 cells through GeoPandas with optional Contextily basemap."""
|
|
633
|
+
from .plotting import plot_h3_cells_map
|
|
634
|
+
|
|
635
|
+
target_cells = list(self.graph.nodes) if cells is None else cells
|
|
636
|
+
return plot_h3_cells_map(
|
|
637
|
+
target_cells,
|
|
638
|
+
title=title,
|
|
639
|
+
selected_cells=highlight_hexes,
|
|
640
|
+
figsize=figsize,
|
|
641
|
+
basemap=basemap,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def get_latlng(self, cells: Sequence[str] | str | None = None) -> list[tuple[float, float]]:
|
|
645
|
+
"""Return graph H3 centers as ``(lat, lng)`` tuples."""
|
|
646
|
+
from .geometry import get_latlng
|
|
647
|
+
|
|
648
|
+
target_cells = list(self.graph.nodes) if cells is None else cells
|
|
649
|
+
return get_latlng(target_cells)
|
|
650
|
+
|
|
651
|
+
def convex_hull(self, cells: Sequence[str] | str | None = None):
|
|
652
|
+
"""Return a Shapely convex hull around graph H3 cell centers."""
|
|
653
|
+
from .geometry import h3_convex_hull
|
|
654
|
+
|
|
655
|
+
target_cells = list(self.graph.nodes) if cells is None else cells
|
|
656
|
+
return h3_convex_hull(target_cells)
|
|
657
|
+
|
|
658
|
+
def convex_hull_geojson(self, cells: Sequence[str] | str | None = None) -> dict | None:
|
|
659
|
+
"""Return graph H3 center convex hull as GeoJSON-like mapping."""
|
|
660
|
+
from .geometry import h3_convex_hull_geojson
|
|
661
|
+
|
|
662
|
+
target_cells = list(self.graph.nodes) if cells is None else cells
|
|
663
|
+
return h3_convex_hull_geojson(target_cells)
|
|
664
|
+
|
|
665
|
+
def _upsert_node(self, h3_hex: str, value: float = 0.0) -> bool:
|
|
666
|
+
self.node_add_count += 1
|
|
667
|
+
self.total_value_sum += float(value)
|
|
668
|
+
|
|
669
|
+
if h3_hex in self.graph:
|
|
670
|
+
self.graph.nodes[h3_hex]["count"] = int(self.graph.nodes[h3_hex].get("count", 0)) + 1
|
|
671
|
+
self.graph.nodes[h3_hex]["value"] = float(self.graph.nodes[h3_hex].get("value", 0)) + float(value)
|
|
672
|
+
return False
|
|
673
|
+
|
|
674
|
+
self.graph.add_node(h3_hex, count=1, value=float(value))
|
|
675
|
+
return True
|
|
676
|
+
|
|
677
|
+
def _add_or_update_edge(
|
|
678
|
+
self,
|
|
679
|
+
source: str,
|
|
680
|
+
target: str,
|
|
681
|
+
distance: float,
|
|
682
|
+
count_increment: int = 1,
|
|
683
|
+
kind: str = "route",
|
|
684
|
+
) -> None:
|
|
685
|
+
if source == target:
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
if self.graph.has_edge(source, target):
|
|
689
|
+
data = self.graph[source][target]
|
|
690
|
+
existing_distance = float(data.get("distance", data.get("weight", distance)) or distance)
|
|
691
|
+
data["distance"] = min(existing_distance, float(distance))
|
|
692
|
+
data["weight"] = data["distance"]
|
|
693
|
+
data["count"] = int(data.get("count", 0) or 0) + int(count_increment)
|
|
694
|
+
data["kind"] = kind if data.get("kind") == kind else "mixed"
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
self.graph.add_edge(
|
|
698
|
+
source,
|
|
699
|
+
target,
|
|
700
|
+
weight=float(distance),
|
|
701
|
+
distance=float(distance),
|
|
702
|
+
count=int(count_increment),
|
|
703
|
+
kind=kind,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def _reroute_attachment_edges(self, new_hex: str, nearest_nodes: Sequence[str], log: dict) -> None:
|
|
707
|
+
edges_to_remove = set()
|
|
708
|
+
edges_to_add = []
|
|
709
|
+
|
|
710
|
+
for nearest in nearest_nodes:
|
|
711
|
+
for neighbor in list(self.graph.neighbors(nearest)):
|
|
712
|
+
if neighbor == new_hex:
|
|
713
|
+
continue
|
|
714
|
+
|
|
715
|
+
edge_data = self.graph[nearest][neighbor]
|
|
716
|
+
if edge_data.get("kind") == "route" or int(edge_data.get("count", 0) or 0) > 0:
|
|
717
|
+
continue
|
|
718
|
+
|
|
719
|
+
old_distance = float(edge_data.get("distance", edge_data.get("weight", 0)) or 0)
|
|
720
|
+
new_distance = self._grid_dist(new_hex, neighbor)
|
|
721
|
+
if new_distance < old_distance:
|
|
722
|
+
edges_to_remove.add((nearest, neighbor))
|
|
723
|
+
if not self.graph.has_edge(new_hex, neighbor):
|
|
724
|
+
edges_to_add.append((new_hex, neighbor, new_distance))
|
|
725
|
+
|
|
726
|
+
for source, target in edges_to_remove:
|
|
727
|
+
if self.graph.has_edge(source, target):
|
|
728
|
+
old_distance = self.graph[source][target].get("distance", self.graph[source][target].get("weight"))
|
|
729
|
+
self.graph.remove_edge(source, target)
|
|
730
|
+
log["edges_removed"].append((source, target, old_distance))
|
|
731
|
+
|
|
732
|
+
for source, target, distance in edges_to_add:
|
|
733
|
+
if not self.graph.has_edge(source, target):
|
|
734
|
+
self._add_or_update_edge(source, target, distance, count_increment=0, kind="attachment")
|
|
735
|
+
log["edges_rerouted"].append((source, target, distance))
|
|
736
|
+
|
|
737
|
+
def _stats_for_hexes(self, hexes: Sequence[str]) -> dict:
|
|
738
|
+
if not hexes:
|
|
739
|
+
return {
|
|
740
|
+
"hexes": [],
|
|
741
|
+
"total_count": 0,
|
|
742
|
+
"total_value": 0,
|
|
743
|
+
"num_hexes": 0,
|
|
744
|
+
"count_coverage_pct": 0,
|
|
745
|
+
"value_coverage_pct": 0,
|
|
746
|
+
"area_km2": None,
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
selected = set(hexes)
|
|
750
|
+
total_count = sum(int(self.graph.nodes[cell].get("count", 0) or 0) for cell in selected)
|
|
751
|
+
total_value = sum(float(self.graph.nodes[cell].get("value", 0) or 0) for cell in selected)
|
|
752
|
+
|
|
753
|
+
area = None
|
|
754
|
+
try:
|
|
755
|
+
area = round(sum(cell_area(cell, unit="km^2") for cell in selected), 4)
|
|
756
|
+
except Exception:
|
|
757
|
+
pass
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
"hexes": list(selected),
|
|
761
|
+
"total_count": total_count,
|
|
762
|
+
"total_value": round(total_value, 2),
|
|
763
|
+
"num_hexes": len(selected),
|
|
764
|
+
"count_coverage_pct": round(total_count / self.node_add_count * 100, 2)
|
|
765
|
+
if self.node_add_count
|
|
766
|
+
else 0,
|
|
767
|
+
"value_coverage_pct": round(total_value / self.total_value_sum * 100, 2)
|
|
768
|
+
if self.total_value_sum
|
|
769
|
+
else 0,
|
|
770
|
+
"area_km2": area,
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
def _validate_hex(self, h3_hex: str) -> None:
|
|
774
|
+
if not is_valid_cell(h3_hex):
|
|
775
|
+
raise ValueError(f"Invalid H3 cell: {h3_hex}")
|