Skip to content

Commit 849c456

Browse files
authored
Merge pull request #58 from petercorke/feat/se2-metric
feat: SE2Metric, a weight on the angle of the SE2 metric
2 parents 3b50b04 + 4990b85 commit 849c456

5 files changed

Lines changed: 267 additions & 14 deletions

File tree

‎docs/source/construction.rst‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,34 @@ Copying a vertex
4343

4444
.. automethod:: pgraph.PGraph.Vertex.copy
4545

46+
Distance metrics
47+
----------------
48+
49+
The cost of an edge, the nearest vertex to a point and the heuristic of the A*
50+
planner all come from a distance metric, a function of the difference between
51+
two coordinates. It is chosen by name, ``"L2"`` (the default, Euclidean),
52+
``"L1"`` (Manhattan) or ``"SE2"``, or you can supply your own function; see
53+
:attr:`~pgraph.PGraph.Graph.metric` and :attr:`~pgraph.PGraph.Graph.heuristic`.
54+
55+
The ``"SE2"`` metric is for poses :math:`(x, y, \theta)` and adds a position
56+
in metres to an angle in radians, which are not the same kind of quantity. It
57+
counts a radian as a metre. If a turn should count for more or less, use
58+
:class:`~pgraph.PGraph.SE2Metric` with a weight, which is the length that a
59+
radian is worth. Below, one vertex is 2 m ahead and facing the same way, and
60+
another is at our position but turned through 1.5 radians. With a weight of 1 the
61+
turn costs 1.5, so the turned vertex is the nearer; with a weight of 2 it costs 3,
62+
and the vertex ahead is nearer
63+
64+
.. runblock:: pycon
65+
66+
>>> from pgraph import UGraph, SE2Metric
67+
>>> for weight in (1.0, 2.0):
68+
... g = UGraph(dim=3, metric=SE2Metric(weight=weight))
69+
... v = g.add_vertex([2, 0, 0], name="ahead")
70+
... v = g.add_vertex([0, 0, 1.5], name="turned")
71+
... vertex, distance = g.closest([0, 0, 0])
72+
... print(weight, vertex.name, distance)
73+
74+
.. autoclass:: pgraph.PGraph.SE2Metric
75+
4676

‎docs/source/intro.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ parallel edges are permitted.
77

88
Vertices can optionally be *embedded*: given a coordinate in a space of any
99
dimension. Edge costs are then computed from the vertex positions using a
10-
distance metric (Euclidean, Manhattan, SE(2), or your own), the vertex nearest
10+
distance metric (Euclidean, Manhattan, SE(2) with a weight on the angle, or your own), the vertex nearest
1111
a point can be found, ``A*`` uses the metric as its heuristic, and the graph can
1212
be plotted, or exported with its layout preserved.
1313

‎src/pgraph/PGraph.py‎

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import math
34
import os
45
import sys
56
import warnings
@@ -31,23 +32,83 @@ def _L2(v: NDArray) -> float:
3132
return float(np.linalg.norm(v))
3233

3334

