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

This commit is contained in:
x01da
2026-05-07 14:52:54 +02:00
parent 0365d6eac7
commit 8493b60468
3 changed files with 797 additions and 144 deletions
@@ -5,6 +5,7 @@ Digital Twin: Custom BEC widget to support the beamline alignment.
import sys
import numpy as np
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
# pylint: disable=E0611
from qtpy.QtWidgets import (
@@ -22,6 +23,7 @@ from qtpy.QtCore import (
from qtpy.QtGui import (
QColor,
QBrush,
QCloseEvent,
)
import pyqtgraph as pg
@@ -33,8 +35,8 @@ from debye_bec.bec_widgets.widgets.qt_widgets import (
ComboBox,
Group,
NumberIndicator,
Mover,
)
from debye_bec.bec_widgets.widgets.digital_twin.move_widget import MoveWidget
from debye_bec.bec_widgets.widgets.digital_twin.calc_positions import calc_positions
from debye_bec.bec_widgets.widgets.digital_twin.calc_sideview import calc_sideview
from debye_bec.bec_widgets.widgets.digital_twin.calc_surfaces import calc_surfaces
@@ -77,14 +79,14 @@ class DigitalTwin(BECWidget, QWidget):
self.sideview_plot = SideviewPlot()
self.surface_plots = SurfacePlots()
self.positions = PositionsPanel()
# self.mover = MoverPanel(dev=self.dev)
self.mover = MoverPanel(self.dev)
self.root_layout.addWidget(self.input, stretch=1, alignment=Qt.AlignTop) # type: ignore
self.plot_layout.addWidget(self.sideview_plot) # type: ignore
self.plot_layout.addWidget(self.surface_plots) # type: ignore
self.root_layout.addWidget(self.plot_widget, stretch=1, alignment=Qt.AlignTop) # type: ignore
self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) # type: ignore
# self.root_layout.addWidget(self.mover, stretch=1, alignment=Qt.AlignTop) # type: ignore
# self.root_layout.addWidget(self.positions, stretch=1, alignment=Qt.AlignTop) # type: ignore
self.root_layout.addWidget(self.mover, stretch=1, alignment=Qt.AlignTop)
self.setLayout(self.root_layout)
self.setWindowTitle("Digital Twin")
@@ -112,13 +114,14 @@ class DigitalTwin(BECWidget, QWidget):
# Timer: update plot every 1 second
self._timer = QTimer(self)
self._timer.setInterval(1000)
self._timer.setInterval(100)
self._timer.timeout.connect(self.calc_reality)
self._timer.start()
def apply_theme(self, theme):
self.sideview_plot.apply_theme(theme)
self.surface_plots.apply_theme(theme)
self.mover.apply_theme(theme)
@SafeSlot()
def calc_assistant(self, *args, **kwargs):
@@ -203,38 +206,60 @@ class DigitalTwin(BECWidget, QWidget):
return config
def get_reality_config(self):
if abs(self.dev.mo1_trx.read()['mo1_trx']['value']) > 5:
mo1_trx = self.dev.mo1_trx.read(cached=True)['mo1_trx']['value']
if abs(mo1_trx) > 5:
mo1_mode = 'Monochromatic'
else:
mo1_mode = 'Pinkbeam'
mo1_bragg = self.dev.mo1_bragg.read()
sldi_gapx = self.dev.sldi_gapx.read()['sldi_gapx']['value']
sldi_gapy = self.dev.sldi_gapy.read()['sldi_gapy']['value']
mo1_bragg = self.dev.mo1_bragg.read(cached=True)
sldi_gapx = self.dev.sldi_gapx.read(cached=True)['sldi_gapx']['value']
sldi_gapy = self.dev.sldi_gapy.read(cached=True)['sldi_gapy']['value']
h_acc, v_acc = sldi_gap_to_acc(sldi_gapx, sldi_gapy)
cm_trx = -self.dev.cm_trx.read()['cm_trx']['value']
cm_trx = -self.dev.cm_trx.read(cached=True)['cm_trx']['value']
cm_stripe = cm_trx_to_stripe(cm_trx)
cm_pitch = -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3
fm_trx = -self.dev.fm_trx.read()['fm_trx']['value']
cm_pitch = self.dev.cm_rotx.read(cached=True)['cm_rotx']['value']
fm_trx = -self.dev.fm_trx.read(cached=True)['fm_trx']['value']
fm_stripe = fm_trx_to_stripe(fm_trx)
fm_pitch = -self.dev.fm_rotx.read()['fm_rotx']['value'] * 1e-3
fm_pitch = self.dev.fm_rotx.read(cached=True)['fm_rotx']['value']
fm_pitch_real = 2 * cm_pitch - fm_pitch
smpl = self.dev.ot_es1_trz.read(cached=True)['ot_es1_trz']['value']
config = { # Config in SI units!
'energy' : mo1_bragg['mo1_bragg']['value'],
'h_acc' : h_acc,
'v_acc' : v_acc,
'cm_pitch' : cm_pitch,
'cm_pitch' : -cm_pitch * 1e-3,
'cm_stripe' : cm_stripe,
'cm_trx' : cm_trx,
'mo1_mode' : mo1_mode,
'mo1_xtal' : mo1_bragg['mo1_bragg_crystal_current_xtal_string']['value'],
'mo1_bragg' : mo1_bragg['mo1_bragg_angle']['value']/180*np.pi,
'fm_pitch' : fm_pitch_real,
'fm_pitch' : -fm_pitch_real * 1e-3,
'fm_stripe' : fm_stripe,
'fm_trx' : fm_trx,
'fm_gain_height' : 1,
'smpl' : self.dev.ot_es1_trz.read()['ot_es1_trz']['value'],
'smpl' : smpl,
}
# logger.info(f'Config created: {config}')
self.mover.sldi_gapx.set_feedback(sldi_gapx)
self.mover.sldi_gapy.set_feedback(sldi_gapy)
self.mover.cm_trx.set_feedback(cm_trx)
self.mover.cm_try.set_feedback(self.dev.cm_try.read(cached=True)['cm_try']['value'])
self.mover.cm_bnd.set_feedback(self.dev.cm_bnd_radius.read(cached=True)['cm_bnd_radius']['value'])
self.mover.cm_rotx.set_feedback(cm_pitch)
self.mover.mo1_bragg_angle.set_feedback(mo1_bragg['mo1_bragg_angle']['value'])
self.mover.mo1_trx.set_feedback(mo1_trx)
self.mover.mo1_try.set_feedback(self.dev.mo1_try.read(cached=True)['mo1_try']['value'])
self.mover.sl1_centery.set_feedback(self.dev.sl1_centery.read(cached=True)['sl1_centery']['value'])
self.mover.bm1_try.set_feedback(self.dev.bm1_try.read(cached=True)['bm1_try']['value'])
self.mover.fm_trx.set_feedback(fm_trx)
self.mover.fm_try.set_feedback(self.dev.fm_try.read(cached=True)['fm_try']['value'])
self.mover.fm_bnd.set_feedback(self.dev.fm_bnd_radius.read(cached=True)['fm_bnd_radius']['value'])
self.mover.fm_rotx.set_feedback(fm_pitch)
self.mover.sl2_centery.set_feedback(self.dev.sl2_centery.read(cached=True)['sl2_centery']['value'])
self.mover.bm2_try.set_feedback(self.dev.bm2_try.read(cached=True)['bm2_try']['value'])
self.mover.ot_try.set_feedback(self.dev.ot_try.read(cached=True)['ot_try']['value'])
self.mover.ot_rotx.set_feedback(self.dev.ot_rotx.read(cached=True)['ot_rotx']['value'])
self.mover.ot_es1_trz.set_feedback(smpl)
return config
def update_fm_mode(self):
@@ -330,18 +355,39 @@ class DigitalTwin(BECWidget, QWidget):
self.positions.ot_rotx.setValue(out['ot_rotx']['value'])
self.positions.ot_es1_trz.setValue(out['ot_es1_trz']['value'])
self.mover.sldi_gapx.set_target(out['sldi_gapx']['value'])
self.mover.sldi_gapy.set_target(out['sldi_gapy']['value'])
self.mover.cm_trx.set_target(out['cm_trx']['value'])
self.mover.cm_try.set_target(out['cm_try']['value'])
self.mover.cm_bnd.set_target(out['cm_bnd_radius']['value'])
self.mover.cm_rotx.set_target(out['cm_rotx']['value'])
self.mover.mo1_bragg_angle.set_target(out['mo1_bragg_angle']['value'])
self.mover.mo1_trx.set_target(out['mo1_trx']['value'])
self.mover.mo1_try.set_target(out['mo1_try']['value'])
self.mover.sl1_centery.set_target(out['sl1_centery']['value'])
self.mover.bm1_try.set_target(out['bm1_try']['value'])
self.mover.fm_trx.set_target(out['fm_trx']['value'])
self.mover.fm_try.set_target(out['fm_try']['value'])
self.mover.fm_bnd.set_target(out['fm_bnd_radius']['value'])
self.mover.fm_rotx.set_target(out['fm_rotx']['value'])
self.mover.sl2_centery.set_target(out['sl2_centery']['value'])
self.mover.bm2_try.set_target(out['bm2_try']['value'])
self.mover.ot_try.set_target(out['ot_try']['value'])
self.mover.ot_rotx.set_target(out['ot_rotx']['value'])
self.mover.ot_es1_trz.set_target(out['ot_es1_trz']['value'])
def calc_mo1_bragg_angle(self):
"""
Calculates bragg angle in rad
"""
xtal = self.input.mo1_xtal.currentText()
if xtal in 'Si(111)':
d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si111.get()
d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si111.read(cached=True)['mo1_bragg_crystal_d_spacing_si111']['value']
elif xtal in 'Si(311)':
d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si311.get()
d_spacing = self.dev.mo1_bragg.crystal.d_spacing_si311.read(cached=True)['mo1_bragg_crystal_d_spacing_si311']['value']
else:
raise Exception(f'Invalid xtal selection: {xtal}')
cm_pitch = -self.dev.cm_rotx.read()['cm_rotx']['value'] * 1e-3
cm_pitch = -self.dev.cm_rotx.read(cached=True)['cm_rotx']['value'] * 1e-3
mo1_mode = self.input.mo1_mode.currentText()
energy = self.input.energy.value()
theta, theta_cor = mo1_bragg_angle(mo1_mode, d_spacing, energy, cm_pitch)
@@ -607,19 +653,20 @@ class MoverPanel(QWidget):
def __init__(self, dev, parent=None):
super().__init__(parent)
self.dev = dev
self._layout = QVBoxLayout(self)
self._layout.setSizeConstraint(QLayout.SetFixedSize) # type: ignore
self.mover_widgets = []
self.dev = dev
# FE Slits
mot = self.dev.sldi_gapx
egu = self.dev.sldi_gapx.egu()
prec = self.dev.sldi_gapx.precision
self.sldi_gapx = Mover(mot, egu, prec)
self.sldi_gapx = MoveWidget(motor=mot, label='GAPX', unit='mm', decimals=2)
self.mover_widgets.append(self.sldi_gapx)
mot = self.dev.sldi_gapy
egu = self.dev.sldi_gapy.egu()
prec = self.dev.sldi_gapy.precision
self.sldi_gapy = Mover(mot, egu, prec)
self.sldi_gapy = MoveWidget(motor=mot, label='GAPY', unit='mm', decimals=2)
self.mover_widgets.append(self.sldi_gapy)
self.sldi_mov_group = Group(
'FE Slits',
@@ -629,16 +676,174 @@ class MoverPanel(QWidget):
]
)
# Assemble complete assitant group
# Collimating mirror
mot = self.dev.cm_trx
self.cm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2)
self.mover_widgets.append(self.cm_trx)
mot = self.dev.cm_try
self.cm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2)
self.mover_widgets.append(self.cm_try)
mot = self.dev.cm_bnd
self.cm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2)
self.mover_widgets.append(self.cm_bnd)
mot = self.dev.cm_rotx
self.cm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3)
self.mover_widgets.append(self.cm_rotx)
self.cm_mov_group = Group(
'Collimating Mirror',
[
self.cm_trx,
self.cm_try,
self.cm_bnd,
self.cm_rotx,
]
)
# Monochromator
mot = self.dev.mo1_bragg
self.mo1_bragg_angle = MoveWidget(motor=mot, label='Bragg Angle', unit='deg', decimals=3)
self.mover_widgets.append(self.mo1_bragg_angle)
mot = self.dev.mo1_trx
self.mo1_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2)
self.mover_widgets.append(self.mo1_trx)
mot = self.dev.mo1_try
self.mo1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2)
self.mover_widgets.append(self.mo1_try)
self.mo1_mov_group = Group(
'Monochromator',
[
self.mo1_bragg_angle,
self.mo1_trx,
self.mo1_try,
]
)
# OP Slits 1
mot = self.dev.sl1_centery
self.sl1_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2)
self.mover_widgets.append(self.sl1_centery)
self.sl1_mov_group = Group(
'OP Slits 1',
[
self.sl1_centery,
]
)
# OP Beam Monitor 1
mot = self.dev.bm1_try
self.bm1_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2)
self.mover_widgets.append(self.bm1_try)
self.bm1_mov_group = Group(
'OP Beam Monitor 1',
[
self.bm1_try,
]
)
# Focusing Mirror
mot = self.dev.fm_trx
self.fm_trx = MoveWidget(motor=mot, label='TRX', unit='mm', decimals=2)
self.mover_widgets.append(self.fm_trx)
mot = self.dev.fm_try
self.fm_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2)
self.mover_widgets.append(self.fm_try)
mot = self.dev.fm_bnd
self.fm_bnd = MoveWidget(motor=mot, label='BENDER', unit='km', decimals=2)
self.mover_widgets.append(self.fm_bnd)
mot = self.dev.fm_rotx
self.fm_rotx = MoveWidget(motor=mot, label='PITCH', unit='mrad', decimals=3)
self.mover_widgets.append(self.fm_rotx)
self.fm_mov_group = Group(
'Focusing Mirror',
[
self.fm_trx,
self.fm_try,
self.fm_bnd,
self.fm_rotx,
]
)
# OP Slits 2
mot = self.dev.sl2_centery
self.sl2_centery = MoveWidget(motor=mot, label='CENTERY', unit='mm', decimals=2)
self.mover_widgets.append(self.sl2_centery)
self.sl2_mov_group = Group(
'OP Slits 2',
[
self.sl2_centery,
]
)
# OP Beam Monitor 2
mot = self.dev.bm2_try
self.bm2_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2)
self.mover_widgets.append(self.bm2_try)
self.bm2_mov_group = Group(
'OP Beam Monitor 2',
[
self.bm2_try,
]
)
# Optical Table
mot = self.dev.ot_try
self.ot_try = MoveWidget(motor=mot, label='TRY', unit='mm', decimals=2)
self.mover_widgets.append(self.ot_try)
mot = self.dev.ot_rotx
self.ot_rotx = MoveWidget(motor=mot, label='ROTX', unit='mrad', decimals=3)
self.mover_widgets.append(self.ot_rotx)
mot = self.dev.ot_es1_trz
self.ot_es1_trz = MoveWidget(motor=mot, label='ES1 TRZ', unit='mm', decimals=0)
self.mover_widgets.append(self.ot_es1_trz)
self.ot_mov_group = Group(
'Optical Table',
[
self.ot_try,
self.ot_rotx,
self.ot_es1_trz,
]
)
# Assemble complete mover group
self.mover_group = Group(
'Mover',
[
self.sldi_mov_group,
self.cm_mov_group,
self.mo1_mov_group,
self.sl1_mov_group,
self.bm1_mov_group,
self.fm_mov_group,
self.sl2_mov_group,
self.bm2_mov_group,
self.ot_mov_group,
]
)
self._layout .addWidget(self.mover_group)
self._layout .addStretch()
def apply_theme(self, theme):
for widget in self.mover_widgets:
widget.apply_theme(theme)
class SurfacePlots(QWidget):
"""Plot widget with two curves and legend."""
@@ -0,0 +1,448 @@
import time
import random
import threading
# import qtawesome as qta
from bec_qthemes import material_icon
from bec_widgets.utils.colors import get_accent_colors
from bec_lib import bec_logger
from qtpy.QtCore import Qt, QThread, Signal, QObject, Property, QPropertyAnimation
from qtpy.QtWidgets import (
QGroupBox, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
QDoubleSpinBox, QFrame, QWidget, QApplication
)
from qtpy.QtGui import QTransform
logger = bec_logger.logger
class Status:
IN_POSITION = "in_position" # green mdi.check-circle
NOT_IN_POSITION = "not_in_position" # orange mdi.close-circle
MOVING = "moving" # blue mdi.loading (spinning)
ERROR = "error" # red mdi.alert-circle
# class StatusIcon(qta.IconWidget):
# """
# Displays a status icon using qtawesome Material Design Icons.
# Handles its own spin animation for the MOVING state via qta.Spin.
# """
# ICON_SIZE = 36
# # Map each status to an (icon_name, color) pair
# _ICON_MAP = {
# Status.IN_POSITION: ("mdi.check-circle-outline", "#27ae60"),
# Status.NOT_IN_POSITION: ("mdi.close-circle-outline", "#e6d922"),
# Status.ERROR: ("mdi.alert-outline", "#e74c3c"),
# Status.MOVING: ("mdi.gamepad-circle-outline", "#2980b9"),
# }
# def __init__(self, parent=None):
# super().__init__(parent=parent)
# self._status = None
# self._spin_anim = qta.Spin(self, autostart=True)
# self.setFixedSize(self.ICON_SIZE, self.ICON_SIZE)
# self.set_status(Status.NOT_IN_POSITION)
# def set_status(self, status: str):
# if status == self._status:
# return
# self._status = status
# icon_name, color = self._ICON_MAP[status]
# if status == Status.MOVING:
# icon = qta.icon(icon_name, color=color, animation=self._spin_anim)
# self._spin_anim.start()
# else:
# self._spin_anim.stop()
# icon = qta.icon(icon_name, color=color)
# self.setIcon(icon)
# self.setIconSize(QSize(self.ICON_SIZE, self.ICON_SIZE))
class StatusIcon(QWidget):
"""
Displays a status icon using bec_qthemes Material Design Icons.
Handles its own spin animation for the MOVING state via QPropertyAnimation.
"""
ICON_SIZE = 20
_ICON_MAP = {
Status.IN_POSITION: ("check_circle", "#27ae60"),
Status.NOT_IN_POSITION: ("cancel", "#e6d922"),
Status.ERROR: ("warning", "#e74c3c"),
Status.MOVING: ("cycle", "#2980b9"),
}
def __init__(self, parent=None):
super().__init__(parent=parent)
self._status = None
self._rotation = 0.0
self._label = QLabel(self)
self._label.setFixedSize(self.ICON_SIZE, self.ICON_SIZE)
self._label.setAlignment(Qt.AlignCenter)
self.setFixedSize(self.ICON_SIZE, self.ICON_SIZE)
self._spin_anim = QPropertyAnimation(self, b"rotation")
self._spin_anim.setStartValue(0)
self._spin_anim.setEndValue(360)
self._spin_anim.setDuration(1000)
self._spin_anim.setLoopCount(-1) # Loop indefinitely
self.set_status(Status.NOT_IN_POSITION)
def get_rotation(self):
return self._rotation
def set_rotation(self, angle):
self._rotation = angle
if self._current_pixmap_base is not None:
cx = self._current_pixmap_base.width() / 2
cy = self._current_pixmap_base.height() / 2
t = QTransform().translate(cx, cy).rotate(angle).translate(-cx, -cy)
self._label.setPixmap(self._current_pixmap_base.transformed(t, Qt.SmoothTransformation))
rotation = Property(float, get_rotation, set_rotation)
def set_status(self, status: str):
if status == self._status:
return
self._status = status
icon_name, color = self._ICON_MAP[status]
icon = material_icon(icon_name, size=(self.ICON_SIZE, self.ICON_SIZE), color=color, convert_to_pixmap=True)
self._current_pixmap_base = icon
if status == Status.MOVING:
self._spin_anim.start()
else:
self._spin_anim.stop()
self._label.setPixmap(icon)
class MotionWorker(QObject):
"""
Simulates moving a stage from current_pos to target_pos.
Emits position_changed and finished signals.
"""
position_changed = Signal(float)
error = Signal(bool) # True = error
finished = Signal(bool) # True = reached target, False = stopped
def __init__(self, motor, target_pos: float):
super().__init__()
self.motor = motor
self.name = motor.dotted_name
self._target = target_pos
self._stop_flag = threading.Event()
def stop(self):
self._stop_flag.set()
def run(self):
logger.info(f'Would run motor {self.name}')
simulated_run_time = 3
start = time.time()
while (time.time() - start) < simulated_run_time:
if self._stop_flag.is_set():
break
time.sleep(0.01)
# self.motor.move(self._target, relative=False)
# while self.motor.motor_is_moving.get():
# if self._stop_flag.is_set():
# self.motor.motor_stop()
# self.position_changed.emit(self.motor.read[self.name]['value'])
# time.sleep(0.1)
self.finished.emit(True)
# def run(self):
# """Simulate motion: move in small steps with realistic deceleration."""
# distance = abs(self._target - self._current)
# direction = 1 if self._target > self._current else -1
# speed = max(0.5, distance / 3.0) # units/second (simulated)
# step_time = 0.05 # seconds per step
# pos = self._current
# while not self._stop_flag.is_set():
# remaining = abs(self._target - pos)
# if remaining < 0.01:
# pos = self._target
# self.position_changed.emit(round(pos, 4))
# self.finished.emit(True)
# return
# # Decelerate near the end
# effective_speed = speed * min(1.0, remaining / (distance * 0.2 + 0.001))
# effective_speed = max(0.05, effective_speed)
# step = effective_speed * step_time * direction
# if abs(step) > remaining:
# step = remaining * direction
# pos += step
# pos += random.gauss(0, 0.002) # tiny simulated encoder noise
# if pos > 20: # Simulated error if above 20 mm
# self.error.emit(True)
# return
# self.position_changed.emit(round(pos, 4))
# time.sleep(step_time)
# # Stopped by user
# self.position_changed.emit(round(pos, 4))
# self.finished.emit(False)
class MoveWidget(QWidget):
"""
One motor stage control group containing:
- Value spinbox (target position)
- Feedback label (current position)
- Status icon (qtawesome)
- Start / Stop button
"""
DEADBAND = 0.02 # mm — positions within this tolerance are "in position"
def __init__(self, motor, label: str = '', unit=None, decimals=3):
super().__init__()
self.fb = 0.0
self.target = 0
self.motor = motor
self._status = Status.IN_POSITION
self._thread: QThread | None = None
self._worker: MotionWorker | None = None
self.text_color = (0, 0, 0)
self.unit = unit
self.decimals = decimals
# self._set_status(Status.IN_POSITION)
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 0, 0, 0)
layout.setSpacing(0)
# Name
self.label = QLabel(label)
self.label.setFixedWidth(100)
self.label.setContentsMargins(0, 0, 10, 0)
self.label.setWordWrap(True)
layout.addWidget(self.label)
# Target
self.target_label = QLabel('-')
self.target_label.setFixedWidth(100)
layout.addWidget(self.target_label)
# Feedback
self.fb_label = QLabel('-')
self.fb_label.setFixedWidth(100)
layout.addWidget(self.fb_label)
# Status icon
self.status_icon = StatusIcon()
self.status_icon.setFixedWidth(30)
self.status_icon.setContentsMargins(0, 0, 10, 0)
layout.addWidget(self.status_icon)
# Start / Stop button
self.btn_action = QPushButton("▶ Move")
self.btn_action.setFixedWidth(90)
self.btn_action.setFixedHeight(20)
self.btn_action.clicked.connect(self._on_button_clicked)
layout.addWidget(self.btn_action)
self._apply_button_style("start")
self.apply_theme()
def apply_theme(self, theme=None):
if theme is None:
app = QApplication.instance()
theme = app.theme.theme # type: ignore
if theme == "light":
self.text_color = {'target': (79, 163, 224), 'fb': (240, 128, 60)}
else: # dark theme
self.text_color = {'target': (26, 111, 173), 'fb': (212, 83, 10)}
r, g, b = self.text_color['target']
self.target_label.setStyleSheet(f'QLabel {{color: rgb({r}, {g}, {b})}}')
r, g, b = self.text_color['fb']
self.fb_label.setStyleSheet(f'QLabel {{color: rgb({r}, {g}, {b})}}')
def set_target(self, target):
self.target = target
text = f'{target:.{int(self.decimals)}f}'
if self.unit is not None:
text = text + ' ' + self.unit
self.target_label.setText(text)
self._on_target_or_fb_changed()
def set_feedback(self, fb):
if self._status != Status.MOVING:
self.fb = fb
text = f'{fb:.{int(self.decimals)}f}'
if self.unit is not None:
text = text + ' ' + self.unit
self.fb_label.setText(text)
self._on_target_or_fb_changed()
# ------------------------------------------------------------------
# Button style helpers
# ------------------------------------------------------------------
def _apply_button_style(self, mode: str):
if mode == "start":
self.btn_action.setText("▶ Move")
self.btn_action.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().success.name()}; color: white;}}"
)
else: # stop
self.btn_action.setText("■ Stop")
self.btn_action.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
)
# ------------------------------------------------------------------
# Status management
# ------------------------------------------------------------------
def _set_status(self, status: str):
self._status = status
self.status_icon.set_status(status)
# ------------------------------------------------------------------
# Motion control
# ------------------------------------------------------------------
def _on_target_or_fb_changed(self):
"""Re-evaluate in-position status whenever the target value changes."""
if self._status in (Status.ERROR, Status.MOVING):
return
if abs(self.fb - self.target) <= self.DEADBAND:
self._set_status(Status.IN_POSITION)
else:
self._set_status(Status.NOT_IN_POSITION)
def _on_button_clicked(self):
if self._thread and self._thread.isRunning():
self._stop_motion()
else:
self._start_motion()
def _start_motion(self):
target = self.target
if abs(target - self.fb) <= self.DEADBAND:
self._set_status(Status.IN_POSITION)
return
self._set_status(Status.MOVING)
self._apply_button_style("stop")
self._worker = MotionWorker(self.motor, target)
self._thread = QThread()
self._worker.moveToThread(self._thread)
# Wire signals
self._thread.started.connect(self._worker.run)
self._worker.position_changed.connect(self._on_position_changed)
self._worker.error.connect(self._on_error)
self._worker.error.connect(self._thread.quit)
self._worker.finished.connect(self._on_motion_finished)
self._worker.finished.connect(self._thread.quit)
self._thread.finished.connect(self._cleanup_thread)
self._thread.start()
def _on_error(self):
self._set_status(Status.ERROR)
self._apply_button_style("start")
def _stop_motion(self):
if self._worker:
self._worker.stop()
# UI will update via finished signal
def _on_position_changed(self, pos: float):
self.fb = pos
text = f'{pos:.{int(self.decimals)}f}'
if self.unit is not None:
text = text + ' ' + self.unit
self.fb_label.setText(text)
def _on_motion_finished(self, reached: bool):
target = self.target
if abs(self.fb - target) <= self.DEADBAND:
self._set_status(Status.IN_POSITION)
else:
self._set_status(Status.NOT_IN_POSITION)
self._apply_button_style("start")
def _cleanup_thread(self):
if self._thread:
self._thread.deleteLater()
self._thread = None
if self._worker:
self._worker.deleteLater()
self._worker = None
# ------------------------------------------------------------------
# Called on application close — stop motion safely
# ------------------------------------------------------------------
def shutdown(self):
if self._worker:
self._worker.stop()
if self._thread:
self._thread.quit()
self._thread.wait(2000) # max 2 s grace period
# # ---------------------------------------------------------------------------
# # Main window
# # ---------------------------------------------------------------------------
# class MainWindow(QMainWindow):
# def __init__(self):
# super().__init__()
# self.setWindowTitle("Motor Stage Controller")
# self.setMinimumWidth(620)
# central = QWidget()
# self.setCentralWidget(central)
# layout = QVBoxLayout(central)
# layout.setContentsMargins(16, 16, 16, 16)
# layout.setSpacing(12)
# # Title
# title = QLabel("Motor Stage Controller")
# layout.addWidget(title)
# sep = QFrame()
# sep.setFrameShape(QFrame.HLine)
# layout.addWidget(sep)
# # Three example stage groups
# self.stages: list[StageGroupWidget] = []
# for axis in ("X Axis", "Y Axis", "Z Axis"):
# stage = StageGroupWidget(axis)
# self.stages.append(stage)
# layout.addWidget(stage)
# # Set different initial positions for demo variety
# self.stages[0].spin_value.setValue(25.0)
# self.stages[1].spin_value.setValue(-10.5)
# self.stages[2].spin_value.setValue(5.75)
# def closeEvent(self, event):
# """Stop all motion threads before closing."""
# for stage in self.stages:
# stage.shutdown()
# event.accept()
# if __name__ == "__main__":
# app = QApplication(sys.argv)
# # app.setStyle("Fusion")
# window = MainWindow()
# window.show()
# sys.exit(app.exec())
+117 -117
View File
@@ -15,32 +15,6 @@ class Group(QGroupBox):
for widget in widgets:
self.layout.addWidget(widget) # type: ignore
# class TextIndicator(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 NumberIndicator(QWidget):
def __init__(self, label='', unit=None, highlight=False, decimals=3):
super().__init__()
@@ -80,38 +54,6 @@ class NumberIndicator(QWidget):
text = text + ' ' + self.unit
self.val.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(140)
# self.label.setContentsMargins(0, 0, 10, 0)
# self.label.setWordWrap(True)
# layout.addWidget(self.label)
# self.val = QLineEdit()
# self.val.setPlaceholderText('0')
# # self.val.setFixedWidth(140)
# layout.addWidget(self.val)
# def set_text(self, text):
# self.val.setText(text)
# def has_focus(self) -> bool:
# return self.val.hasFocus()
# def text(self) -> str:
# return self.val.text()
# def set_on_return(self, func):
# """Connect a function to the Enter/Return key press."""
# self.val.returnPressed.connect(
# partial(func, self.val, self.topic, lambda: self.val.text())
# )
class InputNumberField(QWidget):
def __init__(self, identifier='', label='', unit=None, prefix=None, init=0.0, decimals=1, single_step=0.1, ll=-1e6, hl=1e6):
super().__init__()
@@ -186,70 +128,96 @@ class ComboBox(QWidget):
def setDisabled(self, disable):
self.value.setDisabled(disable)
class Mover(QWidget):
def __init__(self, dev, egu, prec):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 0, 0, 0)
layout.setSpacing(0)
self.position = QLabel('-')
self.position.setFixedWidth(150)
layout.addWidget(self.position)
self.led = QLabel()
self.led.setFixedWidth(30)
self.led.setStyleSheet("background-color: 0, 0, 0; border: 1px solid black;")
layout.addWidget(self.led)
self.start = QPushButton('Move')
self.start.setStyleSheet("color: black; background-color: green;")
self.start.setFixedWidth(80)
self.stop = QPushButton('Stop')
self.stop.setStyleSheet("color: black; background-color: firebrick;")
self.stop.setFixedWidth(80)
layout.addWidget(self.start)
layout.addWidget(self.stop)
self.dev = dev
self.unit = egu
self.decimals = prec
# class Mover(QWidget):
# def __init__(self, dev, egu, prec):
# super().__init__()
# layout = QHBoxLayout(self)
# layout.setContentsMargins(10, 0, 0, 0)
# layout.setSpacing(0)
# self.position = QLabel('-')
# self.position.setFixedWidth(150)
# layout.addWidget(self.position)
# self.led = QLabel()
# self.led.setFixedWidth(30)
# self.led.setStyleSheet("background-color: 0, 0, 0; border: 1px solid black;")
# layout.addWidget(self.led)
# self.start = QPushButton('Move')
# self.start.setStyleSheet("color: black; background-color: green;")
# self.start.setFixedWidth(80)
# self.stop = QPushButton('Stop')
# self.stop.setStyleSheet("color: black; background-color: firebrick;")
# self.stop.setFixedWidth(80)
# layout.addWidget(self.start)
# layout.addWidget(self.stop)
# self.dev = dev
# self.unit = egu
# self.decimals = prec
def led_set_status(self, status):
if status in 'out':
self.led.setStyleSheet("background-color: 255, 0, 0; border: 1px solid black;")
elif status in 'moving':
self.led.setStyleSheet("background-color: 255, 255, 0; border: 1px solid black;")
elif status in 'in':
self.led.setStyleSheet("background-color: 0, 255, 0; border: 1px solid black;")
# def led_set_status(self, status):
# if status in 'out':
# self.led.setStyleSheet("background-color: 255, 0, 0; border: 1px solid black;")
# elif status in 'moving':
# self.led.setStyleSheet("background-color: 255, 255, 0; border: 1px solid black;")
# elif status in 'in':
# self.led.setStyleSheet("background-color: 0, 255, 0; border: 1px solid black;")
def position_setValue(self, number):
text = f'{number:.{int(self.decimals)}f}'
if self.unit is not None:
text = text + ' ' + self.unit
self.position.setText(text)
# def position_setValue(self, number):
# text = f'{number:.{int(self.decimals)}f}'
# if self.unit is not None:
# text = text + ' ' + self.unit
# self.position.setText(text)
def start_clicked_connect(self, func):
"""Connect a function to the start button press."""
self.start.clicked.connect(
partial(func, dev=self.dev)
)
# def start_clicked_connect(self, func):
# """Connect a function to the start button press."""
# self.start.clicked.connect(
# partial(func, dev=self.dev)
# )
def stop_clicked_connect(self, func):
"""Connect a function to the stop button press."""
self.stop.clicked.connect(
partial(func, dev=self.dev)
)
# def stop_clicked_connect(self, func):
# """Connect a function to the stop button press."""
# self.stop.clicked.connect(
# partial(func, dev=self.dev)
# )
def start_setEnabled(self, enable):
self.start.setEnabled(enable)
if enable:
self.start.setStyleSheet("color: black; background-color: green;")
else:
self.start.setStyleSheet("color: black; background-color: grey;")
# def start_setEnabled(self, enable):
# self.start.setEnabled(enable)
# if enable:
# self.start.setStyleSheet("color: black; background-color: green;")
# else:
# self.start.setStyleSheet("color: black; background-color: grey;")
def stop_setEnabled(self, enable):
self.stop.setEnabled(enable)
if enable:
self.stop.setStyleSheet("color: black; background-color: firebrick;")
else:
self.stop.setStyleSheet("color: black; background-color: grey;")
# def stop_setEnabled(self, enable):
# self.stop.setEnabled(enable)
# if enable:
# self.stop.setStyleSheet("color: black; background-color: firebrick;")
# else:
# self.stop.setStyleSheet("color: black; background-color: grey;")
# class TextIndicator(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 Button(QWidget):
# def __init__(self, label, label_button):
@@ -297,4 +265,36 @@ class Mover(QWidget):
# def apply_color(self, val):
# color = self.colors[self.states.index(val)]
# self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;")
# self.led.setStyleSheet(f"background-color: {color}; border: 1px solid black;")
# 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(140)
# self.label.setContentsMargins(0, 0, 10, 0)
# self.label.setWordWrap(True)
# layout.addWidget(self.label)
# self.val = QLineEdit()
# self.val.setPlaceholderText('0')
# # self.val.setFixedWidth(140)
# layout.addWidget(self.val)
# def set_text(self, text):
# self.val.setText(text)
# def has_focus(self) -> bool:
# return self.val.hasFocus()
# def text(self) -> str:
# return self.val.text()
# def set_on_return(self, func):
# """Connect a function to the Enter/Return key press."""
# self.val.returnPressed.connect(
# partial(func, self.val, self.topic, lambda: self.val.text())
# )