mecha10_behavior_runtime/actions/
flight.rs1use crate::control::{PidController, PidGains};
12use crate::{BehaviorNode, Context, NodeStatus};
13use async_trait::async_trait;
14use mecha10_core::messages::Odometry;
15use mecha10_core::topics::Topic;
16use mecha10_core::Receiver;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::time::{Duration, Instant};
20
21#[derive(Debug, Serialize, Deserialize)]
24struct AltitudeHoldConfig {
25 #[serde(default = "default_motor_cmd_topic")]
28 motor_cmd_topic: String,
29 #[serde(default = "default_odometry_topic")]
31 odometry_topic: String,
32 #[serde(default = "default_rotor_count")]
34 rotor_count: usize,
35 #[serde(default = "default_hover_thrust")]
40 hover_thrust: f64,
41 #[serde(default = "default_min_thrust")]
42 min_thrust: f64,
43 #[serde(default = "default_max_thrust")]
44 max_thrust: f64,
45 #[serde(default = "default_tolerance_m")]
47 tolerance_m: f64,
48 #[serde(default = "default_timeout_secs")]
51 timeout_secs: f64,
52 #[serde(default)]
53 pid: PidGains,
54}
55
56fn default_motor_cmd_topic() -> String {
57 "/actuator/motor_cmd".to_string()
58}
59
60fn default_odometry_topic() -> String {
61 "/sensor/odometry".to_string()
62}
63
64fn default_rotor_count() -> usize {
65 4
66}
67
68fn default_hover_thrust() -> f64 {
69 0.5
70}
71
72fn default_min_thrust() -> f64 {
73 0.0
74}
75
76fn default_max_thrust() -> f64 {
77 1.0
78}
79
80fn default_tolerance_m() -> f64 {
81 0.05
82}
83
84fn default_timeout_secs() -> f64 {
85 15.0
86}
87
88#[derive(Debug, PartialEq)]
92enum AltitudeStep {
93 Hold(f64),
95 Reached,
97 TimedOut,
99}
100
101struct AltitudeHoldLoop {
103 motor_cmd_topic: String,
104 odometry_topic: String,
105 rotor_count: usize,
106 hover_thrust: f64,
107 min_thrust: f64,
108 max_thrust: f64,
109 tolerance_m: f64,
110 timeout: Duration,
111 target_altitude_m: f64,
112 pid: PidController,
113 receiver: Option<Receiver<Odometry>>,
114 current_altitude_m: Option<f64>,
115 last_tick: Option<Instant>,
116 started_at: Option<Instant>,
117}
118
119impl std::fmt::Debug for AltitudeHoldLoop {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.debug_struct("AltitudeHoldLoop")
122 .field("motor_cmd_topic", &self.motor_cmd_topic)
123 .field("odometry_topic", &self.odometry_topic)
124 .field("target_altitude_m", &self.target_altitude_m)
125 .field("current_altitude_m", &self.current_altitude_m)
126 .field("subscribed", &self.receiver.is_some())
127 .finish()
128 }
129}
130
131impl AltitudeHoldLoop {
132 fn new(config: AltitudeHoldConfig, target_altitude_m: f64) -> Self {
133 let pid = PidController::new(config.pid, (config.min_thrust, config.max_thrust))
134 .with_integral_limits((config.min_thrust, config.max_thrust));
135
136 Self {
137 motor_cmd_topic: config.motor_cmd_topic,
138 odometry_topic: config.odometry_topic,
139 rotor_count: config.rotor_count,
140 hover_thrust: config.hover_thrust,
141 min_thrust: config.min_thrust,
142 max_thrust: config.max_thrust,
143 tolerance_m: config.tolerance_m,
144 timeout: Duration::from_secs_f64(config.timeout_secs),
145 target_altitude_m,
146 pid,
147 receiver: None,
148 current_altitude_m: None,
149 last_tick: None,
150 started_at: None,
151 }
152 }
153
154 fn step(&mut self) -> AltitudeStep {
158 if let Some(receiver) = self.receiver.as_mut() {
159 while let Ok(odom) = receiver.try_recv() {
160 self.current_altitude_m = Some(odom.position[2] as f64);
161 }
162 }
163
164 let now = Instant::now();
165 let started_at = *self.started_at.get_or_insert(now);
166 if now.duration_since(started_at) >= self.timeout {
167 return AltitudeStep::TimedOut;
168 }
169
170 let Some(altitude) = self.current_altitude_m else {
171 return AltitudeStep::Hold(self.hover_thrust);
174 };
175
176 if (self.target_altitude_m - altitude).abs() <= self.tolerance_m {
177 return AltitudeStep::Reached;
178 }
179
180 let dt = self
181 .last_tick
182 .map(|previous| now.duration_since(previous).as_secs_f64())
183 .unwrap_or(0.0);
184 self.last_tick = Some(now);
185
186 let correction = self.pid.update(self.target_altitude_m, altitude, dt);
187 let thrust = (self.hover_thrust + correction).clamp(self.min_thrust, self.max_thrust);
188 AltitudeStep::Hold(thrust)
189 }
190
191 async fn publish_thrust(&self, ctx: &Context, thrust: f64) -> anyhow::Result<()> {
192 let thrusts = vec![thrust as f32; self.rotor_count];
193 let topic_static: &'static str = Box::leak(self.motor_cmd_topic.clone().into_boxed_str());
194 ctx.publish_to(Topic::<Vec<f32>>::new(topic_static), &thrusts).await?;
195 Ok(())
196 }
197
198 async fn ensure_subscribed(&mut self, ctx: &Context) -> anyhow::Result<()> {
199 if self.receiver.is_none() {
200 self.receiver = Some(ctx.subscribe_raw::<Odometry>(&self.odometry_topic).await?);
201 }
202 Ok(())
203 }
204
205 fn reset(&mut self) {
206 self.pid.reset();
207 self.current_altitude_m = None;
208 self.last_tick = None;
209 self.started_at = None;
210 }
211
212 #[cfg(test)]
213 fn with_receiver(config: AltitudeHoldConfig, target_altitude_m: f64, receiver: Receiver<Odometry>) -> Self {
214 let mut loop_ = Self::new(config, target_altitude_m);
215 loop_.receiver = Some(receiver);
216 loop_
217 }
218}
219
220pub struct TakeoffNode {
224 control: AltitudeHoldLoop,
225}
226
227#[derive(Debug, Serialize, Deserialize)]
228struct TakeoffConfig {
229 target_altitude_m: f64,
230 #[serde(flatten)]
231 shared: AltitudeHoldConfig,
232}
233
234impl std::fmt::Debug for TakeoffNode {
235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236 f.debug_struct("TakeoffNode").field("control", &self.control).finish()
237 }
238}
239
240impl TakeoffNode {
241 pub fn from_json(config: Value) -> anyhow::Result<Self> {
243 let cfg: TakeoffConfig = serde_json::from_value(config)?;
244 Ok(Self {
245 control: AltitudeHoldLoop::new(cfg.shared, cfg.target_altitude_m),
246 })
247 }
248}
249
250#[async_trait]
251impl BehaviorNode for TakeoffNode {
252 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
253 self.control.ensure_subscribed(ctx).await?;
254
255 match self.control.step() {
256 AltitudeStep::Hold(thrust) => {
257 self.control.publish_thrust(ctx, thrust).await?;
258 Ok(NodeStatus::Running)
259 }
260 AltitudeStep::Reached => {
261 tracing::debug!("TakeoffNode: reached target altitude");
262 Ok(NodeStatus::Success)
263 }
264 AltitudeStep::TimedOut => {
265 tracing::warn!("TakeoffNode: timed out before reaching target altitude");
266 Ok(NodeStatus::Failure)
267 }
268 }
269 }
270
271 fn reset_self(&mut self) {
272 self.control.reset();
273 }
274
275 fn name(&self) -> &str {
276 "TakeoffNode"
277 }
278}
279
280pub struct LandNode {
284 control: AltitudeHoldLoop,
285}
286
287#[derive(Debug, Serialize, Deserialize)]
288struct LandConfig {
289 #[serde(default)]
290 ground_altitude_m: f64,
291 #[serde(flatten)]
292 shared: AltitudeHoldConfig,
293}
294
295impl std::fmt::Debug for LandNode {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 f.debug_struct("LandNode").field("control", &self.control).finish()
298 }
299}
300
301impl LandNode {
302 pub fn from_json(config: Value) -> anyhow::Result<Self> {
304 let cfg: LandConfig = serde_json::from_value(config)?;
305 Ok(Self {
306 control: AltitudeHoldLoop::new(cfg.shared, cfg.ground_altitude_m),
307 })
308 }
309}
310
311#[async_trait]
312impl BehaviorNode for LandNode {
313 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
314 self.control.ensure_subscribed(ctx).await?;
315
316 match self.control.step() {
317 AltitudeStep::Hold(thrust) => {
318 self.control.publish_thrust(ctx, thrust).await?;
319 Ok(NodeStatus::Running)
320 }
321 AltitudeStep::Reached => {
322 tracing::debug!("LandNode: touched down, cutting rotor thrust");
323 self.control.publish_thrust(ctx, 0.0).await?;
324 Ok(NodeStatus::Success)
325 }
326 AltitudeStep::TimedOut => {
327 tracing::warn!("LandNode: timed out before reaching ground altitude");
328 Ok(NodeStatus::Failure)
329 }
330 }
331 }
332
333 fn reset_self(&mut self) {
334 self.control.reset();
335 }
336
337 fn name(&self) -> &str {
338 "LandNode"
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use mecha10_core::Receiver;
346 use serde_json::json;
347 use tokio::sync::mpsc;
348
349 fn default_config() -> AltitudeHoldConfig {
350 AltitudeHoldConfig {
351 motor_cmd_topic: default_motor_cmd_topic(),
352 odometry_topic: default_odometry_topic(),
353 rotor_count: 4,
354 hover_thrust: 0.5,
355 min_thrust: 0.0,
356 max_thrust: 1.0,
357 tolerance_m: 0.05,
358 timeout_secs: 15.0,
359 pid: PidGains {
360 kp: 0.5,
361 ki: 0.1,
362 kd: 0.05,
363 },
364 }
365 }
366
367 fn odometry_at(altitude: f32) -> Odometry {
368 Odometry {
369 timestamp: 0,
370 position: [0.0, 0.0, altitude],
371 orientation: [0.0, 0.0, 0.0, 1.0],
372 linear_velocity: [0.0, 0.0, 0.0],
373 angular_velocity: [0.0, 0.0, 0.0],
374 frame_id: None,
375 child_frame_id: None,
376 }
377 }
378
379 fn loop_with_channel(target_altitude_m: f64) -> (AltitudeHoldLoop, mpsc::UnboundedSender<Odometry>) {
380 let (tx, rx) = mpsc::unbounded_channel();
381 let control = AltitudeHoldLoop::with_receiver(default_config(), target_altitude_m, Receiver::from(rx));
382 (control, tx)
383 }
384
385 #[test]
386 fn no_reading_yet_holds_at_hover_thrust() {
387 let (mut control, _tx) = loop_with_channel(1.0);
388 assert_eq!(control.step(), AltitudeStep::Hold(0.5));
389 }
390
391 #[test]
392 fn below_target_climbs_with_thrust_above_hover() {
393 let (mut control, tx) = loop_with_channel(1.0);
394 tx.send(odometry_at(0.0)).unwrap();
395
396 match control.step() {
397 AltitudeStep::Hold(thrust) => assert!(thrust > 0.5, "expected climb thrust above hover, got {thrust}"),
398 other => panic!("expected Hold, got {other:?}"),
399 }
400 }
401
402 #[test]
403 fn within_tolerance_reports_reached() {
404 let (mut control, tx) = loop_with_channel(1.0);
405 tx.send(odometry_at(0.98)).unwrap();
406 assert_eq!(control.step(), AltitudeStep::Reached);
407 }
408
409 #[test]
410 fn exact_target_reports_reached() {
411 let (mut control, tx) = loop_with_channel(1.0);
412 tx.send(odometry_at(1.0)).unwrap();
413 assert_eq!(control.step(), AltitudeStep::Reached);
414 }
415
416 #[test]
417 fn overshoot_above_target_still_within_tolerance_is_reached() {
418 let (mut control, tx) = loop_with_channel(1.0);
419 tx.send(odometry_at(1.03)).unwrap();
420 assert_eq!(control.step(), AltitudeStep::Reached);
421 }
422
423 #[test]
424 fn thrust_is_clamped_to_configured_max() {
425 let mut config = default_config();
426 config.pid.kp = 100.0; let (tx, rx) = mpsc::unbounded_channel();
428 let mut control = AltitudeHoldLoop::with_receiver(config, 100.0, Receiver::from(rx));
429 tx.send(odometry_at(0.0)).unwrap();
430
431 match control.step() {
432 AltitudeStep::Hold(thrust) => assert!(thrust <= 1.0),
433 other => panic!("expected Hold, got {other:?}"),
434 }
435 }
436
437 #[test]
438 fn later_reading_overrides_earlier_one() {
439 let (mut control, tx) = loop_with_channel(1.0);
440 tx.send(odometry_at(0.0)).unwrap();
441 tx.send(odometry_at(1.0)).unwrap();
442
443 assert_eq!(control.step(), AltitudeStep::Reached);
444 }
445
446 #[test]
447 fn timed_out_after_timeout_elapses_without_reaching_target() {
448 let mut config = default_config();
449 config.timeout_secs = 0.0;
450 let (tx, rx) = mpsc::unbounded_channel();
451 let mut control = AltitudeHoldLoop::with_receiver(config, 1.0, Receiver::from(rx));
452 tx.send(odometry_at(0.0)).unwrap();
453
454 assert_eq!(control.step(), AltitudeStep::TimedOut);
455 }
456
457 #[test]
458 fn reset_clears_altitude_and_pid_state() {
459 let (mut control, tx) = loop_with_channel(1.0);
460 tx.send(odometry_at(1.0)).unwrap();
461 assert_eq!(control.step(), AltitudeStep::Reached);
462
463 control.reset();
464 assert_eq!(control.current_altitude_m, None);
465 }
466
467 #[test]
468 fn takeoff_from_json_parses_defaults() {
469 let config = json!({ "target_altitude_m": 1.5 });
470 assert!(TakeoffNode::from_json(config).is_ok());
471 }
472
473 #[test]
474 fn takeoff_from_json_requires_target_altitude() {
475 let config = json!({});
476 assert!(TakeoffNode::from_json(config).is_err());
477 }
478
479 #[test]
480 fn land_from_json_defaults_ground_altitude_to_zero() {
481 let config = json!({});
482 let node = LandNode::from_json(config).unwrap();
483 assert_eq!(node.control.target_altitude_m, 0.0);
484 }
485
486 #[test]
487 fn land_from_json_accepts_custom_ground_altitude() {
488 let config = json!({ "ground_altitude_m": 0.2 });
489 let node = LandNode::from_json(config).unwrap();
490 assert_eq!(node.control.target_altitude_m, 0.2);
491 }
492}