diff --git a/README.md b/README.md
index 5cd4794e..e9921cb2 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@
This pure-Python package provides classes for directed and undirected graphs: vertices
connected by edges, with edge costs and path planning. Self-loops and
-parallel edges are permitted.
+parallel edges are permitted. Performance is good enough to support bundle adjustment, pose-graph SLAM and path planning over thousands of vertices.
Vertices can optionally be *embedded*: given a coordinate in a space of any
dimension. Edge costs are then computed from the vertex positions using a
@@ -44,34 +44,57 @@ distance metric (Euclidean, Manhattan, SE(2), or your own), the vertex nearest
a point can be found, `A*` uses the metric as its heuristic, and the graph can
be plotted, or exported with its layout preserved.
-
+## Installation
```
-from pgraph import *
+pip install pgraph-python
+```
+
+This needs Python 3.9 or later. The dependencies, NumPy, Matplotlib and Spatial Maths, are installed with it.
+
+## Examples
+
+### A small example: routing through towns in Queensland
+
+
+
+```python
+from pgraph import UGraph
import json
-# load places and routes
-with open('places.json', 'r') as f:
- places = json.loads(f.read())
-with open('routes.json', 'r') as f:
- routes = json.loads(f.read())
+# load the places and routes
+with open('queensland.json', 'r') as f:
+ data = json.load(f)
# build the graph
g = UGraph()
-
-for name, info in places.items():
- g.add_vertex(name=name, coord=info["utm"])
-
-for route in routes:
- g.add_edge(route[0], route[1], cost=route[2])
+for name, info in data['places'].items():
+ g.add_vertex(name=name, coord=info['utm'])
+for route in data['routes']:
+ g.add_edge(route['start'], route['end'], cost=route['distance'])
# plan a path from Hughenden to Brisbane
p, length, _ = g.path_Astar('Hughenden', 'Brisbane')
-g.plot(block=False) # plot it
+g.plot(block=False) # plot it
g.highlight_path(p) # overlay the path
```
+The data file and the complete example are in the notebook [`examples/roads.ipynb`](https://github.com/petercorke/pgraph-python/blob/main/examples/roads.ipynb).
+
+### A bigger example: routing through central London
+
+
+
+A directed graph of the road network of central London, taken from [OpenStreetMap](https://www.openstreetmap.org/copyright), and the shortest drive between Buckingham Palace and Tower Bridge, there (solid) and back (dashed). The two routes differ in length, 5,515 m and 5,948 m, because of the one-way streets. The notebook [`examples/london.ipynb`](https://github.com/petercorke/pgraph-python/blob/main/examples/london.ipynb) builds the graph from the map data, keeps the part of it that can be driven around in, and plans with `A*`. This graph has over 4,000 vertices.
+
+Map data © OpenStreetMap contributors, available under the [Open Database License](https://www.openstreetmap.org/copyright).
+
+## Using pgraph
+
+Full details are given in the [online documentation](https://petercorke.github.io/pgraph-python).
+
### Properties and methods of the graph
+
Graphs belong to the class `UGraph` or `DGraph` for undirected or directed graphs respectively. The graph is essentially a container for the vertices.
- `g.add_vertex()` add a vertex
@@ -84,23 +107,39 @@ Graphs belong to the class `UGraph` or `DGraph` for undirected or directed graph
- `g.edges()` all edges in the graph
- `g.plot()` plots the vertices and edges
- `g.nc` the number of graph components, 1 if fully connected
-- `g.component(v)` the component that vertex `v` belongs to
+- `g.component(c)` the vertices in component number `c`, numbered from 0
- `g.samecomponent(v1, v2)` predicate for vertices belonging to the same component
***
-- `g.path_BFS()` breadth-first search
-- `g.path_Astar()` A* search
+- `g.path_BFS()` breadth-first search, the path with the fewest edges
+- `g.path_UCS()` uniform-cost search, the cheapest path
+- `g.path_Astar()` A* search, the cheapest path using a heuristic
+
+ ***
+- `g.closest(point)` the vertex nearest to a point
+- `g.near(point, radius)` the vertices within a distance of a point
+- `g.within(box)` the vertices inside a box
+- `g.subgraph(vertices)` the part of the graph that those vertices induce, as a new graph
+- `g.coordinates()` the vertex coordinates as a matrix, a column per vertex
+- `g.build_kdtree()` an optional spatial index (needs scipy) that makes `closest` and `near` faster on a large graph
+- `g.metric` the distance metric: `"L2"` (the default), `"L1"`, `"SE2"`, `SE2Metric(weight)` that weights the angle of a pose, or a function of your own
***
- `g.adjacency()` adjacency matrix
- `g.laplacian()` Laplacian matrix
- `g.incidence()` incidence matrix
+ ***
+- `g.copy()` a deep copy; a graph can also be pickled
+- `g.export()` write the graph as GraphViz DOT, Mermaid, GraphML, ELK, or an image (which needs GraphViz)
+- `g.validate()` check that the graph's records agree with each other
+
### Properties and methods of a vertex
Vertices are instances of the class `Vertex`, for both undirected and directed graphs.
- `v.coord` the coordinate vector for embedded graph (optional)
- `v.name` the name of the vertex (optional)
+- `v.label` the number of the component that the vertex belongs to
- `v.adjacent()` is a list of the adjacent vertices (`neighbours()` and `neighbors()` are the same)
Vertices can be named and referenced by name.
@@ -113,12 +152,12 @@ Each edge references a pair of vertices, the graph lists all of its edges in `g.
- `e.next(v)` vertex on edge `e` that is not `v`
- `e.v1`, `e.v2` the two vertices that define the edge `e`
-## Modifying a graph
+### Modifying a graph
- `g.remove_vertex(v)` or `v.remove()` remove vertex `v`
- `g.remove_edge(e)` or `e.remove()` remove edge `e`
-## Subclassing pgraph classes
+### Subclassing `pgraph` classes
Consider a user class `Foo` that we would like to connect using a graph _overlay_, i.e.
instances of `Foo` become vertices in a graph.
@@ -126,12 +165,16 @@ instances of `Foo` become vertices in a graph.
- Have it subclass `Vertex`
- Then place instances of `Foo` into the graph using `add_vertex` and create edges as required
-```
+```python
+from pgraph import UGraph, Vertex
+
class Foo(Vertex):
- # foo stuff goes here
-
-f1 = Foo(...)
-f2 = Foo(...)
+ # foo stuff goes here
+ def say_hi(self):
+ print(f"hi from {self.name}")
+
+f1 = Foo(name="f1")
+f2 = Foo(name="f2")
g = UGraph() # create a new undirected graph
g.add_vertex(f1)
@@ -142,14 +185,52 @@ for f in f1.adjacent():
f.say_hi() # say hi to the neighbours
```
-## MATLAB version
-
-This is a re-engineered version of [PGraph.m](https://github.com/petercorke/spatialmath-matlab/blob/main/PGraph.m) which ships as part of the [Spatial Math Toolbox for MATLAB](https://github.com/petercorke/spatialmath-matlab). This class is used to support bundle adjustment, pose-graph SLAM and various planners such as PRM, RRT and Lattice.
-
-The Python version was designed from the start to work with directed and undirected graphs, whereas directed graphs were a late addition to the MATLAB version. Semantics are similar but not identical. In particular the use of subclassing rather than references to
-_user data_ is encouraged.
-
-
-
-
-
+Or tell the graph which classes to make, with `UGraph(vertex_class=Foo, edge_class=Bar)`, and `g.add_vertex()` and `g.add_edge()` create instances of them, so they never need to be constructed by hand.
+
+## Performance
+
+Timings of the key operations on random geometric graphs, each vertex joined to its 4 nearest neighbours, of 100, 1,000 and 10,000 vertices, measured with [`examples/benchmark.py`](https://github.com/petercorke/pgraph-python/blob/main/examples/benchmark.py) on an Apple M4 with Python 3.12 and NumPy 2.5. The path times are the median of 10 random start and goal pairs. The last column is the exponent *b* in time ∝ N*b* between the smallest and the largest graph for the quantity shown, so 0 is constant, 1 is linear and 2 is quadratic. The adjacency, Laplacian and incidence matrices are dense, so they are only formed up to 1,000 vertices.
+
+### Undirected graphs
+
+| Operation | per | N=100 | N=1,000 | N=10,000 | growth exponent |
+| :----------------------------------- | :---------- | -----: | ------: | -------: | --------------: |
+| add a vertex | µs / vertex | 1.52 | 1.65 | 1.59 | 0.0 |
+| add an edge (cost from the metric) | µs / edge | 1.55 | 1.58 | 1.85 | 0.0 |
+| copy the graph | ms | 0.823 | 8.17 | 151 | 1.1 |
+| pickle and unpickle the graph | ms | 0.456 | 4.76 | 113 | 1.2 |
+| number of components, after a change | ms | 0.0292 | 0.319 | 3.86 | 1.1 |
+| number of components, unchanged | µs | 0.083 | 0.083 | 0.083 | 0.0 |
+| remove a vertex | µs | 6.19 | 11.6 | 56.5 | 0.5 |
+| closest vertex to a point | µs / query | 69.9 | 708 | 7,056 | 1.0 |
+| adjacency matrix | ms | 0.107 | 1.46 | – | 1.1 |
+| Laplacian matrix | ms | 0.121 | 2.76 | – | 1.4 |
+| incidence matrix | ms | 0.0565 | 1.44 | – | 1.4 |
+| path, breadth-first | ms / query | 0.038 | 0.317 | 5.4 | 1.1 |
+| path, uniform cost | ms / query | 0.0822 | 0.806 | 11.7 | 1.1 |
+| path, A* | ms / query | 0.0498 | 0.295 | 2.48 | 0.8 |
+
+### Directed graphs
+
+| Operation | per | N=100 | N=1,000 | N=10,000 | growth exponent |
+| :----------------------------------- | :---------- | -----: | ------: | -------: | --------------: |
+| add a vertex | µs / vertex | 1.53 | 1.58 | 1.59 | 0.0 |
+| add an edge (cost from the metric) | µs / edge | 1.56 | 1.64 | 1.84 | 0.0 |
+| copy the graph | ms | 1.12 | 12.1 | 227 | 1.2 |
+| pickle and unpickle the graph | ms | 0.573 | 6.58 | 151 | 1.2 |
+| number of components, after a change | ms | 0.0405 | 0.485 | 5.87 | 1.1 |
+| number of components, unchanged | µs | 0.083 | 0.083 | 0.083 | 0.0 |
+| remove a vertex | µs | 8.04 | 15.7 | 70 | 0.5 |
+| closest vertex to a point | µs / query | 70.2 | 711 | 6,921 | 1.0 |
+| adjacency matrix | ms | 0.0815 | 1.21 | – | 1.2 |
+| Laplacian matrix | ms | 0.0928 | 2.64 | – | 1.5 |
+| incidence matrix | ms | 0.0871 | 2.41 | – | 1.4 |
+| path, breadth-first | ms / query | 0.0225 | 0.386 | 3.87 | 1.1 |
+| path, uniform cost | ms / query | 0.0536 | 0.936 | 8.06 | 1.1 |
+| path, A* | ms / query | 0.046 | 0.721 | 3.68 | 1.0 |
+
+## History
+
+This is a re-engineered version of [PGraph.m](https://github.com/petercorke/spatialmath-matlab/blob/main/PGraph.m) which ships as part of the [Spatial Math Toolbox for MATLAB](https://github.com/petercorke/spatialmath-matlab). That class was used to support bundle adjustment, pose-graph SLAM and various planners such as PRM, RRT and Lattice.
+
+The Python version was designed from the start to work with directed and undirected graphs, whereas directed graphs were a late addition to the MATLAB version. Semantics are similar but not identical. In particular the use of subclassing rather than references to _user data_ is encouraged.
diff --git a/docs/figs/london_route.png b/docs/figs/london_route.png
new file mode 100644
index 00000000..7e29a46f
Binary files /dev/null and b/docs/figs/london_route.png differ
diff --git a/docs/source/intro.rst b/docs/source/intro.rst
index 4b9aec8e..71da1c45 100644
--- a/docs/source/intro.rst
+++ b/docs/source/intro.rst
@@ -300,5 +300,5 @@ Alternatively, we can make the decision at runtime by using the ``directed`` arg
History
--------
-``pgaph-python`` is based on ``PGraph.m`` which is part of the
+``pgraph-python`` is based on ``PGraph.m`` which is part of the
earlier `Spatial Math Toolbox for MATLAB `_.
diff --git a/examples/roads.png b/examples/roads.png
index 4938b223..a8e95a4b 100644
Binary files a/examples/roads.png and b/examples/roads.png differ