Skip to main content

mecha10_behavior_runtime/actions/
flight.rs

1//! Flight-stabilization action nodes for rotorcraft (e.g. the `drone` template's quadcopter):
2//! `TakeoffNode` climbs to a target altitude, `LandNode` descends back to the ground, both using
3//! a PID altitude-hold loop ([`crate::control::PidController`]) against odometry feedback to
4//! compute per-rotor thrust - the drone equivalent of `movement::MoveNode` publishing a `Twist`
5//! for a rover, except gated on reaching an altitude rather than on elapsed time.
6//!
7//! Both nodes share the same altitude-hold control loop ([`AltitudeHoldLoop`]) - they only differ
8//! in which altitude they hold as the setpoint (a target height above ground vs. ground level
9//! itself) and what a "reached" outcome means (climbing finished vs. touched down).
10
11use 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/// Shared configuration fields for [`TakeoffNode`]/[`LandNode`] - everything except the
22/// altitude each one holds as its setpoint.
23#[derive(Debug, Serialize, Deserialize)]
24struct AltitudeHoldConfig {
25    /// Topic to publish per-rotor thrust commands to (a `Vec<f32>`, one entry per rotor, all
26    /// equal since this is a pure vertical-thrust hold with no attitude correction).
27    #[serde(default = "default_motor_cmd_topic")]
28    motor_cmd_topic: String,
29    /// Topic to read altitude feedback from (`Odometry.position[2]`).
30    #[serde(default = "default_odometry_topic")]
31    odometry_topic: String,
32    /// Number of rotors to publish a thrust value for - matches the 4-prop drone template.
33    #[serde(default = "default_rotor_count")]
34    rotor_count: usize,
35    /// Baseline thrust (fraction of `max_thrust`) assumed to roughly counteract gravity at
36    /// hover; the PID output is added on top of this rather than driving thrust from zero, so
37    /// the controller only has to correct the *residual* error instead of re-deriving the
38    /// entire hover point from accumulated integral every time.
39    #[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    /// Altitude error (meters) within which the target is considered reached.
46    #[serde(default = "default_tolerance_m")]
47    tolerance_m: f64,
48    /// Give up and report `Failure` if the target altitude hasn't been reached within this many
49    /// seconds (e.g. the drone is stuck, or there's no odometry feed at all).
50    #[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/// Outcome of one [`AltitudeHoldLoop::step`] call, separated from `tick()`'s `ctx` wiring
89/// (subscribe/publish) so the altitude/PID decision logic is unit-testable without a live
90/// `Context` - same split as `SensorCheckNode::evaluate` vs. `SensorCheckNode::tick`.
91#[derive(Debug, PartialEq)]
92enum AltitudeStep {
93    /// Not reached yet - publish this thrust fraction to every rotor and keep running.
94    Hold(f64),
95    /// Within tolerance of the target altitude.
96    Reached,
97    /// `timeout_secs` elapsed without reaching the target altitude.
98    TimedOut,
99}
100
101/// Shared altitude-hold control loop used by both [`TakeoffNode`] and [`LandNode`].
102struct 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    /// Drain the odometry receiver (keeping only the most recent reading) and advance the PID
155    /// loop by one step. Pure with respect to messaging infrastructure - operates on the
156    /// already-subscribed `Receiver` and wall-clock time only.
157    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            // No odometry reading yet - hold at the hover baseline rather than climbing/
172            // descending blind.
173            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
220/// Climbs from the ground to `target_altitude_m` using a PID altitude-hold loop, publishing
221/// per-rotor thrust to `motor_cmd_topic` until the target is reached (`Success`) or
222/// `timeout_secs` elapses without reaching it (`Failure`).
223pub 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    /// Create a new TakeoffNode from JSON configuration
242    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
280/// Descends from the current altitude down to `ground_altitude_m` (default `0.0`) using the
281/// same PID altitude-hold loop as [`TakeoffNode`], publishing a zero-thrust command once
282/// touchdown is detected so the rotors don't keep holding hover thrust at ground level.
283pub 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    /// Create a new LandNode from JSON configuration
303    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; // force a huge correction
427        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}