Skip to main content

mecha10_behavior_runtime/actions/
go_to_location.rs

1//! Planning node for the "go-to-location" MVP (LAB-4442): drives the robot to a relative
2//! navigation goal using odometry-based dead reckoning. No SLAM/map is involved - the goal
3//! received on `navigation::GOAL` is expressed in the robot's own frame at the moment the goal
4//! was issued (see `SetGoToLocationTargetNode`), and this node tracks progress toward it purely
5//! by integrating odometry deltas since that moment.
6//!
7//! This is the behaviour-tree counterpart to `SetGoToLocationTargetNode`: that node turns a
8//! dashboard click into a goal, this node turns a goal into motion. Both are registered
9//! independently in `register_builtin_actions` and are meant to run side by side (e.g. under a
10//! `Parallel`/`Selector`), the same way `SetInstanceTrackTargetNode`/`InstanceTrackTaskNode` do.
11
12use crate::control::{PidController, PidGains};
13use crate::{BehaviorNode, Context, NodeStatus};
14use async_trait::async_trait;
15use mecha10_core::messages::navigation::NavigationState;
16use mecha10_core::messages::{NavigationGoal, NavigationStatus, Odometry, Twist};
17use mecha10_core::topics::{actuator, navigation, sensor};
18use mecha10_core::Receiver;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::time::Instant;
22
23fn default_position_tolerance_m() -> f32 {
24    0.15
25}
26
27fn default_max_linear_velocity() -> f32 {
28    0.4
29}
30
31fn default_max_angular_velocity() -> f32 {
32    1.0
33}
34
35fn default_heading_lock_threshold_rad() -> f32 {
36    // While the heading error is larger than this, spin in place rather than drive forward.
37    0.6
38}
39
40fn default_linear_gains() -> PidGains {
41    PidGains {
42        kp: 0.8,
43        ki: 0.0,
44        kd: 0.0,
45    }
46}
47
48fn default_angular_gains() -> PidGains {
49    PidGains {
50        kp: 1.5,
51        ki: 0.0,
52        kd: 0.1,
53    }
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57struct GoToLocationConfig {
58    #[serde(default = "default_position_tolerance_m")]
59    position_tolerance_m: f32,
60    #[serde(default = "default_max_linear_velocity")]
61    max_linear_velocity: f32,
62    #[serde(default = "default_max_angular_velocity")]
63    max_angular_velocity: f32,
64    #[serde(default = "default_heading_lock_threshold_rad")]
65    heading_lock_threshold_rad: f32,
66    #[serde(default = "default_linear_gains")]
67    linear_gains: PidGains,
68    #[serde(default = "default_angular_gains")]
69    angular_gains: PidGains,
70}
71
72impl Default for GoToLocationConfig {
73    fn default() -> Self {
74        Self {
75            position_tolerance_m: default_position_tolerance_m(),
76            max_linear_velocity: default_max_linear_velocity(),
77            max_angular_velocity: default_max_angular_velocity(),
78            heading_lock_threshold_rad: default_heading_lock_threshold_rad(),
79            linear_gains: default_linear_gains(),
80            angular_gains: default_angular_gains(),
81        }
82    }
83}
84
85/// The odometry pose captured when a goal is accepted - the frame the goal's relative target is
86/// expressed in. Everything downstream (`remaining_in_anchor_frame`) is relative to this.
87#[derive(Debug, Clone, Copy)]
88pub(crate) struct Anchor {
89    position: [f32; 2],
90    yaw: f32,
91}
92
93/// Extracts yaw (rotation about the vertical axis) from a `[x, y, z, w]` quaternion.
94pub(crate) fn yaw_from_quaternion(q: [f32; 4]) -> f32 {
95    let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
96    (2.0 * (w * z + x * y)).atan2(1.0 - 2.0 * (y * y + z * z))
97}
98
99/// Wraps an angle to `(-pi, pi]`.
100pub(crate) fn normalize_angle(angle: f32) -> f32 {
101    let two_pi = std::f32::consts::TAU;
102    let mut wrapped = angle % two_pi;
103    if wrapped > std::f32::consts::PI {
104        wrapped -= two_pi;
105    } else if wrapped <= -std::f32::consts::PI {
106        wrapped += two_pi;
107    }
108    wrapped
109}
110
111/// Transforms a world-frame point into the `anchor`'s frame (the frame the goal's `target.
112/// position` is expressed in) - i.e. "how far has the robot moved, expressed in the direction
113/// it was originally facing".
114pub(crate) fn world_to_anchor_frame(anchor: &Anchor, world_point: [f32; 2]) -> [f32; 2] {
115    let dx = world_point[0] - anchor.position[0];
116    let dy = world_point[1] - anchor.position[1];
117    let (sin_a, cos_a) = (-anchor.yaw).sin_cos();
118    [dx * cos_a - dy * sin_a, dx * sin_a + dy * cos_a]
119}
120
121/// Computes the remaining distance and heading error (goal heading minus current heading, both
122/// in the anchor frame, wrapped to `(-pi, pi]`) toward `goal_position` from the robot's current
123/// `traveled` offset (already in the anchor frame) and `heading_in_anchor_frame`.
124pub(crate) fn compute_control(goal_position: [f32; 2], traveled: [f32; 2], heading_in_anchor_frame: f32) -> (f32, f32) {
125    let remaining = [goal_position[0] - traveled[0], goal_position[1] - traveled[1]];
126    let distance = (remaining[0] * remaining[0] + remaining[1] * remaining[1]).sqrt();
127    let desired_heading = remaining[1].atan2(remaining[0]);
128    let heading_error = normalize_angle(desired_heading - heading_in_anchor_frame);
129    (distance, heading_error)
130}
131
132pub struct GoToLocationNode {
133    config: GoToLocationConfig,
134    goal_receiver: Option<Receiver<NavigationGoal>>,
135    odometry_receiver: Option<Receiver<Odometry>>,
136    cancel_receiver: Option<Receiver<bool>>,
137    goal: Option<NavigationGoal>,
138    anchor: Option<Anchor>,
139    start_distance: Option<f32>,
140    last_tick: Option<Instant>,
141    linear_pid: PidController,
142    angular_pid: PidController,
143}
144
145impl std::fmt::Debug for GoToLocationNode {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.debug_struct("GoToLocationNode")
148            .field("has_goal", &self.goal.is_some())
149            .field("subscribed", &self.goal_receiver.is_some())
150            .finish()
151    }
152}
153
154impl GoToLocationNode {
155    /// Create a new node from JSON configuration. `null` is normalized to `{}`.
156    pub fn from_json(config: Value) -> anyhow::Result<Self> {
157        let config = if config.is_null() {
158            serde_json::json!({})
159        } else {
160            config
161        };
162        let cfg: GoToLocationConfig = serde_json::from_value(config)?;
163        Ok(Self::with_config(cfg))
164    }
165
166    fn with_config(config: GoToLocationConfig) -> Self {
167        let linear_pid = PidController::new(config.linear_gains, (0.0, config.max_linear_velocity as f64));
168        let angular_pid = PidController::new(
169            config.angular_gains,
170            (-config.max_angular_velocity as f64, config.max_angular_velocity as f64),
171        );
172        Self {
173            config,
174            goal_receiver: None,
175            odometry_receiver: None,
176            cancel_receiver: None,
177            goal: None,
178            anchor: None,
179            start_distance: None,
180            last_tick: None,
181            linear_pid,
182            angular_pid,
183        }
184    }
185
186    fn accept_goal(&mut self, goal: NavigationGoal, odometry: &Odometry) {
187        let anchor = Anchor {
188            position: [odometry.position[0], odometry.position[1]],
189            yaw: yaw_from_quaternion(odometry.orientation),
190        };
191        let goal_position = [goal.target.position[0], goal.target.position[1]];
192        self.start_distance = Some((goal_position[0].powi(2) + goal_position[1].powi(2)).sqrt());
193        self.goal = Some(goal);
194        self.anchor = Some(anchor);
195        self.linear_pid.reset();
196        self.angular_pid.reset();
197        self.last_tick = None;
198    }
199
200    fn clear_goal(&mut self) {
201        self.goal = None;
202        self.anchor = None;
203        self.start_distance = None;
204        self.last_tick = None;
205    }
206
207    async fn publish_stop(&self, ctx: &Context) -> anyhow::Result<()> {
208        ctx.publish_to(actuator::CMD_VEL_NAV, &Twist::stop()).await?;
209        Ok(())
210    }
211
212    async fn publish_status(
213        &self,
214        ctx: &Context,
215        goal_id: &str,
216        state: NavigationState,
217        distance_remaining: f32,
218        progress: f32,
219    ) -> anyhow::Result<()> {
220        let status = NavigationStatus {
221            timestamp: mecha10_core::prelude::now_micros(),
222            goal_id: goal_id.to_string(),
223            state,
224            distance_remaining,
225            time_remaining: None,
226            progress,
227        };
228        ctx.publish_to(navigation::STATUS, &status).await?;
229        Ok(())
230    }
231
232    fn progress(&self, distance_remaining: f32) -> f32 {
233        match self.start_distance {
234            Some(start) if start > f32::EPSILON => (1.0 - distance_remaining / start).clamp(0.0, 1.0),
235            _ => 1.0,
236        }
237    }
238}
239
240#[async_trait]
241impl BehaviorNode for GoToLocationNode {
242    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
243        if self.goal_receiver.is_none() {
244            self.goal_receiver = Some(ctx.subscribe(navigation::GOAL).await?);
245        }
246        if self.odometry_receiver.is_none() {
247            self.odometry_receiver = Some(ctx.subscribe(sensor::ODOMETRY).await?);
248        }
249        if self.cancel_receiver.is_none() {
250            self.cancel_receiver = Some(ctx.subscribe(navigation::CANCEL).await?);
251        }
252
253        let mut cancelled = false;
254        while let Ok(cancel) = self.cancel_receiver.as_mut().expect("just subscribed above").try_recv() {
255            cancelled = cancelled || cancel;
256        }
257
258        let mut latest_odometry = None;
259        while let Ok(odometry) = self
260            .odometry_receiver
261            .as_mut()
262            .expect("just subscribed above")
263            .try_recv()
264        {
265            latest_odometry = Some(odometry);
266        }
267
268        let mut latest_goal = None;
269        while let Ok(goal) = self.goal_receiver.as_mut().expect("just subscribed above").try_recv() {
270            latest_goal = Some(goal);
271        }
272
273        if cancelled {
274            if let Some(goal) = self.goal.take() {
275                self.publish_stop(ctx).await?;
276                self.publish_status(ctx, &goal.goal_id, NavigationState::Failed, 0.0, self.progress(0.0))
277                    .await?;
278            }
279            self.clear_goal();
280            return Ok(NodeStatus::Failure);
281        }
282
283        if let Some(goal) = latest_goal {
284            if let Some(odometry) = latest_odometry.as_ref() {
285                self.accept_goal(goal, odometry);
286            } else {
287                tracing::warn!("GoToLocationNode: dropping goal received before any odometry - can't anchor it");
288            }
289        }
290
291        let Some(goal) = self.goal.clone() else {
292            return Ok(NodeStatus::Failure);
293        };
294
295        let Some(odometry) = latest_odometry else {
296            return Ok(NodeStatus::Running);
297        };
298
299        let anchor = self.anchor.expect("a goal is only ever set alongside its anchor");
300        let traveled = world_to_anchor_frame(&anchor, [odometry.position[0], odometry.position[1]]);
301        let heading_in_anchor_frame = normalize_angle(yaw_from_quaternion(odometry.orientation) - anchor.yaw);
302        let goal_position = [goal.target.position[0], goal.target.position[1]];
303        let (distance_remaining, heading_error) = compute_control(goal_position, traveled, heading_in_anchor_frame);
304
305        let tolerance = goal.position_tolerance.unwrap_or(self.config.position_tolerance_m);
306        if distance_remaining <= tolerance {
307            self.publish_stop(ctx).await?;
308            self.publish_status(ctx, &goal.goal_id, NavigationState::Succeeded, distance_remaining, 1.0)
309                .await?;
310            self.clear_goal();
311            return Ok(NodeStatus::Success);
312        }
313
314        let now = Instant::now();
315        let dt = self
316            .last_tick
317            .map(|last| now.duration_since(last).as_secs_f64())
318            .unwrap_or(0.0);
319        self.last_tick = Some(now);
320
321        let angular = self.angular_pid.update(heading_error as f64, 0.0, dt) as f32;
322        let linear = if heading_error.abs() <= self.config.heading_lock_threshold_rad {
323            self.linear_pid.update(distance_remaining as f64, 0.0, dt) as f32
324        } else {
325            0.0
326        };
327
328        ctx.publish_to(actuator::CMD_VEL_NAV, &Twist::new_2d(linear, angular))
329            .await?;
330        self.publish_status(
331            ctx,
332            &goal.goal_id,
333            NavigationState::Moving,
334            distance_remaining,
335            self.progress(distance_remaining),
336        )
337        .await?;
338
339        Ok(NodeStatus::Running)
340    }
341
342    fn reset_self(&mut self) {
343        self.clear_goal();
344        self.linear_pid.reset();
345        self.angular_pid.reset();
346    }
347
348    fn name(&self) -> &str {
349        "GoToLocationNode"
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use mecha10_core::messages::Pose;
357    use std::f32::consts::{FRAC_PI_2, PI};
358
359    fn pose_2d(x: f32, y: f32, yaw: f32) -> Pose {
360        Pose::new_2d(x, y, yaw)
361    }
362
363    #[test]
364    fn yaw_from_quaternion_recovers_a_known_yaw() {
365        let pose = pose_2d(0.0, 0.0, FRAC_PI_2);
366        let yaw = yaw_from_quaternion(pose.orientation);
367        assert!((yaw - FRAC_PI_2).abs() < 1e-5);
368    }
369
370    #[test]
371    fn yaw_from_quaternion_is_zero_for_the_identity_quaternion() {
372        assert_eq!(yaw_from_quaternion([0.0, 0.0, 0.0, 1.0]), 0.0);
373    }
374
375    #[test]
376    fn normalize_angle_wraps_values_outside_pi() {
377        assert!((normalize_angle(PI * 1.5) - (-PI * 0.5)).abs() < 1e-5);
378        assert!((normalize_angle(-PI * 1.5) - (PI * 0.5)).abs() < 1e-5);
379        assert_eq!(normalize_angle(0.0), 0.0);
380    }
381
382    #[test]
383    fn world_to_anchor_frame_is_identity_when_anchor_is_at_the_origin_facing_forward() {
384        let anchor = Anchor {
385            position: [0.0, 0.0],
386            yaw: 0.0,
387        };
388        assert_eq!(world_to_anchor_frame(&anchor, [1.0, 2.0]), [1.0, 2.0]);
389    }
390
391    #[test]
392    fn world_to_anchor_frame_rotates_by_the_negative_anchor_yaw() {
393        // Anchor facing +90deg (yaw = pi/2): the world's +y axis becomes the anchor's +x axis.
394        let anchor = Anchor {
395            position: [0.0, 0.0],
396            yaw: FRAC_PI_2,
397        };
398        let transformed = world_to_anchor_frame(&anchor, [0.0, 1.0]);
399        assert!((transformed[0] - 1.0).abs() < 1e-5);
400        assert!(transformed[1].abs() < 1e-5);
401    }
402
403    #[test]
404    fn world_to_anchor_frame_subtracts_the_anchor_position_first() {
405        let anchor = Anchor {
406            position: [5.0, 5.0],
407            yaw: 0.0,
408        };
409        assert_eq!(world_to_anchor_frame(&anchor, [6.0, 5.0]), [1.0, 0.0]);
410    }
411
412    #[test]
413    fn compute_control_reports_zero_distance_and_error_once_at_the_goal() {
414        let (distance, heading_error) = compute_control([2.0, 0.0], [2.0, 0.0], 0.0);
415        assert_eq!(distance, 0.0);
416        assert_eq!(heading_error, 0.0);
417    }
418
419    #[test]
420    fn compute_control_points_straight_ahead_when_the_goal_is_directly_ahead() {
421        let (distance, heading_error) = compute_control([3.0, 0.0], [0.0, 0.0], 0.0);
422        assert_eq!(distance, 3.0);
423        assert_eq!(heading_error, 0.0);
424    }
425
426    #[test]
427    fn compute_control_reports_a_positive_heading_error_for_a_goal_to_the_left() {
428        let (_, heading_error) = compute_control([0.0, 3.0], [0.0, 0.0], 0.0);
429        assert!((heading_error - FRAC_PI_2).abs() < 1e-5);
430    }
431
432    #[test]
433    fn compute_control_accounts_for_the_robots_current_heading() {
434        // Goal is straight ahead in the anchor frame, but the robot has already turned 90deg
435        // left - so from its current heading, the goal is now 90deg to the right.
436        let (_, heading_error) = compute_control([3.0, 0.0], [0.0, 0.0], FRAC_PI_2);
437        assert!((heading_error + FRAC_PI_2).abs() < 1e-5);
438    }
439
440    #[test]
441    fn from_json_defaults_all_fields() {
442        let node = GoToLocationNode::from_json(serde_json::json!({})).unwrap();
443        assert_eq!(node.config.position_tolerance_m, 0.15);
444        assert_eq!(node.config.max_linear_velocity, 0.4);
445        assert_eq!(node.config.max_angular_velocity, 1.0);
446    }
447
448    #[test]
449    fn from_json_accepts_null_the_same_as_an_empty_object() {
450        let node = GoToLocationNode::from_json(Value::Null).unwrap();
451        assert_eq!(node.config.position_tolerance_m, 0.15);
452    }
453
454    #[test]
455    fn from_json_reads_overridden_fields() {
456        let node = GoToLocationNode::from_json(serde_json::json!({
457            "position_tolerance_m": 0.5,
458            "max_linear_velocity": 1.0,
459            "max_angular_velocity": 2.0
460        }))
461        .unwrap();
462        assert_eq!(node.config.position_tolerance_m, 0.5);
463        assert_eq!(node.config.max_linear_velocity, 1.0);
464        assert_eq!(node.config.max_angular_velocity, 2.0);
465    }
466
467    fn sample_goal(x: f32, y: f32) -> NavigationGoal {
468        NavigationGoal::new_2d("test-goal".to_string(), x, y, 0.0)
469    }
470
471    fn sample_odometry(x: f32, y: f32, yaw: f32) -> Odometry {
472        let pose = pose_2d(x, y, yaw);
473        Odometry {
474            timestamp: 0,
475            position: pose.position,
476            orientation: pose.orientation,
477            linear_velocity: [0.0, 0.0, 0.0],
478            angular_velocity: [0.0, 0.0, 0.0],
479            frame_id: None,
480            child_frame_id: None,
481        }
482    }
483
484    #[test]
485    fn accept_goal_anchors_on_the_odometry_pose_at_acceptance_time() {
486        let mut node = GoToLocationNode::from_json(Value::Null).unwrap();
487        let goal = sample_goal(2.0, 0.0);
488        let odometry = sample_odometry(1.0, 1.0, FRAC_PI_2);
489
490        node.accept_goal(goal, &odometry);
491
492        let anchor = node.anchor.expect("anchor set");
493        assert_eq!(anchor.position, [1.0, 1.0]);
494        assert!((anchor.yaw - FRAC_PI_2).abs() < 1e-5);
495        assert_eq!(node.start_distance, Some(2.0));
496    }
497
498    #[test]
499    fn clear_goal_resets_tracking_state() {
500        let mut node = GoToLocationNode::from_json(Value::Null).unwrap();
501        node.accept_goal(sample_goal(1.0, 0.0), &sample_odometry(0.0, 0.0, 0.0));
502        assert!(node.goal.is_some());
503
504        node.clear_goal();
505
506        assert!(node.goal.is_none());
507        assert!(node.anchor.is_none());
508        assert!(node.start_distance.is_none());
509    }
510
511    #[test]
512    fn progress_tracks_fraction_of_distance_covered() {
513        let mut node = GoToLocationNode::from_json(Value::Null).unwrap();
514        node.accept_goal(sample_goal(4.0, 0.0), &sample_odometry(0.0, 0.0, 0.0));
515
516        assert_eq!(node.progress(4.0), 0.0);
517        assert_eq!(node.progress(2.0), 0.5);
518        assert_eq!(node.progress(0.0), 1.0);
519    }
520
521    #[test]
522    fn progress_is_complete_when_there_is_no_tracked_start_distance() {
523        let node = GoToLocationNode::from_json(Value::Null).unwrap();
524        assert_eq!(node.progress(5.0), 1.0);
525    }
526}