diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 2326e1d..5c1ab1e 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -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.""" diff --git a/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py new file mode 100644 index 0000000..2067042 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/digital_twin/move_widget.py @@ -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()) diff --git a/debye_bec/bec_widgets/widgets/qt_widgets.py b/debye_bec/bec_widgets/widgets/qt_widgets.py index c5f2057..636b4fe 100644 --- a/debye_bec/bec_widgets/widgets/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/qt_widgets.py @@ -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;") \ No newline at end of file +# 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()) +# )