|
| 1 | +1.0.0 |
| 2 | + |
| 3 | +First 1.0 release: six years of production use since the first commit in 2020 |
| 4 | +(including as a real dependency of both the Robotics Toolbox for Python and the |
| 5 | +Machine Vision Toolbox for Python), and this cycle's type hints, documentation, |
| 6 | +testing and bug-fixing pass, felt like the right point to commit to API |
| 7 | +stability. This is the first release with a considered API, so it contains |
| 8 | +several deliberate breaking changes, listed first with how to migrate. |
| 9 | + |
| 10 | +Breaking changes (from 0.6.5) |
| 11 | + |
| 12 | +- The `UGraph`/`DGraph` constructor arguments are keyword-only. 0.6.5 accepted |
| 13 | + and silently ignored a first positional argument, so `UGraph(6)` looked as |
| 14 | + though it set a dimension but did nothing. Now it is a `TypeError`: write |
| 15 | + `UGraph(dim=6)`. |
| 16 | +- `degree` of a `DGraph` is the graph-theory degree, in plus out, so a |
| 17 | + self-loop counts 2 and the degrees sum to twice the number of edges. It used |
| 18 | + to count only the edges leaving the vertex. This changes `vertex.degree`, |
| 19 | + `graph.degree()` and `graph.connectivity()`, and `graph.average_degree()` is |
| 20 | + now 2E/N for both kinds of graph (it was E/N for a `DGraph`). The previous |
| 21 | + values are `vertex.out_degree`, `[v.out_degree for v in g]` and `g.ne / g.n`. |
| 22 | + `laplacian()` is unchanged (out-degree minus adjacency). Undirected graphs |
| 23 | + are unchanged. |
| 24 | +- `graph.edges()` is a `list` in the order the edges were added. It was the |
| 25 | + graph's own `set`, with no defined order, although documented as a list. Both |
| 26 | + `graph.edges()` and `vertex.edges()` return a new list, so changing the result |
| 27 | + cannot corrupt the graph. Use `set(g.edges())` for set operations. |
| 28 | +- `Edge.v1` and `Edge.v2` are read-only, and so is `Vertex.label` (the graph |
| 29 | + assigns it, and reading it now finds the components, where 0.6.5 gave `None` |
| 30 | + until `nc` had been asked for). Assigning an endpoint of an edge that was in a graph left the |
| 31 | + graph inconsistent and the edge impossible to remove. Give the vertices to |
| 32 | + `Edge(v1, v2)` or connect the edge with `v1.connect(v2, edge=e)`. |
| 33 | +- Assigning `Vertex.name` renames it in the graph (so `g[new]` works) and |
| 34 | + refuses a name already in use or `None`. |
| 35 | +- `add_vertex` raises `ValueError` for a name already used in the graph or a |
| 36 | + vertex that already belongs to a graph, instead of silently shadowing it. |
| 37 | + The default name `#N` is now the first free one, which could previously |
| 38 | + repeat after a removal. |
| 39 | +- There is one vertex class, `Vertex`: a vertex has no direction of its own, the |
| 40 | + graph it belongs to decides how an edge is recorded at its ends. `UVertex` |
| 41 | + and `DVertex` are deprecated names for it (below). A vertex given to |
| 42 | + `add_vertex` is no longer checked against the kind of graph, because there is |
| 43 | + no kind to mismatch. An override of the private `_attach`/`_detach` hooks |
| 44 | + moves from a vertex subclass to a `UGraph`/`DGraph` subclass. |
| 45 | +- The abstract base class `PGraph` is now `Graph`, and `Graph` and `Vertex` are |
| 46 | + exported from the package. `Graph` is concrete: `Graph(directed=True)` is a |
| 47 | + `DGraph` and `Graph()` a `UGraph`. |
| 48 | +- `Adjacency()` treats a non-zero diagonal element as a self-loop. |
| 49 | +- The `str` of an edge between vertices that are not in a graph shows `--`, |
| 50 | + because there is no kind of graph to say whether it is directed. |
| 51 | + |
| 52 | +Deprecated (still work, with a `DeprecationWarning`) |
| 53 | + |
| 54 | +- `Laplacian()` is `laplacian()`: a capital initial denotes a class method that |
| 55 | + builds a graph, like `Adjacency()` and `Dict()`. |
| 56 | +- `UVertex` and `DVertex` are `Vertex`. |
| 57 | +- `remove()` is `remove_vertex()` or `remove_edge()`, which say which they do. |
| 58 | +- The options of `plot()` are named for what they are: `vopt` is `vertex_opt`, |
| 59 | + `eopt` is `edge_opt`, `text` is `labels` and `text_opt`, `colorcomponents` is |
| 60 | + `color_components` and `force2d` is `force_2d`. The positional order is |
| 61 | + unchanged; giving both an old name and its new one is a `TypeError`. |
| 62 | + |
| 63 | +Added |
| 64 | + |
| 65 | +- `adjacent()` and `isadjacent()`, the graph-theory names, which have one |
| 66 | + spelling. `neighbours()`, `neighbors()` and `isneighbour()` from 0.6.5 are |
| 67 | + kept as aliases, with `isneighbor()` added, and none of them warn. |
| 68 | +- `plot()` draws each edge once. A directed graph has arrowheads, which |
| 69 | + `arrow_opt` styles; edges that join the same pair of vertices, in either |
| 70 | + direction, are arcs that bow apart; and a loop is a circle at its vertex. In |
| 71 | + 0.6.5 a directed graph looked like an undirected one, parallel edges lay |
| 72 | + exactly on top of each other and a loop was a line of no length. `plot()` |
| 73 | + does the same in 3D, and `highlight_edge()` follows an arc. |
| 74 | +- `Graph(directed=...)`, the `directed` property, and `vertex_class` and |
| 75 | + `edge_class` constructor arguments, so that user subclasses of `Vertex` and |
| 76 | + `Edge` are built by the graph. |
| 77 | +- `add_edge()` accepts an `Edge`, and `connect()` an `edge=` argument. |
| 78 | +- `edges_to(dest)` lists every edge from a vertex to another, so parallel edges |
| 79 | + can be seen; `edgeto(dest)` is still the cheapest. |
| 80 | +- Directed vertices record their incoming edges: `predecessors()`, |
| 81 | + `successors()`, `in_edges()`, `out_edges()`, `in_degree` and `out_degree`. |
| 82 | +- `export()` writes a graph as GraphViz dot, Mermaid, GraphML or ELK JSON, or as |
| 83 | + an image rendered by the GraphViz executables, with the layout preserved. The |
| 84 | + `pgraph.exporters` module can be extended with `register_exporter`. |
| 85 | +- `validate()` reports every way in which a graph's records disagree, for tests |
| 86 | + and debugging. |
| 87 | +- A graph can be pickled, and `copy()` works for graphs of any size. `copy()` |
| 88 | + raised `RecursionError` for a path of about 100 vertices. |
| 89 | +- `iscyclic()`, `remove_edge()`, `remove_vertex()` and the `dim` argument, which |
| 90 | + declares that every embedded vertex has a coordinate of exactly that length. |
| 91 | +- Self-loops and parallel edges are supported throughout: adjacency, incidence, |
| 92 | + degree and Laplacian matrices, and planning, which takes the cheapest of |
| 93 | + parallel edges. |
| 94 | +- `SE2Metric(weight)` is the `"SE2"` metric with a weight on the angle, the |
| 95 | + length that a radian is worth, so that position and orientation need not |
| 96 | + count equally. `"SE2"` is `SE2Metric()`, a weight of 1, so existing results |
| 97 | + are unchanged. It can be the metric or the heuristic of a graph and is |
| 98 | + pickled with its weight. |
| 99 | +- Spatial queries on an embedded graph: `coordinates()` is the matrix of vertex |
| 100 | + coordinates, a column per vertex; `within(box)` the vertices inside a box; |
| 101 | + `near(point, radius)` those within a distance, nearest first; and |
| 102 | + `subgraph(vertices)` the graph that a set of vertices induces, given as |
| 103 | + vertices, names or a boolean mask, as a new graph that shares nothing with |
| 104 | + the original. |
| 105 | +- `build_kdtree()` makes an optional k-d tree that `closest()` and `near()` use |
| 106 | + for the built-in metrics, `SE2Metric` included. It needs scipy, which is |
| 107 | + imported only then and is not a dependency; and it is dropped whenever a |
| 108 | + vertex is added or removed or a coordinate is assigned, so a stale one is |
| 109 | + never used. |
| 110 | +- `validate()` also checks that coordinates are finite vectors of one length, |
| 111 | + and with `embedded=True` that every vertex has one. |
| 112 | + |
| 113 | +Faster |
| 114 | + |
| 115 | +- `path_BFS`, `path_UCS` and `path_Astar` scaled badly: they searched lists with |
| 116 | + `in`, and took the minimum of the whole frontier at every step. Results and |
| 117 | + the verbose output are identical. On a random graph of 10,000 vertices and |
| 118 | + 30,000 edges, the mean of six random queries is 3 ms for breadth-first search |
| 119 | + where 0.6.5 took 172 ms, 18 ms for uniform cost where it took 1,500 ms, and |
| 120 | + 13 ms for A* where it took 410 ms. |
| 121 | +- The components are found by a union-find over the edges, 4 ms for that graph |
| 122 | + where 0.6.5 took 15 ms after a change. |
| 123 | +- Creating a vertex is faster: `Vertex(coord)` takes 0.35 µs where 0.6.5 took |
| 124 | + 1.5 µs, and `g.add_vertex(coord)` 1.7 µs where it took 2.2 µs. Making the |
| 125 | + coordinate with `np.r_` was most of the cost. |
| 126 | +- `import pgraph` takes 0.04 s where it took 0.6 s, and loads neither |
| 127 | + matplotlib, spatialmath nor scipy: they are imported by the methods that plot |
| 128 | + or build a spatial index. A script that only builds graphs and plans paths no |
| 129 | + longer pays for them. |
| 130 | +- `str(g)` separates thousands: `DGraph: 4,881 vertices, 8,392 edges, 14 |
| 131 | + components`. |
| 132 | + |
| 133 | +Fixed |
| 134 | + |
| 135 | +- `Dict()` always created undirected vertices, even for a `DGraph`; `connect()` |
| 136 | + joined vertices of two different graphs, and now also validates the |
| 137 | + endpoints of a supplied edge; `path_BFS`/`path_UCS`/`path_Astar` checked the |
| 138 | + type of the start vertex twice and never the goal; and `distance()` and the |
| 139 | + planners packed `nan` or `None` into their results for edges with no cost |
| 140 | + instead of raising. |
| 141 | +- `nc` never returned for many directed graphs, the smallest a 4-cycle, and |
| 142 | + neither did `component()`, `samecomponent()` or `plot()`, which ask for it. |
| 143 | + Components of a directed graph are weakly connected, as they were meant to |
| 144 | + be, and are numbered from 0 by the first vertex of each, for both kinds of |
| 145 | + graph. They are also no longer found again by every call once the graph has |
| 146 | + changed. |
| 147 | +- `Edge.vertices()` raised `DeprecationWarning` as an exception; it now warns |
| 148 | + and returns the endpoints, as `Edge.endpoints` does. |
| 149 | +- `incidence()` returned the wrong matrix for every graph with an edge: a loop |
| 150 | + variable overwrote the vertex index, so edges were marked in the wrong rows, |
| 151 | + and for a directed graph the vertex at the end of an edge was not marked at |
| 152 | + all. The columns follow `edges()`, which is now in the order the edges were |
| 153 | + added, where 0.6.5 used the order of a set. |
| 154 | +- `remove()` crashed for every directed edge; `dotfile()` closed `sys.stdout` |
| 155 | + when called with its default arguments; and several methods raised a bare |
| 156 | + `AttributeError` or `TypeError` instead of a clear message for a vertex with |
| 157 | + no coordinate or no graph. |
| 158 | +- `highlight_edge()`, and so `highlight_path()`, raised `AttributeError` for a |
| 159 | + graph of three dimensions, because it drew a flat arrow in the 3D axes (0.6.5 |
| 160 | + drew lines), and `highlight_vertex()` put its marker at z = 0. They draw in |
| 161 | + the dimension of the axes, so a 3D graph plotted with `force_2d` is |
| 162 | + highlighted in 2D. |
| 163 | +- Removing a vertex left its `_graph` pointing at the graph; renaming a vertex |
| 164 | + left the name table stale; `g[x]` accepted any type. |
| 165 | + |
| 166 | +Known limitations |
| 167 | + |
| 168 | +- A graph whose metric is a `lambda` or a locally defined function can be copied |
| 169 | + but not pickled; the built-in `"L1"`, `"L2"` and `"SE2"` and module-level |
| 170 | + functions can be. |
| 171 | + |
| 172 | +Build, tests and documentation |
| 173 | + |
| 174 | +- Modern type hints and reST docstrings throughout, most with a runnable |
| 175 | + example, and `mypy` is a CI check. |
| 176 | +- The documentation is organized by task, with the data structures explained and |
| 177 | + a policy page on object lifecycle, and builds with `sphinx-pyrunblock` (which |
| 178 | + replaces the unmaintained `sphinx-autorun`) and the matplotlib plot directive. |
| 179 | +- Python 3.9 to 3.14 are tested, the unused `scipy` dependency is dropped, and |
| 180 | + PyPI publishing is automated through GitHub Releases and trusted publishing. |
| 181 | + The workflow checks that the tag is the version in `pyproject.toml` and that |
| 182 | + the tagged commit is on `main`, and installs the built wheel and runs a path |
| 183 | + query before it uploads. |
| 184 | +- The documentation site is deployed with the GitHub Pages actions. It had not |
| 185 | + been updated since January 2025, because the CI pushed to a branch that Pages |
| 186 | + no longer served. |
| 187 | +- `llms.txt` and `llms-full.txt` are published with the documentation, for AI |
| 188 | + assistants: a short introduction with a quick start and common mistakes, and the |
| 189 | + whole documentation as one text file. `AGENTS.md` describes the source for |
| 190 | + assistants that work on it. |
| 191 | +- Two notebooks in `examples/`: `roads.ipynb`, the Queensland example, and |
| 192 | + `london.ipynb`, the road network of central London from OpenStreetMap as a |
| 193 | + directed graph with one-way streets, planned through with A*. There is also |
| 194 | + a benchmark, `examples/benchmark.py`, and the README has its results and an |
| 195 | + installation section. |
| 196 | +- The Queensland figure in the README shows the shortest route. It had been |
| 197 | + drawn with coordinates in metres and distances in kilometres, which made the |
| 198 | + A* heuristic far too large, so the route it found, 1,759 km, was not the |
| 199 | + shortest, 1,659 km. |
| 200 | + |
1 | 201 | 0.6.3 |
2 | 202 |
|
3 | 203 | - runs with Numpy2.x |
|
0 commit comments