Files
NmeaClient/gui/sky_tab.py
T
jensandClaude Sonnet 4.6 7862989fd4 sky tab: extend trail storage to 24h, add visible trail depth spinbox
Trail history increased from 1h to 24h (86400 points). Added a spinbox
(0-24h, default 1h) next to 'Show Trails' to control how much trail is
rendered; spinbox is disabled when trails are off.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:08:13 +02:00

266 lines
12 KiB
Python

import sys, os
from collections import deque
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_gui = os.path.dirname(os.path.abspath(__file__))
for _p in [_root, _gui]:
if _p not in sys.path:
sys.path.insert(0, _p)
from PyQt5.QtWidgets import (
QWidget, QHBoxLayout, QVBoxLayout, QLabel, QListWidget,
QListWidgetItem, QTableWidget, QTableWidgetItem, QHeaderView,
QSplitter, QGroupBox, QFrame, QCheckBox, QSpinBox,
)
from PyQt5.QtCore import Qt, QTimer
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']
_TRAIL_MAXLEN = 86400 # 24 hours at 1 Hz
_TRAIL_MAX_HOURS = 24
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._trail: dict = {} # (rid, gnss_id, sv_id) -> deque[(elev, azim)]
outer = QVBoxLayout(self)
outer.setContentsMargins(4, 4, 4, 4)
outer.setSpacing(4)
# ── Source selector + trail toggle ────────────────────────────────────
src_box = QGroupBox("Receiver Sources (check to include)")
src_box.setMaximumHeight(80)
src_bl = QHBoxLayout(src_box)
src_bl.setContentsMargins(4, 2, 4, 2)
src_bl.setSpacing(8)
self._src_list = QListWidget()
self._src_list.setFlow(QListWidget.LeftToRight)
self._src_list.setWrapping(True)
self._src_list.setResizeMode(QListWidget.Adjust)
self._src_list.itemChanged.connect(self._refresh_immediate)
src_bl.addWidget(self._src_list)
self._trail_cb = QCheckBox("Show Trails")
self._trail_cb.setChecked(False)
self._trail_cb.toggled.connect(self._on_trail_toggled)
src_bl.addWidget(self._trail_cb)
self._trail_spin = QSpinBox()
self._trail_spin.setRange(0, _TRAIL_MAX_HOURS)
self._trail_spin.setValue(1)
self._trail_spin.setSuffix(" h")
self._trail_spin.setFixedWidth(58)
self._trail_spin.setEnabled(False)
self._trail_spin.valueChanged.connect(self._on_trail_toggled)
src_bl.addWidget(self._trail_spin)
outer.addWidget(src_box)
# ── Info bar (one card per receiver) ──────────────────────────────────
info_frame = QFrame()
info_frame.setFrameShape(QFrame.StyledPanel)
info_frame.setMaximumHeight(42)
self._info_layout = QHBoxLayout(info_frame)
self._info_layout.setContentsMargins(6, 2, 6, 2)
self._info_layout.setSpacing(12)
self._info_layout.addStretch()
outer.addWidget(info_frame)
# ── Sky plot + satellite table ─────────────────────────────────────────
splitter = QSplitter(Qt.Horizontal)
self._sky = SkyPlotWidget()
splitter.addWidget(self._sky)
self._table = QTableWidget()
self._table.setColumnCount(len(_COL_HEADERS))
self._table.setHorizontalHeaderLabels(_COL_HEADERS)
hdr = self._table.horizontalHeader()
hdr.setSectionResizeMode(QHeaderView.ResizeToContents)
hdr.setStretchLastSection(True)
self._table.setEditTriggers(QTableWidget.NoEditTriggers)
self._table.setSelectionBehavior(QTableWidget.SelectRows)
self._table.setAlternatingRowColors(False)
splitter.addWidget(self._table)
splitter.setStretchFactor(0, 2)
splitter.setStretchFactor(1, 1)
outer.addWidget(splitter)
# ── Table throttle timer (max ~2 Hz rebuilds) ─────────────────────────
self._table_timer = QTimer(self)
self._table_timer.setSingleShot(True)
self._table_timer.setInterval(500)
self._table_timer.timeout.connect(self._rebuild_table_from_store)
# ── Model signals ─────────────────────────────────────────────────────
model.receiver_added.connect(self._on_receiver_added)
model.receiver_removed.connect(self._on_receiver_removed)
model.satellite_update.connect(self._on_sat_update)
model.pdop_update.connect(self._on_pdop_update)
# ── Source list management ────────────────────────────────────────────────
def _on_receiver_added(self, rid: str):
item = QListWidgetItem(f" {rid} ")
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
item.setData(Qt.UserRole, rid)
self._src_list.addItem(item)
card = QFrame()
card.setFrameShape(QFrame.Box)
cl = QHBoxLayout(card)
cl.setContentsMargins(8, 1, 8, 1)
cl.setSpacing(10)
name_lbl = QLabel(f"<b>{rid}</b>")
lbl_pdop = QLabel("pDOP: —")
lbl_visible = QLabel("Visible: —")
lbl_used = QLabel("Used: —")
for w in (name_lbl, lbl_pdop, lbl_visible, lbl_used):
cl.addWidget(w)
self._info_cards[rid] = card
self._info_labels[rid] = (lbl_pdop, lbl_visible, lbl_used)
self._info_layout.insertWidget(self._info_layout.count() - 1, card)
def _on_receiver_removed(self, rid: str):
for i in range(self._src_list.count()):
if self._src_list.item(i).data(Qt.UserRole) == rid:
self._src_list.takeItem(i)
break
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]
self._pdop.pop(rid, None)
card = self._info_cards.pop(rid, None)
if card:
self._info_layout.removeWidget(card)
card.deleteLater()
self._info_labels.pop(rid, None)
self._refresh_immediate()
def _selected_rids(self) -> set:
result = set()
for i in range(self._src_list.count()):
item = self._src_list.item(i)
if item.checkState() == Qt.Checked:
result.add(item.data(Qt.UserRole))
return result
def _visible_sats(self) -> list:
selected = self._selected_rids()
return [(k[0], self._store[k])
for k in self._store if k[0] in selected]
def _active_trails(self) -> dict:
if not self._trail_cb.isChecked():
return {}
n_points = self._trail_spin.value() * 3600
if n_points == 0:
return {}
selected = self._selected_rids()
return {key: list(dq)[-n_points:]
for key, dq in self._trail.items()
if key[0] in selected}
# ── Trail toggle ──────────────────────────────────────────────────────────
def _on_trail_toggled(self, _value=None):
self._trail_spin.setEnabled(self._trail_cb.isChecked())
self._sky.update_data(self._visible_sats(), self._active_trails())
# ── Info bar ──────────────────────────────────────────────────────────────
def _update_info(self):
for rid, (lbl_pdop, lbl_visible, lbl_used) in self._info_labels.items():
sats = [self._store[k] for k in self._store if k[0] == rid]
n_visible = len(sats)
n_used = sum(1 for s in sats if s.used_in_fix)
pdop = self._pdop.get(rid)
lbl_pdop.setText( f"pDOP: {pdop:.1f}" if pdop is not None else "pDOP: —")
lbl_visible.setText(f"Visible: {n_visible}")
lbl_used.setText( f"Used: {n_used}")
# ── Data update ───────────────────────────────────────────────────────────
def _on_sat_update(self, rid: str, sats: list):
for sat in 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()
def _on_pdop_update(self, rid: str, pdop: float, _ts: float):
self._pdop[rid] = pdop
self._update_info()
def _refresh_immediate(self):
self._table_timer.stop()
visible = self._visible_sats()
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())
# ── 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]
if new_keys == self._table_keys:
# Only values changed — update in place, scroll position untouched
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 "")
return
# Satellite set changed — full rebuild
self._table_keys = new_keys
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):
gnss_name = GNSS_NAMES.get(sat.gnss_id, f'SYS{sat.gnss_id}')
bg = QColor(GNSS_COLORS.get(sat.gnss_id, '#9E9E9E'))
bg.setAlpha(55)
brush = QBrush(bg)
def cell(text, br=brush):
it = QTableWidgetItem(str(text))
it.setTextAlignment(Qt.AlignCenter)
it.setBackground(br)
return it
self._table.setItem(row, 0, cell(gnss_name))
self._table.setItem(row, 1, cell(sat.sv_id))
self._table.setItem(row, 2, cell(f"{sat.elevation:.0f}"))
self._table.setItem(row, 3, cell(f"{sat.azimuth:.0f}"))
self._table.setItem(row, 4, cell(f"{sat.cno:.1f}"))
self._table.setItem(row, 5, cell("✓" if sat.used_in_fix else ""))
self._table.setItem(row, 6, cell(rid))
self._table.setSortingEnabled(True)
vp.setUpdatesEnabled(True)