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}