import pyqtgraph as pg from pyqtgraph import PlotWidget from datetime import datetime from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QComboBox, QLabel from PyQt5.QtCore import Qt, QSettings, pyqtSignal from defines import GNSS_NAMES pg.setConfigOption("background", "#0d1b2a") pg.setConfigOption("foreground", "#cccccc") PLOT_Y_FIELDS_TOP = ["pdop", "cno", "azim", "elev"] PLOT_Y_LABELS_TOP = { "pdop": "PDOP", "cno": "C/N₀ (dB)", "azim": "Azimuth (°)", "elev": "Elevation (°)", } MAX_HISTORY = 300 CURVE_PALETTE = [ "#1E90FF", "#00C853", "#E53935", "#FFA500", "#9C27B0", "#00BCD4", "#FF4081", "#FFD600", ] # --------------------------------------------------------------------------- # Persistenz-Hilfsklasse für Plot-Einstellungen # --------------------------------------------------------------------------- class PlotSettingsStore: """ Speichert/lädt die Einstellungen aller SinglePlotPanels via QSettings. Format pro Panel: plot_settings/panel_/field → str (z.B. "cno") plot_settings/panel_/sv → str (z.B. "GPS-12" oder "Alle") """ _KEY = "plot_settings" def __init__(self): self._cfg = QSettings("gnss_skyplot", "connections") def save(self, panels: list) -> None: self._cfg.beginGroup(self._KEY) self._cfg.remove("") # Gruppe leeren for idx, panel in enumerate(panels): self._cfg.setValue(f"panel_{idx}/field", panel.current_field()) self._cfg.setValue(f"panel_{idx}/sv", panel.current_sv()) self._cfg.endGroup() self._cfg.sync() def load(self, panels: list) -> None: self._cfg.beginGroup(self._KEY) for idx, panel in enumerate(panels): field = self._cfg.value(f"panel_{idx}/field", "cno") sv = self._cfg.value(f"panel_{idx}/sv", "Alle") panel.restore(field, sv) self._cfg.endGroup() # --------------------------------------------------------------------------- # Einzelner Plot-Panel # --------------------------------------------------------------------------- class SinglePlotPanel(QWidget): """Ein einzelner 2D-Plot mit Y-Auswahl (Feld + SVid).""" # Signal: Einstellung wurde geändert → PlotTab speichert settings_changed = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) self._history: dict[str, dict] = {} # key → {x:[], y:[], active: bool} self._curves: dict[str, pg.PlotDataItem] = {} self._field = "cno" self._svid_filter = "Alle" self._build_ui() # ── UI ──────────────────────────────────────────────────────────────── def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(4, 4, 4, 4) layout.setSpacing(4) # Steuerleiste ctrl = QHBoxLayout() ctrl.addWidget(QLabel("Y:")) self._y_combo = QComboBox() self._y_combo.setFixedWidth(120) for key in PLOT_Y_FIELDS_TOP: self._y_combo.addItem(PLOT_Y_LABELS_TOP[key], key) self._y_combo.currentIndexChanged.connect(self._on_field_changed) ctrl.addWidget(self._y_combo) ctrl.addWidget(QLabel("SV:")) self._sv_combo = QComboBox() self._sv_combo.setFixedWidth(110) self._sv_combo.addItem("Alle", "Alle") self._sv_combo.currentIndexChanged.connect(self._on_sv_changed) ctrl.addWidget(self._sv_combo) ctrl.addStretch() layout.addLayout(ctrl) # Plot-Widget self._plot = PlotWidget() self._plot.showGrid(x=True, y=True, alpha=0.3) self._plot.setLabel("left", PLOT_Y_LABELS_TOP[self._field]) self._plot.setLabel("bottom", "Zeit") self._plot.addLegend(offset=(5, 5)) self._plot.setAxisItems({"bottom": pg.DateAxisItem(orientation="bottom")}) layout.addWidget(self._plot) # ── Öffentliche Getter für Persistenz ───────────────────────────────── def current_field(self) -> str: return self._field def current_sv(self) -> str: return self._svid_filter # ── Einstellungen wiederherstellen ──────────────────────────────────── def restore(self, field: str, sv: str) -> None: # Y-Combo idx = self._y_combo.findData(field) if idx >= 0: self._y_combo.blockSignals(True) self._y_combo.setCurrentIndex(idx) self._y_combo.blockSignals(False) self._field = field self._plot.setLabel("left", PLOT_Y_LABELS_TOP.get(field, field)) # SV-Combo: Der SV-Wert wird gesetzt, sobald die Combo befüllt ist. # Wir merken ihn vor, _update_sv_combo() wendet ihn an. self._svid_filter = sv # ── Slots ───────────────────────────────────────────────────────────── def _on_field_changed(self, _idx: int): self._field = self._y_combo.currentData() self._plot.setLabel("left", PLOT_Y_LABELS_TOP[self._field]) self._history.clear() self._curves.clear() self._plot.clear() self._plot.addLegend(offset=(5, 5)) # SV-Combo zurücksetzen self._sv_combo.blockSignals(True) self._sv_combo.clear() self._sv_combo.addItem("Alle", "Alle") self._sv_combo.blockSignals(False) self._svid_filter = "Alle" self.settings_changed.emit() def _on_sv_changed(self, _idx: int): self._svid_filter = self._sv_combo.currentData() or "Alle" self._redraw() self.settings_changed.emit() # ── Daten-Update ────────────────────────────────────────────────────── def update_data(self, sats: list[dict], pdop: float | None, ts: float): active_keys: set[str] = set() if self._field == "pdop": if pdop is None: return active_keys.add("PDOP") print(f"PDOP: {pdop}") self._push("PDOP", ts, pdop) else: for sat in sats: key = f"{GNSS_NAMES.get(sat['gnssId'], '?')}-{sat['svId']}" active_keys.add(key) self._push(key, ts, float(sat.get(self._field, 0))) # Markiere inaktive SVs for key, h in self._history.items(): h["active"] = key in active_keys self._update_sv_combo() self._redraw() def _push(self, key: str, ts: float, val: float): if key not in self._history: self._history[key] = {"x": [], "y": [], "active": True} h = self._history[key] h["x"].append(ts) h["y"].append(val) h["active"] = True if len(h["x"]) > MAX_HISTORY: h["x"] = h["x"][-MAX_HISTORY:] h["y"] = h["y"][-MAX_HISTORY:] def _update_sv_combo(self): known = {self._sv_combo.itemData(i) for i in range(self._sv_combo.count())} self._sv_combo.blockSignals(True) for key in self._history: if key not in known: self._sv_combo.addItem(key, key) # Gespeicherten Filter anwenden, falls SV jetzt bekannt ist if self._svid_filter != "Alle": idx = self._sv_combo.findData(self._svid_filter) if idx >= 0 and self._sv_combo.currentIndex() != idx: self._sv_combo.setCurrentIndex(idx) self._sv_combo.blockSignals(False) # ── Zeichnen ────────────────────────────────────────────────────────── def _redraw(self): show_keys = ( list(self._history.keys()) if self._svid_filter == "Alle" else ([self._svid_filter] if self._svid_filter in self._history else []) ) # Kurven entfernen, die nicht mehr im Filter sind for key in list(self._curves.keys()): if key not in show_keys: self._plot.removeItem(self._curves.pop(key)) for idx, key in enumerate(show_keys): if key not in self._history: continue h = self._history[key] color = CURVE_PALETTE[idx % len(CURVE_PALETTE)] active = h.get("active", True) pen = pg.mkPen(color=color, width=1, style=Qt.SolidLine) if key not in self._curves: self._curves[key] = self._plot.plot( h["x"], h["y"], pen=pen, name=key ) else: self._curves[key].setData(h["x"], h["y"]) self._curves[key].setPen(pen) # --------------------------------------------------------------------------- # Plot-Tab (2 × 4 Grid, scrollbar) # --------------------------------------------------------------------------- class PlotTab(QWidget): NUM_PLOTS = 8 def __init__(self, parent=None): super().__init__(parent) self._panels: list[SinglePlotPanel] = [] self._store = PlotSettingsStore() self._build_ui() self._store.load(self._panels) # Einstellungen wiederherstellen def _build_ui(self): from PyQt5.QtWidgets import QScrollArea, QGridLayout scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setStyleSheet("QScrollArea { border: none; }") grid_widget = QWidget() grid = QGridLayout(grid_widget) grid.setSpacing(6) grid.setContentsMargins(6, 6, 6, 6) for i in range(self.NUM_PLOTS): panel = SinglePlotPanel() panel.settings_changed.connect(self._save_settings) self._panels.append(panel) row, col = divmod(i, 2) grid.addWidget(panel, row, col) scroll.setWidget(grid_widget) root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.addWidget(scroll) def _save_settings(self): self._store.save(self._panels) def update_data(self, sats: list[dict], pdop: float | None = None): ts = datetime.now().timestamp() for panel in self._panels: panel.update_data(sats, pdop, ts)