Compare commits

..

3 Commits

Author SHA1 Message Date
VALLONGOL
ee658999d6 sistemato errore creazione minimappa in dettaglio 2025-12-05 13:08:15 +01:00
VALLONGOL
37ee3be527 add 2025-11-12 13:31:49 +01:00
VALLONGOL
3f3cf56750 fix playback command 2025-06-17 12:36:29 +02:00
8 changed files with 388 additions and 174 deletions

3
.vscode/launch.json vendored
View File

@ -8,7 +8,8 @@
"name": "aircraft db", "name": "aircraft db",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "/data/aircraft_database_manager" "module": "flightmonitor.data.aircraft_database_manager",
"cwd": "${workspaceFolder}"
}, },
{ {
"name": "flight monitor", "name": "flight monitor",

25
GEMINI.md Normal file
View File

@ -0,0 +1,25 @@
Sono un ingegnere informatico che sviluppa principalmente in python e c++.
Quando mi proponi del codice ricordati di indicarmi le modifiche con il codice precedente, perchè le hai fatte, dove e come sono state fatte.
Utilizzando tutte le regole per scrivere in maniera migliore possibile codice in python.
Non dare nulla per scontato e spiega tutti i passi che segui nei tuoi ragionamenti.
Con me parla in italiano.
Come deve essere scritto il codice:
1) come standard di scrittura del codice Python, lo standard PEP8.
2) una istruzione per ogni riga di codice
3) nomi di funzioni, variabili, commenti, doc_string devono essere in inglese
4) codice più chiaro, ordinato, riutilizzabile
5) i commenti nel codice devono essere essenziali, stringati e chiari, non devono essere prolissi. dobbiamo cercare di mantenere il codice più pulito possibile, senza troppi fronzoli
6) Non indicare le modifche che fai come commento al codice, ma solo in linea generale in chat. il codice lascialo più pulito possibile
Per semplificare l'operazione di aggiornamento del codice:
1) se le modifiche che proponi interessano solo poche funzioni del modulo, allora indicami il contenuto di tutte le funzioni dove ci sono le modifiche.
2) se le modifiche impattano la maggior parte delle funzioni dello stesso modulo, allora ripeti per intero il codice del modulo senza omissioni.
3) se le modifiche che proponi interessano meno di 5 righe di una funzione, indicami quali sono le linee che cambiano e come modificarle
4) passami sempre un modulo alla volta e ti dico io quando passarmi il successivo, sempre in maniera completa e senza omissioni
Se vedi che il codice di un singolo modulo è più lungo di 1000 righe, prendi in considerazione il fatto di creare un nuovo modulo spostando quelle funzioni che sono omogenee per argomento in questo nuovo modulo e rendere più leggere il file che sta crescendo troppo.
Quando ti passo del codice da analizzare, cerca sempre di capirne le funzionalità e se hai da proporre dei miglioramenti o delle modifiche prima ne discuti con me e poi decidiamo se applicarlo oppure no.
Se noti che nel codice c'è qualcosa da migliorare, ne parli con me e poi vediamo se applicarlo oppure no, per evitare di mettere mano a funzioni che sono già state ottimizzate e funzionano come io voglio, e concentrarsi sulla risoluzione di problemi o l'introduzione di nuove funzioni.

View File

@ -1 +1,8 @@
{} {
"NordItaliaCentrale_44-48_7-12": {
"lat_min": 44.0,
"lon_min": 7.0,
"lat_max": 48.0,
"lon_max": 12.0
}
}

View File