34-
def _SE2(v: NDArray) -> float:
35-
"""Mixed norm for (x, y, theta), the built-in ``"SE2"`` metric"""
36-
if len(v) != 3:
37-
raise ValueError(
38-
f"SE2 metric requires a 3-element (x, y, theta) vector, got length {len(v)}"
39-
)
40-
# wrap angle to range [-pi, pi)
41-
v[2] = (v[2] + np.pi) % (2 * np.pi) - np.pi
42-
return float(np.linalg.norm(v))
35+
class SE2Metric:
36+
r"""
37+
Distance between planar poses, a weighted mixed norm
38+
39+
:param weight: length that one radian of turn counts for, defaults to 1
40+
:raises ValueError: ``weight`` is negative or not finite
41+
42+
The metric of a vector :math:`(x, y, \theta)`, the difference between two
43+
poses, is
44+
45+
.. math::
46+
47+
d = \sqrt{x^2 + y^2 + (w \bar{\theta})^2}
48+
49+
where :math:`\bar{\theta}` is :math:`\theta` wrapped to the interval
50+
:math:`[-\pi, \pi)` and :math:`w` is the ``weight``.
51+
52+
Distance and angle have different units, so their relative importance is
53+
a choice. The weight is the conversion: it is how much length a turn of one
54+
radian is worth, so a turn of :math:`\theta` costs as much as moving
55+
:math:`w \theta`. A weight of 1 treats a radian as a metre, which is what
56+
the string ``"SE2"`` means. A characteristic length of the vehicle, such as
57+
its turning radius, is a natural choice. With a weight of 0 the
58+
orientation is ignored and this is the distance in the plane.
59+
60+
An instance is a metric for a graph, and can be used as its heuristic too::
61+
62+
g = UGraph(dim=3, metric=SE2Metric(weight=0.5))
63+
64+
.. runblock:: pycon
65+
66+
>>> from pgraph import SE2Metric
67+
>>> import numpy as np
68+
>>> v = np.r_[3, 4, np.pi / 2] # 3 east, 4 north, a quarter turn
69+
>>> SE2Metric()(v)
70+
>>> SE2Metric(weight=0.5)(v)
71+
>>> SE2Metric(weight=0)(v)
72+
73+
A metric with a weight is still a metric, so it is a consistent heuristic
74+
for A* when the edge costs are computed with it. If edge costs come from
75+
somewhere else, use a weight low enough, or zero, for the heuristic never to
76+
exceed the cost to go.
77+
78+
:seealso: :meth:`Graph.metric` :meth:`Graph.heuristic`
79+
"""
80+
81+
def __init__(self, weight: float = 1.0):
82+
weight = float(weight)
83+
if not math.isfinite(weight) or weight < 0:
84+
raise ValueError(f"weight must be finite and not negative, got {weight!r}")
85+
self.weight = weight
86+
87+
def __call__(self, v: NDArray) -> float:
88+
if len(v) != 3:
89+
raise ValueError(
90+
f"SE2 metric requires a 3-element (x, y, theta) vector, got length {len(v)}"
91+
)
92+
# wrap the angle to the range [-pi, pi), without changing the argument
93+
theta = (float(v[2]) + math.pi) % (2 * math.pi) - math.pi
94+
return math.sqrt(float(v[0]) ** 2 + float(v[1]) ** 2 + (self.weight * theta) ** 2)
95+
96+
def __repr__(self) -> str:
97+
return f"SE2Metric(weight={self.weight:g})"
98+
99+
def __eq__(self, other: object) -> bool:
100+
return isinstance(other, SE2Metric) and other.weight == self.weight
101+
102+
def __hash__(self) -> int:
103+
return hash(("SE2Metric", self.weight))
43104

44105

45106
#: the built-in metrics by name. They are module level, and pickled by name,
46107
#: so that a graph using one can be pickled.
47108
_METRICS: dict[str, Callable[[NDArray], float]] = {
48109
"L1": _L1,
49110
"L2": _L2,
50-
"SE2": _SE2,
111+
"SE2": SE2Metric(),
51112
}
52113

53114

@@ -796,7 +857,8 @@ def copy(self) -> Graph:
796857
.. note::
797858
798859
- A metric or heuristic given by name (``"L1"``, ``"L2"``,
799-
``"SE2"``) is pickled by name. A function you supply must be
860+
``"SE2"``) is pickled by name, and an :class:`SE2Metric` with
861+
its weight. A function you supply must be
800862
importable at module level to be pickled; a ``lambda`` or a
801863
function defined inside another function can be copied but not
802864
pickled.
@@ -1556,7 +1618,10 @@ def metric(self, metric: Callable[[NDArray], float] | str) -> None:
15561618
is :math:`\theta` wrapped to the interval :math:`[-\pi, \pi)`.
15571619
Requires every coordinate involved -- vertex ``coord`` and any
15581620
point passed to :meth:`closest`/:meth:`Vertex.distance` -- to be
1559-
exactly 3 elements; raises :exc:`ValueError` otherwise.
1621+
exactly 3 elements; raises :exc:`ValueError` otherwise. Radians
1622+
and metres are weighted equally; to change that pass
1623+
:class:`SE2Metric` with a ``weight``, as in
1624+
``g.metric = SE2Metric(weight=0.5)``.
15601625
15611626
The metric is used by :meth:`closest` and :meth:`distance`
15621627
@@ -1567,6 +1632,16 @@ def metric(self, metric: Callable[[NDArray], float] | str) -> None:
15671632
>>> g = UGraph()
15681633
>>> g.metric = 'L1'
15691634
>>> print(g.metric(np.r_[3, -4]))
1635+
1636+
A pose metric whose orientation counts for less than its position
1637+
1638+
.. runblock:: pycon
1639+
1640+
>>> from pgraph import UGraph, SE2Metric
1641+
>>> import numpy as np
1642+
>>> g = UGraph(dim=3)
1643+
>>> g.metric = SE2Metric(weight=0.5)
1644+
>>> print(g.metric(np.r_[3, 4, np.pi / 2]))
15701645
"""
15711646
self._metric = self._metricfunc(metric)
15721647

@@ -1602,7 +1677,10 @@ def heuristic(self, heuristic: Callable[[NDArray], float] | str) -> None:
16021677
is :math:`\theta` wrapped to the interval :math:`[-\pi, \pi)`.
16031678
Requires every coordinate involved -- vertex ``coord`` and any
16041679
point passed to :meth:`closest`/:meth:`Vertex.distance` -- to be
1605-
exactly 3 elements; raises :exc:`ValueError` otherwise.
1680+
exactly 3 elements; raises :exc:`ValueError` otherwise. Radians
1681+
and metres are weighted equally; to change that pass
1682+
:class:`SE2Metric` with a ``weight``, as in
1683+
``g.metric = SE2Metric(weight=0.5)``.
16061684
16071685
The heuristic distance is only used by the A* planner :meth:`path_Astar`.
16081686
@@ -1613,6 +1691,10 @@ def heuristic(self, heuristic: Callable[[NDArray], float] | str) -> None:
16131691
>>> g = UGraph()
16141692
>>> g.heuristic = 'L2'
16151693
>>> print(g.heuristic(np.r_[3, 4]))
1694+
1695+
The heuristic of A* must not exceed the true cost to go. For poses
1696+
whose costs are the planar distance travelled, ignore the orientation
1697+
with ``SE2Metric(weight=0)``.
16161698
"""
16171699
self._heuristic = self._metricfunc(heuristic)
16181700

