added gui
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
import sys, os, math, time
|
||||
from datetime import datetime
|
||||
from queue import Queue, Empty
|
||||
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, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
|
||||
QSpinBox, QListWidget, QListWidgetItem, QSizePolicy, QApplication,
|
||||
)
|
||||
from PyQt5.QtGui import QPainter, QColor, QPen, QBrush, QFont, QImage
|
||||
from PyQt5.QtCore import Qt, QPointF, QRectF, QThread, pyqtSignal
|
||||
|
||||
from data_model import ReceiverManager
|
||||
|
||||
_Y_OPTIONS = ['C/N₀', 'Azimuth', 'Elevation', 'pDOP']
|
||||
_COLORS = ['#2196F3', '#F44336', '#4CAF50', '#FF9800',
|
||||
'#9C27B0', '#00BCD4', '#FFEB3B', '#795548']
|
||||
|
||||
# Static colors — created once in main thread, read-only everywhere else
|
||||
_C_BG = QColor('#1a1a2e')
|
||||
_C_PLOT_BG = QColor('#12121e')
|
||||
_C_GRID = QColor('#2a2a4a')
|
||||
_C_AXIS = QColor('#555577')
|
||||
_C_LABEL = QColor('#aaaacc')
|
||||
_C_TEXT = QColor('#ddddee')
|
||||
_QCOLORS = [QColor(c) for c in _COLORS]
|
||||
|
||||
|
||||
# ── Tick helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _nice_ticks(lo: float, hi: float, n: int = 5) -> list:
|
||||
if hi - lo < 1e-9:
|
||||
hi = lo + 1.0
|
||||
raw = (hi - lo) / n
|
||||
exp = math.floor(math.log10(raw))
|
||||
base = 10 ** exp
|
||||
step = base * 10
|
||||
for m in (1, 2, 2.5, 5, 10):
|
||||
if base * m >= raw:
|
||||
step = base * m
|
||||
break
|
||||
first = math.ceil(lo / step) * step
|
||||
ticks, v = [], first
|
||||
while v <= hi + step * 0.01:
|
||||
ticks.append(v)
|
||||
v += step
|
||||
return ticks
|
||||
|
||||
|
||||
# ── Render thread ─────────────────────────────────────────────────────────────
|
||||
|
||||
class PlotRenderThread(QThread):
|
||||
"""Renders a time-series plot to QImage in a background thread.
|
||||
|
||||
snapshot format: dict[rid -> (list[timestamp_float], list[value_float])]
|
||||
"""
|
||||
|
||||
frame_ready = pyqtSignal(object) # QImage
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._queue = Queue(maxsize=1)
|
||||
self._running = True
|
||||
|
||||
def submit(self, snapshot: dict, y_label: str, width: int, height: int):
|
||||
try:
|
||||
self._queue.get_nowait() # discard stale pending frame
|
||||
except Empty:
|
||||
pass
|
||||
self._queue.put((snapshot, y_label, width, height))
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
try:
|
||||
self._queue.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
self.wait(2000)
|
||||
|
||||
# ── Thread body ───────────────────────────────────────────────────────────
|
||||
|
||||
def run(self):
|
||||
while self._running:
|
||||
try:
|
||||
item = self._queue.get(timeout=0.2)
|
||||
except Empty:
|
||||
continue
|
||||
if item is None:
|
||||
break
|
||||
snapshot, y_label, w, h = item
|
||||
if w > 0 and h > 0:
|
||||
self.frame_ready.emit(self._render(snapshot, y_label, w, h))
|
||||
|
||||
# ── Drawing (QPainter on QImage is thread-safe) ───────────────────────────
|
||||
|
||||
_ML, _MR, _MT, _MB = 56, 8, 10, 36 # margins: left/right/top/bottom px
|
||||
|
||||
def _render(self, snapshot: dict, y_label: str, w: int, h: int) -> QImage:
|
||||
img = QImage(w, h, QImage.Format_RGB32)
|
||||
p = QPainter(img)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
ML, MR, MT, MB = self._ML, self._MR, self._MT, self._MB
|
||||
pw = w - ML - MR
|
||||
ph = h - MT - MB
|
||||
|
||||
p.fillRect(0, 0, w, h, _C_BG)
|
||||
if pw > 0 and ph > 0:
|
||||
p.fillRect(ML, MT, pw, ph, _C_PLOT_BG)
|
||||
|
||||
if pw <= 0 or ph <= 0:
|
||||
p.end()
|
||||
return img
|
||||
|
||||
all_ts = [t for ts, _ in snapshot.values() for t in ts]
|
||||
all_v = [v for _, vs in snapshot.values() for v in vs]
|
||||
|
||||
if not all_ts:
|
||||
self._draw_empty(p, ML, MT, pw, ph)
|
||||
p.end()
|
||||
return img
|
||||
|
||||
ts_min = min(all_ts); ts_max = max(all_ts)
|
||||
v_min = min(all_v); v_max = max(all_v)
|
||||
|
||||
if ts_max - ts_min < 1:
|
||||
ts_min = ts_max - 1.0
|
||||
|
||||
v_ticks = _nice_ticks(v_min, v_max)
|
||||
v_min, v_max = v_ticks[0], v_ticks[-1]
|
||||
if v_max - v_min < 1e-9:
|
||||
v_max = v_min + 1.0
|
||||
|
||||
ts_span = ts_max - ts_min
|
||||
v_span = v_max - v_min
|
||||
|
||||
def px(ts, v):
|
||||
x = ML + (ts - ts_min) / ts_span * pw
|
||||
y = MT + ph - (v - v_min) / v_span * ph
|
||||
return x, y
|
||||
|
||||
self._draw_grid(p, ML, MT, pw, ph, v_ticks, v_min, v_max)
|
||||
self._draw_curves(p, snapshot, px)
|
||||
self._draw_legend(p, snapshot, ML, MT)
|
||||
self._draw_y_labels(p, ML, MT, ph, v_ticks, v_min, v_max, y_label)
|
||||
self._draw_x_labels(p, ML, MT, pw, ph, ts_min, ts_max)
|
||||
|
||||
p.end()
|
||||
return img
|
||||
|
||||
def _draw_empty(self, p, ml, mt, pw, ph):
|
||||
f = QFont(); f.setPointSize(8)
|
||||
p.setFont(f); p.setPen(QPen(_C_LABEL))
|
||||
p.drawText(QRectF(ml, mt, pw, ph), Qt.AlignCenter,
|
||||
"Keine Daten\nQuelle + Satellit auswählen")
|
||||
|
||||
def _draw_grid(self, p, ml, mt, pw, ph, v_ticks, v_min, v_max):
|
||||
p.setPen(QPen(_C_GRID, 1, Qt.DotLine))
|
||||
v_span = v_max - v_min
|
||||
for v in v_ticks:
|
||||
if v_min <= v <= v_max:
|
||||
y = mt + ph - (v - v_min) / v_span * ph
|
||||
p.drawLine(QPointF(ml, y), QPointF(ml + pw, y))
|
||||
for i in range(1, 5):
|
||||
x = ml + i * pw / 5
|
||||
p.drawLine(QPointF(x, mt), QPointF(x, mt + ph))
|
||||
p.setPen(QPen(_C_AXIS, 1))
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawRect(ml, mt, pw, ph)
|
||||
|
||||
def _draw_curves(self, p, snapshot, px):
|
||||
for i, (rid, (ts_list, v_list)) in enumerate(snapshot.items()):
|
||||
if len(ts_list) < 1:
|
||||
continue
|
||||
pen = QPen(_QCOLORS[i % len(_QCOLORS)], 1.5)
|
||||
p.setPen(pen)
|
||||
pts = [QPointF(*px(t, v)) for t, v in zip(ts_list, v_list)]
|
||||
for j in range(1, len(pts)):
|
||||
p.drawLine(pts[j - 1], pts[j])
|
||||
|
||||
def _draw_legend(self, p, snapshot, ml, mt):
|
||||
f = QFont(); f.setPointSize(7); p.setFont(f)
|
||||
lx, ly = ml + 5, mt + 5
|
||||
for i, rid in enumerate(snapshot):
|
||||
color = _QCOLORS[i % len(_QCOLORS)]
|
||||
p.setPen(QPen(color, 2))
|
||||
p.drawLine(QPointF(lx, ly + 5), QPointF(lx + 14, ly + 5))
|
||||
p.setPen(QPen(_C_TEXT))
|
||||
p.drawText(QRectF(lx + 16, ly, 80, 12), Qt.AlignLeft, str(rid))
|
||||
lx += max(20 + len(rid) * 6, 70)
|
||||
|
||||
def _draw_y_labels(self, p, ml, mt, ph, v_ticks, v_min, v_max, y_label):
|
||||
f = QFont(); f.setPointSize(7); p.setFont(f)
|
||||
p.setPen(QPen(_C_LABEL))
|
||||
v_span = v_max - v_min
|
||||
for v in v_ticks:
|
||||
if v_min <= v <= v_max:
|
||||
y = mt + ph - (v - v_min) / v_span * ph
|
||||
lbl = f"{v:.1f}" if abs(v) < 1000 else f"{v:.0f}"
|
||||
p.drawText(QRectF(2, y - 8, ml - 4, 16),
|
||||
Qt.AlignRight | Qt.AlignVCenter, lbl)
|
||||
# rotated axis label
|
||||
p.save()
|
||||
p.translate(10, mt + ph / 2)
|
||||
p.rotate(-90)
|
||||
f2 = QFont(); f2.setPointSize(8); p.setFont(f2)
|
||||
p.drawText(QRectF(-40, -10, 80, 20), Qt.AlignCenter, y_label)
|
||||
p.restore()
|
||||
|
||||
def _draw_x_labels(self, p, ml, mt, pw, ph, ts_min, ts_max):
|
||||
f = QFont(); f.setPointSize(7); p.setFont(f)
|
||||
p.setPen(QPen(_C_LABEL))
|
||||
for i in range(6):
|
||||
ts = ts_min + (ts_max - ts_min) * i / 5
|
||||
x = ml + i * pw / 5
|
||||
lbl = datetime.fromtimestamp(ts).strftime('%H:%M:%S')
|
||||
p.drawText(QRectF(x - 28, mt + ph + 4, 56, 14),
|
||||
Qt.AlignCenter, lbl)
|
||||
|
||||
|
||||
# ── Canvas widget (displays QImage from render thread) ────────────────────────
|
||||
|
||||
class _Canvas(QWidget):
|
||||
resized = pyqtSignal(int, int)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._image = None
|
||||
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.setMinimumHeight(150)
|
||||
|
||||
def set_image(self, img: QImage):
|
||||
self._image = img
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event):
|
||||
p = QPainter(self)
|
||||
if self._image and not self._image.isNull():
|
||||
p.drawImage(0, 0, self._image)
|
||||
else:
|
||||
p.fillRect(self.rect(), _C_BG)
|
||||
p.end()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self.resized.emit(self.width(), self.height())
|
||||
|
||||
|
||||
# ── Single configurable plot ──────────────────────────────────────────────────
|
||||
|
||||
class SinglePlotWidget(QWidget):
|
||||
def __init__(self, model: ReceiverManager):
|
||||
super().__init__()
|
||||
self._model = model
|
||||
self._data: dict = {} # rid → [deque(ts), deque(val)]
|
||||
self._known_sats: dict = {} # rid → set[sat_label] (selected only)
|
||||
|
||||
self._build_ui()
|
||||
|
||||
self._thread = PlotRenderThread()
|
||||
self._thread.frame_ready.connect(self._canvas.set_image)
|
||||
self._thread.start()
|
||||
|
||||
app = QApplication.instance()
|
||||
if app:
|
||||
app.aboutToQuit.connect(self._thread.stop)
|
||||
|
||||
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)
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(2)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(5)
|
||||
|
||||
bar.addWidget(QLabel("Y-Achse:"))
|
||||
self._y_cb = QComboBox(); self._y_cb.addItems(_Y_OPTIONS)
|
||||
self._y_cb.setMaximumWidth(85)
|
||||
self._y_cb.currentTextChanged.connect(self._on_y_changed)
|
||||
bar.addWidget(self._y_cb)
|
||||
|
||||
bar.addWidget(QLabel("Satellit:"))
|
||||
self._val_cb = QComboBox()
|
||||
self._val_cb.setMinimumWidth(90); self._val_cb.setMaximumWidth(120)
|
||||
self._val_cb.currentTextChanged.connect(self._submit_render)
|
||||
bar.addWidget(self._val_cb)
|
||||
|
||||
bar.addWidget(QLabel("Quelle:"))
|
||||
self._src_list = QListWidget()
|
||||
self._src_list.setFlow(QListWidget.LeftToRight)
|
||||
self._src_list.setWrapping(True)
|
||||
self._src_list.setResizeMode(QListWidget.Adjust)
|
||||
self._src_list.setMaximumHeight(46); self._src_list.setMaximumWidth(200)
|
||||
self._src_list.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self._src_list.itemChanged.connect(self._on_source_changed)
|
||||
bar.addWidget(self._src_list)
|
||||
|
||||
bar.addWidget(QLabel("Punkte:"))
|
||||
self._hist_spin = QSpinBox()
|
||||
self._hist_spin.setRange(10, 10000); self._hist_spin.setValue(300)
|
||||
self._hist_spin.setMaximumWidth(70)
|
||||
self._hist_spin.valueChanged.connect(self._on_history_changed)
|
||||
bar.addWidget(self._hist_spin)
|
||||
bar.addStretch()
|
||||
layout.addLayout(bar)
|
||||
|
||||
self._canvas = _Canvas()
|
||||
self._canvas.resized.connect(lambda w, h: self._submit_render())
|
||||
layout.addWidget(self._canvas)
|
||||
|
||||
# ── Source helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _selected_rids(self) -> list:
|
||||
result = []
|
||||
for i in range(self._src_list.count()):
|
||||
it = self._src_list.item(i)
|
||||
if it.checkState() == Qt.Checked:
|
||||
result.append(it.data(Qt.UserRole))
|
||||
return result
|
||||
|
||||
def _rebuild_val_combo(self):
|
||||
selected = set(self._selected_rids())
|
||||
current = self._val_cb.currentText()
|
||||
self._val_cb.blockSignals(True)
|
||||
self._val_cb.clear()
|
||||
sats = set()
|
||||
for rid in selected:
|
||||
sats.update(self._known_sats.get(rid, set()))
|
||||
for s in sorted(sats):
|
||||
self._val_cb.addItem(s)
|
||||
if current in sats:
|
||||
self._val_cb.setCurrentText(current)
|
||||
self._val_cb.blockSignals(False)
|
||||
|
||||
# ── Render submission ─────────────────────────────────────────────────────
|
||||
|
||||
def _submit_render(self):
|
||||
selected = set(self._selected_rids())
|
||||
snapshot = {}
|
||||
for rid in selected:
|
||||
ts_dq, v_dq = self._data.get(rid, ([], []))
|
||||
if ts_dq:
|
||||
snapshot[rid] = (list(ts_dq), list(v_dq))
|
||||
w, h = self._canvas.width(), self._canvas.height()
|
||||
if w > 0 and h > 0:
|
||||
self._thread.submit(snapshot, self._y_cb.currentText(), w, h)
|
||||
|
||||
def _push_point(self, rid: str, ts: float, val: float):
|
||||
if rid not in self._data:
|
||||
n = self._hist_spin.value()
|
||||
self._data[rid] = [deque(maxlen=n), deque(maxlen=n)]
|
||||
self._data[rid][0].append(ts)
|
||||
self._data[rid][1].append(val)
|
||||
self._submit_render()
|
||||
|
||||
# ── Config change handlers ────────────────────────────────────────────────
|
||||
|
||||
def _on_y_changed(self, _text: str):
|
||||
is_pdop = (_text == 'pDOP')
|
||||
self._val_cb.setEnabled(not is_pdop)
|
||||
for rid in list(self._data):
|
||||
n = self._hist_spin.value()
|
||||
self._data[rid] = [deque(maxlen=n), deque(maxlen=n)]
|
||||
self._submit_render()
|
||||
|
||||
def _on_source_changed(self):
|
||||
self._rebuild_val_combo()
|
||||
self._submit_render()
|
||||
|
||||
def _on_history_changed(self, n: int):
|
||||
for rid in list(self._data):
|
||||
old_ts, old_v = self._data[rid]
|
||||
self._data[rid] = [deque(old_ts, maxlen=n), deque(old_v, maxlen=n)]
|
||||
self._submit_render()
|
||||
|
||||
# ── Model signals ─────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
self._known_sats[rid] = set()
|
||||
n = self._hist_spin.value()
|
||||
self._data[rid] = [deque(maxlen=n), deque(maxlen=n)]
|
||||
|
||||
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
|
||||
self._known_sats.pop(rid, None)
|
||||
self._data.pop(rid, None)
|
||||
self._rebuild_val_combo()
|
||||
self._submit_render()
|
||||
|
||||
def _on_sat_update(self, rid: str, sats: list):
|
||||
selected = set(self._selected_rids())
|
||||
if rid not in selected:
|
||||
return
|
||||
y_axis = self._y_cb.currentText()
|
||||
if y_axis == 'pDOP':
|
||||
return
|
||||
|
||||
# Update satellite combo
|
||||
new_labels = {s.sat_label() for s in sats}
|
||||
if not new_labels.issubset(self._known_sats.get(rid, set())):
|
||||
self._known_sats[rid] = self._known_sats.get(rid, set()) | new_labels
|
||||
self._rebuild_val_combo()
|
||||
|
||||
target = self._val_cb.currentText()
|
||||
if not target:
|
||||
return
|
||||
|
||||
ts_now = time.time()
|
||||
for sat in sats:
|
||||
if sat.sat_label() != target:
|
||||
continue
|
||||
val = {'C/N₀': sat.cno, 'Azimuth': sat.azimuth,
|
||||
'Elevation': sat.elevation}.get(y_axis)
|
||||
if val is not None:
|
||||
self._push_point(rid, ts_now, val)
|
||||
break
|
||||
|
||||
def _on_pdop_update(self, rid: str, pdop: float, ts: float):
|
||||
if self._y_cb.currentText() != 'pDOP':
|
||||
return
|
||||
if rid not in set(self._selected_rids()):
|
||||
return
|
||||
self._push_point(rid, ts, pdop)
|
||||
Reference in New Issue
Block a user