0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 03:31:50 +02:00

feat: BECFigure and BECPlotBase created

This commit is contained in:
wyzula-jan
2024-02-14 17:56:07 +01:00
parent 4a1792c209
commit 9ef331c272
4 changed files with 435 additions and 0 deletions

View File

@ -0,0 +1 @@
from .figure import FigureConfig, BECFigure

View File

@ -0,0 +1,245 @@
# pylint: disable = no-name-in-module,missing-module-docstring
import itertools
import os
from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
from PyQt6.QtWidgets import QVBoxLayout, QMainWindow
from pydantic import Field
from pyqtgraph.Qt import uic
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils import BECDispatcher, BECConnector, ConnectionConfig
from bec_widgets.widgets.plots import WidgetConfig, BECPlotBase
class FigureConfig(ConnectionConfig):
"""Configuration for BECFigure. Inheriting from ConnectionConfig widget_class and gui_id"""
theme: Literal["dark", "light"] = Field("dark", description="The theme of the figure widget.")
num_columns: int = Field(1, description="The number of columns in the figure widget.")
widgets: dict[str, WidgetConfig] = Field(
{}, description="The list of widgets to be added to the figure widget."
)
class BECFigure(BECConnector):
def __init__(
self,
parent: Optional[QWidget] = None,
config: Optional[FigureConfig] = None,
client=None,
gui_id: Optional[str] = None,
graphics_layout_widget: Optional[pg.GraphicsLayoutWidget] = None,
):
if config is None:
config = FigureConfig(widget_class=self.__class__.__name__)
else:
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
# pg.GraphicsLayoutWidget.__init__(self, parent) #in case of inheritance
self.glw = (
graphics_layout_widget
if graphics_layout_widget
else pg.GraphicsLayoutWidget(parent=parent)
)
self.widgets = {}
# TODO just testing adding plot
self.add_widget("widget_1", row=0, col=0, title="Plot 1")
self.widgets["widget_1"].plot(
np.linspace(0, 10, 100), np.sin(np.linspace(0, 10, 100)), label="sin(x)"
)
def show(self): # TODO check if useful for anything
self.window = QMainWindow()
self.window.setCentralWidget(self.glw)
self.window.show()
def close(self): # TODO check if useful for anything
if hasattr(self, "window"):
self.window.close()
def add_widget(self, widget_id: str = None, row: int = None, col: int = None, **kwargs):
# Generate unique widget_id if not provided
if not widget_id:
widget_id = self._generate_unique_widget_id()
# Check if id is available
if widget_id in self.widgets:
print(f"Widget with ID {widget_id} already exists.") # TODO change to raise error)
return
# Crete widget instance and its config
widget_config = WidgetConfig(
parent_figure_id=self.gui_id, widget_class="BECPlotBase", gui_id=widget_id, **kwargs
)
plot_item = pg.PlotItem()
widget = BECPlotBase(plot_item=plot_item, config=widget_config)
# Check if position is occupied
if row is not None and col is not None:
print("adding plot")
if self.glw.getItem(row, col):
print(
f"Position at row {row} and column {col} is already occupied."
) # TODO change to raise error
return
else:
widget_config.row = row
widget_config.column = col
# Add widget to the figure
self.glw.addItem(widget.plt, row=row, col=col)
else:
row, col = self._find_next_empty_position()
widget_config.row = row
widget_config.column = col
# Add widget to the figure
self.glw.addItem(widget.plt, row=row, col=col)
# Saving config for future referencing
self.config.widgets[widget_id] = widget_config
self.widgets[widget_id] = widget
def __getitem__(self, key: tuple | str):
if isinstance(key, tuple) and len(key) == 2:
return self._get_widget_by_coordinates(*key)
elif isinstance(key, str):
widget = self.widgets.get(key)
if widget is None:
raise KeyError(f"No widget with ID {key}")
return self.widgets.get(key)
else:
raise TypeError(
"Key must be a string (widget id) or a tuple of two integers (coordinates)"
)
def _get_widget_by_coordinates(self, row: int, col: int) -> BECPlotBase:
"""
Get widget by its coordinates in the figure.
Args:
row(int): the row coordinate
col(int): the column coordinate
Returns:
BECPlotBase: the widget at the given coordinates
"""
widget = self.glw.getItem(row, col)
if widget is None:
raise KeyError(f"No widget at coordinates ({row}, {col})")
return widget
def _add_waveform1d(self, widget_id: str = None, row: int = None, col: int = None, **kwargs):
"""
Add a 1D waveform widget to the figure.
Args:
widget_id:
row:
col:
**kwargs:
Returns:
"""
def _find_next_empty_position(self):
"""Find the next empty position (new row) in the figure."""
row, col = 0, 0
while self.glw.getItem(row, col):
row += 1
return row, col
def _generate_unique_widget_id(self):
"""Generate a unique widget ID."""
existing_ids = set(self.widgets.keys())
for i in itertools.count(1):
widget_id = f"widget_{i}"
if widget_id not in existing_ids:
return widget_id
##################################################
##################################################
# Debug window
##################################################
##################################################
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtconsole.inprocess import QtInProcessKernelManager
class JupyterConsoleWidget(RichJupyterWidget):
def __init__(self):
super().__init__()
self.kernel_manager = QtInProcessKernelManager()
self.kernel_manager.start_kernel(show_banner=False)
self.kernel_client = self.kernel_manager.client()
self.kernel_client.start_channels()
self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
def shutdown_kernel(self):
self.kernel_client.stop_channels()
self.kernel_manager.shutdown_kernel()
class DebugWindow(QWidget):
"""Debug window for BEC widgets"""
def __init__(self, parent=None):
super().__init__(parent)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "figure_debug_minimal.ui"), self)
self._init_ui()
# console push
self.console.kernel_manager.kernel.shell.push({"fig": self.figure})
def _init_ui(self):
# Plotting window
self.glw_1_layout = QVBoxLayout(self.glw) # Create a new QVBoxLayout
graphics_layout_widget = (
pg.GraphicsLayoutWidget()
) # create a new pg.GraphicsLayoutWidget instance
self.figure = BECFigure(
graphics_layout_widget=graphics_layout_widget, parent=self
) # Create a new BECDeviceMonitor
self.glw_1_layout.addWidget(self.figure.glw) # Add BECDeviceMonitor to the layout
self.console_layout = QVBoxLayout(self.widget_console)
self.console = JupyterConsoleWidget()
self.console_layout.addWidget(self.console)
self.console.set_default_style("linux")
if __name__ == "__main__": # pragma: no cover
import sys
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
win = DebugWindow()
win.show()
sys.exit(app.exec_())
# if __name__ == "__main__": # pragma: no cover
# from PyQt6.QtWidgets import QApplication
#
# app = QApplication([])
#
# fig = BECFigure()
# fig.show()
#
# app.exec()

