"""pyqtgraph PlotWidget wrapper for embedding in QML. Exposes a QWidget with a pyqtgraph PlotWidget that can be displayed via QML's `import QtWidgets` or `QtWidgets.QWidget` integration. """ from __future__ import annotations import logging from typing import Any, Optional import pyqtgraph as pg from pyqtgraph import PlotWidget from PySide6.QtCore import Property, QObject, Signal, Slot from PySide6.QtWidgets import QWidget log = logging.getLogger(__name__) class GraphView(QObject): """QML-exposed controller for a pyqtgraph line chart. Accepts sensor temperature data (list of lists) and renders each sensor as a separate line series. """ # ---- signals ---- dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list} def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent) self._plot_widget: Optional[PlotWidget] = None self._plot_window: Optional[QWidget] = None self._series: list[Any] = [] self._sensor_names: list[str] = [] @Property(object, notify=dataReady) def plotWidget(self) -> Any: """Return the QWidget hosting the pyqtgraph PlotWidget for QML embedding.""" return self._plot_window @Slot() def createPlotWidget(self, parent_widget: Optional[QWidget] = None) -> None: """Create and return a QWidget containing a pyqtgraph PlotWidget. Args: parent_widget: Optional parent widget. """ pg.setConfigOption("background", "default") pg.setConfigOption("foreground", "default") self._plot_window = QWidget(parent=parent_widget) self._plot_widget = PlotWidget() self._plot_widget.setBackground("default") from PySide6.QtWidgets import QVBoxLayout layout = QVBoxLayout(self._plot_window) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self._plot_widget) # Set axis labels self._plot_widget.setLabel("left", "Temperature", units="°C") self._plot_widget.setLabel("bottom", "Measurement Interval") self._plot_widget.setTitle("Sensor Temperature Over Time") @Slot(str, str) def updateChart(self, sensor_names_str: str, series_data_str: str) -> None: """Update the chart with sensor data. Args: sensor_names_str: Comma-separated sensor names (e.g. "Sensor1,Sensor2"). series_data_str: JSON-like string of nested lists for each sensor's values. """ import json if not self._plot_widget: log.warning("PlotWidget not created yet") return try: sensor_names = [s.strip() for s in sensor_names_str.split(",") if s.strip()] series_data = json.loads(series_data_str) except (json.JSONDecodeError, AttributeError) as exc: log.error("Failed to parse chart data: %s", exc) return # Clear existing series self._plot_widget.clear() self._series = [] if not series_data: return # Determine Y-axis range from all data all_values = [] for sensor_values in series_data: for v in sensor_values: try: all_values.append(float(v)) except (ValueError, TypeError): pass if all_values: y_min = min(all_values) y_max = max(all_values) y_range = y_max - y_min buffer = max(y_range * 0.1, 1.0) # At least 1 degree buffer self._plot_widget.setYRange(y_min - buffer, y_max + buffer) else: self._plot_widget.setYRange(-50, 150) # X-axis: measurement intervals (0-based index) num_points = len(series_data[0]) if series_data else 0 x_axis = list(range(num_points)) # Define a set of distinct colors for series colors = [ (255, 87, 87), # Red (66, 165, 245), # Blue (102, 187, 106), # Green (255, 167, 38), # Orange (171, 71, 188), # Purple (0, 188, 212), # Cyan (255, 112, 67), # Deep Orange (121, 85, 72), # Brown (92, 107, 192), # Indigo (48, 125, 117), # Teal ] # Add each sensor as a line series for i, sensor_name in enumerate(sensor_names): if i >= len(series_data): break sensor_values = series_data[i] y_values = [] for v in sensor_values: try: y_values.append(float(v)) except (ValueError, TypeError): y_values.append(0.0) color = colors[i % len(colors)] pen = pg.mkPen(color=color, width=1) curve = self._plot_widget.plot(x_axis, y_values, name=sensor_name, pen=pen) self._series.append(curve) self._sensor_names = sensor_names @Slot() def resetChart(self) -> None: """Clear the chart.""" if self._plot_widget: self._plot_widget.clear() self._series = [] self._sensor_names = [] @Slot() def destroyPlotWidget(self) -> None: """Destroy the plot widget.""" if self._plot_window: self._plot_window.deleteLater() self._plot_window = None self._plot_widget = None self._series = []