Эх сурвалжийг харах

Align log timestamps across buses

LiuYang 2 сар өмнө
parent
commit
3a749a8fa3

+ 43 - 13
src/mlog_tool/parser.py

@@ -168,34 +168,64 @@ class MLogParser:
         """Convert aggregated column buffers into DataFrames."""
 
         buses: dict[str, BusFrame] = {}
+        time_origin = self._find_time_origin(bus_definitions, layouts_by_id)
 
         for bus in bus_definitions:
             layout = layouts_by_id.get(bus.msg_id)
             if layout is None or not layout.column_data or not layout.column_data[0]:
                 continue
 
-            frame = pd.DataFrame(
-                {column_name: values for column_name, values in zip(layout.column_names, layout.column_data)},
-                copy=False,
-            )
             timestamp_field = layout.timestamp_field
+            relative_time_name: str | None = None
+            frame_data: dict[str, Any] = {}
 
-            if timestamp_field and timestamp_field in frame.columns:
+            if timestamp_field and timestamp_field in layout.column_names:
                 relative_time_name = "time_s"
-                if relative_time_name in frame.columns:
+                if relative_time_name in layout.column_names:
                     relative_time_name = "relative_time_s"
 
-                timestamp_series = pd.to_numeric(frame[timestamp_field], errors="coerce")
-                relative_time = (timestamp_series - timestamp_series.iloc[0]) * 0.001
-                insert_at = list(frame.columns).index(timestamp_field) + 1
-                frame.insert(insert_at, relative_time_name, relative_time)
-                buses[bus.name] = BusFrame(name=bus.name, frame=frame, timestamp_field=relative_time_name)
-                continue
+            for column_name, values in zip(layout.column_names, layout.column_data):
+                frame_data[column_name] = values
+                if column_name != timestamp_field or relative_time_name is None:
+                    continue
+
+                timestamp_series = pd.to_numeric(pd.Series(values, copy=False), errors="coerce")
+                frame_data[relative_time_name] = (
+                    (timestamp_series - time_origin) * 0.001
+                ).to_numpy(copy=False)
 
-            buses[bus.name] = BusFrame(name=bus.name, frame=frame, timestamp_field=None)
+            frame = pd.DataFrame(frame_data, copy=False)
+            buses[bus.name] = BusFrame(name=bus.name, frame=frame, timestamp_field=relative_time_name)
 
         return buses
 
+    def _find_time_origin(
+        self,
+        bus_definitions: list[BusDefinition],
+        layouts_by_id: dict[int, _BusLayout],
+    ) -> float:
+        """Return the earliest timestamp observed across all timestamped buses."""
+
+        origins: list[float] = []
+        for bus in bus_definitions:
+            layout = layouts_by_id.get(bus.msg_id)
+            if layout is None or layout.timestamp_field is None:
+                continue
+            try:
+                timestamp_index = layout.column_names.index(layout.timestamp_field)
+            except ValueError:
+                continue
+
+            values = layout.column_data[timestamp_index]
+            if not values:
+                continue
+
+            timestamp_series = pd.to_numeric(pd.Series(values, copy=False), errors="coerce")
+            if timestamp_series.notna().any():
+                origins.append(float(timestamp_series.min(skipna=True)))
+
+        return min(origins) if origins else 0.0
+
     def _prepare_bus_layouts(self, bus_definitions: list[BusDefinition]) -> dict[int, _BusLayout]:
         """Precompute column layouts to avoid per-message schema work."""
 

+ 28 - 5
src/mlog_tool/plotting.py

@@ -12,6 +12,8 @@ from .models import ParsedLog, PlotTraceSpec
 class LogPlotter:
     """Create quick-look plots for selected bus fields."""
 
+    MAX_SUBPLOTS = 4
+
     def _resolve_trace(self, parsed_log: ParsedLog, trace: PlotTraceSpec) -> tuple[object, str, object, str]:
         """Resolve one trace spec into x data, x label, y data, and legend label."""
 
@@ -48,12 +50,10 @@ class LogPlotter:
         subplot_traces: list[list[PlotTraceSpec]],
         subplot_count: int = 2,
     ) -> Figure:
-        """Build a figure with one or two subplots that may mix different buses."""
+        """Build a figure with up to four subplots that may mix different buses."""
 
