Sistema di controllo autonomo per drone capace di:
- Seguire un percorso di pattugliamento a waypoint predefiniti
- Rilevare e agganciare un bersaglio mobile tramite visione artificiale a colore
- Mantenere il tracking sotto jamming GPS/RF grazie a un filtro di Kalman con incertezza di misura adattiva
- Ricercare il bersaglio con una spirale espandibile quando lo perde di vista
- Confrontarsi con un bersaglio non collaborativo, che tenta l'evasione una volta accortosi di essere inseguito
Il progetto nasce dall'analisi delle tecnologie drone impiegate nel conflitto russo-ucraino, dove il jamming GPS di massa ha reso necessari sistemi di navigazione autonomi basati su visione artificiale.
Gazebo Harmonic (simulazione 3D fisica)
↕ JSON UDP (porte 9012/9013)
ArduPilot SITL (firmware Copter 4.7.0)
↕ MAVLink TCP (porta 5760)
MAVProxy (hub MAVLink / ground control station)
↕ UDP (porta 14555)
MAVROS2 (bridge MAVLink ↔ ROS 2)
↕ ROS 2 topics/services
┌───────────────────────────────────────────────┐
│ Nodi ROS 2 │
│ │
│ detector_node │
│ → segmentazione HSV del bersaglio rosso │
│ ↓ /target/position │
│ jammer_node │
│ → corrompe il segnale, simula EW │
│ ↓ /target/jammed_position │
│ ↓ /rf/noise_level │
│ tracker_node │
│ → Kalman con R adattiva al rumore RF │
│ ↓ /target/tracked_position │
│ ├─ controller_node │
│ │ → PD su velocità in body frame │
│ │ ↓ /mavros/setpoint_velocity/… │
│ └─ mission_node │
│ → macchina a stati, waypoint, ricerca │
│ ↓ /mavros/setpoint_position/local │
│ ↓ /mission/stato │
│ target_mover_node │
│ → muove ed evade il bersaglio in Gazebo │
│ metrics_node │
│ → registra la prova su CSV, con la verità │
│ a terra letta da Gazebo │
└───────────────────────────────────────────────┘
Il topic /mission/stato è il canale di sincronizzazione: detector, tracker,
controller e target_mover cambiano tutti comportamento in base alla fase corrente.
| Componente | Versione |
|---|---|
| OS | Ubuntu 24.04 |
| ROS 2 | Jazzy Jalisco |
| Simulatore | Gazebo Harmonic (gz-sim8) |
| Firmware | ArduPilot SITL — Copter 4.7.0 (container) / master 4.8.0-dev (sviluppo) |
| Bridge | MAVROS2 + ros_gz_bridge |
| Visione | OpenCV (segmentazione HSV) |
| Navigazione | Filtro di Kalman custom [x, y, vx, vy] |
Il modo più semplice per far girare tutto senza installare nulla. Serve solo Docker con Compose.
Prerequisito — copiare gli asset Gazebo personalizzati (mondo con il
bersaglio e drone con la telecamera) in sim/. ArduPilot e ardupilot_gazebo
non servono: il Dockerfile li compila da sorgente. Vedi
sim/README.md.
docker compose buildLa prima build dura 20–40 minuti (compila ArduPilot da zero); le successive sfruttano la cache.
docker compose up -d
docker compose exec sim start_all.shstart_all.sh apre una sessione tmux con un pannello per componente — Gazebo,
SITL, MAVProxy, MAVROS, nodi ROS 2 — avviati in sequenza con i ritardi giusti.
Dopo ~30 secondi, dal pannello shell:
takeoff.shros2 topic pub --once /mission/avvia std_msgs/msg/Bool "data: true"Comandi tmux utili: Ctrl-b n/Ctrl-b p per cambiare pannello, Ctrl-b d per
staccarsi lasciando tutto in esecuzione.
docker compose exec sim tmux kill-session -t droneProcedura per verificare che l'intera catena funzioni, con cosa aspettarsi a ogni
passo. Tutti i comandi vanno dati dal pannello shell di tmux, oppure con
docker compose exec sim <comando> da un altro terminale.
1. Verifica che lo stack sia su
ros2 node list | grep -cE '(detector|tracker|jammer|controller|mission|target_mover)_node'Deve rispondere 6, uno per nodo del progetto. Se è meno, qualche pannello non
è partito: controlla con tmux attach -t drone. Nota che ros_gz_bridge e
mavros_node compaiono nell'elenco ma non in questo conteggio, e MAVROS impiega
qualche secondo in più degli altri a registrarsi.
2. Stato iniziale
ros2 topic echo /mission/stato --onceDeve dire ATTESA. In questa fase il detector non pubblica: se controlli
/target/position troverai (0, 0, 0). È il comportamento corretto, non un
guasto.
3. Decollo
takeoff.shPoi verifica che salga davvero:
ros2 topic echo /mavros/global_position/rel_alt --onceAttendi che il valore sia stabile intorno a 12. Se resta a zero, guarda il
pannello mavproxy: quasi sempre è un prearm check che ha rifiutato l'arming.
4. Avvio della missione
ros2 topic pub --once /mission/avvia std_msgs/msg/Bool "data: true"Da qui lo stato passa a PATTUGLIAMENTO:0 e il numero cresce man mano che i
waypoint vengono raggiunti. Il rilevamento si attiva 2 secondi dopo.
5. Monitora la missione
Il modo più comodo è il cruscotto live, che raccoglie tutto su una schermata:
monitor.sh Fase AGGANCIO
Quota 11.8 m Posizione x 17.1 y 21.9
Camera 11.2 Hz
Datalink JAMMING ATTIVO rumore RF 0.8
Catena di percezione (posizione orizzontale nel frame)
detector +0.209 ····················|···#················
jammer assente ·········································
kalman assente ·········································
Comando laterale -0.78 m/s longitudinale +0.53 m/s
Le tre barre sono lo stesso bersaglio nei tre stadi della catena: # è la sua
posizione orizzontale nel frame, | il centro. Lo scatto qui sopra è preso
durante una finestra di jamming, nell'istante in cui il jammer ha scartato il
pacchetto: il detector vede il bersaglio, gli stadi a valle no.
start_all.sh lo avvia già nel pannello tmux monitor. Per lanciarlo a mano con
un intervallo diverso: monitor.sh 1.0.
5b. Osserva i singoli topic
Se vuoi i numeri grezzi invece del cruscotto, gli stessi dati nei tre stadi.
ros2 topic echo /target/position --field xros2 topic echo /target/jammed_position --field xros2 topic echo /target/tracked_position --field xDurante le finestre di jamming (~4 s ogni ~14 s) il secondo valore deve risultare visibilmente più rumoroso del primo, e ogni tanto azzerarsi di colpo — è la perdita di pacchetto simulata. Il terzo deve restare molto più liscio di entrambi: è la R adattiva che fa scendere il guadagno di Kalman.
Per sapere quando il jammer è attivo:
ros2 topic echo /gps/jammed6. Guarda cosa vede il drone
Senza GUI, i frame annotati si salvano come PNG:
snapshot.sh 30I file finiscono in ./snapshots/ sull'host, apribili da Esplora Risorse. Sul
frame trovi il contorno del bersaglio in verde, il centroide in giallo e le
coordinate normalizzate con l'area. Per il feed grezzo senza annotazioni:
snapshot.sh 10 /drone/camera/image_raw7. Aggancio ed evasione
Quando il drone arriva sul waypoint (150, 150) e conferma il bersaglio per 5
rilevamenti consecutivi, lo stato passa ad AGGANCIO. Dieci secondi dopo il
bersaglio inizia la fuga a 15 m/s, e se il drone lo perde lo stato diventa
RICERCA: prima l'inseguimento nella direzione di fuga, poi la spirale.
Per seguire tutte le transizioni:
ros2 topic echo /mission/statoDi default il container gira headless (HEADLESS=1): parte solo il server
fisico, senza finestra. È la modalità consigliata — la simulazione è più veloce
e non serve alcuna configurazione grafica.
Per vedere la finestra 3D servono HEADLESS=0 e un server X raggiungibile:
- Linux — decommentare
DISPLAY,QT_X11_NO_MITSHMe il volume/tmp/.X11-unixin docker-compose.yml, poixhost +local:docker - Windows 11 — funziona via WSLg lanciando Docker dentro WSL2, con
DISPLAY=$DISPLAYe il volume/tmp/.X11-unix - macOS — serve XQuartz con "Allow connections from network clients"
Per il solo tracking la GUI non serve: rqt_image_view sul topic
/target/debug_image mostra già cosa vede il drone.
Le porte 5760/tcp (MAVLink diretto dal SITL) e 14550/udp (uscita MAVProxy)
sono esposte sull'host: Mission Planner o QGroundControl possono collegarsi
dall'esterno del container.
src/drone_tracking/drone_tracking e launch/ sono montati come volume e
l'immagine è costruita con --symlink-install: le modifiche ai file Python sono
visibili subito, basta riavviare il pannello nodes. Sono montati anche
setup.py, package.xml e docker/sitl-defaults.parm, quindi punti d'ingresso,
manifesto e taratura dell'autopilota si cambiano a caldo. Serve un docker compose build solo per gli asset in sim/.
Un nodo nuovo richiede comunque una ricostruzione del workspace, non
dell'immagine: il montaggio porta dentro il setup.py aggiornato, ma
l'eseguibile in install/ lo crea colcon.
docker exec drone-tracking bash -lc 'source /opt/ros/jazzy/setup.bash && cd /ws && colcon build --symlink-install'Va rifatto anche dopo ogni docker compose up -d che ricrea il container:
/ws/install non è un volume, quindi torna allo stato dell'immagine e i nodi
aggiunti dopo l'ultima build spariscono. Il sintomo è muto — il launch non trova
l'eseguibile, metà stack non parte e la missione non pubblica mai /mission/stato
— e non assomiglia affatto alla sua causa.
drone_tracking/
├── docker/
│ ├── Dockerfile # immagine all-in-one (build multi-stage)
│ ├── entrypoint.sh # source degli ambienti ROS 2
│ └── start_all.sh # orchestrazione tmux dello stack
├── docker-compose.yml
├── scripts/
│ ├── takeoff.sh # arm + GUIDED + decollo via servizi MAVROS
│ └── export_sim_assets.sh # estrae i file Gazebo custom dalla VM
├── sim/ # asset Gazebo personalizzati (vedi sim/README.md)
│ ├── worlds/iris_runway.sdf
│ └── models/iris_with_ardupilot/
└── src/
└── drone_tracking/
├── drone_tracking/
│ ├── detector_node.py # rilevamento bersaglio (OpenCV)
│ ├── tracker_node.py # filtro di Kalman
│ ├── jammer_node.py # simulazione guerra elettronica
│ ├── controller_node.py # controllo PD del drone
│ ├── mission_node.py # macchina a stati della missione
│ └── target_mover_node.py # movimento ed evasione del bersaglio
├── launch/
│ └── tracking.launch.py # 6 nodi + ponte ros_gz per la camera
├── package.xml
└── setup.py
Macchina a stati che governa l'intera missione. La fase corrente è pubblicata su
/mission/stato e condiziona il comportamento di tutti gli altri nodi.
| Fase | Comportamento |
|---|---|
ATTESA |
Drone a terra. Il detector non rileva, il tracker resta azzerato. |
PATTUGLIAMENTO |
Percorre i waypoint pubblicando su /mavros/setpoint_position/local. Il rilevamento si attiva 2 s dopo l'avvio, per dare tempo al decollo. |
AGGANCIO |
Bersaglio confermato: il controllo passa a controller_node. |
RICERCA |
Bersaglio perso: inseguimento nella direzione di fuga, poi spirale. |
Percorso di pattugliamento — circuito quadrato a 50 m di quota, con soglia
di raggiungimento pari al parametro soglia_waypoint:
(0,0) → (150,0) → (150,150) → (0,150) → (0,0)
Il vertice (150,150) coincide con la zona in cui orbita il bersaglio. Se il
giro si chiude senza aggancio, il pattugliamento riparte dal waypoint 1.
Aggancio — richiede frame_conferma_richiesti rilevamenti consecutivi,
e solo sopra i 2 m di quota, per evitare falsi positivi durante il decollo. Sono
rilevamenti e non messaggi validi del tracker: quest'ultimo resta valido per
soglia_perdita fotogrammi dopo l'ultima misura, quindi un solo avvistamento ne
produrrebbe abbastanza da soddisfare qualunque soglia di conferma. Il conteggio
è sul flusso a valle del jammer, lo stesso che il filtro riceve in ingresso:
contare i rilevamenti puliti farebbe confermare un aggancio su un'informazione
che il filtro non ha mai avuto.
Perdita e ricerca — trascorso soglia_avvia_ricerca_s senza bersaglio in
AGGANCIO, la fase passa a RICERCA, che si svolge in due tempi.
Nel primo il drone vola dove il bersaglio sarebbe se avesse proseguito dritto,
estrapolando dalla posizione e dalla velocità che controller_node pubblica su
/target/odometria. Dura durata_inseguimento_cieco_s. È il tempo in cui si
usa l'unica informazione direzionale disponibile, e a velocità reali vale più di
qualunque strategia di copertura: la spirale si espande di pochi metri al
secondo mentre un veicolo in fuga ne percorre quindici.
Nel secondo si apre la spirale, centrata sulla posizione extrapolata del
bersaglio e non su quella del drone — a queste velocità le due differiscono di
decine di metri, e cercare attorno a sé significa cercare dove il bersaglio non
è. Il raggio parte da ricerca_raggio e cresce di ricerca_vel_espansione al
secondo fino a ricerca_raggio_max, oltre il quale la ricerca è dichiarata
fallita e la missione torna a PATTUGLIAMENTO.
Per tornare in AGGANCIO servono frame_conferma_riaggancio rilevamenti
consecutivi. Anche qui rilevamenti e non messaggi del tracker, e per un motivo
misurato: si veda la sezione sulla ricerca direzionale.
Il conteggio della perdita avviene sia all'arrivo dei messaggi del tracker sia
nel timer periodico: se /target/tracked_position tace del tutto — detector
fermo, ponte delle immagini caduto — nessun messaggio farebbe partire il
conteggio, e la fase resterebbe AGGANCIO a tempo indeterminato.
Legge il feed della telecamera montata sul drone (puntata a nadir, FOV
orizzontale 90°, 640×480, update_rate dichiarato 30 Hz ma reso molto piu
basso dal rasterizzatore software — vedi Problemi noti) e isola il bersaglio
rosso con doppia soglia HSV: due intervalli, perché il rosso è a cavallo del
wrap-around della tinta.
mask1: H ∈ [0, tolleranza_tinta] S ≥ saturazione_minima V ≥ valore_minimo
mask2: H ∈ [180 − tolleranza_tinta, 180] S ≥ saturazione_minima V ≥ valore_minimo
I tre estremi sono parametri ROS (tolleranza_tinta, saturazione_minima,
valore_minimo): saturazione e valore minimi escludono i grigi e le zone in
ombra, che rientrerebbero nella tinta giusta.
Del contorno più grande calcola il centroide via momenti di immagine e lo
normalizza in [-1, +1]. I contorni sotto area_minima_px vengono scartati
come rumore visivo.
Il campo z del messaggio trasporta l'area del contorno e funge da flag di
visibilità: z > 0 significa bersaglio presente. Tutti i nodi a valle usano
questa convenzione — è ciò che distingue "bersaglio al centro dell'immagine" da
"bersaglio assente", due casi che le sole coordinate (0, 0) confonderebbero.
Nota sullo smoothing. È presente un filtro esponenziale con
alphaconfigurabile, ma il valore è impostato a1.0(nessun filtraggio): il lag introdotto faceva perdere l'aggancio durante l'evasione del bersaglio ad alta velocità. Il codice resta per poter riattivare lo smoothing con bersagli lenti.
Pubblica anche /target/debug_image con contorno, centroide e coordinate
disegnati sul frame.
Il rilevatore prendeva il contorno rosso più grande sopra una soglia fissa in pixel, e nient'altro: un tetto rosso, un telo, un'auto parcheggiata venivano accettati con la stessa fiducia del bersaglio.
Il controllo aggiunto non è euristico ma geometrico. A quota h l'impronta a
terra vale 2·h·tan(semicampo), quindi la scala è (larghezza_px/2)/(h·tan)
pixel per metro, e un veicolo di dimensioni note deve occupare un'area precisa
che cala con 1/h². A 50 m un bersaglio di 4 × 2 m vale ~330 px²; un blob
sessanta volte più grande non è quel bersaglio, e viene scartato con il motivo
scritto nel log.
La tolleranza è un fattore quattro in più o in meno, che copre l'orientamento del veicolo e la visione parziale al bordo dell'inquadratura, dove metà del bersaglio è fuori campo e l'area dimezza. Senza quota nota il giudizio non è possibile e si accetta: scartare per ignoranza sarebbe peggio, e al decollo la quota non è ancora arrivata.
Un tetto operativo che nasce da qui. La soglia minima assoluta esiste contro
il rumore visivo e vale 100 px²; insieme alla legge 1/h² fissa la quota oltre la
quale il bersaglio è troppo piccolo per essere visto, che è ~90 m. È più
bassa dei 120 m che sicurezza_node consente per ragioni normative: il limite
che conta davvero, per questo bersaglio e questa ottica, è quello della
percezione.
Simula un sistema di guerra elettronica che si interpone tra detector e tracker.
Cicla automaticamente ON/OFF su un timer a 10 Hz: jam_on_duration tick attivo,
jam_off_duration inattivo.
Quando il jamming è attivo:
- pubblica
/gps/jammed: truee/rf/noise_level: 0.8 - inietta rumore gaussiano di deviazione standard
deviazione_rumoresulle coordinate, con clamp a[-1, +1] - con probabilità
probabilita_perdita_segnalesimula la perdita totale del pacchetto (azzera x, y, z)
Il disturbo è ripetibile. Il generatore pseudocasuale parte da un seme
fisso, il parametro seed: due prove della stessa configurazione ricevono lo
stesso disturbo, quindi il loro confronto misura la modifica al codice e non due
sequenze di rumore diverse. Con seed negativo si torna al comportamento non
deterministico.
Due accorgimenti importanti: non corrompe segnali già nulli (non genera falsi positivi dal nulla) ed è limitato a un messaggio ogni 50 ms, perché a piena frequenza il tracker veniva sovraccaricato.
Filtro di Kalman a 4 stati [x, y, vx, vy] in coordinate immagine.
| Matrice | Nome nel codice | Ruolo |
|---|---|---|
| F | evoluzione_stato |
Modello cinematico a velocità costante, con dt reale |
| B·u | _ingresso_noto(dt) |
Moto del velivolo, vedi sotto |
| H | mappa_osservazione |
Osserva solo posizione x/y |
| Q | _matrice_Q(dt) |
Rumore di processo, ricostruito sul dt effettivo |
| R | incertezza_sensore |
Adattiva, vedi sotto |
| S | innovazione | Covarianza dell'innovazione: gating e NIS |
Il moto del velivolo è un ingresso noto, non rumore. La posizione del
bersaglio nell'immagine cambia per due motivi: il bersaglio che si muove e il
velivolo che si muove. Il secondo è misurato da MAVROS, e trattarlo come
rumore di processo costringeva il filtro a spiegare con l'incertezza qualcosa
che già sapeva. _ingresso_noto converte la traslazione del velivolo in
spostamento d'immagine — in assi velivolo tramite l'imbardata, diviso per
l'impronta a terra quota · tan(semicampo) — e la somma alla predizione.
La conseguenza pratica è sulla grandezza pubblicata: vx, vy diventano la
velocità propria del bersaglio e non più quella relativa al velivolo. Prima,
chi voleva l'assoluta doveva sommarci la velocità del drone, e quella differenza
fra due grandezze grandi per ottenerne una piccola produceva una stima con
errore pari al segnale — 10.8 m/s contro un bersaglio che ne percorre 10.0.
L'ingresso noto da solo non ha però risolto: la misura in volo dava ancora 10.3.
A spostare il risultato è stata la taratura che gli strumenti nuovi hanno reso
possibile, descritta più sotto.
Quando il moto del velivolo non è disponibile o è vecchio, l'ingresso non si
applica e la velocità non viene pubblicata affatto: sarebbe una grandezza
diversa da quella promessa, e chi la legge non avrebbe modo di accorgersene.
Le misure implausibili vengono rifiutate, non pesate. Il disturbo non è
gaussiano: il 30% dei messaggi è una perdita totale. Un filtro gaussiano senza
rifiuto non può fare altro che mediare un valore anomalo. Ogni misura passa
quindi un test sulla distanza di Mahalanobis — la covarianza dell'innovazione
S era già calcolata per il guadagno e veniva buttata — con soglia
soglia_gating, 9.21, cioè il 99% di un chi-quadro a due gradi di libertà.
Un filtro che rifiuta tutto però diverge in silenzio, convinto di sapere dove
sia il bersaglio. Dopo max_rifiuti_consecutivi scarti di fila la spiegazione
più probabile non è che le misure siano sbagliate ma che lo sia lo stato, e il
filtro riparte dalla misura.
Il filtro pubblica il proprio NIS su /target/nis. L'innovazione
normalizzata vale in media il numero di gradi di libertà della misura — due — se
Q e R descrivono la realtà; sopra, il filtro è troppo sicuro di sé, sotto
troppo prudente. Serve perché Q è stata tarata confrontando la stima con la
posizione vera letta dal simulatore, e su un velivolo reale quella posizione non
esiste: il NIS usa solo grandezze che il filtro già calcola, quindi è la sola
taratura ripetibile fuori dalla simulazione. metriche.py riassumi ne stampa la
media con il giudizio, separando le finestre di disturbo: lì R viene
gonfiata apposta, e un NIS basso è il comportamento voluto invece che un difetto.
I due strumenti nuovi hanno trovato subito due difetti, e nessuno dei due era quello che sembrava a prima vista.
R si misura, non si sceglie. È la covarianza del rumore di misura, e quel
rumore è misurabile: metriche.py rumore proietta nell'immagine la posizione
vera del bersaglio — la stessa geometria che il controllo percorre al contrario
— e confronta con ciò che il rilevatore riporta. Lo scarto va però separato: la
parte che varia lentamente è errore di modello e in R non ci va, perché
metterla dentro renderebbe il filtro più sordo di quanto già sia. Resta la parte
bianca, varianza 0.006 sull'asse x e 0.016 sull'asse y, contro lo 0.05 che
era in uso. Il NIS lo segnalava già: valeva 0.35 invece di 2.0.
Q era stata tarata sul criterio sbagliato. Il valore precedente, 0.5, era
stato scelto confrontando la velocità stimata con quella vera e guardando la
pendenza della regressione — cioè l'assenza di distorsione, che a 0.5 valeva
1.03 e sembrava perfetta. Ma una stima può essere non distorta e insieme
rumorosissima, ed era esattamente il caso: la correlazione di 0.6, già nota, lo
diceva senza che nessuno la leggesse come un difetto di taratura.
Spazzando q sull'errore invece che sulla pendenza — con il filtro vero,
non una riscrittura della sua matematica, su misure sintetiche col rumore
misurato in volo:
q |
errore | q |
errore | |
|---|---|---|---|---|
| 5.0 | 22.3 m/s | 0.05 | 4.0 m/s | |
| 0.5 | 9.3 m/s | 0.01 | 3.0 m/s | |
| 0.1 | 5.0 m/s | 0.002 | 3.7 m/s |
I 9.3 m/s previsti per il valore in uso coincidono con i 10.3 misurati in volo: il modello sintetico descrive la situazione vera, quindi la spazzata è credibile.
Esiste un limite, ed è calcolabile. Con 4.7 m di rumore di posizione e un bersaglio che curva a 0.25 rad/s, la finestra di lisciamento ottima è ~1.5 s e l'errore minimo raggiungibile è 2.6 m/s: più corta e domina il rumore, più lunga e domina la curvatura della traiettoria. Il 3.0 della spazzata ci arriva vicino, il che dice che oltre non si va senza cambiare la misura.
Ogni strumento misura una cosa sola, e va detto quale. Il NIS resta fra 1.8
e 2.2 su tutta la spazzata mentre l'errore varia da 22 a 3 m/s: R domina S e
q vi entra poco. Il NIS valida R; solo il confronto con una verità valida
q. In volo il NIS a 0.25 stava segnalando R, e leggerlo come un verdetto
su q sarebbe stato un errore.
Risultato in volo, tre prove con R = 0.016 e q = 0.01:
| prima | dopo | |
|---|---|---|
| Errore della velocità stimata | 10.3 m/s | 6.7 m/s |
| Rapporto errore/segnale | 1.03 | 0.67 |
| NIS senza disturbo | 0.35 | 2.63 · 1.77 · 1.69 |
Il NIS è arrivato dove doveva in tutte e tre le prove. Resta però un divario fra il 6.7 misurato in volo e il 3.0 previsto: in volo entrano l'errore e la latenza della velocità che MAVROS riporta — che ora alimenta la predizione direttamente — e manovre più aggressive dell'orbita sintetica. È il prossimo filo da tirare, e non è ancora stato tirato.
Questi numeri sono stati corretti.
metrics_nodeteneva l'ultimo NIS ricevuto e lo riscriveva a ogni riga del registro: il filtro lo pubblica a ogni misura corretta, il registro esce a 5 Hz, e con il bersaglio non visibile lo stesso valore restava fermo anche per secondi. Ogni media sulla colonna contava quindi più volte lo stesso campione, pesando i tratti in cui il filtro non si pronunciava. La terza prova qui sopra sembrava fallita a 0.68: aveva 97 campioni veri su 377 righe, tre quarti di doppioni. Ora la colonna resta vuota quando non c'è nulla di nuovo, e i tre valori cadono dove dovevano. Vale la pena dirlo perché è un errore di misura che confermava quello che ci si aspettava — il genere più difficile da notare.
Q dipende dal dt. Era una matrice costante, sommata identica a ogni
predizione qualunque fosse il tempo trascorso: al ritmo variabile della
telecamera lo stesso intervallo veniva penalizzato o premiato a caso. Ora si usa
la discretizzazione standard di un modello a velocità quasi costante: da
un'accelerazione ignota di intensità intensita_rumore_accel, integrata su
dt, seguono una varianza q·dt³/3 sulla posizione, q·dt sulla velocità e
una covarianza q·dt²/2 fra le due — termini incrociati che nel modello
esistono e che la matrice diagonale precedente ignorava.
R adattiva — è il punto centrale della resistenza al jamming. Il tracker si
iscrive a /rf/noise_level e ricalcola l'incertezza di misura a ogni variazione:
R = rumore_sensore_base + (rumore_sensore_max − rumore_sensore_base) · livello_rumore
Con il jamming attivo (livello_rumore = 0.8) l'incertezza di misura sale di
circa 30 volte: il guadagno di Kalman crolla e il filtro dà fiducia alla propria
predizione invece che ai dati corrotti. Senza jamming torna a fidarsi del
detector. È l'equivalente in coordinate immagine di un INS che degrada
gracefully quando il GNSS diventa inaffidabile.
La velocità stimata esce dal filtro. Il filtro calcolava vx, vy fin
dall'inizio, ma nessuno li leggeva. Ora sono pubblicati su
/target/tracked_velocity, in coordinate immagine al secondo e relativi al
drone: l'immagine si muove anche quando a muoversi è il velivolo.
L'intensità di rumore del modello è tarata sulla verità a terra. Il valore
iniziale, 5.0, era stato scelto per riprodurre a dt nominale il termine di
velocità della vecchia matrice costante — un argomento di continuità con una
matrice che era essa stessa sbagliata. Confrontando la velocità stimata con
quella reale del bersaglio letta dal simulatore, la pendenza della regressione
vale:
intensita_rumore_accel |
Pendenza in avanti | Pendenza laterale |
|---|---|---|
| 5.0 | 2.36 | 1.51 |
| 0.5 | 1.03 | 0.86 |
| 0.05 | 0.55 | 0.55 |
A 5.0 la stima era gonfiata del doppio, a 0.05 il filtro insegue troppo lentamente e la dimezza. Il difetto era invisibile finché la velocità non è servita a qualcuno. La correlazione con la verità resta intorno a 0.6 in tutti e tre i casi: quella è la qualità intrinseca della misura e non dipende da questo parametro. La correzione ha migliorato anche l'inseguimento di per sé, dal 73.2% all'84.0% di fotogrammi con bersaglio a 5.5 m/s di fuga.
Gestione della perdita di segnale — durante i buchi il filtro pubblica la
predizione, tollerando fino a soglia_perdita messaggi consecutivi; oltre
quella soglia si resetta e pubblica un punto nullo, segnalando la perdita a
mission e controller.
Sullo smorzamento della velocità stimata, rimosso. Una versione precedente
moltiplicava per 0.6 la velocità nello stato a ogni correzione, per contenere le
predizioni sbagliate. Era uno smorzamento applicato allo stato senza toccare la
covarianza corrispondente: il filtro dichiarava una fiducia che non
corrispondeva più alla stima, e la coerenza fra le due è l'unica cosa che rende
ottimo un filtro di Kalman. Peggio, il fattore si componeva ad ogni misura:
dopo dieci aggiornamenti la velocità stimata era lo 0.6% di quella calcolata, e
la predizione durante una perdita di segnale restava ferma sull'ultima posizione
invece di estrapolare il moto del bersaglio. Lo stesso effetto — una stima di
velocità meno nervosa — si ottiene ora per la via corretta, cioè scegliendo
intensita_rumore_accel, che governa quanto la velocità può cambiare fra due
misure aggiornando di conseguenza anche l'incertezza.
Controllo proporzionale-derivativo che traduce l'errore di posizione nel frame della telecamera in comandi di velocità in body frame.
Tutti i valori sono parametri ROS: la colonna riporta il default nel codice,
non un numero fisso. ros2 param get /controller_node kp_x dice quale era
attivo durante una prova.
| Parametro | Default | Unità |
|---|---|---|
kp_x, kp_y |
2.0 | 1/s |
kd_x, kd_y |
0.35 | — |
vel_max |
20.0 | m/s (per asse) |
deadzone |
2.0 | metri |
k_anticipo |
0.0 | — (misurato, non conviene) |
finestra_velocita_s |
1.0 | s (mediana della velocita stimata) |
campioni_velocita_min |
5 | campioni sotto i quali la stima si disattiva |
vel_bersaglio_max |
25.0 | m/s — limite fisico, non una taratura |
validita_velocita_s |
2.0 | s per cui l'ultima velocita nota resta usabile |
durata_coasting_s |
2.0 | s |
timeout_percezione_s |
0.5 | s |
timeout_posa_s / timeout_quota_s |
1.0 / 2.0 | s |
| Frequenza di pubblicazione | 10 | Hz |
I guadagni agiscono su metri, non su pixel. L'errore normalizzato d'immagine viene prima convertito in scostamento al suolo:
error = norm * quota * tan(semi_fov)Le coordinate normalizzate cambiano significato al variare di quota e ottica —
lo stesso 0.3 vale 2 m a 12 m con FOV 60° e 3.6 m con FOV 90° — quindi una
taratura fatta su di esse va rifatta a ogni modifica. In metri il guadagno ha un
senso fisico diretto: kp = 1.2 significa 1.2 m/s di comando per ogni metro di
scarto, cioè uno scarto a regime di velocità_bersaglio / kp ≈ 1 m contro un
bersaglio a 1.2 m/s. La quota entra nella conversione, quindi non serve più
scalare i guadagni separatamente.
Mappatura degli assi — l'errore in pixel normalizzati diventa velocità nel frame del drone:
error_x→ velocità laterale, segno invertitoerror_y→ velocità longitudinale, segno invertito
Rotazione nel frame del mondo — il comando così ottenuto non va
pubblicato direttamente. /mavros/setpoint_velocity/cmd_vel_unstamped viene
tradotto da MAVROS in SET_POSITION_TARGET_LOCAL_NED con frame LOCAL_NED,
cioè il frame del mondo, non quello del velivolo. Il controller ruota quindi
il vettore con lo yaw letto da /mavros/local_position/pose:
cmd.linear.x = v_avanti * cos(yaw) - v_laterale * sin(yaw)
cmd.linear.y = v_avanti * sin(yaw) + v_laterale * cos(yaw)Senza questa rotazione il sistema è corretto solo con yaw esattamente zero. In volo lo yaw non è comandato e deriva: misurato fra 25° e 47°, con oscillazioni di ±20°. Il comando finiva in media 61° fuori bersaglio e il drone spingeva di traverso, incapace di seguire perfino l'orbita lenta del bersaglio.
| Prima | Dopo | |
|---|---|---|
| Scarto medio comando/bersaglio | −61.3° | −8.6° |
| Scarto mediano | −61.3° | −11.4° |
Tempo in AGGANCIO |
— | 98.3% |
| Distanza minima raggiunta | — | 2.9 m |
Per diagnosticare guasti di questo tipo non basta osservare se il drone si muove: va confrontata la direzione del comando con la direzione reale del bersaglio. Uno scarto sistematico costante indica un frame sbagliato, non una taratura da rivedere.
Compensazione d'assetto — è la correzione che ha reso possibile l'inseguimento. La telecamera è solidale al corpo, quindi una rotazione del velivolo trasla l'immagine indipendentemente da dove sia il bersaglio. Con FOV orizzontale 90° su 640×480 i semicampi valgono 0.785 rad in orizzontale e 0.644 in verticale: bastano pochi gradi di pitch per spostare il bersaglio di una frazione visibile del campo. Il controller sottrae quindi il contributo dell'assetto, lavorando sugli angoli e non sulle coordinate normalizzate:
alpha_x = atan(msg.x * tan(semi_fov_o)) - roll
alpha_y = atan(msg.y * tan(semi_fov_v)) + pitch
norm_x = tan(alpha_x) / tan(semi_fov_o)
norm_y = tan(alpha_y) / tan(semi_fov_v)In una proiezione prospettica vale u = tan(alpha)/tan(semi_fov), quindi
coordinata e angolo non sono proporzionali. Una versione precedente divideva
l'assetto per il semicampo in radianti, che è il primo termine dello sviluppo
della tangente: sovracorreggeva del 27% in orizzontale e del 16% in verticale,
cioè a 25° di rollio introduceva un errore fantasma di circa 1.3 m al suolo a
12 m di quota.
Senza questa sottrazione l'errore misurava l'inclinazione del drone più della posizione del bersaglio: correlazione r = −0.665 fra pitch ed errore verticale, con l'errore che spazzava l'intero campo visivo (−0.98…+0.97) mentre il bersaglio era pressoché fermo. Il risultato era una retroazione che divergeva in pochi secondi — il drone agganciava, inseguiva un paio di secondi e perdeva.
| Senza compensazione | Con compensazione | |
|---|---|---|
| Distanza mediana dal bersaglio | 10.7 m | 3.0 m |
| Tempo entro 8 m | — | 98% |
Tempo in AGGANCIO |
98.3% | 100% |
| Campioni con bersaglio visibile in 100 s | 219 | 892 |
Campo visivo largo — l'ottica è a 90° invece di 60°: a 12 m di quota l'impronta a terra passa da ~14 a ~24 m. Con il campo stretto il bersaglio usciva dall'inquadratura appena il drone si inclinava, e veniva perso dopo mezzo secondo. Alzare i guadagni non aiutava, anzi: più il drone è aggressivo più si inclina, e il bersaglio esce prima. Misurato — frazione di frame con bersaglio inquadrato: 57% con FOV 60° e guadagni alzati, 84% con FOV 90° e guadagni metrici.
Guardia FOV — le posizioni con |x| > 1.2 o |y| > 1.2 non vengono
inseguite: sono predizioni di Kalman ormai fuori dal campo visivo. Non azzerano
però il comando di colpo, perché è la situazione tipica di una fuga veloce (il
bersaglio scivola verso il bordo poco prima di sparire): si passa al coasting,
descritto sotto.
Coasting alla perdita di vista — quando il tracker rinuncia, il comando non
viene azzerato di netto ma smorzato a zero su durata_coasting_s, proseguendo
nella direzione in cui il bersaglio si stava muovendo, che è la più probabile
per riacquisirlo. Prima il drone restava immobile per tutta l'attesa che precede
RICERCA: la sequenza reale era ~1.4 s di inseguimento sulla predizione di
Kalman, poi comando nullo fino allo scadere di soglia_avvia_ricerca_s.
Watchdog sugli ingressi — il timer di pubblicazione verifica che percezione,
posa e quota si stiano ancora aggiornando; se una tace oltre la propria soglia,
il comando viene azzerato e l'evento loggato come errore. Senza questa verifica
la morte di detector_node, o un ponte immagini che si ferma, lasciava il drone
a ripetere all'infinito l'ultima velocità nota — volo alla cieca fino a
vel_max, senza che nulla nei log lo segnalasse. Il comando azzerato viene
comunque pubblicato: interrompere lo stream di setpoint farebbe uscire ArduPilot
dalla modalità GUIDED.
Anti derivative-kick — al primo frame dopo ogni aggancio la derivata è azzerata, altrimenti il salto iniziale dell'errore produrrebbe uno strappo.
Guida predittiva, misurata e spenta. Il controllo è reattivo: corregge lo
scarto osservato, non anticipa. Da qui l'errore a regime velocità / kp di
5.2. Il rimedio naturale è aggiungere al comando la velocità del bersaglio, che
il filtro stima già — con una precisazione che cambia il progetto del termine:
quella stima è relativa, e sommarla direttamente duplicherebbe il termine
derivativo, che usa la stessa grandezza. Per pareggiare la velocità del
bersaglio serve la sua velocità assoluta, cioè quella relativa più quella
del velivolo letta da /mavros/local_position/velocity_local (verificato
sperimentalmente che sia nel frame del mondo e non del corpo: lo scarto rispetto
alla verità a terra vale 0.29 m/s contro 0.43 m/s dell'ipotesi opposta).
Implementato così, contro un bersaglio in fuga a 5.5 m/s e con la stima già corretta in scala:
k_anticipo |
Bersaglio rilevato | Distanza mediana | Durata aggancio |
|---|---|---|---|
| 0.0 | 84.0% | 3.92 m | 43.4 s |
| 0.4 | 77.6% | 4.21 m | 41.8 s |
| 0.7 | 82.0% | 4.06 m | 41.8 s |
| 1.0 | 74.0% | 6.42 m | 43.8 s |
Lo zero è il punto migliore, e i valori intermedi si equivalgono entro la dispersione fra prove ripetute a questa velocità. La ragione non è il termine in sé ma la qualità della stima: con correlazione 0.6 fra velocità stimata e velocità vera, poco più di un terzo della varianza della stima è segnale, e un anticipo somma l'intera stima al comando — rumore compreso — che costa più del ritardo che elimina.
Il termine resta nel codice, parametrico e spento per default. Renderlo conveniente richiede una stima migliore, non un guadagno diverso: la via naturale è dare al filtro la velocità del velivolo come ingresso noto, così che stimi direttamente la velocità assoluta del bersaglio invece di ricavarla per differenza da un'immagine in cui i due moti sono sovrapposti.
Quell'ingresso è stato poi implementato — si veda la sezione sul filtro — ma l'effetto sul termine di anticipo non è ancora misurato: finché non lo è, il default resta zero.
Un timer dedicato a 10 Hz ripubblica il comando corrente su
/mavros/setpoint_velocity/cmd_vel_unstamped: ArduPilot esce dal controllo in
velocità se non riceve setpoint con continuità. Pubblica solo in fase AGGANCIO
e sopra 1 m di quota.
Muove il bersaglio in Gazebo — un parallelepipedo rosso di 4.0 × 2.0 × 1.6 m,
le dimensioni di un veicolo leggero — comandandone la posa tramite il servizio
set_pose, con due comportamenti:
| Fase | Comportamento | Velocità |
|---|---|---|
PATTUGLIO |
Orbita circolare attorno a (150, 150), raggio raggio_orbita (40 m) |
velocita_angolare (0.25 rad/s ≈ 10 m/s, 36 km/h) |
EVASIONE |
Fuga in linea retta opposta al drone, per durata_evasione_s |
vel_evasione a regime, raggiunta con rampa accel_evasione |
Valori attuali. La fuga è a vel_evasione = 15 m/s, cioè 54 km/h, raggiunti
con una rampa di 3 m/s²: cinque secondi per il regime, come un mezzo leggero su
sterrato. Dura durata_evasione_s = 20 s, che a quella velocità porta il
bersaglio a circa 260 m dal punto di partenza — molto più del semicampo
inquadrato, quindi la fuga mette davvero alla prova l'inseguimento invece di
svolgersi tutta dentro una sola inquadratura.
Perché il limite non è la velocità del drone. Il vincolo è l'errore a
regime del controllo proporzionale: inseguendo un bersaglio a velocità
costante, l'errore d'immagine si stabilizza intorno a velocità_bersaglio / kp.
La tabella seguente appartiene allo scenario precedente — orbita da 3 m a passo
d'uomo, quota 12 m, kp = 4.0 su coordinate normalizzate — e resta qui perché
il ragionamento vale ancora, non i numeri:
| Velocità bersaglio | Errore a regime | Esito |
|---|---|---|
| 1.2 m/s | ~0.30 | margine ampio, regge anche sotto jamming |
| 2.0 m/s | ~0.50 | metà semicampo: la prima finestra di jamming lo fa uscire |
| 4.2 m/s | oltre il campo | mai agganciato — era il valore originale dell'orbita |
Misure su 100 s di missione, distanza orizzontale drone-bersaglio:
| Fuga a 2.0 m/s | Fuga a 1.2 m/s | |
|---|---|---|
| Distanza massima | 48.5 m | 26.2 m |
| Distanza finale | 43.4 m | 6.3 m |
Tempo in AGGANCIO |
76% | 68% |
Con la fuga a 2 m/s il drone terminava a 43 m e in allontanamento; a 1.2 m/s
recupera e si riporta sopra il bersaglio. Per rendere l'inseguimento più difficile
conviene alzare kp_x/kp_y in controller_node insieme alla velocità, non la
velocità da sola.
L'evasione scatta dopo che la missione è entrata in AGGANCIO: il bersaglio si
comporta come un veicolo che si accorge di essere inseguito e reagisce con un
ritardo. Terminata la fuga riprende a orbitare attorno alla nuova posizione — è
ciò che mette davvero alla prova il tracker e la fase di RICERCA.
Il ritardo è ritardo_evasione_s = 10.0, misurato sull'orologio e non contando
messaggi. Il conteggio riparte da capo se l'aggancio si interrompe: il bersaglio
fugge solo dopo essere stato inseguito per 10 secondi consecutivi, quindi con
un tracking discontinuo il ritardo osservato è più lungo. Terminata la fuga,
lunga durata_evasione_s, riprende a orbitare attorno alla nuova posizione.
Il comportamento osservato in simulazione è quello descritto sopra: è questo nodo
a muovere il bersaglio. Il modello nel world dichiara anche un plugin
TrajectoryFollower che però resta inerte — vedi Problemi noti.
Registra la prova su un file CSV, uno per esecuzione, in /ws/metrics (montato
sull'host come ./metrics/). Campiona a frequenza fissa — indipendente
dall'arrivo dei messaggi, così il file si media e si diagramma senza
reinterpolare — e riporta per ogni istante: fase della missione, validità e
coordinate del rilevamento e della stima, posizione secondo l'EKF, posizione
vera di drone e bersaglio letta da Gazebo, distanza fra i due, stato del
jamming, ritmo effettivo della catena di percezione.
La verità a terra viene da /world/iris_runway/pose/info e non da MAVROS,
perché la stima dell'EKF è essa stessa oggetto di misura e non può fare da
riferimento a se stessa. Entrambe sono registrate: la colonna dist_xy_ekf
accanto a dist_xy_gt rende visibile nei dati lo scarto fra le due, che il resto
del progetto assume nullo.
L'ingresso del filtro, non solo la sua uscita. Le colonne jam_x, jam_y,
jam_valido registrano /target/jammed_position, cioè il rilevamento dopo
il disturbo — quello che il filtro riceve davvero — rumore_rf il livello
dichiarato sul datalink, da cui dipende la matrice R, e nis l'innovazione
normalizzata con cui si giudica la coerenza del filtro. Mancavano, e senza di
esse la domanda «quanto serve il filtro» non è rispondibile con i dati:
confrontare la sua uscita con il rilevamento pulito misura l'errore residuo, non
il guadagno, perché l'alternativa al filtro non è il segnale pulito — che
nessuno possiede — ma quello disturbato.
Alla chiusura il nodo stampa un riepilogo (campioni, percentuale di fotogrammi con bersaglio, distanza media, tempo per fase).
Stabilizza la telecamera comandando i due giunti della sospensione cardanica in senso opposto all'assetto misurato. Il difetto che affronta è geometrico e non di taratura: la telecamera era imbullonata al corpo, e un multirotore accelera inclinandosi, quindi ogni comando di inseguimento produceva una rotazione che traslava l'inquadratura indipendentemente da dove fosse il bersaglio. Più il controllo era pronto, più il velivolo si inclinava, e prima il bersaglio usciva dal campo — le due grandezze non si potevano ottimizzare separatamente.
La compensazione analitica in controller_node resta al suo posto: corregge
l'errore di controllo, non l'osservazione. Se l'inclinazione porta il bersaglio
fuori dai pixel, nessun calcolo lo recupera; e quando il giunto satura ai 45°
del proprio limite, il residuo torna a suo carico.
| Parametro | Default | Significato |
|---|---|---|
abilitato |
true |
a false comanda zero: la telecamera si comporta come fissa |
guadagno |
1.0 | frazione dell'assetto compensata, per misurare quanto conta |
limite_rad |
0.7854 | limite meccanico del giunto, 45° |
timeout_posa_s |
1.0 | oltre, il comando resta all'ultimo valore e l'anomalia è segnalata |
Due dettagli non ovvi del modello. Gli assi dei giunti sono dichiarati nel frame
del modello (expressed_in="__model__") e non in quello del giunto: la
telecamera è ruotata di 90° per guardare a nadir, e senza quella precisazione
gli assi erediterebbero la rotazione, scambiando rollio e beccheggio. I link
della sospensione hanno massa 10 g e non zero: due corpi quasi privi di massa in
serie, comandati da un regolatore, sono una sorgente classica di instabilità
numerica.
Il regolatore è gz-sim-joint-position-controller-system, con
use_velocity_commands attivo: con corpi così leggeri un anello in coppia
oscilla, mentre un servo reale è comunque rigido in posizione. Verificato sul
banco a terra — comandati +0.30 e −0.20 rad, i giunti raggiungono esattamente
+0.30 e −0.20, sia dal lato Gazebo sia attraverso il ponte da ROS 2.
La configurazione di riferimento si ottiene con gimbal:=false, che lascia i
giunti fermi a zero: il confronto avviene così a parità di velivolo, di masse e
di dinamica, invece di confrontare due modelli diversi.
docker compose exec -e LAUNCH_ARGS="gimbal:=false" sim start_all.sh --detachIl gimbal da solo peggiora le cose. È il risultato più istruttivo di questa
fase e vale la pena riportarlo. Attivando la sospensione senza toccare nient'
altro, l'accoppiamento crolla come previsto ma l'inseguimento degrada: bersaglio
rilevato dall'82.9% al 54.2%, distanza mediana da 3.70 a 10.54 m, e per la prima
volta la missione cade in RICERCA.
La causa è che due correzioni agivano sullo stesso effetto. Il gimbal stabilizza
l'immagine, e controller_node continuava a sottrarre l'assetto del corpo
da un'immagine in cui quell'assetto non compariva più: un errore fantasma
proporzionale all'inclinazione, cioè lo stesso difetto che la compensazione
doveva eliminare, col segno rovesciato. La grandezza corretta è l'assetto della
telecamera, che vale assetto del corpo più angolo del giunto — per questo il
controller si iscrive ai due topic di comando del gimbal. Senza gimbal l'angolo
è zero e la formula torna quella precedente; con il giunto in saturazione resta
il residuo, che è esattamente ciò che va ancora compensato.
Misure su fuga a 4 m/s, cinquanta secondi simulati per configurazione:
| Telecamera fissa | Gimbal, doppia compensazione | Gimbal + residuo | |
|---|---|---|---|
Accoppiamento pitch/det_y |
−0.858 | −0.317 | −0.222 |
Accoppiamento roll/det_x |
+0.702 | +0.199 | +0.217 |
| Bersaglio rilevato | 82.9% | 54.2% | 84.0% |
| Fornito dal filtro | 82.9% | 66.7% | 84.7% |
| Distanza mediana | 3.70 m | 10.54 m | 3.88 m |
| Durata aggancio | 42.0 s | 49.0 s + 9 s di ricerca | 45.8 s |
L'assetto spiegava il 74% della varianza della posizione del bersaglio nell'immagine (r² = 0.74); con la sospensione ne spiega il 5%. A questa velocità di fuga, però, le metriche di inseguimento pareggiano il riferimento invece di migliorarlo: il vincolo dominante qui non è l'accoppiamento — il riferimento teneva l'aggancio per tutta la prova — ma l'errore a regime del controllo proporzionale descritto sopra.
La prova è stata ripetuta a 5.5 m/s, oltre il limite di velocità del velivolo, dove il riferimento è in difficoltà:
| Telecamera fissa | Gimbal + residuo | |
|---|---|---|
Accoppiamento pitch/det_y |
−0.869 | −0.241 |
Accoppiamento roll/det_x |
+0.544 | +0.129 |
| Bersaglio rilevato | 73.2% | 74.3% |
| Fornito dal filtro | 81.3% | 83.0% |
| Distanza mediana | 3.86 m | 3.66 m |
| Durata aggancio | 41.8 s | 43.8 s |
Di nuovo l'accoppiamento scompare (r² da 0.76 a 0.06) e di nuovo le metriche di inseguimento si muovono appena. La spiegazione sta nei numeri stessi: con una distanza mediana di 3.9 m e un semicampo che a 12 m di quota copre 12 m al suolo, la traslazione da assetto — 5.6 m equivalenti a 25° di inclinazione — porta il bersaglio a circa 9.5 m, ancora dentro l'inquadratura. Finché il bersaglio resta inquadrato comunque, eliminare l'accoppiamento non aggiunge fotogrammi.
Il valore della sospensione si vede alzando il guadagno. Il progetto
sosteneva che aggressività e osservabilità non si possono ottimizzare
separatamente: più il controllo è pronto, più il velivolo si inclina, e prima il
bersaglio esce dall'inquadratura. Se la sospensione disaccoppia davvero le due
cose, quel prezzo deve smettere di esistere. La prova, con kp_x e kp_y
portati da 1.2 a 2.0 e fuga a 5.5 m/s:
| Telecamera fissa | Gimbal | |
|---|---|---|
| Sequenza delle fasi | PATT → AGG → RICERCA → AGG |
PATT → AGG |
| Bersaglio rilevato | 53.1% | 80.6% |
| Fornito dal filtro | 64.8% | 82.8% |
| Distanza mediana | 8.58 m | 4.12 m |
| Agganci | 2 (26.4 e 12.8 s), perso una volta | 1 (44.4 s), mai perso |
Accoppiamento pitch/det_y |
−0.786 | −0.371 |
Confrontate con il guadagno nominale alla stessa velocità: senza sospensione l'aumento fa crollare il rilevamento dal 73.2% al 53.1% e fa perdere l'aggancio; con la sospensione lo fa salire dal 74.3% all'80.6% e l'aggancio tiene per l'intera prova. È la dimostrazione diretta dell'affermazione architetturale: il conflitto fra guadagno e campo visivo esiste solo con la telecamera solidale al corpo.
Cosa vale allora la sospensione. Rende l'immagine una misura del bersaglio invece che dell'assetto del velivolo — dimostrato — e con questo rimuove il vincolo che teneva bassi i guadagni. Al guadagno nominale non paga nulla, e anche questo va detto: il vincolo dominante a 1.2 non era l'accoppiamento. Paga quando si sfrutta il margine che apre.
I default nel codice restano kp = 1.2, che è il valore sicuro in entrambe le
configurazioni: con gimbal:=false e kp = 2.0 il sistema è peggiore di
quello attuale. Chi gira con la sospensione attiva può alzarlo senza
ricompilare:
ros2 param set /controller_node kp_x 2.0 && ros2 param set /controller_node kp_y 2.0Limiti operativi: cosa il velivolo non deve fare, qualunque cosa chieda la missione. Fino a poco fa non esisteva nulla del genere — nessun confine, nessun tetto di quota, nessuna sorveglianza della batteria — e un bersaglio che fuggiva verso l'orizzonte veniva inseguito finché c'era corrente.
| Limite | Parametro | Default | Perché quel valore |
|---|---|---|---|
| Confine | raggio_max |
500 m | Il circuito è 150 × 150 m e la fuga arriva a ~260 m dal centro dell'orbita: 500 m è il margine oltre il quale l'inseguimento non è più l'attività che stiamo svolgendo |
Un vincolo che si paga subito se ignorato: raggio_max deve superare la
diagonale del circuito di pattugliamento, che per 150 × 150 m vale 212 m.
Sotto quel valore il blocco scatta al vertice lontano durante il normale
pattugliamento, prima ancora che ci sia un bersaglio da inseguire — verificato
in volo con il confine a 200 m.
| Tetto | quota_max | 120 m | Limite della categoria aperta europea |
| Quota minima | quota_min | 5 m | Sotto, la conversione da immagine a metri perde senso |
| Batteria | batteria_min | 0.25 | Frazione di carica sotto la quale si rientra |
Tre scelte di progetto che vale la pena spiegare.
È un nodo separato, e scavalca la missione. Una rete di sicurezza che vive
dentro la logica che deve sorvegliare non è una rete: se mission_node si
blocca in uno stato imprevisto, deve esserci qualcosa di esterno che se ne
accorge. Il meccanismo con cui scavalca è il cambio di modo di volo: fuori
da GUIDED ArduPilot ignora i setpoint di velocità, quindi l'intervento vince
senza dover convincere nessun altro nodo a smettere.
Il blocco viene comunque pubblicato su /sicurezza/blocco, e controllo e
missione lo rispettano smettendo di spingere. Senza, i due continuerebbero a
pubblicare setpoint e waypoint contro un rientro in corso, e appena il velivolo
fosse rimesso in GUIDED riprenderebbero l'inseguimento come se nulla fosse.
Il blocco non cade da solo. Rientrare nel confine non basta: se cadesse
appena il velivolo torna dentro, la missione riprenderebbe a inseguire e lo
riporterebbe fuori — un ciclo che entra ed esce dal confine invece di fermarsi.
Si sgancia solo con un /sicurezza/reset esplicito, che è una decisione di chi
comanda e non del sistema.
Una distinzione che sembra un dettaglio e non lo è: MAVROS riporta -1 quando
la carica non è misurata, e confonderlo con lo zero fermerebbe ogni volo su un
velivolo che non riporta la percentuale. «Non misurata» e «scarica» sono cose
diverse, e il nodo le tiene separate. In questo simulatore la percentuale non
esiste — il SITL riporta -0.01 — quindi il limite di batteria è verificato
in prova ma non esercitabile in volo qui.
Con il confine abbassato a 250 m perché l'inseguimento lo superasse davvero:
t= 79 s 196 m dall'origine AGGANCIO aggancia il bersaglio
t= 91 s 298 m AGGANCIO inseguendo, supera il confine
t=103 s 95 m RICERCA sta rientrando
t=115 s 0 m RICERCA a casa
... 0 m e ci resta per i restanti 190 s
Blocco a 258 m, modo finale RTL, e nessuna ripresa dell'inseguimento benché il
bersaglio continuasse a orbitare a portata. Due numeri che ne escono e che
contano per chi sceglie i valori:
- l'escursione oltre il confine è stata di 59 m, da 250 a 308.8, perché a 20 m/s fermarsi e invertire richiede tempo. Il confine non è una barriera: è la soglia oltre la quale si comincia a fermarsi, e va scelto con quel margine;
raggio_maxdeve superare la diagonale del circuito, 212 m per 150 × 150. Con il confine a 200 m il blocco scattava al vertice lontano durante il normale pattugliamento, prima ancora che ci fosse un bersaglio da inseguire.
Il volo ha anche trovato due difetti che le prove a banco non avevano preso, ed
è la ragione per cui si prova in volo: un RTL comandato durante il decollo,
a 1 m di quota, perché il limite inferiore non distingueva «sotto il minimo» da
«ci sto arrivando»; e una missione che, bloccata, continuava ad avanzare di fase
scrivendo AGGANCIO nel registro mentre il velivolo rientrava — nessun comando
usciva davvero, ma la prova mentiva a chi poi la analizza.
Attacca il ricevitore satellitare del drone, iniettando il disturbo nei
parametri del SITL invece di simularlo a livello di topic: quello che viene
messo alla prova è il sistema reale, autopilota compreso, non una sua
imitazione. È un nodo distinto da jammer_node perché i due guasti sono
fisicamente diversi — quello disturba il canale con cui il bersaglio viene
rilevato, questo il GPS del velivolo — e confonderli era il difetto principale
della prima versione, in cui /gps/jammed veniva pubblicato ma non guidava
nulla.
| Modo | Parametro | Effetto |
|---|---|---|
jamming |
SIM_GPS1_JAM = 1 |
fix intermittente: misurato fix_type da 6 a 1, satelliti da 10 a 3, accuratezza dichiarata fino a 191 m |
negazione |
SIM_GPS1_ENABLE = 0 |
ricevitore muto, equivalente a un'antenna staccata |
spoofing |
SIM_GPS1_GLTCH_X/Y |
fix falsificato di un offset in gradi (0.0002° ≈ 22 m) |
Non parte con lo stack: va abilitato esplicitamente, altrimenti non esisterebbe più una linea di riferimento con cui confrontarlo.
docker compose exec -e LAUNCH_ARGS="gnss_denial:=true gnss_modo:=negazione" sim start_all.sh --detachIl nodo non tocca il GPS prima che la missione lasci ATTESA: arming e decollo
hanno bisogno di un fix valido, e negarlo li farebbe fallire per un motivo che
non ha niente a che vedere con l'esperimento. Le chiamate a /mavros/param/set
sono asincrone — una risposta lenta bloccherebbe la pubblicazione di
/gps/denial_active, falsando l'annotazione delle finestre di attacco nei dati
— e alla chiusura i parametri vengono ripristinati, perché un attacco rimasto
impostato farebbe partire la prova successiva con il GPS compromesso senza che
nulla lo dica.
Il progetto è nato con l'affermazione di essere resistente al GPS denial. Verificarla ha prodotto tre risultati, tutti misurati prima di scrivere il nodo e riportati qui come sono, non come ci si aspettava.
1. Negare il GPS non degrada la navigazione. Con il ricevitore spento per
60 secondi, lo scarto fra la stima di posizione dell'autopilota e la verità a
terra di Gazebo resta sotto il metro e mezzo, lo stesso valore che si misura
con il GPS sano. Le ragioni appartengono all'autopilota, non a questo progetto:
la quota viene dal barometro (EK3_SRC1_POSZ = 1), la navigazione inerziale non
deriva in modo apprezzabile su tempi di questo ordine, e una missione dura
un minuto.
2. Falsificare il GPS non inganna l'EKF. Un offset di 22 m, applicato sia a
gradino sia a rampa, si vede sul fix grezzo — verificato su
/mavros/global_position/raw/fix, la latitudine passa da −35.3630816 a
−35.362882 — ma la stima non lo segue. L'EKF rifiuta la misura incoerente
con la propria predizione, che è il comportamento corretto di un filtro ben
fatto.
3. La via di iniezione funziona. La scrittura di un parametro
dell'autopilota via /mavros/param/set risponde in 9-97 ms, quindi un nodo può
pilotarla senza rischio di bloccarsi.
Ne segue che l'affermazione originale non è dimostrabile in questa configurazione, non perché il sistema sia fragile ma perché la negazione del GNSS non produce, qui, un effetto a cui resistere. Cancellare l'affermazione sarebbe però sbagliato quanto tenerla: quello che si può dimostrare, e che è stato misurato, è che l'inseguimento visivo non dipende dal GNSS. Si esegue la stessa missione due volte, con GPS sano e con GPS negato per tutta la durata, e si confrontano durata dell'aggancio, distanza mediana e frazione di fotogrammi con bersaglio:
prova.sh gnss_off 50
# ...riavvio con gnss_denial:=true gnss_modo:=negazione...
prova.sh gnss_negato 50
metriche.py confronta /ws/metrics/*gnss_off*.csv /ws/metrics/*gnss_negato*.csvIl confronto, su 50 secondi simulati per prova, con il ricevitore spento nel 99.2% dei campioni della seconda:
| Indicatore | GPS sano | GPS negato |
|---|---|---|
| Sequenza delle fasi | PATTUGLIAMENTO → AGGANCIO |
identica |
| Durata dell'aggancio | 40.8 s | 42.0 s |
| Distanza mediana | 2.21 m | 2.10 m |
| Distanza minima | 0.28 m | 0.25 m |
| Bersaglio rilevato | 82.6% | 82.9% |
| Scarto stima-verità, mediano | 0.15 m | 0.15 m |
| Scarto stima-verità, massimo | 1.24 m | 1.20 m |
Le due colonne coincidono entro la banda di ripetibilità misurata (±3%). L'inseguimento visivo non usa il GNSS e la prova lo mostra; l'ultima riga dice però anche l'altra metà della storia, ovvero che nemmeno la navigazione dell'autopilota degrada, ed è la ragione per cui la resistenza alla negazione non è dimostrabile qui: non c'è nulla a cui resistere.
Questa è la formulazione che le misure sostengono, e sostituisce quella che il progetto dichiarava senza prove.
La spirale funzionava contro un bersaglio a passo d'uomo e non poteva funzionare contro un veicolo. Il motivo è aritmetico prima che sperimentale: si espande di pochi metri al secondo attorno al punto di perdita mentre il bersaglio se ne allontana a quindici, quindi non lo raggiungerà mai, qualunque sia il raggio massimo.
La misura di partenza. Cinque prove a scala reale, con la spirale isotropa centrata sulla posizione del drone:
| Prova | Esito della ricerca | Distanza iniziale → minima |
|---|---|---|
reale_kp2p0 |
riagganciato dopo 32.2 s | 128 → 44 m |
reale_ff1p0 |
fallita | 155 → 100 m |
reale_kp4p0 |
fallita | 114 → 114 m |
reale_ff0p0 |
fallita | 110 → 110 m |
scenario_50m |
fallita | 106 → 106 m |
Una su cinque, e in tre casi la distanza minima coincide con quella
iniziale: durante l'intera ricerca il drone non si è avvicinato nemmeno di un
metro. In reale_ff0p0 è passata da 110 a 221 m con un massimo di 302: la
spirale portava il drone via dal bersaglio. L'unico successo non smentisce il
ragionamento, lo conferma — arriva quando la fuga è finita e il bersaglio ha
ripreso l'orbita, cioè quando ha smesso di allontanarsi.
I numeri che dicono cosa serviva. Due grandezze misurate delimitano il
problema. Alla dichiarazione di perdita il bersaglio è già a 106-155 m, non
a pochi metri: la fase AGGANCIO sopravvive per inerzia — coasting del
controllo più predizione del filtro — mentre il bersaglio è fuori inquadratura
da un pezzo, e l'ultimo campione in AGGANCIO risulta a 107 m. In ricerca il
drone vola però a 20 m/s, saturando WP_SPD, contro un bersaglio a 10-15:
il margine di recupero esiste, vale 5-10 m/s, e la spirale lo spende girando in
tondo. Poiché a 50 m di quota l'impronta a terra è larga 100 m, per rivedere il
bersaglio basta scendere sotto i ~40 m di distanza, cioè recuperarne 70: sono
sette-quattordici secondi di inseguimento nella direzione giusta.
La modifica. La ricerca diventa in due tempi. Nel primo il drone vola dove
il bersaglio sarebbe se avesse proseguito, estrapolando dalla posizione e dalla
velocità che controller_node pubblica su /target/odometria; nel secondo si
apre la spirale attorno al punto extrapolato invece che attorno al luogo della
perdita. La conversione da coordinate immagine a metri la pubblica il controllo
e non la rifà la missione: è la stessa formula, e due copie della stessa formula
divergono al primo che ne corregge una sola.
Anticipando la conclusione, perché le misure che seguono si leggano sapendo
dove portano: delle due metà, il centro della ricerca sul bersaglio resta
acceso e l'estrapolazione no. La prima non dipende dalla velocità stimata, la
seconda sì, e quella stima non è all'altezza — durata_inseguimento_cieco_s
vale quindi 0.0 per default. Il perché è quantificato più sotto.
Misurata su tre prove per configurazione, alternate, contro la stessa spirale centrata però sulla posizione del bersaglio — quindi il confronto isola l'estrapolazione, non l'intera modifica:
| Su tutti gli episodi di ricerca | direzionale | spirale |
|---|---|---|
| Riagganci | 75% | 60% |
| Tempo di riaggancio, mediano | 4.8 s | 23.2 s |
Cinque volte più in fretta. E il contatto è reale: nei fotogrammi in cui il bersaglio ricompare, le sue coordinate immagine valgono (-0.82, -0.89) e (+0.73, -0.95), cioè gli angoli dell'inquadratura, a 78 e 65 m di distanza. La geometria torna senza ipotesi aggiuntive: l'angolo del fotogramma cade a √(50² + 37.5²) = 62.5 m, e ai due istanti il velivolo è inclinato di 0.26 e 0.32 rad, che spostano l'impronta di 50·tan(0.32) ≈ 17 m.
Arrivare vicino al bersaglio ha però reso osservabile un difetto che prima non aveva occasione di manifestarsi, e che rendeva il successo apparente. Separando gli agganci iniziali da quelli riconquistati dopo una ricerca:
| durata mediana | fotogrammi con bersaglio visto | |
|---|---|---|
| Ricerca direzionale, aggancio iniziale | 105 s | 64.7% |
| Ricerca direzionale, dopo una ricerca | 4.4 s | 0.0% |
| Spirale, dopo una ricerca | 24.2 s | 64.8% |
Tutti e tre i riagganci del braccio direzionale erano ciechi: il rilevatore non
vedeva il bersaglio in nessun campione, e l'aggancio si sfaldava in quattro
secondi. Il meccanismo: il bersaglio compare per uno o due fotogrammi
nell'angolo dell'inquadratura, quel lampo resuscita il filtro, che da quel
momento pubblica predizioni valide per soglia_perdita fotogrammi; la missione
ne contava due e dichiarava il riaggancio. Per tutta la durata del finto
aggancio la missione smetteva di cercare, affidandosi a un controllo visivo
senza immagine: il finto riaggancio costava più di quanto valesse.
Il difetto non era nella ricerca ma nel criterio che la interrompe. Ora
l'ingresso in AGGANCIO richiede rilevamenti veri e consecutivi, contati sul
flusso a valle del jammer; restare in AGGANCIO può invece poggiare sulla
predizione. L'asimmetria è voluta: la predizione serve a superare le
micro-interruzioni di un inseguimento in corso, non a crearne uno.
| A parità di tutto il resto | criterio vecchio | criterio corretto |
|---|---|---|
Fotogrammi con bersaglio visto in AGGANCIO |
64.7% | 70.1% |
Distanza mediana in AGGANCIO |
41.7 m | 24.2 m |
| Durata mediana dell'aggancio | 19.4 s | 47.3 s |
| Riagganci dichiarati | 75% | 33% |
| Agganci riconquistati | 3, da 4-9 s, visto 0.0% | 1, da 20.2 s, visto 54.9% |
I finti agganci sono spariti. Il calo dei riagganci dal 75% al 33% è il prezzo apparente di quel 75%, che contava tre successi che non erano successi; migliorano invece le grandezze che non dipendono dal conteggio, perché non ci sono più episodi da quattro secondi a inquinare le mediane.
Con il criterio corretto è emerso il limite vero. In una prova la ricerca è durata 79.8 s partendo da 151.7 m con distanza minima esattamente quella iniziale, e il log dice perché:
Bersaglio perso — ricerca da (205, 177) con velocita (-7.7, -12.8) m/s
RICERCA inseguimento cieco -> (179, 134) ... -> (141, 70)
Il bersaglio vero era a (340, 250) e si allontanava verso nord-est. Il drone ha volato verso sud-ovest.
La causa non è un errore di segno. Nei fotogrammi che precedono ogni perdita la posizione del bersaglio nell'immagine oscilla da un bordo all'altro — il velivolo manovra e il bersaglio è al margine — e la velocità istantanea del filtro fotografa quell'oscillazione. È la stessa debolezza già misurata quando il termine di anticipo è stato provato, dove la stima correla 0.6 con il vero; lì costava rumore nel comando, qui costa la direzione della ricerca.
Il dato che ha trasformato l'ipotesi in diagnosi sono le quattordici stime registrate in una giornata di prove: tre valevano 41, 33 e 76 m/s, contro un bersaglio che non può superare i 15 imposti dal simulatore. Non stime rumorose: stime impossibili, accettate senza controllo. Settantasei metri al secondo estrapolati per otto secondi mandano il punto di ricerca a seicento metri dal vero.
Quattro presidi, nessuno dei quali una taratura — e il quarto esiste perché il terzo, da solo, peggiorava le cose:
- mediana su una finestra recente (
finestra_velocita_s) invece di un singolo campione: una direzione che si inverte fra un fotogramma e il successivo non sopravvive alla mediana, un moto vero sì; - limite fisico (
vel_bersaglio_max, 25 m/s cioè 90 km/h per un veicolo terrestre): una stima che lo supera viene rifiutata, non troncata. Un valore impossibile non significa «circa quello», significa «non lo so», e chi lo usa deve poterlo sapere. Senza velocità ricostruibile la ricerca non estrapola: vola all'ultima posizione nota e apre la spirale lì, che è la degradazione controllata; - la velocità predetta non è una misura. Il filtro pubblicava come valida anche la velocità durante la predizione, dove lo stato resta congelato all'ultimo valore: la finestra si riempiva di copie dello stesso numero proprio nei fotogrammi che precedono la perdita, e la mediana di quindici copie di un valore è quel valore. La posizione predetta resta valida — è quella che tollera le micro-interruzioni — la velocità no;
- ma una velocità stabilita da misure vere non diventa ignota appena le
misure cessano. Il presidio precedente, da solo, ha prodotto l'effetto
opposto a quello voluto, e la prima prova lo ha mostrato subito: escluse le
predizioni, negli ultimi fotogrammi entrano solo campioni di misure vere, la
finestra da un secondo scende sotto il minimo di cinque — il rilevatore in
fuga cala fino a 10 Hz, e 0.4 s a 10 Hz sono quattro campioni — e la velocità
pubblicata alla perdita valeva
(+0.0, +0.0). La ricerca smetteva di estrapolare e volava all'ultima posizione nota, cioè la degradazione controllata invece della funzione. Lo sbaglio non era escludere le predizioni: era dedurne che la velocità fosse sconosciuta, quando era conosciuta mezzo secondo prima. Ora l'ultimo valore ben stabilito resta utilizzabile pervalidita_velocita_s, dopo il quale torna ignoto davvero. Il valore ricordato non viene mai sostituito da una stima fuori dal limite fisico: una misura impossibile non deve poter scacciare una buona.
Quanto vale davvero quella stima. Il sospetto naturale, davanti a mediane stabilmente sopra il limite fisico, è un errore sistematico: se la velocità del velivolo fosse sommata con il segno sbagliato si otterrebbe la somma dei moduli invece della differenza. La verifica contro la verità a terra lo esclude — l'errore mediano vale 9.3 m/s con la somma e 31.3 m/s con la differenza, quindi il segno è quello giusto — ma nel farlo produce il numero che decide l'intera questione.
Su 18 prove e 5426 campioni, l'errore mediano della velocità ricostruita valeva 10.8 m/s, contro un bersaglio che viaggia a 10.0 m/s. Ogni singola prova concordava, da 7.4 a 20.8 m/s: l'errore era grande quanto il segnale. (Misura del 6 settembre. Dopo la taratura descritta nella sezione sul filtro vale 6.7 m/s, rapporto 0.67.)
Da una grandezza così non si ricava una direzione, e nessun filtraggio a valle
può cambiarlo: mediana, limite fisico e memoria impediscono il disastro, non
creano l'informazione che manca. È il motivo per cui
durata_inseguimento_cieco_s è spento per default, con la stessa logica con cui
k_anticipo vale zero — misurato, non conveniente, lasciato parametrico con la
ragione scritta accanto. Migliorata la stima, si riaccende senza altre
modifiche.
L'effetto del limite fisico è comunque categorico prima che statistico: nessuna ricerca vola più nella direzione sbagliata. Con il limite attivo tutte e quattro le ricerche hanno chiuso distanza — 25, 83, 0 e 49 m recuperati — mentre senza, una era andata da 151.7 a 151.7 m con il drone a 250 m dalla parte opposta.
| A parità di tutto il resto | senza limite | con limite |
|---|---|---|
| Riagganci | 33% | 50% |
| Tempo di riaggancio, mediano | 13.8 s | 5.3 s |
| Distanza minima in ricerca | 81.8 m | 64.2 m |
| Ricerche che si sono avvicinate | 2 su 3 | 4 su 4 |
Le differenze sulle grandezze d'esito — percentuali di riaggancio, tempo in
AGGANCIO, durata degli agganci — sono dentro la dispersione misurata a
questa scala e non vanno lette come risultati: con tre prove per configurazione
e un fattore due-quattro fra prove identiche, tabelle di questo tipo mostrano
tendenze e non misure. Si veda il paragrafo sulla ripetibilità.
Ciò che è dimostrato è di natura diversa, e non richiede statistica:
- la spirale isotropa non chiudeva la distanza, con la minima uguale all'iniziale in tre prove su cinque;
- i riagganci del criterio vecchio erano ciechi, con zero fotogrammi rilevati su tre episodi, e ora non lo sono;
- tre stime di velocità su quattordici erano fisicamente impossibili, e ora vengono rifiutate;
- con il limite attivo nessuna ricerca si è allontanata dal bersaglio;
- l'errore della velocità stimata valeva quanto la velocità stessa — 10.8
contro 10.0 m/s su 5426 campioni — ed è la misura che, sola fra tutte quelle
di questa sezione, non risente della dispersione. Dopo la taratura di
QeRè sceso a 6.7 m/s, rapporto 0.67: migliorato di un terzo, non ancora risolto.
Resta aperto il limite di fondo, che è quello già dichiarato da questo progetto: la qualità della stima di velocità. Che sia profondo lo dicono due osservazioni indipendenti — perfino la mediana su un secondo, venticinque campioni, produceva ancora valori oltre il limite fisico, quindi il rumore è correlato su tempi più lunghi della finestra e non si elimina filtrando; e l'errore mediano eguaglia il segnale su 5426 campioni.
La via indicata dalla struttura del problema è sempre stata la stessa: fornire al filtro la velocità del velivolo come ingresso noto, così che stimi direttamente la velocità assoluta del bersaglio invece di ricavarla per differenza da un'immagine in cui i due moti sono sovrapposti. L'argomento più forte a favore non è teorico: due funzioni indipendenti — guida predittiva e ricerca direzionale — sono state progettate, implementate e misurate separatamente, e si sono fermate entrambe davanti alla medesima stima. Quando due strade distinte incontrano lo stesso muro, è quel muro il problema.
L'ingresso noto è ora implementato, insieme al rifiuto delle misure
implausibili e alla pubblicazione del NIS. L'effetto non è ancora misurato,
quindi i due termini che dipendono da quella stima — k_anticipo e
durata_inseguimento_cieco_s — restano a zero finché una batteria di prove non
dirà se conviene riaccenderli.
Cosa resta acceso. Il centro della ricerca sull'ultima posizione nota del bersaglio, che non dipende dalla velocità e la cui utilità è visibile nel confronto con le prove storiche; il criterio di riaggancio sui rilevamenti veri; i quattro presidi sulla stima di velocità, che valgono anche per chiunque la usi in futuro. Spenta resta solo l'estrapolazione, in attesa della stima che la renderebbe sensata.
In simulazione fra otturatore e rilevamento non passava quasi niente, quindi il progetto ha sempre trattato l'istante d'arrivo di un messaggio come l'istante a cui il dato si riferiva. Su hardware i due differiscono di 50–150 ms. Prima di cambiare il contratto fra sei nodi per marcarli temporalmente, si è misurato se il danno lo giustificasse.
Il rilevatore sa iniettare un ritardo (ritardo_s, default 0.0): trattiene i
rilevamenti in coda e li rilascia più tardi, conservando l'istante originale.
Quattro voli di due minuti, due per configurazione:
| 0 s | 0,15 s | |
|---|---|---|
| errore sulla stima di velocità | 5,6 · 5,9 m/s | 7,6 · 7,3 m/s |
bersaglio inquadrato in AGGANCIO |
67 % · 74 % | 54 % · 55 % |
distanza mediana in AGGANCIO |
25 · 36 m | 42 · 41 m |
| durata mediana dell'aggancio | 38,0 s | 8,4 s |
Quattro indicatori indipendenti degradano insieme, e le due prove di ogni configurazione cadono vicine. Il conto torna con la geometria: a 10 m/s, 0,15 s sono 1,5 m di ritardo, che a 50 m di quota valgono 0,03 in coordinate normalizzate, circa il doppio del rumore di misura (0,016). Ma è un errore costante e direzionale, quindi non si media via, e arriva dentro un anello chiuso con un termine derivativo: sfasare la retroazione costa più di quanto la sua ampiezza suggerisca.
Il NIS non si muove: 1,05 senza ritardo, 1,03 e 1,07 con.
Non è un difetto della diagnostica. Un ritardo puro applicato a una traiettoria regolare è una traiettoria regolare: le innovazioni restano statisticamente coerenti perché il filtro sta stimando fedelmente dov'era il bersaglio un istante fa. È sano e sbagliato allo stesso tempo, e nessun controllo a tempo d'esecuzione può distinguerlo. Un difetto che non si può sorvegliare va tolto per costruzione: è questo, e non la dimensione del danno, ad aver deciso il cambio dei contratti.
I quattro topic della catena sono passati da Point a PointStamped. L'istante
propagato è quello dell'otturatore, copiato dall'header dell'immagine e non
generato al momento della pubblicazione: così la marcatura copre anche il ponte
della telecamera. Il jammer lo lascia passare intatto, perché disturbare un
canale cambia il valore e non quando la scena è stata guardata.
Il filtro ne fa due usi:
- il
dtdella predizione si calcola fra gli istanti dichiarati, non fra gli arrivi, che includono un trasporto variabile; - prima di pubblicare, lo stato viene portato avanti fino ad adesso —
velocità di stato per l'età della misura, più il termine noto del moto del
velivolo sullo stesso intervallo. L'estrapolazione ha un tetto
(
eta_massima_misura_s, 0,3 s): oltre, l'errore sulla velocità moltiplicato per il tempo supera il ritardo che si sta correggendo.
Stesse quattro prove, stessa latenza iniettata, codice nuovo:
| a 0,15 s di latenza | prima | dopo |
|---|---|---|
| errore sulla stima di velocità | 7,6 · 7,3 m/s | 5,6 · 6,7 m/s |
bersaglio inquadrato in AGGANCIO |
54 % · 55 % | 76 % · 77 % |
distanza mediana in AGGANCIO |
42 · 41 m | 31 · 34 m |
| durata mediana dell'aggancio | 8,4 s | 40,0 s |
Entrambe le prove concordano, e riportano il sistema al livello che aveva senza alcuna latenza: la correzione non attenua il danno, lo annulla.
A latenza iniettata zero non cambia nulla di misurabile (4,5 e 7,4 m/s contro 5,6 e 5,9; inquadratura 75 % e 60 % contro 67 % e 74 %): la dispersione fra prove copre la differenza. Anche questo torna, perché ora la latenza vera della catena si può misurare invece di supporla — confrontando l'istante dichiarato con quello d'arrivo, sullo stesso orologio dei nodi:
immagine 33,0 ms di età all'arrivo (massimo 36)
rilevamento 35,1 ms
Trentacinque millisecondi, di cui 33 nel ponte della telecamera e 2 nel rilevamento. A 10 m/s sono 35 cm: un quinto di quello che costano 0,15 s, e sotto la soglia in cui il resto del sistema se ne accorge. La marcatura non serve a questa simulazione: serve al velivolo vero, dove la catena costa da tre a cinque volte tanto, ed era l'unica configurazione in cui il difetto si poteva ancora misurare prima di incontrarlo.
Le cifre citate in questo documento nascevano da script Python scritti sul momento e mai salvati: nessun terzo poteva riprodurle, e due prove della stessa configurazione non erano confrontabili perché a cambiare era anche lo strumento. Gli elementi che chiudono la lacuna sono quattro.
Tempo di simulazione. Tutti i nodi girano con use_sim_time attivo e
/clock pontato da Gazebo (tracking.launch.py). Prima gli intervalli erano
misurati sull'orologio di parete, cosa che vale solo perché il SITL gira a
velocità 1 e non a lockstep — un'assunzione mai dichiarata. Attenzione: con
use_sim_time i timer dei nodi non partono finché /clock non pubblica, cioè
finché Gazebo non è in esecuzione. Per lanciare i nodi da soli:
ros2 launch drone_tracking tracking.launch.py use_sim_time:=falseDisturbo ripetibile. jammer_node parte da un seme fisso (seed, default
42), quindi due prove ricevono la stessa sequenza di rumore.
Una prova in un comando. prova.sh fa la sequenza completa — fotografa la
configurazione, decolla se serve, avvia la missione, attende, riassume:
prova.sh baseline 150Una procedura digitata a mano cambia ogni volta di qualche dettaglio, e quel
dettaglio finisce nei numeri. metrics_node apre un file nuovo a ogni avvio di
missione, quindi una prova corrisponde sempre a un file.
Dati della prova. metrics_node scrive il CSV; metriche.py lo legge:
metriche.py riassumi /ws/metrics/metrics_20260904_181500.csv
metriche.py confronta /ws/metrics/prova_A.csv /ws/metrics/prova_B.csv
metriche.py ricerche /ws/metrics/*_cieco8_*.csv
metriche.py gruppi '*_cieco8_*.csv' '*_spirale_*.csv'
metriche.py stima /ws/metrics/*.csvFigure. grafici.py rigenera dalle stesse tracce tre figure: lo stato
stimato con R nel tempo, l'errore di posizione con e senza filtro, e i
percorsi di velivolo e bersaglio in pianta — pattugliamento, fughe, perdita e
ricerca in un solo volo:
python3 /usr/local/share/drone_tracking/scripts/grafici.py \
/ws/metrics/<prova>.csv /ws/metrics/figureEsiste come script versionato e non come sessione interattiva per la stessa
ragione di metriche.py: una figura che finisce in una relazione deve poter
essere rifatta da chiunque, dallo stesso dato, ottenendo la stessa immagine.
Le due figure sul filtro restringono da sé la finestra al tratto di AGGANCIO
continuo più lungo — il regime in cui il filtro lavora — così l'inquadratura non
è scelta a occhio; quella dei percorsi usa invece la prova intera, perché lì il
soggetto è il volo dall'inizio alla fine. Nella figura dei percorsi la fuga è
distinta dall'orbita per tasso di virata, 0.00 contro 0.25 rad/s, e non per
velocità: l'orbita vale 10 m/s tangenziali e la fuga sale a 15 passando per
tutti i valori intermedi durante la rampa, quindi il modulo da solo non separa i
due moti mentre la geometria sì.
riassumi dà durata degli agganci, distanza mediana e media, frazione di
campioni con bersaglio, tempo per fase, ritmo della percezione. confronta
verifica la ripetibilità di due prove gemelle.
ricerche elenca ogni episodio di RICERCA con il suo esito: durata, distanza
al momento della perdita, distanza minima raggiunta, e se il bersaglio è stato
davvero ritrovato. Quest'ultimo punto non si legge dalla macchina a stati, che
dichiara il riaggancio su fotogrammi validi del tracker e quindi anche su
predizioni; la prova sta invece nella semantica del filtro, che dopo
soglia_perdita si azzera e può tornare valido solo ricevendo una misura vera.
Una risalita di trk_valido da 0 a 1 è percio un rilevamento avvenuto, anche
quando il campionamento a 5 Hz non lo vede — e spesso non lo vede, perché il
rilevatore pubblica a ~25 Hz e un avvistamento di un solo fotogramma ha una
probabilità su cinque di finire in un campione.
gruppi confronta due configurazioni con più prove ciascuna. Esiste per non
invitare più a usare confronta dove non si può: si veda il paragrafo seguente.
stima misura la qualità della velocità che il filtro dichiara, ricostruendola
dalla stima e confrontandola con quella vera letta dal simulatore. Riporta
l'errore accanto al segnale che dovrebbe misurare, perché è il rapporto fra
i due a contare: un errore di 10 m/s su un bersaglio che ne percorre 10 significa
che la stima non porta informazione, per quanto il numero possa sembrare piccolo
in assoluto. È il valore su cui questo progetto ha cambiato idea due volte, e
ogni volta era stato ricavato da uno script scritto al momento.
Il comando rifiuta le tracce precedenti all'ingresso noto: fino ad allora
trk_vx/vy erano velocità relative al velivolo, e applicarvi la conversione
attuale restituisce un numero sbagliato senza che nulla lo segnali — verificato,
12.8 e 17.2 m/s contro i 10.8 che la conversione corretta per l'epoca dava.
Quanto sono ripetibili, in concreto. Due prove con la stessa configurazione e lo stesso seme, da stack riavviato (misura del 4 settembre 2026, 60 s simulati ciascuna):
| A | B | |
|---|---|---|
| Sequenza di fasi | PATTUGLIAMENTO → AGGANCIO |
identica |
| Durata dell'aggancio | 52.4 s | 53.8 s |
| Distanza mediana | 2.66 m | 2.51 m |
| Distanza media | 5.33 m | 5.32 m |
| Bersaglio rilevato | 85.5% | 85.0% |
| Fattore di tempo reale | 0.30x | 0.26x |
Le grandezze aggregate coincidono entro il 3%, ma le posizioni istantanee no: allineando le due prove riga per riga, la posizione del drone differisce in media di 6 m. Non e divergenza della dinamica, e sfasamento — la missione parte a un istante diverso e da lì tutto slitta. La ripetibilità del progetto e quindi in distribuzione, non in traiettoria: si confrontano durata degli agganci, distanze mediane e frazioni di visibilità, non gli istanti uno per uno.
Alla scala reale la ripetibilità è molto peggiore, e va detto. Il 3% qui sopra è stato misurato con il bersaglio a passo d'uomo su un'orbita da 3 m: a quella scala il drone stava sopra il bersaglio per quasi tutta la prova e non c'era molto che potesse andare diversamente. Con la fuga a 15 m/s la dinamica diventa marginale — il drone recupera 5-10 m/s su un bersaglio che ne fa 15 — e in una situazione marginale piccole differenze di istante decidono l'esito.
La misura, sei prove del 6 settembre 2026 a 120 s simulati, tre per configurazione:
| durate dell'aggancio iniziale | |
|---|---|
| Configurazione A | 29 s, 107 s, 105 s |
| Configurazione B | 54 s, 74 s, 3 s |
Fino alla prima perdita del bersaglio le due configurazioni eseguono codice
identico — differiscono solo in cosa fanno durante RICERCA — quindi quei sei
numeri misurano la dispersione e nient'altro. Va da 3 a 107 secondi: un fattore
trenta fra il caso peggiore e il migliore, e un fattore due-quattro fra prove
della stessa configurazione.
Le conseguenze sul metodo sono due, e valgono per qualunque misura futura a questa scala:
- niente conclusioni da una prova sola, e nemmeno da due: servono almeno tre ripetizioni per configurazione, alternate fra loro perché una deriva della macchina non cada tutta su una;
- l'unità di analisi è l'episodio, non la prova, dove la domanda lo
consente. Per giudicare la ricerca, per esempio, la domanda è «dato che il
bersaglio è stato perso, viene ritrovato?»: una prova che non lo perde mai non
ha voce in capitolo, ma peserebbe eccome su una mediana per prova. Aggregando
gli episodi il campione utile passa da tre valori a una decina. È la ragione
per cui
metriche.py gruppistampa due blocchi separati.
Per ridurre lo sfasamento, target_mover_node riporta il bersaglio al punto di
partenza dell'orbita quando la missione lascia ATTESA: senza questo, ogni
prova trovava il bersaglio in un punto diverso dell'orbita a seconda di quanto
era durato il decollo.
Prove automatiche. Le correzioni piu facili da rompere in seguito senza
accorgersene — watchdog, coasting, scalatura di Q, soglia di ricerca — hanno
una prova che le esercita direttamente, senza far volare nulla:
colcon test --packages-select drone_tracking && colcon test-result --verboseProvocare quei casi in simulazione richiederebbe di fermare un nodo a mano o di aspettare che scada una soglia; qui il tempo si simula riavvolgendo gli istanti registrati dai nodi.
Configurazione della prova. Il CSV dice come è andata, non con che taratura, e i parametri sono modificabili a caldo. Prima di una prova conviene quindi fotografarli:
salva_config.sh nome_della_provache scrive <marca>_<nome>.params.yaml accanto ai CSV. È il motivo per cui le
costanti tarate sono diventate parametri ROS: un valore letterale nel codice non
dice nulla su una prova già conclusa.
Per dare un'etichetta al file di una prova:
ros2 launch drone_tracking tracking.launch.py etichetta_config:=gimbal_off seed:=7Da usare sulla VM/macchina Ubuntu dove ArduPilot e Gazebo sono installati localmente. Sostituire i percorsi con i propri. L'ordine è obbligatorio.
Symlink degli asset Gazebo. Il repository versiona mondo e modello sotto
sim/, ma gz sim li carica da ardupilot_gazebo/: senza questo passaggio
git pull aggiorna file che la simulazione non legge. Vedi Problemi noti.
cd "$HOME/Desktop/Progetto Drone/ardupilot_gazebo/worlds"
mv iris_runway.sdf iris_runway.sdf.bak
ln -s "$HOME/Desktop/Progetto Drone/drone_tracking_ws/sim/worlds/iris_runway.sdf" .cd "$HOME/Desktop/Progetto Drone/ardupilot_gazebo/models/iris_with_ardupilot"
mv model.sdf model.sdf.bak
ln -s "$HOME/Desktop/Progetto Drone/drone_tracking_ws/sim/models/iris_with_ardupilot/model.sdf" .Parametri aggiuntivi del SITL — non c'è nulla da creare. Stanno in
docker/sitl-defaults.parm, versionato nel
repository, e il comando T2 lo carica direttamente: container e VM leggono lo
stesso file, quindi git pull aggiorna anche la taratura del volo. Il nome della
cartella docker/ è storico, il contenuto non ha nulla di specifico del
container.
Due note su quel file. ARMING_CHECK non esiste in questa versione di
ArduPilot e viene ignorato in silenzio, lasciando tutti i controlli attivi: il
parametro giusto è ARMING_SKIPCHK, con logica inversa, dove 1 significa
"salta tutto". Qui vale 0, cioè tutti i controlli attivi. E i SIM_*_RND
azzerano il rumore degli IMU simulati, perché quando la fisica singhiozza i tre
giroscopi divergono e l'arming viene rifiutato con Arm: Gyros inconsistent,
un controllo che ARMING_SKIPCHK non copre.
cd "$HOME/Desktop/Progetto Drone/drone_tracking_ws"
git pull
colcon build --packages-select drone_tracking
source install/setup.bashT1 — Gazebo
export LIBGL_ALWAYS_SOFTWARE=1
gz sim -v4 -r "$HOME/Desktop/Progetto Drone/ardupilot_gazebo/worlds/iris_runway.sdf"T2 — ArduPilot SITL
cd "$HOME/Desktop/Progetto Drone/ardupilot"
./build/sitl/bin/arducopter --model JSON --speedup 1 \
--sim-address=127.0.0.1 --sim-port-in=9013 --sim-port-out=9012 \
-I0 --defaults Tools/autotest/default_params/gazebo-iris.parm,"$HOME/Desktop/Progetto Drone/drone_tracking_ws/docker/sitl-defaults.parm"T3 — MAVProxy
source ~/venv-ardupilot/bin/activate
mavproxy.py --master tcp:127.0.0.1:5760 \
--out 127.0.0.1:14550 --out udp:127.0.0.1:14555 --consoleT4 — MAVROS2
source /opt/ros/jazzy/setup.bash
ros2 run mavros mavros_node --ros-args \
-p fcu_url:=udp://:14555@127.0.0.1:14556 \
-p target_system_id:=1 -p target_component_id:=1 \
-p plugin_denylist:=[distance_sensor]MAVROS si collega all'uscita UDP di MAVProxy, non a una porta TCP del SITL: vedi Problemi noti per il motivo, che è tutt'altro che ovvio.
L'ultimo parametro esclude il plugin distance_sensor, che altrimenti riempie i
log di DS: no mapping for sensor id: 0, type: 4, orientation: 25 più volte al
secondo. Omettendolo il sistema funziona lo stesso, ma i log diventano
illeggibili.
T5 — Nodi ROS 2
export RCUTILS_COLORIZED_OUTPUT=1
cd "$HOME/Desktop/Progetto Drone/drone_tracking_ws"
source install/setup.bash
ros2 launch drone_tracking tracking.launch.pyIl launch file avvia i sei nodi e il parameter_bridge di ros_gz che porta
/drone/camera/image_raw da Gazebo a ROS 2. Non serve quindi lanciare un secondo
ponte a mano: due parameter_bridge sullo stesso topic si sovrappongono, e se il
secondo è dichiarato bidirezionale (@gz.msgs.Image invece di [gz.msgs.Image)
rimanda anche i messaggi da ROS 2 verso Gazebo.
T6 — Decollo (nella console MAVProxy)
Passaggio obbligatorio. Saltandolo il drone resta a terra:
mission_nodecomincia a pubblicare i setpoint di posizione, ArduPilot ruota per allinearsi allo yaw richiesto — sembra "girare su se stesso" — ma in GUIDED non decolla senza un comando esplicito, quindi non raggiunge mai i waypoint.
mode guided
arm throttle
takeoff 12
I controlli di arming sono attivi (ARMING_SKIPCHK 0, caricato all'avvio
del SITL da docker/sitl-defaults.parm). Dopo un avvio a freddo il primo
tentativo può essere rifiutato con Arm: Gyros inconsistent: si ritenta, e passa.
takeoff.sh lo fa da solo fino a cinque volte.
MAVProxy annuncia
Arming checks disableda ogni armamento anche quando i controlli sono attivi. Non è una diagnosi: il suo moduloarmleggeARMING_CHECK, che in questa versione non esiste, ottieneNonee conclude che i controlli siano spenti. È lo stesso scarto di nome descritto sopra, che qui produce un messaggio rassicurante e falso, in entrambe le direzioni.
T7 — Avvio missione
ros2 topic pub --once /mission/avvia std_msgs/msg/Bool "data: true"La quota di decollo dev'essere 50 m, coerente con i waypoint. La quota entra anche nella conversione da coordinate immagine a metri fatta dal controller, quindi volare a una quota molto diversa cambia la scala dell'errore — non i guadagni, che sono in unità fisiche, ma l'ampiezza dell'area inquadrata.
| Topic | Tipo | Descrizione |
|---|---|---|
/drone/camera/image_raw |
sensor_msgs/Image |
Feed telecamera dal ponte ros_gz |
/target/position |
geometry_msgs/PointStamped |
Posizione grezza dal detector (point.z = area) |
/target/jammed_position |
geometry_msgs/PointStamped |
Posizione corrotta dal jammer |
/target/tracked_position |
geometry_msgs/PointStamped |
Stima filtrata dal Kalman, portata all'istante dell'header |
/target/tracked_velocity |
geometry_msgs/PointStamped |
Velocità del bersaglio in unità immagine/s (point.z = 1 se valida) |
/target/nis |
std_msgs/Float32 |
Innovazione normalizzata del filtro |
/target/debug_image |
sensor_msgs/Image |
Frame annotato per il debug |
L'header non è decorativo. Su tutta la catena porta l'istante dell'otturatore,
copiato dall'header dell'immagine e propagato invariato attraverso il jammer:
disturbare un canale cambia il valore, non quando la scena è stata guardata. Il
filtro lo usa per due cose — calcolare il dt della predizione fra gli istanti
dichiarati invece che fra gli arrivi, e portare la stima fino ad adesso prima di
pubblicarla. Su /target/tracked_position l'header dice a quale istante la stima
si riferisce, che dopo l'estrapolazione non è più quello della misura.
| Topic | Tipo | Descrizione |
|---|---|---|
/gps/jammed |
std_msgs/Bool |
Stato del jamming GPS |
/rf/noise_level |
std_msgs/Float32 |
Intensità rumore RF (0.0 – 1.0) |
| Topic | Tipo | Descrizione |
|---|---|---|
/mission/avvia |
std_msgs/Bool |
Trigger di avvio del pattugliamento |
/mission/stato |
std_msgs/String |
Fase corrente della missione |
/tracker/reset |
std_msgs/Bool |
Reset forzato del filtro |
/drone/cmd_vel |
geometry_msgs/Twist |
Comandi di velocità (solo debug) |
| Topic | Tipo | Direzione |
|---|---|---|
/mavros/setpoint_velocity/cmd_vel_unstamped |
geometry_msgs/Twist |
← controller (fase AGGANCIO) |
/mavros/setpoint_position/local |
geometry_msgs/PoseStamped |
← mission (PATTUGLIAMENTO, RICERCA) |
/mavros/local_position/pose |
geometry_msgs/PoseStamped |
→ mission, target_mover |
/mavros/global_position/rel_alt |
std_msgs/Float64 |
→ controller |
I subscriber su topic MAVROS usano QoS BEST_EFFORT con depth=1: MAVROS
pubblica con QoS sensor-like e un subscriber RELIABLE non riceverebbe nulla.
ros2 run rqt_image_view rqt_image_viewSelezionare /target/debug_image per vedere il rilevamento in tempo reale.
ros2 topic echo /mission/statoros2 run rqt_plot rqt_plot /target/position/x /target/jammed_position/x /target/tracked_position/xQuest'ultimo è il grafico più significativo del progetto: mostra il segnale pulito, quello corrotto dal jammer e la ricostruzione del Kalman sovrapposti.
Lo stack parte senza simulatore, e nessuno lo dice — se start_all.sh viene
invocato da una shell che ha gia caricato ROS 2 (docker compose exec sim bash -lc 'start_all.sh', oppure entrando nel container e lanciandolo da li), il
comando gz sim stampa l'elenco dei sottocomandi disponibili e termina con
esito zero. Motivo: il CLI gz scopre i propri sottocomandi dai file di
configurazione elencati in GZ_CONFIG_PATH, e il source di ROS 2 sovrascrive
quella variabile con i soli percorsi dei pacchetti vendored
(gz_transport_vendor, gz_msgs_vendor), dove sim non esiste. L'ambiente
passa al server tmux e da questo a tutte le finestre.
Il guasto è insidioso perché tutto il resto funziona: MAVROS si avvia, i sette
nodi partono, ros2 node list li elenca tutti, nessun log segnala un errore.
Con use_sim_time i timer restano semplicemente fermi e la missione non fa
nulla. Sintomo diagnostico: /clock non pubblica, e il pannello gazebo di
tmux mostra un prompt invece del log del simulatore.
start_all.sh rimette ora il percorso di sistema in testa alla variabile:
export GZ_CONFIG_PATH="/usr/share/gz${GZ_CONFIG_PATH:+:$GZ_CONFIG_PATH}"Telecamera — link camera_link fissato a base_link, pose
0.1 0 -0.05 0 1.5708 0: puntata a nadir. Le versioni precedenti la
inclinavano in avanti (1.047 rad = 60°, documentata erroneamente come 45°). A
nadir "bersaglio al centro dell'immagine" coincide con "drone sopra il
bersaglio", che è l'obiettivo della missione; con l'asse inclinato in avanti il
drone doveva invece mantenere una distanza di stallo di ~7 m e non poteva mai
sovrastare il bersaglio. Con FOV orizzontale di 60° a 12 m di quota
l'inquadratura copre ~14 m di terreno.
Attenzione: cambiare l'inclinazione non riduce l'accoppiamento fra assetto e immagine — una rotazione del corpo trasla l'inquadratura della stessa quantità qualunque sia il puntamento. Per quello serve la compensazione d'assetto nel controller.
Plugin RosCamera inerte — iris_with_ardupilot/model.sdf contiene un blocco
gz-sim-ros-camera-system: quel plugin non esiste in Gazebo Harmonic, che lo
ignora con un errore a console. Il feed arriva a ROS 2 unicamente tramite il tag
<topic>/drone/camera/image_raw</topic> del sensore, raccolto dal
parameter_bridge nel launch file. Il blocco può essere rimosso senza effetti.
Convenzione del campo z — Point.z non è una coordinata: trasporta l'area
del contorno e serve da flag di visibilità. Modificando i nodi va preservata,
perché è ciò che permette di distinguere il bersaglio centrato dal bersaglio
assente.
Accelerazione 3D assente in VM — servono LIBGL_ALWAYS_SOFTWARE=1 e, per
ottenere una fisica fluida, la modalità server-only (gz sim -s). Nel container
è il default (HEADLESS=1).
Conflitto sulla porta 9002 — la porta usata di default per il canale ArduPilot ↔ Gazebo viene occupata da un processo Ruby interno a Gazebo. Il progetto usa 9012/9013.
È la chiave per capire il resto di questa sezione. Nel sistema convivono due famiglie di nodi con nature diverse.
Prima di distinguere i nodi a timer da quelli a callback va detta una cosa che
riguarda tutti e due i tipi. Senza accelerazione grafica Gazebo non tiene il
passo del tempo reale: misurato con metriche.py su questa macchina, il
fattore e 0.23, cioe un secondo simulato richiede oltre quattro secondi di
orologio. Il fattore non e una costante del progetto: dipende dal carico della
macchina, ed e per questo che va riportato in ogni prova.
Finche i nodi misuravano gli intervalli sull'orologio di parete, quel rapporto
si infilava in ogni grandezza calcolata a partire da un dt, con conseguenze
che sono state prese per difetti del controllo:
| Grandezza | Effetto del dt di parete |
|---|---|
| Velocita del bersaglio | target_mover_node integrava il moto sul dt di parete, quindi il bersaglio si spostava di ~4.3 volte la velocita nominale. Le prove fatte contro "un bersaglio a 1.2 m/s" avevano di fronte un bersaglio a circa 5 m/s simulati. |
| Velocita stimata dal Kalman | Il filtro divideva lo spostamento per un dt ~4.3 volte troppo grande, sottostimando della stessa quantita la velocita del bersaglio. |
| Soglie in secondi | soglia_avvia_ricerca_s, ritardo_evasione_s e le altre valevano circa un quarto del dichiarato in tempo simulato, con un fattore che cambiava fra una prova e la successiva. |
Da qui la scelta di use_sim_time come primo intervento in assoluto: non e una
raffinatezza formale, e la condizione perche i parametri in secondi e in metri
al secondo significhino quello che dicono. Le prove precedenti a questa
correzione restano valide come osservazioni, ma i valori di velocita del
bersaglio che riportano vanno letti moltiplicati per il fattore di allora, che
non e stato registrato — un'altra ragione per cui metrics_node lo scrive
adesso in ogni riga.
Guidati da timer — battono a frequenza fissa, decisa da loro soli:
mission_node a 2 Hz (create_timer(0.5, …)), jammer_node, la
ripubblicazione di controller_node e target_mover_node a 10 Hz.
Guidati da callback — non hanno frequenza propria: ereditano quella di chi sta a monte. E a monte dell'intera catena di percezione c'è la telecamera di Gazebo, l'unico elemento la cui velocità dipende dal carico della macchina anziché da una costante.
Un nodo a callback può solo perdere messaggi, mai crearne, quindi il ritmo cala scendendo la catena. Misure simultanee su 20 s di missione:
| Topic | Frequenza | Note |
|---|---|---|
/drone/camera/image_raw |
15.5 Hz | sorgente, limitata dal rendering |
/target/position |
15.5 Hz | detector, 1:1 coi frame |
/target/jammed_position |
13.5 Hz | limitatore del jammer a 50 ms |
/target/tracked_position |
13.5 Hz | nessuna perdita |
/mission/stato |
2.0 Hz | orologio indipendente |
I 2 Hz di /mission/stato non hanno alcun rapporto con i 15 Hz del detector: non
sono lo stesso orologio. Confonderli è stata l'origine di diversi bug, ora
corretti — vedi sotto.
Tempi in secondi, non in conteggi di messaggi — tutti i ritardi e i dt sono
ora misurati sull'orologio, non contando messaggi ricevuti. In precedenza erano
costanti tarate su un ipotetico 10 Hz che quasi nessun topic rispetta, con effetti
concreti:
| Costante | Comportamento reale prima | Ora |
|---|---|---|
| Ritardo di evasione | 50 conteggi su un topic a 2 Hz → 25 s invece di 5 | ritardo_evasione_s = 10.0 |
dt del Kalman |
fisso a 0.1 con ingresso fra 5 e 15 Hz | ricavato dai tempi reali |
| Derivata del controller | divisione per 0.1 fisso | divisione per il dt misurato |
| Soglia di avvio ricerca | 20 frame → fra 1.5 e 4 s secondo il carico | soglia_avvia_ricerca_s |
| Espansione della spirale | 0.002 per chiamata a 2 Hz = 4 mm/s | ricerca_vel_espansione = 3.0 m/s |
La spirale di ricerca non si allargava — è il caso più estremo dello stesso
errore. ricerca_espansione += 0.002 a ogni chiamata, su un timer a 2 Hz, dà
4 millimetri al secondo: per passare da 3 a 25 m di raggio servivano
92 minuti. In pratica non era una spirale ma un cerchio fisso di raggio 3 m,
mentre il bersaglio in fuga si allontanava a 1.2 m/s. Il drone entrava in
RICERCA e non ritrovava più nulla.
Corretta l'espansione, la spirale ha poi dovuto seguire la scala dello
scenario. Ai valori attuali — espansione 3.0 m/s, velocità angolare
0.25 rad/s — un giro dura ~25 s e allarga il raggio di ~75 m, meno dei 100 m
di lato dell'impronta a 50 m di quota, quindi non restano porzioni di terreno
non guardate. Da ricerca_raggio = 30 m a ricerca_raggio_max = 300 m in 90
secondi, oltre i quali la ricerca è dichiarata fallita e la missione torna a
PATTUGLIAMENTO invece di allargarsi indefinitamente allontanandosi dall'area
di interesse.
Effetto misurato su 100 s di missione:
| Prima | Dopo | |
|---|---|---|
Tempo in AGGANCIO |
68% | 99.5% |
| Distanza mediana | 12.5 m | 9.6 m |
| Riagganci dopo perdita | mai | 16.5 s e 0.9 s nei due casi osservati |
Il bersaglio girava a un terzo della velocità prevista — target_mover_node
comandava la posa lanciando il comando esterno gz service e attendendone la
fine. Una chiamata costa ~360 ms (misurato), quindi il timer dichiarato a 10 Hz
girava in realtà a ~2.8 Hz, e i parametri di moto erano di fatto tarati contro
quel timer strozzato.
Ora il nodo usa i binding Python di gz-transport (python3-gz-transport13),
che riusano un nodo di trasporto persistente: 0.4 ms per richiesta, contro i
360 ms del CLI. Le velocità sono inoltre espresse in unità al secondo e integrate
sul dt reale, quindi il moto non dipende più dalla frequenza del timer. Se i
binding non sono installati il nodo ricade sul comando esterno, avvisando che
l'aggiornamento della posa scenderà a ~3 Hz.
Il bersaglio scivolava via dalla traiettoria — il modello nel mondo era
dinamico, quindi fra un comando di posa e il successivo la fisica se ne
impossessava: una sfera senza attrito di rotolamento accumulava velocità e
rotolava lentamente fuori dal percorso previsto, tanto da non farsi mai trovare
dal drone al primo passaggio. Ora è dichiarato <static>true</static>: il moto è
interamente comandato da target_mover_node e la fisica non lo tocca. La quota
del modello vale metà della sua altezza — 0.8 m per il parallelepipedo attuale,
0.3 m per la sfera di allora — così poggia a terra invece di restare sospeso.
MAVROS riempiva i log di errori sul sensore di distanza — ArduPilot invia
messaggi DISTANCE_SENSOR dal rangefinder simulato, e il plugin distance_sensor
di MAVROS li rifiuta più volte al secondo perché non ha una mappatura configurata:
[ERROR] [mavros.distance_sensor]: DS: no mapping for sensor id: 0, type: 4, orientation: 25
Il progetto non usa il rangefinder, quindi il plugin viene escluso all'avvio con
-p plugin_denylist:=[distance_sensor]. Resta una sola riga informativa,
Plugin distance_sensor ignored. Si è preferito questo a disabilitare il
rangefinder lato ArduPilot, che avrebbe alterato il comportamento di volo.
Il tracker perdeva un messaggio a ogni riacquisizione — sul frame di acquisizione il nodo usciva senza pubblicare. Sotto jamming, dove le riacquisizioni sono continue, questo costava il 19% dei messaggi (9.2 Hz in uscita contro 11.4 in ingresso). Ora la posizione appena acquisita viene pubblicata subito, e la catena non perde più nulla.
Il tracker marcava le proprie predizioni come "bersaglio assente" — durante
una perdita di segnale pubblicava la stima di Kalman ricopiando z dal messaggio
in ingresso, che vale 0, cioè il codice convenzionale di assenza. I nodi a valle
non ne soffrivano perché decidono su x/y, ma chiunque seguisse la convenzione
documentata avrebbe scartato stime valide. Ora z porta l'ultima area valida.
Due copie degli asset Gazebo — il container costruisce l'immagine a partire da
sim/, mentre l'avvio manuale (T1) carica i file da
ardupilot_gazebo/worlds e ardupilot_gazebo/models. Sono percorsi distinti con
gli stessi nomi: git pull aggiorna solo i primi, mentre Gazebo legge i secondi.
Il file corretto finisce sul disco e resta inutilizzato, e le correzioni sembrano
non avere effetto.
La soluzione definitiva è un symlink, da fare una volta sola. Non richiede di cambiare il comando di avvio né di ricopiare nulla a ogni aggiornamento:
cd "$HOME/Desktop/Progetto Drone/ardupilot_gazebo/worlds"
mv iris_runway.sdf iris_runway.sdf.bak
ln -s "$HOME/Desktop/Progetto Drone/drone_tracking_ws/sim/worlds/iris_runway.sdf" .cd "$HOME/Desktop/Progetto Drone/ardupilot_gazebo/models/iris_with_ardupilot"
mv model.sdf model.sdf.bak
ln -s "$HOME/Desktop/Progetto Drone/drone_tracking_ws/sim/models/iris_with_ardupilot/model.sdf" .Da quel momento le due copie sono lo stesso file e git pull aggiorna davvero la
simulazione. Per controllare che la copia usata da Gazebo sia allineata:
grep -c "<static>true</static>" "$HOME/Desktop/Progetto Drone/ardupilot_gazebo/worlds/iris_runway.sdf"grep "update_rate" "$HOME/Desktop/Progetto Drone/ardupilot_gazebo/models/iris_with_ardupilot/model.sdf"Il primo deve restituire 1 (bersaglio statico: se non lo è, la fisica lo
sposta), il
secondo <update_rate>30</update_rate>. Se i valori non corrispondono, la
simulazione sta girando su asset vecchi e le correzioni del repository non hanno
effetto.
L'immagine sobbalza, ma il drone è fermo — con il rendering software i frame non arrivano a cadenza regolare, e a schermo l'effetto è un video che "salta". Non è un'oscillazione del velivolo: la verità di Gazebo, campionata a 30 Hz dal topic delle pose, dà in hover roll std 0.26° ed escursione ±0.4°, con pitch praticamente nullo. Il drone è stabile.
Attenzione a non farsi ingannare da /mavros/imu/data: pubblica a ~1.6 Hz e
campionarlo suggerisce oscillazioni di 0.4 rad/s che non esistono — è aliasing.
Per giudicare l'assetto va usato il topic delle pose di Gazebo, non l'IMU via
MAVLink.
Il rimedio misurato è abbassare <update_rate> della telecamera da 30 a 15:
chiedere una frequenza irraggiungibile fa mancare al renderer ogni scadenza, e i
frame vengono consegnati quando capita. Misure su 20 s:
update_rate 30 |
update_rate 15 |
|
|---|---|---|
| Frequenza effettiva | 11.4 Hz | 10.9 Hz |
| Deviazione standard | 31.2 ms | 14.2 ms |
| Intervallo peggiore | 397.6 ms | 179.4 ms |
| Jitter relativo | 36.4% | 17.1% |
Il jitter si dimezza senza perdere frequenza. Il residuo dipende dal rasterizzatore software: sparisce con accelerazione grafica.
Nel repository il valore è comunque 30, con la misura riportata nel commento
del modello: la scelta è deliberata, perché su una macchina con GPU i 30 Hz sono
raggiungibili e il jitter non si presenta, mentre abbassare il valore
penalizzerebbe anche quel caso. Chi gira in headless senza accelerazione e vuole
un'immagine più regolare può portarlo a 15 in
sim/models/iris_with_ardupilot/model.sdf.
Frame rate della telecamera nel container — il sensore dichiara
<update_rate>30</update_rate>, ma in headless senza GPU Gazebo renderizza via
rasterizzatore software e il topic /drone/camera/image_raw resta molto sotto:
misurato fra 5 e 11 Hz su WSL2, a seconda del carico della macchina. Il
tracking funziona comunque: il filtro di Kalman ricava il proprio dt
dall'intervallo reale fra due misure, e la matrice del rumore di processo viene
ricostruita su quel dt, quindi una frequenza bassa allarga l'incertezza invece
di falsare la predizione. Resta il fatto che meno fotogrammi al secondo
significano meno informazione: per confrontare due prove conviene verificare che
girassero allo stesso ritmo, colonna det_hz del CSV di metrics_node oppure
ros2 topic hz /drone/camera/image_rawVersione del firmware — ArduPilot non pubblica più un branch per ogni
release: i branch si fermano a Copter-4.5, le versioni successive esistono solo
come tag. Un binario compilato da master si dichiara 4.8.0-dev pur non
corrispondendo ad alcuna release: è da lì che veniva il "v4.8.0" indicato in una
versione precedente di questo documento. Il container usa il tag stabile
Copter-4.7.0, modificabile con l'argomento di build ARDUPILOT_REF.
MAVROS aborta con Promise already satisfied — è un difetto di MAVROS 2, non
del progetto, ma si può evitare togliendone la causa scatenante. La sequenza nei
log è sempre questa:
CON: Lost connection, HEARTBEAT timed out.
VER: autopilot version service timeout
VER: command plugin service call failed!
failed to send response to /mavros/cmd/command (timeout)
terminate called after throwing an instance of 'std::future_error'
what(): std::future_error: Promise already satisfied
Alla perdita di heartbeat la richiesta AUTOPILOT_VERSION va in timeout e MAVROS
chiude la promise con errore; quando la risposta arriva in ritardo prova a
chiuderla di nuovo, l'eccezione non è gestita e il processo aborta.
Il fattore scatenante è la perdita di heartbeat, favorita dal percorso
SITL → MAVProxy → UDP → MAVROS quando la macchina è carica per il rendering.
Non collegare MAVROS direttamente al TCP del SITL per evitarlo. Sembra la
soluzione ovvia — il SITL espone SERIAL1 sulla 5762 e SERIAL2 sulla 5763, e
il TCP non perde pacchetti — ma non funziona: ArduPilot regola gli stream
MAVLink per singola porta seriale, e su SERIAL1 quelli di posizione non sono
attivi. Il risultato misurato è insidioso perché parziale:
| Topic | Via MAVProxy (UDP) | Via TCP su SERIAL1 |
|---|---|---|
/mavros/state |
ok | ok, 0.86 Hz |
/mavros/local_position/pose |
ok | nessun dato |
/mavros/global_position/rel_alt |
ok | nessun dato |
MAVROS risulta connesso e l'heartbeat arriva, quindi tutto sembra a posto, ma
mission_node non riceve mai la posizione: distanza_waypoint restituisce
inf, il waypoint non è mai raggiunto e il drone resta fermo sul punto di
decollo. Il sintomo a log è inconfondibile:
[mission_node]: Waypoint 0/4 → (0,0,12)m dist:infm
Per usare davvero il TCP diretto occorrerebbe abilitare gli stream su quella
seriale (famiglia di parametri SR1_*, rinominata nelle versioni recenti), cosa
non verificata qui. Finché non lo è, si passa da MAVProxy.
Il crash di MAVROS resta quindi possibile sotto carico: se capita, basta rilanciare MAVROS, gli altri nodi si riconnettono da soli senza toccare Gazebo o il SITL.
Controlli di arming — il SITL con backend JSON fallisce spesso i controlli
sui sensori simulati, tipicamente con Arm: Gyros inconsistent o
Accels inconsistent: gli IMU simulati divergono quando la fisica singhiozza.
Il parametro che li disattiva è ARMING_SKIPCHK, non ARMING_CHECK.
Quest'ultimo non esiste in questa versione di ArduPilot e viene ignorato in
silenzio: si crede di aver disabilitato i controlli e invece sono tutti
attivi. ARMING_SKIPCHK ha inoltre logica inversa — 1 significa "salta
tutto", non "controlla tutto".
Oggi vale 0: i controlli sono tutti attivi. Ha volato a lungo con 1, che
però spegneva una ventina di controlli per aggirarne uno, nascondendo con quello
qualunque altro difetto del velivolo al momento di armare — e su hardware la
scorciatoia non esiste. La prova del 12/09, con la EEPROM cancellata perché i
default fossero autoritativi: il primo tentativo rifiutato con
Arm: Gyros inconsistent, il secondo accettato, decollo regolare a 43 m. Il
ciclo di ritentativi di takeoff.sh, nato per i rifiuti transitori, copre il
caso senza altre modifiche.
Plugin TrajectoryFollower inerte — il modello bersaglio dichiara un
gz-sim-trajectory-follower-system con cinque waypoint, ma quel percorso non ha
mai effetto e il bersaglio è mosso interamente da target_mover_node. Due motivi,
entrambi verificati sul sorgente di gz-sim8:
- Schema dei waypoint sbagliato. Il plugin legge ogni
<waypoint>comemath::Vector2ddal valore dell'elemento, cioè si aspetta<waypoint>20 20</waypoint>. Il world usa la forma<waypoint><time>…</time><pose>…</pose></waypoint>, che è lo schema delle traiettorie degli actor: il valore dell'elemento è vuoto eGet<Vector2d>()restituisce il default, quindi tutti i waypoint collassano su(0, 0). - Meccanismo di attuazione diverso. Il plugin muove il link con
AddWorldWrench(forza e coppia), mentretarget_mover_nodechiamagz service set_pose, che riscrive la posa del modello 10 volte al secondo. Il teletrasporto azzera qualunque effetto della spinta a ogni tick.
Il risultato è corretto, ma per caso: il blocco <plugin> è di fatto codice morto
che applica una spinta parassita verso l'origine. Conviene rimuoverlo dal modello
bersaglio in sim/worlds/iris_runway.sdf, così il controllo resta in un posto
solo.
Errori gz service silenziosi — target_mover_node invoca subprocess.run(cmd, capture_output=True) senza controllare returncode: se il servizio set_pose
fallisce (nome del mondo o del modello cambiato) il bersaglio resta fermo senza
alcun messaggio a log.
Ordine di avvio — il SITL deve trovare Gazebo già in ascolto sulle porte
JSON, e MAVROS deve trovare MAVProxy già attivo. start_all.sh gestisce i
ritardi automaticamente; a mano vanno rispettati i tempi tra un terminale e
l'altro.
Il progetto simula scenari ispirati al conflitto russo-ucraino, dove:
- I jammer GPS russi (Krasukha, Murmansk-BN) rendono inaffidabile la navigazione GNSS su vaste aree
- I droni FPV ucraini usano visione artificiale per mantenere l'aggancio anche in zone di jamming attivo
- La guerra elettronica cognitiva sfrutta l'IA per riconoscere e falsificare firme elettromagnetiche
Il filtro di Kalman implementato replica il comportamento di un sistema INS semplificato in coordinate immagine: predice la posizione del bersaglio durante la perdita di segnale, aumenta l'incertezza di misura quando rileva rumore RF, e corregge la stima quando il datalink viene ripristinato.
- ArduPilot SITL con Gazebo: https://ardupilot.org/dev/docs/sitl-with-gazebo.html
- ardupilot_gazebo: https://github.com/ArduPilot/ardupilot_gazebo
- MAVROS2: https://github.com/mavlink/mavros
- ROS 2 Jazzy: https://docs.ros.org/en/jazzy
- Kalman Filter: Welch & Bishop, An Introduction to the Kalman Filter, UNC Chapel Hill (2006)
- CSIS, Quantum Sensing and the Future of Warfare (2025)
Distribuito con licenza MIT. Vedi LICENSE.
Linee guida in CONTRIBUTING.md, regole di convivenza in CODE_OF_CONDUCT.md. Per le vulnerabilità di sicurezza, e per le precauzioni da adottare prima di portare l'architettura su hardware reale, vedi SECURITY.md.