nadata 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.
Files changed (86) hide show
  1. nadata/__init__.py +15 -0
  2. nadata/api.py +894 -0
  3. nadata/cli.py +445 -0
  4. nadata/console.py +486 -0
  5. nadata/core/__init__.py +0 -0
  6. nadata/core/database/__init__.py +38 -0
  7. nadata/core/database/builtins.py +87 -0
  8. nadata/core/database/database.py +958 -0
  9. nadata/core/database/dumper.py +285 -0
  10. nadata/core/database/entity_id.py +142 -0
  11. nadata/core/database/instance.py +706 -0
  12. nadata/core/database/network_index.py +1142 -0
  13. nadata/core/database/page_manager.py +175 -0
  14. nadata/core/database/persistence/__init__.py +28 -0
  15. nadata/core/database/persistence/binary_cells.py +1141 -0
  16. nadata/core/database/persistence/compaction.py +97 -0
  17. nadata/core/database/persistence/dumper.py +798 -0
  18. nadata/core/database/persistence/lazy_instances.py +1537 -0
  19. nadata/core/database/persistence/loader.py +424 -0
  20. nadata/core/database/persistence/record_store.py +228 -0
  21. nadata/core/database/persistence/serialization.py +605 -0
  22. nadata/core/database/query_engine.py +222 -0
  23. nadata/core/database/queryset.py +508 -0
  24. nadata/core/database/reconciliation.py +287 -0
  25. nadata/core/database/slotted_page.py +190 -0
  26. nadata/core/database/spatial_index.py +1080 -0
  27. nadata/core/expressions/__init__.py +44 -0
  28. nadata/core/expressions/autograd.py +2909 -0
  29. nadata/core/expressions/builtins.py +4673 -0
  30. nadata/core/expressions/expression.py +2658 -0
  31. nadata/core/expressions/io_builtins.py +972 -0
  32. nadata/core/expressions/scope.py +336 -0
  33. nadata/core/expressions/statement.py +953 -0
  34. nadata/core/struct/__init__.py +14 -0
  35. nadata/core/struct/struct.py +218 -0
  36. nadata/core/struct/struct_attribute.py +100 -0
  37. nadata/core/struct/struct_method.py +324 -0
  38. nadata/core/types/__init__.py +33 -0
  39. nadata/core/types/bits/__init__.py +0 -0
  40. nadata/core/types/bits/bits.py +686 -0
  41. nadata/core/types/home_types/__init__.py +19 -0
  42. nadata/core/types/home_types/color.py +102 -0
  43. nadata/core/types/home_types/coordinate.py +1008 -0
  44. nadata/core/types/home_types/datetime.py +362 -0
  45. nadata/core/types/home_types/geogrid.py +92 -0
  46. nadata/core/types/home_types/geometry.py +1223 -0
  47. nadata/core/types/home_types/graph.py +0 -0
  48. nadata/core/types/home_types/planet.py +359 -0
  49. nadata/core/types/home_types/sea_level.py +98 -0
  50. nadata/core/types/home_types/vec.py +0 -0
  51. nadata/core/types/types.py +1345 -0
  52. nadata/core/values/__init__.py +14 -0
  53. nadata/core/values/values.py +1568 -0
  54. nadata/parser/__init__.py +0 -0
  55. nadata/parser/expressions/__init__.py +6 -0
  56. nadata/parser/expressions/dependencies.py +318 -0
  57. nadata/parser/expressions/expression_parser.py +993 -0
  58. nadata/parser/expressions/splitter.py +98 -0
  59. nadata/parser/expressions/statement_parser.py +1425 -0
  60. nadata/parser/expressions/tokenizer.py +402 -0
  61. nadata/parser/expressions/type_parser.py +187 -0
  62. nadata/parser/nadata_loader/__init__.py +8 -0
  63. nadata/parser/nadata_loader/loader.py +951 -0
  64. nadata/parser/nastruct_parser/__init__.py +0 -0
  65. nadata/parser/nastruct_parser/declaration.py +105 -0
  66. nadata/parser/nastruct_parser/imports.py +164 -0
  67. nadata/parser/nastruct_parser/nastruct_parser.py +209 -0
  68. nadata/parser/nastruct_parser/parse_attributes.py +149 -0
  69. nadata/parser/nastruct_parser/parse_callables.py +436 -0
  70. nadata/parser/nastruct_parser/parse_expression.py +59 -0
  71. nadata/parser/nastruct_parser/parse_structs.py +206 -0
  72. nadata/parser/nastruct_parser/parser_state.py +84 -0
  73. nadata/parser/nastruct_parser/resolver.py +1412 -0
  74. nadata/parser/nastruct_parser/struct_body_state.py +50 -0
  75. nadata/parser/nastruct_parser/tmp_structs.py +156 -0
  76. nadata/parser/utils/__init__.py +20 -0
  77. nadata/parser/utils/brackets.py +252 -0
  78. nadata/parser/utils/comments_remove.py +149 -0
  79. nadata/parser/utils/identifiers.py +121 -0
  80. nadata/session.py +868 -0
  81. nadata-0.1.0.dist-info/METADATA +191 -0
  82. nadata-0.1.0.dist-info/RECORD +86 -0
  83. nadata-0.1.0.dist-info/WHEEL +5 -0
  84. nadata-0.1.0.dist-info/entry_points.txt +2 -0
  85. nadata-0.1.0.dist-info/licenses/LICENSE +21 -0
  86. nadata-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1142 @@
