Skip to content

Commit e1c3db2

Browse files
authored
Merge branch 'main' into perf/path-planners
2 parents 0c0f013 + 149abc1 commit e1c3db2

5 files changed

Lines changed: 211 additions & 2 deletions

File tree

‎docs/source/edges_vertices.rst‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,142 @@ For the directed case, each vertex maintains two edge lists: a list of outgoing
1515
The graph class provides methods for adding and removing vertices and edges (see :doc:`modification`)
1616
and for querying its structure (see :doc:`analysis`).
1717

18+
What a vertex holds
19+
~~~~~~~~~~~~~~~~~~~~
20+
21+
22+
Each vertex has **two** private edge lists, plus a back-pointer to its graph.
23+
24+
.. list-table::
25+
:header-rows: 1
26+
:widths: 15 55 30
27+
28+
* - Attribute
29+
- For undirected graphs
30+
- For directed graphs
31+
* - ``_edgelist``
32+
- the edges *incident on* it
33+
- the edges *leaving* it
34+
* - ``_inlist``
35+
- empty
36+
- the edges *entering* it
37+
* - ``_graph``
38+
- the graph it belongs to (``None`` if standalone)
39+
- the graph it belongs to (``None`` if standalone)
40+
41+
Every list entry is a reference to the same :class:`~pgraph.PGraph.Edge` class object, and
42+
there is exactly one edge object for each edge in the graph.
43+
44+
* **Undirected:** one list does everything, since an undirected edge is both "out"
45+
and "in". That is also why a loop is stored twice, once for each end, and why it
46+
counts 2 towards ``degree``.
47+
* **Directed:** the two lists are the out-list and the in-list. A loop sits once in
48+
each, so ``degree`` (in + out) is 2 there too.
49+
50+
An example, as lists
51+
~~~~~~~~~~~~~~~~~~~~~~
52+
53+
For these simple graphs
54+
55+
.. plot::
56+
:include-source: no
57+
:alt: the example graph drawn as an undirected and as a directed graph: two parallel edges e1 and e2 from a to b, e3 from b to c and a loop e4 on c
58+
59+
import matplotlib.pyplot as plt
60+
from matplotlib.patches import Circle, FancyArrowPatch
61+
62+
pos = {"a": (0.0, 0.0), "b": (2.0, 0.0), "c": (4.0, 0.0)}
63+
# (name, from, to, curvature, label offset): e1 and e2 are parallel, drawn
64+
# as two arcs curving to either side
65+
edges = [("e1", "a", "b", 0.3, (0, 0.5)), ("e2", "a", "b", -0.3, (0, -0.5)),
66+
("e3", "b", "c", 0, (0, 0.18))]
67+
68+
fig, axs = plt.subplots(1, 2, figsize=(9, 2.3))
69+
for ax, is_directed, title in zip(axs, (False, True), ("UGraph", "DGraph")):
70+
style = "-|>" if is_directed else "-"
71+
for name, p, q, rad, (dx, dy) in edges:
72+
ax.add_patch(FancyArrowPatch(pos[p], pos[q], arrowstyle=style, mutation_scale=16,
73+
connectionstyle=f"arc3,rad={rad}", shrinkA=15, shrinkB=15,
74+
color="0.15", lw=1.6))
75+
mx = (pos[p][0] + pos[q][0]) / 2 + dx
76+
my = (pos[p][1] + pos[q][1]) / 2 + dy
77+
ax.text(mx, my, name, ha="center", va="center", fontsize=10, color="0.15")
78+
x, y = pos["c"] # e4: a loop, an edge from c to itself
79+
ax.add_patch(FancyArrowPatch((x - 0.15, y + 0.15), (x + 0.15, y + 0.15), arrowstyle=style,
80+
mutation_scale=16, connectionstyle="arc3,rad=-1.8",
81+
color="0.15", lw=1.6))
82+
ax.text(x, y + 0.62, "e4", ha="center", va="center", fontsize=10, color="0.15")
83+
for name, (x, y) in pos.items():
84+
ax.add_patch(Circle((x, y), 0.2, fc="#1f77b4", ec="none", zorder=3))
85+
ax.text(x, y, name, ha="center", va="center", color="white", fontsize=11, zorder=4)
86+
ax.set_xlim(-0.6, 4.6)
87+
ax.set_ylim(-0.8, 0.95)
88+
ax.set_aspect("equal")
89+
ax.axis("off")
90+
ax.set_title(title, fontsize=11)
91+
fig.tight_layout()
92+
93+
The vertices ``a``, ``b`` and ``c`` and edges ``e1`` and ``e2`` are added in that order.
94+
The resulting vertex lists are
95+
96+
.. list-table::
97+
:header-rows: 1
98+
:widths: 10 30 30 30
99+
100+
* - Vertex name
101+
- ``UGraph`` ``_edgelist``
102+
- ``DGraph`` ``_edgelist``
103+
- ``DGraph`` ``_inlist``
104+
* - ``a``
105+
- ``[e1, e2]``
106+
- ``[e1, e2]``
107+
- ``[]``
108+
* - ``b``
109+
- ``[e1, e2, e3]``
110+
- ``[e3]``
111+
- ``[e1, e2]``
112+
* - ``c``
113+
- ``[e3, e4, e4]``
114+
- ``[e4]``
115+
- ``[e3, e4]``
116+
117+
The graph also keeps **every edge once** in ``graph._edges`` (an insertion-ordered
118+
dict, so ``[e1, e2, e3, e4]``).
119+
120+
The public view of those lists
121+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
122+
123+
.. list-table::
124+
:header-rows: 1
125+
:widths: 30 35 35
126+
127+
* - Method
128+
- Returns for ``UGraph``
129+
- Returns for ``DGraph``
130+
* - ``edges()``, ``out_edges()``
131+
- a copy of ``_edgelist``
132+
- a copy of ``_edgelist``
133+
* - ``in_edges()``
134+
- a copy of ``_edgelist``
135+
- a copy of ``_inlist``
136+
* - ``degree``
137+
- ``len(_edgelist)``
138+
- ``len(_edgelist) + len(_inlist)``
139+
* - ``in_degree``
140+
- ``len(_edgelist)``
141+
- ``len(_inlist)``
142+
* - ``out_degree``
143+
- ``len(_edgelist)``
144+
- ``len(_edgelist)``
145+
* - ``adjacent()``
146+
- the far end of each edge in ``_edgelist``
147+
- the far end of each edge in ``_edgelist``
148+
* - ``incidences()``
149+
- ``adjacent()`` paired with ``out_edges()``
150+
- ``adjacent()`` paired with ``out_edges()``
151+
152+
All are returned in the order the edges were connected.
153+
18154

