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