View File

@ -0,0 +1,2 @@
from .plot_base import AxisConfig, WidgetConfig, BECPlotBase
from .waveform1d import Waveform1DConfig, BECWaveform1D

View File

@ -0,0 +1,187 @@
from typing import Optional, Literal
import pyqtgraph as pg
import numpy as np
from pydantic import BaseModel, Field
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig
class AxisConfig(BaseModel):
title: Optional[str] = Field(None, description="The title of the axes.")
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
x_grid: bool = Field(False, description="Show grid on the x-axis.")
y_grid: bool = Field(False, description="Show grid on the y-axis.")
class WidgetConfig(ConnectionConfig):
parent_figure_id: Optional[str] = Field(None, description="The parent figure of the plot.")
# Coordinates in the figure
row: int = Field(0, description="The row coordinate in the figure.")
column: int = Field(0, description="The column coordinate in the figure.")
# Appearance settings
axis: AxisConfig = Field(
default_factory=AxisConfig, description="The axis configuration of the plot."
)
class BECPlotBase(BECConnector): # , pg.PlotItem):
USER_ACCESS = [
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"plot",
]
def __init__(
self,
parent: Optional[QWidget] = None, # TODO decide if needed for this class
plot_item: Optional[pg.PlotItem] = None,
config: Optional[WidgetConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = WidgetConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, config=config, gui_id=gui_id)
self.plt = plot_item if plot_item else pg.PlotItem(parent=parent)
def set(self, **kwargs) -> None:
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
"""
# Mapping of keywords to setter methods
method_map = {
"title": self.set_title,
"x_label": self.set_x_label,
"y_label": self.set_y_label,
"x_scale": self.set_x_scale,
"y_scale": self.set_y_scale,
"x_lim": self.set_x_lim,
"y_lim": self.set_y_lim,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
def apply_axis_config(self):
"""Apply the axis configuration to the plot widget."""
config_mappings = {
"title": self.config.axis.title,
"x_label": self.config.axis.x_label,
"y_label": self.config.axis.y_label,
"x_scale": self.config.axis.x_scale,
"y_scale": self.config.axis.y_scale,
}
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
def set_title(self, title: str):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
"""
self.plt.setTitle(title)
self.config.axis.title = title
def set_x_label(self, label: str):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
"""
self.plt.setLabel("bottom", label)
self.config.axis.x_label = label
def set_y_label(self, label: str):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
"""
self.plt.setLabel("left", label)
self.config.axis.y_label = label
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the x-axis.
Args:
scale(Literal["linear", "log"]): Scale of the x-axis.
"""
self.plt.setLogMode(x=(scale == "log"))
self.config.axis.x_scale = scale
def set_y_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the y-axis.
Args:
scale(Literal["linear", "log"]): Scale of the y-axis.
"""
self.plt.setLogMode(y=(scale == "log"))
self.config.axis.y_scale = scale
def set_x_lim(self, x_lim: tuple) -> None:
"""
Set the limits of the x-axis.
Args:
x_lim(tuple): Limits of the x-axis.
"""
self.plt.setXRange(x_lim[0], x_lim[1])
self.config.axis.x_lim = x_lim
def set_y_lim(self, y_lim: tuple) -> None:
"""
Set the limits of the y-axis.
Args:
y_lim(tuple): Limits of the y-axis.
"""
self.plt.setYRange(y_lim[0], y_lim[1])
self.config.axis.y_lim = y_lim
def set_grid(self, x: bool = False, y: bool = False):
"""
Set the grid of the plot widget.
Args:
x(bool): Show grid on the x-axis.
y(bool): Show grid on the y-axis.
"""
self.plt.showGrid(x, y)
self.config.axis.x_grid = x
self.config.axis.y_grid = y
def plot(self, data_x: list | np.ndarray, data_y: list | np.ndarray, label: str = None):
"""
Plot custom data on the plot widget. These data are not saved in config.
Args:
data_x(list|np.ndarray): x-axis data
data_y(list|np.ndarray): y-axis data
label(str): label of the plot
"""
self.plt.plot(data_x, data_y, name=label)