@ -8,6 +8,7 @@ import subprocess
from queue import Queue, Empty as QueueEmpty from queue import Queue, Empty as QueueEmpty
from typing import List, Optional, Dict, Any, TYPE_CHECKING from typing import List, Optional, Dict, Any, TYPE_CHECKING
from tkinter import messagebox, simpledialog from tkinter import messagebox, simpledialog
from datetime import timezone,datetime
# Import dei processori e degli adapter # Import dei processori e degli adapter
from flightmonitor.controller.historical_data_processor import HistoricalDataProcessor from flightmonitor.controller.historical_data_processor import HistoricalDataProcessor
@ -614,9 +615,23 @@ class AppController:
self.stop_live_monitoring() self.stop_live_monitoring()
self.stop_historical_download() self.stop_historical_download()
if self.playback_data_queue:
while not self.playback_data_queue.empty():
try:
self.playback_data_queue.get_nowait()
except QueueEmpty:
break
module_logger.info("Playback data queue cleared before starting new session.")
date_str = session_info.get("date_str") date_str = session_info.get("date_str")
start_ts = session_info.get("start_timestamp") start_ts = session_info.get("start_timestamp")
end_ts = session_info.get("end_timestamp") end_ts = session_info.get("end_timestamp")
module_logger.info(
f"Controller: Validating playback session info. "
f"Start TS: {start_ts} ({datetime.fromtimestamp(start_ts, timezone.utc) if start_ts else 'N/A'}), "
f"End TS: {end_ts} ({datetime.fromtimestamp(end_ts, timezone.utc) if end_ts else 'N/A'})"
)
bbox = { bbox = {
"lat_min": session_info.get("lat_min"), "lat_min": session_info.get("lat_min"),
"lon_min": session_info.get("lon_min"), "lon_min": session_info.get("lon_min"),
@ -1165,3 +1180,69 @@ class AppController:
return True return True
module_logger.warning("Map tile manager not available to clear cache.") module_logger.warning("Map tile manager not available to clear cache.")
return False return False
def seek_playback_to_time(self, timestamp: float):
"""Seeks the playback to a specific time and forces a single frame update."""
if self.playback_adapter_thread and self.is_playback_active:
self.pause_playback() # Ensure playback is paused
self.playback_adapter_thread.seek_to_time(timestamp)
# After seeking, force the adapter to push one frame of data for that timestamp
# to the queue, so the GUI updates to the new position.
self.playback_adapter_thread.force_update_for_timestamp(timestamp)
module_logger.info(f"Playback seeked to {timestamp} and a frame update was forced.")
def reset_playback(self):
"""Resets the CURRENTLY ACTIVE playback to its starting point."""
if self.playback_adapter_thread and self.is_playback_active:
self.playback_adapter_thread.reset()
self.pause_playback() # Mette in pausa
# Forza un aggiornamento al primo frame per resettare la mappa
start_ts = self.playback_adapter_thread.start_ts
self.playback_adapter_thread.force_update_for_timestamp(start_ts)
module_logger.info("Active playback session has been reset.")
def reset_playback_to_session(self, session_info: Dict[str, Any]):
"""
Resets the GUI to the start of a given session, fetching and displaying
the initial frame of data without starting the playback loop.
"""
if self.is_playback_active:
self.stop_playback() # Se c'era un altro playback, lo ferma
start_ts = session_info.get("start_timestamp")
date_str = session_info.get("date_str")
if start_ts is None or not date_str or not self.data_storage:
return
# Aggiorna la GUI (orologio e timeline)
if self.main_window:
playback_panel = self.main_window.function_notebook_panel.playback_panel
if playback_panel:
playback_panel.update_virtual_clock(start_ts)
end_ts = session_info.get("end_timestamp", start_ts)
playback_panel.update_timeline(start_ts, start_ts, end_ts)
# Preleva il primo "frame" di dati direttamente dallo storage
# per mostrare lo stato iniziale sulla mappa.
try:
initial_frame_states = self.data_storage.get_positions_in_range(
date_str, start_ts, start_ts + 1.0 # Dati del primo secondo
)
if self.main_window and self.main_window.map_manager_instance:
# Passa i dati al map manager per il disegno
self.main_window.map_manager_instance.update_playback_frame(
initial_frame_states, start_ts
)
else:
# Se la mappa non è pronta, pulisce comunque la vista
if self.main_window:
self.main_window.clear_all_views_data()
except Exception as e:
module_logger.error(f"Failed to fetch initial frame for session reset: {e}", exc_info=True)
if self.main_window:
self.main_window.clear_all_views_data()
module_logger.info(f"Playback view reset to the start of session ID {session_info.get('scan_id')}.")

View File

@ -105,6 +105,15 @@ class PlaybackAdapter(BaseLiveDataAdapter):
f"{self.name}: Thread starting playback for session on {self.session_date_str} " f"{self.name}: Thread starting playback for session on {self.session_date_str} "
f"from {self.start_ts:.0f} to {self.end_ts:.0f}." f"from {self.start_ts:.0f} to {self.end_ts:.0f}."
) )
if self.start_ts >= self.end_ts:
err_msg = f"Invalid time range: start_ts ({self.start_ts}) is not less than end_ts ({self.end_ts}). Stopping."
module_logger.error(f"{self.name}: {err_msg}")
self._send_status_to_queue(STATUS_PERMANENT_FAILURE, err_msg)
# Invia anche un messaggio di stop normale per chiudere pulitamente la UI
self._send_status_to_queue(STATUS_STOPPED, "Playback failed due to invalid time range.")
return
self._send_status_to_queue(STATUS_STARTING, "Playback session started.") self._send_status_to_queue(STATUS_STARTING, "Playback session started.")
if not self.data_storage: if not self.data_storage:
@ -176,3 +185,45 @@ class PlaybackAdapter(BaseLiveDataAdapter):
else: else:
module_logger.info(f"{self.name}: Playback stopped by user.") module_logger.info(f"{self.name}: Playback stopped by user.")
self._send_status_to_queue(STATUS_STOPPED, "Playback stopped.") self._send_status_to_queue(STATUS_STOPPED, "Playback stopped.")
def seek_to_time(self, timestamp: float):
"""Jumps the playback to a specific timestamp within the session."""
with self._control_lock:
# Clamp the timestamp to be within the valid session range
self._virtual_time = max(self.start_ts, min(timestamp, self.end_ts))
module_logger.info(f"{self.name}: Seeking playback to timestamp {self._virtual_time:.0f}.")
def reset(self):
"""Resets the playback to the beginning of the session."""
with self._control_lock:
self._virtual_time = self.start_ts
module_logger.info(f"{self.name}: Playback reset to the beginning.")
def force_update_for_timestamp(self, timestamp: float):
"""
Queries all data from the session start up to the specified timestamp
and puts it into the output queue as a single "frame".
"""
if not self.data_storage:
module_logger.error(f"{self.name}: DataStorage not available. Cannot force update.")
return
module_logger.info(f"{self.name}: Forcing data update for all history up to timestamp {timestamp:.0f}.")
try:
# Query for ALL data from the beginning of the session up to the seek time.
all_states_until_seek = self.data_storage.get_positions_in_range(
self.session_date_str, self.start_ts, timestamp
)
# Il "payload" ora contiene la storia completa fino a quel punto.
# Il MapCanvasManager userà questi dati per costruire le tracce e mostrare l'ultimo stato.
payload = {"canonical": all_states_until_seek, "raw_json": "{}"}
message: AdapterMessage = {
"type": MSG_TYPE_FLIGHT_DATA,
"timestamp": timestamp, # L'orologio si sincronizza con l'istante scelto
"payload": payload,
}
self.output_queue.put(message)
except Exception as e:
module_logger.error(f"{self.name}: Error during forced data update: {e}", exc_info=True)

