|
@@ -0,0 +1,690 @@
|
|
|
|
|
+"""PySide6 + PyQtGraph desktop GUI."""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+from typing import Any
|
|
|
|
|
+
|
|
|
|
|
+import pandas as pd
|
|
|
|
|
+
|
|
|
|
|
+from . import __version__
|
|
|
|
|
+from .constants import TYPE_DISPLAY_NAMES
|
|
|
|
|
+from .models import ParsedLog, PlotTraceSpec
|
|
|
|
|
+from .services import MLogService
|
|
|
|
|
+
|
|
|
|
|
+QT_IMPORT_ERROR: Exception | None = None
|
|
|
|
|
+
|
|
|
|
|
+try:
|
|
|
|
|
+ from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
|
|
|
|
|
+ from PySide6.QtGui import QAction, QColor, QBrush, QFont
|
|
|
|
|
+ from PySide6.QtWidgets import (
|
|
|
|
|
+ QApplication,
|
|
|
|
|
+ QFileDialog,
|
|
|
|
|
+ QHeaderView,
|
|
|
|
|
+ QLabel,
|
|
|
|
|
+ QMainWindow,
|
|
|
|
|
+ QMenu,
|
|
|
|
|
+ QMessageBox,
|
|
|
|
|
+ QPlainTextEdit,
|
|
|
|
|
+ QSplitter,
|
|
|
|
|
+ QTableView,
|
|
|
|
|
+ QToolBar,
|
|
|
|
|
+ QToolButton,
|
|
|
|
|
+ QTreeWidget,
|
|
|
|
|
+ QTreeWidgetItem,
|
|
|
|
|
+ QVBoxLayout,
|
|
|
|
|
+ QWidget,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ from .qt_plot_widget import ComparisonPlotWidget
|
|
|
|
|
+except Exception as exc: # pragma: no cover - depends on local Qt install
|
|
|
|
|
+ QT_IMPORT_ERROR = exc
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if QT_IMPORT_ERROR is None:
|
|
|
|
|
+ ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 20
|
|
|
|
|
+ ITEM_VALUE_ROLE = Qt.ItemDataRole.UserRole + 21
|
|
|
|
|
+ ITEM_PARENT_ROLE = Qt.ItemDataRole.UserRole + 22
|
|
|
|
|
+
|
|
|
|
|
+ class DataFrameTableModel(QAbstractTableModel):
|
|
|
|
|
+ """Lightweight table model for large pandas DataFrames."""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self, frame: pd.DataFrame | None = None) -> None:
|
|
|
|
|
+ super().__init__()
|
|
|
|
|
+ self._frame = frame if frame is not None else pd.DataFrame()
|
|
|
|
|
+
|
|
|
|
|
+ def set_frame(self, frame: pd.DataFrame) -> None:
|
|
|
|
|
+ self.beginResetModel()
|
|
|
|
|
+ self._frame = frame
|
|
|
|
|
+ self.endResetModel()
|
|
|
|
|
+
|
|
|
|
|
+ def clear(self) -> None:
|
|
|
|
|
+ self.set_frame(pd.DataFrame())
|
|
|
|
|
+
|
|
|
|
|
+ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
|
|
|
|
|
+ if parent.isValid():
|
|
|
|
|
+ return 0
|
|
|
|
|
+ return len(self._frame.index)
|
|
|
|
|
+
|
|
|
|
|
+ def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
|
|
|
|
|
+ if parent.isValid():
|
|
|
|
|
+ return 0
|
|
|
|
|
+ return len(self._frame.columns)
|
|
|
|
|
+
|
|
|
|
|
+ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
|
|
|
|
|
+ if not index.isValid():
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ value = self._frame.iat[index.row(), index.column()]
|
|
|
|
|
+
|
|
|
|
|
+ if role == Qt.ItemDataRole.DisplayRole:
|
|
|
|
|
+ if pd.isna(value):
|
|
|
|
|
+ return ""
|
|
|
|
|
+ return str(value)
|
|
|
|
|
+
|
|
|
|
|
+ if role == Qt.ItemDataRole.TextAlignmentRole:
|
|
|
|
|
+ if pd.api.types.is_number(value) and not pd.isna(value):
|
|
|
|
|
+ return int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
|
|
|
+ return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
|
|
|
|
+
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def headerData(
|
|
|
|
|
+ self,
|
|
|
|
|
+ section: int,
|
|
|
|
|
+ orientation: Qt.Orientation,
|
|
|
|
|
+ role: int = Qt.ItemDataRole.DisplayRole,
|
|
|
|
|
+ ) -> Any: # noqa: N802
|
|
|
|
|
+ if role != Qt.ItemDataRole.DisplayRole:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ if orientation == Qt.Orientation.Horizontal:
|
|
|
|
|
+ if 0 <= section < len(self._frame.columns):
|
|
|
|
|
+ return str(self._frame.columns[section])
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ if 0 <= section < len(self._frame.index):
|
|
|
|
|
+ return str(self._frame.index[section])
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ class QtMLogMainWindow(QMainWindow):
|
|
|
|
|
+ """Qt main window for the MLog parser and plot workflow."""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self) -> None:
|
|
|
|
|
+ super().__init__()
|
|
|
|
|
+ self.setWindowTitle(f"MLog Tool Qt v{__version__}")
|
|
|
|
|
+ self.resize(1440, 860)
|
|
|
|
|
+
|
|
|
|
|
+ self.service = MLogService()
|
|
|
|
|
+ self.parsed_log: ParsedLog | None = None
|
|
|
|
|
+ self.selected_log: Path | None = None
|
|
|
|
|
+ self.subplot_count = 2
|
|
|
|
|
+ self.subplot_traces: list[list[PlotTraceSpec]] = [[], []]
|
|
|
|
|
+ self._updating_tree = False
|
|
|
|
|
+ self.preview_model = DataFrameTableModel()
|
|
|
|
|
+
|
|
|
|
|
+ self._build_ui()
|
|
|
|
|
+
|
|
|
|
|
+ def _build_ui(self) -> None:
|
|
|
|
|
+ toolbar = QToolBar("Main")
|
|
|
|
|
+ toolbar.setMovable(False)
|
|
|
|
|
+ self.addToolBar(toolbar)
|
|
|
|
|
+
|
|
|
|
|
+ open_action = QAction("Open Log", self)
|
|
|
|
|
+ open_action.triggered.connect(self.open_log_file)
|
|
|
|
|
+ toolbar.addAction(open_action)
|
|
|
|
|
+
|
|
|
|
|
+ export_action = QAction("Export CSV", self)
|
|
|
|
|
+ export_action.triggered.connect(self.export_csv)
|
|
|
|
|
+ toolbar.addAction(export_action)
|
|
|
|
|
+
|
|
|
|
|
+ refresh_action = QAction("Refresh Plot", self)
|
|
|
|
|
+ refresh_action.triggered.connect(self.refresh_plot)
|
|
|
|
|
+ toolbar.addAction(refresh_action)
|
|
|
|
|
+
|
|
|
|
|
+ cursor_action = QAction("Cursor", self)
|
|
|
|
|
+ cursor_action.setCheckable(True)
|
|
|
|
|
+ cursor_action.setShortcut("C")
|
|
|
|
|
+ cursor_action.toggled.connect(self._toggle_cursor)
|
|
|
|
|
+ toolbar.addAction(cursor_action)
|
|
|
|
|
+
|
|
|
|
|
+ toolbar.addSeparator()
|
|
|
|
|
+
|
|
|
|
|
+ layout_menu = QMenu(self)
|
|
|
|
|
+ one_subplot_action = QAction("1 Subplot", self)
|
|
|
|
|
+ one_subplot_action.triggered.connect(lambda: self.set_subplot_count(1))
|
|
|
|
|
+ two_subplot_action = QAction("2 Subplots", self)
|
|
|
|
|
+ two_subplot_action.triggered.connect(lambda: self.set_subplot_count(2))
|
|
|
|
|
+ layout_menu.addAction(one_subplot_action)
|
|
|
|
|
+ layout_menu.addAction(two_subplot_action)
|
|
|
|
|
+
|
|
|
|
|
+ layout_button = QToolButton()
|
|
|
|
|
+ layout_button.setText("Layout")
|
|
|
|
|
+ layout_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
|
|
|
+ layout_button.setMenu(layout_menu)
|
|
|
|
|
+ toolbar.addWidget(layout_button)
|
|
|
|
|
+
|
|
|
|
|
+ toolbar.addSeparator()
|
|
|
|
|
+
|
|
|
|
|
+ clear_menu = QMenu(self)
|
|
|
|
|
+ clear_s1_action = QAction("Clear S1", self)
|
|
|
|
|
+ clear_s1_action.triggered.connect(lambda: self.clear_target_subplot(0))
|
|
|
|
|
+ clear_s2_action = QAction("Clear S2", self)
|
|
|
|
|
+ clear_s2_action.triggered.connect(lambda: self.clear_target_subplot(1))
|
|
|
|
|
+ clear_all_action = QAction("Clear All", self)
|
|
|
|
|
+ clear_all_action.triggered.connect(self.clear_all_traces)
|
|
|
|
|
+ clear_menu.addAction(clear_s1_action)
|
|
|
|
|
+ clear_menu.addAction(clear_s2_action)
|
|
|
|
|
+ clear_menu.addSeparator()
|
|
|
|
|
+ clear_menu.addAction(clear_all_action)
|
|
|
|
|
+
|
|
|
|
|
+ clear_button = QToolButton()
|
|
|
|
|
+ clear_button.setText("Clear")
|
|
|
|
|
+ clear_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
|
|
|
+ clear_button.setMenu(clear_menu)
|
|
|
|
|
+ toolbar.addWidget(clear_button)
|
|
|
|
|
+
|
|
|
|
|
+ central = QWidget()
|
|
|
|
|
+ self.setCentralWidget(central)
|
|
|
|
|
+ root_layout = QVBoxLayout(central)
|
|
|
|
|
+ root_layout.setContentsMargins(10, 10, 10, 10)
|
|
|
|
|
+
|
|
|
|
|
+ self.path_label = QLabel("No log selected.")
|
|
|
|
|
+ self.summary_label = QLabel("Select a log file to parse it automatically.")
|
|
|
|
|
+ root_layout.addWidget(self.path_label)
|
|
|
|
|
+ root_layout.addWidget(self.summary_label)
|
|
|
|
|
+
|
|
|
|
|
+ splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
|
|
|
+ root_layout.addWidget(splitter, 1)
|
|
|
|
|
+
|
|
|
|
|
+ self.signal_tree = QTreeWidget()
|
|
|
|
|
+ self.signal_tree.setColumnCount(3)
|
|
|
|
|
+ self.signal_tree.setHeaderLabels(["Items", "S1", "S2"])
|
|
|
|
|
+ self.signal_tree.setAlternatingRowColors(True)
|
|
|
|
|
+ self.signal_tree.setColumnWidth(0, 380)
|
|
|
|
|
+ self.signal_tree.setColumnWidth(1, 48)
|
|
|
|
|
+ self.signal_tree.setColumnWidth(2, 48)
|
|
|
|
|
+ self.signal_tree.itemSelectionChanged.connect(self.on_tree_selection_changed)
|
|
|
|
|
+ self.signal_tree.itemChanged.connect(self.on_tree_item_changed)
|
|
|
|
|
+ splitter.addWidget(self.signal_tree)
|
|
|
|
|
+
|
|
|
|
|
+ right_splitter = QSplitter(Qt.Orientation.Vertical)
|
|
|
|
|
+ splitter.addWidget(right_splitter)
|
|
|
|
|
+
|
|
|
|
|
+ self.plot_widget = ComparisonPlotWidget()
|
|
|
|
|
+ right_splitter.addWidget(self.plot_widget)
|
|
|
|
|
+
|
|
|
|
|
+ self.preview = QPlainTextEdit()
|
|
|
|
|
+ self.preview.setReadOnly(True)
|
|
|
|
|
+ self.preview.setMaximumHeight(96)
|
|
|
|
|
+
|
|
|
|
|
+ preview_container = QWidget()
|
|
|
|
|
+ preview_layout = QVBoxLayout(preview_container)
|
|
|
|
|
+ preview_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
+ preview_layout.addWidget(self.preview)
|
|
|
|
|
+
|
|
|
|
|
+ self.preview_table = QTableView()
|
|
|
|
|
+ self.preview_table.setModel(self.preview_model)
|
|
|
|
|
+ self.preview_table.setAlternatingRowColors(True)
|
|
|
|
|
+ self.preview_table.setShowGrid(True)
|
|
|
|
|
+ self.preview_table.setWordWrap(False)
|
|
|
|
|
+ self.preview_table.verticalHeader().setVisible(False)
|
|
|
|
|
+ self.preview_table.horizontalHeader().setStretchLastSection(False)
|
|
|
|
|
+ self.preview_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
|
|
|
|
+ self.preview_table.setSortingEnabled(False)
|
|
|
|
|
+ preview_layout.addWidget(self.preview_table, 1)
|
|
|
|
|
+
|
|
|
|
|
+ right_splitter.addWidget(preview_container)
|
|
|
|
|
+
|
|
|
|
|
+ splitter.setSizes([420, 980])
|
|
|
|
|
+ right_splitter.setSizes([620, 220])
|
|
|
|
|
+
|
|
|
|
|
+ def open_log_file(self) -> None:
|
|
|
|
|
+ selected, _ = QFileDialog.getOpenFileName(
|
|
|
|
|
+ self,
|
|
|
|
|
+ "Select MLog file",
|
|
|
|
|
+ "",
|
|
|
|
|
+ "MLog files (*.bin *.log);;All files (*.*)",
|
|
|
|
|
+ )
|
|
|
|
|
+ if not selected:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ self.selected_log = Path(selected)
|
|
|
|
|
+ self.path_label.setText(str(self.selected_log))
|
|
|
|
|
+ self.summary_label.setText("Log selected. Parsing...")
|
|
|
|
|
+ self.parse_log()
|
|
|
|
|
+
|
|
|
|
|
+ def parse_log(self) -> None:
|
|
|
|
|
+ if self.selected_log is None:
|
|
|
|
|
+ QMessageBox.information(self, "No file", "Select a log file first.")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.parsed_log = self.service.parse(self.selected_log)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ QMessageBox.critical(self, "Parse failed", str(exc))
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ self.subplot_traces = [[], []]
|
|
|
|
|
+ self._populate_tree()
|
|
|
|
|
+ self.summary_label.setText(
|
|
|
|
|
+ f"Parsed {len(self.parsed_log.header.buses)} bus definitions, "
|
|
|
|
|
+ f"{len(self.parsed_log.header.parameter_groups)} parameter groups, "
|
|
|
|
|
+ f"{len(self.parsed_log.buses)} buses with data."
|
|
|
|
|
+ )
|
|
|
|
|
+ self.refresh_plot()
|
|
|
|
|
+
|
|
|
|
|
+ first_bus = self._first_bus_item()
|
|
|
|
|
+ if first_bus is not None:
|
|
|
|
|
+ first_bus.setExpanded(True)
|
|
|
|
|
+ self.signal_tree.setCurrentItem(first_bus)
|
|
|
|
|
+ self.on_tree_selection_changed()
|
|
|
|
|
+ elif self.signal_tree.topLevelItemCount() > 0:
|
|
|
|
|
+ first_item = self.signal_tree.topLevelItem(0)
|
|
|
|
|
+ first_item.setExpanded(True)
|
|
|
|
|
+ self.signal_tree.setCurrentItem(first_item)
|
|
|
|
|
+ self.on_tree_selection_changed()
|
|
|
|
|
+
|
|
|
|
|
+ def export_csv(self) -> None:
|
|
|
|
|
+ if self.parsed_log is None:
|
|
|
|
|
+ QMessageBox.information(self, "No data", "Parse a log file first.")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ output_dir = QFileDialog.getExistingDirectory(self, "Select output folder")
|
|
|
|
|
+ if not output_dir:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ written = self.service.export_csv(self.parsed_log, output_dir)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ QMessageBox.critical(self, "Export failed", str(exc))
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ QMessageBox.information(self, "Export complete", f"Exported {len(written)} CSV files.")
|
|
|
|
|
+
|
|
|
|
|
+ def refresh_plot(self) -> None:
|
|
|
|
|
+ self.plot_widget.set_plot_data(self.parsed_log, self.subplot_traces, self.subplot_count)
|
|
|
|
|
+
|
|
|
|
|
+ def set_subplot_count(self, count: int) -> None:
|
|
|
|
|
+ self.subplot_count = 1 if count <= 1 else 2
|
|
|
|
|
+ self.refresh_plot()
|
|
|
|
|
+
|
|
|
|
|
+ def clear_target_subplot(self, subplot_index: int) -> None:
|
|
|
|
|
+ if subplot_index not in (0, 1):
|
|
|
|
|
+ return
|
|
|
|
|
+ self.subplot_traces[subplot_index] = []
|
|
|
|
|
+ self._refresh_tree_state()
|
|
|
|
|
+ self.refresh_plot()
|
|
|
|
|
+
|
|
|
|
|
+ def clear_all_traces(self) -> None:
|
|
|
|
|
+ self.subplot_traces = [[], []]
|
|
|
|
|
+ self._refresh_tree_state()
|
|
|
|
|
+ self.refresh_plot()
|
|
|
|
|
+
|
|
|
|
|
+ def _toggle_cursor(self, enabled: bool) -> None:
|
|
|
|
|
+ self.plot_widget.set_cursor_enabled(enabled)
|
|
|
|
|
+
|
|
|
|
|
+ def on_tree_selection_changed(self) -> None:
|
|
|
|
|
+ item = self.signal_tree.currentItem()
|
|
|
|
|
+ if item is None or self.parsed_log is None:
|
|
|
|
|
+ self.preview.setPlainText("")
|
|
|
|
|
+ self._clear_preview_table()
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ item_kind = item.data(0, ITEM_KIND_ROLE)
|
|
|
|
|
+ if item_kind in {"bus", "field"}:
|
|
|
|
|
+ bus_name = item.data(0, ITEM_VALUE_ROLE)
|
|
|
|
|
+ self._show_bus_preview(str(bus_name))
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ if item_kind == "param_root":
|
|
|
|
|
+ self._show_parameter_root_preview()
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ if item_kind == "bus_root":
|
|
|
|
|
+ self._show_bus_root_preview()
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ if item_kind in {"param_group", "param"}:
|
|
|
|
|
+ group_name = item.data(0, ITEM_VALUE_ROLE)
|
|
|
|
|
+ if item_kind == "param":
|
|
|
|
|
+ group_name = item.data(0, ITEM_PARENT_ROLE)
|
|
|
|
|
+ if group_name:
|
|
|
|
|
+ self._show_parameter_group_preview(str(group_name))
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ self.preview.setPlainText("")
|
|
|
|
|
+ self._clear_preview_table()
|
|
|
|
|
+
|
|
|
|
|
+ def on_tree_item_changed(self, item: QTreeWidgetItem, column: int) -> None:
|
|
|
|
|
+ if self._updating_tree or self.parsed_log is None:
|
|
|
|
|
+ return
|
|
|
|
|
+ if column not in (1, 2):
|
|
|
|
|
+ return
|
|
|
|
|
+ if item.childCount() > 0:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ if item.data(0, ITEM_KIND_ROLE) != "field":
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ bus_name = item.data(0, ITEM_VALUE_ROLE)
|
|
|
|
|
+ field_name = item.data(0, ITEM_PARENT_ROLE)
|
|
|
|
|
+ if not bus_name or not field_name:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ subplot_index = column - 1
|
|
|
|
|
+ if subplot_index == 1 and self.subplot_count == 1 and item.checkState(column) == Qt.CheckState.Checked:
|
|
|
|
|
+ self._updating_tree = True
|
|
|
|
|
+ item.setCheckState(column, Qt.CheckState.Unchecked)
|
|
|
|
|
+ self._updating_tree = False
|
|
|
|
|
+ QMessageBox.information(self, "Single subplot mode", "Switch to 2 Subplots before using S2.")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ enabled = item.checkState(column) == Qt.CheckState.Checked
|
|
|
|
|
+ self._set_trace_enabled(str(bus_name), str(field_name), subplot_index, enabled)
|
|
|
|
|
+ self._refresh_tree_state()
|
|
|
|
|
+ self.refresh_plot()
|
|
|
|
|
+
|
|
|
|
|
+ def _populate_tree(self) -> None:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ self._updating_tree = True
|
|
|
|
|
+ self.signal_tree.clear()
|
|
|
|
|
+
|
|
|
|
|
+ parameter_root = QTreeWidgetItem(
|
|
|
|
|
+ [f"Parameters ({len(self.parsed_log.header.parameter_groups)} groups)", "", ""]
|
|
|
|
|
+ )
|
|
|
|
|
+ parameter_root.setData(0, ITEM_KIND_ROLE, "param_root")
|
|
|
|
|
+ parameter_root.setData(0, ITEM_VALUE_ROLE, "__parameters__")
|
|
|
|
|
+ self.signal_tree.addTopLevelItem(parameter_root)
|
|
|
|
|
+
|
|
|
|
|
+ for group in self.parsed_log.header.parameter_groups:
|
|
|
|
|
+ group_item = QTreeWidgetItem([self._parameter_group_display_text(group.name), "", ""])
|
|
|
|
|
+ group_item.setData(0, ITEM_KIND_ROLE, "param_group")
|
|
|
|
|
+ group_item.setData(0, ITEM_VALUE_ROLE, group.name)
|
|
|
|
|
+ parameter_root.addChild(group_item)
|
|
|
|
|
+
|
|
|
|
|
+ bus_root = QTreeWidgetItem([f"Buses ({len(self.parsed_log.buses)} with data)", "", ""])
|
|
|
|
|
+ bus_root.setData(0, ITEM_KIND_ROLE, "bus_root")
|
|
|
|
|
+ bus_root.setData(0, ITEM_VALUE_ROLE, "__buses__")
|
|
|
|
|
+ self.signal_tree.addTopLevelItem(bus_root)
|
|
|
|
|
+
|
|
|
|
|
+ for bus_name in self.parsed_log.buses:
|
|
|
|
|
+ bus_item = QTreeWidgetItem([self._bus_display_text(bus_name), "", ""])
|
|
|
|
|
+ bus_item.setData(0, ITEM_KIND_ROLE, "bus")
|
|
|
|
|
+ bus_item.setData(0, ITEM_VALUE_ROLE, bus_name)
|
|
|
|
|
+ bus_root.addChild(bus_item)
|
|
|
|
|
+
|
|
|
|
|
+ for field_name in self._plottable_columns(bus_name):
|
|
|
|
|
+ field_item = QTreeWidgetItem([field_name, "", ""])
|
|
|
|
|
+ field_item.setFlags(
|
|
|
|
|
+ field_item.flags()
|
|
|
|
|
+ | Qt.ItemFlag.ItemIsUserCheckable
|
|
|
|
|
+ | Qt.ItemFlag.ItemIsSelectable
|
|
|
|
|
+ | Qt.ItemFlag.ItemIsEnabled
|
|
|
|
|
+ )
|
|
|
|
|
+ field_item.setData(0, ITEM_KIND_ROLE, "field")
|
|
|
|
|
+ field_item.setData(0, ITEM_VALUE_ROLE, bus_name)
|
|
|
|
|
+ field_item.setData(0, ITEM_PARENT_ROLE, field_name)
|
|
|
|
|
+ field_item.setCheckState(1, Qt.CheckState.Unchecked)
|
|
|
|
|
+ field_item.setCheckState(2, Qt.CheckState.Unchecked)
|
|
|
|
|
+ bus_item.addChild(field_item)
|
|
|
|
|
+
|
|
|
|
|
+ parameter_root.setExpanded(True)
|
|
|
|
|
+ bus_root.setExpanded(True)
|
|
|
|
|
+ self._updating_tree = False
|
|
|
|
|
+ self._refresh_tree_state()
|
|
|
|
|
+
|
|
|
|
|
+ def _refresh_tree_state(self) -> None:
|
|
|
|
|
+ self._updating_tree = True
|
|
|
|
|
+
|
|
|
|
|
+ for index in range(self.signal_tree.topLevelItemCount()):
|
|
|
|
|
+ root_item = self.signal_tree.topLevelItem(index)
|
|
|
|
|
+ self._refresh_bus_item_state(root_item)
|
|
|
|
|
+
|
|
|
|
|
+ self._updating_tree = False
|
|
|
|
|
+
|
|
|
|
|
+ def _refresh_bus_item_state(self, item: QTreeWidgetItem) -> None:
|
|
|
|
|
+ item_kind = item.data(0, ITEM_KIND_ROLE)
|
|
|
|
|
+ if item_kind == "bus":
|
|
|
|
|
+ bus_name = str(item.data(0, ITEM_VALUE_ROLE))
|
|
|
|
|
+ item.setText(0, self._bus_display_text(bus_name))
|
|
|
|
|
+
|
|
|
|
|
+ bus_font = item.font(0)
|
|
|
|
|
+ bus_font.setBold(self._bus_has_active_traces(bus_name))
|
|
|
|
|
+ item.setFont(0, bus_font)
|
|
|
|
|
+
|
|
|
|
|
+ for child_index in range(item.childCount()):
|
|
|
|
|
+ field_item = item.child(child_index)
|
|
|
|
|
+ field_name = str(field_item.data(0, ITEM_PARENT_ROLE))
|
|
|
|
|
+ in_s1 = self._trace_exists(bus_name, field_name, 0)
|
|
|
|
|
+ in_s2 = self._trace_exists(bus_name, field_name, 1)
|
|
|
|
|
+ field_item.setCheckState(1, Qt.CheckState.Checked if in_s1 else Qt.CheckState.Unchecked)
|
|
|
|
|
+ field_item.setCheckState(2, Qt.CheckState.Checked if in_s2 else Qt.CheckState.Unchecked)
|
|
|
|
|
+
|
|
|
|
|
+ color = None
|
|
|
|
|
+ if in_s1 and in_s2:
|
|
|
|
|
+ color = QColor("#f6ebff")
|
|
|
|
|
+ elif in_s1:
|
|
|
|
|
+ color = QColor("#e8f2ff")
|
|
|
|
|
+ elif in_s2:
|
|
|
|
|
+ color = QColor("#eef8e8")
|
|
|
|
|
+
|
|
|
|
|
+ for column in range(3):
|
|
|
|
|
+ field_item.setBackground(column, QBrush(color) if color else QBrush())
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ for child_index in range(item.childCount()):
|
|
|
|
|
+ self._refresh_bus_item_state(item.child(child_index))
|
|
|
|
|
+
|
|
|
|
|
+ def _first_bus_item(self) -> QTreeWidgetItem | None:
|
|
|
|
|
+ for index in range(self.signal_tree.topLevelItemCount()):
|
|
|
|
|
+ item = self.signal_tree.topLevelItem(index)
|
|
|
|
|
+ result = self._first_bus_item_from(item)
|
|
|
|
|
+ if result is not None:
|
|
|
|
|
+ return result
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def _first_bus_item_from(self, item: QTreeWidgetItem) -> QTreeWidgetItem | None:
|
|
|
|
|
+ if item.data(0, ITEM_KIND_ROLE) == "bus":
|
|
|
|
|
+ return item
|
|
|
|
|
+ for child_index in range(item.childCount()):
|
|
|
|
|
+ result = self._first_bus_item_from(item.child(child_index))
|
|
|
|
|
+ if result is not None:
|
|
|
|
|
+ return result
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def _plottable_columns(self, bus_name: str) -> list[str]:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ bus = self.parsed_log.get_bus(bus_name)
|
|
|
|
|
+ if bus is None:
|
|
|
|
|
+ return []
|
|
|
|
|
+ excluded = {bus.timestamp_field, "timestamp", "timestamp_ms"}
|
|
|
|
|
+ return [column for column in bus.frame.columns if column not in excluded]
|
|
|
|
|
+
|
|
|
|
|
+ def _trace_exists(self, bus_name: str, field_name: str, subplot_index: int) -> bool:
|
|
|
|
|
+ return any(
|
|
|
|
|
+ trace.bus_name == bus_name and trace.field_name == field_name
|
|
|
|
|
+ for trace in self.subplot_traces[subplot_index]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _set_trace_enabled(self, bus_name: str, field_name: str, subplot_index: int, enabled: bool) -> None:
|
|
|
|
|
+ traces = self.subplot_traces[subplot_index]
|
|
|
|
|
+ exists = self._trace_exists(bus_name, field_name, subplot_index)
|
|
|
|
|
+
|
|
|
|
|
+ if enabled and not exists:
|
|
|
|
|
+ traces.append(
|
|
|
|
|
+ PlotTraceSpec(
|
|
|
|
|
+ bus_name=bus_name,
|
|
|
|
|
+ field_name=field_name,
|
|
|
|
|
+ subplot_index=subplot_index,
|
|
|
|
|
+ label=f"{bus_name}.{field_name}",
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ elif not enabled and exists:
|
|
|
|
|
+ self.subplot_traces[subplot_index] = [
|
|
|
|
|
+ trace
|
|
|
|
|
+ for trace in traces
|
|
|
|
|
+ if not (trace.bus_name == bus_name and trace.field_name == field_name)
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ def _bus_has_active_traces(self, bus_name: str) -> bool:
|
|
|
|
|
+ return any(trace.bus_name == bus_name for traces in self.subplot_traces for trace in traces)
|
|
|
|
|
+
|
|
|
|
|
+ def _bus_display_text(self, bus_name: str) -> str:
|
|
|
|
|
+ field_count = len(self._plottable_columns(bus_name)) if self.parsed_log is not None else 0
|
|
|
|
|
+ s1_count = sum(1 for trace in self.subplot_traces[0] if trace.bus_name == bus_name)
|
|
|
|
|
+ s2_count = sum(1 for trace in self.subplot_traces[1] if trace.bus_name == bus_name)
|
|
|
|
|
+ return f"{bus_name} ({field_count} fields | S1:{s1_count} S2:{s2_count})"
|
|
|
|
|
+
|
|
|
|
|
+ def _parameter_group_display_text(self, group_name: str) -> str:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ group = self.parsed_log.get_parameter_group(group_name)
|
|
|
|
|
+ parameter_count = len(group.parameters) if group is not None else 0
|
|
|
|
|
+ return f"{group_name} ({parameter_count} params)"
|
|
|
|
|
+
|
|
|
|
|
+ def _format_bus_preview(self, bus_name: str) -> str:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ bus = self.parsed_log.get_bus(bus_name)
|
|
|
|
|
+ if bus is None:
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+ return "\n".join(
|
|
|
|
|
+ [
|
|
|
|
|
+ f"Bus: {bus_name}",
|
|
|
|
|
+ f"Rows: {len(bus.frame)}",
|
|
|
|
|
+ f"Columns: {len(bus.frame.columns)}",
|
|
|
|
|
+ f"Plot x-axis: {bus.timestamp_field or 'index'}",
|
|
|
|
|
+ "Preview: full table",
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _format_parameter_group_preview(self, group_name: str) -> str:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ group = self.parsed_log.get_parameter_group(group_name)
|
|
|
|
|
+ if group is None:
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+ return "\n".join(
|
|
|
|
|
+ [
|
|
|
|
|
+ f"Parameter Group: {group.name}",
|
|
|
|
|
+ f"Parameters: {len(group.parameters)}",
|
|
|
|
|
+ "Preview: full table",
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _format_parameter_root_preview(self) -> str:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ group_count = len(self.parsed_log.header.parameter_groups)
|
|
|
|
|
+ parameter_count = sum(len(group.parameters) for group in self.parsed_log.header.parameter_groups)
|
|
|
|
|
+ return "\n".join(
|
|
|
|
|
+ [
|
|
|
|
|
+ "Parameter Overview",
|
|
|
|
|
+ f"Groups: {group_count}",
|
|
|
|
|
+ f"Parameters: {parameter_count}",
|
|
|
|
|
+ "Preview: full table",
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _format_bus_root_preview(self) -> str:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ field_count = sum(len(bus.frame.columns) for bus in self.parsed_log.buses.values())
|
|
|
|
|
+ row_count = sum(len(bus.frame) for bus in self.parsed_log.buses.values())
|
|
|
|
|
+ return "\n".join(
|
|
|
|
|
+ [
|
|
|
|
|
+ "Bus Overview",
|
|
|
|
|
+ f"Buses with data: {len(self.parsed_log.buses)}",
|
|
|
|
|
+ f"Total rows: {row_count}",
|
|
|
|
|
+ f"Total columns: {field_count}",
|
|
|
|
|
+ "Preview: full table",
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _show_bus_preview(self, bus_name: str) -> None:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ bus = self.parsed_log.get_bus(bus_name)
|
|
|
|
|
+ if bus is None:
|
|
|
|
|
+ self.preview.setPlainText("")
|
|
|
|
|
+ self._clear_preview_table()
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ self.preview.setPlainText(self._format_bus_preview(bus_name))
|
|
|
|
|
+ self._set_preview_table(bus.frame)
|
|
|
|
|
+
|
|
|
|
|
+ def _show_parameter_group_preview(self, group_name: str) -> None:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ group = self.parsed_log.get_parameter_group(group_name)
|
|
|
|
|
+ if group is None:
|
|
|
|
|
+ self.preview.setPlainText("")
|
|
|
|
|
+ self._clear_preview_table()
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ rows = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "name": parameter.name,
|
|
|
|
|
+ "type": TYPE_DISPLAY_NAMES.get(parameter.type_id, str(parameter.type_id)),
|
|
|
|
|
+ "value": parameter.value,
|
|
|
|
|
+ }
|
|
|
|
|
+ for parameter in group.parameters
|
|
|
|
|
+ ]
|
|
|
|
|
+ preview_frame = pd.DataFrame(rows)
|
|
|
|
|
+ self.preview.setPlainText(self._format_parameter_group_preview(group_name))
|
|
|
|
|
+ self._set_preview_table(preview_frame)
|
|
|
|
|
+
|
|
|
|
|
+ def _show_parameter_root_preview(self) -> None:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ rows = [
|
|
|
|
|
+ {"group": group.name, "parameters": len(group.parameters)}
|
|
|
|
|
+ for group in self.parsed_log.header.parameter_groups
|
|
|
|
|
+ ]
|
|
|
|
|
+ preview_frame = pd.DataFrame(rows)
|
|
|
|
|
+ self.preview.setPlainText(self._format_parameter_root_preview())
|
|
|
|
|
+ self._set_preview_table(preview_frame)
|
|
|
|
|
+
|
|
|
|
|
+ def _show_bus_root_preview(self) -> None:
|
|
|
|
|
+ assert self.parsed_log is not None
|
|
|
|
|
+ rows = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "bus": bus_name,
|
|
|
|
|
+ "rows": len(bus.frame),
|
|
|
|
|
+ "columns": len(bus.frame.columns),
|
|
|
|
|
+ "x_axis": bus.timestamp_field or "index",
|
|
|
|
|
+ }
|
|
|
|
|
+ for bus_name, bus in self.parsed_log.buses.items()
|
|
|
|
|
+ ]
|
|
|
|
|
+ preview_frame = pd.DataFrame(rows)
|
|
|
|
|
+ self.preview.setPlainText(self._format_bus_root_preview())
|
|
|
|
|
+ self._set_preview_table(preview_frame)
|
|
|
|
|
+
|
|
|
|
|
+ def _clear_preview_table(self) -> None:
|
|
|
|
|
+ self.preview_model.clear()
|
|
|
|
|
+
|
|
|
|
|
+ def _set_preview_table(self, frame: pd.DataFrame) -> None:
|
|
|
|
|
+ self.preview_model.set_frame(frame)
|
|
|
|
|
+ header = self.preview_table.horizontalHeader()
|
|
|
|
|
+ if len(frame.columns) <= 12:
|
|
|
|
|
+ self.preview_table.resizeColumnsToContents()
|
|
|
|
|
+ else:
|
|
|
|
|
+ for column_index in range(min(len(frame.columns), 6)):
|
|
|
|
|
+ header.setSectionResizeMode(column_index, QHeaderView.ResizeMode.ResizeToContents)
|
|
|
|
|
+ self.preview_table.resizeColumnToContents(column_index)
|
|
|
|
|
+ header.setSectionResizeMode(column_index, QHeaderView.ResizeMode.Interactive)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main() -> None:
|
|
|
|
|
+ """Run the Qt GUI entry point."""
|
|
|
|
|
+
|
|
|
|
|
+ if QT_IMPORT_ERROR is not None:
|
|
|
|
|
+ raise SystemExit(
|
|
|
|
|
+ "PySide6 + PyQtGraph are required for the Qt GUI.\n"
|
|
|
|
|
+ "Install them with: uv pip install --python ./.venv/bin/python '.[qt]'\n"
|
|
|
|
|
+ f"Original import error: {QT_IMPORT_ERROR}"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ app = QApplication.instance() or QApplication([])
|
|
|
|
|
+ window = QtMLogMainWindow()
|
|
|
|
|
+ window.show()
|
|
|
|
|
+ app.exec()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main()
|