1
+ """
2
+ Network index and routing over user-defined node / edge structs.
3
+
4
+ A network binds a node struct (carrying a Coordinate position) and an
5
+ edge struct (carrying from/to references into the node struct, and
6
+ optionally modes, per-mode speeds, and a Polyline shape). Routing runs
7
+ A* over mode-filtered edges with geodesic costs; indoor graphs (rooms
8
+ as nodes, doors as edges) use the same machinery.
9
+
10
+ Like spatial indexes, networks are session scoped: they are rebuilt by
11
+ scripts after load, and maintained incrementally on insert / removal /
12
+ write of the involved tables.
13
+ """
14
+
15
+ # Import Modules
16
+ import heapq
17
+ import math
18
+ from typing import Any, Optional
19
+
20
+ from nadata.core.database.entity_id import EntityId
21
+ from nadata.core.types.home_types.coordinate import Coordinate
22
+ from nadata.core.types.home_types.geometry import Polyline
23
+ from nadata.core.types.home_types.planet import get_current_planet
24
+
25
+ # km/h to m/s
26
+ _KMH_TO_MPS: float = 1.0 / 3.6
27
+
28
+ # Fallback speed for edges (and networks) without any speed data;
29
+ # keeps time-cost routing connected on unannotated graphs
30
+ _DEFAULT_SPEED_MPS: float = 50.0 * _KMH_TO_MPS
31
+
32
+
33
+
34
+ class Path:
35
+ """
36
+ Routing result: the traversed nodes and edges with the total cost.
37
+
38
+ Attributes:
39
+ nodes (list[Any]): The traversed node instances, from the
40
+ snapped start to the snapped end.
41
+ edges (list[Any]): The traversed edge instances, in order.
42
+ cost (float): Total cost (meters for the distance mode,
43
+ seconds for the time mode).
44
+ mode (str): The routing mode the path was computed for.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ nodes: list[Any],
50
+ edges: list[Any],
51
+ cost: float,
52
+ mode: str,
53
+ ) -> None:
54
+ """
55
+ Initializes the Path instance.
56
+
57
+ Args:
58
+ nodes (list[Any]): Traversed node instances.
59
+ edges (list[Any]): Traversed edge instances.
60
+ cost (float): Total cost.
61
+ mode (str): Routing mode.
62
+ """
63
+
64
+ self.nodes: list[Any] = nodes
65
+ self.edges: list[Any] = edges
66
+ self.cost: float = cost
67
+ self.mode: str = mode
68
+
69
+ def __repr__(self) -> str:
70
+ """
71
+ Returns string representation of the Path.
72
+
73
+ Returns:
74
+ str: Representation with the mode, hop count, and cost.
75
+ """
76
+
77
+ return (
78
+ f"Path(mode={self.mode!r}, hops={len(self.edges)}, "
79
+ f"cost={self.cost:.2f})"
80
+ )
81
+
82
+
83
+
84
+ class NetworkIndex:
85
+ """
86
+ Session-scoped routing network over a node struct and an edge
87
+ struct.
88
+
89
+ Attributes:
90
+ nodes_table (Any): The StructTable of node instances.
91
+ edges_table (Any): The StructTable of edge instances.
92
+ position_attribute (str): The node Coordinate attribute name.
93
+ from_attribute (str): The edge attribute referencing the start
94
+ node.
95
+ to_attribute (str): The edge attribute referencing the end
96
+ node.
97
+ modes_attribute (Optional[str]): The edge Array[Str] modes
98
+ attribute, or None when edges are unfiltered.
99
+ speed_attribute (Optional[str]): The edge per-mode speed dict
100
+ attribute (km/h), or None.
101
+ shape_attribute (Optional[str]): The edge Polyline geometry
102
+ attribute, or None.
103
+ adjacency (dict[int, list[tuple[int, int]]]): Node local id to
104
+ (edge local id, other node local id) pairs.
105
+ node_ids (set[int]): Known node local ids.
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ nodes_table: Any,
111
+ edges_table: Any,
112
+ ) -> None:
113
+ """
114
+ Initializes the NetworkIndex instance and detects attributes.
115
+
116
+ Args:
117
+ nodes_table (Any): The node StructTable.
118
+ edges_table (Any): The edge StructTable.
119
+
120
+ Raises:
121
+ TypeError: If the structs lack the required attributes.
122
+ """
123
+
124
+ self.nodes_table: Any = nodes_table
125
+ self.edges_table: Any = edges_table
126
+
127
+ node_struct: Any = nodes_table.struct
128
+ edge_struct: Any = edges_table.struct
129
+
130
+ # The node position: the first Coordinate attribute
131
+ self.position_attribute: Optional[str] = None
132
+
133
+ for name, attr_def in dict(node_struct.get_all_attributes()).items():
134
+ if repr(attr_def.attr_type) == "Coordinate":
135
+ self.position_attribute = name
136
+
137
+ break
138
+
139
+ if self.position_attribute is None:
140
+ raise TypeError(
141
+ f"Struct '{node_struct.name}' has no Coordinate attribute "
142
+ f"for network node positions"
143
+ )
144
+
145
+ # The edge endpoints: Struct-typed attributes matching the node
146
+ # struct
147
+ from_attr: Optional[str] = None
148
+ to_attr: Optional[str] = None
149
+
150
+ for name, attr_def in dict(edge_struct.get_all_attributes()).items():
151
+ attr_type: Any = attr_def.attr_type
152
+
153
+ is_node_ref: bool = (
154
+ getattr(attr_type, "name", None) == node_struct.name
155
+ and hasattr(attr_type, "get_all_attributes")
156
+ )
157
+
158
+ if is_node_ref:
159
+ if from_attr is None:
160
+ from_attr = name
161
+ else:
162
+ to_attr = name
163
+
164
+ if from_attr is None or to_attr is None:
165
+ raise TypeError(
166
+ f"Struct '{edge_struct.name}' needs two attributes "
167
+ f"referencing '{node_struct.name}' (from / to)"
168
+ )
169
+
170
+ self.from_attribute: str = from_attr
171
+ self.to_attribute: str = to_attr
172
+
173
+ # Optional overlays
174
+ self.modes_attribute: Optional[str] = (
175
+ "modes" if edge_struct.get_attribute(name="modes") is not None else None
176
+ )
177
+ self.speed_attribute: Optional[str] = (
178
+ "speed" if edge_struct.get_attribute(name="speed") is not None else None
179
+ )
180
+ self.shape_attribute: Optional[str] = (
181
+ "shape" if edge_struct.get_attribute(name="shape") is not None else None
182
+ )
183
+
184
+ self.adjacency: dict[int, list[tuple[int, int]]] = {}
185
+ self.node_ids: set[int] = set()
186
+
187
+ # Edge local ids whose endpoint references were not yet
188
+ # populated at insert time (instance shells fill in later)
189
+ self._pending_edges: set[int] = set()
190
+
191
+ # Cost cache per edge local id: (cost mode, transport mode)
192
+ # -> seconds or meters
193
+ self._edge_costs: dict[int, dict[tuple[str, str], float]] = {}
194
+
195
+ # Mode -> node local id -> connected component id (lazily
196
+ # built for mode-aware snapping, cleared on every mutation)
197
+ self._component_cache: dict[str, dict[int, int]] = {}
198
+
199
+ # Fastest speed on the network (m/s) for the time heuristic
200
+ # and for unannotated edges; computed at build time (an
201
+ # underestimated max makes the heuristic inadmissible and
202
+ # strands A*)
203
+ self._max_speed_mps: float = 0.0
204
+
205
+ # A cell index over node positions for endpoint snapping
206
+ self._node_index: Any = None
207
+
208
+ def _ensure_node_index(self) -> Any:
209
+ """
210
+ Returns the cell index over node positions, creating it once.
211
+
212
+ Returns:
213
+ Any: The SpatialIndex over the position attribute.
214
+ """
215
+
216
+ if self._node_index is None:
217
+ existing: Optional[Any] = self.nodes_table.spatial_indexes.get(
218
+ self.position_attribute
219
+ )
220
+
221
+ if existing is not None:
222
+ self._node_index = existing
223
+ else:
224
+ self._node_index = self.nodes_table.create_spatial_index(
225
+ attribute=self.position_attribute, lod=10
226
+ )
227
+
228
+ return self._node_index
229
+
230
+ def build(self) -> None:
231
+ """
232
+ Indexes all existing nodes and edges of both tables.
233
+
234
+ Returns:
235
+ None: Nothing.
236
+ """
237
+
238
+ self._ensure_node_index()
239
+
240
+ self._component_cache.clear()
241
+
242
+ for instance in self.nodes_table.instances.values():
243
+ self._add_node(local_id=instance.entity_id.local_id)
244
+
245
+ for instance in self.edges_table.instances.values():
246
+ self._add_edge(instance=instance)
247
+ self._absorb_speed(instance=instance)
248
+
249
+ self._resolve_pending_edges()
250
+
251
+ # --- Maintenance hooks ---
252
+
253
+ def on_insert(self, instance: Any) -> None:
254
+ """
255
+ Adds a newly inserted node or edge instance.
256
+
257
+ Args:
258
+ instance (Any): The inserted StructInstance.
259
+ """
260
+
261
+ self._component_cache.clear()
262
+
263
+ if instance.struct is self.nodes_table.struct:
264
+ self._add_node(local_id=instance.entity_id.local_id)
265
+
266
+ return None
267
+
268
+ if instance.struct is self.edges_table.struct:
269
+ self._add_edge(instance=instance)
270
+ self._absorb_speed(instance=instance)
271
+
272
+ def on_remove(self, local_id: int, table: Any) -> None:
273
+ """
274
+ Drops a local id from the network.
275
+
276
+ Node and edge local ids live in separate per-table spaces, so
277
+ the calling table disambiguates the removal.
278
+
279
+ Args:
280
+ local_id (int): The removed local id.
281
+ table (Any): The StructTable the removal happened on.
282
+ """
283
+
284
+ self._component_cache.clear()
285
+
286
+ if table is self.nodes_table:
287
+ self._remove_node(local_id=local_id)
288
+
289
+ return None
290
+
291
+ self._remove_edge(local_id=local_id)
292
+
293
+ def on_update(self, instance: Any) -> None:
294
+ """
295
+ Invalidates cached costs after an edge attribute write.
296
+
297
+ Args:
298
+ instance (Any): The written StructInstance.
299
+ """
300
+
301
+ if instance.struct is not self.edges_table.struct:
302
+ return None
303
+
304
+ self._component_cache.clear()
305
+
306
+ local_id: int = instance.entity_id.local_id
307
+
308
+ self._edge_costs.pop(local_id, None)
309
+
310
+ self._remove_edge(local_id=local_id)
311
+ self._add_edge(instance=instance)
312
+
313
+ def _add_node(self, local_id: int) -> None:
314
+ """
315
+ Registers one node local id.
316
+
317
+ Args:
318
+ local_id (int): The node local id.
319
+ """
320
+
321
+ self.node_ids.add(local_id)
322
+
323
+ self.adjacency.setdefault(local_id, [])
324
+
325
+ def _remove_node(self, local_id: int) -> None:
326
+ """
327
+ Drops one node and every incident edge.
328
+
329
+ Args:
330
+ local_id (int): The node local id.
331
+ """
332
+
333
+ self.node_ids.discard(local_id)
334
+
335
+ self.adjacency.pop(local_id, None)
336
+
337
+ # Drop dangling edge entries pointing at the removed node
338
+ for node_id, links in self.adjacency.items():
339
+ self.adjacency[node_id] = [
340
+ (edge_id, other)
341
+ for edge_id, other in links
342
+ if other != local_id
343
+ ]
344
+
345
+ def _remove_edge(self, local_id: int) -> None:
346
+ """
347
+ Drops one edge from the adjacency and the cost cache.
348
+
349
+ Args:
350
+ local_id (int): The edge local id.
351
+ """
352
+
353
+ self._edge_costs.pop(local_id, None)
354
+ self._pending_edges.discard(local_id)
355
+
356
+ for node_id in list(self.adjacency.keys()):
357
+ self.adjacency[node_id] = [
358
+ (edge_id, other)
359
+ for edge_id, other in self.adjacency[node_id]
360
+ if edge_id != local_id
361
+ ]
362
+
363
+ def _resolve_pending_edges(self) -> None:
364
+ """
365
+ Retries edges whose endpoints were missing when they arrived.
366
+
367
+ Returns:
368
+ None: Nothing.
369
+ """
370
+
371
+ for local_id in list(self._pending_edges):
372
+ instance: Any = self.edges_table.instances.get(local_id)
373
+
374
+ if instance is None:
375
+ self._pending_edges.discard(local_id)
376
+
377
+ continue
378
+
379
+ self._pending_edges.discard(local_id)
380
+
381
+ self._add_edge(instance=instance)
382
+
383
+ def _add_edge(self, instance: Any) -> None:
384
+ """
385
+ Registers one edge instance in the adjacency.
386
+
387
+ Edges whose endpoint references are not yet populated are
388
+ parked as pending and retried before the next route.
389
+
390
+ Args:
391
+ instance (Any): The edge StructInstance.
392
+ """
393
+
394
+ local_id: int = instance.entity_id.local_id
395
+
396
+ try:
397
+ from_node: int = self._endpoint_local_id(
398
+ instance=instance, attribute=self.from_attribute
399
+ )
400
+ to_node: int = self._endpoint_local_id(
401
+ instance=instance, attribute=self.to_attribute
402
+ )
403
+ except TypeError:
404
+ self._pending_edges.add(local_id)
405
+
406
+ return None
407
+
408
+ if from_node not in self.node_ids:
409
+ self._add_node(local_id=from_node)
410
+
411
+ if to_node not in self.node_ids:
412
+ self._add_node(local_id=to_node)
413
+
414
+ self.adjacency.setdefault(from_node, []).append(
415
+ (instance.entity_id.local_id, to_node)
416
+ )
417
+ self.adjacency.setdefault(to_node, []).append(
418
+ (instance.entity_id.local_id, from_node)
419
+ )
420
+
421
+ def _endpoint_local_id(self, instance: Any, attribute: str) -> int:
422
+ """
423
+ Resolves an endpoint reference to a node local id.
424
+
425
+ Args:
426
+ instance (Any): The edge StructInstance.
427
+ attribute (str): The referencing attribute name.
428
+
429
+ Returns:
430
+ int: The node local id.
431
+
432
+ Raises:
433
+ TypeError: If the attribute does not reference an instance.
434
+ """
435
+
436
+ value: Any = instance.attributes.get(attribute)
437
+
438
+ if value is None:
439
+ raise TypeError(
440
+ f"Edge instance is missing its '{attribute}' endpoint"
441
+ )
442
+
443
+ raw: Any = value.get() if hasattr(value, "get") else value
444
+
445
+ local_id: int = raw.entity_id.local_id
446
+
447
+ return local_id
448
+
449
+ # --- Costs ---
450
+
451
+ def _edge_instance(self, local_id: int) -> Any:
452
+ """
453
+ Returns an edge instance by local id.
454
+
455
+ Args:
456
+ local_id (int): The edge local id.
457
+
458
+ Returns:
459
+ Any: The StructInstance.
460
+ """
461
+
462
+ return self.edges_table.get(local_id=local_id)
463
+
464
+ def _edge_length(self, edge: Any) -> float:
465
+ """
466
+ Returns the geodesic length of an edge in meters.
467
+
468
+ Uses the shape Polyline when present; otherwise the direct
469
+ geodesic distance between the endpoint positions.
470
+
471
+ Args:
472
+ edge (Any): The edge StructInstance.
473
+
474
+ Returns:
475
+ float: Length in meters.
476
+ """
477
+
478
+ if self.shape_attribute is not None:
479
+ shape_value: Any = edge.attributes.get(self.shape_attribute)
480
+
481
+ if shape_value is not None:
482
+ raw: Any = shape_value.get() if hasattr(shape_value, "get") else (
483
+ shape_value
484
+ )
485
+
486
+ if isinstance(raw, Polyline):
487
+ return raw.length()
488
+
489
+ return self._direct_length(edge=edge)
490
+
491
+ def _direct_length(self, edge: Any) -> float:
492
+ """
493
+ Returns the direct geodesic distance between the endpoints.
494
+
495
+ Args:
496
+ edge (Any): The edge StructInstance.
497
+
498
+ Returns:
499
+ float: Distance in meters.
500
+ """
501
+
502
+ planet: Any = get_current_planet()
503
+
504
+ start: Coordinate = self._node_position(
505
+ local_id=self._endpoint_local_id(
506
+ instance=edge, attribute=self.from_attribute
507
+ )
508
+ )
509
+ end: Coordinate = self._node_position(
510
+ local_id=self._endpoint_local_id(
511
+ instance=edge, attribute=self.to_attribute
512
+ )
513
+ )
514
+
515
+ return start.distance(other=end, planet=planet)
516
+
517
+ def _node_position(self, local_id: int) -> Coordinate:
518
+ """
519
+ Returns the position Coordinate of a node.
520
+
521
+ Args:
522
+ local_id (int): The node local id.
523
+
524
+ Returns:
525
+ Coordinate: The position.
526
+
527
+ Raises:
528
+ TypeError: If the node has no position attribute.
529
+ """
530
+
531
+ node: Any = self.nodes_table.get(local_id=local_id)
532
+
533
+ value: Any = node.attributes.get(self.position_attribute)
534
+
535
+ if value is None:
536
+ raise TypeError(
537
+ f"Node {local_id} has no '{self.position_attribute}' value"
538
+ )
539
+
540
+ raw: Any = value.get() if hasattr(value, "get") else value
541
+
542
+ position: Coordinate = raw
543
+
544
+ return position
545
+
546
+ def _edge_cost(
547
+ self,
548
+ edge_local_id: int,
549
+ transport_mode: str,
550
+ cost_mode: str,
551
+ ) -> Optional[float]:
552
+ """
553
+ Returns the cost of an edge, or None when the edge is
554
+ unusable.
555
+
556
+ Args:
557
+ edge_local_id (int): The edge local id.
558
+ transport_mode (str): The transport mode filtering edges.
559
+ cost_mode (str): "distance" (meters) or "time" (seconds).
560
+
561
+ Returns:
562
+ Optional[float]: The cost, or None when the mode is
563
+ unavailable.
564
+ """
565
+
566
+ cache: Optional[dict[tuple[str, str], float]] = (
567
+ self._edge_costs.get(edge_local_id)
568
+ )
569
+
570
+ key: tuple[str, str] = (cost_mode, transport_mode)
571
+
572
+ if cache is not None and key in cache:
573
+ return cache[key]
574
+
575
+ edge: Any = self._edge_instance(local_id=edge_local_id)
576
+
577
+ if not self._allows_mode(edge=edge, mode=transport_mode):
578
+ cost: Optional[float] = None
579
+ elif cost_mode == "time":
580
+ cost = self._time_cost(edge=edge, transport_mode=transport_mode)
581
+ else:
582
+ cost = self._edge_length(edge=edge)
583
+
584
+ if cache is None:
585
+ cache = {}
586
+ self._edge_costs[edge_local_id] = cache
587
+
588
+ if cost is not None:
589
+ cache[key] = cost
590
+
591
+ return cost
592
+
593
+ def _allows_mode(self, edge: Any, mode: str) -> bool:
594
+ """
595
+ Checks whether an edge carries a mode.
596
+
597
+ Args:
598
+ edge (Any): The edge StructInstance.
599
+ mode (str): The routing mode.
600
+
601
+ Returns:
602
+ bool: True when the edge is usable in the mode (always
603
+ true when the edge struct has no modes attribute).
604
+ """
605
+
606
+ if self.modes_attribute is None:
607
+ return True
608
+
609
+ value: Any = edge.attributes.get(self.modes_attribute)
610
+
611
+ if value is None:
612
+ return True
613
+
614
+ raw: Any = value.get() if hasattr(value, "get") else value
615
+
616
+ return mode in raw
617
+
618
+ def _unannotated_speed(self) -> float:
619
+ """
620
+ Returns the speed assumed for edges without speed data.
621
+
622
+ The network's fastest seen speed when known; a conservative
623
+ urban default otherwise (the heuristic divides by the same
624
+ value, so it stays admissible).
625
+
626
+ Returns:
627
+ float: Speed in m/s.
628
+ """
629
+
630
+ if self._max_speed_mps > 0.0:
631
+ return self._max_speed_mps
632
+
633
+ return _DEFAULT_SPEED_MPS
634
+
635
+ def _time_cost(self, edge: Any, transport_mode: str) -> Optional[float]:
636
+ """
637
+ Returns the traversal time of an edge in seconds.
638
+
639
+ The speed for the transport mode is preferred; an edge without
640
+ that entry falls back to its fastest speed so partially
641
+ annotated networks stay connected.
642
+
643
+ Args:
644
+ edge (Any): The edge StructInstance.
645
+ transport_mode (str): The transport mode.
646
+
647
+ Returns:
648
+ Optional[float]: Seconds, or None when the edge has no
649
+ usable speed.
650
+ """
651
+
652
+ if self.speed_attribute is None:
653
+ return None
654
+
655
+ value: Any = edge.attributes.get(self.speed_attribute)
656
+
657
+ if value is None:
658
+ # Missing (defaulted) speed attributes behave like empty
659
+ # ones: optimistic estimate keeps the edge connected
660
+ return self._edge_length(edge=edge) / self._unannotated_speed()
661
+
662
+ raw: Any = value.get() if hasattr(value, "get") else value
663
+
664
+ if not raw:
665
+ # Unannotated edges stay connected with an optimistic
666
+ # estimate (the network's fastest seen speed); this also
667
+ # keeps the time heuristic admissible
668
+ return self._edge_length(edge=edge) / self._unannotated_speed()
669
+
670
+ length: float = self._edge_length(edge=edge)
671
+
672
+ preferred: Optional[float] = raw.get(transport_mode)
673
+
674
+ if (
675
+ isinstance(preferred, (int, float))
676
+ and not isinstance(preferred, bool)
677
+ and preferred > 0.0
678
+ ):
679
+ speed_mps: float = preferred * _KMH_TO_MPS
680
+
681
+ if speed_mps > self._max_speed_mps:
682
+ self._max_speed_mps = speed_mps
683
+
684
+ seconds: float = length / speed_mps
685
+
686
+ return seconds
687
+
688
+ best_speed_mps: Optional[float] = None
689
+
690
+ for speed_kmh in raw.values():
691
+ if isinstance(speed_kmh, bool) or not isinstance(speed_kmh, (int, float)):
692
+ continue
693
+
694
+ fallback_mps: float = speed_kmh * _KMH_TO_MPS
695
+
696
+ if fallback_mps <= 0.0:
697
+ continue
698
+
699
+ if best_speed_mps is None or fallback_mps > best_speed_mps:
700
+ best_speed_mps = fallback_mps
701
+
702
+ if fallback_mps > self._max_speed_mps:
703
+ self._max_speed_mps = fallback_mps
704
+
705
+ if best_speed_mps is None:
706
+ return None
707
+
708
+ return length / best_speed_mps
709
+
710
+ # --- Routing ---
711
+
712
+ def snap(self, coord: Coordinate) -> Optional[int]:
713
+ """
714
+ Snaps a coordinate to the nearest node local id.
715
+
716
+ Args:
717
+ coord (Coordinate): The query coordinate.
718
+
719
+ Returns:
720
+ Optional[int]: The node local id, or None when the network
721
+ has no nodes.
722
+ """
723
+
724
+ index: Any = self._ensure_node_index()
725
+
726
+ result: list[int] = index.nearest(coord=coord, k=1)
727
+
728
+ if not result:
729
+ return None
730
+
731
+ # The shared spatial index keys rows by global entity id;
732
+ # network node ids are local ids in the nodes table
733
+ return EntityId(value=result[0]).local_id
734
+
735
+ def _absorb_speed(self, instance: Any) -> None:
736
+ """
737
+ Tracks an edge's fastest speed for the time heuristic.
738
+
739
+ Args:
740
+ instance (Any): The edge StructInstance.
741
+ """
742
+
743
+ if self.speed_attribute is None:
744
+ return None
745
+
746
+ value: Any = instance.attributes.get(self.speed_attribute)
747
+
748
+ if value is None:
749
+ return None
750
+
751
+ raw: Any = value.get() if hasattr(value, "get") else value
752
+
753
+ if not raw:
754
+ return None
755
+
756
+ for speed_kmh in raw.values():
757
+ if isinstance(speed_kmh, bool) or not isinstance(
758
+ speed_kmh, (int, float)
759
+ ):
760
+ continue
761
+
762
+ if speed_kmh > 0.0:
763
+ speed_mps: float = speed_kmh * _KMH_TO_MPS
764
+
765
+ if speed_mps > self._max_speed_mps:
766
+ self._max_speed_mps = speed_mps
767
+
768
+ def _mode_components(self, mode: str) -> dict[int, int]:
769
+ """
770
+ Maps every mode-usable node to its connected component id.
771
+
772
+ Args:
773
+ mode (str): The transport mode.
774
+
775
+ Returns:
776
+ dict[int, int]: Node local id to component id.
777
+ """
778
+
779
+ cached: Optional[dict[int, int]] = self._component_cache.get(mode)
780
+
781
+ if cached is not None:
782
+ return cached
783
+
784
+ parent: dict[int, int] = {}
785
+ rank: dict[int, int] = {}
786
+
787
+ def find(node: int) -> int:
788
+ """
789
+ Returns the component root of one node (with path
790
+ compression).
791
+
792
+ Args:
793
+ node (int): The node local id.
794
+
795
+ Returns:
796
+ int: The root local id.
797
+ """
798
+
799
+ root: int = node
800
+
801
+ while parent.get(root, root) != root:
802
+ root = parent[root]
803
+
804
+ while parent.get(node, node) != node:
805
+ parent[node], node = root, parent[node]
806
+
807
+ return root
808
+
809
+ def union(node_a: int, node_b: int) -> None:
810
+ """
811
+ Merges two components (by rank).
812
+
813
+ Args:
814
+ node_a (int): One side's node local id.
815
+ node_b (int): The other side's node local id.
816
+ """
817
+
818
+ root_a: int = find(node_a)
819
+ root_b: int = find(node_b)
820
+
821
+ if root_a == root_b:
822
+ return None
823
+
824
+ rank_a: int = rank.get(root_a, 0)
825
+ rank_b: int = rank.get(root_b, 0)
826
+
827
+ if rank_a < rank_b:
828
+ parent[root_a] = root_b
829
+
830
+ elif rank_a > rank_b:
831
+ parent[root_b] = root_a
832
+
833
+ else:
834
+ parent[root_b] = root_a
835
+ rank[root_a] = rank_a + 1
836
+
837
+ for node_id in self.node_ids:
838
+ parent[node_id] = node_id
839
+
840
+ for node_id, entries in self.adjacency.items():
841
+ for edge_id, neighbor in entries:
842
+ edge: Any = self._edge_instance(local_id=edge_id)
843
+
844
+ if not self._allows_mode(edge=edge, mode=mode):
845
+ continue
846
+
847
+ union(node_id, neighbor)
848
+
849
+ components: dict[int, int] = {
850
+ node_id: find(node_id) for node_id in parent
851
+ }
852
+
853
+ self._component_cache[mode] = components
854
+
855
+ return components
856
+
857
+ def _largest_component(self, components: dict[int, int]) -> int:
858
+ """
859
+ Returns the component id with the most nodes.
860
+
861
+ Args:
862
+ components (dict[int, int]): Node to component mapping.
863
+
864
+ Returns:
865
+ int: The dominant component id (0 when empty).
866
+ """
867
+
868
+ counts: dict[int, int] = {}
869
+
870
+ for component in components.values():
871
+ counts[component] = counts.get(component, 0) + 1
872
+
873
+ return max(counts, key=lambda c: counts[c]) if counts else 0
874
+
875
+ def _mode_snap(self, coord: Coordinate, mode: str) -> Optional[int]:
876
+ """
877
+ Snaps a coordinate to the nearest node usable in a mode.
878
+
879
+ The plain snap ignores transport modes; a point in a
880
+ pedestrian zone would otherwise anchor a car route to a
881
+ walk-only node. Candidates are scanned nearest-first, preferring
882
+ nodes of the mode's largest connected component so courtyards
883
+ and parking-lot stubs do not strand the route.
884
+
885
+ Args:
886
+ coord (Coordinate): The query coordinate.
887
+ mode (str): The transport mode.
888
+
889
+ Returns:
890
+ Optional[int]: The node local id, or None when no nearby
891
+ node is usable in the mode.
892
+ """
893
+
894
+ index: Any = self._ensure_node_index()
895
+
896
+ # Entity-id keys until converted; the scan widens until a
897
+ # node of the mode's dominant component is found (dense
898
+ # pedestrian zones can bury the car network deep in the
899
+ # nearest-first order)
900
+ fallback: Optional[int] = None
901
+
902
+ for radius in (32, 128, 512, 2048):
903
+ keys: list[int] = index.nearest(coord=coord, k=radius)
904
+
905
+ components: Optional[dict[int, int]] = None
906
+
907
+ for key in keys:
908
+ node_id: int = EntityId(value=key).local_id
909
+
910
+ usable: bool = False
911
+
912
+ for edge_id, _neighbor in self.adjacency.get(node_id, []):
913
+ edge: Any = self._edge_instance(local_id=edge_id)
914
+
915
+ if self._allows_mode(edge=edge, mode=mode):
916
+ usable = True
917
+
918
+ break
919
+
920
+ if not usable:
921
+ continue
922
+
923
+ if fallback is None:
924
+ fallback = node_id
925
+
926
+ if components is None:
927
+ components = self._mode_components(mode=mode)
928
+
929
+ # The largest component wins: stubs and courtyards
930
+ # are real nodes but dead ends for routing
931
+ if components.get(
932
+ node_id, -1
933
+ ) == self._largest_component(components=components):
934
+ return node_id
935
+
936
+ return fallback
937
+
938
+ def route(
939
+ self,
940
+ start: Coordinate,
941
+ end: Coordinate,
942
+ mode: str,
943
+ cost: str = "distance",
944
+ ) -> Path:
945
+ """
946
+ Routes between two coordinates over mode-filtered edges.
947
+
948
+ Both endpoints snap to the nearest node; A* (Dijkstra when the
949
+ heuristic degenerates) finds the cheapest path.
950
+
951
+ Args:
952
+ start (Coordinate): The start coordinate.
953
+ end (Coordinate): The end coordinate.
954
+ mode (str): The transport mode filtering edges (for
955
+ example "car" or "pedestrian").
956
+ cost (str, optional): Cost mode, "distance" (meters) or
957
+ "time" (seconds via per-mode speeds). Defaults to
958
+ "distance".
959
+
960
+ Returns:
961
+ Path: The routing result.
962
+
963
+ Raises:
964
+ ValueError: If no node is near an endpoint, or no path
965
+ exists in the mode.
966
+ """
967
+
968
+ self._resolve_pending_edges()
969
+
970
+ if cost not in ("distance", "time"):
971
+ raise ValueError(
972
+ f"Unknown cost mode '{cost}' (expected 'distance' or 'time')"
973
+ )
974
+
975
+ start_id: Optional[int] = self._mode_snap(coord=start, mode=mode)
976
+ end_id: Optional[int] = self._mode_snap(coord=end, mode=mode)
977
+
978
+ if start_id is None:
979
+ raise ValueError("No network node near the start coordinate")
980
+
981
+ if end_id is None:
982
+ raise ValueError("No network node near the end coordinate")
983
+
984
+ planet: Any = get_current_planet()
985
+
986
+ def heuristic(node_id: int) -> float:
987
+ """
988
+ Estimates the remaining cost to the target.
989
+
990
+ Args:
991
+ node_id (int): The node local id.
992
+
993
+ Returns:
994
+ float: Meters for the distance mode, seconds for time
995
+ (geodesic distance over the fastest known speed).
996
+ """
997
+
998
+ distance: float = self._node_position(local_id=node_id).distance(
999
+ other=end, planet=planet
1000
+ )
1001
+
1002
+ if cost == "time":
1003
+ if self._max_speed_mps <= 0.0:
1004
+ return 0.0
1005
+
1006
+ return distance / self._max_speed_mps
1007
+
1008
+ return distance
1009
+
1010
+ counter: int = 0
1011
+
1012
+ # Heap entries: (f, tie-breaker, g, node) so stale pushes are
1013
+ # detected against the best known g score
1014
+ frontier: list[tuple[float, int, float, int]] = [
1015
+ (heuristic(node_id=start_id), counter, 0.0, start_id)
1016
+ ]
1017
+ g_score: dict[int, float] = {start_id: 0.0}
1018
+ came_from: dict[int, tuple[int, int]] = {}
1019
+
1020
+ while frontier:
1021
+ _f, _tie, popped_g, current = heapq.heappop(frontier)
1022
+
1023
+ if popped_g > g_score.get(current, math.inf):
1024
+ continue
1025
+
1026
+ if current == end_id:
1027
+ return self._reconstruct(
1028
+ came_from=came_from, end_id=end_id, cost=cost, mode=mode
1029
+ )
1030
+
1031
+ for edge_id, neighbor in self.adjacency.get(current, []):
1032
+ edge_cost: Optional[float] = self._edge_cost(
1033
+ edge_local_id=edge_id,
1034
+ transport_mode=mode,
1035
+ cost_mode=cost,
1036
+ )
1037
+
1038
+ if edge_cost is None:
1039
+ continue
1040
+
1041
+ new_g: float = popped_g + edge_cost
1042
+
1043
+ if new_g < g_score.get(neighbor, math.inf):
1044
+ g_score[neighbor] = new_g
1045
+ came_from[neighbor] = (current, edge_id)
1046
+
1047
+ counter += 1
1048
+
1049
+ heapq.heappush(
1050
+ frontier,
1051
+ (
1052
+ new_g + heuristic(node_id=neighbor),
1053
+ counter,
1054
+ new_g,
1055
+ neighbor,
1056
+ ),
1057
+ )
1058
+
1059
+ raise ValueError(
1060
+ f"No path exists for mode '{mode}' between the given "
1061
+ f"coordinates"
1062
+ )
1063
+
1064
+ def _reconstruct(
1065
+ self,
1066
+ came_from: dict[int, tuple[int, int]],
1067
+ end_id: int,
1068
+ cost: str,
1069
+ mode: str,
1070
+ ) -> Path:
1071
+ """
1072
+ Rebuilds the Path from the A* came-from map.
1073
+
1074
+ Args:
1075
+ came_from (dict[int, tuple[int, int]]): Node local id to
1076
+ (previous node, via edge) pairs.
1077
+ end_id (int): The goal node local id.
1078
+ mode (str): The routing mode.
1079
+
1080
+ Returns:
1081
+ Path: The routing result.
1082
+ """
1083
+
1084
+ node_ids: list[int] = [end_id]
1085
+ edge_ids: list[int] = []
1086
+
1087
+ cursor: int = end_id
1088
+
1089
+ while cursor in came_from:
1090
+ previous, via = came_from[cursor]
1091
+
1092
+ edge_ids.append(via)
1093
+ node_ids.append(previous)
1094
+
1095
+ cursor = previous
1096
+
1097
+ node_ids.reverse()
1098
+ edge_ids.reverse()
1099
+
1100
+ return Path(
1101
+ nodes=[
1102
+ self.nodes_table.get(local_id=node_id) for node_id in node_ids
1103
+ ],
1104
+ edges=[
1105
+ self._edge_instance(local_id=edge_id) for edge_id in edge_ids
1106
+ ],
1107
+ cost=0.0 if len(node_ids) == 1 else self._path_cost(
1108
+ edge_ids=edge_ids, cost=cost, mode=mode
1109
+ ),
1110
+ mode=mode,
1111
+ )
1112
+
1113
+ def _path_cost(
1114
+ self,
1115
+ edge_ids: list[int],
1116
+ cost: str,
1117
+ mode: str,
1118
+ ) -> float:
1119
+ """
1120
+ Sums the cached edge costs along a path.
1121
+
1122
+ Args:
1123
+ edge_ids (list[int]): The traversed edge local ids.
1124
+ mode (str): The routing mode.
1125
+
1126
+ Returns:
1127
+ float: The total cost.
1128
+ """
1129
+
1130
+ total: float = 0.0
1131
+
1132
+ for edge_id in edge_ids:
1133
+ edge_cost: Optional[float] = self._edge_cost(
1134
+ edge_local_id=edge_id,
1135
+ transport_mode=mode,
1136
+ cost_mode=cost,
1137
+ )
1138
+
1139
+ if edge_cost is not None:
1140
+ total += edge_cost
1141
+
1142
+ return total