View File

@ -24,6 +24,7 @@ class PlaybackPanel:
# --- Internal State --- # --- Internal State ---
self._session_data_map: Dict[str, Dict[str, Any]] = {} self._session_data_map: Dict[str, Dict[str, Any]] = {}
self._current_session_info: Optional[Dict[str, Any]] = None
# --- Tkinter Variables --- # --- Tkinter Variables ---
self.selected_date_var = tk.StringVar() self.selected_date_var = tk.StringVar()
@ -37,6 +38,7 @@ class PlaybackPanel:
self.play_button: Optional[ttk.Button] = None self.play_button: Optional[ttk.Button] = None
self.pause_button: Optional[ttk.Button] = None self.pause_button: Optional[ttk.Button] = None
self.stop_button: Optional[ttk.Button] = None self.stop_button: Optional[ttk.Button] = None
self.reset_button: Optional[ttk.Button] = None
self.timeline_slider: Optional[ttk.Scale] = None self.timeline_slider: Optional[ttk.Scale] = None
self._build_ui() self._build_ui()
@ -47,7 +49,6 @@ class PlaybackPanel:
container = ttk.Frame(self.parent_frame, padding="10 10 10 10") container = ttk.Frame(self.parent_frame, padding="10 10 10 10")
container.pack(fill=tk.BOTH, expand=True) container.pack(fill=tk.BOTH, expand=True)
# --- Session Selection Frame ---
session_frame = ttk.LabelFrame( session_frame = ttk.LabelFrame(
container, text="Playback Session Selection", padding=10 container, text="Playback Session Selection", padding=10
) )
@ -80,6 +81,7 @@ class PlaybackPanel:
selectmode="browse", selectmode="browse",
) )
self.sessions_treeview.grid(row=0, column=0, sticky="nsew") self.sessions_treeview.grid(row=0, column=0, sticky="nsew")
self.sessions_treeview.bind("<<TreeviewSelect>>", self._on_session_selected_in_tree)
self.sessions_treeview.heading("start_time", text="Start Time (UTC)") self.sessions_treeview.heading("start_time", text="Start Time (UTC)")
self.sessions_treeview.column("start_time", width=130, anchor=tk.W) self.sessions_treeview.column("start_time", width=130, anchor=tk.W)
@ -96,12 +98,10 @@ class PlaybackPanel:
scrollbar.grid(row=0, column=1, sticky="ns") scrollbar.grid(row=0, column=1, sticky="ns")
self.sessions_treeview.configure(yscrollcommand=scrollbar.set) self.sessions_treeview.configure(yscrollcommand=scrollbar.set)
# --- Playback Controls Frame ---
controls_frame = ttk.LabelFrame(container, text="Playback Controls", padding=10) controls_frame = ttk.LabelFrame(container, text="Playback Controls", padding=10)
controls_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0)) controls_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
controls_frame.columnconfigure(1, weight=1) controls_frame.columnconfigure(1, weight=1)
# Timeline and Clock
clock_font = tkFont.Font(family="Courier New", size=14, weight="bold") clock_font = tkFont.Font(family="Courier New", size=14, weight="bold")
virtual_clock_label = ttk.Label( virtual_clock_label = ttk.Label(
controls_frame, controls_frame,
@ -122,8 +122,10 @@ class PlaybackPanel:
self.timeline_slider.grid( self.timeline_slider.grid(
row=1, column=0, columnspan=4, sticky="ew", pady=(0, 10) row=1, column=0, columnspan=4, sticky="ew", pady=(0, 10)
) )
self.timeline_slider.bind("<ButtonPress-1>", self._on_timeline_press)
self.timeline_slider.bind("<B1-Motion>", self._on_timeline_drag)
self.timeline_slider.bind("<ButtonRelease-1>", self._on_timeline_release)
# Buttons and Speed Control
button_frame = ttk.Frame(controls_frame) button_frame = ttk.Frame(controls_frame)
button_frame.grid(row=2, column=0, columnspan=4) button_frame.grid(row=2, column=0, columnspan=4)
@ -142,6 +144,11 @@ class PlaybackPanel:
) )
self.stop_button.pack(side=tk.LEFT, padx=5) self.stop_button.pack(side=tk.LEFT, padx=5)
self.reset_button = ttk.Button(
button_frame, text="⟲ Reset", command=self._on_reset, state=tk.DISABLED
)
self.reset_button.pack(side=tk.LEFT, padx=(10, 5))
ttk.Label(button_frame, text="Speed:").pack(side=tk.LEFT, padx=(15, 2)) ttk.Label(button_frame, text="Speed:").pack(side=tk.LEFT, padx=(15, 2))
speed_options = ["0.5", "1.0", "2.0", "4.0", "8.0"] speed_options = ["0.5", "1.0", "2.0", "4.0", "8.0"]
speed_combobox = ttk.Combobox( speed_combobox = ttk.Combobox(
@ -149,6 +156,7 @@ class PlaybackPanel:
textvariable=self.playback_speed_var, textvariable=self.playback_speed_var,
values=speed_options, values=speed_options,
width=5, width=5,
state="readonly"
) )
speed_combobox.pack(side=tk.LEFT) speed_combobox.pack(side=tk.LEFT)
speed_combobox.bind("<<ComboboxSelected>>", self._on_speed_change) speed_combobox.bind("<<ComboboxSelected>>", self._on_speed_change)
@ -157,8 +165,7 @@ class PlaybackPanel:
self.update_available_dates() self.update_available_dates()
def update_available_dates(self): def update_available_dates(self):
if not self.controller: if not self.controller: return
return
dates = self.controller.get_available_recording_dates() dates = self.controller.get_available_recording_dates()
if self.date_combobox: if self.date_combobox:
self.date_combobox["values"] = dates self.date_combobox["values"] = dates
@ -183,8 +190,7 @@ class PlaybackPanel:
def update_sessions_for_date(self, date_str: str): def update_sessions_for_date(self, date_str: str):
self._clear_sessions_treeview() self._clear_sessions_treeview()
if not self.controller or not self.sessions_treeview: if not self.controller or not self.sessions_treeview: return
return
sessions = self.controller.get_sessions_for_date(date_str) sessions = self.controller.get_sessions_for_date(date_str)
if not sessions: if not sessions:
@ -193,39 +199,25 @@ class PlaybackPanel:
for session in sessions: for session in sessions:
scan_id = session.get("scan_id") scan_id = session.get("scan_id")
if scan_id is None: if scan_id is None: continue
continue
self._session_data_map[str(scan_id)] = session self._session_data_map[str(scan_id)] = session
try: try:
start_ts = session.get("start_timestamp", 0) start_ts, end_ts = session.get("start_timestamp", 0), session.get("end_timestamp", 0)
end_ts = session.get("end_timestamp", 0) duration_delta = timedelta(seconds=end_ts - start_ts)
area_desc = f"BBox [{session.get('lat_min', 0):.2f}, {session.get('lon_min', 0):.2f}]"
start_dt = datetime.fromtimestamp(start_ts, timezone.utc)
end_dt = datetime.fromtimestamp(end_ts, timezone.utc)
duration_delta = end_dt - start_dt
duration_str = str(duration_delta).split(".")[0]
area_desc = (
f"BBox [{session.get('lat_min', 0):.2f}, {session.get('lon_min', 0):.2f}]"
)
self.sessions_treeview.insert( self.sessions_treeview.insert(
"", "", "end", iid=str(scan_id),
"end",
values=( values=(
start_dt.strftime("%H:%M:%S"), datetime.fromtimestamp(start_ts, timezone.utc).strftime("%H:%M:%S"),
duration_str, str(duration_delta).split(".")[0],
session.get("type", "Unknown"), session.get("type", "Unknown"),
area_desc, area_desc,
), ),
iid=str(scan_id),
) )
except Exception as e: except Exception as e:
module_logger.error(f"Failed to process session for display: {e}") module_logger.error(f"Failed to process session for display: {e}")
# Select the first item by default and enable play button
children = self.sessions_treeview.get_children() children = self.sessions_treeview.get_children()
if children: if children:
self.sessions_treeview.selection_set(children[0]) self.sessions_treeview.selection_set(children[0])
@ -235,86 +227,171 @@ class PlaybackPanel:
self.play_button.config(state=tk.DISABLED) self.play_button.config(state=tk.DISABLED)
def _on_play(self): def _on_play(self):
if not self.controller or not self.sessions_treeview: if not self.controller or not self.sessions_treeview: return
return
selected_items = self.sessions_treeview.selection() selected_items = self.sessions_treeview.selection()
if not selected_items: if not selected_items:
messagebox.showwarning("No Selection", "Please select a session to play.", parent=self.parent_frame) messagebox.showwarning("No Selection", "Please select a session to play.", parent=self.parent_frame)
return return
session_id = selected_items[0] session_id = selected_items[0]
session_info = self._session_data_map.get(session_id) self._current_session_info = self._session_data_map.get(session_id) # <-- Riga importante
if not session_info: if not self._current_session_info:
messagebox.showerror("Error", "Could not find data for the selected session.", parent=self.parent_frame) messagebox.showerror("Error", "Could not find data for the selected session.", parent=self.parent_frame)
return return
self.controller.start_playback(session_info) self.controller.start_playback(self._current_session_info)
def _on_pause(self): def _on_pause(self):
if self.controller: if self.controller: self.controller.pause_playback()
self.controller.pause_playback()
def _on_stop(self): def _on_stop(self):
if self.controller: if self.controller: self.controller.stop_playback()
self.controller.stop_playback() self._current_session_info = None # <-- Riga importante
def _on_reset(self):
# Se il playback è attivo, lo resetta.
# Se non è attivo, l'utente dovrebbe selezionare una riga per resettare la vista.
if self.controller and self.controller.is_playback_active:
self.controller.reset_playback()
elif self.controller and self._current_session_info:
# Se non è attivo ma una sessione è selezionata, resetta comunque la vista.
self.controller.reset_playback_to_session(self._current_session_info)
def _on_speed_change(self, event=None): def _on_speed_change(self, event=None):
if self.controller: if self.controller:
try: try:
speed = float(self.playback_speed_var.get()) self.controller.set_playback_speed(float(self.playback_speed_var.get()))
self.controller.set_playback_speed(speed) except (ValueError, TypeError): pass
except (ValueError, TypeError):
module_logger.warning("Invalid playback speed value.") def _on_timeline_press(self, event=None):
if self.controller and self.controller.is_playback_active:
self.controller.pause_playback()
def _on_timeline_release(self, event=None):
if not self.controller or not self.controller.is_playback_active or not self._current_session_info:
return
start_ts = self._current_session_info.get("start_timestamp", 0)
end_ts = self._current_session_info.get("end_timestamp", 0)
duration = end_ts - start_ts
if duration > 0:
seek_percentage = self.timeline_var.get() / 100.0
seek_timestamp = start_ts + (duration * seek_percentage)
self.controller.seek_playback_to_time(seek_timestamp)
def set_controls_state(self, is_playing: bool, is_paused: bool = False): def set_controls_state(self, is_playing: bool, is_paused: bool = False):
if not self.parent_frame.winfo_exists(): if not self.parent_frame.winfo_exists():
return return
# Controls disabled during playback # Stato per i controlli di selezione della sessione
scan_and_select_state = tk.DISABLED if is_playing else tk.NORMAL session_selection_state = tk.DISABLED if is_playing else tk.NORMAL
session_selection_readonly_state = tk.DISABLED if is_playing else "readonly"
if self.date_combobox: if self.date_combobox:
self.date_combobox.config(state="readonly" if not is_playing else tk.DISABLED) self.date_combobox.config(state=session_selection_readonly_state)
# Also disable scan button and treeview if self.sessions_treeview:
# ... (assuming direct widget references or iterating children) # Per i Treeview, non c'è uno stato 'readonly', ma possiamo intercettare gli eventi
# per prevenire la selezione. Per ora, lo lasciamo attivo per semplicità visiva.
# In alternativa, potremmo disabilitarlo ma diventerebbe grigio.
pass
# Trova il bottone "Scan for Recordings" e impostane lo stato
# Questo assume che il bottone sia un figlio diretto del frame del notebook o di un suo figlio
for child in self.parent_frame.winfo_children():
if isinstance(child, ttk.Frame): # Cerca dentro i frame
for sub_child in child.winfo_children():
if isinstance(sub_child, ttk.Button) and "scan" in sub_child.cget("text").lower():
sub_child.config(state=session_selection_state)
# Stato per i controlli di playback (timeline, pulsanti)
playback_controls_state = tk.NORMAL if is_playing else tk.DISABLED
if self.timeline_slider:
self.timeline_slider.config(state=playback_controls_state)
if self.stop_button:
self.stop_button.config(state=playback_controls_state)
is_session_selected = bool(self._current_session_info)
reset_state = tk.NORMAL if is_session_selected else tk.DISABLED
if self.reset_button:
self.reset_button.config(state=reset_state)
# Stato specifico per Play/Pause
if is_playing: if is_playing:
if is_paused: if is_paused:
if self.play_button:
self.play_button.config(state=tk.NORMAL, text="▶ Resume") self.play_button.config(state=tk.NORMAL, text="▶ Resume")
self.play_button.configure(command=self._on_resume) self.play_button.configure(command=self._on_resume)
if self.pause_button:
self.pause_button.config(state=tk.DISABLED) self.pause_button.config(state=tk.DISABLED)
self.stop_button.config(state=tk.NORMAL)
else: # Playing else: # Playing
if self.play_button:
self.play_button.config(state=tk.DISABLED, text="▶ Play") self.play_button.config(state=tk.DISABLED, text="▶ Play")
if self.pause_button:
self.pause_button.config(state=tk.NORMAL) self.pause_button.config(state=tk.NORMAL)
self.stop_button.config(state=tk.NORMAL)
else: # Stopped else: # Stopped
if self.play_button:
self.play_button.config(state=tk.NORMAL, text="▶ Play") self.play_button.config(state=tk.NORMAL, text="▶ Play")
self.play_button.configure(command=self._on_play) self.play_button.configure(command=self._on_play)
if self.pause_button:
self.pause_button.config(state=tk.DISABLED) self.pause_button.config(state=tk.DISABLED)
self.stop_button.config(state=tk.DISABLED)
def _on_resume(self): def _on_resume(self):
if self.controller: if self.controller: self.controller.resume_playback()
self.controller.resume_playback()
def update_virtual_clock(self, timestamp: float): def update_virtual_clock(self, timestamp: float):
if not self.parent_frame.winfo_exists(): if not self.parent_frame.winfo_exists(): return
return
try: try:
dt_object = datetime.fromtimestamp(timestamp, timezone.utc) self.virtual_clock_var.set(datetime.fromtimestamp(timestamp, timezone.utc).strftime("%H:%M:%S"))
self.virtual_clock_var.set(dt_object.strftime("%H:%M:%S")) except (ValueError, TypeError): pass
except (ValueError, TypeError):
self.virtual_clock_var.set("Invalid Time")
def update_timeline(self, current_ts: float, start_ts: float, end_ts: float): def update_timeline(self, current_ts: float, start_ts: float, end_ts: float):
if not self.parent_frame.winfo_exists() or not self.timeline_slider: if not self.parent_frame.winfo_exists() or not self.timeline_slider: return
return
if (end_ts - start_ts) > 0: if (end_ts - start_ts) > 0:
progress_percent = ((current_ts - start_ts) / (end_ts - start_ts)) * 100 self.timeline_var.set(((current_ts - start_ts) / (end_ts - start_ts)) * 100)
self.timeline_var.set(progress_percent)
else: else:
self.timeline_var.set(100) self.timeline_var.set(100)
def _on_timeline_drag(self, event=None):
"""Updates the virtual clock in real-time as the user drags the timeline."""
if not self._current_session_info:
return
start_ts = self._current_session_info.get("start_timestamp", 0)
end_ts = self._current_session_info.get("end_timestamp", 0)
duration = end_ts - start_ts
if duration > 0:
# Get the current value of the slider as it's being dragged
seek_percentage = self.timeline_var.get() / 100.0
current_drag_timestamp = start_ts + (duration * seek_percentage)
# Update only the clock display, do not send any commands yet
self.update_virtual_clock(current_drag_timestamp)
def _on_session_selected_in_tree(self, event=None):
"""Called when the user selects a different session in the treeview."""
if not self.sessions_treeview: return
selected_items = self.sessions_treeview.selection()
if not selected_items:
self._current_session_info = None
if self.reset_button: self.reset_button.config(state=tk.DISABLED)
if self.play_button: self.play_button.config(state=tk.DISABLED)
return
session_id = selected_items[0]
self._current_session_info = self._session_data_map.get(session_id)
if self._current_session_info:
# Abilita i pulsanti Play e Reset
if self.play_button: self.play_button.config(state=tk.NORMAL)
if self.reset_button: self.reset_button.config(state=tk.NORMAL)
# Resetta la visualizzazione della mappa e l'orologio all'inizio della sessione selezionata
self.controller.reset_playback_to_session(self._current_session_info)
else:
if self.reset_button: self.reset_button.config(state=tk.DISABLED)
if self.play_button: self.play_button.config(state=tk.DISABLED)

