Repository navigation
Expand file tree
/
Copy pathPGraph.py
More file actions
5396 lines (4446 loc) · 202 KB
/
Copy pathPGraph.py
File metadata and controls
5396 lines (4446 loc) · 202 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from __future__ import annotations
import math
import os
import sys
import warnings
import numpy as np
import copy
import heapq
import importlib
from collections import deque
from collections.abc import Iterable, Iterator
import tempfile
import webbrowser
from typing import IO, Any, Callable, ClassVar, overload
from numpy.typing import ArrayLike, NDArray
# matplotlib and spatialmath are imported by the methods that plot, and by
# pgraph._plot, so that importing pgraph does not load them.
def _L1(v: NDArray) -> float:
"""L1 (Manhattan) norm, the built-in ``"L1"`` metric"""
return float(np.linalg.norm(v, 1))
def _L2(v: NDArray) -> float:
"""L2 (Euclidean) norm, the built-in ``"L2"`` metric"""
return float(np.linalg.norm(v))
class _KDTree:
"""A spatial index of a graph's vertices and how to query it (private)"""
def __init__(self, tree: Any, index: NDArray, p: float, prepare: Any):
self.tree = tree # scipy.spatial.cKDTree
self.index = index # position in the graph's vertex list of each point
self.p = p # the Minkowski norm that the tree uses
self.prepare = prepare # point -> point for the tree, or None if out of range
class SE2Metric:
r"""
Distance between planar poses, a weighted mixed norm
:param weight: length that one radian of turn counts for, defaults to 1
:raises ValueError: ``weight`` is negative or not finite
The metric of a vector :math:`(x, y, \theta)`, the difference between two
poses, is
.. math::
d = \sqrt{x^2 + y^2 + (w \bar{\theta})^2}
where :math:`\bar{\theta}` is :math:`\theta` wrapped to the interval
:math:`[-\pi, \pi)` and :math:`w` is the ``weight``.
Distance and angle have different units, so their relative importance is
a choice. The weight is the conversion: it is how much length a turn of one
radian is worth, so a turn of :math:`\theta` costs as much as moving
:math:`w \theta`. A weight of 1 treats a radian as a metre, which is what
the string ``"SE2"`` means. A characteristic length of the vehicle, such as
its turning radius, is a natural choice. With a weight of 0 the
orientation is ignored and this is the distance in the plane.
An instance is a metric for a graph, and can be used as its heuristic too::
g = UGraph(dim=3, metric=SE2Metric(weight=0.5))
.. runblock:: pycon
>>> from pgraph import SE2Metric
>>> import numpy as np
>>> v = np.r_[3, 4, np.pi / 2] # 3 east, 4 north, a quarter turn
>>> SE2Metric()(v)
>>> SE2Metric(weight=0.5)(v)
>>> SE2Metric(weight=0)(v)
A metric with a weight is still a metric, so it is a consistent heuristic
for A* when the edge costs are computed with it. If edge costs come from
somewhere else, use a weight low enough, or zero, for the heuristic never to
exceed the cost to go.
:seealso: :meth:`Graph.metric` :meth:`Graph.heuristic`
"""
def __init__(self, weight: float = 1.0):
weight = float(weight)
if not math.isfinite(weight) or weight < 0:
raise ValueError(f"weight must be finite and not negative, got {weight!r}")
self.weight = weight
def __call__(self, v: NDArray) -> float:
if len(v) != 3:
raise ValueError(
f"SE2 metric requires a 3-element (x, y, theta) vector, got length {len(v)}"
)
# wrap the angle to the range [-pi, pi), without changing the argument
theta = (float(v[2]) + math.pi) % (2 * math.pi) - math.pi
return math.sqrt(float(v[0]) ** 2 + float(v[1]) ** 2 + (self.weight * theta) ** 2)
def __repr__(self) -> str:
return f"SE2Metric(weight={self.weight:g})"
def __eq__(self, other: object) -> bool:
return isinstance(other, SE2Metric) and other.weight == self.weight
def __hash__(self) -> int:
return hash(("SE2Metric", self.weight))
#: the built-in metrics by name. They are module level, and pickled by name,
#: so that a graph using one can be pickled.
_METRICS: dict[str, Callable[[NDArray], float]] = {
"L1": _L1,
"L2": _L2,
"SE2": SE2Metric(),
}
#: curvature, as the ``rad`` of an ``arc3`` connection style, between successive
#: edges that join the same pair of vertices
_ARC_STEP = 0.35
#: radius in points of the first loop at a vertex, and the growth for each next
_LOOP_RADIUS = 20.0
_LOOP_GROWTH = 6.0
#: angle in degrees at which the first loop leaves a vertex, and the turn for each next
_LOOP_ANGLE = 60.0
_LOOP_TURN = 75.0
def _renamed(
func: str, old_name: str, old: Any, new_name: str, new: Any, stacklevel: int = 3
) -> Any:
"""
Resolve an option that has been renamed (private function)
Returns ``new`` if the old name was not used. Using the old name is still
accepted, with a :exc:`DeprecationWarning` that names its replacement.
Using both is a :exc:`TypeError`, since it is not clear which is meant.
"""
if old is None:
return new
if new is not None:
raise TypeError(
f"{func}() got both {old_name} and {new_name}; "
f"{old_name} is deprecated, use {new_name} only"
)
warnings.warn(
f"{func}(): {old_name} is deprecated, use {new_name}",
DeprecationWarning,
stacklevel=stacklevel,
)
return old
class Graph:
"""
Base class for all graphs
A graph is a set of :class:`Vertex` objects joined by :class:`Edge`
objects, which are directed or not according to the kind of graph:
:class:`UGraph` is undirected and :class:`DGraph` is directed. This
class holds everything they share, and ``Graph(directed=flag)`` makes one
or the other. The constructor arguments are described below.
:seealso: :class:`UGraph` :class:`DGraph` :class:`Vertex` :class:`Edge`
"""
#: whether the edges of this kind of graph are directed, set by
#: :class:`UGraph` and :class:`DGraph`
_directed: ClassVar[bool]
def __new__(cls, *args: Any, directed: bool | None = None, **kwargs: Any) -> Graph:
# Graph(directed=True) is a DGraph and Graph() a UGraph; the classes
# underneath stay honest, so isinstance and the hooks work as usual
if cls is Graph:
cls = DGraph if directed else UGraph
elif not hasattr(cls, "_directed"):
raise TypeError(
f"{cls.__name__} does not say whether its edges are directed; "
"subclass UGraph or DGraph"
)
return super().__new__(cls)
def __init__(
self,
*,
metric: Callable[[NDArray], float] | str | None = None,
heuristic: Callable[[NDArray], float] | str | None = None,
verbose: bool = False,
dim: int | None = None,
vertex_class: type[Vertex] | None = None,
edge_class: type[Edge] | None = None,
directed: bool | None = None,
):
"""
Graph, undirected or directed
:param metric: distance metric, defaults to "L2"
:type metric: callable or str, optional
:param heuristic: heuristic distance metric for A*, defaults to the
same as ``metric``
:type heuristic: callable or str, optional
:param verbose: print diagnostic information as vertices/edges are
added, defaults to False
:param dim: required length of every vertex's ``coord``, defaults to
None (unconstrained -- vertices may have coordinates of any
length, or none at all)
:type dim: int, optional
:param vertex_class: class of the vertices this graph creates, defaults
to :class:`Vertex`
:type vertex_class: subclass of :class:`Vertex`, optional
:param edge_class: class of the edges this graph creates, defaults to
:class:`Edge`
:type edge_class: subclass of ``Edge``, optional
:raises ValueError: ``dim`` is given but is not a positive integer
:param directed: whether the edges are directed, defaults to False
for ``Graph`` and is fixed by the class for :class:`UGraph` and
:class:`DGraph`
:type directed: bool, optional
:raises ValueError: ``directed`` contradicts the class, for example
``UGraph(directed=True)``
:raises TypeError: ``vertex_class`` is not a subclass of
:class:`Vertex`, or ``edge_class`` is not a subclass of ``Edge``
``Graph(directed=True)`` is a :class:`DGraph` and ``Graph()`` a
:class:`UGraph`, so the kind can be chosen by a variable::
g = Graph(directed=is_directed)
:class:`UGraph` and :class:`DGraph` remain, and are what
``isinstance`` and the documentation refer to. Everything else, the
vertices included, is the same for both kinds, so there is one vertex
class, :class:`Vertex`. A vertex has no direction of its own; the
graph it belongs to decides how an edge is recorded at its ends.
All arguments are keyword-only, so ``UGraph(dim=6)`` is correct and
``UGraph(6)`` raises :exc:`TypeError`.
.. versionchanged:: 1.0
Arguments are keyword-only. In 0.6.5 the first positional
argument was accepted and ignored, so ``UGraph(6)`` silently did
nothing.
``vertex_class`` and ``edge_class`` let user subclasses be built by the
graph, so they never need to be constructed by hand::
g = UGraph(vertex_class=MyVertex, edge_class=MyEdge)
v = g.add_vertex([0, 0]) # a MyVertex
e = g.add_edge(v, w) # a MyEdge
The classes are called as ``vertex_class(coord, name=name)`` and
``edge_class(v1, v2, cost=cost, data=data)``, so a subclass that
overrides ``__init__`` must accept and pass on those arguments.
A graph given a ``vertex_class`` only accepts vertices of that class
in :meth:`add_vertex`, and one given an ``edge_class`` only accepts
edges of that class.
:seealso: :class:`UGraph` :class:`DGraph` :meth:`add_vertex`
:meth:`add_edge` :attr:`vertex_class` :attr:`edge_class`
"""
if dim is not None and dim <= 0:
raise ValueError(f"dim must be a positive integer, got {dim!r}")
if directed is not None and bool(directed) != type(self)._directed:
raise ValueError(
f"{type(self).__name__} is "
f"{'directed' if type(self)._directed else 'undirected'}, "
f"but directed={directed!r} was given"
)
if vertex_class is None:
vertex_class = Vertex
elif not (isinstance(vertex_class, type) and issubclass(vertex_class, Vertex)):
raise TypeError(f"vertex_class must be a subclass of Vertex, got {vertex_class!r}")
if edge_class is None:
edge_class = Edge
elif not (isinstance(edge_class, type) and issubclass(edge_class, Edge)):
raise TypeError(f"edge_class must be a subclass of Edge, got {edge_class!r}")
self._vertex_class: type[Vertex] = vertex_class
self._edge_class: type[Edge] = edge_class
# we use a list and a dict, the list respects the order of adding
self._vertexlist: list[Vertex] = []
self._vertexdict: dict[str, Vertex] = {}
# an insertion-ordered set: the keys are the edges, hashed by identity
# (Edge defines no __eq__), the values are unused. This gives O(1)
# removal and a defined order.
self._edges: dict[Edge, None] = {}
self._verbose = verbose
self._dim = dim
self._ncomponents = 0
self._connectivitychange = False
# the optional spatial index of build_kdtree(), and whether the
# vertices have changed since it was built
self._kdtree: _KDTree | None = None
self._spatialchange = False
if metric is None:
self.metric = "L2"
else:
self.metric = metric
if heuristic is None:
self.heuristic = self.metric
else:
self.heuristic = heuristic
def __str__(self) -> str:
"""
Human-readable summary of the graph
:return: one-line summary of vertex/edge/component counts
:rtype: str
Counts of 1,000 or more have thousands separators.
.. runblock:: pycon
>>> from pgraph import UGraph
>>> g = UGraph()
>>> v1 = g.add_vertex(coord=[0,0], name='v1')
>>> v2 = g.add_vertex(coord=[1,1], name='v2')
>>> v3 = g.add_vertex(coord=[2,2], name='v3')
>>> g.add_edge(v1, v2)
>>> g.add_edge(v2, v3)
>>> str(g)
:seealso: :meth:`__repr__` :meth:`show`
"""
s = f"{self.__class__.__name__}: {self.n:,} {'vertex' if self.n==1 else 'vertices'}, {self.ne:,} edge{'s'[:self.ne^1]}, {self.nc:,} component{'s'[:self.nc^1]}"
return s
def __repr__(self) -> str:
"""
Representation of the graph, the same one-line summary as :meth:`__str__`
:return: one-line summary of vertex/edge/component counts
:rtype: str
The representation of a graph does not list its vertices, since that
would be as long as the graph, and a ``repr`` is what appears in a
notebook cell, a debugger, a log or an error message. Use
:meth:`show` to list them.
.. runblock:: pycon
>>> from pgraph import UGraph
>>> g = UGraph()
>>> v1 = g.add_vertex(coord=[0,0], name='v1')
>>> v2 = g.add_vertex(coord=[1,1], name='v2')
>>> g.add_edge(v1, v2)
>>> repr(g)
.. versionchanged:: 1.1
Was one line per vertex, with its name, coordinate and component;
that is now printed by :meth:`show`.
:seealso: :meth:`__str__` :meth:`show`
"""
return str(self)
@classmethod
def Dict(cls, d: dict, reverse: bool = False, **kwargs: Any) -> Graph:
"""
Create graph from parent/child dictionary
:param d: dictionary that maps from ``Vertex`` subclass to ``Vertex`` subclass
:type d: dict
:param reverse: reverse link direction, defaults to False
:param kwargs: optional arguments passed to the graph constructor, for
example ``vertex_class`` or ``edge_class``
:raises TypeError: ``vertex_class`` or ``edge_class`` is not a
suitable class, see :class:`~pgraph.PGraph.Graph`
:return: graph
:rtype: UGraph or DGraph
Behaves like a constructor for a ``DGraph`` or ``UGraph`` from a
dictionary that maps vertices to parents. From this information it
can create a graph that is a tree or forest.
By default parent vertices are linked their children. If ``reverse`` is
True then children are linked to their parents.
.. runblock:: pycon
>>> from pgraph import UGraph
>>> d = {'b': 'a', 'c': 'a', 'd': 'b'}
>>> g = UGraph.Dict(d)
>>> print(g)
:seealso: :meth:`Adjacency`
"""
g = cls(**kwargs)
for vertex, parent in d.items():
if isinstance(vertex, str):
vertex_name = vertex
else:
vertex_name = vertex.name
if vertex_name in g:
vertex = g[vertex_name]
else:
vertex = g.add_vertex(name=vertex_name)
if isinstance(parent, str):
parent_name = parent
else:
parent_name = parent.name
if parent_name in g:
parent = g[parent_name]
else:
parent = g.add_vertex(name=parent_name)
if reverse:
g.add_edge(vertex, parent)
else:
g.add_edge(parent, vertex)
return g
@classmethod
def Adjacency(
cls,
A: NDArray,
coords: NDArray | None = None,
names: list[str] | None = None,
**kwargs: Any,
) -> Graph:
"""
Create graph from adjacency matrix
:param A: adjacency matrix
:type A: ndarray(N,N)
:param coords: coordinates of vertices, defaults to None
:type coords: ndarray(N,M), optional
:param names: names of vertices, defaults to None
:type names: list(N) of str, optional
:param kwargs: optional arguments passed to the graph constructor, for
example ``vertex_class`` or ``edge_class``
:raises ValueError: ``A`` is not square, or the length of ``names`` or
the number of rows of ``coords`` does not match ``A``
:raises TypeError: ``vertex_class`` or ``edge_class`` is not a
suitable class, see :class:`~pgraph.PGraph.Graph`
:return: graph
:rtype: UGraph or DGraph
Behaves like a constructor for a ``DGraph`` or ``UGraph`` creating the graph from an adjacency matrix ``A``.
Here non-zero elements ``A[i,j]``
correspond to edges from vertex ``i`` to vertex ``j``.
A non-zero diagonal element ``A[i,i]`` is a self-loop on vertex ``i``,
for both kinds of graph.
.. warning:: For an undirected graph ``A`` should be symmetric but this
is not checked. Only the upper triangular part, including the
diagonal, is used. Elements are costs, not edge counts: a non-zero
element is a single edge with that element as its cost, so
parallel edges cannot be created this way.
.. runblock:: pycon
>>> from pgraph import UGraph
>>> import numpy as np
>>> A = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]])
>>> g = UGraph.Adjacency(A)
>>> print(g)
:seealso: :meth:`Dict` :meth:`adjacency`
"""
if A.shape[0] != A.shape[1]:
raise ValueError("Adjacency matrix must be square")
if names is not None and len(names) != A.shape[0]:
raise ValueError("length of names must match dimension of adjacency matrix")
if coords is not None and coords.shape[0] != A.shape[0]:
raise ValueError("coords must have same number of rows as adjacency matrix")
g = cls(**kwargs)
name = None
coord = None
for i in range(A.shape[0]):
if names is not None:
name = names[i]
if coords is not None:
coord = coords[i, :]
g.add_vertex(name=name, coord=coord)
if not g.directed:
# undirected graph: upper triangle including the diagonal
for i in range(A.shape[0]):
for j in range(i, A.shape[1]):
if A[i, j] > 0:
g[i].connect(g[j], cost=A[i, j])
else:
# directed graph: whole matrix, a diagonal element is a self-loop
for i in range(A.shape[0]):
for j in range(A.shape[1]):
if A[i, j] > 0:
g[i].connect(g[j], cost=A[i, j])
return g
@property
def directed(self) -> bool:
"""
Test if the graph is directed
:return: True for a :class:`DGraph`, False for a :class:`UGraph`
:rtype: bool
An undirected edge is recorded at both of its vertices, and is
followed in either direction. A directed edge is recorded as leaving
its start vertex and entering its end vertex.
.. runblock:: pycon
>>> from pgraph import Graph
>>> Graph().directed
>>> Graph(directed=True).directed
:seealso: :class:`UGraph` :class:`DGraph`
"""
return self._directed
# the hooks: the one place where undirected and directed graphs differ in
# how an edge is held. A vertex has no direction of its own, so its
# connect() and the graph's remove_edge() ask the graph.
def _attach(self, edge: Edge) -> None:
"""
Record a new edge in the edge lists of its vertices (private method)
:param edge: the edge just created or supplied to
:meth:`Vertex.connect`, with ``edge.v1`` and ``edge.v2`` set and
already part of the graph
:raises NotImplementedError: not provided by this graph kind
This is the hook that :meth:`Vertex.connect` calls once the connection
has been validated and the edge registered with the graph, so it is
never called for a connection that is refused. :class:`UGraph`
records the edge at both ends and :class:`DGraph` as leaving its start
and entering its end. It is defined by the graph kind, not by
:meth:`Vertex.connect`.
"""
raise NotImplementedError(
f"{type(self).__name__} does not define how an edge is attached; "
"subclass UGraph or DGraph"
)
def _detach(self, edge: Edge) -> None:
"""
Remove an edge from the edge lists of its vertices (private method)
:param edge: the edge being removed, with ``edge.v1`` and ``edge.v2``
still set
:raises NotImplementedError: not provided by this graph kind
The twin of :meth:`_attach`: it undoes exactly what that recorded.
Called by :meth:`remove_edge` before the edge's endpoints are
cleared. An edge that is absent from a list is skipped rather than
treated as an error.
"""
raise NotImplementedError(
f"{type(self).__name__} does not define how an edge is detached; "
"subclass UGraph or DGraph"
)
def _incoming(self, vertex: Vertex) -> list[Edge]:
"""
The edges that end at a vertex (private method)
:param vertex: a vertex of this graph
:raises NotImplementedError: not provided by this graph kind
"""
raise NotImplementedError(
f"{type(self).__name__} does not define the incoming edges; "
"subclass UGraph or DGraph"
)
def _endpoints_match(self, edge: Edge, v1: Vertex, v2: Vertex) -> bool:
"""
Test if an edge already connects two vertices (private method)
A directed edge must run from ``v1`` to ``v2``; see
:meth:`UGraph._endpoints_match` for the undirected relaxation.
"""
return edge.v1 is v1 and edge.v2 is v2
@property
def vertex_class(self) -> type[Vertex]:
"""
Class of the vertices this graph creates
:return: vertex class
:rtype: subclass of ``Vertex``
This is read-only. It is set by the ``vertex_class`` argument of the
graph constructor, see :class:`Graph`, and is :class:`Vertex` if not
given.
.. runblock:: pycon
>>> from pgraph import UGraph, Vertex
>>> class MyVertex(Vertex):
... pass
>>> g = UGraph(vertex_class=MyVertex)
>>> print(g.vertex_class.__name__)
:seealso: :meth:`add_vertex` :attr:`edge_class`
"""
return self._vertex_class
@property
def edge_class(self) -> type[Edge]:
"""
Class of the edges this graph creates
:return: edge class
:rtype: subclass of ``Edge``
This is read-only. It is set by the ``edge_class`` argument of the
graph constructor, see :class:`Graph`, and is :class:`Edge` if not
given.
.. runblock:: pycon
>>> from pgraph import UGraph, Edge
>>> class MyEdge(Edge):
... pass
>>> g = UGraph(edge_class=MyEdge)
>>> print(g.edge_class.__name__)
:seealso: :meth:`add_edge` :attr:`vertex_class`
"""
return self._edge_class
def validate(self, embedded: bool = False) -> list[str]:
"""
Check that the graph's records agree with each other
:param embedded: also report a vertex that has no coordinate, defaults
to False
:return: a description of each inconsistency found, empty if the graph
is consistent
:rtype: list of str
A graph keeps the same facts in several places: its set of edges, each
vertex's list of outgoing edges (and, for a directed graph, of incoming
ones), each edge's two vertices, each vertex's pointer back to its
graph, and the table of vertex names. The library keeps them in step,
so a graph built and edited through the public methods is always
valid. This is a check for tests, for debugging, and for code that
reaches into the internals.
It does not change the graph, and takes time proportional to the
number of vertices plus edges. It reports every inconsistency, not
only the first.
The checks are:
- the vertex list holds only vertices, once each, and the edge set
holds only edges
- every edge in the graph has two vertices, both in this graph, and is
an instance of :attr:`edge_class`
- every vertex points back at this graph, is an instance of
:attr:`vertex_class`, is in the name table under its own name, and
has a coordinate of the required length if ``dim`` was given
- every coordinate is a finite one-dimensional numeric vector, and if
``dim`` was not given they all have the same length
- with ``embedded=True``, every vertex has a coordinate. A graph with
no coordinates is valid, but one that is half embedded rarely is
- every entry of the name table is a vertex of this graph and is
filed under its own name
- each edge is in the right vertex lists, the right number of times:
both ends for an undirected edge (twice at one vertex for a
self-loop), the source's outgoing and the target's incoming list for
a directed edge
- no vertex list holds an edge that is not in the graph
.. runblock:: pycon
>>> from pgraph import UGraph
>>> g = UGraph()
>>> v1 = g.add_vertex(name='v1')
>>> v2 = g.add_vertex(name='v2')
>>> g.add_edge(v1, v2)
>>> print(g.validate())
:seealso: :meth:`remove_edge` :meth:`remove_vertex`
"""
from collections import Counter
problems: list[str] = []
directed = self.directed
# ---- the containers themselves must hold the right kind of thing; a
# stray object is reported and set aside so the checks below can run
vertices: list[Vertex] = []
for entry in self._vertexlist:
if isinstance(entry, Vertex):
vertices.append(entry)
else:
problems.append(f"the vertex list holds {entry!r}, which is not a vertex")
graph_edges: list[Edge] = []
for member in self._edges:
if isinstance(member, Edge):
graph_edges.append(member)
else:
problems.append(f"the graph's edges hold {member!r}, which is not an edge")
# ---- vertices
members = {id(v) for v in vertices}
for vid, n in Counter(id(v) for v in vertices).items():
if n > 1:
v = next(x for x in vertices if id(x) == vid)
problems.append(f"vertex {v.name!r} is listed {n} times in the vertex list")
for key, v in self._vertexdict.items():
if not isinstance(v, Vertex) or id(v) not in members:
problems.append(
f"name table entry {key!r} refers to a vertex that is not in the graph"
)
elif v.name != key:
problems.append(
f"name table entry {key!r} refers to vertex {v.name!r}, "
"which has a different name"
)
length: int | None = None # of the coordinates, if dim was not given
for v in vertices:
if v._graph is not self:
problems.append(f"vertex {v.name!r} does not point back at this graph")
if v.name is None or self._vertexdict.get(v.name) is not v:
problems.append(
f"vertex {v.name!r} is not in the name table under its own name"
)
if not isinstance(v, self._vertex_class):
problems.append(
f"vertex {v.name!r} is a {type(v).__name__}, not a "
f"{self._vertex_class.__name__}"
)
if (
self._dim is not None
and v.coord is not None
and len(v.coord) != self._dim
):
problems.append(
f"vertex {v.name!r} has a coordinate of length "
f"{len(v.coord)} but the graph requires dim={self._dim}"
)
if v.coord is None:
if embedded:
problems.append(f"vertex {v.name!r} has no coordinate")
else:
try:
coord = np.asarray(v.coord, dtype=float)
except (TypeError, ValueError):
problems.append(f"vertex {v.name!r} has a coordinate that is not numeric")
else:
if coord.ndim != 1:
problems.append(
f"vertex {v.name!r} has a coordinate of shape "
f"{coord.shape}, not a vector"
)
elif not np.all(np.isfinite(coord)):
problems.append(
f"vertex {v.name!r} has a coordinate that is not finite"
)
elif self._dim is None:
if length is None:
length = len(coord)
elif len(coord) != length:
problems.append(
f"vertex {v.name!r} has a coordinate of length "
f"{len(coord)}, but others have {length}"
)
if not directed and v._inlist:
problems.append(
f"vertex {v.name!r} has {len(v._inlist)} incoming edges "
"recorded, but an undirected vertex does not use that list"
)
# ---- edges, and what each vertex list should then contain
expected_out: Counter[tuple[int, int]] = Counter()
expected_in: Counter[tuple[int, int]] = Counter()
edges: dict[int, Edge] = {}
in_graph = {id(e) for e in graph_edges}
for e in graph_edges:
edges[id(e)] = e
if e.v1 is None or e.v2 is None:
problems.append(f"edge {e!r} is in the graph but has no vertices")
continue
for end in (e.v1, e.v2):
if id(end) not in members:
problems.append(
f"edge {e!r} joins vertex {end.name!r}, which is not in this graph"
)
if not isinstance(e, self._edge_class):
problems.append(
f"edge {e!r} is a {type(e).__name__}, not a {self._edge_class.__name__}"
)
expected_out[(id(e.v1), id(e))] += 1
if directed:
expected_in[(id(e.v2), id(e))] += 1
else:
expected_out[(id(e.v2), id(e))] += 1
actual_out: Counter[tuple[int, int]] = Counter()
actual_in: Counter[tuple[int, int]] = Counter()
owner = {id(v): v for v in vertices}
for v in vertices:
for listname, held, counter in (
("outgoing" if directed else "edge", v._edgelist, actual_out),
("incoming", v._inlist, actual_in),
):
for e in held:
if not isinstance(e, Edge):
problems.append(
f"vertex {v.name!r} holds {e!r} in its {listname} list, "
"which is not an edge"
)
continue
counter[(id(v), id(e))] += 1
edges.setdefault(id(e), e)
def report(expected: Counter, actual: Counter, listname: str) -> None:
for key in expected.keys() | actual.keys():
vid, eid = key
want, have = expected[key], actual[key]
if want == have:
continue
v, e = owner.get(vid), edges[eid]
vname = v.name if v is not None else "?"
if eid not in in_graph:
problems.append(
f"vertex {vname!r} holds edge {e!r} in its {listname} "
"list, but that edge is not in the graph"
)
elif have < want:
problems.append(
f"edge {e!r} is missing from the {listname} list of "
f"vertex {vname!r} (found {have}, expected {want})"
)
else:
problems.append(
f"edge {e!r} appears {have} times in the {listname} list "
f"of vertex {vname!r}, expected {want}"
)
report(expected_out, actual_out, "outgoing" if directed else "edge")
if directed:
report(expected_in, actual_in, "incoming")
return problems
def copy(self) -> Graph:
"""
Deepcopy of graph
:return: deep copy
:rtype: Graph
The copy has its own vertices and edges, which keep their class, name,
coordinate, cost and any attributes the user has added, and in the
same order, so it can be modified without affecting the original.
A graph can also be pickled, and copied with :func:`copy.deepcopy`,
which this method calls. Both work for a graph of any size. A
vertex or an edge cannot be pickled or copied on its own, since it has
no meaning apart from its graph: a :exc:`TypeError` is raised, and the
graph should be copied instead.
.. note::
- A metric or heuristic given by name (``"L1"``, ``"L2"``,
``"SE2"``) is pickled by name, and an :class:`SE2Metric` with
its weight. A function you supply must be
importable at module level to be pickled; a ``lambda`` or a
function defined inside another function can be copied but not
pickled.
- Attributes that you add to vertices or edges must not refer to
other vertices or edges of the graph, since those cannot be
pickled on their own.
- A pickle is not a long-term storage format. Use :meth:`export`
for that.
.. versionchanged:: 1.0
Pickling a graph works, and copying one no longer fails with a
``RecursionError`` for a graph with a path of about a hundred
vertices.
Copies the graph, its vertices and edges, but not any external data that may be
attached to them. The copy is a new graph of the same kind, with the same
vertices and edges, but the vertices and edges are new objects.
.. runblock:: pycon
>>> from pgraph import UGraph
>>> g = UGraph()
>>> v1 = g.add_vertex(coord=[0,0], name='v1')
>>> g2 = g.copy()
>>> g2 is g
>>> g2[0] is g[0]
:seealso: :meth:`vertex_copy` :meth:`export`
"""
return copy.deepcopy(self)
def subgraph(self, vertices: Iterable[Vertex | str] | NDArray) -> Graph:
"""
Subgraph induced by a set of vertices
:param vertices: vertices of this graph, or their names, or a boolean
mask with one element per vertex
:type vertices: iterable of Vertex subclass or str, or ndarray(n) of bool
:raises ValueError: a vertex is not in this graph, or a mask is not
the length of the number of vertices
:raises KeyError: a name is not that of a vertex in this graph
:raises TypeError: an item is neither a vertex nor a string
:return: a new graph of the same kind
:rtype: Graph
The new graph has the given vertices and every edge of this graph that
has *both* its end vertices among them. An edge that has one end
outside is removed, so the edges that cross the border of a region
are dropped. Parallel edges and loops are kept.
Like :meth:`copy` the new graph is independent of this one: its
vertices and edges are new objects that keep their class, name,
coordinate, cost and any attributes that were added, and the graph
keeps its metric, heuristic and vertex and edge classes. They are in
the order of this graph, however ``vertices`` was ordered. Names are
kept, so the vertex ``g[name]`` has its counterpart ``h[name]``.
.. runblock:: pycon
>>> from pgraph import UGraph
>>> g = UGraph()
>>> v = [g.add_vertex(coord=[i, 0], name=f"v{i}") for i in range(4)]
>>> for i in range(3):
... g.add_edge(v[i], v[i + 1])
>>> h = g.subgraph([v[1], v[2]])
>>> print(h)
>>> h.edges()
It is a single pass over the graph. The vertices might be a component,
the vertices that satisfy a test, or those in a region of an embedded
graph from :meth:`within`.
A boolean mask, such as the result of a comparison on
:meth:`coordinates`, selects the vertices by their position in the
graph, the ``True`` elements being kept::
h = g.subgraph(g.coordinates()[0] > 0) # the vertices with x > 0
The mask must be of boolean type (a list of ``bool`` also works) and of
length ``g.n``. A vector of 0 and 1 of integer type is not accepted, since
it could as well be a list of indices.
:seealso: :meth:`copy` :meth:`within` :meth:`component`
"""
mask = None
if isinstance(vertices, np.ndarray) and vertices.dtype == np.bool_:
mask = vertices
elif (
isinstance(vertices, (list, tuple))
and len(vertices) > 0
and all(isinstance(x, (bool, np.bool_)) for x in vertices)
):
mask = np.array(vertices, dtype=bool)
if mask is not None:
if mask.shape != (len(self._vertexlist),):
raise ValueError(
f"a mask must have one element per vertex, {len(self._vertexlist)}, "
f"not shape {mask.shape}"
)
keep = [int(i) for i in np.flatnonzero(mask)]
else:
wanted = set()
items: Iterable[Any] = vertices
for item in items:
v = self._resolve_vertex(item, "vertex")
if v._graph is not self:
raise ValueError(f"vertex {v.name!r} is not in this graph")
wanted.add(id(v))
keep = [i for i, v in enumerate(self._vertexlist) if id(v) in wanted]
state = self.__getstate__()
flat = state["_flat"]
renumber = {old: new for new, old in enumerate(keep)}
flat["vertices"] = [flat["vertices"][i] for i in keep]
flat["edges"] = [
(cls, renumber[i], renumber[j], attrs)
for cls, i, j, attrs in flat["edges"]
if i in renumber and j in renumber
]
h = type(self).__new__(type(self))
h.__setstate__(copy.deepcopy(state))
h._connectivitychange = True # the components are those of the new graph