@@ -39,6 +39,42 @@ def __init__(self, tree: Any, index: NDArray, p: float, prepare: Any):
3939 self .prepare = prepare # point -> point for the tree, or None if out of range
4040
4141
42+ class _PointCache :
43+ """
44+ The coordinates of a graph's embedded vertices, as rows of a matrix (private)
45+
46+ Row ``i`` is the coordinate of ``vertices[i]``, in the order of the graph.
47+ The buffer has spare rows so that adding a vertex is an append, which is
48+ what lets :meth:`Graph.closest` stay fast while a planner interleaves
49+ ``add_vertex`` with queries.
50+ """
51+
52+ def __init__ (self , points : NDArray , vertices : list [Vertex ]):
53+ n , dim = points .shape
54+ self .buf = np .empty ((n + n // 2 + 16 , dim ))
55+ self .buf [:n ] = points
56+ self .n = n
57+ self .vertices = vertices
58+
59+ @property
60+ def dim (self ) -> int :
61+ return self .buf .shape [1 ]
62+
63+ @property
64+ def points (self ) -> NDArray :
65+ """The ``n`` x dim matrix of coordinates (a view, not a copy)"""
66+ return self .buf [: self .n ]
67+
68+ def append (self , vertex : Vertex , coord : NDArray ) -> None :
69+ if self .n == self .buf .shape [0 ]:
70+ grown = np .empty ((2 * self .n , self .dim ))
71+ grown [: self .n ] = self .buf
72+ self .buf = grown
73+ self .buf [self .n ] = coord
74+ self .n += 1
75+ self .vertices .append (vertex )
76+
77+
4278class SE2Metric :
4379 r"""
4480 Distance between planar poses, a weighted mixed norm
@@ -294,6 +330,10 @@ def __init__(
294330 # vertices have changed since it was built
295331 self ._kdtree : _KDTree | None = None
296332 self ._spatialchange = False
333+ # the coordinates of the embedded vertices as a matrix, built when
334+ # first needed and kept up to date by add_vertex; None if not built or
335+ # invalidated by a removal or an assignment to a ``coord``
336+ self ._pts : _PointCache | None = None
297337 if metric is None :
298338 self .metric = "L2"
299339 else :
@@ -1003,7 +1043,7 @@ def subgraph(self, vertices: Iterable[Vertex | str] | NDArray) -> Graph:
10031043 # pickling and copying
10041044
10051045 #: the graph's own containers, which are saved as a flat description
1006- _STRUCTURE = ("_vertexlist" , "_vertexdict" , "_edges" , "_kdtree" )
1046+ _STRUCTURE = ("_vertexlist" , "_vertexdict" , "_edges" , "_kdtree" , "_pts" )
10071047
10081048 #: the references a vertex holds, which are rebuilt rather than saved
10091049 _VERTEX_LINKS = ("_edgelist" , "_inlist" , "_graph" )
@@ -1066,6 +1106,7 @@ def __setstate__(self, state: dict[str, Any]) -> None:
10661106 state [key ] = _METRICS [value [1 ]]
10671107 self .__dict__ .update (state )
10681108 self ._kdtree = None
1109+ self ._pts = None
10691110 self ._spatialchange = False
10701111
10711112 self ._vertexlist = []
@@ -1209,6 +1250,16 @@ def add_vertex(
12091250 vertex .__dict__ ["_graph" ] = self
12101251 self ._connectivitychange = True
12111252 self ._spatialchange = True
1253+ pts = self ._pts
1254+ if pts is not None and vertex .coord is not None :
1255+ # keep the matrix of coordinates up to date; one that does not
1256+ # fit it (another length, not finite) is left for _points to
1257+ # judge, by dropping the matrix
1258+ coord = vertex .coord
1259+ if coord .ndim == 1 and coord .shape [0 ] == pts .dim and np .isfinite (coord ).all ():
1260+ pts .append (vertex , coord )
1261+ else :
1262+ self ._pts = None
12121263 return vertex
12131264
12141265 @classmethod
@@ -1494,6 +1545,7 @@ def remove_vertex(self, vertex: Vertex) -> None:
14941545 vertex ._label = None # and so not in a component
14951546 self ._connectivitychange = True
14961547 self ._spatialchange = True
1548+ self ._pts = None
14971549
14981550 def remove (self , x : Edge | Vertex ) -> None :
14991551 """
@@ -1986,12 +2038,14 @@ def build_kdtree(self) -> None:
19862038 3 elements, or no vertex has a coordinate
19872039 :raises ImportError: scipy is not installed
19882040
1989- Finding the nearest vertex to a point is a pass over all the vertices,
1990- which is the cost of a query on a large graph that is queried often. A
1991- k-d tree answers in a time that grows more slowly, at the cost of
1992- building it once. :meth:`closest` and :meth:`near` use the index if it
1993- has been built, and fall back to the pass over the vertices if it has
1994- not or if the graph has changed.
2041+ Without an index, finding the nearest vertex to a point measures the
2042+ distance to every vertex, which is the cost of a query on a large graph
2043+ that is queried often. A k-d tree answers in a time that grows more
2044+ slowly, at the cost of building it once, which pays for a graph that is
2045+ built and then queried. A graph that is queried while it is growing
2046+ does better without it, since adding a vertex discards the index.
2047+ :meth:`closest` and :meth:`near` use the index if it has been built,
2048+ and measure every vertex if it has not or if the graph has changed.
19952049
19962050 The index is for the built-in metrics ``"L1"``, ``"L2"`` and
19972051 ``"SE2"`` (with any weight, see :class:`SE2Metric`), and results
@@ -2117,6 +2171,36 @@ def _metric_columns(self, diff: NDArray) -> NDArray | None:
21172171 )
21182172 return None
21192173
2174+ def _points (self ) -> _PointCache | None :
2175+ """
2176+ The coordinates of the embedded vertices as a matrix, or None (private)
2177+
2178+ Built on first use and kept by :meth:`add_vertex`. It is None, so
2179+ that the caller makes its pass over the vertices, if no vertex has a
2180+ coordinate or the coordinates are not all vectors of the same length,
2181+ which that pass reports. A vertex whose coordinate is not finite is
2182+ left out, as it is never the closest.
2183+ """
2184+ if self ._pts is not None :
2185+ return self ._pts
2186+ vertices = [v for v in self ._vertexlist if v .coord is not None ]
2187+ if not vertices :
2188+ return None
2189+ try :
2190+ points = np .array ([v .coord for v in vertices ], dtype = float )
2191+ except (ValueError , TypeError ):
2192+ return None # not all the same length, or not numbers
2193+ if points .ndim != 2 or points .shape [1 ] == 0 :
2194+ return None
2195+ finite = np .isfinite (points ).all (axis = 1 )
2196+ if not finite .all ():
2197+ points = points [finite ]
2198+ vertices = [v for v , ok in zip (vertices , finite ) if ok ]
2199+ if not vertices :
2200+ return None
2201+ self ._pts = _PointCache (points , vertices )
2202+ return self ._pts
2203+
21202204 def closest (self , coord : ArrayLike ) -> tuple [Vertex | None , float ]:
21212205 """
21222206 Vertex closest to point
@@ -2131,8 +2215,17 @@ def closest(self, coord: ArrayLike) -> tuple[Vertex | None, float]:
21312215 according to the graph's metric. Vertices without a coordinate
21322216 (``coord`` is None) are skipped -- they have no position to compare.
21332217
2134- This is a pass over the vertices, unless a spatial index has been
2135- built with :meth:`build_kdtree` and is still valid.
2218+ For the built-in metrics the distance to every vertex is found in one
2219+ operation on a matrix of the coordinates that the graph keeps, which
2220+ adding a vertex only appends to, so this stays fast when vertices are
2221+ added between queries, as a planner does. A spatial index built with
2222+ :meth:`build_kdtree`, if it is still valid, is faster again for a graph
2223+ that is not changing. A metric of your own is applied to each vertex
2224+ in turn.
2225+
2226+ .. note:: The matrix is a copy, so a coordinate changed in place,
2227+ ``vertex.coord[0] = x``, is not seen. Assigning
2228+ ``vertex.coord = x`` (or ``+=``) is.
21362229
21372230 .. runblock:: pycon
21382231
@@ -2153,6 +2246,15 @@ def closest(self, coord: ArrayLike) -> tuple[Vertex | None, float]:
21532246 if np .isfinite (d ):
21542247 return self ._vertexlist [int (index .index [i ])], float (d )
21552248
2249+ pts = self ._points ()
2250+ if pts is not None :
2251+ q = np .asarray (coord , dtype = float )
2252+ if q .shape == (pts .dim ,) and np .isfinite (q ).all ():
2253+ d = self ._metric_columns ((pts .points - q ).T )
2254+ if d is not None : # a built-in metric, applied to all at once
2255+ i = int (np .argmin (d ))
2256+ return pts .vertices [i ], float (d [i ])
2257+
21562258 min_dist = np .inf
21572259 min_which : Vertex | None = None
21582260
@@ -2234,9 +2336,9 @@ def near(self, coord: ArrayLike, radius: float) -> list[tuple[Vertex, float]]:
22342336 vertices at equal distance are in the order of the graph.
22352337
22362338 For the built-in metrics the distance to every vertex is found in one
2237- operation on :meth:`coordinates` , or by the spatial index if
2238- :meth:`build_kdtree` was called; a metric of your own is applied
2239- to each vertex in turn.
2339+ operation on the matrix of coordinates that :meth:`closest` uses , or
2340+ by the spatial index if :meth:`build_kdtree` was called; a metric of
2341+ your own is applied to each vertex in turn.
22402342
22412343 .. runblock:: pycon
22422344
@@ -2261,14 +2363,14 @@ def near(self, coord: ArrayLike, radius: float) -> list[tuple[Vertex, float]]:
22612363 candidates = [self ._vertexlist [int (index .index [i ])] for i in sorted (hits )]
22622364
22632365 if candidates is None :
2264- C = self .coordinates ()
2366+ pts = self ._points ()
22652367 point = np .asarray (coord , dtype = float )
2266- if C . shape [ 0 ] != 0 and point .shape == (C . shape [ 0 ] ,):
2267- d = self ._metric_columns (C - point [:, None ] )
2368+ if pts is not None and point .shape == (pts . dim ,):
2369+ d = self ._metric_columns (( pts . points - point ). T )
22682370 if d is not None :
22692371 hit = np .flatnonzero (d <= radius )
22702372 hit = hit [np .argsort (d [hit ], kind = "stable" )]
2271- return [(self . _vertexlist [i ], float (d [i ])) for i in hit ]
2373+ return [(pts . vertices [i ], float (d [i ])) for i in hit ]
22722374 candidates = [v for v in self ._vertexlist if v .coord is not None ]
22732375
22742376 found = []
@@ -4531,6 +4633,7 @@ def __setattr__(self, name: str, value: Any) -> None:
45314633 graph = self .__dict__ .get ("_graph" )
45324634 if graph is not None :
45334635 graph ._spatialchange = True
4636+ graph ._pts = None
45344637 object .__setattr__ (self , name , value )
45354638
45364639 def __reduce__ (self ) -> Any :
0 commit comments