How would you fly a mesh drone swarm from one GCS with GPS degraded?
One ground station, N autonomous aircraft, a VIO stack and companion computer on each — and three separate problems arriving disguised as one. The radio is a shared, half-duplex medium whose capacity is divided by the whole fleet. Every drone's VIO origin is its own private frame, so "go to that point" means nothing until the frames are related. And a single GCS must sit outside every control loop, or the swarm is only as good as its worst link.
2.1Separate the Question Before Answering It
The instinct is to start drawing a system diagram. Resist it for a minute, because the question bundles four independent design problems, and conflating them is what produces architectures that fly beautifully with two drones and fall apart at eight:
- The medium. A Doodle Labs mesh is one shared, half-duplex RF channel. Total capacity is fixed and divided among every node, and it halves again for every relay hop. This is a bandwidth-and-airtime budgeting problem, and it is the one that hard-caps swarm size.
- The frame. Without GPS, each drone's VIO gives an excellent local pose in an origin it invented at boot. Twenty drones means twenty unrelated coordinate systems. Everything cooperative — formation, deconfliction, coverage, "the target is at X" — needs a common frame, and the residual error in that frame, not the VIO's own precision, is what sets your minimum separation distance.
- Authority. One GCS supervising N autonomous vehicles is a distributed-systems consistency problem: versioned intent, idempotent commands, partition behaviour, and a strict rule that no safety decision ever depends on a packet arriving.
- Degradation. "GPS-degraded" is not "GPS-denied." Denied is easy — the fix is gone, you switch sources and move on. Degraded means the receiver keeps reporting a plausible fix that is quietly wrong, and that is the case that crashes aircraft.
- MANET / layer-2 mesh
- Mobile ad-hoc network. Every radio is both an endpoint and a relay, so nodes out of direct range reach each other over multiple hops with no infrastructure. Doodle's OpenWrt-based firmware presents this as a flat layer-2 broadcast domain (802.11s or batman-adv depending on the build), which is why plain UDP sockets "just work" across the fleet — and why broadcast traffic is far more expensive than it looks.
- Airtime
- The real currency of a wireless network. Not bits per second — microseconds of channel occupancy. A small packet sent at a low modulation rate can cost more airtime than a large packet sent fast, and every retransmission, ACK, and relay hop spends it again.
- MCS / rate adaptation
- Modulation and Coding Scheme index. The radio picks a higher MCS (more bits per symbol) on a clean link and drops to a robust low MCS as SNR falls. Throughput therefore collapses non-linearly with range, and the collapse is what you feel first at the edge of a mission box.
- TDD
- Time-division duplex. Transmit and receive share one frequency by alternating in time, so a node cannot listen while it talks. Sending and receiving both draw from the same airtime budget.
- DFS
- Dynamic Frequency Selection. On radar-shared 5 GHz channels, regulation requires the radio to detect a radar pulse and vacate the channel within seconds. Mid-mission, uncommanded, fleet-wide.
- VIO
- Visual-Inertial Odometry. Fuses camera feature tracks with IMU integration to estimate 6-DOF motion. Excellent short-term relative accuracy, unbounded long-term drift, and no notion of a global frame.
2.2Per-Drone Stack: Two Chains, Not One
Each aircraft carries three compute-bearing elements: the flight controller (an STM32-class MCU running ArduPilot or PX4, owning the 400 Hz control loop and the EKF), the companion computer (a Jetson Orin NX or Qualcomm RB5 class SoC running VIO, perception, and the swarm agent), and the Doodle Labs radio (itself a small Linux box on the network, not a dumb modem). The critical design decision is that the estimation chain and the comms chain are separate, and they touch in exactly two places.
The single most important architectural choice on this diagram is what is not there: no MAVLink on the mesh. The tempting build is mavlink-router on each companion bridging the flight controller's serial link straight onto the network, with the GCS opening N MAVLink connections. It works for two drones and dies at eight. Default ArduPilot telemetry streams run 40–60 kB/s per vehicle of ATTITUDE, SYS_STATUS, RAW_IMU and friends, most of it useless to a swarm supervisor; MAVLink's ACK-and-retry semantics for parameters and missions were designed for a single point-to-point link; and multi-hop routing of per-vehicle streams means the far drone's chatter is relayed by three others before reaching the operator. Terminate MAVLink at the companion. Publish a compact, purpose-built swarm state message instead. Bridge full MAVLink to the GCS only on demand, for one vehicle at a time, when an operator explicitly opens a detail view.
2.3The Medium Is the Constraint: Budgeting Airtime
Doodle Labs Mesh Rider radios are 802.11-derived: a TDD, rate-adaptive, CSMA-based PHY/MAC in a selectable band (sub-GHz through 5.x GHz) with configurable channel widths from roughly 3 MHz up to 40 MHz. In a 20 MHz channel you might see 40–60 Mbps PHY rate at short range, which translates to somewhere in the 20–30 Mbps range of usable single-hop application throughput — shared across the entire fleet, halving with each additional hop, and degrading with range as rate adaptation walks down the MCS ladder. That aggregate number, not any per-drone figure, is the budget you spend.
The trap specific to a layer-2 MANET is broadcast. It looks free — one sendto() to a broadcast address and every drone has your state — but broadcast frames are unacknowledged, transmitted at the low basic/multicast rate rather than the negotiated MCS, and re-flooded by every forwarding node so that a single logical message occupies the channel once per relay.
Airtime for one broadcast frame, 72 B payload, multicast rate left at the 6 Mbps default:
t ≈ preamble(20µs) + DIFS(34µs) + avg backoff(~67µs) + payload(1024 bits ÷ 6 Mbps = 171µs) ≈ 290 µs
20 drones broadcasting state at 5 Hz = 100 frames/s ⇒ 29 ms/s ≈ 2.9% airtime. Comfortable.
Now put those 20 drones in a mesh where 6 nodes forward: each frame is re-sent 6 more times ⇒ 700 frames/s ⇒ ~20% airtime, for state broadcast alone — before a single video frame, task bid, or log line moves.
Raise the multicast rate to 24 Mbps and the payload term drops to 43 µs, cutting the total to ~164 µs. Roughly a 40% airtime saving from one radio config line, at the cost of shorter broadcast reach — which is exactly the trade you want to make consciously rather than inherit.
So the design rules follow from arithmetic, not taste: keep the broadcast payload small and fixed-size, keep its rate low, raise the multicast MCS deliberately, and push anything larger than a state beacon onto unicast — unicast is routed rather than flooded, rate-adapted to the actual link, and ACKed, so a 1 MB unicast transfer to one peer is cheaper in airtime than a 100 kB broadcast to all of them.
Traffic classes and what each one is allowed to cost
| Class | Rate / size | Transport | Loss tolerance | Priority |
|---|---|---|---|---|
| Safety commands (abort, land, hold) | Event-driven, <40 B | Unicast + ACK, retried, plus broadcast epoch bump | Zero — must be idempotent and re-sendable | Highest (DSCP CS6 / WMM voice) |
| Swarm state beacon | 5 Hz, 72 B fixed | Broadcast, no retry | High — next one arrives in 200 ms | High |
| Task consensus / bids | 1–2 Hz during allocation only, ~200 B | Broadcast, epoch-versioned | High — protocol must converge under loss | Medium |
| Mission intent (GCS → fleet) | Event-driven, 1–20 kB | Unicast per drone + ACK on (epoch, hash) | Zero, but retry is safe by construction | Medium |
| Detections / perception metadata | 1 Hz, ~500 B, plus thumbnails at 32–64 kbps | Unicast to GCS | Medium | Low |
| Live video | 1080p30 H.265 ≈ 3–4 Mbps per stream | Unicast, GCS-arbitrated, one or two slots total | High (it is a picture, not a control input) | Lowest, and rate-capped |
| Logs / OTA payloads | Hundreds of MB | Unicast, ground-only, never in flight | n/a | Scavenger |
Video is the line item that decides the architecture. Four megabits per stream against a ~25 Mbps shared single-hop ceiling means two, maybe three concurrent 1080p feeds — at one hop, at short range, with nothing else happening. At three hops it is one. Twenty simultaneous operator video feeds is not a tuning problem; it is a physics problem. The answer is that video is an arbitrated, scarce resource: every drone continuously ships cheap detection metadata and a low-rate thumbnail, and full-rate video is granted to one or two vehicles at a time on operator request, with adaptive bitrate control driving the encoder from measured link quality rather than a fixed setting.
The state beacon, concretely
Fixed-size, scaled integers rather than floats, sequence-numbered so receivers can measure loss, and DSCP-tagged so the radio's WMM queues actually prioritise it:
struct __attribute__((packed)) SwarmState { // 72 bytes on the wire
uint8_t system_id;
uint8_t msg_version;
uint16_t seq; // wraps; receiver derives loss rate
uint64_t t_mono_us; // sender's synced monotonic clock
int32_t pos_cm[3]; // position in the COMMON frame, cm
int16_t vel_cm_s[3]; // velocity, cm/s
int16_t yaw_cdeg; // centidegrees
uint16_t pos_sigma_cm; // 1-sigma horizontal, common-frame
uint16_t frame_sigma_cm; // uncertainty of my VIO->common transform
uint8_t nav_source; // GPS_VIO | VIO | FLOW | DEAD_RECKON
uint8_t vio_health; // 0-255: feature count + covariance trace
uint8_t reset_counter; // increments on every VIO discontinuity
uint8_t flight_mode;
uint8_t battery_pct;
uint16_t task_id;
uint32_t mission_epoch; // which intent version I am executing
uint8_t reserved[8];
};
static_assert(sizeof(SwarmState) == 72, "wire format is frozen");
// Mark it CS6 so the radio's WMM queue treats it as control traffic,
// and cap the socket so a bug cannot flood the channel.
int dscp = 0xC0; // CS6 << 2
setsockopt(fd, IPPROTO_IP, IP_TOS, &dscp, sizeof(dscp));
Two fields in there matter more than the rest and are the ones candidates leave out. frame_sigma_cm makes the frame-alignment uncertainty a first-class, transmitted quantity, so every receiver can size its own safety margins correctly. And reset_counter propagates VIO discontinuities across the fleet, so a neighbour knows to distrust a 4-metre position jump instead of planning an avoidance manoeuvre around it.
2.4The Frame Problem: Twenty Origins, One Mission
This is the part that separates people who have flown a GPS-denied swarm from people who have read about one. A VIO stack initialises its origin wherever the drone happened to be when it converged, with a yaw that is whatever the initialisation procedure produced. Drone 3's "(12, 40, −8)" and drone 7's "(12, 40, −8)" are different places on the earth. Until you relate the frames, no cooperative behaviour is possible — not formation, not coverage partitioning, not "drone 5, investigate what drone 2 just found," and above all not deconfliction.
Formally, each drone i maintains an estimate of the rigid transform from its VIO frame to a shared world frame, T_world_vio_i ∈ SE(3), and the operationally relevant quantity is not the accuracy of either drone's odometry but the error in the relative transform between any two of them. Four ways to get it, and they compose:
| Method | What it gives | Typical relative error | Cost | Fails when |
|---|---|---|---|---|
| Surveyed launch grid | Initial T_world_vio_i from known takeoff positions and a common heading reference | Starts at 10–30 cm; then grows with VIO drift, unbounded | Nearly free — ten minutes of setup | Long missions; yaw error dominates and is the hardest axis to initialise well |
| Fiducial anchors (AprilTag at surveyed points) | Direct absolute pose fix on sight, via PnP | 2–10 cm at close range, degrading with distance and viewing angle | Infrastructure placement + a camera that can see them | No line of sight; unprepared sites; tags occluded or wet |
| Opportunistic GNSS anchoring | Loose prior on T_world_vio_i whenever a fix passes quality gates | 1–5 m, but bounded — drift stops accumulating | Free; the receiver is already aboard | Truly denied environments; and dangerous if you trust a degraded fix (see §2.6) |
| Inter-drone constraints (UWB ranging, or shared-landmark co-visibility) | Relative geometry directly, without any world frame | 10–30 cm (UWB); metre-level from co-visibility | Extra payload (UWB) or real bandwidth + compute (collaborative SLAM) | Sparse fleets; NLOS between drones; feature-poor scenes |
For a real deployment I would layer them rather than pick one: surveyed launch to bootstrap, opportunistic GNSS anchoring to bound growth wherever the sky opens up at all, fiducials at the handful of locations where precision genuinely matters (ingress point, target area, landing pad), and — only if the mission demands tight relative flight — UWB for direct inter-drone ranging. Collaborative SLAM by exchanging place-recognition descriptors and keyframe poses is the elegant answer for large-area missions, and it is bandwidth-feasible if and only if you exchange compact descriptors (DBoW2 vectors, NetVLAD embeddings) plus relative-pose constraints, and never point clouds.
sep ≥ 3σ_frame + 3σ_control + vehicle_radius + reaction_distance, transmit σ_frame in the state beacon, and expand the corridors automatically as it grows. A swarm that tightens formation while its frame uncertainty is quietly widening is a swarm building toward a mid-air.The altitude trick worth knowing
Horizontal frame error is hard. Vertical separation is comparatively cheap, so lean on it: assign each drone a distinct altitude band and you decouple the collision risk from your worst-estimated axis. The subtlety is choosing the reference. Barometric altitude drifts with weather, but it drifts common-mode — so if the GCS broadcasts a single reference pressure that every drone uses for its baro conversion, absolute altitude may wander by metres while relative altitude between drones stays consistent to tens of centimetres. That is precisely the property deconfliction needs. Per-drone rangefinders give true AGL, which is what you want for terrain following, but AGL is a bad separation reference over sloping ground: two drones at 20 m AGL on opposite sides of a ridge are not vertically separated at all.
2.5The VIO Stack on the Companion
Sensor selection dominates algorithm selection, the same way it did in Q1. Non-negotiables: a global-shutter stereo pair (rolling shutter plus prop vibration produces geometric distortion that no back-end can model away), hardware-triggered exposure with the IMU on the same time base (software timestamps at 30 Hz leave jitter that appears to the estimator as spurious acceleration), a wide field of view to keep features in frame during aggressive yaw, and an IMU physically isolated from the frame with its own mount rather than borrowed from the flight controller. Stereo over monocular, because monocular scale is unobservable without sustained acceleration — and "sustained acceleration" is exactly what a drone hovering to inspect something is not doing.
| Stack | Type | Drift (translation) | Relocalisation | Companion load | Best fit |
|---|---|---|---|---|---|
| OpenVINS | MSCKF filter, tightly coupled | ~0.5–1.5% of path length | None built in | Low, CPU-only, very predictable | Constrained companions; when bounded, deterministic CPU cost matters more than the last 0.3% of accuracy |
| VINS-Fusion | Sliding-window optimisation, loop closure | ~0.3–1% | Yes (DBoW2) | Medium-high, multi-core CPU, bursty | Long missions revisiting the same space; the loop closure genuinely pays |
| ORB-SLAM3 | Feature SLAM, map reuse, multi-map | ~0.3–0.8% with loop closure | Strong — relocalises into a saved map | High; memory grows with map | Repeated missions over a pre-mapped site; the saved map is effectively free absolute localisation |
| cuVSLAM / Isaac ROS Visual SLAM | GPU stereo VIO | ~0.5% | Yes | GPU-heavy, CPU-light — leaves cores for perception | Jetson platforms already carrying a detection network; the division of labour is the win |
| Downward flow + rangefinder | Not VIO — velocity only | Position drifts without bound; velocity stays honest | None | Negligible | The fallback tier, not the primary. Keeps a drone controllable when VIO dies outright |
Note what the drift column actually means operationally: 1% of path length over a 1.2 km sortie is 12 metres of accumulated position error. If the mission plan has 8-metre corridors, the VIO was never the problem — the plan was.
Handing the pose to the flight controller correctly
The companion publishes MAVLink ODOMETRY (#331) or VISION_POSITION_ESTIMATE (#102) into the EKF, and there are four ways to get this wrong that all present as "the drone twitches":
import time
from pymavlink import mavutil
class VioBridge:
"""Publishes VIO pose to ArduPilot EKF3 at 30 Hz."""
def __init__(self, mav):
self.mav = mav
self.reset_counter = 0 # MUST increment on every VIO discontinuity
self.clock_offset_us = 0 # companion monotonic -> FC boot micros
self.last_vio_seq = None
def sync_clock(self):
# MAVLink TIMESYNC round-trip; run continuously and low-pass the
# estimate. Never use wall-clock time -- NTP steps will step your
# state estimate, and chrony can slew mid-flight.
t0 = time.monotonic_ns() // 1000
self.mav.mav.timesync_send(0, t0)
msg = self.mav.recv_match(type="TIMESYNC", blocking=True, timeout=0.5)
if msg is None:
return
t1 = time.monotonic_ns() // 1000
rtt = t1 - t0
if rtt > 20000: # 20 ms: too jittery to trust this sample
return
remote = msg.ts1 // 1000
self.clock_offset_us = remote - (t0 + rtt // 2)
def publish(self, vio):
if vio.seq != self.last_vio_seq and vio.had_reset:
# A VIO restart, relocalisation jump, or origin change. Without
# bumping this, EKF3 treats the jump as a real 4 m translation
# and the position controller flies the aircraft at it.
self.reset_counter = (self.reset_counter + 1) % 256
self.last_vio_seq = vio.seq
# Covariance is not decoration: EKF3 weights the fusion by it, and
# a constant optimistic value is how VIO drift gets baked in as
# truth. Feed the real marginal covariance from the back-end,
# inflated when feature count drops.
cov = vio.pose_covariance_upper_triangle() # 21 floats
self.mav.mav.odometry_send(
int(time.monotonic_ns() // 1000) + self.clock_offset_us,
mavutil.mavlink.MAV_FRAME_LOCAL_FRD, # frame_id
mavutil.mavlink.MAV_FRAME_BODY_FRD, # child_frame_id
vio.x, vio.y, vio.z,
vio.q, # [w, x, y, z]
vio.vx, vio.vy, vio.vz,
vio.rollspeed, vio.pitchspeed, vio.yawspeed,
cov[:21], cov[:21],
self.reset_counter,
mavutil.mavlink.MAV_ESTIMATOR_TYPE_VIO,
vio.quality, # 0-100, -1 if unknown
)
On the flight-controller side, the parameters that have to match physical reality (names drift between releases — check against the version you are flying): the camera's lever arm in body frame, so the EKF can rotate out the rotational-velocity contribution at the sensor; the sensor-to-body orientation; and the measurement delay, because a VIO pose describing where the drone was 60 ms ago, fused as though it describes now, becomes a phase lag in the position loop that shows up as a slow oscillation in hover.
# ArduPilot: external nav + EKF3 source sets
VISO_TYPE 2 # MAVLink ODOMETRY / VISION_POSITION_ESTIMATE
VISO_POS_X 0.10 # camera lever arm from IMU, body frame, metres
VISO_POS_Y 0.00
VISO_POS_Z -0.05
VISO_ORIENT 0
VISO_DELAY_MS 60 # measure this; do not guess it
VISO_POS_M_NSE 0.20 # floor on trusted position noise
VISO_VEL_M_NSE 0.10
# Source set 1 = GNSS healthy. Set 2 = VIO primary. Set 3 = flow fallback.
EK3_SRC1_POSXY 3 # GPS
EK3_SRC1_VELXY 3
EK3_SRC1_POSZ 1 # baro
EK3_SRC1_YAW 1 # compass
EK3_SRC2_POSXY 6 # ExternalNav (VIO)
EK3_SRC2_VELXY 6
EK3_SRC2_POSZ 1
EK3_SRC2_YAW 6 # VIO yaw -- see the field note below
EK3_SRC3_POSXY 0 # none
EK3_SRC3_VELXY 5 # optical flow
EK3_SRC3_POSZ 2 # rangefinder
EK3_SRC3_YAW 1
2.6Degraded Is Worse Than Denied
A drone that loses GPS entirely is in a well-defined state: the receiver reports no fix, the EKF's GPS innovations blow past their gates, the source arbitrator switches to VIO, the mission continues. Handled.
The dangerous case is the one the question actually names. Under multipath in an urban canyon, or partial sky blockage under canopy, or in the presence of interference or spoofing, the receiver reports a fix with a plausible HDOP and a plausible satellite count that is wrong by 20 metres and wandering. The EKF fuses it happily — it looks exactly like a valid measurement — and the aircraft flies a smooth, confident trajectory into a building. GPS quality metrics reported by the receiver are self-assessment, and self-assessment is the one thing a compromised or confused sensor cannot be trusted to provide.
Detection has to come from cross-validation between independent sensors, not from any one sensor's opinion of itself. The three signals worth combining:
- EKF innovation statistics. The normalised innovation squared for the GPS position measurement, gated against a chi-squared threshold, tests whether the fix is consistent with the propagated state. A slowly walking spoof or multipath bias produces innovations that are individually inside the gate but persistently biased — so track the running mean of the innovation, not just per-sample rejection. A zero-mean innovation sequence is healthy; a consistently positive one is a sensor lying smoothly.
- VIO–GNSS disagreement over a window. Over 10 seconds, VIO's relative displacement is far more trustworthy than GNSS's. Compare the two displacement vectors: if GNSS says the drone moved 14 m north and VIO says 6 m north, GNSS is wrong, regardless of what HDOP claims.
- Physical plausibility. Jumps exceeding the airframe's kinematic limits, satellite count changing implausibly fast, carrier-to-noise ratios that are suspiciously uniform across satellites (a classic spoofing signature), reported velocity inconsistent with the accelerometer integral.
class GnssArbiter:
"""Decides whether the GNSS fix deserves to be in the estimate.
Degrade in under 200 ms; require 10 s of consistent evidence to recover."""
GATE = 9.0 # chi-squared, 2-DOF, ~99%
BIAS_WINDOW = 10.0 # seconds
BIAS_LIMIT = 3.0 # metres of persistent innovation mean
VIO_DISAGREE_LIMIT = 4.0
RECOVER_HOLD = 10.0
def update(self, t, fix, ekf, vio):
reasons = []
# 1. Per-sample consistency with the propagated state.
nis = ekf.normalised_innovation_squared(fix)
if nis > self.GATE:
reasons.append(f"innovation gate {nis:.1f}")
# 2. Persistent bias -- the signature of multipath or a walking spoof.
# Individually plausible samples, collectively impossible.
self.bias.push(t, ekf.innovation_vector(fix))
if self.bias.window_mean_norm(self.BIAS_WINDOW) > self.BIAS_LIMIT:
reasons.append("innovation mean biased")
# 3. Independent cross-check: relative displacement over the window.
d_gnss = fix.pos - self.fix_history.at(t - self.BIAS_WINDOW)
d_vio = vio.pos - self.vio_history.at(t - self.BIAS_WINDOW)
if norm(d_gnss - d_vio) > self.VIO_DISAGREE_LIMIT and vio.healthy:
reasons.append("disagrees with VIO displacement")
# 4. Cheap physical plausibility.
if fix.jumped_further_than(self.max_speed * fix.dt * 1.5):
reasons.append("kinematically impossible jump")
if fix.cn0_variance_across_sats() self.RECOVER_HOLD:
self.trusted = True
log.info("GNSS restored after %.1f s clean", t - self.healthy_since)
return "PROMOTE_TO_GNSS"
return "HOLD"
2.7One GCS, N Autonomous Vehicles
The single-GCS requirement is where the answer becomes a distributed-systems answer. The rule: the GCS publishes intent and consumes state; it is never inside a control loop, and no safety-critical decision depends on a packet arriving.
Concretely, the operator does not command trajectories. The operator commands a mission specification — the area to cover, the tasks to perform, the constraint envelope (geofence, altitude bands, minimum separation, no-go volumes), and the rules of engagement for degradation. That specification carries a monotonically increasing mission_epoch and a content hash. It goes out by unicast to each drone, which acknowledges (epoch, hash). Retransmission is safe by construction because applying the same epoch twice is a no-op, which means the protocol needs no exactly-once delivery guarantee — and over a lossy mesh, "make retries idempotent" is a far more robust engineering choice than "make delivery reliable."
The state beacon flows the other way, and the GCS reconstructs the fleet picture from it. Note the asymmetry that makes this scale: intent is large but rare and unicast; state is small but continuous and broadcast.
The failsafe timer ladder
This is where fleets bite you, and the failure is always the same shape: two layers each independently decide to act, and the wrong one wins. A GCS configured with a 5-second link-loss timeout that sends a return-to-home command is useless if the flight controller's own 3-second failsafe has already forced a mode change that overrides it. Both timers were individually reasonable. Together they produced a drone that neither returned home nor held position.
The rule that prevents it: the layer that owns a decision must fire first, and every backstop layer must have a timeout of at least twice the layer inside it, plus worst-case network jitter. Write the ladder down as a table, put it under version control, and make it a review gate — because these numbers live in three different config files on three different processors, and nothing in any build system will tell you they have drifted out of order.
| Timeout | Owner | Detects | Action |
|---|---|---|---|
| 200 ms | Companion | Flight-controller serial link stale | Stop sending setpoints; let the FC's own position hold take over |
| 600 ms | Companion | Neighbour state stale (3 missed beacons) | Inflate that neighbour's position uncertainty; shrink own permitted volume |
| 2 s | Companion | Neighbour presumed lost | Suspend cooperative manoeuvres; hold inside own leased volume only |
| 5 s | Companion | GCS unreachable | Continue the current mission autonomously — this is the designed case, not a fault |
| 60 s | Companion | GCS still unreachable | Complete current task, then return to the rendezvous point per mission ROE |
| 120 s | Flight controller | No companion heartbeat at all | Independent RTL / land — the backstop, and it must not be reachable by any earlier layer |
Task allocation and deconfliction under partition
Assignment uses a distributed market-based auction (CBBA and its variants): each drone bids on task bundles from its own battery state, distance, and sensor fit; agents exchange bid vectors over the mesh and converge on a conflict-free assignment with no central scheduler. This matters for the single-GCS requirement specifically, because it means the operator's laptop is not the allocator — the swarm reassigns itself when a drone drops out, whether or not the GCS is watching.
What people miss is that the auction must be designed for a network that loses messages and partitions. That means bids are idempotent and epoch-versioned, convergence is eventual rather than synchronous, and a partition must be safe rather than merely unlikely. The mechanism I would use for the safety half is volume leases: each drone holds a time-bounded reservation on a spatial volume, renewed continuously while it can talk to its neighbours. If the mesh partitions, leases expire on their own, and each drone falls back to the volume it last held uncontested. Two isolated halves of a partitioned swarm cannot both claim the same airspace, because neither can renew a lease the other holds. Safety comes from the expiry, which needs no network at all, rather than from an agreement that needs one.
For tactical avoidance, the sharp edge is stale data. A drone executing a reciprocal-velocity-obstacle manoeuvre against a neighbour position from 3 seconds ago is actively dangerous — it is confidently avoiding where that neighbour used to be. Stale state is worse than absent state, because absent state at least triggers the conservative path. The fix is to make staleness explicit in the geometry:
def neighbour_keepout_radius(nbr, t_now, self_frame_sigma):
"""Radius to keep clear of a neighbour, given everything we do not know.
Each term is a distinct source of ignorance; they add, they do not max."""
age = t_now - nbr.t_mono_us * 1e-6
# Where it could have travelled since we last heard from it.
reachable = nbr.speed * age + 0.5 * MAX_ACCEL * age ** 2
# Frame uncertainty is pairwise: our transform error plus theirs.
frame = 3.0 * hypot(self_frame_sigma, nbr.frame_sigma_cm / 100.0)
# Their own estimator's uncertainty, in their own frame.
est = 3.0 * nbr.pos_sigma_cm / 100.0
r = VEHICLE_RADIUS + reachable + frame + est + CONTROL_ERROR_MARGIN
if age > NEIGHBOUR_LOST_S:
# Past this point the reachable-set term is so large that "avoid" is
# meaningless. Stop cooperating and retreat to our own leased volume:
# a manoeuvre computed from a 3-second-old position is worse than
# no manoeuvre at all.
raise NeighbourLost(nbr.system_id, age)
return r
2.8Clock Sync, and Why It Is Load-Bearing
Four things in this system need a common time base: cross-drone state fusion (a neighbour's position is meaningless without knowing when it was true), any application-layer transmit scheduling, post-flight multi-log correlation during incident analysis, and — most tightly — camera-to-IMU alignment inside each VIO instance.
With GNSS available, the pulse-per-second output gives every drone a sub-microsecond common epoch, which is more than enough for all four. GPS-degraded is precisely the case where that goes away, so the fallback matters. PTP with hardware timestamping is not available across an 802.11 mesh, so the realistic fallback is NTP or a lightweight round-trip offset protocol against a designated leader — the GCS, or whichever drone currently holds the best fix — landing somewhere in the 1–10 ms range over a wireless multi-hop link. Ask whether that is good enough and the answer splits by use case: at 15 m/s, 10 ms is 15 cm of position ambiguity, which is fine for swarm state and deconfliction margins, and nowhere near good enough to fuse visual observations across drones into a shared map. Design accordingly — per-drone VIO can rely on hardware-triggered local sync, and anything cross-drone must be built to tolerate millisecond-scale timestamp error.
CLOCK_REALTIME by seconds. Any timer, timeout, or timestamp difference on the critical path uses CLOCK_MONOTONIC, with an explicitly estimated offset to whatever shared epoch you need. This is a one-line bug that produces a fleet-wide incident, and it is invisible on the bench because the bench clock is already synced.2.9What Breaks at Each Scale
Swarm architectures do not degrade smoothly with fleet size; they hit distinct walls. Knowing which wall arrives when is the difference between an architecture and a demo:
| Fleet size | What breaks first | What it forces |
|---|---|---|
| 2–3 | Nothing. Naive designs work: bridged MAVLink, per-drone GCS connections, no frame alignment beyond a shared takeoff point. | Nothing — which is exactly why demos at this scale teach you nothing about the real system. |
| 5–8 | Bridged MAVLink telemetry saturates the channel; the operator UI becomes unusable as N windows. | Terminate MAVLink at the companion. Compact state beacon. Exception-based UI instead of N telemetry panes. |
| 10–20 | Broadcast re-flooding dominates airtime; multi-hop latency becomes visible; frame drift between the most-separated pair exceeds planned corridor widths. | Raise multicast MCS, cut beacon rate, unicast anything large. Active frame-alignment (anchors, opportunistic GNSS) rather than survey-and-hope. Video becomes an arbitrated resource. |
| 25–50 | Mesh routing overhead and route flapping under mobility; auction convergence time approaches the mission's replanning interval; one operator cannot supervise the fleet cognitively regardless of what the network does. | Hierarchy: sub-swarms with designated relay/leader nodes, aggregated upward. Operator supervises groups and exceptions, never individual aircraft. Consider a second RF channel or band for the backhaul tier. |
2.10How You Would Actually Validate It
The validation ladder matters as much as the architecture, because almost every failure mode above is invisible in simulation unless you deliberately simulate the network, and invisible on the bench unless you deliberately degrade the RF:
- Replay. Recorded flight data through the VIO stack with synthetically injected GPS degradation — slow biases, sudden jumps, plausible-but-wrong fixes. This is the only cheap way to test §2.6, and the only way to test it repeatably.
- Swarm SITL, with the network modelled. N flight-controller SITL instances plus N real companion stacks, connected through emulated links —
tc netemfor loss, latency, jitter and bandwidth caps, plus a topology model so multi-hop and partition are real. Simulating the vehicles but not the network is the most common gap, and it hides every interesting bug in this system. - HITL on the bench. Real flight controllers, real companions, real Doodle Labs radios, with programmable RF attenuators sweeping the link budget from full-rate down to link loss. This is where the failsafe ladder in §2.7 gets verified with a stopwatch, and where you find the two timers that are in the wrong order.
- Incremental field. One drone GPS-denied. Then two, separated far beyond any plausible frame error, measuring actual relative frame drift against surveyed ground truth. Only then tighten separation, and only to the margin the measurements justify. The measured
σ_frameis what earns a corridor width — not the VIO paper's drift number. - Adversarial rehearsal. Deliberately partition the mesh mid-mission. Kill a drone's VIO mid-transit. Spoof a GPS bias in flight if you have the range clearance to do it safely. Every one of these must be a rehearsed, observed outcome before it is a surprise.
Why not just run ROS 2 over the mesh and let DDS handle discovery and QoS?
Because default DDS discovery is multicast-flooded and scales as O(N²) in participant pairs, and every one of those flooded packets pays the broadcast airtime penalty from §2.3 — sent at the basic rate, re-flooded by every forwarder. A fleet of twenty ROS 2 nodes-per-drone can spend a substantial fraction of the channel on discovery and liveliness traffic before publishing anything useful. DDS's reliability QoS also assumes a link where retransmission is cheap; over a multi-hop mesh, aggressive ACKNACK repair turns a lossy link into a congested one.
It is workable with real effort — static discovery peer lists, a discovery server, careful QoS profiles per topic, and a domain/partition layout that keeps intra-drone traffic off the air entirely. But the honest version is that you end up hand-configuring away most of what DDS does automatically. Running ROS 2 within each drone, where the transport is loopback or shared memory and its ergonomics are a real win, and a purpose-built compact protocol over the mesh between drones, gives you explicit control of exactly the resource that is scarce. The general principle: on a shared, lossy, half-duplex medium, prefer a protocol whose wire cost you can compute by hand.
How do you know the relative frame error in flight, rather than hoping it is small?
Make it an observable, not an assumption. Four cheap sources: (1) when two drones see the same fiducial or the same distinctive landmark, the disagreement between their two computed world poses is a direct sample of the pairwise frame error; (2) whenever any drone gets a GNSS fix that passes the §2.6 gates, the residual against its VIO-predicted position bounds its own transform error; (3) UWB ranging, if fitted, measures inter-drone distance directly, and the discrepancy against the distance implied by the two reported positions is exactly the pairwise frame error along that baseline; (4) as a floor, propagate each VIO's covariance and the elapsed distance travelled through the drift model to get a conservative bound with no measurement at all.
Then do the part that most designs skip: transmit it (frame_sigma_cm in the beacon), display it to the operator alongside battery, and let it drive behaviour — corridors and separation minima widen automatically as it grows, and the mission degrades to a more conservative pattern when it exceeds what the plan assumed. A number that is measured but does not change any decision is telemetry theatre.
The operator wants manual stick control of one drone inside the GPS-degraded area. What do you give them?
Velocity commands in the drone's body frame, at a modest rate, with a hard stale-command timeout and an onboard envelope — never attitude or rate commands. Attitude control over a multi-hop mesh with variable latency and jitter is closing a fast loop over a link with unbounded delay, and the operator's inner ear is not in the loop the way it is with a direct RC link.
The specifics: cap command age at 200–300 ms and transition to position hold when it is exceeded (position hold, not "keep the last velocity" — a drone that keeps flying the last commanded velocity after link loss is a runaway). Enforce the geofence, terrain floor, separation minima and the drone's leased volume onboard, so the manual input is a request the autonomy clips rather than an authority that overrides it. And bump the video allocation to that vehicle for the duration, because manual control without a live picture is worse than no manual control, and be explicit that the operator is now consuming most of the fleet's video budget.
How do you push a firmware or config update to twenty drones over this mesh?
Never in flight, and never to all twenty at once. On the ground, with A/B partitioned images so a bad update rolls back on next boot rather than bricking an airframe: write to the inactive partition, verify a hash, mark it try-once, boot, and only commit after the new image proves itself healthy. Bandwidth-wise, sequential unicast to each drone beats broadcast — unicast is rate-adapted to each link and does not flood, so twenty sequential 200 MB transfers move more total bytes per unit airtime than one broadcast attempt, and a failed transfer retries against one drone instead of restarting the fleet.
Operationally: canary first. One drone updates, flies a short validation sortie, and only then does the rest of the fleet follow. The fleet-wide failure mode you are protecting against is a config change that is individually valid on every aircraft but breaks an inter-drone assumption — a wire-format change, a timer that is now out of ladder order, a beacon rate that pushes airtime over budget. Those are invisible to per-drone testing by construction, which is why a version-compatibility check belongs in the beacon (msg_version) and mixed-version operation should be either explicitly supported or explicitly refused at arm time.
How many drones can one operator actually supervise, as opposed to how many the network supports?
Fewer than the network allows, and the two limits should be discussed separately. Network capacity in this architecture runs out somewhere in the 20–50 range depending on hop count and video policy. Operator capacity, if the UI presents one panel per aircraft, runs out around four to six — and no amount of RF engineering moves that number.
The design answer is exception-based supervision: the default view is the mission and its progress, not the vehicles. Individual aircraft surface only when they need a decision — a task that cannot be reassigned, a frame uncertainty that has outgrown the plan, a battery that will not make the rendezvous, a vehicle that has fallen off the ladder in Fig. 2. Everything else the swarm handles itself, which is the same requirement as §2.7's "losing the GCS is a designed mode" seen from the human side. If the swarm needs the operator, it also needs the link — and then you have no safe state.
What if you only have a single downward-facing camera per drone instead of a stereo pair?
You lose metric scale, which is the property the whole architecture depends on. Monocular VIO recovers scale only from IMU-observed acceleration, so it needs sustained excitation — and a drone hovering to inspect something, or flying a smooth coverage lawnmower at constant velocity, provides almost none. Scale then drifts, and scale drift is uniquely nasty because it corrupts distance rather than adding a bias: a 10% scale error means every commanded 100 m leg is actually 90 or 110 m, so the error grows with the mission instead of averaging out.
The workable single-camera build is downward camera plus a downward rangefinder: the range measurement provides the metric reference that makes scale observable, which is exactly the classic optical-flow-plus-lidar configuration and why it appears as the fallback tier in Fig. 2. Its limits are geometric — it degrades over textureless or self-similar ground (water, fresh snow, uniform crop canopy, a smooth warehouse floor), and it is blind to anything not directly below, so it gives you velocity rather than a map and contributes nothing to obstacle awareness or to the co-visibility-based frame alignment in §2.4. As a safety fallback it is excellent and cheap. As the primary sensor for a cooperative swarm that needs a shared frame, it is not enough.