From 1522a2eb6ac0a8e3b055f40fa2c3e3172f386486 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 24 May 2026 13:23:14 +0200 Subject: [PATCH] sky plot: add per-satellite trajectory trails - Store up to 1 hour of (elevation, azimuth) history per satellite/source - Trail column in satellite table (checkbox, default off); clicking the column header toggles all trails on/off - Trail rendered in GNSS color with alpha fading from 15 (oldest) to 200 (newest), subsampled to max 120 points for render performance - Trail column fixed at 42 px to avoid checkbox + empty-text visual split Co-Authored-By: Claude Sonnet 4.6 --- gui/sky_tab.py | 111 +++++++++++++++++++++++++++++++++++++--------- gui/sky_widget.py | 47 +++++++++++++++----- 2 files changed, 126 insertions(+), 32 deletions(-) diff --git a/gui/sky_tab.py b/gui/sky_tab.py index 9ac4115..ec5da5f 100644 --- a/gui/sky_tab.py +++ b/gui/sky_tab.py @@ -1,4 +1,5 @@ -import sys, os +import sys, os, time +from collections import deque _root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _gui = os.path.dirname(os.path.abspath(__file__)) @@ -17,18 +18,22 @@ from PyQt5.QtGui import QColor, QBrush from data_model import ReceiverManager, GNSS_NAMES, GNSS_COLORS from sky_widget import SkyPlotWidget -_COL_HEADERS = ['GNSS', 'SV', 'Elev °', 'Az °', 'C/N₀', 'Fix', 'Receiver'] +_COL_HEADERS = ['GNSS', 'SV', 'Elev °', 'Az °', 'C/N₀', 'Fix', 'Receiver', 'Trail'] +_COL_TRAIL = len(_COL_HEADERS) - 1 +_TRAIL_MAXLEN = 3600 # 1 hour at 1 Hz class SkyTab(QWidget): def __init__(self, model: ReceiverManager): super().__init__() self._model = model - self._store: dict = {} # (rid, gnss_id, sv_id) -> SatelliteData - self._table_keys: list = [] # current row order as (rid, gnss_id, sv_id) - self._pdop: dict = {} # rid -> float - self._info_cards: dict = {} # rid -> QFrame - self._info_labels: dict = {} # rid -> (lbl_pdop, lbl_visible, lbl_used) + self._store: dict = {} # (rid, gnss_id, sv_id) -> SatelliteData + self._table_keys: list = [] # current row order as (rid, gnss_id, sv_id) + self._pdop: dict = {} # rid -> float + self._info_cards: dict = {} # rid -> QFrame + self._info_labels: dict = {} # rid -> (lbl_pdop, lbl_visible, lbl_used) + self._trail: dict = {} # (rid, gnss_id, sv_id) -> deque[(elev, azim)] + self._trail_enabled: dict = {} # (rid, gnss_id, sv_id) -> bool outer = QVBoxLayout(self) outer.setContentsMargins(4, 4, 4, 4) @@ -66,11 +71,18 @@ class SkyTab(QWidget): self._table = QTableWidget() self._table.setColumnCount(len(_COL_HEADERS)) self._table.setHorizontalHeaderLabels(_COL_HEADERS) - self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) - self._table.horizontalHeader().setStretchLastSection(True) + hdr = self._table.horizontalHeader() + hdr.setSectionResizeMode(QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(_COL_TRAIL, QHeaderView.Fixed) + hdr.setStretchLastSection(False) + hdr.sectionClicked.connect(self._on_header_clicked) + self._table.setColumnWidth(_COL_TRAIL, 42) + self._table.horizontalHeader().setToolTip( + "Click 'Trail' header to select / deselect all") self._table.setEditTriggers(QTableWidget.NoEditTriggers) self._table.setSelectionBehavior(QTableWidget.SelectRows) self._table.setAlternatingRowColors(False) + self._table.itemChanged.connect(self._on_table_item_changed) splitter.addWidget(self._table) splitter.setStretchFactor(0, 2) @@ -89,7 +101,7 @@ class SkyTab(QWidget): model.satellite_update.connect(self._on_sat_update) model.pdop_update.connect(self._on_pdop_update) - # ── Source list management ──────────────────────────────────────────────── + # ── Source list management ───────────────────────────────────────────���──── def _on_receiver_added(self, rid: str): item = QListWidgetItem(f" {rid} ") @@ -98,13 +110,12 @@ class SkyTab(QWidget): item.setData(Qt.UserRole, rid) self._src_list.addItem(item) - # Info card for this receiver card = QFrame() card.setFrameShape(QFrame.Box) cl = QHBoxLayout(card) cl.setContentsMargins(8, 1, 8, 1) cl.setSpacing(10) - name_lbl = QLabel(f"{rid}") + name_lbl = QLabel(f"{rid}") lbl_pdop = QLabel("pDOP: —") lbl_visible = QLabel("Visible: —") lbl_used = QLabel("Used: —") @@ -119,9 +130,9 @@ class SkyTab(QWidget): if self._src_list.item(i).data(Qt.UserRole) == rid: self._src_list.takeItem(i) break - keys = [k for k in self._store if k[0] == rid] - for k in keys: - del self._store[k] + for k in [k for k in self._store if k[0] == rid]: del self._store[k] + for k in [k for k in self._trail if k[0] == rid]: del self._trail[k] + for k in [k for k in self._trail_enabled if k[0] == rid]: del self._trail_enabled[k] self._pdop.pop(rid, None) card = self._info_cards.pop(rid, None) if card: @@ -143,6 +154,14 @@ class SkyTab(QWidget): return [(k[0], self._store[k]) for k in self._store if k[0] in selected] + def _active_trails(self) -> dict: + selected = self._selected_rids() + result = {} + for key, dq in self._trail.items(): + if key[0] in selected and self._trail_enabled.get(key, False): + result[key] = list(dq) + return result + # ── Info bar ────────────────────────────────────────────────────────────── def _update_info(self): @@ -159,8 +178,13 @@ class SkyTab(QWidget): def _on_sat_update(self, rid: str, sats: list): for sat in sats: - self._store[(rid, sat.gnss_id, sat.sv_id)] = sat - self._sky.update_data(self._visible_sats()) + key = (rid, sat.gnss_id, sat.sv_id) + self._store[key] = sat + if key not in self._trail: + self._trail[key] = deque(maxlen=_TRAIL_MAXLEN) + self._trail[key].append((sat.elevation, sat.azimuth)) + + self._sky.update_data(self._visible_sats(), self._active_trails()) self._update_info() if not self._table_timer.isActive(): self._table_timer.start() @@ -170,41 +194,77 @@ class SkyTab(QWidget): self._update_info() def _refresh_immediate(self): - """Called on source-checkbox toggle or receiver removal — rebuild now.""" self._table_timer.stop() visible = self._visible_sats() - self._sky.update_data(visible) + self._sky.update_data(visible, self._active_trails()) self._update_info() self._rebuild_table(visible) def _rebuild_table_from_store(self): self._rebuild_table(self._visible_sats()) + # ── Trail toggle ────────────────────────────────────────────────────────── + + def _on_table_item_changed(self, item): + if item.column() != _COL_TRAIL: + return + key = item.data(Qt.UserRole) + if key is None: + return + self._trail_enabled[key] = (item.checkState() == Qt.Checked) + self._sky.update_data(self._visible_sats(), self._active_trails()) + + def _on_header_clicked(self, col: int): + if col != _COL_TRAIL: + return + # Enable all if any are off, otherwise disable all + rows = self._table.rowCount() + any_off = any( + self._table.item(r, _COL_TRAIL).checkState() != Qt.Checked + for r in range(rows) + if self._table.item(r, _COL_TRAIL) + ) + new_state = Qt.Checked if any_off else Qt.Unchecked + self._table.blockSignals(True) + for r in range(rows): + item = self._table.item(r, _COL_TRAIL) + if item: + key = item.data(Qt.UserRole) + if key: + self._trail_enabled[key] = (new_state == Qt.Checked) + item.setCheckState(new_state) + self._table.blockSignals(False) + self._sky.update_data(self._visible_sats(), self._active_trails()) + # ── Table ───────────────────────────────────────────────────────────────── def _rebuild_table(self, sats: list): sorted_sats = sorted(sats, key=lambda x: (x[1].gnss_id, x[1].sv_id)) - new_keys = [(rid, sat.gnss_id, sat.sv_id) for rid, sat in sorted_sats] + new_keys = [(rid, sat.gnss_id, sat.sv_id) for rid, sat in sorted_sats] if new_keys == self._table_keys: # Only values changed — update in place, scroll position untouched + self._table.blockSignals(True) for row, (_, sat) in enumerate(sorted_sats): self._table.item(row, 2).setText(f"{sat.elevation:.0f}") self._table.item(row, 3).setText(f"{sat.azimuth:.0f}") self._table.item(row, 4).setText(f"{sat.cno:.1f}") self._table.item(row, 5).setText("✓" if sat.used_in_fix else "") + self._table.blockSignals(False) return # Satellite set changed — full rebuild self._table_keys = new_keys + self._table.blockSignals(True) vp = self._table.viewport() vp.setUpdatesEnabled(False) self._table.setSortingEnabled(False) self._table.setRowCount(len(sorted_sats)) for row, (rid, sat) in enumerate(sorted_sats): + key = (rid, sat.gnss_id, sat.sv_id) gnss_name = GNSS_NAMES.get(sat.gnss_id, f'SYS{sat.gnss_id}') - bg = QColor(GNSS_COLORS.get(sat.gnss_id, '#9E9E9E')) + bg = QColor(GNSS_COLORS.get(sat.gnss_id, '#9E9E9E')) bg.setAlpha(55) brush = QBrush(bg) @@ -222,5 +282,14 @@ class SkyTab(QWidget): self._table.setItem(row, 5, cell("✓" if sat.used_in_fix else "")) self._table.setItem(row, 6, cell(rid)) + trail_item = QTableWidgetItem() + trail_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + trail_item.setCheckState( + Qt.Checked if self._trail_enabled.get(key, False) else Qt.Unchecked) + trail_item.setData(Qt.UserRole, key) + trail_item.setTextAlignment(Qt.AlignCenter) + self._table.setItem(row, _COL_TRAIL, trail_item) + self._table.setSortingEnabled(True) vp.setUpdatesEnabled(True) + self._table.blockSignals(False) diff --git a/gui/sky_widget.py b/gui/sky_widget.py index 4faee05..801ddd6 100644 --- a/gui/sky_widget.py +++ b/gui/sky_widget.py @@ -45,13 +45,13 @@ class SkyRenderThread(QThread): self._queue: Queue = Queue(maxsize=1) self._running = True - def submit(self, sats: list, width: int, height: int): + def submit(self, sats: list, trails: dict, width: int, height: int): # Discard any pending (not yet rendered) frame so we always render latest try: self._queue.get_nowait() except Empty: pass - self._queue.put((list(sats), width, height)) + self._queue.put((list(sats), trails, width, height)) def stop(self): self._running = False @@ -71,14 +71,14 @@ class SkyRenderThread(QThread): continue if item is None: break - sats, w, h = item + sats, trails, w, h = item if w > 0 and h > 0: - img = self._render(sats, w, h) + img = self._render(sats, trails, w, h) self.frame_ready.emit(img) # ── Drawing (runs in render thread — QPainter on QImage is thread-safe) ── - def _render(self, sats: list, w: int, h: int) -> QImage: + def _render(self, sats: list, trails: dict, w: int, h: int) -> QImage: img = QImage(w, h, QImage.Format_RGB32) p = QPainter(img) @@ -89,6 +89,8 @@ class SkyRenderThread(QThread): self._draw_bg(p, w, h) self._draw_grid(p, cx, cy, max_r) + if trails: + self._draw_trails(p, cx, cy, max_r, trails) self._draw_satellites(p, cx, cy, max_r, sats) self._draw_legend(p, sats) @@ -138,6 +140,27 @@ class SkyRenderThread(QThread): p.setPen(Qt.NoPen) p.drawEllipse(QPointF(cx, cy), 3, 3) + def _draw_trails(self, p: QPainter, cx, cy, max_r, trails: dict): + for (_, gnss_id, _), points in trails.items(): + n = len(points) + if n < 2: + continue + base_color = _GNSS_QCOLORS.get(gnss_id, _C_LABEL) + r, g, b = base_color.red(), base_color.green(), base_color.blue() + + # Subsample to at most 120 points so rendering stays fast + step = max(1, n // 120) + pts = points[::step] + m = len(pts) + + for i in range(1, m): + # Alpha fades from 15 (oldest) to 200 (newest) + alpha = int(15 + 185 * i / m) + p.setPen(QPen(QColor(r, g, b, alpha), 1.5)) + x1, y1 = _polar_xy(cx, cy, max_r, pts[i - 1][0], pts[i - 1][1]) + x2, y2 = _polar_xy(cx, cy, max_r, pts[i][0], pts[i][1]) + p.drawLine(QPointF(x1, y1), QPointF(x2, y2)) + def _draw_satellites(self, p: QPainter, cx, cy, max_r, sats): f = QFont(); f.setPointSize(7) p.setFont(f) @@ -188,8 +211,9 @@ class SkyPlotWidget(QWidget): def __init__(self): super().__init__() - self._sats: list = [] - self._image: QImage = None + self._sats: list = [] + self._trails: dict = {} + self._image: QImage = None self.setMinimumSize(400, 400) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) @@ -206,16 +230,17 @@ class SkyPlotWidget(QWidget): # ── Public API ──────────────────────────────────────────────────────────── - def update_data(self, sats: list): - self._sats = sats - self._thread.submit(sats, self.width(), self.height()) + def update_data(self, sats: list, trails: dict = None): + self._sats = sats + self._trails = trails or {} + self._thread.submit(sats, self._trails, self.width(), self.height()) # ── Qt events ───────────────────────────────────────────────────────────── def resizeEvent(self, event): super().resizeEvent(event) if self._sats: - self._thread.submit(self._sats, self.width(), self.height()) + self._thread.submit(self._sats, self._trails, self.width(), self.height()) def paintEvent(self, _event): p = QPainter(self)