-        plot_count = 1 if subplot_count <= 1 else 2
-        figure = Figure(figsize=(9, 4.6) if plot_count == 1 else (12, 4.8), dpi=100)
-        axes = figure.subplots(1, plot_count, sharex=True, squeeze=False)
-        axis_list = list(axes[0, :])
+        plot_count = max(1, min(subplot_count, self.MAX_SUBPLOTS))
+        figure, axis_list = self._create_axes(plot_count)
         cursor_payload: list[dict[str, object]] = []
 
         for subplot_index in range(plot_count):
@@ -102,6 +102,29 @@ class LogPlotter:
         figure._mlog_cursor_payload = cursor_payload
         return figure
 
+    def _create_axes(self, plot_count: int) -> tuple[Figure, list[object]]:
+        if plot_count == 1:
+            figure = Figure(figsize=(9, 4.6), dpi=100)
+            grid = figure.add_gridspec(1, 1)
+            return figure, [figure.add_subplot(grid[0, 0])]
+
+        if plot_count == 2:
+            figure = Figure(figsize=(12, 4.8), dpi=100)
+            grid = figure.add_gridspec(1, 2)
+            first = figure.add_subplot(grid[0, 0])
+            second = figure.add_subplot(grid[0, 1], sharex=first)
+            return figure, [first, second]
+
+        figure = Figure(figsize=(12, 8), dpi=100)
+        grid = figure.add_gridspec(2, 2)
+        first = figure.add_subplot(grid[0, 0])
+        second = figure.add_subplot(grid[0, 1], sharex=first)
+        third = figure.add_subplot(grid[1, :] if plot_count == 3 else grid[1, 0], sharex=first)
+        axes = [first, second, third]
+        if plot_count == 4:
+            axes.append(figure.add_subplot(grid[1, 1], sharex=first))
+        return figure, axes
+
     def plot_fields(self, parsed_log: ParsedLog, bus_name: str, fields: list[str]) -> None:
         """Show a standalone interactive plot window."""
 

+ 69 - 47
src/mlog_tool/qt_gui.py

@@ -42,6 +42,8 @@ except Exception as exc:  # pragma: no cover - depends on local Qt install
 
 
 if QT_IMPORT_ERROR is None:
+    MAX_SUBPLOTS = 4
+    TREE_COLUMN_COUNT = MAX_SUBPLOTS + 1
     ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 20
     ITEM_VALUE_ROLE = Qt.ItemDataRole.UserRole + 21
     ITEM_PARENT_ROLE = Qt.ItemDataRole.UserRole + 22
@@ -138,7 +140,7 @@ if QT_IMPORT_ERROR is None:
             self.parsed_log: ParsedLog | None = None
             self.selected_log: Path | None = None
             self.subplot_count = 2
-            self.subplot_traces: list[list[PlotTraceSpec]] = [[], []]
+            self.subplot_traces: list[list[PlotTraceSpec]] = self._empty_subplot_traces()
             self._updating_tree = False
             self._parse_thread: ParseThread | None = None
             self.preview_model = DataFrameTableModel()
@@ -172,12 +174,11 @@ if QT_IMPORT_ERROR is None:
             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)
+            for count in range(1, MAX_SUBPLOTS + 1):
+                label = f"{count} Subplot" if count == 1 else f"{count} Subplots"
+                action = QAction(label, self)
+                action.triggered.connect(lambda _checked=False, value=count: self.set_subplot_count(value))
+                layout_menu.addAction(action)
 
             layout_button = QToolButton()
             layout_button.setText("Layout")
@@ -188,14 +189,14 @@ if QT_IMPORT_ERROR is None:
             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))
+            for subplot_index in range(MAX_SUBPLOTS):
+                action = QAction(f"Clear S{subplot_index + 1}", self)
+                action.triggered.connect(
+                    lambda _checked=False, value=subplot_index: self.clear_target_subplot(value)
+                )
+                clear_menu.addAction(action)
             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)
 
@@ -219,12 +220,12 @@ if QT_IMPORT_ERROR is None:
             root_layout.addWidget(splitter, 1)
 
             self.signal_tree = QTreeWidget()
-            self.signal_tree.setColumnCount(3)
-            self.signal_tree.setHeaderLabels(["Items", "S1", "S2"])
+            self.signal_tree.setColumnCount(TREE_COLUMN_COUNT)
+            self.signal_tree.setHeaderLabels(["Items", "S1", "S2", "S3", "S4"])
             self.signal_tree.setAlternatingRowColors(True)
             self.signal_tree.setColumnWidth(0, 380)
