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)