View File

@ -123,6 +123,7 @@ class MapCanvasManager:
# --- Data for Drawing --- # --- Data for Drawing ---
self._current_flights_to_display_gui: List[CanonicalFlightState] = [] self._current_flights_to_display_gui: List[CanonicalFlightState] = []
self._active_aircraft_states: Dict[str, CanonicalFlightState] = {} self._active_aircraft_states: Dict[str, CanonicalFlightState] = {}
self._last_playback_timestamp: float = 0.0
self.flight_tracks_gui: Dict[str, deque] = {} self.flight_tracks_gui: Dict[str, deque] = {}
self.max_track_points: int = app_config.DEFAULT_TRACK_HISTORY_POINTS self.max_track_points: int = app_config.DEFAULT_TRACK_HISTORY_POINTS
self._map_data_lock: threading.Lock = threading.Lock() self._map_data_lock: threading.Lock = threading.Lock()
@ -507,22 +508,56 @@ class MapCanvasManager:
self.flight_tracks_gui[state.icao24].append((state.latitude, state.longitude, state.timestamp)) self.flight_tracks_gui[state.icao24].append((state.latitude, state.longitude, state.timestamp))
self._request_overlay_render() self._request_overlay_render()
def update_playback_frame(self, new_flight_states: List[CanonicalFlightState], virtual_timestamp: float): def update_playback_frame(self, frame_flight_states: List[CanonicalFlightState], virtual_timestamp: float):
"""
Updates the map for a playback frame. Can handle a single tick or a full history refresh on seek.
"""
with self._map_data_lock: with self._map_data_lock:
timed_out_icaos = {icao for icao, state in self._active_aircraft_states.items() if virtual_timestamp - state.last_contact_timestamp > AIRCRAFT_VISIBILITY_TIMEOUT_SECONDS} # Se il nuovo timestamp è inferiore all'ultimo, è un "seek" all'indietro.
for icao in timed_out_icaos: # Dobbiamo resettare completamente lo stato per ricostruire la cronologia.
del self._active_aircraft_states[icao] is_seek_event = virtual_timestamp < self._last_playback_timestamp
if icao in self.flight_tracks_gui: del self.flight_tracks_gui[icao]
for state in new_flight_states: if is_seek_event:
state.last_contact_timestamp = virtual_timestamp # Update last seen time for playback logger.debug("Playback seek detected. Resetting active states and tracks.")
self._active_aircraft_states.clear()
self.flight_tracks_gui.clear()
# Processa tutti gli stati ricevuti per questo "frame"
# Durante un tick normale, sarà una piccola lista. Durante un seek, sarà l'intera storia.
for state in frame_flight_states:
# Aggiorna lo stato più recente dell'aereo
self._active_aircraft_states[state.icao24] = state self._active_aircraft_states[state.icao24] = state
# Aggiungi il punto alla traccia
if state.icao24 not in self.flight_tracks_gui: if state.icao24 not in self.flight_tracks_gui:
self.flight_tracks_gui[state.icao24] = deque(maxlen=self.max_track_points + 5) self.flight_tracks_gui[state.icao24] = deque(maxlen=self.max_track_points + 5)
if state.latitude is not None and state.longitude is not None and state.timestamp is not None:
self.flight_tracks_gui[state.icao24].append((state.latitude, state.longitude, state.timestamp))
if state.latitude is not None and state.longitude is not None and state.timestamp is not None:
self.flight_tracks_gui[state.icao24].append(
(state.latitude, state.longitude, state.timestamp)
)
# Rimuovi aerei che non sono più attivi (importante per il tick normale)
# Durante un seek, questo non farà nulla perché abbiamo appena pulito _active_aircraft_states
active_icaos_in_frame = {s.icao24 for s in frame_flight_states}
current_active_icaos = set(self._active_aircraft_states.keys())
# Se non è un seek, possiamo fare il pruning dei velivoli scaduti
if not is_seek_event:
timed_out_icaos = {
icao for icao, state in self._active_aircraft_states.items()
if virtual_timestamp - state.last_contact_timestamp > AIRCRAFT_VISIBILITY_TIMEOUT_SECONDS
}
if timed_out_icaos:
for icao in timed_out_icaos:
if icao in self._active_aircraft_states: del self._active_aircraft_states[icao]
if icao in self.flight_tracks_gui: del self.flight_tracks_gui[icao]
# L'elenco degli aerei da disegnare è lo stato finale degli aerei attivi
self._current_flights_to_display_gui = list(self._active_aircraft_states.values()) self._current_flights_to_display_gui = list(self._active_aircraft_states.values())
self._last_playback_timestamp = virtual_timestamp
# Richiedi un aggiornamento dell'overlay con le tracce e posizioni appena calcolate
self._request_overlay_render() self._request_overlay_render()
def get_current_map_info(self) -> Dict[str, Any]: def get_current_map_info(self) -> Dict[str, Any]:
@ -581,8 +616,21 @@ class MapCanvasManager:
new_lon, new_lat, _ = geod.fwd(new_lon, new_lat, 0, dmy) new_lon, new_lat, _ = geod.fwd(new_lon, new_lat, 0, dmy)
self._request_base_map_render(new_lat, new_lon, self._current_zoom_gui) self._request_base_map_render(new_lat, new_lon, self._current_zoom_gui)
def center_map_and_fit_patch(self, lat: float, lon: float, size_km: float): def center_map_and_fit_patch(
zoom_w = calculate_zoom_level_for_geographic_size(lat, size_km * 1000, self.canvas_width, self.tile_manager.tile_size) self, lat: float, lon: float, size_km: Optional[float] = None, patch_size_km: Optional[float] = None
zoom_h = calculate_zoom_level_for_geographic_size(lat, size_km * 1000, self.canvas_height, self.tile_manager.tile_size) ):
"""
Center the map at (lat, lon) and set zoom to fit a square patch of approx size_km (or patch_size_km).
Backwards-compatible: callers may pass either `size_km` (old name) or `patch_size_km` (new callers).
If both are provided, `patch_size_km` takes precedence.
"""
# Prefer explicitly named `patch_size_km` if provided for newer callers
chosen_size_km = patch_size_km if patch_size_km is not None else size_km
if chosen_size_km is None:
logger.error("center_map_and_fit_patch called without a size_km or patch_size_km value")
return
zoom_w = calculate_zoom_level_for_geographic_size(lat, chosen_size_km * 1000, self.canvas_width, self.tile_manager.tile_size)
zoom_h = calculate_zoom_level_for_geographic_size(lat, chosen_size_km * 1000, self.canvas_height, self.tile_manager.tile_size)
zoom = min(zoom_w, zoom_h) - 1 if zoom_w and zoom_h else map_constants.DEFAULT_INITIAL_ZOOM zoom = min(zoom_w, zoom_h) - 1 if zoom_w and zoom_h else map_constants.DEFAULT_INITIAL_ZOOM
self._request_base_map_render(lat, lon, max(map_constants.MIN_ZOOM_LEVEL, zoom)) self._request_base_map_render(lat, lon, max(map_constants.MIN_ZOOM_LEVEL, zoom))

