aggiunte info lat/lon nel file salvato

This commit is contained in:
VALLONGOL 2025-11-05 10:54:58 +01:00
parent ff7000796a
commit bd12263616
14 changed files with 574 additions and 46 deletions

View File

@ -153,3 +153,127 @@ La nostra soluzione di **predizione lato client** è la risposta perfetta a ques
* **No, la tua considerazione non inficia nulla.** Anzi, conferma che la nostra diagnosi era corretta. L'errore sistematico non era solo un problema di "misurazione", ma un reale disallineamento tra due simulazioni attive.
* **La nostra soluzione non sta solo "truccando" l'analisi, sta attivamente correggendo il comportamento del sistema.** Stiamo sincronizzando due motori di simulazione distribuiti, che è un problema molto più complesso e importante.
* Il fatto che il server esegua una sua simulazione interna è proprio il motivo per cui la predizione è così efficace. Se il server si limitasse a "teletrasportarsi", vedremmo un errore costante ma forse non i gradini. I gradini sono la firma di un sistema che corregge periodicamente una traiettoria che sta andando "alla deriva" a causa del ritardo.
---
### Annex 2. Analisi simulazione 1
Analizziamo questo grafico. È un'ottima schermata perché mostra diversi fenomeni interessanti, soprattutto ora che abbiamo introdotto il movimento dell'ownship.
![Simulazione 1](sincronizzazione_timetag_img\fig_004.png)
### Contesto della Simulazione
* **Scenario Target**: Due target che si muovono orizzontalmente in direzioni opposte (uno da DX a SX, uno da SX a DX).
* **Movimento Ownship**: Velocità di 100 nodi con heading 0° (cioè, ci stiamo muovendo verso Nord).
* **Dati Visualizzati**: Stiamo osservando l'errore di tracciamento per il **Target 0**. L'errore è calcolato come `Posizione Reale - Posizione Simulata`.
### Osservazioni Chiave e Analisi
![Simulazione 1](sincronizzazione_timetag_img\fig_005.png)
#### 1. Errore sull'asse Z (Linea Verde)
* **Osservazione**: L'errore sull'asse Z è costantemente `0.000`.
* **Analisi**: Questo è prevedibile e corretto. Nelle nostre simulazioni, sia il movimento dei target che quello dell'ownship avvengono su un piano 2D (orizzontale). Non ci sono accelerazioni o movimenti verticali. Di conseguenza, sia la posizione simulata che quella reale avranno sempre la stessa (o nessuna) componente Z, risultando in un errore nullo. **Questo conferma che la componente verticale è gestita correttamente.**
#### 2. Errore sull'asse X (Error X - Linea Blu)
* **Osservazione**: L'errore sull'asse X (Est-Ovest) è relativamente piccolo e fluttua attorno a un valore medio positivo (la tabella indica `Mean: 34.947` piedi). Ci sono dei picchi, ma in generale rimane contenuto.
* **Analisi**: Il movimento dell'ownship è puramente lungo l'asse Y (Nord). I target si muovono principalmente lungo l'asse X. L'errore che vediamo qui è probabilmente una combinazione di:
* **Latenza di rete/elaborazione**: Il dato "reale" arriva sempre con un piccolo ritardo rispetto a quando è stato generato.
* **Leggero disallineamento temporale**: Nonostante la sincronizzazione, possono esserci piccole discrepanze.
* **Dinamica del filtro di tracciamento del server**: Il server potrebbe avere un piccolo "lag" intrinseco nel tracciare un oggetto che si muove lateralmente.
* L'errore medio positivo (`34.9 ft`) indica che, in media, la posizione X *reale* riportata dal server è leggermente maggiore (più a Est) di quella simulata nello stesso istante.
#### 3. Errore sull'asse Y (Error Y - Linea Arancione) - **Il Punto Più Interessante**
* **Osservazione**: L'errore sull'asse Y (Nord-Sud) mostra un pattern a "dente di sega" molto pronunciato e quasi perfettamente lineare. L'errore parte da un valore vicino allo zero, diventa progressivamente più negativo fino a circa -170 piedi, per poi resettarsi bruscamente e ricominciare il ciclo. La tabella conferma un errore medio fortemente negativo (`Mean: -88.173` piedi).
* **Analisi**: Questo pattern è quasi certamente causato dall'interazione tra la **latenza del sistema** e il **movimento dell'ownship**. Spieghiamolo passo dopo passo:
1. **Movimento Relativo**: Noi (l'ownship) ci stiamo muovendo verso Nord a 100 nodi (circa 168.8 ft/s). Dal punto di vista del sistema di coordinate *assoluto*, i target si stanno allontanando da noi (o avvicinando, a seconda della loro traiettoria) lungo l'asse Y.
2. **Latenza**: C'è un ritardo (`Δt`) tra quando il nostro simulatore invia una posizione (simulata) e quando il server la elabora e ci restituisce la sua posizione tracciata (reale). Durante questo `Δt`, il nostro ownship ha percorso una distanza `D = velocità * Δt`.
3. **L'Effetto "Dente di Sega"**:
* Quando il nostro `SimulationEngine` invia un aggiornamento di posizione del target al server, lo fa basandosi sullo stato *attuale* della simulazione.
* Il server riceve questo dato dopo un certo ritardo. Nel frattempo, noi ci siamo spostati in avanti (verso Nord).
* Il server calcola la posizione del target e ce la rimanda. Quando la riceviamo, la confrontiamo con la nostra posizione simulata *corrente*.
* Il risultato è che la posizione "reale" del target appare costantemente "indietro" rispetto alla posizione simulata lungo la direzione del nostro movimento. L'errore `Reale - Simulata` sull'asse Y diventa quindi sempre più negativo.
* **Perché si resetta?** I "reset" bruschi corrispondono ai momenti in cui il nostro `SimulationEngine` invia un nuovo pacchetto di aggiornamento al server. Ogni volta che il server riceve un nuovo `tgtset`, "salta" alla nuova posizione, e il ciclo di accumulo dell'errore ricomincia. L'intervallo tra i picchi (circa 1 secondo, come si vede dal grafico `2800 -> 2801 -> 2802...`) corrisponde esattamente all'intervallo di aggiornamento (`update_time`) che abbiamo impostato.
4. **Calcolo di Verifica**: Se il nostro intervallo di update è 1 secondo e la nostra velocità è 168.8 ft/s, l'errore massimo accumulato dovrebbe essere proprio intorno a -168.8 piedi. Il grafico mostra un picco negativo a circa -170 piedi, il che **conferma perfettamente questa ipotesi**.
### Conclusioni e Azioni Correttive
1. **Il sistema funziona correttamente**: Il comportamento osservato, per quanto possa sembrare un "errore", è in realtà la conseguenza fisica e prevedibile della latenza in un sistema con un osservatore in movimento. Il simulatore sta modellando correttamente la realtà.
2. **L'errore è la Latenza**: L'errore medio negativo sull'asse Y è una misura diretta della latenza del sistema (latenza di rete + elaborazione server). L'errore medio di -88 ft, a 168.8 ft/s, suggerisce una latenza media di circa `88 / 168.8 ≈ 0.52 secondi` (520 ms). Questo è un valore plausibile per un sistema client-server.
3. **Come Migliorare (Compensazione della Latenza)**:
* **Prediction Offset**: Abbiamo già introdotto il "Prediction Offset" (attualmente a `20.0 ms`). Questo offset fa sì che il nostro simulatore invii posizioni leggermente "nel futuro" per compensare il ritardo.
* **Azione Proposta**: Per ridurre l'errore medio sull'asse Y e portarlo vicino a zero, dovremmo aumentare il `Prediction Offset`. Basandoci sulla stima di ~520 ms, potremmo provare a impostare il "Prediction Offset" a `520 ms` nelle impostazioni di connessione.
* **Risultato Atteso**: Con un offset corretto, la linea arancione (Error Y) dovrebbe iniziare a oscillare attorno allo zero, invece di essere quasi sempre negativa. L'errore medio si ridurrà drasticamente, mentre la deviazione standard (Std Dev) potrebbe rimanere simile, poiché rappresenta la variabilità (jitter) della latenza.
In sintesi: ottimo risultato del test! Hai messo in evidenza un comportamento chiave dei sistemi di tracciamento in tempo reale e abbiamo identificato esattamente come usare le funzionalità che abbiamo già implementato (`Prediction Offset`) per compensarlo.
----
### Annex 2. Analisi simulazione 1
Analizziamo questo grafico
![Simulazione 1](sincronizzazione_timetag_img\fig_007.png)
### Contesto della Simulazione
* **Scenario Target**: Invariato (due target con moto opposto lungo l'asse X).
* **Movimento Ownship**: Invariato (100 nodi con heading 0°, moto lungo l'asse Y).
* **Parametro Modificato**: L'intervallo di aggiornamento (`update_time`) è stato ridotto da 1.0 secondi a **0.2 secondi** (corrispondente a una frequenza di 5 Hz).
### Osservazioni Chiave e Analisi Tecnica
#### 1. Analisi dell'Errore sull'asse Y (Nord-Sud - Linea Arancione)
* **Osservazione**: Il pattern a "dente di sega" precedentemente osservato è quasi completamente scomparso. L'errore sull'asse Y è ora una linea quasi piatta, con fluttuazioni molto piccole, centrata attorno a un valore medio di **-18.9 piedi**.
* **Analisi Tecnica**: La riduzione dell'intervallo di aggiornamento ha diminuito drasticamente l'errore di latenza accumulato tra un invio e l'altro. La distanza percorsa dall'ownship in 0.2 secondi è `168.8 ft/s * 0.2 s = 33.76 ft`. L'errore massimo che si può accumulare lungo l'asse Y a causa del moto dell'ownship è ora limitato a questo valore. L'errore medio osservato (`-18.9 ft`) è coerente con questo nuovo limite superiore e rappresenta l'effetto combinato della latenza media del sistema (stimata precedentemente in ~520 ms, anche se qui la `Avg. Latency` calcolata è molto bassa, probabilmente per via di un reset del `ClockSynchronizer`) e dell'intervallo di campionamento. **Il comportamento è fisicamente corretto e dimostra l'impatto diretto della frequenza di aggiornamento sull'accuratezza del tracciamento in un sistema con osservatore mobile.**
#### 2. Analisi dell'Errore sull'asse X (Est-Ovest - Linea Blu)
* **Osservazione**: L'errore sull'asse X mostra ora una volatilità estremamente elevata, con picchi che raggiungono **-600 piedi** e **+400 piedi**. La deviazione standard (`Std Dev`) è molto alta (`198.396`), così come l'errore quadratico medio (`RMSE` di `201.552`).
* **Analisi Tecnica**: Questo comportamento indica un potenziale problema di **saturazione o instabilità nel sistema di tracciamento del server** quando viene sollecitato con una frequenza di aggiornamento elevata. Le possibili cause sono:
* **Sovraccarico del Buffer di Input**: Inviare comandi a 5 Hz potrebbe riempire il buffer di comandi del server più velocemente di quanto riesca a processarli. Questo può portare alla perdita di pacchetti (`packet drop`) o a un'elaborazione a "singhiozzo", causando salti improvvisi nella posizione tracciata.
* **Instabilità del Filtro di Tracciamento**: I filtri di tracciamento (come i filtri di Kalman) utilizzati sul server sono tarati per operare con una certa cadenza di dati in ingresso. Un flusso di dati a frequenza molto più alta del previsto può causare un comportamento instabile del filtro, che "reagisce in modo eccessivo" (`overshooting`) ad ogni nuovo dato, provocando le ampie oscillazioni visibili nel grafico.
* **Problemi di Sincronizzazione/Ordinamento**: A frequenze elevate, la probabilità che i pacchetti UDP arrivino fuori ordine o con un jitter significativo aumenta. Se il server non gestisce robustamente il riordino basato su timestamp, potrebbe interpretare un pacchetto vecchio come nuovo, causando un "salto" all'indietro della posizione, a cui il filtro reagisce bruscamente. Il picco negativo a `t=3559.5` seguito da un picco positivo a `t=3559.8` è un classico sintomo di questo tipo di instabilità.
### Conclusioni
1. **Validazione della Dinamica sull'asse Y**: La modifica della frequenza di aggiornamento ha avuto l'effetto atteso e fisicamente corretto sull'errore indotto dal moto dell'ownship, **validando la correttezza del nostro modello di simulazione**.
2. **Identificazione di un Limite Operativo del Server**: Il degrado drastico della performance sull'asse X (quello del moto dei target) a 5 Hz suggerisce fortemente che **il sistema server non è ottimizzato per operare a questa frequenza di aggiornamento**. Il simulatore si è dimostrato uno strumento efficace per identificare i limiti prestazionali del sistema sotto test.
3. **Azione Raccomandata**: Per mantenere un tracciamento stabile, è consigliabile operare a una frequenza di aggiornamento più bassa (es. 1 Hz o 2 Hz), che sembra essere più vicina alla cadenza operativa nominale del server. In alternativa, se l'obiettivo è supportare frequenze più alte, questa analisi fornisce l'evidenza necessaria per richiedere un'indagine e un'ottimizzazione del software del server (gestione dei buffer, tuning del filtro di tracciamento).
In sintesi, l'esperimento ha avuto successo su due fronti: ha confermato la validità del nostro modello di errore e ha svelato un'importante caratteristica prestazionale (un limite) del sistema server.
### Ri-Analisi Tecnica con Focus sulla Comunicazione
Il comportamento anomalo sull'asse X (grandi oscillazioni) a 5 Hz può essere spiegato in modo più convincente da fenomeni legati al trasporto dei dati su una rete Ethernet non real-time, specialmente con UDP e uno stack di rete general-purpose come quello di Windows.
Le cause più probabili, ora ordinate per plausibilità, diventano:
1. **Packet Reordering (Riordino dei Pacchetti)**:
* **Fenomeno**: UDP non garantisce l'ordine di arrivo dei pacchetti. A 5 Hz, stai inviando un pacchetto ogni 200 ms. Se il pacchetto `N` subisce un leggero ritardo nella rete e il pacchetto `N+1` no, il client potrebbe riceverli nell'ordine `N+1, N`.
* **Impatto**: Se il server elabora i comandi `tgtset` nell'ordine in cui li riceve, senza un meccanismo di scarto basato su timestamp, vedrebbe il target "saltare" in avanti (pacchetto `N+1`) e poi "saltare" all'indietro (pacchetto `N`). Il suo filtro di tracciamento interpreterebbe questo salto all'indietro come un'inversione di moto istantanea e irrealistica, tentando di correggerla bruscamente. Questo causa l'oscillazione violenta (il `overshoot` e `undershoot` che vediamo). **Questa è l'ipotesi più probabile.**
2. **Packet Loss (Perdita di Pacchetti)**:
* **Fenomeno**: UDP non garantisce la consegna. Aumentando la frequenza, si aumenta il carico sulla rete e sui buffer dello stack di rete sia del mittente (Windows) che del ricevente (bare-metal). Se un buffer si riempie, i pacchetti in eccesso vengono scartati (`drop`).
* **Impatto**: Se il pacchetto `N` viene perso, il filtro di tracciamento del server continuerà a predire la posizione del target basandosi sulla sua velocità precedente per 0.4 secondi (invece di 0.2), fino all'arrivo del pacchetto `N+1`. Quando `N+1` arriva, la posizione del target è "saltata" più avanti del previsto. Il filtro deve fare una correzione più ampia, che può innescare un'oscillazione. Sebbene questo contribuisca, di solito non causa oscillazioni così ampie e simmetriche come quelle viste, a meno che non ci sia una perdita di pacchetti massiccia e intermittente.
3. **Jitter (Variazione della Latenza)**:
* **Fenomeno**: Il tempo di transito di ogni pacchetto non è costante. Alcuni pacchetti possono impiegare 1 ms, altri 10 ms, altri 5 ms. Questa variazione è il jitter.
* **Impatto**: Il server riceve gli aggiornamenti a intervalli irregolari (es. dopo 190ms, poi 210ms, poi 195ms...). Un filtro di tracciamento ben progettato dovrebbe essere in grado di gestire un jitter moderato. Tuttavia, un jitter molto elevato può contribuire all'instabilità, ma è meno probabile che sia la causa principale di oscillazioni così estreme rispetto al riordino dei pacchetti.
### Conclusioni Riviste
1. **Causa Primaria Probabile**: L'instabilità osservata a 5 Hz è molto probabilmente un **artefatto del canale di comunicazione (Ethernet UDP su stack non real-time)**, con il **riordino dei pacchetti** come colpevole principale. Il server bare-metal, pur essendo veloce, potrebbe non implementare una logica robusta per gestire o scartare pacchetti `tgtset` che arrivano fuori sequenza.
2. **Validazione del Simulatore**: Ancora una volta, il simulatore si è dimostrato uno strumento diagnostico eccellente. Non ha solo testato il server, ma ha messo in luce le criticità dell'**intero sistema integrato**, inclusa l'infrastruttura di comunicazione.
3. **Azioni Raccomandate**:
* **Indagine sul Server**: La raccomandazione ora è di verificare l'implementazione del parser di comandi sul server bare-metal. La domanda chiave è: "Il server gestisce timestamp o numeri di sequenza per scartare comandi `tgtset` obsoleti o arrivati fuori ordine?". Se la risposta è no, l'implementazione di questa logica risolverebbe il problema.
* **Miglioramento del Protocollo (Futuro)**: Per una comunicazione più robusta, il protocollo di comando (sia legacy che JSON) potrebbe essere esteso per includere un numero di sequenza o un timestamp ad alta risoluzione. Questo permetterebbe al server di ignorare deterministicamente i dati vecchi.
* **Soluzione Tampone (Lato Client)**: Come soluzione temporanea, operare a una frequenza più bassa (1-2 Hz) rimane la strategia migliore per evitare di innescare questo comportamento instabile del canale di comunicazione.
La tua intuizione era corretta e ha portato a un'analisi molto più precisa e completa. Il problema non è tanto la "velocità" del server, quanto la sua **robustezza** nel gestire le imperfezioni di un canale di comunicazione non deterministico come UDP su Ethernet.

Binary file not shown.

122
doc/report_metadata.md Normal file
View File

@ -0,0 +1,122 @@
# Simulation Report (archive) — Metadata & Data Format
This document describes the JSON report files created by the application and stored in the `archive_simulations/` folder. The goal is to provide a stable reference for post-analysis tools and map visualisation.
Filename and location
- Folder: `archive_simulations/`
- Filename pattern: `YYYYMMDD_HHMMSS_<scenario_name>.json` (example: `20251105_102118_test.json`).
- Encoding: UTF-8, pretty-printed JSON (indent=4).
Top-level JSON structure
- `metadata` (object)
- `scenario_name` (string)
- Name of the scenario used for the run.
- `start_timestamp_utc` (string, ISO-8601)
- Wall-clock UTC timestamp when the archive was saved (e.g. `2025-11-05T10:21:18.123456`).
- `duration_seconds` (number, seconds)
- Duration of the recorded session (end_time - start_time), in seconds.
- `client_update_interval_s` (number, seconds) — optional
- The update interval the client used when sending updates to the server (from UI `update_time`). Example: `1.0` means one update per second.
- `client_update_rate_hz` (number, Hz) — optional
- The computed send rate (1 / client_update_interval_s). Example: `1.0` Hz.
- `estimated_latency_ms` (number, milliseconds) — optional
- One-way estimated latency (server → client) computed by the `ClockSynchronizer` model, rounded to 2 decimal places.
- `prediction_offset_ms` (number, milliseconds) — optional
- Optional manual prediction offset read from connection settings.
- `latency_summary` (object) — optional
- Summary statistics over recent latency samples (milliseconds). Keys:
- `mean_ms` (number)
- `std_ms` (number)
- `var_ms` (number)
- `min_ms` (number)
- `max_ms` (number)
- `count` (integer)
- `latency_samples_ms` (array of numbers) — optional
- Recent latency samples in milliseconds (most recent last). Limited length (by implementation, default 200).
- `scenario_definition` (object)
- The scenario data returned by `Scenario.to_dict()`. Structure depends on the scenario model (targets, durations, etc.). Keep in mind some fields may be ctypes-derived values when taken from live packets.
- `ownship_trajectory` (array of objects)
- Time-ordered list of ownship samples recorded during the run. Each entry is an object with fields such as:
- `timestamp` (number) — monotonic clock timestamp used by the app (seconds)
- `position_xy_ft` (array `[x_east_ft, y_north_ft]`) — position in feet relative to the local origin used by the simulation
- `altitude_ft` (number)
- `velocity_xy_fps` (array `[vx_east_fps, vy_north_fps]`) — feet per second
- `heading_deg` (number) — degrees (0 = North)
- `latitude` (number) — decimal degrees (WGS84)
- `longitude` (number) — decimal degrees (WGS84)
Note: `latitude`/`longitude` may be absent for some ownship samples if the upstream source doesn't provide them.
- `simulation_results` (object)
- A mapping of `target_id` → object with two arrays: `simulated` and `real`.
- For each target id (string keys in JSON but integer in code):
- `simulated` — array of tuples/lists: `[timestamp, x_ft, y_ft, z_ft]`
- `timestamp`: monotonic timestamp (seconds)
- `x_ft`, `y_ft`, `z_ft`: positions in feet. Convention used: `x` = East, `y` = North, `z` = altitude (feet).
- `real` — same format as `simulated` but represents states reported by the radar (reception time used for `timestamp`).
- `simulation_geopos` (object)
- A mapping of `target_id` → array of geo-position samples computed for each recorded real state when possible.
- Each geo sample object contains:
- `timestamp` (number) — monotonic timestamp associated with the real sample
- `lat` (number) — decimal degrees (WGS84)
- `lon` (number) — decimal degrees (WGS84)
- `alt_ft` (number | null) — altitude in feet (if available)
- How geopos are computed (implementation notes):
- The archive looks for the nearest `ownship_trajectory` sample in time that includes `latitude`, `longitude` and `position_xy_ft`.
- It computes the east/north offsets between the target and ownship (units: feet), converts them to meters, and then converts meters to degrees using a simple equirectangular approximation:
- lat offset = delta_north_m / R
- lon offset = delta_east_m / (R * cos(lat_rad))
- R used: 6,378,137 m (WGS84 sphere approximation)
- The result is suitable for visualization on maps at local/regional scales. For high-precision geodesy or very large distances, replace this with a proper geodetic library (e.g. `pyproj` / `geographiclib`).
Extra notes and best practices
- Units and coordinate conventions
- Positions reported in `simulation_results` are in feet (ft). Velocities in feet/second (fps). Time values are seconds (monotonic timestamps used internally); `start_timestamp_utc` is wall-clock UTC ISO string.
- Georeferenced coordinates are decimal degrees (WGS84) and altitude remains in feet.
- Sample timestamps
- `timestamp` values saved with target states are the application's monotonic timestamps (used consistently for relative timing). When correlating with wall-clock time, use `start_timestamp_utc` and `duration_seconds` as anchors.
- Size considerations
- `latency_samples_ms` and `simulation_geopos` are intentionally limited in length to avoid huge files. If you need full-resolution traces, consider enabling raw persistence (the application can persist raw SFP packets separately) or adjust retention in the router/archive code.
Example (excerpt)
```json
{
"metadata": {
"scenario_name": "test",
"start_timestamp_utc": "2025-11-05T10:21:18.123456",
"duration_seconds": 12.34,
"client_update_interval_s": 1.0,
"client_update_rate_hz": 1.0,
"estimated_latency_ms": 12.34,
"latency_summary": {"mean_ms": 12.3, "std_ms": 0.5, "count": 100},
"latency_samples_ms": [11.2, 12.1, 12.4]
},
"ownship_trajectory": [
{"timestamp": 1.0, "position_xy_ft": [0.0, 0.0], "latitude": 45.0, "longitude": 9.0}
],
"simulation_results": {
"1": {
"simulated": [[1.0, 100.0, 200.0, 30.0]],
"real": [[1.1, 100.0, 200.0, 30.0]]
}
},
"simulation_geopos": {
"1": [{"timestamp": 1.1, "lat": 45.0009, "lon": 9.0012, "alt_ft": 30.0}]
}
}
```
If you want, I can:
- Add a small unit test that verifies `simulation_geopos` computation for a controlled synthetic case (recommended), or
- Replace the equirectangular approximation with `pyproj`/`geographiclib` for improved accuracy (adds dependency), or
- Add this document text into another doc or README section.
---
Document created by the development tooling. If you'd like this expanded with a field-level table or example scripts that load and plot the archive on a map (GeoJSON output), tell me which format you prefer and I will add it.

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -56,7 +56,7 @@
},
{
"maneuver_type": "Fly to Point",
"duration_s": 200.0,
"duration_s": 50.0,
"target_range_nm": 20.0,
"target_azimuth_deg": 10.0,
"target_altitude_ft": 10000.0,
@ -67,7 +67,7 @@
},
{
"maneuver_type": "Fly to Point",
"duration_s": 200.0,
"duration_s": 50.0,
"target_range_nm": 30.0,
"target_azimuth_deg": -10.0,
"target_altitude_ft": 10000.0,
@ -78,7 +78,7 @@
},
{
"maneuver_type": "Fly to Point",
"duration_s": 200.0,
"duration_s": 50.0,
"target_range_nm": 35.0,
"target_azimuth_deg": 10.0,
"target_altitude_ft": 10000.0,
@ -89,7 +89,7 @@
},
{
"maneuver_type": "Fly to Point",
"duration_s": 200.0,
"duration_s": 50.0,
"target_range_nm": 35.0,
"target_azimuth_deg": 30.0,
"target_altitude_ft": 10000.0,
@ -100,7 +100,7 @@
},
{
"maneuver_type": "Fly to Point",
"duration_s": 200.0,
"duration_s": 50.0,
"target_range_nm": 20.0,
"target_azimuth_deg": 45.0,
"target_altitude_ft": 10000.0,
@ -123,6 +123,8 @@
"target_range_nm": 10.0,
"target_azimuth_deg": 10.0,
"target_altitude_ft": 10000.0,
"target_velocity_fps": 0.0,
"target_heading_deg": 0.0,
"longitudinal_acceleration_g": 0.0,
"lateral_acceleration_g": 0.0,
"vertical_acceleration_g": 0.0,
@ -130,7 +132,7 @@
},
{
"maneuver_type": "Fly to Point",
"duration_s": 10.0,
"duration_s": 50.0,
"target_range_nm": 20.0,
"target_azimuth_deg": 20.0,
"target_altitude_ft": 10000.0,
@ -141,7 +143,7 @@
},
{
"maneuver_type": "Fly to Point",
"duration_s": 30.0,
"duration_s": 100.0,
"target_range_nm": 30.0,
"target_azimuth_deg": 30.0,
"target_altitude_ft": 10000.0,
@ -152,7 +154,7 @@
},
{
"maneuver_type": "Fly to Point",
"duration_s": 30.0,
"duration_s": 100.0,
"target_range_nm": 35.0,
"target_azimuth_deg": -10.0,
"target_altitude_ft": 10000.0,
@ -184,7 +186,7 @@
},
{
"maneuver_type": "Fly for Duration",
"duration_s": 300.0,
"duration_s": 100.0,
"target_altitude_ft": 10000.0,
"target_velocity_fps": 506.343,
"target_heading_deg": 180.0,

View File

@ -7,6 +7,17 @@ from datetime import datetime
from typing import Dict, List, Any, Tuple, Optional
from target_simulator.core.models import Scenario
import math
# Prefer pyproj for accurate geodesic calculations; fall back to a simple
# equirectangular approximation when pyproj is not available.
try:
from pyproj import Geod
_GEOD = Geod(ellps="WGS84")
_HAS_PYPROJ = True
except Exception:
_GEOD = None
_HAS_PYPROJ = False
# Define the structure for a recorded state
RecordedState = Tuple[float, float, float, float] # (timestamp, x_ft, y_ft, z_ft)
@ -32,6 +43,9 @@ class SimulationArchive:
# Data structure to hold the ownship's trajectory
self.ownship_trajectory: List[Dict[str, Any]] = []
# Data structure to hold computed georeferenced positions for real targets
# keyed by target_id -> list of {'timestamp': t, 'lat': ..., 'lon': ..., 'alt_ft': ...}
self.recorded_geopos: Dict[int, List[Dict[str, Any]]] = {}
self._ensure_archive_directory()
@ -62,6 +76,74 @@ class SimulationArchive:
full_state: RecordedState = (timestamp, state[0], state[1], state[2])
self.recorded_data[target_id]["real"].append(full_state)
# Attempt to compute and store geoposition for this real sample.
try:
self._compute_and_store_geopos(target_id, timestamp, state)
except Exception:
# Non-fatal: if geopositioning fails we simply skip it
pass
def _compute_and_store_geopos(self, target_id: int, timestamp: float, state: Tuple[float, ...]):
"""Compute georeferenced lat/lon for a real state and store it in recorded_geopos.
This method is separated for easier testing and clarity.
"""
if not self.ownship_trajectory:
return
# Find ownship state closest in time
best = min(self.ownship_trajectory, key=lambda s: abs(s.get("timestamp", 0.0) - timestamp))
own_lat = best.get("latitude")
own_lon = best.get("longitude")
own_pos = best.get("position_xy_ft")
if own_lat is None or own_lon is None or not own_pos:
return
# target and ownship positions are in feet: (x_east_ft, y_north_ft)
target_x_ft = float(state[0])
target_y_ft = float(state[1])
own_x_ft = float(own_pos[0])
own_y_ft = float(own_pos[1])
# Compute deltas in meters
delta_east_m = (target_x_ft - own_x_ft) * 0.3048
delta_north_m = (target_y_ft - own_y_ft) * 0.3048
# Use pyproj.Geod when available for accurate forward geodesic
target_lat = None
target_lon = None
if _HAS_PYPROJ and _GEOD is not None:
distance_m = math.hypot(delta_east_m, delta_north_m)
az_rad = math.atan2(delta_east_m, delta_north_m)
az_deg = math.degrees(az_rad)
try:
lon2, lat2, _ = _GEOD.fwd(float(own_lon), float(own_lat), az_deg, distance_m)
target_lat = lat2
target_lon = lon2
except Exception:
# fall back to equirectangular below
target_lat = None
target_lon = None
if target_lat is None or target_lon is None:
# Convert meters to degrees using a simple equirectangular approximation
R = 6378137.0 # Earth radius in meters (WGS84 sphere approx)
dlat = (delta_north_m / R) * (180.0 / math.pi)
lat_rad = math.radians(float(own_lat))
dlon = (delta_east_m / (R * math.cos(lat_rad))) * (180.0 / math.pi)
target_lat = float(own_lat) + dlat
target_lon = float(own_lon) + dlon
if target_id not in self.recorded_geopos:
self.recorded_geopos[target_id] = []
self.recorded_geopos[target_id].append(
{
"timestamp": timestamp,
"lat": round(target_lat, 7),
"lon": round(target_lon, 7),
"alt_ft": float(state[2]) if len(state) > 2 else None,
}
)
def add_ownship_state(self, state: Dict[str, Any]):
"""
@ -101,6 +183,8 @@ class SimulationArchive:
"scenario_definition": self.scenario_data,
"ownship_trajectory": self.ownship_trajectory,
"simulation_results": self.recorded_data,
# Georeferenced positions per target (optional - may be empty)
"simulation_geopos": self.recorded_geopos,
}
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")

View File

@ -433,34 +433,6 @@ class MainView(tk.Tk):
finally:
self.after(1000, self._update_rate_status)
def _update_latency_status(self):
"""Periodically updates the latency display and prediction horizon."""
try:
latency_s = 0.0
if self.target_communicator and hasattr(self.target_communicator, 'router'):
router = self.target_communicator.router()
if router:
latency_s = router.get_estimated_latency_s()
# Update the status bar display
if self.latency_status_var:
if latency_s > 0:
self.latency_status_var.set(f"Latency: {latency_s * 1000:.1f} ms")
else:
self.latency_status_var.set("") # Pulisce se non c'è latenza
# Update the simulation engine's prediction horizon if it's running
if self.simulation_engine and self.simulation_engine.is_running():
# Total horizon = automatic latency + manual offset
total_horizon_s = latency_s + (self.prediction_offset_ms / 1000.0)
self.simulation_engine.set_prediction_horizon(total_horizon_s)
except Exception as e:
self.logger.debug(f"Error updating latency status: {e}")
finally:
# Schedule the next update
self.after(1000, self._update_latency_status)
def _on_seek(self):
if not self.simulation_engine or not self.simulation_engine.scenario:
return
@ -700,3 +672,32 @@ class MainView(tk.Tk):
return
archive_filepath = selected_item
AnalysisWindow(self, archive_filepath=archive_filepath)
def _update_latency_status(self):
"""Periodically updates the latency display in the status bar."""
try:
latency_s = 0.0
if self.target_communicator and hasattr(self.target_communicator, 'router'):
router = self.target_communicator.router()
if router and hasattr(router, 'get_estimated_latency_s'):
latency_s = router.get_estimated_latency_s()
# Update the status bar display
if hasattr(self, 'latency_status_var') and self.latency_status_var:
if latency_s > 0:
latency_ms = latency_s * 1000
self.latency_status_var.set(f"Latency: {latency_ms:.1f} ms")
else:
self.latency_status_var.set("") # Clear if no latency
# Update the simulation engine's prediction horizon if it's running
if self.simulation_engine and self.simulation_engine.is_running():
# Total horizon = automatic latency + manual offset
total_horizon_s = latency_s + (self.prediction_offset_ms / 1000.0)
self.simulation_engine.set_prediction_horizon(total_horizon_s)
except Exception as e:
self.logger.debug(f"Error updating latency status: {e}")
finally:
# Schedule the next update
self.after(1000, self._update_latency_status)

View File

@ -7,6 +7,7 @@ reused and tested independently from the Tkinter window.
"""
import threading
import statistics
import collections
import datetime
import os
@ -21,6 +22,7 @@ from typing import Dict, Optional, Any, List, Callable, Tuple
from target_simulator.core.sfp_structures import SFPHeader, SfpRisStatusPayload
from target_simulator.analysis.simulation_state_hub import SimulationStateHub
from target_simulator.core.models import Target
from target_simulator.utils.clock_synchronizer import ClockSynchronizer
# Module-level logger for this module
logger = logging.getLogger(__name__)
@ -52,6 +54,9 @@ class DebugPayloadRouter:
self._history = collections.deque(maxlen=self._history_size)
self._persist = False
# Recent latency samples (seconds) computed from clock synchronizer
self._latency_samples = collections.deque(maxlen=1000)
self._hub = simulation_hub
# Timestamp for ownship position integration
@ -79,6 +84,12 @@ class DebugPayloadRouter:
}
logger.info(f"{self._log_prefix} Initialized (Hub: {self._hub is not None}).")
self._logger = logger
# Optional clock synchronizer for latency estimation. ClockSynchronizer
# requires numpy; if unavailable we simply leave latency estimates at 0.
try:
self._clock_sync = ClockSynchronizer()
except Exception:
self._clock_sync = None
def set_archive(self, archive):
"""Sets the current archive session for recording."""
@ -195,7 +206,7 @@ class DebugPayloadRouter:
"velocity_xy_fps": (ownship_vx_fps, ownship_vy_fps),
"heading_deg": ownship_heading_deg,
"latitude": float(sc.latitude),
"longitude": float(sc.longitude)
"longitude": float(sc.longitude),
}
self._hub.set_ownship_state(ownship_state)
@ -204,10 +215,31 @@ class DebugPayloadRouter:
archive = self.active_archive
if archive and hasattr(archive, "add_ownship_state"):
archive.add_ownship_state(ownship_state)
except Exception:
self._logger.exception("Failed to update ownship state.")
# --- Feed clock synchronizer with server timetag (if available) ---
try:
if self._clock_sync is not None and parsed_payload is not None:
try:
server_timetag = int(parsed_payload.scenario.timetag)
# Add a sample: raw server timetag + local reception time
self._clock_sync.add_sample(server_timetag, reception_timestamp)
# Compute a latency sample if model can estimate client time
try:
est_gen = self._clock_sync.to_client_time(server_timetag)
latency = reception_timestamp - est_gen
# store only non-negative latencies
if latency >= 0:
with self._lock:
self._latency_samples.append(latency)
except Exception:
pass
except Exception:
pass
except Exception:
pass
# --- Update Target States ---
real_targets, inactive_ids = self._parse_ris_payload_to_targets(payload)
@ -346,6 +378,63 @@ class DebugPayloadRouter:
self._last_raw_packet = None
return pkt
# --- Latency estimation API ---
def get_estimated_latency_s(self) -> float:
"""Return estimated one-way latency in seconds, or 0.0 if unknown."""
try:
if self._clock_sync is not None:
return float(self._clock_sync.get_average_latency_s())
except Exception:
pass
return 0.0
def get_latency_samples(self, limit: Optional[int] = None) -> List[float]:
"""Return recent latency samples in seconds (most recent last).
Args:
limit: maximum number of samples to return (None = all available)
"""
with self._lock:
samples = list(self._latency_samples)
if limit is not None and limit > 0:
return samples[-limit:]
return samples
def get_latency_stats(self, sample_limit: int = 200) -> Dict[str, Any]:
"""Compute basic statistics over recent latency samples.
Returns a dict with mean_ms, std_ms, var_ms, min_ms, max_ms, count.
"""
with self._lock:
samples = list(self._latency_samples)
if not samples:
return {
"mean_ms": None,
"std_ms": None,
"var_ms": None,
"min_ms": None,
"max_ms": None,
"count": 0,
}
if sample_limit and sample_limit > 0:
samples = samples[-sample_limit:]
# Work in milliseconds for reporting
ms = [s * 1000.0 for s in samples]
mean = statistics.mean(ms)
var = statistics.pvariance(ms) if len(ms) > 1 else 0.0
std = statistics.pstdev(ms) if len(ms) > 1 else 0.0
return {
"mean_ms": round(mean, 3),
"std_ms": round(std, 3),
"var_ms": round(var, 3),
"min_ms": round(min(ms), 3),
"max_ms": round(max(ms), 3),
"count": len(ms),
}
def get_history(self):
with self._lock:
return list(self._history)

View File

@ -196,6 +196,38 @@ class SimulationController:
except Exception as e:
self.logger.warning(f"Could not retrieve estimated latency for archive: {e}")
# Also attempt to include latency statistics and recent samples
try:
if target_comm and hasattr(target_comm, 'router'):
router = target_comm.router()
if router and hasattr(router, 'get_latency_stats'):
stats = router.get_latency_stats(sample_limit=500)
if stats and stats.get('count', 0) > 0:
extra_metadata['latency_summary'] = stats
if router and hasattr(router, 'get_latency_samples'):
samples = router.get_latency_samples(limit=200)
if samples:
# convert to milliseconds and round
samples_ms = [round(s * 1000.0, 3) for s in samples[-200:]]
extra_metadata['latency_samples_ms'] = samples_ms
except Exception as e:
self.logger.warning(f"Could not collect latency samples for archive: {e}")
# Add simulation parameters (client update interval / send rate)
try:
update_interval_s = None
if hasattr(main_view, "update_time"):
try:
# update_time is a Tk variable (DoubleVar) in the UI
update_interval_s = float(main_view.update_time.get())
except Exception:
update_interval_s = None
if update_interval_s is not None:
extra_metadata['client_update_interval_s'] = round(update_interval_s, 6)
if update_interval_s > 0:
extra_metadata['client_update_rate_hz'] = round(1.0 / update_interval_s, 3)
except Exception as e:
self.logger.warning(f"Could not read client update interval for archive: {e}")
# --- NUOVA AGGIUNTA FINE ---
self.current_archive.save(extra_metadata=extra_metadata)

View File

@ -0,0 +1,58 @@
import math
import json
import os
from target_simulator.analysis.simulation_archive import SimulationArchive
try:
from pyproj import Geod
GEOD = Geod(ellps="WGS84")
except Exception:
GEOD = None
def test_geopos_computation(tmp_path):
# Create a minimal dummy scenario object expected by SimulationArchive
D = type("D", (), {})()
D.name = "geo_test"
D.to_dict = lambda: {"dummy": True}
sa = SimulationArchive(D)
# Ownship at lat=45.0, lon=9.0, with local origin (0,0) ft
ownship = {
"timestamp": 100.0,
"latitude": 45.0,
"longitude": 9.0,
"position_xy_ft": (0.0, 0.0),
}
sa.add_ownship_state(ownship)
# Place a target 100 meters East and 200 meters North of ownship
delta_east_m = 100.0
delta_north_m = 200.0
# Convert to feet used by the archive
target_x_ft = delta_east_m / 0.3048
target_y_ft = delta_north_m / 0.3048
# Add a real state; timestamp close to ownship timestamp
sa.add_real_state(1, 100.5, (target_x_ft, target_y_ft, 30.0))
# The archive should have computed geopos for target id 1
assert 1 in sa.recorded_geopos
gps = sa.recorded_geopos[1]
assert len(gps) >= 1
sample = gps[-1]
# Compute expected lat/lon using geodetic forward if available
if GEOD is not None:
dist = math.hypot(delta_east_m, delta_north_m)
az_rad = math.atan2(delta_east_m, delta_north_m)
az_deg = math.degrees(az_rad)
lon2, lat2, _ = GEOD.fwd(ownship["longitude"], ownship["latitude"], az_deg, dist)
# Compare within ~0.5 meter -> ~5e-6 degrees
assert abs(sample["lat"] - lat2) < 5e-6
assert abs(sample["lon"] - lon2) < 5e-6
else:
# Fallback check: lat/lon should be numbers and within reasonable bounds
assert isinstance(sample["lat"], float)
assert isinstance(sample["lon"], float)

16
tools/debug_geo.py Normal file
View File

@ -0,0 +1,16 @@
from target_simulator.analysis.simulation_archive import SimulationArchive
D=type('D',(),{})()
D.name='geo_test'
D.to_dict=lambda: {'dummy':True}
sa=SimulationArchive(D)
ownship={'timestamp':100.0,'latitude':45.0,'longitude':9.0,'position_xy_ft':(0.0,0.0)}
sa.add_ownship_state(ownship)
target_x_ft = 100.0/0.3048
target_y_ft = 200.0/0.3048
sa.add_real_state(1,100.5,(target_x_ft,target_y_ft,30.0))
print('ownship_trajectory=', sa.ownship_trajectory)
print('recorded_data=', sa.recorded_data)
print('recorded_geopos=', sa.recorded_geopos)
import target_simulator.analysis.simulation_archive as mod
print('HAS_PYPROJ=', getattr(mod,'_HAS_PYPROJ',None))
print('module file=', mod.__file__)