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 <noreply@anthropic.com>
This commit is contained in:
+90
-21
@@ -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__)))
|
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
_gui = 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 data_model import ReceiverManager, GNSS_NAMES, GNSS_COLORS
|
||||||
from sky_widget import SkyPlotWidget
|
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):
|
class SkyTab(QWidget):
|
||||||
def __init__(self, model: ReceiverManager):
|
def __init__(self, model: ReceiverManager):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._model = model
|
self._model = model
|
||||||
self._store: dict = {} # (rid, gnss_id, sv_id) -> SatelliteData
|
self._store: dict = {} # (rid, gnss_id, sv_id) -> SatelliteData
|
||||||
self._table_keys: list = [] # current row order as (rid, gnss_id, sv_id)
|
self._table_keys: list = [] # current row order as (rid, gnss_id, sv_id)
|
||||||
self._pdop: dict = {} # rid -> float
|
self._pdop: dict = {} # rid -> float
|
||||||
self._info_cards: dict = {} # rid -> QFrame
|
self._info_cards: dict = {} # rid -> QFrame
|
||||||
self._info_labels: dict = {} # rid -> (lbl_pdop, lbl_visible, lbl_used)
|
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 = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(4, 4, 4, 4)
|
outer.setContentsMargins(4, 4, 4, 4)
|
||||||
@@ -66,11 +71,18 @@ class SkyTab(QWidget):
|
|||||||
self._table = QTableWidget()
|
self._table = QTableWidget()
|
||||||
self._table.setColumnCount(len(_COL_HEADERS))
|
self._table.setColumnCount(len(_COL_HEADERS))
|
||||||
self._table.setHorizontalHeaderLabels(_COL_HEADERS)
|
self._table.setHorizontalHeaderLabels(_COL_HEADERS)
|
||||||
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
|
hdr = self._table.horizontalHeader()
|
||||||
self._table.horizontalHeader().setStretchLastSection(True)
|
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.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||||
self._table.setSelectionBehavior(QTableWidget.SelectRows)
|
self._table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||||
self._table.setAlternatingRowColors(False)
|
self._table.setAlternatingRowColors(False)
|
||||||
|
self._table.itemChanged.connect(self._on_table_item_changed)
|
||||||
splitter.addWidget(self._table)
|
splitter.addWidget(self._table)
|
||||||
|
|
||||||
splitter.setStretchFactor(0, 2)
|
splitter.setStretchFactor(0, 2)
|
||||||
@@ -89,7 +101,7 @@ class SkyTab(QWidget):
|
|||||||
model.satellite_update.connect(self._on_sat_update)
|
model.satellite_update.connect(self._on_sat_update)
|
||||||
model.pdop_update.connect(self._on_pdop_update)
|
model.pdop_update.connect(self._on_pdop_update)
|
||||||
|
|
||||||
# ── Source list management ────────────────────────────────────────────────
|
# ── Source list management ───────────────────────────────────────────���────
|
||||||
|
|
||||||
def _on_receiver_added(self, rid: str):
|
def _on_receiver_added(self, rid: str):
|
||||||
item = QListWidgetItem(f" {rid} ")
|
item = QListWidgetItem(f" {rid} ")
|
||||||
@@ -98,13 +110,12 @@ class SkyTab(QWidget):
|
|||||||
item.setData(Qt.UserRole, rid)
|
item.setData(Qt.UserRole, rid)
|
||||||
self._src_list.addItem(item)
|
self._src_list.addItem(item)
|
||||||
|
|
||||||
# Info card for this receiver
|
|
||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setFrameShape(QFrame.Box)
|
card.setFrameShape(QFrame.Box)
|
||||||
cl = QHBoxLayout(card)
|
cl = QHBoxLayout(card)
|
||||||
cl.setContentsMargins(8, 1, 8, 1)
|
cl.setContentsMargins(8, 1, 8, 1)
|
||||||
cl.setSpacing(10)
|
cl.setSpacing(10)
|
||||||
name_lbl = QLabel(f"<b>{rid}</b>")
|
name_lbl = QLabel(f"<b>{rid}</b>")
|
||||||
lbl_pdop = QLabel("pDOP: —")
|
lbl_pdop = QLabel("pDOP: —")
|
||||||
lbl_visible = QLabel("Visible: —")
|
lbl_visible = QLabel("Visible: —")
|
||||||
lbl_used = QLabel("Used: —")
|
lbl_used = QLabel("Used: —")
|
||||||
@@ -119,9 +130,9 @@ class SkyTab(QWidget):
|
|||||||
if self._src_list.item(i).data(Qt.UserRole) == rid:
|
if self._src_list.item(i).data(Qt.UserRole) == rid:
|
||||||
self._src_list.takeItem(i)
|
self._src_list.takeItem(i)
|
||||||
break
|
break
|
||||||
keys = [k for k in self._store if k[0] == rid]
|
for k in [k for k in self._store if k[0] == rid]: del self._store[k]
|
||||||
for k in keys:
|
for k in [k for k in self._trail if k[0] == rid]: del self._trail[k]
|
||||||
del self._store[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)
|
self._pdop.pop(rid, None)
|
||||||
card = self._info_cards.pop(rid, None)
|
card = self._info_cards.pop(rid, None)
|
||||||
if card:
|
if card:
|
||||||
@@ -143,6 +154,14 @@ class SkyTab(QWidget):
|
|||||||
return [(k[0], self._store[k])
|
return [(k[0], self._store[k])
|
||||||
for k in self._store if k[0] in selected]
|
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 ──────────────────────────────────────────────────────────────
|
# ── Info bar ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _update_info(self):
|
def _update_info(self):
|
||||||
@@ -159,8 +178,13 @@ class SkyTab(QWidget):
|
|||||||
|
|
||||||
def _on_sat_update(self, rid: str, sats: list):
|
def _on_sat_update(self, rid: str, sats: list):
|
||||||
for sat in sats:
|
for sat in sats:
|
||||||
self._store[(rid, sat.gnss_id, sat.sv_id)] = sat
|
key = (rid, sat.gnss_id, sat.sv_id)
|
||||||
self._sky.update_data(self._visible_sats())
|
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()
|
self._update_info()
|
||||||
if not self._table_timer.isActive():
|
if not self._table_timer.isActive():
|
||||||
self._table_timer.start()
|
self._table_timer.start()
|
||||||
@@ -170,41 +194,77 @@ class SkyTab(QWidget):
|
|||||||
self._update_info()
|
self._update_info()
|
||||||
|
|
||||||
def _refresh_immediate(self):
|
def _refresh_immediate(self):
|
||||||
"""Called on source-checkbox toggle or receiver removal — rebuild now."""
|
|
||||||
self._table_timer.stop()
|
self._table_timer.stop()
|
||||||
visible = self._visible_sats()
|
visible = self._visible_sats()
|
||||||
self._sky.update_data(visible)
|
self._sky.update_data(visible, self._active_trails())
|
||||||
self._update_info()
|
self._update_info()
|
||||||
self._rebuild_table(visible)
|
self._rebuild_table(visible)
|
||||||
|
|
||||||
def _rebuild_table_from_store(self):
|
def _rebuild_table_from_store(self):
|
||||||
self._rebuild_table(self._visible_sats())
|
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 ─────────────────────────────────────────────────────────────────
|
# ── Table ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _rebuild_table(self, sats: list):
|
def _rebuild_table(self, sats: list):
|
||||||
sorted_sats = sorted(sats, key=lambda x: (x[1].gnss_id, x[1].sv_id))
|
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:
|
if new_keys == self._table_keys:
|
||||||
# Only values changed — update in place, scroll position untouched
|
# Only values changed — update in place, scroll position untouched
|
||||||
|
self._table.blockSignals(True)
|
||||||
for row, (_, sat) in enumerate(sorted_sats):
|
for row, (_, sat) in enumerate(sorted_sats):
|
||||||
self._table.item(row, 2).setText(f"{sat.elevation:.0f}")
|
self._table.item(row, 2).setText(f"{sat.elevation:.0f}")
|
||||||
self._table.item(row, 3).setText(f"{sat.azimuth:.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, 4).setText(f"{sat.cno:.1f}")
|
||||||
self._table.item(row, 5).setText("✓" if sat.used_in_fix else "")
|
self._table.item(row, 5).setText("✓" if sat.used_in_fix else "")
|
||||||
|
self._table.blockSignals(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Satellite set changed — full rebuild
|
# Satellite set changed — full rebuild
|
||||||
self._table_keys = new_keys
|
self._table_keys = new_keys
|
||||||
|
self._table.blockSignals(True)
|
||||||
vp = self._table.viewport()
|
vp = self._table.viewport()
|
||||||
vp.setUpdatesEnabled(False)
|
vp.setUpdatesEnabled(False)
|
||||||
self._table.setSortingEnabled(False)
|
self._table.setSortingEnabled(False)
|
||||||
self._table.setRowCount(len(sorted_sats))
|
self._table.setRowCount(len(sorted_sats))
|
||||||
|
|
||||||
for row, (rid, sat) in enumerate(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}')
|
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)
|
bg.setAlpha(55)
|
||||||
brush = QBrush(bg)
|
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, 5, cell("✓" if sat.used_in_fix else ""))
|
||||||
self._table.setItem(row, 6, cell(rid))
|
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)
|
self._table.setSortingEnabled(True)
|
||||||
vp.setUpdatesEnabled(True)
|
vp.setUpdatesEnabled(True)
|
||||||
|
self._table.blockSignals(False)
|
||||||
|
|||||||
+36
-11
@@ -45,13 +45,13 @@ class SkyRenderThread(QThread):
|
|||||||
self._queue: Queue = Queue(maxsize=1)
|
self._queue: Queue = Queue(maxsize=1)
|
||||||
self._running = True
|
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
|
# Discard any pending (not yet rendered) frame so we always render latest
|
||||||
try:
|
try:
|
||||||
self._queue.get_nowait()
|
self._queue.get_nowait()
|
||||||
except Empty:
|
except Empty:
|
||||||
pass
|
pass
|
||||||
self._queue.put((list(sats), width, height))
|
self._queue.put((list(sats), trails, width, height))
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -71,14 +71,14 @@ class SkyRenderThread(QThread):
|
|||||||
continue
|
continue
|
||||||
if item is None:
|
if item is None:
|
||||||
break
|
break
|
||||||
sats, w, h = item
|
sats, trails, w, h = item
|
||||||
if w > 0 and h > 0:
|
if w > 0 and h > 0:
|
||||||
img = self._render(sats, w, h)
|
img = self._render(sats, trails, w, h)
|
||||||
self.frame_ready.emit(img)
|
self.frame_ready.emit(img)
|
||||||
|
|
||||||
# ── Drawing (runs in render thread — QPainter on QImage is thread-safe) ──
|
# ── 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)
|
img = QImage(w, h, QImage.Format_RGB32)
|
||||||
|
|
||||||
p = QPainter(img)
|
p = QPainter(img)
|
||||||
@@ -89,6 +89,8 @@ class SkyRenderThread(QThread):
|
|||||||
|
|
||||||
self._draw_bg(p, w, h)
|
self._draw_bg(p, w, h)
|
||||||
self._draw_grid(p, cx, cy, max_r)
|
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_satellites(p, cx, cy, max_r, sats)
|
||||||
self._draw_legend(p, sats)
|
self._draw_legend(p, sats)
|
||||||
|
|
||||||
@@ -138,6 +140,27 @@ class SkyRenderThread(QThread):
|
|||||||
p.setPen(Qt.NoPen)
|
p.setPen(Qt.NoPen)
|
||||||
p.drawEllipse(QPointF(cx, cy), 3, 3)
|
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):
|
def _draw_satellites(self, p: QPainter, cx, cy, max_r, sats):
|
||||||
f = QFont(); f.setPointSize(7)
|
f = QFont(); f.setPointSize(7)
|
||||||
p.setFont(f)
|
p.setFont(f)
|
||||||
@@ -188,8 +211,9 @@ class SkyPlotWidget(QWidget):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._sats: list = []
|
self._sats: list = []
|
||||||
self._image: QImage = None
|
self._trails: dict = {}
|
||||||
|
self._image: QImage = None
|
||||||
|
|
||||||
self.setMinimumSize(400, 400)
|
self.setMinimumSize(400, 400)
|
||||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
@@ -206,16 +230,17 @@ class SkyPlotWidget(QWidget):
|
|||||||
|
|
||||||
# ── Public API ────────────────────────────────────────────────────────────
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def update_data(self, sats: list):
|
def update_data(self, sats: list, trails: dict = None):
|
||||||
self._sats = sats
|
self._sats = sats
|
||||||
self._thread.submit(sats, self.width(), self.height())
|
self._trails = trails or {}
|
||||||
|
self._thread.submit(sats, self._trails, self.width(), self.height())
|
||||||
|
|
||||||
# ── Qt events ─────────────────────────────────────────────────────────────
|
# ── Qt events ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
if self._sats:
|
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):
|
def paintEvent(self, _event):
|
||||||
p = QPainter(self)
|
p = QPainter(self)
|
||||||
|
|||||||
Reference in New Issue
Block a user