86
todo.md
View File

@ -1,91 +1,15 @@
Perfetto, ora ho capito esattamente cosa intendi e hai assolutamente ragione. La mia proposta precedente era troppo semplicistica e non teneva conto del rate-limiting dell'API in un ciclo di download potenzialmente lungo. La tua osservazione è corretta e fondamentale per la robustezza della funzione.
Mi scuso per l'incomprensione. La tua spiegazione è chiarissima. Riformuliamo il concetto e il piano, integrando correttamente i due parametri. # TODOs
# owner: team-ris
# conventions: - use tags #tag, owner @user, prio:<low|med|high>, est:<time>
### Correzione del Concetto e del Flusso
Hai due esigenze distinte ma collegate: - [ ] https://opensky-network.org/datasets/#metadata/ aggiungere la funzione che permetta di scaricare i dati dei voli dal sito
1. **Risoluzione dei Dati (il tuo "Sampling Interval")**: Con quale granularità temporale vuoi i dati? Ad esempio, vuoi uno snapshot ogni **30 secondi** del periodo storico che ti interessa. Questo è il `time_step` del nostro ciclo.
2. **Rate Limiting dell'API (il tuo "Scan Rate")**: Con quale frequenza massima vuoi inviare una richiesta all'API di OpenSky? Ad esempio, non più di una richiesta ogni **15 secondi**. Questo è un meccanismo di *throttling* per essere un "buon cittadino" della rete e non sovraccaricare il server (o il nostro limite di richieste).
**Il flusso corretto del `HistoricalAdapter` diventa quindi questo:**
```python
# Dentro il metodo run() dell'HistoricalAdapter
current_time = start_time
last_api_request_time = 0
while current_time <= end_time: # FIXME List
# 1. THROTTLING (Controllo del Rate Limiting)
time_since_last_request = time.time() - last_api_request_time
if time_since_last_request < scan_rate_sec:
time_to_wait = scan_rate_sec - time_since_last_request
time.sleep(time_to_wait) # Attesa per rispettare il rate
# 2. CONTROLLO CRONOLOGIA (Opzionale, come discusso)
# if is_interval_already_scanned(current_time, bbox):
# # Salta questa iterazione e avanza l'orologio
# current_time += sampling_interval_sec
# continue
# 3. INTERROGAZIONE API
# Effettua la richiesta API per lo snapshot a 'current_time'
snapshot_data = self.api.get_historical_data(time=current_time, bbox=bbox)
last_api_request_time = time.time() # Aggiorna il timestamp dell'ultima richiesta
# 4. ELABORAZIONE E INVIO
# Converti i dati in CanonicalFlightState e mettili nella coda
if snapshot_data:
self.output_queue.put(snapshot_data)
# 5. AVANZAMENTO OROLOGIO VIRTUALE
# Avanza l'orologio del nostro "sampling interval"
current_time += sampling_interval_sec
```
Questo flusso rispetta entrambi i parametri:
* Chiede i dati a intervalli definiti dal `sampling_interval_sec`.
* Ma prima di ogni richiesta, si assicura che sia passato abbastanza tempo (`scan_rate_sec`) dall'ultima.
### Piano di Sviluppo Definitivo (Aggiornato con i tuoi requisiti)
Il piano rimane lo stesso nelle sue fasi, ma con i contenuti corretti.
1. **Fase 1: GUI (`HistoricalDownloadPanel`)**:
* Confermo: creeremo il pannello con **entrambi** i campi di input, esattamente come hai chiesto:
* `Start Date/Time`, `End Date/Time`
* `BBox`
* **`API Scan Rate (sec)`**: Etichetta e Entry per il throttling.
* **`Sampling Interval (sec)`**: Etichetta e Entry per la risoluzione dei dati.
* L'orologio virtuale e i bottoni rimangono come discusso.
2. **Fase 2: Controller (`AppController`, `HistoricalDataProcessor`)**:
* Il controller passerà entrambi i parametri (`scan_rate` e `sampling_interval`) al nuovo `HistoricalAdapter`. Il resto della sua logica (gestione thread, avvisi, etc.) rimane valido.
3. **Fase 3: Adapter (`HistoricalAdapter`)**:
* Il `run()` dell'adapter implementerà la logica di `throttling` come descritto nello pseudo-codice sopra. Sarà la modifica chiave rispetto al piano precedente.
4. **Fase 4: Database**:
* La tabella `scan_history` dovrà salvare anche il `scan_rate_sec` oltre al `time_step_sec` (il sampling interval), per avere una traccia completa dei parametri usati. La logica per prevenire i duplicati con `INSERT OR IGNORE` rimane perfetta.
* Struttura finale della tabella `scan_history`:
```sql
CREATE TABLE IF NOT EXISTS scan_history (
scan_id INTEGER PRIMARY KEY AUTOINCREMENT,
start_timestamp REAL NOT NULL,
end_timestamp REAL NOT NULL,
sampling_interval_sec INTEGER, -- Tuo "Sampling Interval"
scan_rate_sec INTEGER, -- Tuo "Scan Rate"
lat_min REAL NOT NULL,
lon_min REAL NOT NULL,
lat_max REAL NOT NULL,
lon_max REAL NOT NULL,
status TEXT,
completed_at REAL
);
```
Grazie mille per la precisazione, era fondamentale. Ora il piano è corretto e completo.
**Sei d'accordo con questa versione finale?** Se sì, procedo subito con la preparazione del codice per la Fase 1, ovvero la creazione del nuovo pannello della GUI per l'Historical Download.