Skip to main content

ados_offload/
tier.rs

1//! The perception tier the agent picks: where detection / tracking / SLAM runs.
2//!
3//! NPU-less boards are first-class: a board with no accelerator runs an
4//! autonomous mission by offloading the heavy perception to a paired compute
5//! node, not as a degraded mode. The agent picks the tier from three inputs ,
6//! the HAL accelerator capability, whether a compute node is paired on an
7//! acceptable bearer, and whether the board can carry a light local path.
8
9use serde::{Deserialize, Serialize};
10
11/// Where the heavy perception (detection / tracking / SLAM) runs.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum PerceptionTier {
15    /// On the drone's own NPU / GPU (lowest latency).
16    Local,
17    /// Entirely on the paired compute node (NPU-less board, near-real-time).
18    Offload,
19    /// Light/fast work local (a small detector + fast VIO for the control loop),
20    /// heavy work (the big detector + drift-corrected SLAM) on the node.
21    Hybrid,
22}
23
24/// The inputs the agent reads to pick a perception tier.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct TierInputs {
27    /// The board has an NPU / GPU.
28    pub has_accelerator: bool,
29    /// The board has no NPU but a CPU strong enough to run the detector locally
30    /// (for example a Cortex-A76-class SoC). A distinct signal from
31    /// `has_accelerator`: it is a full local compute path, so it resolves to
32    /// `Local` the same way an accelerator does, and the board runs detection
33    /// on-board rather than offloading. Set it only when the board really has
34    /// that path, so the picker never invents one.
35    pub local_inference_capable: bool,
36    /// The required models fit + run on the local accelerator.
37    pub models_fit_locally: bool,
38    /// A compute node is paired (local-first, over the LAN or a relay).
39    pub compute_node_paired: bool,
40    /// The bearer's latency is inside the offload budget for the mission.
41    pub bearer_acceptable: bool,
42    /// The board can run a light local detector + fast VIO (the control-loop
43    /// path) even though it cannot run the heavy models.
44    pub can_run_light_local: bool,
45}
46
47impl TierInputs {
48    /// The tier inputs for a drone: the local compute picture (an NPU, or a CPU
49    /// strong enough for the detector) plus the offload-link signal (a paired,
50    /// reachable compute node). `models_fit_locally` is held `true` for an
51    /// accelerator board (assumed to fit its recommended detector, as there is
52    /// no model-fit probe) and `can_run_light_local` `false` (no split
53    /// light-detector plus fast-VIO path). Build every status view through this
54    /// so they all feed [`pick_tier`] identically.
55    pub fn for_drone(
56        has_accelerator: bool,
57        local_inference_capable: bool,
58        compute_node_paired: bool,
59        bearer_acceptable: bool,
60    ) -> Self {
61        TierInputs {
62            has_accelerator,
63            local_inference_capable,
64            models_fit_locally: true,
65            compute_node_paired,
66            bearer_acceptable,
67            can_run_light_local: false,
68        }
69    }
70}
71
72/// Pick the perception tier, or `None` when no heavy perception is available
73/// (an NPU-less board with no usable compute node, it flies on bare odometry,
74/// with no detection / tracking / map-based autonomy).
75///
76/// - A local compute path that fits the models ⇒ **Local** (the default): an
77///   accelerator (NPU / GPU), or a CPU strong enough to run the detector via the
78///   in-process ONNX backend (`local_inference_capable`). Either is a full
79///   on-board path, so it wins over an available node (lowest latency).
80/// - Otherwise, a paired node on an acceptable bearer ⇒ **Hybrid** when the
81///   board can carry the light local path, else **Offload**.
82/// - Otherwise, a board that can run a light local detector (no usable node) ⇒
83///   **Local** (degraded: the small on-board detector only, no heavy remote).
84/// - Otherwise ⇒ `None` (bare odometry, no detection / tracking / map autonomy).
85pub fn pick_tier(inputs: &TierInputs) -> Option<PerceptionTier> {
86    if (inputs.has_accelerator && inputs.models_fit_locally) || inputs.local_inference_capable {
87        return Some(PerceptionTier::Local);
88    }
89    if inputs.compute_node_paired && inputs.bearer_acceptable {
90        return Some(if inputs.can_run_light_local {
91            PerceptionTier::Hybrid
92        } else {
93            PerceptionTier::Offload
94        });
95    }
96    // No usable node: a board that can run a small local detector still has
97    // some local perception (degraded local), not none.
98    if inputs.can_run_light_local {
99        return Some(PerceptionTier::Local);
100    }
101    None
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn inputs() -> TierInputs {
109        TierInputs {
110            has_accelerator: false,
111            local_inference_capable: false,
112            models_fit_locally: false,
113            compute_node_paired: false,
114            bearer_acceptable: false,
115            can_run_light_local: false,
116        }
117    }
118
119    #[test]
120    fn an_accelerator_that_fits_the_models_is_local() {
121        let mut i = inputs();
122        i.has_accelerator = true;
123        i.models_fit_locally = true;
124        // Local wins even when a node is paired (lowest latency on-board).
125        i.compute_node_paired = true;
126        i.bearer_acceptable = true;
127        assert_eq!(pick_tier(&i), Some(PerceptionTier::Local));
128    }
129
130    #[test]
131    fn an_accelerator_whose_models_do_not_fit_offloads_when_a_node_is_paired() {
132        let mut i = inputs();
133        i.has_accelerator = true;
134        i.models_fit_locally = false; // models too heavy for this accelerator
135        i.compute_node_paired = true;
136        i.bearer_acceptable = true;
137        assert_eq!(pick_tier(&i), Some(PerceptionTier::Offload));
138    }
139
140    #[test]
141    fn a_cpu_onnx_capable_board_runs_local_without_an_accelerator() {
142        // A board with no NPU but a CPU strong enough for the in-process ONNX
143        // detector is a full local path: it runs detection on-board.
144        let mut i = inputs();
145        i.has_accelerator = false;
146        i.local_inference_capable = true;
147        assert_eq!(pick_tier(&i), Some(PerceptionTier::Local));
148        // Local wins even when a node is paired (lowest latency on-board).
149        i.compute_node_paired = true;
150        i.bearer_acceptable = true;
151        assert_eq!(pick_tier(&i), Some(PerceptionTier::Local));
152    }
153
154    #[test]
155    fn an_npu_less_board_with_a_paired_node_offloads() {
156        let mut i = inputs();
157        i.compute_node_paired = true;
158        i.bearer_acceptable = true;
159        assert_eq!(pick_tier(&i), Some(PerceptionTier::Offload));
160    }
161
162    #[test]
163    fn a_board_that_can_carry_the_light_path_is_hybrid() {
164        let mut i = inputs();
165        i.compute_node_paired = true;
166        i.bearer_acceptable = true;
167        i.can_run_light_local = true;
168        assert_eq!(pick_tier(&i), Some(PerceptionTier::Hybrid));
169    }
170
171    #[test]
172    fn an_unacceptable_bearer_is_not_an_offload_path() {
173        let mut i = inputs();
174        i.compute_node_paired = true;
175        i.bearer_acceptable = false; // too slow / saturated
176        assert_eq!(pick_tier(&i), None);
177    }
178
179    #[test]
180    fn a_light_local_board_with_no_node_runs_local_not_none() {
181        let mut i = inputs();
182        i.can_run_light_local = true; // a small on-board detector, no node
183        assert_eq!(pick_tier(&i), Some(PerceptionTier::Local));
184    }
185
186    #[test]
187    fn no_accelerator_no_node_no_light_local_is_none() {
188        // Bare odometry only: no detection / tracking / map-based autonomy.
189        assert_eq!(pick_tier(&inputs()), None);
190    }
191
192    #[test]
193    fn for_drone_maps_the_offload_link_to_the_expected_tier() {
194        // NPU-less, not CPU-inference-capable + a paired reachable workstation ⇒ offload.
195        assert_eq!(
196            pick_tier(&TierInputs::for_drone(false, false, true, true)),
197            Some(PerceptionTier::Offload)
198        );
199        // NPU-less + no link ⇒ none (honest: no offload path).
200        assert_eq!(
201            pick_tier(&TierInputs::for_drone(false, false, false, false)),
202            None
203        );
204        // An accelerator board runs local regardless of a link.
205        assert_eq!(
206            pick_tier(&TierInputs::for_drone(true, false, true, true)),
207            Some(PerceptionTier::Local)
208        );
209        // A CPU-ONNX-capable board runs local regardless of a link (no offload).
210        assert_eq!(
211            pick_tier(&TierInputs::for_drone(false, true, true, true)),
212            Some(PerceptionTier::Local)
213        );
214        assert_eq!(
215            pick_tier(&TierInputs::for_drone(false, true, false, false)),
216            Some(PerceptionTier::Local)
217        );
218        // A paired node on an unacceptable bearer is not an offload path.
219        assert_eq!(
220            pick_tier(&TierInputs::for_drone(false, false, true, false)),
221            None
222        );
223    }
224}