-            self.signal_tree.setColumnWidth(1, 48)
-            self.signal_tree.setColumnWidth(2, 48)
+            for column in range(1, TREE_COLUMN_COUNT):
+                self.signal_tree.setColumnWidth(column, 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)
@@ -295,7 +296,7 @@ if QT_IMPORT_ERROR is None:
 
         def _on_parse_succeeded(self, parsed_log: ParsedLog) -> None:
             self.parsed_log = parsed_log
-            self.subplot_traces = [[], []]
+            self.subplot_traces = self._empty_subplot_traces()
             self._populate_tree()
             self.summary_label.setText(
                 f"Parsed {len(self.parsed_log.header.buses)} bus definitions, "
@@ -344,24 +345,25 @@ if QT_IMPORT_ERROR is 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.subplot_count = max(1, min(count, MAX_SUBPLOTS))
+            self._refresh_tree_state()
             self.refresh_plot()
 
         def clear_target_subplot(self, subplot_index: int) -> None:
-            if subplot_index not in (0, 1):
+            if not 0 <= subplot_index < MAX_SUBPLOTS:
                 return
             self.subplot_traces[subplot_index] = []
             self._refresh_tree_state()
             self.refresh_plot()
 
         def clear_all_traces(self) -> None:
-            self.subplot_traces = [[], []]
+            self.subplot_traces = self._empty_subplot_traces()
             self._refresh_tree_state()
             self.refresh_plot()
 
         def _clear_loaded_log(self) -> None:
             self.parsed_log = None
-            self.subplot_traces = [[], []]
+            self.subplot_traces = self._empty_subplot_traces()
             self.signal_tree.clear()
             self.preview.setPlainText("")
             self._clear_preview_table()
@@ -415,7 +417,7 @@ if QT_IMPORT_ERROR is None:
         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):
+            if not 1 <= column < TREE_COLUMN_COUNT:
                 return
             if item.childCount() > 0:
                 return
@@ -429,11 +431,15 @@ if QT_IMPORT_ERROR is None:
                 return
 
             subplot_index = column - 1
-            if subplot_index == 1 and self.subplot_count == 1 and item.checkState(column) == Qt.CheckState.Checked:
+            if subplot_index >= self.subplot_count 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.")
+                QMessageBox.information(
+                    self,
+                    "Inactive subplot",
+                    f"Switch to at least {subplot_index + 1} Subplots before using S{subplot_index + 1}.",
+                )
                 return
 
             enabled = item.checkState(column) == Qt.CheckState.Checked
