Skip to main content

cu_rrt_star/
lib.rs

1//! An RRT* path planner as a Copper anytime task.
2//!
3//! [`RrtStarPlanner`] consumes a [`PlanRequest`] and publishes a [`PlanPath`].
4//! `base()` grows the tree until it has a first, crude path; every `refine()`
5//! runs one more block of RRT* iterations and republishes only when the path
6//! got shorter. The task reports how good the path is; the RON `anytime:`
7//! policy decides how long to keep going.
8
9pub mod rrt;
10
11pub use cu_spatial_payloads::{BBox2f, Point2f};
12pub use rrt::{
13    Clearance, MAX_NODES, MAX_OBSTACLES, MAX_WAYPOINTS, Obstacle, PlanPoint, PointSet, RrtParams,
14    RrtSpace, RrtStar, World,
15};
16
17use bincode::de::Decoder;
18use bincode::enc::Encoder;
19use bincode::error::{DecodeError, EncodeError};
20use bincode::{Decode, Encode};
21use cu_rng::prelude::*;
22use cu29::cutask_anytime::{AnytimeStatus, CuAnytimeTask, Quality, quality_from_f32};
23use cu29::prelude::*;
24use cu29::units::si::f32::Length;
25use serde::{Deserialize, Serialize};
26
27/// Quality at which the path matches the straight-line lower bound: there is
28/// nothing left to refine.
29const CONVERGED_QUALITY: f32 = 0.999;
30
31/// True once the published quality leaves nothing to refine.
32fn converged(quality: Quality) -> bool {
33    quality.raw() >= CONVERGED_QUALITY
34}
35
36/// One planning problem. The map travels with the job, so the source owns it
37/// and may change it between jobs.
38#[derive(Default, Debug, Clone, Encode, Decode, Serialize, Deserialize, Reflect)]
39pub struct PlanRequest {
40    pub world: World,
41    pub start: Point2f,
42    pub goal: Point2f,
43}
44
45/// The best path known when the refinement window closed.
46///
47/// Every consecutive pair of waypoints is collision free, so the path can be
48/// driven as published.
49#[derive(Default, Debug, Clone, Encode, Decode, Serialize, Deserialize, Reflect)]
50pub struct PlanPath {
51    pub waypoints: [Point2f; MAX_WAYPOINTS],
52    /// Waypoints actually used in `waypoints`.
53    pub len: u32,
54    /// Cost of the RRT* tree path, which is what the anytime quality scores.
55    /// The published waypoints are a shortcut of it, so this is an upper bound
56    /// on the distance actually driven.
57    pub cost: Length,
58    /// RRT* iterations spent on this path, base block included.
59    pub iterations: u32,
60}
61
62/// What a remote debugger sees of a planner node: the progress of the job,
63/// not the thousands of tree nodes behind it.
64///
65/// Always built, not only under a debug feature: the anytime task's debug
66/// hooks are plain trait methods with no `cfg` on them.
67#[derive(Default, Debug, Reflect)]
68pub struct PlannerDebugState {
69    pub iterations: u32,
70    pub tree_size: u32,
71    /// Nodes on the best path before it is shortcut for publication.
72    pub tree_path_len: u32,
73    /// Cost of the best path in the tree, infinite while there is none.
74    pub best_cost: Length,
75    /// Cost of the path currently in the output.
76    pub published_cost: Length,
77    pub published_quality: Quality,
78}
79
80mod planner_resources {
81    use super::*;
82    resources!({ rng => Owned<CuRng> });
83}
84
85/// An RRT* planner as an anytime task.
86///
87/// `base()` runs the first block of iterations and publishes the first path it
88/// finds; each `refine()` runs one more block and republishes only when the
89/// path got shorter. How many blocks run is the policy's call, not the task's.
90///
91/// Randomness comes from a `cu_rng::CuRngBundle` resource: job N's stream is
92/// a pure function of the resource seed and N, so the same seed replays the
93/// same trees.
94#[derive(Reflect)]
95pub struct RrtStarPlanner {
96    params: RrtParams,
97    /// Iterations of the base block, aiming at a first path.
98    base_iterations: u32,
99    /// Iterations of one refinement quantum.
100    block_iterations: u32,
101    /// One draw from the node's RNG resource, taken at construction; every
102    /// job's stream derives from it.
103    base_seed: u64,
104    /// Jobs started so far, part of the frozen state: replay reruns job N on
105    /// the stream job N used live.
106    job_counter: u64,
107    /// The current job, `None` before the first `base()`.
108    #[reflect(ignore)]
109    planner: Option<RrtStar>,
110    /// Cost of the path currently in the output; infinite while none was
111    /// published for this job.
112    published_cost: Length,
113    published_quality: Quality,
114}
115
116// The search state is per-job and re-initialized by `base()` at the start of
117// every copperlist; what must survive a keyframe is the seeding state.
118impl Freezable for RrtStarPlanner {
119    fn freeze<E: Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
120        Encode::encode(&self.base_seed, encoder)?;
121        Encode::encode(&self.job_counter, encoder)
122    }
123
124    fn thaw<D: Decoder>(&mut self, decoder: &mut D) -> Result<(), DecodeError> {
125        self.base_seed = Decode::decode(decoder)?;
126        self.job_counter = Decode::decode(decoder)?;
127        Ok(())
128    }
129}
130
131impl RrtStarPlanner {
132    /// Commits the best path of the tree when it beats the published one, and
133    /// returns the published quality. Leaving the output alone when nothing
134    /// improved is what the anytime contract asks for: the output always holds
135    /// the best result so far, so the runtime can publish it at any stop point.
136    fn publish(&mut self, output: &mut CuMsg<PlanPath>) -> Quality {
137        if let Some(planner) = self.planner.as_ref()
138            && planner.has_solution()
139            && planner.best_cost() < self.published_cost
140        {
141            let mut waypoints = [Point2f::default(); MAX_WAYPOINTS];
142            // A path too long to represent is not published: the output keeps
143            // the last valid one and a later quantum tries again.
144            if let Some(len) = planner.write_path(&mut waypoints) {
145                output.set_payload(PlanPath {
146                    waypoints,
147                    len,
148                    cost: planner.best_cost(),
149                    iterations: planner.iterations(),
150                });
151                self.published_cost = planner.best_cost();
152                self.published_quality = planner.quality();
153            }
154        }
155        self.published_quality
156    }
157
158    /// The projected view a debug session gets instead of the whole tree.
159    fn debug_state(&self) -> PlannerDebugState {
160        let planner = self.planner.as_ref();
161        PlannerDebugState {
162            iterations: planner.map_or(0, RrtStar::iterations),
163            tree_size: planner.map_or(0, RrtStar::tree_size),
164            tree_path_len: planner.map_or(0, |p| p.tree_path_len() as u32),
165            best_cost: planner.map_or(rrt::meters(f32::INFINITY), RrtStar::best_cost),
166            published_cost: self.published_cost,
167            published_quality: self.published_quality,
168        }
169    }
170}
171
172impl CuAnytimeTask for RrtStarPlanner {
173    type Input<'m> = input_msg!(PlanRequest);
174    type Output<'m> = output_msg!(PlanPath);
175    type Resources<'r> = planner_resources::Resources;
176    type Quality = Quality;
177
178    // The task struct holds a whole RRT* tree, up to `max_nodes` entries. The
179    // default hooks would ship all of it on every debug read, so the node
180    // exposes a small view instead.
181    fn register_debug_state_types(registry: &mut TypeRegistry) {
182        registry.register::<PlannerDebugState>();
183    }
184
185    fn debug_state_type_path() -> &'static str {
186        PlannerDebugState::type_path()
187    }
188
189    fn with_debug_state<R>(&self, f: impl FnOnce(&dyn bevy_reflect::Reflect) -> R) -> R {
190        f(&self.debug_state())
191    }
192
193    fn new(config: Option<&ComponentConfig>, resources: Self::Resources<'_>) -> CuResult<Self> {
194        let mut params = RrtParams::default();
195        let mut base_iterations = 400u32;
196        let mut block_iterations = 256u32;
197        if let Some(config) = config {
198            if let Some(value) = config.get::<f32>("step_size")? {
199                params.step_size = rrt::meters(value);
200            }
201            if let Some(value) = config.get::<f32>("goal_bias")? {
202                params.goal_bias = rrt::ratio_of(value);
203            }
204            if let Some(value) = config.get::<f32>("goal_threshold")? {
205                params.goal_threshold = rrt::meters(value);
206            }
207            if let Some(value) = config.get::<f32>("gamma")? {
208                params.gamma = rrt::meters(value);
209            }
210            if let Some(value) = config.get::<u32>("prune_interval")? {
211                params.prune_interval = value;
212            }
213            if let Some(value) = config.get::<u32>("max_nodes")? {
214                if value as usize > MAX_NODES {
215                    warning!(
216                        "rrt*: max_nodes {} exceeds the capacity {}, capping it",
217                        value,
218                        MAX_NODES as u32
219                    );
220                }
221                params.max_nodes = value;
222            }
223            if let Some(value) = config.get::<u32>("base_iterations")? {
224                base_iterations = value;
225            }
226            if let Some(value) = config.get::<u32>("block_iterations")? {
227                block_iterations = value;
228            }
229        }
230        let Owned(mut rng) = resources.rng;
231        Ok(Self {
232            params,
233            base_iterations,
234            block_iterations,
235            base_seed: rng.random::<u64>(),
236            job_counter: 0,
237            planner: None,
238            published_cost: rrt::meters(f32::INFINITY),
239            published_quality: quality_from_f32(0.0),
240        })
241    }
242
243    fn base(
244        &mut self,
245        _ctx: &CuContext,
246        input: &Self::Input<'_>,
247        output: &mut Self::Output<'_>,
248    ) -> CuResult<AnytimeStatus<Quality>> {
249        let Some(request) = input.payload() else {
250            // A source may publish nothing in a copperlist: skip the job
251            // instead of failing the application. The recycled output must
252            // not keep the previous job's path.
253            output.clear_payload();
254            self.published_cost = rrt::meters(f32::INFINITY);
255            self.published_quality = quality_from_f32(0.0);
256            return Ok(AnytimeStatus::Aborted);
257        };
258        self.job_counter = self.job_counter.wrapping_add(1);
259        // ChaCha8 seeding decorrelates consecutive seed values, so a plain
260        // add is enough to give every job an independent stream.
261        let seed = self.base_seed.wrapping_add(self.job_counter);
262        let planner = match self.planner.as_mut() {
263            // Restart on the previous job's memory instead of a fresh tree.
264            Some(planner) => {
265                planner.reset(request.world.clone(), request.start, request.goal, seed);
266                planner
267            }
268            None => self.planner.insert(RrtStar::new(
269                request.world.clone(),
270                self.params,
271                request.start,
272                request.goal,
273                seed,
274            )),
275        };
276        planner.grow(self.base_iterations);
277        self.published_cost = rrt::meters(f32::INFINITY);
278        self.published_quality = quality_from_f32(0.0);
279        // Output messages are recycled: with no path yet the message must not
280        // still carry the previous job's path.
281        output.clear_payload();
282        let quality = self.publish(output);
283        if converged(self.published_quality) {
284            // The base path already matches the straight line: no refinement
285            // can beat it.
286            return Ok(AnytimeStatus::Converged(quality));
287        }
288        // Even with no path found the job goes on: refinement is what usually
289        // finds one, and a quality of 0.0 stays under any configured floor.
290        Ok(AnytimeStatus::Improved(quality))
291    }
292
293    fn refine(
294        &mut self,
295        _ctx: &CuContext,
296        output: &mut Self::Output<'_>,
297    ) -> CuResult<AnytimeStatus<Quality>> {
298        let planner = self
299            .planner
300            .as_mut()
301            .ok_or("rrt*: refine() without a job from base()")?;
302        if planner.is_exhausted() {
303            return Ok(AnytimeStatus::Converged(self.published_quality));
304        }
305        planner.grow(self.block_iterations);
306        let quality = self.publish(output);
307        if converged(self.published_quality) {
308            // The path matches the straight line: no iteration can beat it.
309            return Ok(AnytimeStatus::Converged(quality));
310        }
311        Ok(AnytimeStatus::Improved(quality))
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn start() -> Point2f {
320        Point2f::from_meters(0.5, 0.5)
321    }
322
323    fn goal() -> Point2f {
324        Point2f::from_meters(9.5, 9.5)
325    }
326
327    /// The resource binding as the generated runtime would hand it over.
328    fn test_resources(seed: u64) -> planner_resources::Resources {
329        planner_resources::Resources {
330            rng: Owned(CuRng::from_seed(seed)),
331        }
332    }
333
334    /// The anytime contract driven by hand: `base()` publishes a first path,
335    /// every `refine()` leaves the output holding the best path so far, and
336    /// the debug state tracks the job instead of exposing the whole tree.
337    #[test]
338    fn refinement_only_commits_improvements() {
339        let ctx = CuContext::new_with_clock();
340        let mut task = RrtStarPlanner::new(None, test_resources(42)).unwrap();
341        let input = CuMsg::new(Some(PlanRequest {
342            world: World::depot(),
343            start: start(),
344            goal: goal(),
345        }));
346        let mut output = CuMsg::new(None);
347
348        task.start(&ctx).unwrap();
349        let status = task.base(&ctx, &input, &mut output).unwrap();
350        assert!(matches!(status, AnytimeStatus::Improved(_)));
351
352        let mut best = f32::INFINITY;
353        for _ in 0..24 {
354            if let AnytimeStatus::Aborted = task.refine(&ctx, &mut output).unwrap() {
355                panic!("the planner should not abort on a solvable map");
356            }
357            if let Some(path) = output.payload() {
358                assert!(
359                    path.cost.raw() <= best + 1e-4,
360                    "the output regressed: {best} then {}",
361                    path.cost.raw()
362                );
363                best = path.cost.raw();
364            }
365        }
366        assert!(output.payload().is_some(), "no path after 24 quanta");
367
368        let state = task.debug_state();
369        assert_eq!(state.published_cost.raw(), best);
370        assert!(state.best_cost <= state.published_cost);
371        assert!(state.published_quality.raw() > 0.0);
372        assert!(state.iterations > 0 && state.tree_size > 0);
373    }
374
375    /// A copperlist without a request skips the job instead of failing the
376    /// application, and must not leak the previous job's path.
377    #[test]
378    fn missing_request_skips_the_job() {
379        let ctx = CuContext::new_with_clock();
380        let mut task = RrtStarPlanner::new(None, test_resources(42)).unwrap();
381        let input = CuMsg::new(Some(PlanRequest {
382            world: World::depot(),
383            start: start(),
384            goal: goal(),
385        }));
386        let mut output = CuMsg::new(None);
387
388        task.start(&ctx).unwrap();
389        task.base(&ctx, &input, &mut output).unwrap();
390        for _ in 0..24 {
391            task.refine(&ctx, &mut output).unwrap();
392        }
393        assert!(output.payload().is_some(), "no path to leak");
394
395        let empty = CuMsg::new(None);
396        let status = task.base(&ctx, &empty, &mut output).unwrap();
397        assert!(matches!(status, AnytimeStatus::Aborted));
398        assert!(output.payload().is_none(), "the old path leaked");
399        assert_eq!(task.debug_state().published_quality.raw(), 0.0);
400    }
401
402    /// A request with the start on the goal is solved by definition: `base()`
403    /// converges at once with quality 1.0, never NaN.
404    #[test]
405    fn start_on_goal_converges_immediately() {
406        let ctx = CuContext::new_with_clock();
407        let mut task = RrtStarPlanner::new(None, test_resources(3)).unwrap();
408        let input = CuMsg::new(Some(PlanRequest {
409            world: World::depot(),
410            start: start(),
411            goal: start(),
412        }));
413        let mut output = CuMsg::new(None);
414
415        task.start(&ctx).unwrap();
416        let status = task.base(&ctx, &input, &mut output).unwrap();
417        assert!(matches!(status, AnytimeStatus::Converged(_)));
418        assert!(output.payload().is_some(), "a trivial path is still a path");
419        assert_eq!(task.debug_state().published_quality.raw(), 1.0);
420    }
421}