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.
- nadata/__init__.py +15 -0
- nadata/api.py +894 -0
- nadata/cli.py +445 -0
- nadata/console.py +486 -0
- nadata/core/__init__.py +0 -0
- nadata/core/database/__init__.py +38 -0
- nadata/core/database/builtins.py +87 -0
- nadata/core/database/database.py +958 -0
- nadata/core/database/dumper.py +285 -0
- nadata/core/database/entity_id.py +142 -0
- nadata/core/database/instance.py +706 -0
- nadata/core/database/network_index.py +1142 -0
- nadata/core/database/page_manager.py +175 -0
- nadata/core/database/persistence/__init__.py +28 -0
- nadata/core/database/persistence/binary_cells.py +1141 -0
- nadata/core/database/persistence/compaction.py +97 -0
- nadata/core/database/persistence/dumper.py +798 -0
- nadata/core/database/persistence/lazy_instances.py +1537 -0
- nadata/core/database/persistence/loader.py +424 -0
- nadata/core/database/persistence/record_store.py +228 -0
- nadata/core/database/persistence/serialization.py +605 -0
- nadata/core/database/query_engine.py +222 -0
- nadata/core/database/queryset.py +508 -0
- nadata/core/database/reconciliation.py +287 -0
- nadata/core/database/slotted_page.py +190 -0
- nadata/core/database/spatial_index.py +1080 -0
- nadata/core/expressions/__init__.py +44 -0
- nadata/core/expressions/autograd.py +2909 -0
- nadata/core/expressions/builtins.py +4673 -0
- nadata/core/expressions/expression.py +2658 -0
- nadata/core/expressions/io_builtins.py +972 -0
- nadata/core/expressions/scope.py +336 -0
- nadata/core/expressions/statement.py +953 -0
- nadata/core/struct/__init__.py +14 -0
- nadata/core/struct/struct.py +218 -0
- nadata/core/struct/struct_attribute.py +100 -0
- nadata/core/struct/struct_method.py +324 -0
- nadata/core/types/__init__.py +33 -0
- nadata/core/types/bits/__init__.py +0 -0
- nadata/core/types/bits/bits.py +686 -0
- nadata/core/types/home_types/__init__.py +19 -0
- nadata/core/types/home_types/color.py +102 -0
- nadata/core/types/home_types/coordinate.py +1008 -0
- nadata/core/types/home_types/datetime.py +362 -0
- nadata/core/types/home_types/geogrid.py +92 -0
- nadata/core/types/home_types/geometry.py +1223 -0
- nadata/core/types/home_types/graph.py +0 -0
- nadata/core/types/home_types/planet.py +359 -0
- nadata/core/types/home_types/sea_level.py +98 -0
- nadata/core/types/home_types/vec.py +0 -0
- nadata/core/types/types.py +1345 -0
- nadata/core/values/__init__.py +14 -0
- nadata/core/values/values.py +1568 -0
- nadata/parser/__init__.py +0 -0
- nadata/parser/expressions/__init__.py +6 -0
- nadata/parser/expressions/dependencies.py +318 -0
- nadata/parser/expressions/expression_parser.py +993 -0
- nadata/parser/expressions/splitter.py +98 -0
- nadata/parser/expressions/statement_parser.py +1425 -0
- nadata/parser/expressions/tokenizer.py +402 -0
- nadata/parser/expressions/type_parser.py +187 -0
- nadata/parser/nadata_loader/__init__.py +8 -0
- nadata/parser/nadata_loader/loader.py +951 -0
- nadata/parser/nastruct_parser/__init__.py +0 -0
- nadata/parser/nastruct_parser/declaration.py +105 -0
- nadata/parser/nastruct_parser/imports.py +164 -0
- nadata/parser/nastruct_parser/nastruct_parser.py +209 -0
- nadata/parser/nastruct_parser/parse_attributes.py +149 -0
- nadata/parser/nastruct_parser/parse_callables.py +436 -0
- nadata/parser/nastruct_parser/parse_expression.py +59 -0
- nadata/parser/nastruct_parser/parse_structs.py +206 -0
- nadata/parser/nastruct_parser/parser_state.py +84 -0
- nadata/parser/nastruct_parser/resolver.py +1412 -0
- nadata/parser/nastruct_parser/struct_body_state.py +50 -0
- nadata/parser/nastruct_parser/tmp_structs.py +156 -0
- nadata/parser/utils/__init__.py +20 -0
- nadata/parser/utils/brackets.py +252 -0
- nadata/parser/utils/comments_remove.py +149 -0
- nadata/parser/utils/identifiers.py +121 -0
- nadata/session.py +868 -0
- nadata-0.1.0.dist-info/METADATA +191 -0
- nadata-0.1.0.dist-info/RECORD +86 -0
- nadata-0.1.0.dist-info/WHEEL +5 -0
- nadata-0.1.0.dist-info/entry_points.txt +2 -0
- nadata-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|