diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 213c7d39..6d0f1da2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -112,4 +112,4 @@ jobs: # pinned to a commit, as this action can publish to PyPI; dependabot updates it - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: - skip_existing: true + skip-existing: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b00fd65..cf35675b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,203 @@ +1.0.0 + +First 1.0 release: six years of production use since the first commit in 2020 +(including as a real dependency of both the Robotics Toolbox for Python and the +Machine Vision Toolbox for Python), and this cycle's type hints, documentation, +testing and bug-fixing pass, felt like the right point to commit to API +stability. This is the first release with a considered API, so it contains +several deliberate breaking changes, listed first with how to migrate. + +Breaking changes (from 0.6.5) + +- The `UGraph`/`DGraph` constructor arguments are keyword-only. 0.6.5 accepted + and silently ignored a first positional argument, so `UGraph(6)` looked as + though it set a dimension but did nothing. Now it is a `TypeError`: write + `UGraph(dim=6)`. +- `degree` of a `DGraph` is the graph-theory degree, in plus out, so a + self-loop counts 2 and the degrees sum to twice the number of edges. It used + to count only the edges leaving the vertex. This changes `vertex.degree`, + `graph.degree()` and `graph.connectivity()`, and `graph.average_degree()` is + now 2E/N for both kinds of graph (it was E/N for a `DGraph`). The previous + values are `vertex.out_degree`, `[v.out_degree for v in g]` and `g.ne / g.n`. + `laplacian()` is unchanged (out-degree minus adjacency). Undirected graphs + are unchanged. +- `graph.edges()` is a `list` in the order the edges were added. It was the + graph's own `set`, with no defined order, although documented as a list. Both + `graph.edges()` and `vertex.edges()` return a new list, so changing the result + cannot corrupt the graph. Use `set(g.edges())` for set operations. +- `Edge.v1` and `Edge.v2` are read-only, and so is `Vertex.label` (the graph + assigns it, and reading it now finds the components, where 0.6.5 gave `None` + until `nc` had been asked for). Assigning an endpoint of an edge that was in a graph left the + graph inconsistent and the edge impossible to remove. Give the vertices to + `Edge(v1, v2)` or connect the edge with `v1.connect(v2, edge=e)`. +- Assigning `Vertex.name` renames it in the graph (so `g[new]` works) and + refuses a name already in use or `None`. +- `add_vertex` raises `ValueError` for a name already used in the graph or a + vertex that already belongs to a graph, instead of silently shadowing it. + The default name `#N` is now the first free one, which could previously + repeat after a removal. +- There is one vertex class, `Vertex`: a vertex has no direction of its own, the + graph it belongs to decides how an edge is recorded at its ends. `UVertex` + and `DVertex` are deprecated names for it (below). A vertex given to + `add_vertex` is no longer checked against the kind of graph, because there is + no kind to mismatch. An override of the private `_attach`/`_detach` hooks + moves from a vertex subclass to a `UGraph`/`DGraph` subclass. +- The abstract base class `PGraph` is now `Graph`, and `Graph` and `Vertex` are + exported from the package. `Graph` is concrete: `Graph(directed=True)` is a + `DGraph` and `Graph()` a `UGraph`. +- `Adjacency()` treats a non-zero diagonal element as a self-loop. +- The `str` of an edge between vertices that are not in a graph shows `--`, + because there is no kind of graph to say whether it is directed. + +Deprecated (still work, with a `DeprecationWarning`) + +- `Laplacian()` is `laplacian()`: a capital initial denotes a class method that + builds a graph, like `Adjacency()` and `Dict()`. +- `UVertex` and `DVertex` are `Vertex`. +- `remove()` is `remove_vertex()` or `remove_edge()`, which say which they do. +- The options of `plot()` are named for what they are: `vopt` is `vertex_opt`, + `eopt` is `edge_opt`, `text` is `labels` and `text_opt`, `colorcomponents` is + `color_components` and `force2d` is `force_2d`. The positional order is + unchanged; giving both an old name and its new one is a `TypeError`. + +Added + +- `adjacent()` and `isadjacent()`, the graph-theory names, which have one + spelling. `neighbours()`, `neighbors()` and `isneighbour()` from 0.6.5 are + kept as aliases, with `isneighbor()` added, and none of them warn. +- `plot()` draws each edge once. A directed graph has arrowheads, which + `arrow_opt` styles; edges that join the same pair of vertices, in either + direction, are arcs that bow apart; and a loop is a circle at its vertex. In + 0.6.5 a directed graph looked like an undirected one, parallel edges lay + exactly on top of each other and a loop was a line of no length. `plot()` + does the same in 3D, and `highlight_edge()` follows an arc. +- `Graph(directed=...)`, the `directed` property, and `vertex_class` and + `edge_class` constructor arguments, so that user subclasses of `Vertex` and + `Edge` are built by the graph. +- `add_edge()` accepts an `Edge`, and `connect()` an `edge=` argument. +- `edges_to(dest)` lists every edge from a vertex to another, so parallel edges + can be seen; `edgeto(dest)` is still the cheapest. +- Directed vertices record their incoming edges: `predecessors()`, + `successors()`, `in_edges()`, `out_edges()`, `in_degree` and `out_degree`. +- `export()` writes a graph as GraphViz dot, Mermaid, GraphML or ELK JSON, or as + an image rendered by the GraphViz executables, with the layout preserved. The + `pgraph.exporters` module can be extended with `register_exporter`. +- `validate()` reports every way in which a graph's records disagree, for tests + and debugging. +- A graph can be pickled, and `copy()` works for graphs of any size. `copy()` + raised `RecursionError` for a path of about 100 vertices. +- `iscyclic()`, `remove_edge()`, `remove_vertex()` and the `dim` argument, which + declares that every embedded vertex has a coordinate of exactly that length. +- Self-loops and parallel edges are supported throughout: adjacency, incidence, + degree and Laplacian matrices, and planning, which takes the cheapest of + parallel edges. +- `SE2Metric(weight)` is the `"SE2"` metric with a weight on the angle, the + length that a radian is worth, so that position and orientation need not + count equally. `"SE2"` is `SE2Metric()`, a weight of 1, so existing results + are unchanged. It can be the metric or the heuristic of a graph and is + pickled with its weight. +- Spatial queries on an embedded graph: `coordinates()` is the matrix of vertex + coordinates, a column per vertex; `within(box)` the vertices inside a box; + `near(point, radius)` those within a distance, nearest first; and + `subgraph(vertices)` the graph that a set of vertices induces, given as + vertices, names or a boolean mask, as a new graph that shares nothing with + the original. +- `build_kdtree()` makes an optional k-d tree that `closest()` and `near()` use + for the built-in metrics, `SE2Metric` included. It needs scipy, which is + imported only then and is not a dependency; and it is dropped whenever a + vertex is added or removed or a coordinate is assigned, so a stale one is + never used. +- `validate()` also checks that coordinates are finite vectors of one length, + and with `embedded=True` that every vertex has one. + +Faster + +- `path_BFS`, `path_UCS` and `path_Astar` scaled badly: they searched lists with + `in`, and took the minimum of the whole frontier at every step. Results and + the verbose output are identical. On a random graph of 10,000 vertices and + 30,000 edges, the mean of six random queries is 3 ms for breadth-first search + where 0.6.5 took 172 ms, 18 ms for uniform cost where it took 1,500 ms, and + 13 ms for A* where it took 410 ms. +- The components are found by a union-find over the edges, 4 ms for that graph + where 0.6.5 took 15 ms after a change. +- Creating a vertex is faster: `Vertex(coord)` takes 0.35 µs where 0.6.5 took + 1.5 µs, and `g.add_vertex(coord)` 1.7 µs where it took 2.2 µs. Making the + coordinate with `np.r_` was most of the cost. +- `import pgraph` takes 0.04 s where it took 0.6 s, and loads neither + matplotlib, spatialmath nor scipy: they are imported by the methods that plot + or build a spatial index. A script that only builds graphs and plans paths no + longer pays for them. +- `str(g)` separates thousands: `DGraph: 4,881 vertices, 8,392 edges, 14 + components`. + +Fixed + +- `Dict()` always created undirected vertices, even for a `DGraph`; `connect()` + joined vertices of two different graphs, and now also validates the + endpoints of a supplied edge; `path_BFS`/`path_UCS`/`path_Astar` checked the + type of the start vertex twice and never the goal; and `distance()` and the + planners packed `nan` or `None` into their results for edges with no cost + instead of raising. +- `nc` never returned for many directed graphs, the smallest a 4-cycle, and + neither did `component()`, `samecomponent()` or `plot()`, which ask for it. + Components of a directed graph are weakly connected, as they were meant to + be, and are numbered from 0 by the first vertex of each, for both kinds of + graph. They are also no longer found again by every call once the graph has + changed. +- `Edge.vertices()` raised `DeprecationWarning` as an exception; it now warns + and returns the endpoints, as `Edge.endpoints` does. +- `incidence()` returned the wrong matrix for every graph with an edge: a loop + variable overwrote the vertex index, so edges were marked in the wrong rows, + and for a directed graph the vertex at the end of an edge was not marked at + all. The columns follow `edges()`, which is now in the order the edges were + added, where 0.6.5 used the order of a set. +- `remove()` crashed for every directed edge; `dotfile()` closed `sys.stdout` + when called with its default arguments; and several methods raised a bare + `AttributeError` or `TypeError` instead of a clear message for a vertex with + no coordinate or no graph. +- `highlight_edge()`, and so `highlight_path()`, raised `AttributeError` for a + graph of three dimensions, because it drew a flat arrow in the 3D axes (0.6.5 + drew lines), and `highlight_vertex()` put its marker at z = 0. They draw in + the dimension of the axes, so a 3D graph plotted with `force_2d` is + highlighted in 2D. +- Removing a vertex left its `_graph` pointing at the graph; renaming a vertex + left the name table stale; `g[x]` accepted any type. + +Known limitations + +- A graph whose metric is a `lambda` or a locally defined function can be copied + but not pickled; the built-in `"L1"`, `"L2"` and `"SE2"` and module-level + functions can be. + +Build, tests and documentation + +- Modern type hints and reST docstrings throughout, most with a runnable + example, and `mypy` is a CI check. +- The documentation is organized by task, with the data structures explained and + a policy page on object lifecycle, and builds with `sphinx-pyrunblock` (which + replaces the unmaintained `sphinx-autorun`) and the matplotlib plot directive. +- Python 3.9 to 3.14 are tested, the unused `scipy` dependency is dropped, and + PyPI publishing is automated through GitHub Releases and trusted publishing. + The workflow checks that the tag is the version in `pyproject.toml` and that + the tagged commit is on `main`, and installs the built wheel and runs a path + query before it uploads. +- The documentation site is deployed with the GitHub Pages actions. It had not + been updated since January 2025, because the CI pushed to a branch that Pages + no longer served. +- `llms.txt` and `llms-full.txt` are published with the documentation, for AI + assistants: a short introduction with a quick start and common mistakes, and the + whole documentation as one text file. `AGENTS.md` describes the source for + assistants that work on it. +- Two notebooks in `examples/`: `roads.ipynb`, the Queensland example, and + `london.ipynb`, the road network of central London from OpenStreetMap as a + directed graph with one-way streets, planned through with A*. There is also + a benchmark, `examples/benchmark.py`, and the README has its results and an + installation section. +- The Queensland figure in the README shows the shortest route. It had been + drawn with coordinates in metres and distances in kilometres, which made the + A* heuristic far too large, so the route it found, 1,759 km, was not the + shortest, 1,659 km. + 0.6.3 - runs with Numpy2.x diff --git a/RELEASE b/RELEASE deleted file mode 100644 index ee6cdce3..00000000 --- a/RELEASE +++ /dev/null @@ -1 +0,0 @@ -0.6.1 diff --git a/build/lib/pgraph/PGraph.py b/build/lib/pgraph/PGraph.py deleted file mode 100644 index e0c7a48a..00000000 --- a/build/lib/pgraph/PGraph.py +++ /dev/null @@ -1,1918 +0,0 @@ -from abc import ABC -import sys -import numpy as np -import matplotlib.pyplot as plt -import copy -from collections.abc import Iterable -import tempfile -import subprocess -import webbrowser - -from spatialmath.base.graphics import axes_logic - - -class PGraph(ABC): - - def __init__(self, arg=None, metric=None, heuristic=None, verbose=False): - # we use a list and a dict, the list respects the order of adding - self._vertexlist = [] - self._vertexdict = {} - self._edgelist = set() - self._verbose = verbose - 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): - 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): - return str(self) - - @classmethod - def Dict(cls, d, reverse=False): - """ - 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 - :type reverse: bool, optional - :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 tree graph. - - By default parent vertices are linked their children. If ``reverse`` is - True then children are linked to their parents. - """ - - g = cls() - - 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(UVertex(), 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(UVertex(), name=parent_name) - - if reverse: - g.add_edge(vertex, parent) - else: - g.add_edge(parent, vertex) - - return g - - @classmethod - def Adjacency(cls, A, coords=None, names=None): - """ - 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 - - :return: [description] - :rtype: [type] - - Create a directed or undirected graph where non-zero elements ``A[i,j]`` - correspond to edges from vertex ``i`` to vertex ``j``. - - .. warning:: For undirected graph ``A`` should be symmetric but this - is not checked. Only the upper triangular part is used. - """ - - 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() - - 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 isinstance(g, UGraph): - # undirected graph - for i in range(A.shape[0]): - for j in range(i + 1, A.shape[1]): - if A[i, j] > 0: - g[i].connect(g[j], cost=A[i, j]) - else: - # directed graph - for i in range(A.shape[0]): - for j in range(A.shape[1]): - if A[i, j] > 0: - if i == j: - raise ValueError("loops in graph not supported") - g[i].connect(g[j], cost=A[i, j]) - - return g - - def copy(self): - """ - Deepcopy of graph - - :param g: A graph - :type g: PGraph - :return: deep copy - :rtype: PGraph - """ - return copy.deepcopy(self) - - def add_vertex(self, vertex, name=None): - """ - Add a vertex to the graph (superclass method) - - :param vertex: vertex to add - :type vertex: Vertex subclass - :param name: name of vertex - :type name: str - - ``G.add_vertex(v)`` add vertex ``v`` to the graph ``G``. - - If the vertex has no name and ``name`` is None give it a default name - ``#N`` where ``N`` is a consecutive integer. - - The vertex is placed into a dictionary with a key equal to its name. - """ - if name is None: - name = vertex.name - if name is None: - name = f"#{len(self._vertexlist)}" - vertex.name = name - self._vertexlist.append(vertex) - self._vertexdict[vertex.name] = vertex - if self._verbose: - print(f"New vertex {vertex.name}: {vertex.coord}") - vertex._graph = self - self._connectivitychange = True - return vertex - - def add_edge(self, v1, v2, **kwargs): - """ - Add an edge to the graph (superclass method) - - :param v1: first vertex (start if a directed graph) - :type v1: Vertex subclass - :param v2: second vertex (end if a directed graph) - :type v2: Vertex subclass - :param kwargs: optional arguments to pass to ``Vertex.connect`` - :return: edge - :rtype: Edge - - Create an edge between a vertex pair and adds it to the graph. - - This is a graph centric way of creating an edge. The - alternative is the ``connect`` method of a vertex. - - :seealso: :meth:`Edge.connect` :meth:`Vertex.connect` - """ - if isinstance(v1, str): - v1 = self[v1] - elif not isinstance(v1, Vertex): - raise TypeError("v1 must be Vertex subclass or string name") - if isinstance(v2, str): - v2 = self[v2] - elif not isinstance(v2, Vertex): - raise TypeError("v2 must be Vertex subclass or string name") - - if self._verbose: - print(f"New edge from {v1.name} to {v2.name}") - return v1.connect(v2, **kwargs) - - def remove(self, x): - """ - Remove element from graph (superclass method) - - :param x: element to remove from graph - :type x: Edge or Vertex subclass - :raises TypeError: unknown type - - The edge or vertex is removed, and all references and lists are - updated. - - .. warning:: The connectivity of the network may be changed. - """ - if isinstance(x, Edge): - # remove an edge - - # remove edge from the edgelist of connected vertices - x.v1._edgelist.remove(x) - x.v2._edgelist.remove(x) - - # indicate that connectivity has changed - x.v1._connectivitychange = True - x.v2._connectivitychange = True - self._connectivitychange = True - - # remove references to the vertices - x.v1 = None - x.v2 = None - - # remove from list of all edges - self._edgelist.remove(x) - - elif isinstance(x, Vertex): - # remove a vertex - - # remove all edges of this vertex - for edge in copy.copy(x._edgelist): - self.remove(edge) - - # remove from list and dict of all edges - self._vertexlist.remove(x) - del self._vertexdict[x.name] - else: - raise TypeError("expecting Edge or Vertex") - - def show(self): - print("vertices:") - for v in self._vertexlist: - print(" " + str(v)) - print("edges:") - for e in self._edgelist: - print(" " + str(e)) - - @property - def n(self): - """ - Number of vertices - - :return: Number of vertices - :rtype: int - """ - return len(self._vertexdict) - - @property - def ne(self): - """ - Number of edges - - :return: Number of vertices - :rtype: int - """ - return len(self._edgelist) - - @property - def nc(self): - """ - Number of components - - :return: Number of components - :rtype: int - - .. note:: - - - Components are labeled from 0 to ``g.nc-1``. - - A graph coloring algorithm is run if the graph connectivity - has changed. - - .. note:: A lazy approach is used, and if a connectivity changing - operation has been performed since the last call, the graph - coloring algorithm is run which is potentially expensive for - a large graph. - """ - self._graphcolor() - return self._ncomponents - - def _metricfunc(self, metric): - - def L1(v): - return np.linalg.norm(v, 1) - - def L2(v): - return np.linalg.norm(v) - - def SE2(v): - # wrap angle to range [-pi, pi) - v[2] = (v[2] + np.pi) % (2 * np.pi) - np.pi - return np.linalg.norm(v) - - if callable(metric): - return metric - elif isinstance(metric, str): - if metric == "L1": - return L1 - elif metric == "L2": - return L2 - elif metric == "SE2": - return SE2 - else: - raise ValueError("unknown metric") - - @property - def metric(self): - """ - Get the distance metric for graph - - :return: distance metric - :rtype: callable - - This is a function of a vector and returns a scalar. - """ - return self._metric - - @metric.setter - def metric(self, metric): - r""" - Set the distance metric for graph - - :param metric: distance metric - :type metric: callable or str - - This is a function of a vector and returns a scalar. It can be - user defined function or a string: - - - 'L1' is the norm :math:`L_1 = \Sigma_i | v_i |` - - 'L2' is the norm :math:`L_2 = \sqrt{ \Sigma_i v_i^2}` - - 'SE2' is a mixed norm for vectors :math:`(x, y, \theta)` and - is :math:`\sqrt{x^2 + y^2 + \bar{\theta}^2}` where :math:`\bar{\theta}` - is :math:`\theta` wrapped to the interval :math:`[-\pi, \pi)` - - The metric is used by :meth:`closest` and :meth:`distance` - """ - self._metric = self._metricfunc(metric) - - @property - def heuristic(self): - """ - Get the heuristic distance metric for graph - - :return: heuristic distance metric - :rtype: callable - - This is a function of a vector and returns a scalar. - """ - return self._heuristic - - @heuristic.setter - def heuristic(self, heuristic): - r""" - Set the heuristic distance metric for graph - - :param metric: heuristic distance metric - :type metric: callable or str - - This is a function of a vector and returns a scalar. It can be - user defined function or a string: - - - 'L1' is the norm :math:`L_1 = \Sigma_i | v_i |` - - 'L2' is the norm :math:`L_2 = \sqrt{ \Sigma_i v_i^2}` - - 'SE2' is a mixed norm for vectors :math:`(x, y, \theta)` and - is :math:`\sqrt{x^2 + y^2 + \bar{\theta}^2}` where :math:`\bar{\theta}` - is :math:`\theta` wrapped to the interval :math:`[-\pi, \pi)` - - The heuristic distance is only used by the A* planner :meth:`path_Astar`. - """ - self._heuristic = self._metricfunc(heuristic) - - def __repr__(self): - s = [] - for vertex in self: - ss = f"{vertex.name} at {vertex.coord}" - if vertex.label is not None: - ss += " component={vertex.label}" - s.append(ss) - return "\n".join(s) - - def __getitem__(self, i): - """ - Get vertex (superclass method) - - :param i: vertex description - :type i: int or str - :return: the referenced vertex - :rtype: Vertex subclass - - Retrieve a vertex by index or name: - - -``g[i]`` is the i'th vertex in the graph. This reflects the order of - addition to the graph. - -``g[s]`` is vertex named ``s`` - -``g[v]`` is ``v`` where ``v`` is a ``Vertex`` subclass - - This method also supports iteration over the vertices in a graph:: - - for v in g: - print(v) - - will iterate over all the vertices. - """ - if isinstance(i, int): - return self._vertexlist[i] - elif isinstance(i, str): - return self._vertexdict[i] - elif isinstance(i, Vertex): - return i - - def __contains__(self, item): - """ - Test if vertex in graph - - :param item: vertex or name of vertex - :type item: Vertex subclass or str - :return: true if vertex exists in the graph - :rtype: bool - - - ``'name' in graph`` is true if a vertex named ``'name'`` exists in the - graph. - - ``v in graph`` is true if the vertex reference ``v`` exists in the - graph. - - """ - if isinstance(item, str): - return item in self._vertexdict - elif isinstance(item, Vertex): - return item in self._vertexdict.values() - - def closest(self, coord): - """ - Vertex closest to point - - :param coord: coordinates of a point - :type coord: ndarray(n) - :return: closest vertex - :rtype: Vertex subclass - - Returns the vertex closest to the given point. Distance is computed - according to the graph's metric. - - :seealso: :meth:`metric` - """ - min_dist = np.inf - min_which = None - - for vertex in self: - d = self.metric(vertex.coord - coord) - if d < min_dist: - min_dist = d - min_which = vertex - - return min_which, min_dist - - def edges(self): - """ - Get all edges in graph (superclass method) - - :return: All edges in the graph - :rtype: list of Edge references - - We can iterate over all edges in the graph by:: - - for e in g.edges(): - print(e) - - .. note:: The ``edges()`` of a Vertex is a list of all edges connected - to that vertex. - - :seealso: :meth:`Vertex.edges` - """ - return self._edgelist - - def plot( - self, - colorcomponents=True, - force2d=False, - vopt={}, - eopt={}, - text={}, - block=None, - grid=True, - ax=None, - ): - """ - Plot the graph - - :param vopt: vertex format, defaults to 12pt o-marker - :type vopt: dict, optional - :param eopt: edge format, defaults to None - :type eopt: dict, optional - :param text: text label format, defaults to None - :type text: False or dict, optional - :param colorcomponents: color vertices and edges by component, defaults to None - :type color: bool, optional - :param block: block until figure is dismissed, defaults to True - :type block: bool, optional - - The graph is plotted using matplotlib. - - If ``colorcomponents`` is True then each component is assigned a unique - color. ``vertex`` and ``edge`` cannot include a color keyword. - - If ``text`` is a dict it is used to format text labels for the vertices - which are the vertex names. If ``text`` is None default formatting is - used. If ``text`` is False no labels are added. - """ - vopt = {**dict(marker="o", markersize=12), **vopt} - eopt = {**dict(linewidth=3), **eopt} - - if colorcomponents: - color = plt.cm.coolwarm(np.linspace(0, 1, self.nc)) - - if len(self[0].coord) == 2 or force2d: - # 2D plotting - if ax is None: - ax = axes_logic(ax, 2) - for c in range(self.nc): - # for each component - for vertex in self.component(c): - if text is not False: - ax.text(vertex.x, vertex.y, " " + vertex.name, **text) - if colorcomponents: - ax.plot(vertex.x, vertex.y, color=color[c, :], **vopt) - for v in vertex.neighbours(): - ax.plot( - [vertex.x, v.x], - [vertex.y, v.y], - color=color[c, :], - **eopt, - ) - else: - ax.plot(vertex.x, vertex.y, **vopt) - for v in vertex.neighbours(): - ax.plot([vertex.x, v.x], [vertex.y, v.y], **eopt) - else: - # 3D or higher plotting, just do (x, y, z) - if ax is None: - ax = axes_logic(ax, 3) - for c in range(self.nc): - # for each component - for vertex in self.component(c): - if text is not False: - ax.text( - vertex.x, vertex.y, vertex.z, " " + vertex.name, **text - ) - if colorcomponents: - ax.plot( - vertex.x, - vertex.y, - vertex.z, - **{**dict(color=color[c, :]), **vopt}, - ) - for v in vertex.neighbours(): - ax.plot( - [vertex.x, v.x], - [vertex.y, v.y], - [vertex.z, v.z], - **{**dict(color=color[c, :]), **eopt}, - ) - else: - ax.plot(vertex.x, vertex.y, **vopt) - for v in vertex.neighbours(): - ax.plot( - [vertex.x, v.x], - [vertex.y, v.y], - [vertex.z, v.z], - **eopt, - ) - # if nc > 1: - # # add a colorbar - # plt.colorbar() - ax.grid(grid) - if block is not None: - plt.show(block=block) - - def highlight_path(self, path, block=False, **kwargs): - """ - Highlight a path through the graph - - :param path: [description] - :type path: [type] - :param block: [description], defaults to True - :type block: bool, optional - - The vertices and edges along the path are overwritten with a different - size/width and color. - - :seealso: :meth:`highlight_vertex` :meth:`highlight_edge` - """ - for i in range(len(path)): - if i < len(path) - 1: - e = path[i].edgeto(path[i + 1]) - self.highlight_edge(e, **kwargs) - self.highlight_vertex(path[i], **kwargs) - plt.show(block=block) - - def highlight_edge(self, edge, scale=2, color="r", alpha=0.5): - """ - Highlight an edge in the graph - - :param edge: The edge to highlight - :type edge: Edge subclass - :param scale: Overwrite with a line this much bigger than the original, - defaults to 1.5 - :type scale: float, optional - :param color: Overwrite with a line in this color, defaults to 'r' - :type color: str, optional - """ - p1 = edge.v1 - p2 = edge.v2 - plt.plot( - [p1.x, p2.x], [p1.y, p2.y], color=color, linewidth=3 * scale, alpha=alpha - ) - - def highlight_vertex(self, vertex, scale=2, color="r", alpha=0.5): - """ - Highlight a vertex in the graph - - :param edge: The vertex to highlight - :type edge: Vertex subclass - :param scale: Overwrite with a line this much bigger than the original, - defaults to 1.5 - :type scale: float, optional - :param color: Overwrite with a line in this color, defaults to 'r' - :type color: str, optional - """ - if isinstance(vertex, Iterable): - for n in vertex: - if isinstance(n, str): - n = self[n] - plt.plot(n.x, n.y, "o", color=color, markersize=12 * scale, alpha=alpha) - else: - plt.plot( - vertex.x, vertex.y, "o", color=color, markersize=12 * scale, alpha=alpha - ) - - def dotfile(self, filename=None, direction=None): - """ - Export graph as a GraphViz dot file - - :param filename: filename to save graph to, defaults to None - :type filename: str, optional - - ``g.dotfile()`` creates the specified file which contains the - `GraphViz `_ code to represent the embedded graph. By default output - is to the console. - - .. note:: - - - The graph is undirected if it is a subclass of ``UGraph`` - - The graph is directed if it is a subclass of ``DGraph`` - - Use ``neato`` rather than dot to get the embedded layout - - .. note:: If ``filename`` is a file object then the file will *not* - be closed after the GraphViz model is written. - - :seealso: :func:`showgraph` - """ - - if filename is None: - f = sys.stdout - elif isinstance(filename, str): - f = open(filename, "w") - else: - f = filename - - if isinstance(self, DGraph): - print("digraph {", file=f) - else: - print("graph {", file=f) - - if direction is not None: - print(f"rankdir = {direction}", file=f) - - # add the vertices including name and position - for vertex in self: - if vertex.coord is None: - print(' "{:s}"'.format(vertex.name), file=f) - else: - print( - ' "{:s}" [pos="{:.5g},{:.5g}"]'.format( - vertex.name, vertex.coord[0], vertex.coord[1] - ), - file=f, - ) - print(file=f) - # add the edges - for e in self.edges(): - if isinstance(self, DGraph): - print(' "{:s}" -> "{:s}"'.format(e.v1.name, e.v2.name), file=f) - else: - print(' "{:s}" -- "{:s}"'.format(e.v1.name, e.v2.name), file=f) - - print("}", file=f) - - if filename is None or isinstance(filename, str): - f.close() # noqa - - def showgraph(self, **kwargs): - """ - Display graph in a browser tab - - :param kwargs: arguments passed to :meth:`dotfile` - - ``g.showgraph()`` renders and displays the graph in a browser tab. The - graph is exported in `GraphViz `_ format, rendered to - PDF using ``dot`` and then displayed in a browser tab. - - :seealso: :func:`dotfile` - """ - # create the temporary dotfile - dotfile = tempfile.TemporaryFile(mode="w") - self.dotfile(dotfile, **kwargs) - - # rewind the dot file, create PDF file in the filesystem, run dot - dotfile.seek(0) - pdffile = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) - result = subprocess.run("dot -Tpdf", shell=True, stdin=dotfile, stdout=pdffile) - - if result.returncode == 0: - # dot ran happily - # open the PDF file in browser (hopefully portable), then cleanup - webbrowser.open(f"file://{pdffile.name}") - # time.sleep(1) - # os.remove(pdffile.name) - - def iscyclic(self): - pass - - def average_degree(self): - r""" - Average degree of the graph - - :return: average degree - :rtype: float - - Average degree is :math:`2 E / N` for an undirected graph and - :math:`E / N` for a directed graph where :math:`E` is the total number of - edges and :math:`N` is the number of vertices. - - """ - if isinstance(self, DGraph): - return len(self.edges()) / self.n - elif isinstance(self, UGraph): - return 2 * len(self.edges()) / self.n - - # --------------------------------------------------------------------------- # - - # MATRIX REPRESENTATIONS - - def Laplacian(self): - """ - Laplacian matrix for the graph - - :return: Laplacian matrix - :rtype: NumPy ndarray - - ``g.Laplacian()`` is the Laplacian matrix (NxN) of the graph where N - is the number of vertices. - - .. note:: - - - Laplacian is always positive-semidefinite. - - Laplacian has at least one zero eigenvalue. - - The number of zero-valued eigenvalues is the number of connected - components in the graph. - - :seealso: :meth:`adjacency` :meth:`incidence` :meth:`degree` - """ - return self.degree() - (self.adjacency() > 0) - - def connectivity(self, vertices=None): - """ - Graph connectivity - - :return: a list with the number of edges per vertex - :rtype: list - - The average vertex connectivity is:: - - mean(g.connectivity()) - - and the minimum vertex connectivity is:: - - min(g.connectivity()) - """ - - c = [] - if vertices is None: - vertices = self - for n in vertices: - c.append(len(n._edgelist)) - return c - - def degree(self): - """ - Degree matrix of graph - - :return: degree matrix - :rtype: ndarray(N,N) - - This is a diagonal matrix where element ``[i,i]`` is the number - of edges connected to vertex id ``i``. - - :seealso: :meth:`adjacency` :meth:`incidence` :meth:`laplacian` - """ - - return np.diag(self.connectivity()) - - def adjacency(self): - """ - Adjacency matrix of graph - - :returns: adjacency matrix - :rtype: ndarray(N,N) - - The elements of the adjacency matrix ``[i,j]`` are 1 if vertex ``i`` is - connected to vertex ``j``, else 0. - - .. note:: - - - vertices are numbered in their order of creation. A vertex index - can be resolved to a vertex reference by ``graph[i]``. - - for an undirected graph the matrix is symmetric - - Eigenvalues of ``A`` are real and are known as the spectrum of the graph. - - The element ``A[i,j]`` can be considered the number of walks of length one - edge from vertex ``i`` to vertex ``j`` (either zero or one). - - If ``Ak = A ** k`` the element ``Ak[i,j]`` is the number of - walks of length ``k`` from vertex ``i`` to vertex ``j``. - - :seealso: :meth:`Laplacian` :meth:`incidence` :meth:`degree` - """ - # create a dict mapping vertex to an id - vdict = {} - for i, vert in enumerate(self): - vdict[vert] = i - - A = np.zeros((self.n, self.n)) - for vertex in self: - for n in vertex.neighbours(): - A[vdict[vertex], vdict[n]] = 1 - return A - - def incidence(self): - """ - Incidence matrix of graph - - :returns: incidence matrix - :rtype: ndarray(n,ne) - - The elements of the incidence matrix ``I[i,j]`` are 1 if vertex ``i`` is - connected to edge ``j``, else 0. - - .. note:: - - - vertices are numbered in their order of creation. A vertex index - can be resolved to a vertex reference by ``graph[i]``. - - edges are numbered in the order they appear in ``graph.edges()``. - - :seealso: :meth:`Laplacian` :meth:`adjacency` :meth:`degree` - """ - edges = self.edges() - I = np.zeros((self.n, len(edges))) - - # create a dict mapping edge to an id - edict = {} - for i, edge in enumerate(edges): - edict[edge] = i - - for i, vertex in enumerate(self): - for i, e in enumerate(vertex.edges()): - I[i, edict[e]] = 1 - - return I - - def distance(self): - """ - Distance matrix of graph - - :return: distance matrix - :rtype: ndarray(n,n) - - The elements of the distance matrix ``D[i,j]`` is the edge cost of moving - from vertex ``i`` to vertex ``j``. It is zero if the vertices are not - connected. - """ - # create a dict mapping vertex to an id - vdict = {} - for i, vert in enumerate(self): - vdict[vert] = i - - A = np.zeros((self.n, self.n)) - for v1 in self: - for v2, edge in v1.incidences(): - A[vdict[v1], vdict[v2]] = edge.cost - return A - - # GRAPH COMPONENTS - - def component(self, c): - """ - All vertices in specified graph component - - ``graph.component(c)`` is a list of all vertices in graph component ``c``. - """ - self._graphcolor() # ensure labels are uptodate - return [v for v in self if v.label == c] - - def samecomponent(self, v1, v2): - """ - Test if vertices belong to same graph component - - :param v1: vertex - :type v1: Vertex subclass - :param v2: vertex - :type v2: Vertex subclass - :return: true if vertices belong to same graph component - :rtype: bool - - Test whether vertices belong to the same component. For a: - - - directed graph this implies a path between them - - undirected graph there is not necessarily a path between them - """ - self._graphcolor() # ensure labels are uptodate - - return v1.label == v2.label - - # def remove(self, v): - # # remove edges from neighbour's edge list - # for e in v.edges(): - # next = e.next(v) - # next._edgelist.remove(e) - # next._connectivitychange = True - - # # remove references from the graph - # self._vertexlist.remove(v) - # for key, value in self._vertexdict.items(): - # if value is v: - # del self._vertexdict[key] - # break - - # v._edgelist = [] # remove all references to edges - # --------------------------------------------------------------------------- # - - def path_BFS(self, S, G, verbose=False, summary=False): - """ - Breadth-first search for path - - :param S: start vertex - :type S: Vertex subclass - :param G: goal vertex - :type G: Vertex subclass - :return: list of vertices from S to G inclusive, path length - :rtype: list of Vertex subclass, float - - Returns a list of vertices that form a path from vertex ``S`` to - vertex ``G`` if possible, otherwise return None. - - """ - if isinstance(S, str): - S = self[S] - elif not isinstance(S, Vertex): - raise TypeError("start must be Vertex subclass or string name") - if isinstance(G, str): - G = self[G] - elif not isinstance(S, Vertex): - raise TypeError("goal must be Vertex subclass or string name") - - # we use lists not sets since the order is instructive in verbose - # mode, really need ordered sets... - frontier = [S] - explored = [] - parent = {} - done = False - - while frontier: - if verbose: - print() - print("FRONTIER:", ", ".join([v.name for v in frontier])) - print("EXPLORED:", ", ".join([v.name for v in explored])) - - x = frontier.pop(0) - if verbose: - print(" expand", x.name) - - # expand the vertex - for n in x.neighbours(): - if n is G: - if verbose: - print(" goal", n.name, "reached") - parent[n] = x - done = True - break - if n not in frontier and n not in explored: - # add it to the frontier - frontier.append(n) - if verbose: - print(" add", n.name, "to the frontier") - parent[n] = x - if done: - break - explored.append(x) - if verbose: - print(" move", x.name, "to the explored list") - else: - # no path - return None - - # reconstruct the path from start to goal - x = G - path = [x] - length = 0 - - while x is not S: - p = parent[x] - length += x.edgeto(p).cost - path.insert(0, p) - x = p - - if summary or verbose: - print( - f"{len(explored)} vertices explored, {len(frontier)} remaining on the frontier" - ) - - return path, length - - def path_UCS(self, S, G, verbose=False, summary=False): - """ - Uniform cost search for path - - :param S: start vertex - :type S: Vertex subclass - :param G: goal vertex - :type G: Vertex subclass - :return: list of vertices from S to G inclusive, path length, tree - :rtype: list of Vertex subclass, float, dict - - Returns a list of vertices that form a path from vertex ``S`` to - vertex ``G`` if possible, otherwise return None. - - The search tree is returned as dict that maps a vertex to its parent. - - The heuristic is the distance metric of the graph, which defaults to - Euclidean distance. - """ - if isinstance(S, str): - S = self[S] - elif not isinstance(S, Vertex): - raise TypeError("start must be Vertex subclass or string name") - if isinstance(G, str): - G = self[G] - elif not isinstance(S, Vertex): - raise TypeError("goal must be Vertex subclass or string name") - - frontier = [S] - explored = [] - parent = {} - f = {S: 0} # evaluation function - - while frontier: - if verbose: - print() - print( - "FRONTIER:", ", ".join([f"{v.name}({f[v]:.0f})" for v in frontier]) - ) - print("EXPLORED:", ", ".join([v.name for v in explored])) - - i = np.argmin([f[n] for n in frontier]) # minimum f in frontier - x = frontier.pop(i) - if verbose: - print(" expand", x.name) - if x is G: - break - # expand the vertex - for n, e in x.incidences(): - fnew = f[x] + e.cost - if n not in frontier and n not in explored: - # add it to the frontier - parent[n] = x - f[n] = fnew - frontier.append(n) - if verbose: - print(" add", n.name, "to the frontier") - - elif n in frontier: - # neighbour is already in the frontier - # cost of path via x is lower that previous, reparent it - if fnew < f[n]: - if verbose: - print( - f" reparent {n.name}: cost {fnew} via {x.name} is less than cost {f[n]} via {parent[n].name}, change parent from {parent[n].name} to {x.name} " - ) - f[n] = fnew - parent[n] = x - - explored.append(x) - if verbose: - print(" move", x.name, "to the explored list") - else: - # no path - return None - - # reconstruct the path from start to goal - x = G - path = [x] - length = 0 - - while x is not S: - p = parent[x] - length += p.edgeto(x).cost - path.insert(0, p) - x = p - - parent_names = {} - for v, p in parent.items(): - parent_names[v.name] = p.name - - if summary or verbose: - print( - f"{len(explored)} vertices explored, {len(frontier)} remaining on the frontier" - ) - - return path, length, parent_names - - def path_Astar(self, S, G, verbose=False, summary=False): - """ - A* search for path - - :param S: start vertex - :type S: Vertex subclass - :param G: goal vertex - :type G: Vertex subclass - :return: list of vertices from S to G inclusive, path length, tree - :rtype: list of Vertex subclass, float, dict - - Returns a list of vertices that form a path from vertex ``S`` to - vertex ``G`` if possible, otherwise return None. - - The search tree is returned as dict that maps a vertex to its parent. - - The heuristic is the distance metric of the graph, which defaults to - Euclidean distance. - - :seealso: :meth:`heuristic` - """ - if isinstance(S, str): - S = self[S] - elif not isinstance(S, Vertex): - raise TypeError("start must be Vertex subclass or string name") - if isinstance(G, str): - G = self[G] - elif not isinstance(S, Vertex): - raise TypeError("goal must be Vertex subclass or string name") - - frontier = [S] - explored = [] - parent = {} - g = {S: 0} # cost to come - f = {S: 0} # evaluation function - - while frontier: - if verbose: - print() - print( - "FRONTIER:", ", ".join([f"{v.name}({f[v]:.0f})" for v in frontier]) - ) - print("EXPLORED:", ", ".join([v.name for v in explored])) - - i = np.argmin([f[n] for n in frontier]) # minimum f in frontier - x = frontier.pop(i) - if verbose: - print(" expand", x.name) - if x is G: - break - # expand the vertex - for n, e in x.incidences(): - if n not in frontier and n not in explored: - # add it to the frontier - frontier.append(n) - parent[n] = x - g[n] = g[x] + e.cost # update cost to come - f[n] = g[n] + n.heuristic_distance(G) # heuristic - if verbose: - print(" add", n.name, "to the frontier") - elif n in frontier: - # neighbour is already in the frontier - gnew = g[x] + e.cost - if gnew < g[n]: - # cost of path via x is lower that previous, reparent it - if verbose: - print( - f" reparent {n.name}: cost {gnew} via {x.name} is less than cost {g[n]} via {parent[n].name}, change parent from {parent[n].name} to {x.name} " - ) - g[n] = gnew - f[n] = g[n] + n.heuristic_distance(G) # heuristic - - parent[n] = x # reparent - - explored.append(x) - if verbose: - print(" move", x.name, "to the explored list") - - else: - # no path - return None - - # reconstruct the path from start to goal - x = G - path = [x] - length = 0 - - while x is not S: - p = parent[x] - length += p.edgeto(x).cost - path.insert(0, p) - x = p - - parent_names = {} - for v, p in parent.items(): - parent_names[v.name] = p.name - - if summary or verbose: - print( - f"{len(explored)} vertices explored, {len(frontier)} remaining on the frontier" - ) - - return path, length, parent_names - - -# -------------------------------------------------------------------------- # - - -class UGraph(PGraph): - """ - Class for undirected graphs - - .. inheritance-diagram:: UGraph - """ - - def add_vertex(self, coord=None, name=None): - """ - Add vertex to undirected graph - - :param coord: coordinate for an embedded graph, defaults to None - :type coord: array-like, optional - :param name: vertex name, defaults to "#i" - :type name: str, optional - :return: new vertex - :rtype: UVertex - - - ``g.add_vertex()`` creates a new vertex with optional ``coord`` and - ``name``. - - ``g.add_vertex(v)`` takes an instance or subclass of UVertex and adds - it to the graph - """ - if isinstance(coord, UVertex): - vertex = coord - else: - vertex = UVertex(coord) - super().add_vertex(vertex, name=name) - return vertex - - @classmethod - def vertex_copy(self, vertex): - return DVertex(coord=vertex.coord, name=vertex.name) - - def _graphcolor(self): - """ - Color the graph - - Performs a depth-first labeling operation, assigning the ``label`` - attribute of every vertex with a sequential integer starting from 0. - - This method checks the ``_connectivitychange`` attribute of all vertices - and if any are True it will perform the coloring operation. This flag - is set True by any operation that adds or removes a vertex or edge. - - :seealso: :meth:`nc` - """ - if self._connectivitychange or any([n._connectivitychange for n in self]): - - # color the graph - - # clear all the labels - for vertex in self: - vertex.label = None - vertex._connectivitychange = False - - lastlabel = None - for label in range(self.n): - assignment = False - for v in self: - # find first vertex with no label - if v.label is None: - # do BFS - q = [v] # initialize frontier - while len(q) > 0: - v = q.pop() # expand v - v.label = label - for n in v.neighbours(): - if n.label is None: - q.append(n) - lastlabel = label - assignment = True - break - if not assignment: - break - - self._ncomponents = lastlabel + 1 - - -class DGraph(PGraph): - """ - Class for directed graphs - - .. inheritance-diagram:: DGraph - """ - - def add_vertex(self, coord=None, name=None): - """ - Add vertex to directed graph - - :param coord: coordinate for an embedded graph, defaults to None - :type coord: array-like, optional - :param name: vertex name, defaults to "#i" - :type name: str, optional - :return: new vertex - :rtype: DVertex - - - ``g.add_vertex()`` creates a new vertex with optional ``coord`` and - ``name``. - - ``g.add_vertex(v)`` takes an instance or subclass of DVertex and adds - it to the graph - """ - if isinstance(coord, Vertex): - vertex = coord - else: - vertex = DVertex(coord=coord, name=name) - super().add_vertex(vertex, name=name) - return vertex - - @classmethod - def vertex_copy(self, vertex): - return DVertex(coord=vertex.coord, name=vertex.name) - - def _graphcolor(self): - """ - Color the graph - - Performs a depth-first labeling operation, assigning the ``label`` - attribute of every vertex with a sequential integer starting from 0. - - This method checks the ``_connectivitychange`` attribute of all vertices - and if any are True it will perform the coloring operation. This flag - is set True by any operation that adds or removes a vertex or edge. - - :seealso: :meth:`nc` - """ - if self._connectivitychange or any([n._connectivitychange for n in self]): - - # color the graph - - # clear all the labels - for vertex in self: - vertex.label = None - vertex._connectivitychange = False - - # initial labeling pass - merge = {} - nextlabel = 1 - for v in self: - if v.label is None: - # no label, try to inherit one from a neighbour - for n in v.neighbours(): - if n.label is not None: - # neighbour has a label - v.label = n.label - break - - if v.label is None: - # still not labeled, assign a new label - v.label = nextlabel - nextlabel += 1 - - # now look for clashes - for n in v.neighbours(): - if n.label is None: - # neighbour has no label, give it this one - n.label = v.label - elif v.label != n.label: - # label clash, note it for merging - merge[n.label] = v.label - - # merge labels and find unique labels - unique = set() - for v in self: - while v.label in merge: - v.label = merge[v.label] - unique.add(v.label) - - final = {u: i for i, u in enumerate(unique)} - for v in self: - v.label = final[v.label] - - self._ncomponents = len(unique) - - -# ========================================================================== # - - -class Edge: - """ - Edge class - - Is used to represent directed directed and undirected edges. - - Each edge has: - - ``cost`` cost of traversing this edge, required for planning methods - - ``data`` reference to arbitrary data associated with the edge - - ``v1`` first vertex, start vertex for a directed edge - - ``v2`` second vertex, end vertex for a directed edge - - .. note:: - - - An undirected graph is created by having a single edge object in the - edgelist of _each_ vertex. - - This class can be inherited to provide user objects with graph capability. - - Inheritance is an alternative to providing arbitrary user data. - - An Edge points to a pair of vertices. At ``connect`` time the vertices - get references back to the Edge object. - - ``graph.add_edge(v1, v2)`` calls ``v1.connect(v2)`` - """ - - def __init__(self, v1=None, v2=None, cost=None, data=None): - """ - Create an edge object - - :param v1: start of the edge, defaults to None - :type v1: Vertex subclass, optional - :param v2: end of the edge, defaults to None - :type v2: Vertex subclass, optional - :param cost: edge cost, defaults to None - :type cost: any, optional - :param data: edge data, defaults to None - :type data: any, optional - - Creates an edge but does not connect it to the vertices or add it to the - graph. - - If vertices are given, and have associated coordinates, the edge cost - will be computed according to the distance measure associated with the - graph. - - ``data`` is a way of associating any object with the edge, its value - can be found as the ``.data`` attribute of the edge. An alternative - approach is to subclass the ``Edge`` class. - - .. note:: To compute edge cost from the vertices, the vertices must have - been added to the graph. - - :seealso: :meth:`Edge.connect` :meth:`Vertex.connect` - """ - self.v1 = v1 - self.v2 = v2 - - self.data = data - - # try to compute edge cost as metric distance if not given - if cost is not None: - self.cost = cost - elif not (v1 is None or v1.coord is None or v2 is None or v2.coord is None): - self.cost = v1._graph.metric(v1.coord - v2.coord) - else: - self.cost = None - - def __repr__(self): - return str(self) - - def __str__(self): - - s = f"Edge{{{self.v1} -- {self.v2}, cost={self.cost:.4g}}}" - if self.data is not None: - s += f" data={self.data}" - return s - - def connect(self, v1, v2): - """ - Add edge to the graph - - :param v1: start of the edge - :type v1: Vertex subclass - :param v2: end of the edge - :type v2: Vertex subclass - - The edge is added to the graph and connects vertices ``v1`` and ``v2``. - - .. note:: The vertices must already be added to the graph. - """ - - if v1._graph is None: - raise ValueError("vertex v1 does not belong to a graph") - if v2._graph is None: - raise ValueError("vertex v2 does not belong to a graph") - if not v1._graph is v2._graph: - raise ValueError("vertices must belong to the same graph") - - # connect edge to its vertices - self.v1 = v1 - self.v2 = v2 - - # tell the vertices to add edge to their edgelists as appropriate for - # DGraph or UGraph - v1.connect(v2, edge=self) - - def next(self, vertex): - """ - Return other end of an edge - - :param vertex: one vertex on the edge - :type vertex: Vertex subclass - :raises ValueError: ``vertex`` is not on the edge - :return: the other vertex on the edge - :rtype: Vertex subclass - - ``e.next(v1)`` is the vertex at the other end of edge ``e``, ie. the - vertex that is not ``v1``. - """ - - if self.v1 is vertex: - return self.v2 - elif self.v2 is vertex: - return self.v1 - else: - raise ValueError("shouldnt happen") - - def vertices(self): - raise DeprecationWarning("use endpoints instead") - - @property - def endpoints(self): - return [self.v1, self.v2] - - # def remove(self): - # """ - # Remove edge from graph - - # ``e.remove()`` removes ``e`` from the graph, but does not delete the - # edge object. - # """ - # # remove this edge from the edge list of both end vertices - # if self in self.v1._edgelist: - # self.v1._edgelist.remove(self) - # if self in self.v2._edgelist: - # self.v2._edgelist.remove(self) - - # # indicate that connectivity has changed - # self.v1._connectivitychange = True - # self.v2._connectivitychange = True - - # # remove references to the vertices - # self.v1 = None - # self.v2 = None - - -# ========================================================================== # - - -class Vertex: - """ - Superclass for vertices of directed and non-directed graphs. - - Each vertex has: - - ``name`` - - ``label`` an int indicating which graph component contains it - - ``_edgelist`` a list of edge objects that connect this vertex to others - - ``coord`` the coordinate in an embedded graph (optional) - """ - - def __init__(self, coord=None, name=None): - self._edgelist = [] - if coord is None: - self.coord = None - else: - self.coord = np.r_[coord] - self.name = name - self.label = None - self._connectivitychange = True - self._edgelist = [] - self._graph = None # reference to owning graph - # print('Vertex init', type(self)) - - def __str__(self): - return f"[{self.name:s}]" - - def __repr__(self): - if self.coord is None: - coord = "?" - else: - coord = ", ".join([f"{x:.4g}" for x in self.coord]) - return f"{self.__class__.__name__}[{self.name:s}, coord=({coord})]" - - def copy(self, cls=None): - if cls is not None: - return cls.vertex_copy(self) - else: - return self.__class__(coord=self.coord, name=self.name) - - def adjacent(self): - """ - Adjacent vertices - - ``v.adjacent()`` is a list of vertices adjacent to this vertex. - - :note: For a directed graph the neighbours are those on edges leaving this vertex - - :seealso: :meth:`neighbours` :meth:`neighbors` - """ - return [e.next(self) for e in self._edgelist] - - def neighbours(self): - """ - Neighbours of a vertex - - ``v.neighbours()`` is a list of neighbours of this vertex. - - .. note:: - - For a directed graph the neighbours are those on edges leaving this vertex - - British English synonym for :meth:`adjacent` - - :seealso: :meth:`adjacent` :meth:`neighbours` - """ - return self.adjacent() - - def neighbors(self): - """ - Neighbors of a vertex - - ``v.neighbors()`` is a list of neighbors of this vertex. - - .. note:: - - For a directed graph the neighbors are those on edges leaving this vertex - - US English synonym for :meth:`adjacent` - - :seealso: :meth:`adjacent` :meth:`neighbours` - """ - return self.adjacent() - - def isneighbour(self, vertex): - """ - Test if vertex is a neigbour - - :param vertex: vertex reference - :type vertex: Vertex subclass - :return: true if a neighbour - :rtype: bool - - For a directed graph this is true only if the edge is from ``self`` to - ``vertex``. - """ - return vertex in [e.next(self) for e in self._edgelist] - - def incidences(self): - """ - Neighbours and edges of a vertex - - ``v.incidences()`` is a generator that returns a list of incidences, - tuples of (vertex, edge) for all neighbours of the vertex ``v``. - - .. note:: For a directed graph the edges are those leaving this vertex - """ - return [(e.next(self), e) for e in self._edgelist] - - def connect(self, dest, edge=None, cost=None, data=None): - """ - Connect two vertices with an edge - - :param dest: The vertex to connect to - :type dest: ``Vertex`` subclass - :param edge: Use this as the edge object, otherwise a new ``Edge`` - object is created from the vertices being connected, - and the ``cost`` and ``edge`` parameters, defaults to None - :type edge: ``Edge`` subclass, optional - :param cost: the cost to traverse this edge, defaults to None - :type cost: float, optional - :param data: reference to arbitrary data associated with the edge, - defaults to None - :type data: Any, optional - :raises TypeError: vertex types are different subclasses - :return: the edge connecting the vertices - :rtype: Edge - - ``v1.connect(v2)`` connects vertex ``v1`` to vertex ``v2``. - - .. note:: - - - If the vertices subclass ``UVertex`` the edge is undirected, and if - they subclass ``DVertex`` the edge is directed. - - Vertices must both be of the same ``Vertex`` subclass - - :seealso: :meth:`Edge` - """ - - if not dest.__class__.__bases__[0] is self.__class__.__bases__[0]: - raise TypeError("must connect vertices of same type") - elif isinstance(edge, Edge): - e = edge - else: - e = Edge(self, dest, cost=cost, data=data) - - self._graph._edgelist.add(e) - self._graph._connectivitychange = True - self._connectivitychange = True - - return e - - def edgeto(self, dest): - """ - Get edge connecting vertex to specific neighbour - - :param dest: a neigbouring vertex - :type dest: ``Vertex`` subclass - :raises ValueError: ``dest`` is not a neighbour - :return: the edge from this vertex to ``dest`` - :rtype: Edge - - .. note:: - - - For a directed graph ``dest`` must be at the arrow end of the edge - """ - for n, e in self.incidences(): - if n is dest: - return e - raise ValueError("dest is not a neighbour") - - def edges(self): - """ - All outgoing edges of vertex - - :return: List of all edges leaving this vertex - :rtype: list of Edge - - .. note:: - - - For a directed graph the edges are those leaving this vertex - - For a non-directed graph the edges are those leaving or entering - this vertex - """ - return self._edgelist - - def heuristic_distance(self, v2): - return self._graph.heuristic(self.coord - v2.coord) - - def distance(self, coord): - """ - Distance from vertex to point - - :param coord: coordinates of the point - :type coord: ndarray(n) or Vertex - :return: distance - :rtype: float - - Distance is computed according to the graph's metric. - - :seealso: :meth:`metric` - """ - if isinstance(coord, Vertex): - coord = coord.coord - return self._graph.metric(self.coord - coord) - - @property - def degree(self): - """ - Degree of vertex - - :return: degree of the vertex - :rtype: int - - Returns the number of edges connected to the vertex. - - .. note:: For a ``DGraph`` only outgoing edges are considered. - - :seealso: :meth:`edges` - """ - return len(self.edges()) - - @property - def x(self): - """ - The x-coordinate of an embedded vertex - - :return: The x-coordinate - :rtype: float - """ - return self.coord[0] - - @property - def y(self): - """ - The y-coordinate of an embedded vertex - - :return: The y-coordinate - :rtype: float - """ - return self.coord[1] - - @property - def z(self): - """ - The z-coordinate of an embedded vertex - - :return: The z-coordinate - :rtype: float - """ - return self.coord[2] - - def closest(self): - return self._graph.closest(self.coord) - - -class UVertex(Vertex): - """ - Vertex subclass for undirected graphs - - This class can be inherited to provide user objects with graph capability. - - - .. inheritance-diagram:: UVertex - - """ - - def connect(self, other, **kwargs): - - if isinstance(other, Vertex): - e = super().connect(other, **kwargs) - elif isinstance(other, Edge): - e = super().connect(edge=other) - else: - raise TypeError("bad argument") - - # e = super().connect(other, **kwargs) - - self._edgelist.append(e) - other._edgelist.append(e) - self._graph._edgelist.add(e) - - return e - - -class DVertex(Vertex): - """ - Vertex subclass for directed graphs - - This class can be inherited to provide user objects with graph capability. - - .. inheritance-diagram:: DVertex - - """ - - def connect(self, other, **kwargs): - if isinstance(other, Vertex): - e = super().connect(other, **kwargs) - elif isinstance(other, Edge): - e = super().connect(edge=other) - else: - raise TypeError("bad argument") - - self._edgelist.append(e) - return e - - def remove(self): - self._edgelist = None # remove all references to edges - - -if __name__ == "__main__": - - g = UGraph() - - for i in range(10): - g.add_vertex() - - g.add_edge(g[0], g[1]) - - print(g) diff --git a/build/lib/pgraph/__init__.py b/build/lib/pgraph/__init__.py deleted file mode 100644 index 011d41c7..00000000 --- a/build/lib/pgraph/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from pgraph.PGraph import UGraph, DGraph, UVertex, DVertex, Edge diff --git a/pyproject.toml b/pyproject.toml index fc7deb4e..f67362f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pgraph-python" -version = "0.6.4" +version = "1.0.0" authors = [{ name = "Peter Corke", email = "rvc@petercorke.com" }] description = "Graphs for Python" readme = "README.md"