diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py
new file mode 100644
index 0000000..91294ab
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/client.py
@@ -0,0 +1,31 @@
+# This file was automatically generated by generate_cli.py
+# type: ignore
+
+from __future__ import annotations
+
+from bec_lib.logger import bec_logger
+
+from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
+
+logger = bec_logger.logger
+
+# pylint: skip-file
+
+
+_Widgets = {}
+
+
+class DigitalTwin(RPCBase):
+ """A simple BEC widget with:"""
+
+ @rpc_call
+ def set_a(self, value: float):
+ """
+ Set input A remotely from the BEC CLI.
+ """
+
+ @rpc_call
+ def set_b(self, value: float):
+ """
+ Set input B remotely from the BEC CLI.
+ """
diff --git a/debye_bec/bec_widgets/widgets/digital_twin/__init__.py b/debye_bec/bec_widgets/widgets/digital_twin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py
new file mode 100644
index 0000000..f15b289
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/digital_twin/calculate_positions.py
@@ -0,0 +1,221 @@
+import os
+import numpy as np
+from bec_lib import bec_logger
+
+os.environ["USE_XRT"] = "False"
+import debye_bec.bec_ipython_client.plugins.digital_twin.x01da_parameters as bl
+
+logger = bec_logger.logger
+
+def calculate_positions(cfg):
+
+ pos = {}
+
+ ## FE slits
+ trxr = -np.arctan(cfg['h_acc'])*bl.feSlits.center1[1]
+ trxw = (np.arctan(cfg['h_acc'])*bl.feSlits.center1[1])/bl.feSlits.center1[1]*bl.feSlits.center2[1]
+ tryb = -np.arctan(cfg['v_acc'])*bl.feSlits.center1[1]
+ tryt = (np.arctan(cfg['v_acc'])*bl.feSlits.center1[1])/bl.feSlits.center1[1]*bl.feSlits.center2[1]
+
+ trxw_proj = trxw/bl.feSlits.center2[1]*bl.feSlits.center1[1]
+ tryt_proj = tryt/bl.feSlits.center2[1]*bl.feSlits.center1[1]
+
+ xcen = (trxr + trxw_proj) / 2
+ ycen = (tryb + tryt_proj) / 2
+ xgap = trxw_proj - trxr
+ ygap = tryt_proj - tryb
+
+ pos['sldi_gapx'] = {'value': xgap}
+ pos['sldi_gapy'] = {'value': ygap}
+
+ ## Collimating Mirror
+ obj_dist = bl.cm.center[1] # object distance
+
+ # TRX
+ try:
+ index = bl.cm.surface.index(cfg['cm_stripe'])
+ except:
+ raise ValueError(f"Requested stripe {cfg['cm_stripe']} not found in parameters!")
+ cm_trx = -(bl.cm.limOptX[0][index] + bl.cm.limOptX[1][index]) / 2
+ pos['cm_trx'] = {'value': cm_trx}
+
+ # TRY
+ height = obj_dist * np.tan(cfg['v_acc'])**2 * 1 / np.tan(cfg['cm_pitch'])
+ pos['cm_try'] = {'value': height}
+
+ # Pitch
+ pos['cm_rotx'] = {'value': -cfg["cm_pitch"]*1e3} # invert and convert to mrad (same as EGU of rotx axis)
+
+ # Bending Radius
+ radius = 2. * obj_dist / np.sin(cfg['cm_pitch']) # Elements of modern X-ray Physics, page 108 ff.
+ pos['cm_bnd_radius'] = {'value': radius * 1e-6} # Convert to km
+
+ ## Monochromator
+ # Bragg Angle
+ # TODO Should the bragg angle be corrected for the symmetric bragg case?
+ # See raytracing script or here: bragg = np.asin(rm.ch / (2.*cfg['dSpacing']*cfg['energyCCM'])) - aCrystal.get_dtheta_symmetric_Bragg(cfg['energyCCM'])
+ if cfg['mo_mode'] == 'Monochromatic':
+ # Add 2x CM pitch to the bragg angle
+ bragg = ((2 * cfg['cm_pitch']) + cfg['mo_bragg'][1]) / np.pi * 180
+ elif cfg['mo_mode'] == 'Pinkbeam':
+ # Align xtal surfaces parallel to beam
+ bragg = (2 * cfg['cm_pitch']) / np.pi * 180
+ else:
+ raise Exception('Monochromator mode not supported')
+ pos['mo1_bragg_angle'] = {'value': bragg} # Bragg angle in deg
+
+ # TRY, Height
+ l = bl.mo1.xtalGap[0]/np.sin(cfg['mo_bragg'][1])
+ yhor = l*np.cos(2.*(cfg['mo_bragg'][1]+cfg['cm_pitch']))
+ yver = yhor*np.tan(2.*cfg['cm_pitch'])
+
+ if cfg['mo_mode'] == 'Monochromatic':
+ beamOffsetCCM = l*np.sin(2.*(cfg['mo_bragg'][1]+cfg['cm_pitch']))-yver # Resultat ist korrekt!
+ elif cfg['mo_mode'] == 'Pinkbeam':
+ beamOffsetCCM = 0
+ else:
+ raise Exception('Monochromator mode not supported')
+
+ def csc(a):
+ return 1/np.sin(a)
+
+ def cot(a):
+ return 1/np.tan(a)
+
+ # calculate height of center of first crystal surface
+ f = bl.mo1.rotOffset # rotation offset, mm
+ logger.info(f'f = {f}')
+ d = bl.mo1.heightOffset # xtal height offset, mm
+ logger.info(f'd = {d}')
+ c = d*csc(cfg['mo_bragg'][1])-f*cot(cfg['mo_bragg'][1])
+ logger.info(f'c = {c}')
+
+ # Calculate height of center of rotation
+ b = np.sqrt(d**2*csc(cfg['mo_bragg'][1])**2-2*d*f*cot(cfg['mo_bragg'][1])*csc(cfg['mo_bragg'][1])+f**2*cot(cfg['mo_bragg'][1])**2+f**2)
+ logger.info(f'b = {b}')
+ h = np.cos(np.pi/2-np.arctan(f/c)-cfg['mo_bragg'][1]-2*cfg['cm_pitch'])*b
+ logger.info(f'h = {h}')
+ h2 = ((bl.mo1.center[1] - bl.cm.center[1])-np.sqrt(b**2-h**2))*np.tan(2*cfg['cm_pitch'])
+ logger.info(f'mo1 = {bl.mo1.center[1]}')
+ logger.info(f'cm = {bl.cm.center[1]}')
+ logger.info(f'pitch = {cfg["cm_pitch"]}')
+ logger.info(f'h2 = {h2}')
+ #TODO Mono height not exactly the same as in raytracing
+ heightCCM1real = h + h2 # per design, the height should not change if the pitch of the CM is not changed!
+ # heightCCM1real = heightCCM1real - 30 # Zero position of stage is at 1430 mm from ground.
+ if cfg['mo_mode'] == 'Monochromatic':
+ pass
+ elif cfg['mo_mode'] == 'Pinkbeam':
+ heightCCM1real = heightCCM1real - 13 # Move down to let beam pass between both crystal without touching copper cooler
+ else:
+ raise Exception('Monochromator mode not supported')
+ pos['mo1_try'] = {'value': heightCCM1real}
+
+ # TRX, Crystal selection
+ try:
+ xtal = cfg['mo_xtal'].translate(str.maketrans('', '', '()')) # Remove brackets from xtal name to conform with parameters
+ index = bl.mo1.xtal.index(xtal)
+ except:
+ raise ValueError(f"Requested xtal {xtal} not found in parameters!")
+ pos['mo1_trx'] = {'value': bl.mo1.xtalOffsetX[index]}
+
+
+ #TODO move to mono, calc for beam Z-movement between crystal surfaces
+ diag = bl.mo1.xtalGap[0] / np.sin(bragg) # Calculations for Mono
+ dz = diag * np.cos(2 * (cfg['cm_pitch'] + bragg))
+
+ ## Slits 1
+ d = bl.opSlits1.center[1] - bl.cm.center[1] - dz
+ sl1_beam_height = d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM
+ pos['sl1_centery'] = {'value': sl1_beam_height}
+
+ ## Beam Monitor 1
+ d = bl.opBM1.center[1] - bl.cm.center[1] - dz
+ logger.info(f'distance: {d}')
+ logger.info(f'cm pitch: {cfg["cm_pitch"]}')
+ logger.info(f'mono offset: {beamOffsetCCM}')
+ bm1_beam_height = d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM
+ pos['bm1_try'] = {'value': bm1_beam_height}
+
+ ## Focusing Mirror
+ p = bl.fm.center[1]
+ q = cfg['smpl'] - bl.fm.center[1]
+ f = (p*q)/(p+q) # focal length
+
+ # Bender radius
+ radius = 2 * q / np.sin(cfg['fm_pitch']) # ideal bending radius
+ pos['fm_bnd_radius'] = {'value': radius * 1e-6} # Convert to km
+
+ # Pitch
+ d = bl.fm.center[1] - bl.cm.center[1] - dz
+ fm_pitch = 2 * cfg['cm_pitch'] - cfg['fm_pitch'] # calculate pitch in absolute values (according to horizontal plane)
+ pos['fm_rotx'] = {'value': -fm_pitch * 1e3} # invert and convert to mrad (same as EGU of rotx axis)
+
+ if cfg['fm_stripe'] in ('Rh (toroid)', 'Pt (toroid)'):
+
+ # TRY
+ if cfg['fm_stripe'] in 'Rh (toroid)':
+ r = bl.fm.r[0]
+ h_cyl = bl.fm.hToroid[0]
+ else: # PT toroid
+ r = bl.fm.r[1]
+ h_cyl = bl.fm.hToroid[1]
+ widthBeam = 2 * bl.fm.center[1] * np.tan(cfg['h_acc'] * 1e-3)
+ alpha = np.arccos(1 - widthBeam**2 / (2 * r**2))
+ h = r - (r * np.cos(alpha / 2))
+ fm_beam_height = (d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM) * cfg['fm_gain_height']
+ fm_height = (d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM - h_cyl + h / 2) * cfg['fm_gain_height']
+ pos['fm_try'] = {'value': fm_height}
+
+ # TRX
+ if cfg['fm_stripe'] in 'Rh (toroid)':
+ x_cyl = - bl.fm.xToroid[0]
+ else:
+ x_cyl = - bl.fm.xToroid[1]
+ pos['fm_trx'] = {'value': x_cyl}
+
+ elif cfg['fm_stripe'] in ('Rh (flat)', 'Pt (flat)'):
+
+ # TRY
+ fm_height = (d * np.tan(2 * cfg['cm_pitch']) + beamOffsetCCM) * cfg['fm_gain_height']
+ fm_beam_height = fm_height
+ pos['fm_try'] = {'value': fm_height}
+
+ # TRX
+ if cfg['fm_stripe'] in 'Rh (flat)':
+ x_flat = - bl.fm.xFlat[0]
+ else:
+ x_flat = - bl.fm.xFlat[1]
+ pos['fm_trx'] = {'value': x_flat}
+
+ else:
+ raise Exception('FM Stripe selection not valid')
+
+ ## Slits 2
+ d = bl.opSlits2.center[1] - bl.fm.center[1]
+ sl2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch']))
+ pos['sl2_centery'] = {'value': sl2_beam_height}
+
+ ## Beam Monitor 2
+ d = bl.opBM2.center[1] - bl.fm.center[1]
+ bm2_beam_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch']))
+ pos['bm2_try'] = {'value': bm2_beam_height}
+
+ ## Optical Table / Exit Window
+
+ # TRY
+ d = bl.ehWindow.center[1] - bl.fm.center[1]
+ ot_height = fm_beam_height - d * np.tan(-(2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch']))
+ # logger.info(fm_height)
+ # logger.info(d * np.tan((2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])))
+ pos['ot_try'] = {'value': ot_height}
+
+ # Pitch
+ ot_pitch = - (2 * cfg['cm_pitch'] - 2 * cfg['fm_pitch'])
+ pos['ot_rotx'] = {'value': ot_pitch * 1e3}
+
+ # TRZ ES1
+ ot_es1_trz = cfg['smpl']
+ pos['ot_es1_trz'] = {'value': ot_es1_trz}
+
+ return pos
diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py
new file mode 100644
index 0000000..56b4ebc
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py
@@ -0,0 +1,365 @@
+import sys
+import datetime
+import numpy as np
+from bec_lib import bec_logger
+# pylint: disable=E0611
+from qtpy.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+ QDoubleSpinBox, QGroupBox, QApplication, QLineEdit, QLayout
+)
+# pylint: disable=E0611
+from qtpy.QtCore import QTimer, Qt
+from qtpy.QtGui import QColor
+import pyqtgraph as pg
+
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.utils.error_popups import SafeSlot
+
+from debye_bec.bec_widgets.widgets.qt_widgets import InputNumberField, ComboBox, Group
+
+logger = bec_logger.logger
+
+class DigitalTwin(BECWidget, QWidget):
+ """
+ A simple BEC widget with:
+ - Two numeric inputs (A, B)
+ - Two computed outputs (Sum, Product)
+ - A live plot that updates every second
+ """
+
+ USER_ACCESS = ["set_a", "set_b"]
+ PLUGIN = True
+ ICON_NAME = "lightbulb"
+
+ def __init__(self, parent=None, *arg, **kwargs):
+ super().__init__(parent=parent, theme_update=True, *arg, **kwargs)
+ self.get_bec_shortcuts()
+
+ self._history = [] # stores (sum, product) over time
+ self._t = 0 # tick counter
+
+ central = QWidget()
+ self.root_layout = QHBoxLayout(central)
+
+ self.plot_widget = PlotWidget(title='Plot title', chart_data = [])
+ self.control_panel = InputPanel()
+
+ self.root_layout.addWidget(self.plot_widget, stretch=3)
+ self.root_layout.addWidget(self.control_panel, stretch=1, alignment=Qt.AlignTop)
+
+ self.setLayout(self.root_layout)
+ self.setWindowTitle("Digital Twin")
+ self.resize(600, 500)
+
+ # self.init_ui()
+ # self._recalculate() # populate outputs on startup
+
+ # Timer: update plot every 1 second
+ # self._timer = QTimer(self)
+ # self._timer.setInterval(1000)
+ # self._timer.timeout.connect(self._tick)
+ # self._timer.start()
+
+ # ------------------------------------------------------------------ UI ---
+
+ # def init_ui(self):
+
+ # self.spin_a = InputNumberField('Acceptance 1')
+ # self.spin_b = InputNumberField('Acceptance 2')
+ # self.input_group = Group(
+ # 'Inputs',
+ # [
+ # self.spin_a,
+ # self.spin_b,
+ # ]
+ # )
+ # self.root_layout.addWidget(self.input_group)
+ # self.root_layout.addStretch()
+
+
+ # root = QVBoxLayout(self)
+
+ # # --- Inputs ---
+ # input_group = QGroupBox("Inputs")
+ # input_layout = QHBoxLayout(input_group)
+
+ # self._spin_a = QLineEdit()
+ # self._spin_a.setPlaceholderText('0')
+ # self._spin_a.setText('0')
+ # # self._spin_a.setRange(-1e6, 1e6)
+ # # self._spin_a.setDecimals(3)
+ # # self._spin_a.setValue(1.0)
+ # # self._spin_a.setSingleStep(0.1)
+
+ # self._spin_b = QLineEdit()
+ # self._spin_b.setPlaceholderText('0')
+ # self._spin_b.setText('0')
+ # # self._spin_b.setRange(-1e6, 1e6)
+ # # self._spin_b.setDecimals(3)
+ # # self._spin_b.setValue(2.0)
+ # # self._spin_b.setSingleStep(0.1)
+
+ # self._spin_c = QLineEdit()
+ # self._spin_c.setPlaceholderText('0')
+ # self._spin_c.setText('10')
+
+ # input_layout.addWidget(QLabel("A:"))
+ # input_layout.addWidget(self._spin_a)
+ # input_layout.addWidget(QLabel("B:"))
+ # input_layout.addWidget(self._spin_b)
+ # input_layout.addWidget(QLabel("C:"))
+ # input_layout.addWidget(self._spin_c)
+ # root.addWidget(input_group)
+
+ # # --- Outputs ---
+ # output_group = QGroupBox("Outputs")
+ # output_layout = QHBoxLayout(output_group)
+
+ # self._label_sum = QLabel("Sum: —")
+ # self._label_product = QLabel("Product: —")
+ # output_layout.addWidget(self._label_sum)
+ # output_layout.addWidget(self._label_product)
+ # root.addWidget(output_group)
+
+ # # --- Plot ---
+ # plot_group = QGroupBox("Live History (updates every 1 s)")
+ # plot_layout = QVBoxLayout(plot_group)
+
+ # self._plot_widget = pg.PlotWidget()
+ # self._plot_widget.setBackground("w")
+ # self._plot_widget.addLegend()
+ # self._plot_widget.setLabel("left", "Value")
+ # self._plot_widget.setLabel("bottom", "Tick")
+
+ # self._curve_sum = self._plot_widget.plot(
+ # pen=pg.mkPen("b", width=2), name="Sum"
+ # )
+ # self._curve_product = self._plot_widget.plot(
+ # pen=pg.mkPen("r", width=2), name="Product"
+ # )
+ # plot_layout.addWidget(self._plot_widget)
+ # plot_group.setLayout(plot_layout)
+ # root.addWidget(plot_group)
+
+ # self.setLayout(root)
+ # self.setWindowTitle("BEC Calculator Widget")
+ # self.resize(600, 500)
+
+ # # Connect inputs → recalculate
+ # self._spin_a.editingFinished .connect(self._recalculate)
+ # self._spin_b.editingFinished .connect(self._recalculate)
+
+ # ---------------------------------------------------------- Logic ---
+
+ # @SafeSlot()
+ # def _recalculate(self):
+ # # logger.info(var)
+ # a = float(self._spin_a.text())
+ # b = float(self._spin_b.text())
+ # s = a + b
+ # p = a * b
+ # self._label_sum.setText(f"Sum: {s:.4f}")
+ # self._label_product.setText(f"Product: {p:.4f}")
+ # self._current_sum = s
+ # self._current_product = p
+
+ # @SafeSlot()
+ # def _tick(self):
+ # """Called every second: record current outputs and refresh plot."""
+ # self._history.append((self._t, self._current_sum, self._current_product))
+ # self._t += 1
+
+ # ticks = [h[0] for h in self._history]
+ # sums = [h[1] for h in self._history]
+ # products = [h[2] for h in self._history]
+
+ # self._curve_sum.setData(ticks, sums)
+ # self._curve_product.setData(ticks, products)
+
+ # # --------------------------------------------------- RPC interface ---
+
+ # def set_a(self, value: float):
+ # """Set input A remotely from the BEC CLI."""
+ # self._spin_a.setValue(value)
+
+ # def set_b(self, value: float):
+ # """Set input B remotely from the BEC CLI."""
+ # self._spin_b.setValue(value)
+
+
+class InputPanel(QWidget):
+ """Right-side control panel: input field, indicator, send, recording."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._layout = QVBoxLayout(self)
+ self._layout.setSizeConstraint(QLayout.SetFixedSize)
+
+ self.energy = InputNumberField('Energy [keV]')
+
+ self.sldi_hacc = InputNumberField('Horizontal [± mrad]')
+ self.sldi_vacc = InputNumberField('Vertical [± mrad]')
+ self.fe_slits_group = Group(
+ 'FE Slits Acceptance',
+ [
+ self.sldi_hacc,
+ self.sldi_vacc,
+ ]
+ )
+
+ self.assistant_group = Group(
+ 'Assistant',
+ [
+ self.energy,
+ self.fe_slits_group,
+ ]
+ )
+
+ self._layout .addWidget(self.assistant_group)
+ self._layout .addStretch()
+
+class PlotWidget(QWidget):
+ """Plot widget with two curves and legend."""
+
+ def __init__(self, title: str = "Title", chart_data = [], max_points=2000, parent=None):
+ super().__init__(parent)
+ self.chart_data = chart_data
+ self.max_points = max_points
+
+ self._layout = QVBoxLayout(self)
+ self._title = QLabel(f"
{title}
")
+ self._layout.addWidget(self._title)
+
+ self.plot_widget = pg.PlotWidget(axisItems={'bottom': TimeAxis(orientation='bottom')})
+ self.plot_widget.getAxis('bottom').enableAutoSIPrefix(False)
+ self.plot_widget.addLegend()
+
+ self.curves = []
+ colors = self.golden_angle_color(
+ colormap='plasma', num=max(10, len(self.curves) + 1), format="HEX"
+ )
+
+ for idx, element in enumerate(self.chart_data):
+ self.curves.append(
+ self.plot_widget.plot(
+ [],
+ [],
+ pen=pg.mkPen(color=colors[idx], width=2),
+ name=element,
+ )
+ )
+
+ self._layout.addWidget(self.plot_widget)
+
+ self.plot_widget.setLabel('left', 'Temperature [°C]')
+ self.plot_widget.setLabel('bottom', 'Time')
+
+ def golden_angle_color(
+ self,
+ colormap: str,
+ num: int,
+ format="QColor",
+ theme_offset=0.2,
+ theme=None,
+ ) -> list:
+ """
+ Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
+
+ Args:
+ colormap (str): Name of the colormap.
+ num (int): Number of requested colors.
+ format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
+ theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
+
+ Returns:
+ list: List of colors in the specified format.
+
+ Raises:
+ ValueError: If theme_offset is not between 0 and 1.
+ """
+
+ cmap = pg.colormap.get(colormap)
+ phi = (1 + np.sqrt(5)) / 2 # Golden ratio
+ golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
+
+ min_pos, max_pos = self.set_theme_offset(theme, theme_offset)
+
+ # Generate positions within the acceptable range
+ positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
+ positions = min_pos + positions * (max_pos - min_pos)
+
+ # Sample colors from the colormap at the calculated positions
+ colors = cmap.map(positions, mode="float")
+ color_list = []
+
+ for color in colors:
+ if format.upper() == "HEX":
+ color_list.append(QColor.fromRgbF(*color).name())
+ elif format.upper() == "RGB":
+ color_list.append(tuple((np.array(color) * 255).astype(int)))
+ elif format.upper() == "QCOLOR":
+ color_list.append(QColor.fromRgbF(*color))
+ else:
+ raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
+ return color_list
+
+ def set_theme_offset(self, theme = None, offset=0.2) -> tuple:
+ """
+ Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
+
+ Args:
+ theme(str): The theme to be applied.
+ offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
+
+ Returns:
+ tuple: Tuple of min_pos and max_pos.
+
+ Raises:
+ ValueError: If theme_offset is not between 0 and 1.
+ """
+
+ if offset < 0 or offset > 1:
+ raise ValueError("theme_offset must be between 0 and 1")
+
+ if theme is None:
+ app = QApplication.instance()
+ if hasattr(app, "theme"):
+ theme = app.theme.theme
+
+ if theme == "light":
+ min_pos = 0.0
+ max_pos = 1 - offset
+ else:
+ min_pos = 0.0 + offset
+ max_pos = 1.0
+
+ return min_pos, max_pos
+
+ def update_curves(self, timestamps: list[str], data: list[float]):
+ x = timestamps.copy()
+ y = data.copy()
+ min_len = min([min([len(i) for i in y]), len(x)])
+ x_float = [t.timestamp() for t in x]
+ for idx, element in enumerate(y):
+ self.curves[idx].setData(x=np.array(x_float)[0:min_len], y=np.array(element)[0:min_len])
+
+class TimeAxis(pg.AxisItem):
+ def tickStrings(self, values, scale, spacing):
+ return [datetime.fromtimestamp(value).strftime("%H:%M:%S") for value in values]
+
+
+# --------------------------------------------------------- Standalone run ---
+
+if __name__ == "__main__":
+ from qtpy.QtWidgets import QApplication
+ from bec_widgets.utils import BECDispatcher
+ from bec_widgets.utils.colors import apply_theme
+
+ app = QApplication(sys.argv)
+ apply_theme("dark")
+ dispatcher = BECDispatcher(gui_id="digital_twin")
+ win = DigitalTwin()
+
+ win.resize(1000, 800)
+ win.show()
+ sys.exit(app.exec_())
\ No newline at end of file
diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.pyproject b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.pyproject
new file mode 100644
index 0000000..226dc5f
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.pyproject
@@ -0,0 +1 @@
+{'files': ['digital_twin.py']}
\ No newline at end of file
diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin_plugin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin_plugin.py
new file mode 100644
index 0000000..2decf97
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin_plugin.py
@@ -0,0 +1,57 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+from bec_widgets.utils.bec_designer import designer_material_icon
+from qtpy.QtDesigner import QDesignerCustomWidgetInterface
+from qtpy.QtWidgets import QWidget
+
+from debye_bec.bec_widgets.widgets.digital_twin.digital_twin import DigitalTwin
+
+DOM_XML = """
+
+
+
+
+"""
+
+
+class DigitalTwinPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
+ def __init__(self):
+ super().__init__()
+ self._form_editor = None
+
+ def createWidget(self, parent):
+ if parent is None:
+ return QWidget()
+ t = DigitalTwin(parent)
+ return t
+
+ def domXml(self):
+ return DOM_XML
+
+ def group(self):
+ return ""
+
+ def icon(self):
+ return designer_material_icon(DigitalTwin.ICON_NAME)
+
+ def includeFile(self):
+ return "digital_twin"
+
+ def initialize(self, form_editor):
+ self._form_editor = form_editor
+
+ def isContainer(self):
+ return False
+
+ def isInitialized(self):
+ return self._form_editor is not None
+
+ def name(self):
+ return "DigitalTwin"
+
+ def toolTip(self):
+ return "DigitalTwin"
+
+ def whatsThis(self):
+ return self.toolTip()
diff --git a/debye_bec/bec_widgets/widgets/digital_twin/register_digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/register_digital_twin.py
new file mode 100644
index 0000000..0c5d315
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/digital_twin/register_digital_twin.py
@@ -0,0 +1,15 @@
+def main(): # pragma: no cover
+ from qtpy import PYSIDE6
+
+ if not PYSIDE6:
+ print("PYSIDE6 is not available in the environment. Cannot patch designer.")
+ return
+ from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
+
+ from debye_bec.bec_widgets.widgets.digital_twin.digital_twin_plugin import DigitalTwinPlugin
+
+ QPyDesignerCustomWidgetCollection.addCustomWidget(DigitalTwinPlugin())
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
diff --git a/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py b/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py
new file mode 100644
index 0000000..650e84f
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/digital_twin/x01da_parameters.py
@@ -0,0 +1,289 @@
+"""
+X01DA / Debye Beamline Parameters.
+This file describes the parameter of each component of the Debye beamline
+to be used for raytracing and geometrical calculations.
+"""
+
+import os
+import numpy as np
+from collections import namedtuple
+
+if os.environ.get("USE_XRT", "True").lower() in ("1", "true", "yes"):
+ import xrt.backends.raycing.materials as rm # type: ignore
+else:
+ class _DummyClass:
+ def __init__(self, *args, **kwargs):
+ pass
+ class _DummyMaterials:
+ Material = _DummyClass
+ CrystalSi = _DummyClass
+ rm = _DummyMaterials()
+
+# XRT definitions
+filterBeryl = rm.Material('Be', rho=1.85, kind='plate') # pyright: ignore[reportArgumentType]
+filterDiamond = rm.Material('C', rho=3.52, kind='plate') # pyright: ignore[reportArgumentType]
+filterGraphite = rm.Material('C', rho=2.266, kind='plate') # pyright: ignore[reportArgumentType]
+
+stripeSi = rm.Material('Si', rho=2.33) # pyright: ignore[reportArgumentType]
+stripePt = rm.Material('Pt', rho=21.45) # pyright: ignore[reportArgumentType]
+stripeRh = rm.Material('Rh', rho=12.41) # pyright: ignore[reportArgumentType]
+stripeCr = rm.Material('Cr', rho=7.14) # pyright: ignore[reportArgumentType]
+stripePyrex = rm.Material('Si', rho=2.20) # Use Si as bare element and the density of SiO2 # pyright: ignore[reportArgumentType]
+
+si111_1 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # first xtal surface
+si311_1 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # first xtal surface
+si333_1 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # first xtal surface
+si511_1 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # first xtal surface
+si111_2 = rm.CrystalSi(hkl=(1, 1, 1), tK=77) # second xtal surface
+si311_2 = rm.CrystalSi(hkl=(3, 1, 1), tK=77) # second xtal surface
+si333_2 = rm.CrystalSi(hkl=(3, 3, 3), tK=77) # second xtal surface
+si511_2 = rm.CrystalSi(hkl=(5, 1, 1), tK=77) # second xtal surface
+
+filterDiamond = rm.Material('C', rho=3.52, kind='plate') # pyright: ignore[reportArgumentType]
+filterBe = rm.Material('Be', rho=1.85, kind='plate') # pyright: ignore[reportArgumentType]
+filterSi3N4 = rm.Material(['Si', 'N'], quantities=[3, 4], rho=3.44, kind='plate') # pyright: ignore[reportArgumentType]
+filterAl = rm.Material('Al', rho=2.69, kind='plate') # pyright: ignore[reportArgumentType]
+filterGraphite = rm.Material('C', rho=2.266, kind='plate') # pyright: ignore[reportArgumentType]
+
+# General parameters
+sourceHeight = 0
+
+#Synchrotron
+synchrotron = namedtuple('synchrotron', ['eE', 'eI', 'eEspread',
+ 'eEpsilonX', 'eEpsilonZ', 'betaX', 'betaZ'])
+
+sls1 = synchrotron(
+ eE = 2.4,
+ eI = 0.4,
+ eEspread=0.878e-3,
+ eEpsilonX=5.63,
+ eEpsilonZ=0.007,
+ betaX=0.45,
+ betaZ=14.4,
+ )
+
+sls2 = synchrotron(
+ eE=2.7,
+ eI=0.4,
+ eEspread=1.147e-3,
+ eEpsilonX=0.156,
+ eEpsilonZ=0.01,
+ betaX=0.18,
+ betaZ=4.6,
+ )
+
+# Source
+bendingMagnet = namedtuple('bendingMagnet', ['name', 'center', 'sync', 'B0'])
+
+sls1_14t = bendingMagnet(
+ name='FE-BM-SLS1-1.4T',
+ center=(0, 0, 0),
+ sync=sls1,
+ B0=1.4,)
+
+sls2_21t = bendingMagnet(
+ name='FE-BM-SLS2-2.1T',
+ center=(0, 0, 0),
+ sync=sls2,
+ B0=2.1,)
+
+sls2_35t = bendingMagnet(
+ name='FE-BM-SLS2-3.5T',
+ center=(0, 0, 0),
+ sync=sls2,
+ B0=3.5,)
+
+sls2_50t = bendingMagnet(
+ name='FE-BM-SLS2-5.0T',
+ center=(0, 0, 0),
+ sync=sls2,
+ B0=5.0,)
+
+# FE slits
+fe_slits = namedtuple('slits', ['name', 'center', 'center1', 'center2', 'maxDivH', 'maxDivV'])
+
+feSlits = fe_slits(
+ name='FE-SLITS',
+ center=(0, 6117, sourceHeight),
+ center1=(0, 5045, sourceHeight),
+ center2=(0, 5289.5, sourceHeight),
+ maxDivH=1.8e-3,
+ maxDivV=0.8e-3,)
+
+# FE Window
+filt = namedtuple('filt', ['name', 'center', 'pitch', 'limPhysX', 'limPhysY', 'surface', 'material', 'thickness'])
+
+feWindow = filt(
+ name='FE-WINDOW',
+ center=(0., 7020, sourceHeight),
+ pitch=np.pi/2,
+ limPhysX=(-6, 6),
+ limPhysY=(-3., 3.),
+ surface='None',
+ material=filterDiamond,
+ thickness=0.1,)
+feWindow = feWindow._replace(surface=f'CVD Diamond window {feWindow.thickness*1e3:0.0f} $\\mu$m')
+
+# Collimating mirror
+collimatingMirror = namedtuple('collimatingMirror', ['name',
+ 'center', 'surface', 'material', 'limPhysX', 'limPhysY',
+ 'limOptX', 'limOptY', 'R', 'pitch', 'jack1', 'jack2', 'jack3',
+ 'tx1', 'tx2'])
+
+cm = collimatingMirror(
+ name='FE-CM',
+ center=[0, 6890, sourceHeight],
+ surface=('Si','Pt','Rh'),
+ material=(stripeSi, stripePt, stripeRh),
+ limPhysX=(-34, 34),
+ limPhysY=(-600, 600),
+ limOptX=((-27, -3.5, 15), (-11, 6.5, 25)),
+ limOptY=((-500, -500, -500), (500, 500, 500)),
+ R=[3e6, 15e6],
+ pitch=[-5.0e-3, -0.0e-3],
+ jack1=[0., 7210., 0.], #Tripod X, Y, Z (global)
+ jack2=[-210., 8310., 0.],
+ jack3=[210., 8310., 0.],
+ tx1=[0.0, -575.5], # X-Stage 1 [x, y] (local)
+ tx2=[0.0, 575],) # X-Stage 2
+
+apertures = namedtuple('apertures', ['name', 'center', 'opening'])
+
+fePS = apertures(
+ name='FE-PS',
+ center=[0, 8815, sourceHeight],
+ opening=[-20., 20., -20.+12.5, 20.+12.5]) # left, right, bottom, top
+
+opWbBsBlock = apertures(
+ name='OP-WB-BS-BLOCK',
+ center=[0., 13860, sourceHeight],
+ opening=[-18., 18., 25, 85.5]) # left, right, bottom, top
+ # opening=[-18., 18., 42, 76], # X10DA
+
+# Monochromator
+monochromator = namedtuple('monochromator', ['name', 'center',
+ 'xtal', 'material1', 'material2', 'xtalWidth', 'xtalOffsetX',
+ 'xtalLength1', 'xtalLength2', 'xtalGap', 'rotOffset',
+ 'heightOffset', 'braggLim', 'jack1', 'jack2', 'jack3', 'tx'])
+
+mo1 = monochromator(
+ name='OP-MO1',
+ center=[0., 11750, sourceHeight],
+ xtal=('Si311','Si111'),
+ material1=(si311_1, si111_1),
+ material2=(si311_2, si111_2),
+ xtalWidth = (24, 24),
+ xtalOffsetX=(-21.2, 21.2),
+ xtalLength1 = (55, 55),
+ xtalLength2 = (105, 105),
+ xtalGap = (8, 8),
+ rotOffset = 6,
+ heightOffset = 8.5,
+ braggLim = [3.6, 33],
+ jack1=[0., 11350., 0.], #Tripod maybe not available!
+ jack2=[-400., 12350., 0.],
+ jack3=[400., 12350., 0.],
+ tx=0.0,) # X-Stage [x]
+
+mo2 = monochromator(
+ name='OP-CCM2',
+ center=[0., 13250, sourceHeight],
+ xtal=('Si311','Si111'),
+ material1=(si311_1, si111_1),
+ material2=(si311_2, si111_2),
+ xtalWidth = (24, 24),
+ xtalOffsetX=(-21, 21),
+ xtalLength1 = (55, 55),
+ xtalLength2 = (105, 105),
+ xtalGap = (8, 8),
+ rotOffset = 6,
+ heightOffset = 8.5,
+ braggLim = [3.6, 33],
+ jack1=[0., 13350., 0.], #Tripod maybe not available!
+ jack2=[-400., 14350., 0.],
+ jack3=[400., 14350., 0.],
+ tx=0.0,) # X-Stage [x]
+
+# OP Slits
+op_slits = namedtuple('op_slits', ['name', 'center'])
+
+opSlits1 = op_slits(
+ name='OP-SLITS 1',
+ center=(0, 14349.6, sourceHeight),
+)
+
+opSlits2 = op_slits(
+ name='OP-SLITS 2',
+ center=(0, 18134.8, sourceHeight),
+)
+
+# OP Beam Monitors
+op_bm = namedtuple('op_bm', ['name', 'center'])
+
+opBM1 = op_bm(
+ name='OP Beam Monitor 1',
+ center=(0, 14599.6, sourceHeight),
+)
+
+opBM2 = op_bm(
+ name='OP Beam Monitor 2',
+ center=(0, 18384.8, sourceHeight),
+)
+
+# Focusing mirror
+focusingMirror = namedtuple('focusingMirror', ['name', 'center',
+ 'surfaceToroid', 'materialToroid', 'surfaceFlat', 'materialFlat',
+ 'limPhysXToroid', 'limPhysYToroid', 'limPhysXFlat', 'limPhysYFlat',
+ 'limOptXToroid', 'limOptYToroid', 'limOptXFlat', 'limOptYFlat',
+ 'R', 'pitch', 'r', 'xToroid', 'xFlat', 'hToroid', 'jack1', 'jack2', 'jack3',
+ 'tx1', 'tx2'])
+
+fm = focusingMirror(
+ name='OP-FM',
+ center=[0., 15670, sourceHeight], # nominal height 58 mm above ring, SLS1!
+ surfaceToroid=('Rh', 'Pt'),
+ materialToroid=(stripeRh, stripePt),
+ surfaceFlat=('Rh', 'Pt'),
+ materialFlat=(stripeRh, stripePt),
+ limPhysXToroid=(-79., 79.),
+ limPhysYToroid=(-575., 575.),
+ limPhysXFlat=(-79., 79.),
+ limPhysYFlat=(-575., 575.),
+ limOptXToroid=((-38, 66), (-66, 31)),
+ limOptYToroid=((-500., -500.), (500., 500.)),
+ limOptXFlat=((-11.45, 23.55), (-30.45, -6.45)),
+ limOptYFlat=((-500., -500.), (500., 500.)),
+ R=[3e6, 15e6],
+ pitch=[-5.0e-3, 0e-3],
+ r=[35.510, 24.986],
+ xToroid=[-52, 48.5], # offset in local x
+ xFlat = [-20.95, 8.55],
+ hToroid=[2.88, 7.15], # depth of the cylinder at x = xCylinder1 and x = xCylinder2.
+ jack1=[-130., 15535-538., 0.],
+ jack2=[130., 15535+538., 0.],
+ jack3=[0., 15535+538., 0.],
+ tx1=[0., -575.], # X-Stage 1 [x, y]
+ tx2=[0., 575.],) # X-Stage 2 [x, y]
+
+# EH Window
+ehWindow = filt(
+ name='EH-WINDOW',
+ center=(0., 19998.3, sourceHeight),
+ pitch=np.pi/2,
+ limPhysX=(-20., 20.),
+ limPhysY=(-4, 4),
+ surface='None',
+ material=filterSi3N4,
+ thickness=0.002,)
+ehWindow = ehWindow._replace(surface=f'Beryllium window {ehWindow.thickness*1e3:0.0f} $\\mu$m')
+
+# Sample
+sample = namedtuple('sample', ['name', 'center'])
+
+smpl = sample(
+ name='EH-SMPL',
+ center=[0, 23365, sourceHeight],)
+
+smpl2 = sample(
+ name='EH-SMPL2',
+ center=[0, 27500, sourceHeight],)
diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py
new file mode 100644
index 0000000..4273cce
--- /dev/null
+++ b/debye_bec/bec_widgets/widgets/qt_widgets.py
@@ -0,0 +1,273 @@
+
+from functools import partial
+# pylint: disable=E0611
+from qtpy.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
+ QPushButton, QGroupBox, QComboBox, QApplication, QDoubleSpinBox
+)
+from qtpy.QtGui import QFont
+
+class Group(QGroupBox):
+ def __init__(self, label, widgets):
+ super().__init__(label)
+ self.layout = QVBoxLayout(self)
+ for widget in widgets:
+ self.layout.addWidget(widget)
+
+class Indicator(QWidget):
+ def __init__(self, label, unit=None, highlight=False):
+ super().__init__()
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 0, 0)
+ layout.setSpacing(0)
+ self.label = QLabel(label)
+ self.label.setFixedWidth(150)
+ layout.addWidget(self.label)
+ self.value = QLabel('-')
+ self.value.setFixedWidth(160)
+ layout.addWidget(self.value)
+ self.unit = unit
+ self.highlight = highlight
+ if highlight:
+ font = QFont()
+ font.setBold(True)
+ font.setPointSize(14)
+ self.label.setFont(font)
+ self.value.setFont(font)
+
+ def set_text(self, text):
+ if self.unit is not None:
+ text = text + ' ' + self.unit
+ self.value.setText(text)
+
+class InputTextField(QWidget):
+ def __init__(self, topic, label):
+ super().__init__()
+ self.topic = topic
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 0, 0)
+ layout.setSpacing(0)
+ self.label = QLabel(label)
+ self.label.setFixedWidth(150)
+ layout.addWidget(self.label)
+ self.value = QLineEdit()
+ self.value.setPlaceholderText('0')
+ self.value.setFixedWidth(160)
+ layout.addWidget(self.value)
+
+ def set_text(self, text):
+ self.value.setText(text)
+
+ def has_focus(self) -> bool:
+ return self.value.hasFocus()
+
+ def set_on_return(self, func):
+ """Connect a function to the Enter/Return key press."""
+ self.value.returnPressed.connect(
+ partial(func, self.value, self.topic, lambda: self.value.text())
+ )
+
+class InputNumberField(QWidget):
+ def __init__(self, label, init=0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6):
+ super().__init__()
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 0, 0)
+ layout.setSpacing(0)
+ self.label = QLabel(label)
+ self.label.setFixedWidth(150)
+ layout.addWidget(self.label)
+ self.value = QDoubleSpinBox()
+ self.value.setValue(init)
+ self.value.setRange(ll, hl)
+ self.value.setDecimals(decimals)
+ self.value.setSingleStep(single_step)
+ self.value.setFixedWidth(160)
+ layout.addWidget(self.value)
+
+ def set_number(self, number):
+ self.value.setValue(number)
+
+ def has_focus(self) -> bool:
+ return self.value.hasFocus()
+
+ def set_on_return(self, func):
+ """Connect a function to the Enter/Return key press."""
+ self.value.editingFinished.connect(
+ partial(func, self.value, lambda: self.value.text())
+ )
+
+class IPAdressInputField(QWidget):
+ def __init__(self, topic, label):
+ super().__init__()
+ self.topic = topic
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 0, 0)
+ layout.setSpacing(0)
+ self.label = QLabel(label)
+ self.label.setFixedWidth(150)
+ layout.addWidget(self.label)
+ self.oct0 = QLineEdit()
+ self.oct0.setPlaceholderText('0')
+ self.oct0.setFixedWidth(30)
+ layout.addWidget(self.oct0)
+ separator1 = QLabel('.')
+ layout.addWidget(separator1)
+ self.oct1 = QLineEdit()
+ self.oct1.setPlaceholderText('0')
+ self.oct1.setFixedWidth(30)
+ layout.addWidget(self.oct1)
+ separator2 = QLabel('.')
+ layout.addWidget(separator2)
+ self.oct2 = QLineEdit()
+ self.oct2.setPlaceholderText('0')
+ self.oct2.setFixedWidth(30)
+ layout.addWidget(self.oct2)
+ separator3 = QLabel('.')
+ layout.addWidget(separator3)
+ self.oct3 = QLineEdit()
+ self.oct3.setPlaceholderText('0')
+ self.oct3.setFixedWidth(30)
+ layout.addWidget(self.oct3)
+
+ self.oct0.editingFinished.connect(partial(self.check_octet, self.oct0))
+ self.oct1.editingFinished.connect(partial(self.check_octet, self.oct1))
+ self.oct2.editingFinished.connect(partial(self.check_octet, self.oct2))
+ self.oct3.editingFinished.connect(partial(self.check_octet, self.oct3))
+
+ def check_octet(self, octet):
+ if octet.text().isnumeric():
+ if int(octet.text()) < 0:
+ octet.setText('0')
+ if int(octet.text()) > 254:
+ octet.setText('254')
+ else:
+ octet.setText('')
+
+ def get_ip(self):
+ return f'{self.oct0.text()}.{self.oct1.text()}.{self.oct2.text()}.{self.oct3.text()}'
+
+ def set_ip(self, ip):
+ octets = ip.split('.')
+ if len(octets) == 4 and all(octet.isnumeric() for octet in octets):
+ if all(int(octet) > 0 and int(octet) < 254 for octet in octets):
+ self.oct0.setText(octets[0])
+ self.oct1.setText(octets[1])
+ self.oct2.setText(octets[2])
+ self.oct3.setText(octets[3])
+
+class ComboBox(QWidget):
+ def __init__(self, enums, label):
+ super().__init__()
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 0, 0)
+ layout.setSpacing(0)
+ self.label = QLabel(label)
+ self.label.setFixedWidth(150)
+ layout.addWidget(self.label)
+ self.value = QComboBox()
+ self.value.setFixedWidth(160)
+ for entry in enums:
+ self.value.addItem(entry)
+ layout.addWidget(self.value)
+
+ def set_current_text(self, text):
+ self.value.setCurrentText(text)
+
+ def has_focus(self) -> bool:
+ return QApplication.focusWidget() is self.value.view()
+
+ def set_on_change(self, func, reset_plot=False):
+ """Connect a function to the Enter/Return key press."""
+ self.value.activated.connect(
+ partial(func, self.value, lambda: self.value.currentText(), reset_plot)
+ )
+
+class LED(QWidget):
+ def __init__(self, states, colors, label):
+ super().__init__()
+ self.states = states
+ self.colors = colors
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 0, 0)
+ layout.setSpacing(0)
+ self.label = QLabel(label)
+ self.label.setFixedWidth(150)
+ layout.addWidget(self.label)
+ self.led = QLabel()
+ self.led.setFixedWidth(160)
+ layout.addWidget(self.led)
+
+ def apply_color(self, val):
+ color = self.colors[self.states.index(val)]
+ self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;")
+
+class StartStop(QWidget):
+ def __init__(self, label, label_buttons=['Start', 'Stop']):
+ super().__init__()
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 0, 0)
+ layout.setSpacing(0)
+ self.label = QLabel(label)
+ self.label.setFixedWidth(150)
+ layout.addWidget(self.label)
+ self.start = QPushButton(label_buttons[0])
+ self.start.setStyleSheet("color: black; background-color: green;")
+ self.start.setFixedWidth(80)
+ self.stop = QPushButton(label_buttons[1])
+ self.stop.setStyleSheet("color: black; background-color: firebrick;")
+ self.stop.setFixedWidth(80)
+ layout.addWidget(self.start)
+ layout.addWidget(self.stop)
+
+ def set_on_start(self, func):
+ """Connect a function to the start button press."""
+ self.start.clicked.connect(func)
+
+ def set_on_stop(self, func):
+ """Connect a function to the stop button press."""
+ self.stop.clicked.connect(func)
+
+ def enable_start(self):
+ self.start.setEnabled(True)
+ self.start.setStyleSheet("color: black; background-color: green;")
+
+ def enable_stop(self):
+ self.stop.setEnabled(True)
+ self.stop.setStyleSheet("color: black; background-color: firebrick;")
+
+ def disable_start(self):
+ self.start.setEnabled(False)
+ self.start.setStyleSheet("color: black; background-color: grey;")
+
+ def disable_stop(self):
+ self.stop.setEnabled(False)
+ self.stop.setStyleSheet("color: black; background-color: grey;")
+
+class Button(QWidget):
+ def __init__(self, label, label_button):
+ super().__init__()
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 0, 0)
+ layout.setSpacing(0)
+ self.label = QLabel(label)
+ self.label.setFixedWidth(150)
+ layout.addWidget(self.label)
+ self.button = QPushButton(label_button)
+ self.button.setStyleSheet("color: black; background-color: dodgerblue;")
+ self.button.setFixedWidth(160)
+ layout.addWidget(self.button)
+
+ def set_on_press(self, func):
+ """Connect a function to the button press."""
+ self.button.clicked.connect(func)
+
+ def enable_button(self):
+ self.button.setEnabled(True)
+ self.button.setStyleSheet("color: black; background-color: dodgerblue;")
+
+ def disable_button(self):
+ self.button.setEnabled(False)
+ self.button.setStyleSheet("color: black; background-color: grey;")
+
+ def set_button_text(self, text):
+ self.button.setText(text)