Skip to content

Commit f20c60f

Browse files
awesomebytesclaude
andcommitted
Merge ompl_kit spike: third kit, cross-inheritance proven
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents 3dc5995 + ddaeeee commit f20c60f

9 files changed

Lines changed: 1643 additions & 1 deletion

File tree

docs/ompl_kit/OMPL_KIT.md

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# ompl_kit — cheat sheet for a coding agent
2+
3+
You are writing Python that drives the **Open Motion Planning Library (OMPL)** — a
4+
C++ sampling-based motion planner — through `rclcppyy.kits.ompl_kit`. The kit
5+
**mirrors OMPL's C++ API**: `bringup_ompl()` returns the real `ompl::base` /
6+
`ompl::geometric` namespaces (the conventional `ob` / `og`) and you use
7+
`ob.RealVectorStateSpace`, `og.SimpleSetup`, `og.RRTConnect`,
8+
`setStateValidityChecker`, `setStartAndGoalStates`, `solve`, `getSolutionPath`
9+
exactly as in the OMPL C++ tutorials. The kit only removes the cppyy friction
10+
(bringup, the validity `std::function` signature, the `as` keyword, RNG seeding,
11+
path extraction). You do **not** need to know cppyy.
12+
13+
(For *why* this exists and the C++-vs-Python comparison, see [WHY.md](WHY.md); for
14+
the cross-inheritance mechanics and benchmarks, see [REPORT.md](REPORT.md).)
15+
16+
**Requires** the `ompl` pixi env: `pixi run -e ompl python your_script.py`.
17+
18+
**Golden rules**
19+
- Call `ob, og = ompl_kit.bringup_ompl()` once; it returns the base and geometric
20+
namespaces. Pass `with_geometric=False` if you only need state spaces (skips the
21+
~60 ms planner JIT). Bringup is idempotent (~0.5 s, once).
22+
- Wrap a raw space/planner/checker in the library `Ptr` to hand it to OMPL:
23+
`ob.StateSpacePtr(space)`, `ob.PlannerPtr(planner)`,
24+
`ob.StateValidityCheckerPtr(checker)`. The wrap **transfers ownership** — safe, no
25+
double-free.
26+
- The validity checker is the planner's inner-loop callback. Two ways: a Python
27+
function via `ompl_kit.validity_checker(fn)`, or a Python subclass of
28+
`ob.StateValidityChecker`. Both are shown below.
29+
- Inside a validity/cost callback, cppyy **auto-downcasts** the state, so
30+
`state[0]` / `state[1]` read a `RealVectorStateSpace`'s coordinates directly.
31+
- **Pin** any Python subclass instance you hand to C++ (`owner=` /
32+
`cppyy_kit.keep_alive`), or it is collected and the next call raises "callable was
33+
deleted".
34+
- Seed with `ompl_kit.set_seed(n)` **before** solving; the global RNG can't be
35+
re-seeded mid-process (use a fresh process per seed).
36+
37+
---
38+
39+
## Pattern 1 — a 2D plan, validity checker as a Python function (the minimal path)
40+
*Use for:* planning where the validity/obstacle logic is a plain function.
41+
42+
```python
43+
from rclcppyy.kits import ompl_kit
44+
ob, og = ompl_kit.bringup_ompl()
45+
46+
def is_state_valid(state): # planner's inner-loop callback
47+
return (state[0]-0.5)**2 + (state[1]-0.5)**2 > 0.25**2 # outside a circle
48+
49+
space = ob.RealVectorStateSpace(2) # OMPL's own API, verbatim
50+
bounds = ob.RealVectorBounds(2)
51+
bounds.setLow(0.0); bounds.setHigh(1.0)
52+
space.setBounds(bounds)
53+
54+
ss = og.SimpleSetup(ob.StateSpacePtr(space))
55+
ss.setStateValidityChecker(ompl_kit.validity_checker(is_state_valid, owner=ss))
56+
57+
start = ob.ScopedState[ob.RealVectorStateSpace](ss.getStateSpace())
58+
start[0], start[1] = 0.1, 0.1
59+
goal = ob.ScopedState[ob.RealVectorStateSpace](ss.getStateSpace())
60+
goal[0], goal[1] = 0.9, 0.9
61+
ss.setStartAndGoalStates(start, goal)
62+
ss.setPlanner(ob.PlannerPtr(og.RRTConnect(ss.getSpaceInformation())))
63+
64+
if ss.solve(1.0):
65+
ss.simplifySolution()
66+
print(ompl_kit.path_to_list(ss.getSolutionPath(), dim=2)) # [(x,y), ...]
67+
```
68+
`validity_checker(fn, owner=ss)` wraps `fn` as OMPL's
69+
`std::function<bool(const State*)>` and pins it on `ss`. See
70+
`scripts/ompl_kit_demos/d01_first_plan.py`.
71+
72+
---
73+
74+
## Pattern 2 — validity checker as a Python subclass (cross-inheritance)
75+
*Use for:* an OMPL-idiomatic checker, or one that holds state / queries a map.
76+
77+
```python
78+
from rclcppyy.kits import ompl_kit, cppyy_kit
79+
ob, og = ompl_kit.bringup_ompl()
80+
81+
class CircleChecker(ob.StateValidityChecker):
82+
def __init__(self, si):
83+
super().__init__(si) # REQUIRED: chain the C++ base ctor
84+
self.calls = 0
85+
def isValid(self, state): # override the C++ virtual by name
86+
self.calls += 1
87+
return (state[0]-0.5)**2 + (state[1]-0.5)**2 > 0.25**2
88+
89+
# ... build space + ss as in Pattern 1 ...
90+
checker = CircleChecker(ss.getSpaceInformation())
91+
cppyy_kit.keep_alive(ss, checker) # PIN it (footgun otherwise)
92+
ss.setStateValidityChecker(ob.StateValidityCheckerPtr(checker))
93+
```
94+
The C++ planner calls the Python `isValid` through the vtable. Works because
95+
`isValid` is a *plain* virtual. Cost: ~345 ns/call (~190x a native checker) — fine
96+
until validity dominates, then lower it (Pattern 5).
97+
98+
---
99+
100+
## Pattern 3 — optimal planning with a Python OptimizationObjective (RRT\*)
101+
*Use for:* minimizing a custom cost (path length, clearance, energy) with RRT\* /
102+
an optimizing planner. Same cross-inheritance shape, two virtuals.
103+
104+
```python
105+
class PathLength(ob.OptimizationObjective):
106+
def __init__(self, si):
107+
super().__init__(si); self.si = si
108+
def stateCost(self, s): return ob.Cost(1.0)
109+
def motionCost(self, s1, s2): return ob.Cost(self.si.distance(s1, s2))
110+
111+
obj = PathLength(si)
112+
cppyy_kit.keep_alive(ss, obj)
113+
ss.setOptimizationObjective(ob.OptimizationObjectivePtr(obj))
114+
ss.setPlanner(ob.PlannerPtr(og.RRTstar(si)))
115+
ss.solve(1.0) # motionCost called ~1M times/s
116+
```
117+
118+
---
119+
120+
## Pattern 4 — a compound state space (SE2/SE3) (explicit downcast)
121+
*Use for:* poses with orientation. The auto-downcast covers `RealVectorStateSpace`;
122+
for a compound space, cast explicitly with `as_state` (Python can't write `.as`).
123+
124+
```python
125+
def is_valid(state):
126+
se2 = ompl_kit.as_state(state, ob.SE2StateSpace.StateType)
127+
return in_free_space(se2.getX(), se2.getY(), se2.getYaw())
128+
```
129+
`bringup_ompl` pre-includes SE2/SE3 headers, so `ob.SE2StateSpace` is ready.
130+
131+
---
132+
133+
## Pattern 5 — lower the hot checker to native C++ (when validity dominates)
134+
*Use for:* a planner that calls the checker millions of times (RRT\*, long solves).
135+
Prototype in Python (Patterns 1–3), then lower that one function to C++ — same OMPL
136+
calls, ~150x faster inner loop.
137+
138+
```python
139+
import cppyy
140+
from rclcppyy.kits import ompl_kit, cppyy_kit
141+
ob, og = ompl_kit.bringup_ompl()
142+
143+
cppyy.cppdef(r"""
144+
class CppCircleChecker : public ompl::base::StateValidityChecker {
145+
public:
146+
CppCircleChecker(const ompl::base::SpaceInformationPtr& si)
147+
: ompl::base::StateValidityChecker(si) {}
148+
bool isValid(const ompl::base::State* s) const override {
149+
const auto* rv = s->as<ompl::base::RealVectorStateSpace::StateType>();
150+
double x = (*rv)[0], y = (*rv)[1];
151+
return (x-0.5)*(x-0.5) + (y-0.5)*(y-0.5) > 0.25*0.25; // never leaves C++
152+
}
153+
};
154+
""")
155+
checker = cppyy.gbl.CppCircleChecker(ss.getSpaceInformation())
156+
cppyy_kit.keep_alive(ss, checker)
157+
ss.setStateValidityChecker(ob.StateValidityCheckerPtr(checker))
158+
```
159+
`bench_ompl_validity.py` (`pixi run -e ompl bench-ompl`) measures Python vs this.
160+
161+
---
162+
163+
## Pattern 6 — publish a plan as a ROS 2 nav_msgs/Path
164+
*Use for:* feeding a plan to RViz / a navigation stack. Build the **C++** message.
165+
166+
```python
167+
import os; os.environ.setdefault("ROS_DOMAIN_ID", "45")
168+
import cppyy
169+
from rclcppyy.bringup_rclcpp import bringup_rclcpp, add_ros2_include_paths
170+
from rclcppyy.kits import ompl_kit
171+
172+
waypoints = [...] # from ompl_kit.path_to_list(...)
173+
rclcpp = bringup_rclcpp()
174+
if not rclcpp.ok(): rclcpp.init()
175+
from nav_msgs.msg import Path # Python type registers the topic
176+
177+
add_ros2_include_paths()
178+
cppyy.include("nav_msgs/msg/path.hpp"); cppyy.include("geometry_msgs/msg/pose_stamped.hpp")
179+
msg = cppyy.gbl.nav_msgs.msg.Path() # the C++ message; poses is a vector
180+
msg.header.frame_id = "map"
181+
for x, y in waypoints:
182+
pose = cppyy.gbl.geometry_msgs.msg.PoseStamped()
183+
pose.pose.position.x, pose.pose.position.y = float(x), float(y)
184+
pose.pose.orientation.w = 1.0
185+
msg.poses.push_back(pose)
186+
187+
node = rclcpp.Node("planner")
188+
node.create_publisher(Path, "plan", 10).publish(msg)
189+
```
190+
See `scripts/ompl_kit_demos/d02_publish_path.py`.
191+
192+
---
193+
194+
## Gotchas (short version)
195+
- **Pin** any Python subclass instance (checker/objective) you hand to C++, or the
196+
next dispatch raises `TypeError: callable was deleted`. `validity_checker(owner=)`
197+
does it for you; a raw subclass is yours to `keep_alive`.
198+
- **`validity_checker` needs no hint** — the kit fixes the `bool(const State*)`
199+
signature. Don't rely on `cppyy_kit.callback`'s inference here: it would infer a
200+
`State&` reference, which won't bind to `setStateValidityChecker`.
201+
- Inside a callback, `state[0]`/`state[1]` work for `RealVectorStateSpace`
202+
(auto-downcast); a compound space needs `ompl_kit.as_state(state, ...StateType)`.
203+
- **Seed before solving** with `ompl_kit.set_seed(n)`; a seed set after the first
204+
sample is ignored (OMPL warns). Re-seeding in one process is unreliable — use a
205+
fresh process per reproducible run.
206+
- Wrap raws in the library `Ptr` (`ob.StateSpacePtr`, `ob.PlannerPtr`) to pass them
207+
to OMPL; the wrap transfers ownership (no double-free).
208+
- `path_to_list(path, dim)` needs the space dimension (the path doesn't carry it),
209+
and reads real-vector coordinates; read compound waypoints via `as_state`.
210+
- Only RRTConnect/RRTstar are pre-included; any other planner is one
211+
`cppyy.include("ompl/geometric/planners/.../X.h")` away, then `og.X`.

0 commit comments

Comments
 (0)