@@ -447,31 +453,31 @@ if QT_IMPORT_ERROR is None:
             self.signal_tree.clear()
 
             parameter_root = QTreeWidgetItem(
-                [f"Parameters ({len(self.parsed_log.header.parameter_groups)} groups)", "", ""]
+                self._tree_row(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 = QTreeWidgetItem(self._tree_row(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 = QTreeWidgetItem(self._tree_row(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 = QTreeWidgetItem(self._tree_row(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 = QTreeWidgetItem(self._tree_row(field_name))
                     field_item.setFlags(
                         field_item.flags()
                         | Qt.ItemFlag.ItemIsUserCheckable
@@ -481,8 +487,8 @@ if QT_IMPORT_ERROR is None:
                     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)
+                    for column in range(1, TREE_COLUMN_COUNT):
+                        field_item.setCheckState(column, Qt.CheckState.Unchecked)
                     bus_item.addChild(field_item)
 
             parameter_root.setExpanded(True)
@@ -512,20 +518,24 @@ if QT_IMPORT_ERROR is None:
                 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)
+                    active_subplots: list[int] = []
+                    for subplot_index in range(MAX_SUBPLOTS):
+                        enabled = self._trace_exists(bus_name, field_name, subplot_index)
+                        if enabled:
+                            active_subplots.append(subplot_index)
+                        field_item.setCheckState(
+                            subplot_index + 1,
+                            Qt.CheckState.Checked if enabled 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):
+                    if len(active_subplots) > 1:
+                        color = QColor("#f3ecff")
+                    elif len(active_subplots) == 1:
+                        subplot_colors = ["#e8f2ff", "#eef8e8", "#fff2db", "#fde8ef"]
+                        color = QColor(subplot_colors[active_subplots[0]])
+
+                    for column in range(TREE_COLUMN_COUNT):
                         field_item.setBackground(column, QBrush(color) if color else QBrush())
                 return
 
@@ -558,12 +568,16 @@ if QT_IMPORT_ERROR is None:
             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:
+            if not 0 <= subplot_index < len(self.subplot_traces):
+                return False
             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:
+            if not 0 <= subplot_index < len(self.subplot_traces):
+                return
             traces = self.subplot_traces[subplot_index]
             exists = self._trace_exists(bus_name, field_name, subplot_index)
 
@@ -588,9 +602,17 @@ if QT_IMPORT_ERROR is None:
 
         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})"
+            subplot_counts = " ".join(
+                f"S{subplot_index + 1}:{sum(1 for trace in traces if trace.bus_name == bus_name)}"
+                for subplot_index, traces in enumerate(self.subplot_traces)
+            )
+            return f"{bus_name} ({field_count} fields | {subplot_counts})"
+
+        def _empty_subplot_traces(self) -> list[list[PlotTraceSpec]]:
+            return [[] for _ in range(MAX_SUBPLOTS)]
+
+        def _tree_row(self, first_column: str) -> list[str]:
+            return [first_column, *[""] * MAX_SUBPLOTS]
 
         def _parameter_group_display_text(self, group_name: str) -> str:
             assert self.parsed_log is not None

+ 23 - 15
src/mlog_tool/qt_plot_widget.py

@@ -13,7 +13,9 @@ from .models import ParsedLog, PlotTraceSpec
 
 
 class ComparisonPlotWidget(QWidget):
-    """A simple one- or two-subplot plotting area built on PyQtGraph."""
+    """A simple plotting area that supports up to four subplots."""
+
+    MAX_SUBPLOTS = 4
 
     COLORS = [
         "#1f77b4",
@@ -50,7 +52,7 @@ class ComparisonPlotWidget(QWidget):
         subplot_traces: list[list[PlotTraceSpec]],
         subplot_count: int,
     ) -> None:
-        """Render one or two subplots from the current trace selection."""
+        """Render up to four subplots from the current trace selection."""
 
         self.graphics.clear()
         self.plots = []
@@ -59,20 +61,17 @@ class ComparisonPlotWidget(QWidget):
         if parsed_log is None:
             return
 
-        visible_count = 1 if subplot_count <= 1 else 2
+        visible_count = max(1, min(subplot_count, self.MAX_SUBPLOTS))
         self.cursor_traces = [[] for _ in range(visible_count)]
-        if visible_count == 1:
-            self.graphics.ci.layout.setColumnStretchFactor(0, 1)
-            self.graphics.ci.layout.setColumnStretchFactor(1, 0)
-        else:
-            self.graphics.ci.layout.setColumnStretchFactor(0, 1)
-            self.graphics.ci.layout.setColumnStretchFactor(1, 1)
+        self.graphics.ci.layout.setColumnStretchFactor(0, 1)
+        self.graphics.ci.layout.setColumnStretchFactor(1, 1 if visible_count > 1 else 0)
+        self.graphics.ci.layout.setRowStretchFactor(0, 1)
+        self.graphics.ci.layout.setRowStretchFactor(1, 1 if visible_count > 2 else 0)
 
-        for subplot_index in range(visible_count):
-            column_span = 2 if visible_count == 1 else 1
+        for subplot_index, (row, col, column_span) in enumerate(self._subplot_positions(visible_count)):
             plot = self.graphics.addPlot(
-                row=0,
-                col=subplot_index,
+                row=row,
+                col=col,
                 colspan=column_span,
                 title=f"Subplot {subplot_index + 1}",
             )
@@ -83,8 +82,8 @@ class ComparisonPlotWidget(QWidget):
             plot.getAxis("left").setWidth(64)
             self.plots.append(plot)
 
-        if len(self.plots) == 2:
-            self.plots[1].setXLink(self.plots[0])
+        for plot in self.plots[1:]:
+            plot.setXLink(self.plots[0])
 
         for subplot_index, plot in enumerate(self.plots):
             traces = subplot_traces[subplot_index] if subplot_index < len(subplot_traces) else []
@@ -92,6 +91,15 @@ class ComparisonPlotWidget(QWidget):
 
         self._set_cursor_enabled(self.cursor_enabled)
 
+    def _subplot_positions(self, visible_count: int) -> list[tuple[int, int, int]]:
+        if visible_count == 1:
+            return [(0, 0, 2)]
+        if visible_count == 2:
+            return [(0, 0, 1), (0, 1, 1)]
+        if visible_count == 3:
+            return [(0, 0, 1), (0, 1, 1), (1, 0, 2)]
+        return [(0, 0, 1), (0, 1, 1), (1, 0, 1), (1, 1, 1)]
+
     def set_cursor_enabled(self, enabled: bool) -> None:
         """Toggle the shared vertical cursor."""
 

+ 166 - 0
tests/test_parser.py

@@ -1,4 +1,5 @@
 import struct
+import warnings
 from pathlib import Path
 
 import pandas as pd
@@ -51,6 +52,82 @@ def _write_synthetic_log(path: Path, message_count: int = 2048) -> None:
     path.write_bytes(payload)
 
 
+def _write_wide_synthetic_log(path: Path, field_count: int = 128, message_count: int = 32) -> None:
+    max_name_len = 16
+    max_desc_len = 24
+    max_model_info_len = 24
+
+    payload = bytearray()
+    payload.extend(struct.pack("<H I H H H", 1, 123456789, max_name_len, max_desc_len, max_model_info_len))
+    payload.extend(_pack_fixed_string("wide", max_desc_len))
+    payload.extend(_pack_fixed_string("unit-test", max_model_info_len))
+
+    payload.extend(struct.pack("<B", 1))
+    payload.extend(_pack_fixed_string("WIDE", max_name_len))
+    payload.extend(struct.pack("<B B", 9, field_count + 1))
+
+    payload.extend(_pack_fixed_string("timestamp_ms", max_name_len))
+    payload.extend(struct.pack("<H H", 5, 1))
+
+    for index in range(field_count):
+        payload.extend(_pack_fixed_string(f"f{index}", max_name_len))
+        payload.extend(struct.pack("<H H", 6, 1))
+
+    payload.extend(struct.pack("<B", 0))
+
+    for row_index in range(message_count):
+        payload.extend(bytes([MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, 9]))
+        payload.extend(struct.pack("<I", row_index * 20))
+        for field_index in range(field_count):
+            payload.extend(struct.pack("<f", row_index + field_index / 10))
+        payload.extend(bytes([MLOG_END_MSG]))
+
+    path.write_bytes(payload)
+
+
+def _write_offset_bus_log(path: Path) -> None:
+    max_name_len = 16
+    max_desc_len = 24
+    max_model_info_len = 24
+
+    payload = bytearray()
+    payload.extend(struct.pack("<H I H H H", 1, 123456789, max_name_len, max_desc_len, max_model_info_len))
+    payload.extend(_pack_fixed_string("offset", max_desc_len))
+    payload.extend(_pack_fixed_string("unit-test", max_model_info_len))
+
+    payload.extend(struct.pack("<B", 2))
+
+    payload.extend(_pack_fixed_string("IMU0", max_name_len))
+    payload.extend(struct.pack("<B B", 7, 2))
+    payload.extend(_pack_fixed_string("timestamp_ms", max_name_len))
+    payload.extend(struct.pack("<H H", 5, 1))
+    payload.extend(_pack_fixed_string("ax", max_name_len))
+    payload.extend(struct.pack("<H H", 6, 1))
+
+    payload.extend(_pack_fixed_string("GPS", max_name_len))
+    payload.extend(struct.pack("<B B", 8, 2))
+    payload.extend(_pack_fixed_string("timestamp_ms", max_name_len))
+    payload.extend(struct.pack("<H H", 5, 1))
+    payload.extend(_pack_fixed_string("ve", max_name_len))
+    payload.extend(struct.pack("<H H", 6, 1))
+
+    payload.extend(struct.pack("<B", 0))
+
+    for timestamp_ms, value in [(1000, 1.0), (1010, 2.0)]:
+        payload.extend(bytes([MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, 7]))
+        payload.extend(struct.pack("<I", timestamp_ms))
+        payload.extend(struct.pack("<f", value))
+        payload.extend(bytes([MLOG_END_MSG]))
+
+    for timestamp_ms, value in [(1300, 3.0), (1310, 4.0)]:
+        payload.extend(bytes([MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, 8]))
+        payload.extend(struct.pack("<I", timestamp_ms))
+        payload.extend(struct.pack("<f", value))
+        payload.extend(bytes([MLOG_END_MSG]))
+
+    path.write_bytes(payload)
+
+
 def test_parser_type_exists() -> None:
     parser = MLogParser()
     assert parser is not None
@@ -148,6 +225,61 @@ def test_plotter_can_compare_traces_across_subplots() -> None:
     assert len(figure._mlog_cursor_payload[1]["traces"]) == 1
 
 
+def test_plotter_can_build_four_subplot_grid() -> None:
+    header = LogHeader(
+        version=1,
+        timestamp=0,
+        max_name_len=16,
+        max_desc_len=16,
+        max_model_info_len=16,
+        description="demo",
+        model_info="demo",
+    )
+    frame = pd.DataFrame(
+        {
+            "time_s": [0.0, 0.1, 0.2],
+            "ax": [1.0, 2.0, 3.0],
+            "ay": [4.0, 5.0, 6.0],
+            "az": [7.0, 8.0, 9.0],
+            "gx": [0.1, 0.2, 0.3],
+        }
+    )
+    parsed_log = ParsedLog(
+        source_path=Path("demo.bin"),
+        header=header,
+        buses={"IMU0": BusFrame(name="IMU0", frame=frame, timestamp_field="time_s")},
+    )
+
+    figure = LogPlotter().build_comparison_figure(
+        parsed_log,
+        [
+            [PlotTraceSpec(bus_name="IMU0", field_name="ax", subplot_index=0, label="IMU0.ax")],
+            [PlotTraceSpec(bus_name="IMU0", field_name="ay", subplot_index=1, label="IMU0.ay")],
+            [PlotTraceSpec(bus_name="IMU0", field_name="az", subplot_index=2, label="IMU0.az")],
+            [PlotTraceSpec(bus_name="IMU0", field_name="gx", subplot_index=3, label="IMU0.gx")],
+        ],
+        subplot_count=4,
+    )
+
+    assert len(figure.axes) == 4
+    assert [axis.get_title() for axis in figure.axes] == [
+        "Subplot 1",
+        "Subplot 2",
+        "Subplot 3",
+        "Subplot 4",
+    ]
+    assert figure.axes[0].get_subplotspec().rowspan.start == 0
+    assert figure.axes[1].get_subplotspec().rowspan.start == 0
+    assert figure.axes[2].get_subplotspec().rowspan.start == 1
+    assert figure.axes[3].get_subplotspec().rowspan.start == 1
+    assert figure.axes[0].get_subplotspec().colspan.start == 0
+    assert figure.axes[1].get_subplotspec().colspan.start == 1
+    assert figure.axes[2].get_subplotspec().colspan.start == 0
+    assert figure.axes[3].get_subplotspec().colspan.start == 1
+    assert figure.axes[0].get_shared_x_axes().joined(figure.axes[0], figure.axes[3])
+    assert len(figure._mlog_cursor_payload) == 4
+
+
 def test_parser_handles_many_messages_without_losing_column_shape(tmp_path: Path) -> None:
     parser = MLogParser()
     log_path = tmp_path / "synthetic_mlog.bin"
@@ -163,3 +295,37 @@ def test_parser_handles_many_messages_without_losing_column_shape(tmp_path: Path
     assert imu.frame.iloc[0]["time_s"] == 0.0
     assert imu.frame.iloc[-1]["timestamp_ms"] == 40950
     assert imu.frame.iloc[-1]["gyro_2"] == pytest.approx(4095.3)
+
+
+def test_parser_aligns_relative_time_to_global_first_timestamp(tmp_path: Path) -> None:
+    parser = MLogParser()
+    log_path = tmp_path / "offset_bus_mlog.bin"
+    _write_offset_bus_log(log_path)
+
+    parsed_log = parser.parse(log_path)
+
+    imu = parsed_log.get_bus("IMU0")
+    gps = parsed_log.get_bus("GPS")
+    assert imu is not None
+    assert gps is not None
+    assert imu.timestamp_field == "time_s"
+    assert gps.timestamp_field == "time_s"
+    assert list(imu.frame["time_s"]) == pytest.approx([0.0, 0.01])
+    assert list(gps.frame["time_s"]) == pytest.approx([0.3, 0.31])
+
+
+def test_parser_avoids_fragmentation_warning_when_adding_relative_time(tmp_path: Path) -> None:
+    parser = MLogParser()
+    log_path = tmp_path / "wide_synthetic_mlog.bin"
+    _write_wide_synthetic_log(log_path, field_count=128, message_count=64)
+
+    with warnings.catch_warnings():
+        warnings.simplefilter("error", pd.errors.PerformanceWarning)
+        parsed_log = parser.parse(log_path)
+
+    wide_bus = parsed_log.get_bus("WIDE")
+    assert wide_bus is not None
+    assert wide_bus.timestamp_field == "time_s"
+    assert list(wide_bus.frame.columns[:3]) == ["timestamp_ms", "time_s", "f0"]
+    assert len(wide_bus.frame.columns) == 130
+    assert wide_bus.frame.iloc[-1]["time_s"] == pytest.approx(1.26)