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