19155
Working with vertices and edges directly
20156
-----------------------------------------

‎src/pgraph/PGraph.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,8 @@ def __str__(self) -> str:
290290
:return: one-line summary of vertex/edge/component counts
291291
:rtype: str
292292
293+
Counts of 1,000 or more have thousands separators.
294+
293295
.. runblock:: pycon
294296
295297
>>> from pgraph import UGraph
@@ -303,7 +305,7 @@ def __str__(self) -> str:
303305
304306
:seealso: :meth:`show`
305307
"""
306-
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]}"
308+
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]}"
307309
return s
308310

309311
def __repr__(self) -> str:

‎tests/test_components.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import signal
1919
import unittest
20+
import unittest.mock
2021
from contextlib import contextmanager
2122

2223
import numpy as np
@@ -197,6 +198,41 @@ def test_samecomponent(self):
197198
self.assertFalse(g.samecomponent(g[1], g[2]))
198199

199200

201+
class TestNotRecomputed(unittest.TestCase):
202+
"""The traversal runs once, and again only after the graph has changed"""
203+
204+
def traversals(self, g, ask):
205+
calls = []
206+
original = type(g[0]).adjacent
207+
208+
def spy(vertex, *args, **kwargs):
209+
calls.append(vertex)
210+
return original(vertex, *args, **kwargs)
211+
212+
with unittest.mock.patch.object(type(g[0]), "adjacent", spy):
213+
ask()
214+
return len(calls)
215+
216+
def check(self, cls):
217+
g = build(cls, 4, [(0, 1), (2, 3)])
218+
ask = lambda: (g.nc, g.component(g[0]), g.samecomponent(g[0], g[1]), g[2].label)
219+
220+
self.assertGreater(self.traversals(g, ask), 0) # first time: does the work
221+
self.assertEqual(self.traversals(g, ask), 0) # nothing changed: none
222+
self.assertEqual(self.traversals(g, ask), 0)
223+
224+
g.add_edge(g[1], g[2]) # a change: once more, and the answer is new
225+
self.assertGreater(self.traversals(g, ask), 0)
226+
self.assertEqual(g.nc, 1)
227+
self.assertEqual(self.traversals(g, ask), 0)
228+
229+
def test_undirected(self):
230+
self.check(UGraph)
231+
232+
def test_directed(self):
233+
self.check(DGraph)
234+
235+
200236
class TestUndirectedComponents(_Components, unittest.TestCase):
201237
cls = UGraph
202238

‎tests/test_export.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,8 @@ def test_dotfile_prints_to_console(self):
408408
g.dotfile()
409409
self.assertEqual(out.getvalue(), g.export(format="dot", costs=False))
410410
self.assertNotIn("label=", out.getvalue())
411+
# writing to the console must leave it usable (0.6.5 closed it)
412+
self.assertFalse(out.closed)
411413

412414
def test_dotfile_to_path_and_stream(self):
413415
g, *_ = line_graph(UGraph)

‎tests/test_graph.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import numpy as np
44
import numpy.testing as nt
55

6-
from pgraph import *
6+
from pgraph import DGraph, Edge, UGraph, Vertex
77

88
class TestUGraph(unittest.TestCase):
99

@@ -51,6 +51,21 @@ def test_str(self):
5151
self.assertEqual(len(lines), 3) # class-name header + one line per vertex
5252
self.assertEqual(lines[0], "DGraph:")
5353

54+
55+
def test_str_has_thousands_separators(self):
56+
# counts of 1,000 or more are easier to read with separators
57+
g = UGraph()
58+
vs = [g.add_vertex() for _ in range(1001)]
59+
self.assertEqual(str(g), "UGraph: 1,001 vertices, 0 edges, 1,001 components")
60+
for a, b in zip(vs, vs[1:]):
61+
g.add_edge(a, b)
62+
self.assertEqual(str(g), "UGraph: 1,001 vertices, 1,000 edges, 1 component")
63+
# below a thousand it is as before
64+
h = UGraph()
65+
for _ in range(999):
66+
h.add_vertex()
67+
self.assertEqual(str(h), "UGraph: 999 vertices, 0 edges, 999 components")
68+
5469
def test_attr(self):
5570

5671
g = UGraph()
@@ -298,6 +313,24 @@ def test_edge_vertices_deprecated(self):
298313
verts = e.vertices()
299314
self.assertEqual(verts, e.endpoints)
300315

316+
def test_planners_validate_start_and_goal(self):
317+
# 0.6.5 checked the type of the start twice and never the goal
318+
g = UGraph()
319+
a = g.add_vertex(coord=[0, 0], name="a")
320+
b = g.add_vertex(coord=[1, 0], name="b")
321+
g.add_edge(a, b)
322+
323+
for planner in (g.path_BFS, g.path_UCS, g.path_Astar):
324+
with self.subTest(planner=planner.__name__):
325+
with self.assertRaises(TypeError):
326+
planner(a, 42)
327+
with self.assertRaises(TypeError):
328+
planner(42, b)
329+
with self.assertRaises(KeyError):
330+
planner(a, "no such vertex")
331+
# by name and by reference both work
332+
self.assertEqual(planner("a", "b")[0], [a, b])
333+
301334
def test_edge3(self):
302335

303336
g = UGraph()

0 commit comments

Comments
 (0)