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,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}")