Compare commits
3 Commits
v.0.0.0.37
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee658999d6 | ||
|
|
37ee3be527 | ||
|
|
3f3cf56750 |
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -8,7 +8,8 @@
|
||||
"name": "aircraft db",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "/data/aircraft_database_manager"
|
||||
"module": "flightmonitor.data.aircraft_database_manager",
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "flight monitor",
|
||||
|
||||
25
GEMINI.md
Normal file
25
GEMINI.md
Normal 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.
|
||||
@ -1 +1,8 @@
|
||||
{}
|
||||
{
|
||||
"NordItaliaCentrale_44-48_7-12": {
|
||||
"lat_min": 44.0,
|
||||
"lon_min": 7.0,
|
||||
"lat_max": 48.0,
|
||||
"lon_max": 12.0
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import subprocess
|
||||
from queue import Queue, Empty as QueueEmpty
|
||||
from typing import List, Optional, Dict, Any, TYPE_CHECKING
|
||||
from tkinter import messagebox, simpledialog
|
||||
from datetime import timezone,datetime
|
||||
|
||||
# Import dei processori e degli adapter
|
||||
from flightmonitor.controller.historical_data_processor import HistoricalDataProcessor
|
||||
@ -614,9 +615,23 @@ class AppController:
|
||||
self.stop_live_monitoring()
|
||||
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")
|
||||
start_ts = session_info.get("start_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 = {
|
||||
"lat_min": session_info.get("lat_min"),
|
||||
"lon_min": session_info.get("lon_min"),
|
||||
@ -1165,3 +1180,69 @@ class AppController:
|
||||
return True
|
||||
module_logger.warning("Map tile manager not available to clear cache.")
|
||||
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')}.")
|
||||
@ -105,6 +105,15 @@ class PlaybackAdapter(BaseLiveDataAdapter):
|
||||
f"{self.name}: Thread starting playback for session on {self.session_date_str} "
|
||||
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.")
|
||||
|
||||
if not self.data_storage:
|
||||
@ -176,3 +185,45 @@ class PlaybackAdapter(BaseLiveDataAdapter):
|
||||
else:
|
||||
module_logger.info(f"{self.name}: Playback stopped by user.")
|
||||
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)
|
||||
@ -24,6 +24,7 @@ class PlaybackPanel:
|
||||
|
||||
# --- Internal State ---
|
||||
self._session_data_map: Dict[str, Dict[str, Any]] = {}
|
||||
self._current_session_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
# --- Tkinter Variables ---
|
||||
self.selected_date_var = tk.StringVar()
|
||||
@ -37,6 +38,7 @@ class PlaybackPanel:
|
||||
self.play_button: Optional[ttk.Button] = None
|
||||
self.pause_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._build_ui()
|
||||
@ -47,7 +49,6 @@ class PlaybackPanel:
|
||||
container = ttk.Frame(self.parent_frame, padding="10 10 10 10")
|
||||
container.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# --- Session Selection Frame ---
|
||||
session_frame = ttk.LabelFrame(
|
||||
container, text="Playback Session Selection", padding=10
|
||||
)
|
||||
@ -80,6 +81,7 @@ class PlaybackPanel:
|
||||
selectmode="browse",
|
||||
)
|
||||
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.column("start_time", width=130, anchor=tk.W)
|
||||
@ -96,12 +98,10 @@ class PlaybackPanel:
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
self.sessions_treeview.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# --- Playback Controls Frame ---
|
||||
controls_frame = ttk.LabelFrame(container, text="Playback Controls", padding=10)
|
||||
controls_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
|
||||
controls_frame.columnconfigure(1, weight=1)
|
||||
|
||||
# Timeline and Clock
|
||||
clock_font = tkFont.Font(family="Courier New", size=14, weight="bold")
|
||||
virtual_clock_label = ttk.Label(
|
||||
controls_frame,
|
||||
@ -122,8 +122,10 @@ class PlaybackPanel:
|
||||
self.timeline_slider.grid(
|
||||
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.grid(row=2, column=0, columnspan=4)
|
||||
|
||||
@ -142,6 +144,11 @@ class PlaybackPanel:
|
||||
)
|
||||
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))
|
||||
speed_options = ["0.5", "1.0", "2.0", "4.0", "8.0"]
|
||||
speed_combobox = ttk.Combobox(
|
||||
@ -149,6 +156,7 @@ class PlaybackPanel:
|
||||
textvariable=self.playback_speed_var,
|
||||
values=speed_options,
|
||||
width=5,
|
||||
state="readonly"
|
||||
)
|
||||
speed_combobox.pack(side=tk.LEFT)
|
||||
speed_combobox.bind("<<ComboboxSelected>>", self._on_speed_change)
|
||||
@ -157,8 +165,7 @@ class PlaybackPanel:
|
||||
self.update_available_dates()
|
||||
|
||||
def update_available_dates(self):
|
||||
if not self.controller:
|
||||
return
|
||||
if not self.controller: return
|
||||
dates = self.controller.get_available_recording_dates()
|
||||
if self.date_combobox:
|
||||
self.date_combobox["values"] = dates
|
||||
@ -183,8 +190,7 @@ class PlaybackPanel:
|
||||
|
||||
def update_sessions_for_date(self, date_str: str):
|
||||
self._clear_sessions_treeview()
|
||||
if not self.controller or not self.sessions_treeview:
|
||||
return
|
||||
if not self.controller or not self.sessions_treeview: return
|
||||
|
||||
sessions = self.controller.get_sessions_for_date(date_str)
|
||||
if not sessions:
|
||||
@ -193,39 +199,25 @@ class PlaybackPanel:
|
||||
|
||||
for session in sessions:
|
||||
scan_id = session.get("scan_id")
|
||||
if scan_id is None:
|
||||
continue
|
||||
if scan_id is None: continue
|
||||
|
||||
self._session_data_map[str(scan_id)] = session
|
||||
try:
|
||||
start_ts = session.get("start_timestamp", 0)
|
||||
end_ts = session.get("end_timestamp", 0)
|
||||
|
||||
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}]"
|
||||
)
|
||||
|
||||
start_ts, end_ts = session.get("start_timestamp", 0), 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}]"
|
||||
self.sessions_treeview.insert(
|
||||
"",
|
||||
"end",
|
||||
"", "end", iid=str(scan_id),
|
||||
values=(
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
duration_str,
|
||||
datetime.fromtimestamp(start_ts, timezone.utc).strftime("%H:%M:%S"),
|
||||
str(duration_delta).split(".")[0],
|
||||
session.get("type", "Unknown"),
|
||||
area_desc,
|
||||
),
|
||||
iid=str(scan_id),
|
||||
)
|
||||
except Exception as 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()
|
||||
if children:
|
||||
self.sessions_treeview.selection_set(children[0])
|
||||
@ -235,86 +227,171 @@ class PlaybackPanel:
|
||||
self.play_button.config(state=tk.DISABLED)
|
||||
|
||||
def _on_play(self):
|
||||
if not self.controller or not self.sessions_treeview:
|
||||
return
|
||||
|
||||
if not self.controller or not self.sessions_treeview: return
|
||||
selected_items = self.sessions_treeview.selection()
|
||||
if not selected_items:
|
||||
messagebox.showwarning("No Selection", "Please select a session to play.", parent=self.parent_frame)
|
||||
return
|
||||
|
||||
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)
|
||||
return
|
||||
|
||||
self.controller.start_playback(session_info)
|
||||
self.controller.start_playback(self._current_session_info)
|
||||
|
||||
def _on_pause(self):
|
||||
if self.controller:
|
||||
self.controller.pause_playback()
|
||||
if self.controller: self.controller.pause_playback()
|
||||
|
||||
def _on_stop(self):
|
||||
if self.controller:
|
||||
self.controller.stop_playback()
|
||||
if self.controller: 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):
|
||||
if self.controller:
|
||||
try:
|
||||
speed = float(self.playback_speed_var.get())
|
||||
self.controller.set_playback_speed(speed)
|
||||
except (ValueError, TypeError):
|
||||
module_logger.warning("Invalid playback speed value.")
|
||||
self.controller.set_playback_speed(float(self.playback_speed_var.get()))
|
||||
except (ValueError, TypeError): pass
|
||||
|
||||
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):
|
||||
if not self.parent_frame.winfo_exists():
|
||||
return
|
||||
|
||||
# Controls disabled during playback
|
||||
scan_and_select_state = tk.DISABLED if is_playing else tk.NORMAL
|
||||
# Stato per i controlli di selezione della sessione
|
||||
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:
|
||||
self.date_combobox.config(state="readonly" if not is_playing else tk.DISABLED)
|
||||
# Also disable scan button and treeview
|
||||
# ... (assuming direct widget references or iterating children)
|
||||
self.date_combobox.config(state=session_selection_readonly_state)
|
||||
if self.sessions_treeview:
|
||||
# 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_paused:
|
||||
self.play_button.config(state=tk.NORMAL, text="▶ Resume")
|
||||
self.play_button.configure(command=self._on_resume)
|
||||
if self.play_button:
|
||||
self.play_button.config(state=tk.NORMAL, text="▶ Resume")
|
||||
self.play_button.configure(command=self._on_resume)
|
||||
if self.pause_button:
|
||||
self.pause_button.config(state=tk.DISABLED)
|
||||
else: # Playing
|
||||
if self.play_button:
|
||||
self.play_button.config(state=tk.DISABLED, text="▶ Play")
|
||||
if self.pause_button:
|
||||
self.pause_button.config(state=tk.NORMAL)
|
||||
else: # Stopped
|
||||
if self.play_button:
|
||||
self.play_button.config(state=tk.NORMAL, text="▶ Play")
|
||||
self.play_button.configure(command=self._on_play)
|
||||
if self.pause_button:
|
||||
self.pause_button.config(state=tk.DISABLED)
|
||||
self.stop_button.config(state=tk.NORMAL)
|
||||
else: # Playing
|
||||
self.play_button.config(state=tk.DISABLED, text="▶ Play")
|
||||
self.pause_button.config(state=tk.NORMAL)
|
||||
self.stop_button.config(state=tk.NORMAL)
|
||||
else: # Stopped
|
||||
self.play_button.config(state=tk.NORMAL, text="▶ Play")
|
||||
self.play_button.configure(command=self._on_play)
|
||||
self.pause_button.config(state=tk.DISABLED)
|
||||
self.stop_button.config(state=tk.DISABLED)
|
||||
|
||||
def _on_resume(self):
|
||||
if self.controller:
|
||||
self.controller.resume_playback()
|
||||
if self.controller: self.controller.resume_playback()
|
||||
|
||||
def update_virtual_clock(self, timestamp: float):
|
||||
if not self.parent_frame.winfo_exists():
|
||||
return
|
||||
if not self.parent_frame.winfo_exists(): return
|
||||
try:
|
||||
dt_object = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||
self.virtual_clock_var.set(dt_object.strftime("%H:%M:%S"))
|
||||
except (ValueError, TypeError):
|
||||
self.virtual_clock_var.set("Invalid Time")
|
||||
self.virtual_clock_var.set(datetime.fromtimestamp(timestamp, timezone.utc).strftime("%H:%M:%S"))
|
||||
except (ValueError, TypeError): pass
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
if not self.parent_frame.winfo_exists() or not self.timeline_slider: return
|
||||
if (end_ts - start_ts) > 0:
|
||||
progress_percent = ((current_ts - start_ts) / (end_ts - start_ts)) * 100
|
||||
self.timeline_var.set(progress_percent)
|
||||
self.timeline_var.set(((current_ts - start_ts) / (end_ts - start_ts)) * 100)
|
||||
else:
|
||||
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)
|
||||
@ -123,6 +123,7 @@ class MapCanvasManager:
|
||||
# --- Data for Drawing ---
|
||||
self._current_flights_to_display_gui: List[CanonicalFlightState] = []
|
||||
self._active_aircraft_states: Dict[str, CanonicalFlightState] = {}
|
||||
self._last_playback_timestamp: float = 0.0
|
||||
self.flight_tracks_gui: Dict[str, deque] = {}
|
||||
self.max_track_points: int = app_config.DEFAULT_TRACK_HISTORY_POINTS
|
||||
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._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:
|
||||
timed_out_icaos = {icao for icao, state in self._active_aircraft_states.items() if virtual_timestamp - state.last_contact_timestamp > AIRCRAFT_VISIBILITY_TIMEOUT_SECONDS}
|
||||
for icao in timed_out_icaos:
|
||||
del self._active_aircraft_states[icao]
|
||||
if icao in self.flight_tracks_gui: del self.flight_tracks_gui[icao]
|
||||
# Se il nuovo timestamp è inferiore all'ultimo, è un "seek" all'indietro.
|
||||
# Dobbiamo resettare completamente lo stato per ricostruire la cronologia.
|
||||
is_seek_event = virtual_timestamp < self._last_playback_timestamp
|
||||
|
||||
for state in new_flight_states:
|
||||
state.last_contact_timestamp = virtual_timestamp # Update last seen time for playback
|
||||
if is_seek_event:
|
||||
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
|
||||
|
||||
# Aggiungi il punto alla traccia
|
||||
if state.icao24 not in self.flight_tracks_gui:
|
||||
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._last_playback_timestamp = virtual_timestamp
|
||||
|
||||
# Richiedi un aggiornamento dell'overlay con le tracce e posizioni appena calcolate
|
||||
self._request_overlay_render()
|
||||
|
||||
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)
|
||||
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):
|
||||
zoom_w = calculate_zoom_level_for_geographic_size(lat, size_km * 1000, self.canvas_width, self.tile_manager.tile_size)
|
||||
zoom_h = calculate_zoom_level_for_geographic_size(lat, size_km * 1000, self.canvas_height, self.tile_manager.tile_size)
|
||||
def center_map_and_fit_patch(
|
||||
self, lat: float, lon: float, size_km: Optional[float] = None, patch_size_km: Optional[float] = None
|
||||
):
|
||||
"""
|
||||
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
|
||||
self._request_base_map_render(lat, lon, max(map_constants.MIN_ZOOM_LEVEL, zoom))
|
||||
86
todo.md
86
todo.md
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user