wip: digital twin widget
CI for debye_bec / test (push) Failing after 1m5s
CI for debye_bec / test (pull_request) Failing after 1m2s

This commit is contained in:
x01da
2026-04-29 14:22:10 +02:00
parent 588152871c
commit 339adab06c
9 changed files with 1252 additions and 0 deletions
+31
View File
@@ -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.
"""
@@ -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
@@ -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"<h2>{title}</h2>")
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_())
@@ -0,0 +1 @@
{'files': ['digital_twin.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 = """
<ui language='c++'>
<widget class='DigitalTwin' name='digital_twin'>
</widget>
</ui>
"""
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()
@@ -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()
@@ -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],)
+273
View File
@@ -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)