‎src/pgraph/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
UGraph,
88
DGraph,
99
Edge,
10+
SE2Metric,
1011
)
1112

1213
__all__ = [
@@ -15,6 +16,7 @@
1516
"UGraph",
1617
"DGraph",
1718
"Edge",
19+
"SE2Metric",
1820
]
1921

2022

‎tests/test_se2_metric.py‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""
2+
The ``SE2Metric`` of a pose difference (x, y, theta) and its weight on the
3+
angle, in graphs: edge costs, ``closest``, the A* heuristic, copying and
4+
pickling.
5+
6+
Expectations are worked out by hand.
7+
"""
8+
9+
import copy
10+
import pickle # nosec B403 - only used to round-trip objects made in this file
11+
import unittest
12+
13+
import numpy as np
14+
15+
from pgraph import DGraph, SE2Metric, UGraph
16+
17+
18+
class TestMetric(unittest.TestCase):
19+
def test_default_weight_is_the_string_se2(self):
20+
v = np.r_[3.0, 4.0, 1.0]
21+
self.assertEqual(SE2Metric()(v), UGraph(metric="SE2").metric(v))
22+
self.assertAlmostEqual(SE2Metric()(v), np.sqrt(26)) # 9 + 16 + 1
23+
24+
def test_weight_scales_the_angle_only(self):
25+
v = np.r_[3.0, 4.0, np.pi / 2]
26+
self.assertAlmostEqual(SE2Metric(1)(v), np.sqrt(25 + (np.pi / 2) ** 2))
27+
self.assertAlmostEqual(SE2Metric(2)(v), np.sqrt(25 + np.pi**2))
28+
self.assertAlmostEqual(SE2Metric(0.5)(v), np.sqrt(25 + (np.pi / 4) ** 2))
29+
self.assertAlmostEqual(SE2Metric(0)(v), 5.0)
30+
31+
def test_angle_is_wrapped_before_weighting(self):
32+
for turns in (-2, -1, 0, 1, 2):
33+
with self.subTest(turns=turns):
34+
v = np.r_[0.0, 0.0, 0.5 + turns * 2 * np.pi]
35+
self.assertAlmostEqual(SE2Metric(3)(v), 1.5)
36+
# just past pi is a turn the other way
37+
self.assertAlmostEqual(SE2Metric(1)(np.r_[0.0, 0.0, np.pi + 0.1]), np.pi - 0.1)
38+
39+
def test_a_pure_turn_is_worth_the_weight_times_the_angle(self):
40+
self.assertAlmostEqual(SE2Metric(0.25)(np.r_[0.0, 0.0, 2.0]), 0.5)
41+
42+
def test_the_argument_is_not_changed(self):
43+
v = np.r_[1.0, 2.0, 7.0] # needs wrapping
44+
before = v.copy()
45+
SE2Metric(0.5)(v)
46+
np.testing.assert_array_equal(v, before)
47+
48+
def test_a_list_is_accepted(self):
49+
self.assertAlmostEqual(SE2Metric(2)([0, 0, 1]), 2.0)
50+
51+
def test_needs_three_elements(self):
52+
for v in ([1.0, 2.0], [1.0, 2.0, 3.0, 4.0]):
53+
with self.subTest(length=len(v)):
54+
with self.assertRaises(ValueError):
55+
SE2Metric()(np.array(v))
56+
57+
def test_bad_weights(self):
58+
for weight in (-1, np.inf, np.nan):
59+
with self.subTest(weight=weight):
60+
with self.assertRaises(ValueError):
61+
SE2Metric(weight)
62+
63+
def test_repr_equality_and_hash(self):
64+
self.assertEqual(repr(SE2Metric(0.5)), "SE2Metric(weight=0.5)")
65+
self.assertEqual(SE2Metric(0.5), SE2Metric(0.5))
66+
self.assertNotEqual(SE2Metric(0.5), SE2Metric(2))
67+
self.assertEqual(len({SE2Metric(0.5), SE2Metric(0.5), SE2Metric(2)}), 2)
68+
self.assertEqual(SE2Metric(), UGraph(metric="SE2").metric)
69+
70+
71+
class TestInGraphs(unittest.TestCase):
72+
cls = UGraph
73+
74+
def test_edge_cost_uses_the_weight(self):
75+
g = self.cls(dim=3, metric=SE2Metric(weight=2))
76+
a = g.add_vertex([0.0, 0.0, 0.0])
77+
b = g.add_vertex([0.0, 0.0, 0.5])
78+
self.assertAlmostEqual(g.add_edge(a, b).cost, 1.0) # 2 * 0.5
79+
80+
def test_closest_depends_on_the_weight(self):
81+
# p is 1 away in the plane and no turn; q is at the same place but
82+
# turned by 1 rad: nearer than p only if turning is cheap enough
83+
def nearest(weight):
84+
g = self.cls(dim=3, metric=SE2Metric(weight=weight))
85+
g.add_vertex([2.0, 0.0, 0.0], name="p") # 2 away, no turn
86+
g.add_vertex([0.0, 0.0, 1.5], name="q") # no distance, 1.5 rad
87+
return g.closest([0.0, 0.0, 0.0])
88+
89+
v, d = nearest(1.0) # q is 1.5 away, p is 2 away
90+
self.assertEqual((v.name, round(d, 6)), ("q", 1.5))
91+
v, d = nearest(2.0) # q is 3 away, p is 2 away
92+
self.assertEqual((v.name, round(d, 6)), ("p", 2.0))
93+
v, d = nearest(0.0) # q is 0 away
94+
self.assertEqual((v.name, round(d, 6)), ("q", 0.0))
95+
96+
def test_metric_can_be_set_after(self):
97+
g = self.cls(dim=3)
98+
g.metric = SE2Metric(weight=3)
99+
self.assertAlmostEqual(g.metric(np.r_[0.0, 0.0, 1.0]), 3.0)
100+
101+
def test_astar_with_the_weighted_metric_is_optimal(self):
102+
# heuristic defaults to the metric, which makes the edge costs and the
103+
# heuristic consistent; compare with the uniform cost search
104+
rng = np.random.default_rng(5)
105+
for weight in (0.0, 0.3, 1.0, 4.0):
106+
with self.subTest(weight=weight):
107+
g = self.cls(dim=3, metric=SE2Metric(weight=weight), heuristic=SE2Metric(weight=weight))
108+
vs = [g.add_vertex([*rng.uniform(0, 10, 2), rng.uniform(-3, 3)]) for _ in range(30)]
109+
for i in range(30):
110+
for j in rng.choice(30, 3, replace=False):
111+
if i != j:
112+
g.add_edge(vs[i], vs[int(j)])
113+
for s, t in [(0, 29), (3, 17), (10, 25)]:
114+
ucs = g.path_UCS(vs[s], vs[t])
115+
astar = g.path_Astar(vs[s], vs[t])
116+
self.assertEqual(ucs is None, astar is None)
117+
if ucs is not None:
118+
self.assertAlmostEqual(astar[1], ucs[1])
119+
120+
def test_pickle_and_copy_keep_the_weight(self):
121+
g = self.cls(dim=3, metric=SE2Metric(weight=0.25), heuristic=SE2Metric(weight=0))
122+
g.add_vertex([0.0, 0.0, 0.0])
123+
for h in (pickle.loads(pickle.dumps(g)), g.copy(), copy.deepcopy(g)): # nosec B301
124+
self.assertEqual(h.metric, SE2Metric(0.25))
125+
self.assertEqual(h.heuristic, SE2Metric(0))
126+
h = pickle.loads(pickle.dumps(self.cls(metric="SE2"))) # nosec B301
127+
self.assertEqual(h.metric, SE2Metric())
128+
129+
def test_the_named_metric_is_still_pickled_by_name(self):
130+
g = self.cls(metric="SE2")
131+
self.assertIs(pickle.loads(pickle.dumps(g)).metric, g.metric) # nosec B301
132+
133+
134+
class TestInDirectedGraphs(TestInGraphs):
135+
cls = DGraph
136+
137+
138+
if __name__ == "__main__":
139+
unittest.main()

0 commit comments

Comments
 (0)