1use crate::control::{PidController, PidGains};
5use crate::{BehaviorNode, Context, NodeStatus};
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#[derive(Debug, Clone, Copy)]
79pub(crate) struct Anchor {
80 position: [f32; 2],
81 yaw: f32,
82}
83
84pub(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
90pub(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
102pub(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
112pub(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}
135
136impl std::fmt::Debug for GoToLocationNode {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 f.debug_struct("GoToLocationNode")
139 .field("has_goal", &self.goal.is_some())
140 .field("subscribed", &self.goal_receiver.is_some())
141 .finish()
142 }
143}
144
145impl GoToLocationNode {
146 pub fn from_json(config: Value) -> anyhow::Result<Self> {
148 let config = if config.is_null() {
149 serde_json::json!({})
150 } else {
151 config
152 };
153 let cfg: GoToLocationConfig = serde_json::from_value(config)?;
154 Ok(Self::with_config(cfg))
155 }
156
157 fn with_config(config: GoToLocationConfig) -> Self {
158 let linear_pid = PidController::new(config.linear_gains, (0.0, config.max_linear_velocity as f64));
159 let angular_pid = PidController::new(
160 config.angular_gains,
161 (-config.max_angular_velocity as f64, config.max_angular_velocity as f64),
162 );
163 Self {
164 config,
165 goal_receiver: None,
166 odometry_receiver: None,
167 cancel_receiver: None,
168 goal: None,
169 anchor: None,
170 start_distance: None,
171 last_tick: None,
172 linear_pid,
173 angular_pid,
174 }
175 }
176
177 fn accept_goal(&mut self, goal: NavigationGoal, odometry: &Odometry) {
178 let anchor = Anchor {
179 position: [odometry.position[0], odometry.position[1]],
180 yaw: yaw_from_quaternion(odometry.orientation),
181 };
182 let goal_position = [goal.target.position[0], goal.target.position[1]];
183 self.start_distance = Some((goal_position[0].powi(2) + goal_position[1].powi(2)).sqrt());
184 self.goal = Some(goal);
185 self.anchor = Some(anchor);
186 self.linear_pid.reset();
187 self.angular_pid.reset();
188 self.last_tick = None;
189 }
190
191 fn clear_goal(&mut self) {
192 self.goal = None;
193 self.anchor = None;
194 self.start_distance = None;
195 self.last_tick = None;
196 }
197
198 async fn publish_stop(&self, ctx: &Context) -> anyhow::Result<()> {
199 ctx.publish_to(actuator::CMD_VEL_NAV, &Twist::stop()).await?;
200 Ok(())
201 }
202
203 async fn publish_status(
204 &self,
205 ctx: &Context,
206 goal_id: &str,
207 state: NavigationState,
208 distance_remaining: f32,
209 progress: f32,
210 ) -> anyhow::Result<()> {
211 let status = NavigationStatus {
212 timestamp: mecha10_core::prelude::now_micros(),
213 goal_id: goal_id.to_string(),
214 state,
215 distance_remaining,
216 time_remaining: None,
217 progress,
218 };
219 ctx.publish_to(navigation::STATUS, &status).await?;
220 Ok(())
221 }
222
223 fn progress(&self, distance_remaining: f32) -> f32 {
224 match self.start_distance {
225 Some(start) if start > f32::EPSILON => (1.0 - distance_remaining / start).clamp(0.0, 1.0),
226 _ => 1.0,
227 }
228 }
229}
230
231#[async_trait]
232impl BehaviorNode for GoToLocationNode {
233 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
234 if self.goal_receiver.is_none() {
235 self.goal_receiver = Some(ctx.subscribe(navigation::GOAL).await?);
236 }
237 if self.odometry_receiver.is_none() {
238 self.odometry_receiver = Some(ctx.subscribe(sensor::ODOMETRY).await?);
239 }
240 if self.cancel_receiver.is_none() {
241 self.cancel_receiver = Some(ctx.subscribe(navigation::CANCEL).await?);
242 }
243
244 let mut cancelled = false;
245 while let Ok(cancel) = self.cancel_receiver.as_mut().expect("just subscribed above").try_recv() {
246 cancelled = cancelled || cancel;
247 }
248
249 let mut latest_odometry = None;
250 while let Ok(odometry) = self
251 .odometry_receiver
252 .as_mut()
253 .expect("just subscribed above")
254 .try_recv()
255 {
256 latest_odometry = Some(odometry);
257 }
258
259 let mut latest_goal = None;
260 while let Ok(goal) = self.goal_receiver.as_mut().expect("just subscribed above").try_recv() {
261 latest_goal = Some(goal);
262 }
263
264 if cancelled {
265 if let Some(goal) = self.goal.take() {
266 self.publish_stop(ctx).await?;
267 self.publish_status(ctx, &goal.goal_id, NavigationState::Failed, 0.0, self.progress(0.0))
268 .await?;
269 }
270 self.clear_goal();
271 return Ok(NodeStatus::Failure);
272 }
273
274 if let Some(goal) = latest_goal {
275 if let Some(odometry) = latest_odometry.as_ref() {
276 self.accept_goal(goal, odometry);
277 } else {
278 tracing::warn!("GoToLocationNode: dropping goal received before any odometry - can't anchor it");
279 }
280 }
281
282 let Some(goal) = self.goal.clone() else {
283 return Ok(NodeStatus::Failure);
284 };
285
286 let Some(odometry) = latest_odometry else {
287 return Ok(NodeStatus::Running);
288 };
289
290 let anchor = self.anchor.expect("a goal is only ever set alongside its anchor");
291 let traveled = world_to_anchor_frame(&anchor, [odometry.position[0], odometry.position[1]]);
292 let heading_in_anchor_frame = normalize_angle(yaw_from_quaternion(odometry.orientation) - anchor.yaw);
293 let goal_position = [goal.target.position[0], goal.target.position[1]];
294 let (distance_remaining, heading_error) = compute_control(goal_position, traveled, heading_in_anchor_frame);
295
296 let tolerance = goal.position_tolerance.unwrap_or(self.config.position_tolerance_m);
297 if distance_remaining <= tolerance {
298 self.publish_stop(ctx).await?;
299 self.publish_status(ctx, &goal.goal_id, NavigationState::Succeeded, distance_remaining, 1.0)
300 .await?;
301 self.clear_goal();
302 return Ok(NodeStatus::Success);
303 }
304
305 let now = Instant::now();
306 let dt = self
307 .last_tick
308 .map(|last| now.duration_since(last).as_secs_f64())
309 .unwrap_or(0.0);
310 self.last_tick = Some(now);
311
312 let angular = self.angular_pid.update(heading_error as f64, 0.0, dt) as f32;
313 let linear = if heading_error.abs() <= self.config.heading_lock_threshold_rad {
314 self.linear_pid.update(distance_remaining as f64, 0.0, dt) as f32
315 } else {
316 0.0
317 };
318
319 ctx.publish_to(actuator::CMD_VEL_NAV, &Twist::new_2d(linear, angular))
320 .await?;
321 self.publish_status(
322 ctx,
323 &goal.goal_id,
324 NavigationState::Moving,
325 distance_remaining,
326 self.progress(distance_remaining),
327 )
328 .await?;
329
330 Ok(NodeStatus::Running)
331 }
332
333 fn reset_self(&mut self) {
334 self.clear_goal();
335 self.linear_pid.reset();
336 self.angular_pid.reset();
337 }
338
339 fn name(&self) -> &str {
340 "GoToLocationNode"
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use mecha10_core::messages::Pose;
348 use std::f32::consts::{FRAC_PI_2, PI};
349
350 fn pose_2d(x: f32, y: f32, yaw: f32) -> Pose {
351 Pose::new_2d(x, y, yaw)
352 }
353
354 #[test]
355 fn yaw_from_quaternion_recovers_a_known_yaw() {
356 let pose = pose_2d(0.0, 0.0, FRAC_PI_2);
357 let yaw = yaw_from_quaternion(pose.orientation);
358 assert!((yaw - FRAC_PI_2).abs() < 1e-5);
359 }
360
361 #[test]
362 fn yaw_from_quaternion_is_zero_for_the_identity_quaternion() {
363 assert_eq!(yaw_from_quaternion([0.0, 0.0, 0.0, 1.0]), 0.0);
364 }
365
366 #[test]
367 fn normalize_angle_wraps_values_outside_pi() {
368 assert!((normalize_angle(PI * 1.5) - (-PI * 0.5)).abs() < 1e-5);
369 assert!((normalize_angle(-PI * 1.5) - (PI * 0.5)).abs() < 1e-5);
370 assert_eq!(normalize_angle(0.0), 0.0);
371 }
372
373 #[test]
374 fn world_to_anchor_frame_is_identity_when_anchor_is_at_the_origin_facing_forward() {
375 let anchor = Anchor {
376 position: [0.0, 0.0],
377 yaw: 0.0,
378 };
379 assert_eq!(world_to_anchor_frame(&anchor, [1.0, 2.0]), [1.0, 2.0]);
380 }
381
382 #[test]
383 fn world_to_anchor_frame_rotates_by_the_negative_anchor_yaw() {
384 let anchor = Anchor {
385 position: [0.0, 0.0],
386 yaw: FRAC_PI_2,
387 };
388 let transformed = world_to_anchor_frame(&anchor, [0.0, 1.0]);
389 assert!((transformed[0] - 1.0).abs() < 1e-5);
390 assert!(transformed[1].abs() < 1e-5);
391 }
392
393 #[test]
394 fn world_to_anchor_frame_subtracts_the_anchor_position_first() {
395 let anchor = Anchor {
396 position: [5.0, 5.0],
397 yaw: 0.0,
398 };
399 assert_eq!(world_to_anchor_frame(&anchor, [6.0, 5.0]), [1.0, 0.0]);
400 }
401
402 #[test]
403 fn compute_control_reports_zero_distance_and_error_once_at_the_goal() {
404 let (distance, heading_error) = compute_control([2.0, 0.0], [2.0, 0.0], 0.0);
405 assert_eq!(distance, 0.0);
406 assert_eq!(heading_error, 0.0);
407 }
408
409 #[test]
410 fn compute_control_points_straight_ahead_when_the_goal_is_directly_ahead() {
411 let (distance, heading_error) = compute_control([3.0, 0.0], [0.0, 0.0], 0.0);
412 assert_eq!(distance, 3.0);
413 assert_eq!(heading_error, 0.0);
414 }
415
416 #[test]
417 fn compute_control_reports_a_positive_heading_error_for_a_goal_to_the_left() {
418 let (_, heading_error) = compute_control([0.0, 3.0], [0.0, 0.0], 0.0);
419 assert!((heading_error - FRAC_PI_2).abs() < 1e-5);
420 }
421
422 #[test]
423 fn compute_control_accounts_for_the_robots_current_heading() {
424 let (_, heading_error) = compute_control([3.0, 0.0], [0.0, 0.0], FRAC_PI_2);
425 assert!((heading_error + FRAC_PI_2).abs() < 1e-5);
426 }
427
428 #[test]
429 fn from_json_defaults_all_fields() {
430 let node = GoToLocationNode::from_json(serde_json::json!({})).unwrap();
431 assert_eq!(node.config.position_tolerance_m, 0.15);
432 assert_eq!(node.config.max_linear_velocity, 0.4);
433 assert_eq!(node.config.max_angular_velocity, 1.0);
434 }
435
436 #[test]
437 fn from_json_accepts_null_the_same_as_an_empty_object() {
438 let node = GoToLocationNode::from_json(Value::Null).unwrap();
439 assert_eq!(node.config.position_tolerance_m, 0.15);
440 }
441
442 #[test]
443 fn from_json_reads_overridden_fields() {
444 let node = GoToLocationNode::from_json(serde_json::json!({
445 "position_tolerance_m": 0.5,
446 "max_linear_velocity": 1.0,
447 "max_angular_velocity": 2.0
448 }))
449 .unwrap();
450 assert_eq!(node.config.position_tolerance_m, 0.5);
451 assert_eq!(node.config.max_linear_velocity, 1.0);
452 assert_eq!(node.config.max_angular_velocity, 2.0);
453 }
454
455 fn sample_goal(x: f32, y: f32) -> NavigationGoal {
456 NavigationGoal::new_2d("test-goal".to_string(), x, y, 0.0)
457 }
458
459 fn sample_odometry(x: f32, y: f32, yaw: f32) -> Odometry {
460 let pose = pose_2d(x, y, yaw);
461 Odometry {
462 timestamp: 0,
463 position: pose.position,
464 orientation: pose.orientation,
465 linear_velocity: [0.0, 0.0, 0.0],
466 angular_velocity: [0.0, 0.0, 0.0],
467 frame_id: None,
468 child_frame_id: None,
469 }
470 }
471
472 #[test]
473 fn accept_goal_anchors_on_the_odometry_pose_at_acceptance_time() {
474 let mut node = GoToLocationNode::from_json(Value::Null).unwrap();
475 let goal = sample_goal(2.0, 0.0);
476 let odometry = sample_odometry(1.0, 1.0, FRAC_PI_2);
477
478 node.accept_goal(goal, &odometry);
479
480 let anchor = node.anchor.expect("anchor set");
481 assert_eq!(anchor.position, [1.0, 1.0]);
482 assert!((anchor.yaw - FRAC_PI_2).abs() < 1e-5);
483 assert_eq!(node.start_distance, Some(2.0));
484 }
485
486 #[test]
487 fn clear_goal_resets_tracking_state() {
488 let mut node = GoToLocationNode::from_json(Value::Null).unwrap();
489 node.accept_goal(sample_goal(1.0, 0.0), &sample_odometry(0.0, 0.0, 0.0));
490 assert!(node.goal.is_some());
491
492 node.clear_goal();
493
494 assert!(node.goal.is_none());
495 assert!(node.anchor.is_none());
496 assert!(node.start_distance.is_none());
497 }
498
499 #[test]
500 fn progress_tracks_fraction_of_distance_covered() {
501 let mut node = GoToLocationNode::from_json(Value::Null).unwrap();
502 node.accept_goal(sample_goal(4.0, 0.0), &sample_odometry(0.0, 0.0, 0.0));
503
504 assert_eq!(node.progress(4.0), 0.0);
505 assert_eq!(node.progress(2.0), 0.5);
506 assert_eq!(node.progress(0.0), 1.0);
507 }
508
509 #[test]
510 fn progress_is_complete_when_there_is_no_tracked_start_distance() {
511 let node = GoToLocationNode::from_json(Value::Null).unwrap();
512 assert_eq!(node.progress(5.0), 1.0);
513 }
514}