From 7445a5aae6616b1a824ffcf57c32e9b95f6c4c19 Mon Sep 17 00:00:00 2001 From: Thierry Zamofing Date: Thu, 14 Jul 2022 15:10:49 +0200 Subject: [PATCH] add epics widgets, CustomROI --- CustomROI.py | 627 +++++++++++++++++++++++++++++ app_config.py | 51 +++ app_utils.py | 104 +++++ epics_widgets/MotorTweak.py | 355 ++++++++++++++++ epics_widgets/MotorTweak.ui | 172 ++++++++ epics_widgets/SmaractMotorTweak.py | 334 +++++++++++++++ epics_widgets/__init__.py | 0 qutilities.py | 51 +++ 8 files changed, 1694 insertions(+) create mode 100644 CustomROI.py create mode 100644 app_config.py create mode 100644 app_utils.py create mode 100644 epics_widgets/MotorTweak.py create mode 100644 epics_widgets/MotorTweak.ui create mode 100644 epics_widgets/SmaractMotorTweak.py create mode 100644 epics_widgets/__init__.py create mode 100644 qutilities.py diff --git a/CustomROI.py b/CustomROI.py new file mode 100644 index 0000000..412ddb7 --- /dev/null +++ b/CustomROI.py @@ -0,0 +1,627 @@ +import logging + +from PyQt5 import QtGui, QtCore +from PyQt5.QtGui import QColor, QTransform +from PyQt5.QtWidgets import QMenu, QAction, QSpinBox, QMenu + +from GenericDialog import GenericDialog +from pyqtgraph import ROI, Point, RectROI, mkPen +from pyqtgraph import functions as fn + +from app_config import settings + +from math import * + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +class Retangulo(ROI): + """ + Rectangular ROI subclass with a single scale handle at the top-right corner. + + ============== ============================================================= + **Arguments** + pos (length-2 sequence) The position of the ROI origin. + See ROI(). + size (length-2 sequence) The size of the ROI. See ROI(). + centered (bool) If True, scale handles affect the ROI relative to its + center, rather than its origin. + sideScalers (bool) If True, extra scale handles are added at the top and + right edges. + \**args All extra keyword arguments are passed to ROI() + ============== ============================================================= + + """ + + def __init__(self, pos, size, centered=False, sideScalers=False, **args): + ROI.__init__(self, pos, size, **args) + if centered: + center = [0.5, 0.5] + else: + center = [0, 0] + + self.addScaleRotateHandle([0, 0.5], [1, 0.5]) + self.addScaleHandle([1, 1], center) + if sideScalers: + self.addScaleHandle([1, 0.5], [center[0], 0.5]) + self.addScaleHandle([0.5, 1], [0.5, center[1]]) + + +class Grid(ROI): + """a grid""" + + def __init__( + self, + x_step, + y_step, + x_offset, + y_offset, + grid_index=0, + parent=None, + gonio_xy=(0, 0), + **kargs + ): + self._grid_index = grid_index + self._size = None + self._pos = None + self._shape = None + ROI.__init__(self, (500, 500), (300, 300), **kargs) + self.parent = parent + + self.addScaleHandle([1, 1], [0, 0]) + self.addScaleHandle([0, 0], [1, 1]) + self.addScaleRotateHandle([1, 0], [0, 0]) + # self.addScaleRotateHandle([1, 0.5], [0.5, 0.5]) + + self._steps = x_step / 1000., y_step / 1000. + self._offsets = x_offset / 1000, y_offset / 1000 + self._beam_position = parent.get_beam_mark_on_camera_xy() + self.set_gonio_xy(*gonio_xy) + + self.setToolTip("grid") + + parent.pixelsPerMillimeter.connect(self.update_ppm) + parent.beamCameraCoordinatesChanged.connect(self.update_beam_position) + self.enable_stage_tracking() + + self._ppm = parent.getPpm() + self._mm_size = 300 / self._ppm, 300 / self._ppm + self._original_ppm = self._ppm # marker only works at original zoom + # because zoom axis is not aligned + # with viewing axis + self._fx, self._fy = self.parent.getFastX(), self.parent.getFastY() + self.invertible = True + self.sigRegionChanged.connect(self.invalidate) + self.sigRegionChangeFinished.connect(self.calculate_gonio_xy) + self.scaleSnap = False + self.aspectLocked = False + self.translatable = True + self.rotateAllowed = False + self.removable = True + + self.follow_stage() + # self.calculate_gonio_xy() + + def enable_stage_tracking(self): + self.parent.fast_x_position.connect(self.follow_x) + self.parent.fast_y_position.connect(self.follow_y) + + def disable_stage_tracking(self): + self.parent.fast_x_position.disconnect(self.follow_x) + self.parent.fast_y_position.disconnect(self.follow_y) + + def follow_x(self, v): + self.follow_stage(fx=v) + + def follow_y(self, v): + self.follow_stage(fy=v) + + def removeClicked(self): + self.parent.pixelsPerMillimeter.disconnect(self.update_ppm) + self.parent.beamCameraCoordinatesChanged.disconnect(self.update_beam_position) + self.parent.fast_x_position.disconnect(self.follow_x) + self.parent.fast_y_position.disconnect(self.follow_y) + super(Grid, self).removeClicked() + + def calculate_gonio_xy(self): + self.update_grid_pos() + state = self.getState() + angle = state["angle"] + w, h = state["size"] + w, h = 1000 * w / self._ppm, 1000 * h / self._ppm + self._mm_size = w / 1000, h / 1000 + self.calculate_targets() + + def update_ppm(self, ppm): + self._ppm = ppm + self.update_grid_size(ppm) + self.update_grid_pos() + self.stateChanged() + + def update_grid_size(self, ppm, finish=False): + mmw, mmh = self._mm_size + w, h = mmw * ppm, mmh * ppm + self.setSize((w, h), update=False) + + def follow_stage(self, fx=None, fy=None): + self._fx, self._fy = self.parent.getFastX(), self.parent.getFastY() + self.update_grid_pos() + self.stateChanged() + + def set_gonio_xy(self, x, y): + self._gx, self._gy = x, y + + def update_grid_pos(self): + idx = self._grid_index + ppm = self._ppm + bx, by = self.parent.get_beam_mark_on_camera_xy() + gx, gy = self._gx, self._gy # marker position in gonio coords + fx, fy = self._fx, self._fy + dx, dy = ( + ppm * (fx - gx), + ppm * (fy - gy), + ) # distance to beam in pixels for new ppm + x, y = bx - dx, by + dy + self.setPos(x, y, update=False) + mmw, mmh = self._mm_size + + def calculate_targets(self): + state = self.getState() + angle = radians(state["angle"]) + cosa = cos(angle) + sina = sin(angle) + w, h = state["size"] + xs, ys = self._steps + w, h = w / self._ppm, h / self._ppm # mm + gx, gy = self._gx, self._gy + nx = int(abs(w) / xs) + ny = int(abs(h) / ys) + points = [] + dx = xs + dy = ys + for yi in range(ny): + ly = yi * dy + y = gy + ly + for xi in range(nx): + lx = xi * dx + x = gx + lx + l = sqrt((lx) ** 2 + (ly) ** 2) + try: + alpha = acos((lx) / l) + except: + alpha = 0 + x2 = gx + l * cos(alpha + angle) + y2 = gy - l * sin(alpha + angle) + # print( + # "l={:8.3f} alpha={:.3f} xy={:8.3f}{:8.3f} xy2={:8.3f}{:8.3f}".format( + # l, degrees(alpha), x, y, x2, y2 + # ) + # ) + + points.append((x2, y2)) + self._grid_dimensions = (nx, ny) + self._points = points + self.parent.gridUpdated.emit(self._grid_index) + + def get_sorted_grid_targets(self): + pass + + def get_grid_targets(self): + return self._points + + def update_beam_position(self, pos): + self._beam_position = pos + + def invalidate(self): + state = self.getState() + x, y = state["pos"] + w, h = state["size"] + self._mm_size = w / self._ppm, h / self._ppm + fx, fy = self.camera2gonio(x, y) + self.set_gonio_xy(fx, fy) + self._shape = None + self.prepareGeometryChange() + + def boundingRect(self): + return self.shape().boundingRect() + + def shape(self): + w, h = self.getState()["size"] + if self._shape is None: + radius = self.getState()["size"][1] + p = QtGui.QPainterPath() + p.addRect(0, 0, w, h) + stroker = QtGui.QPainterPathStroker() + stroker.setWidth(10) + outline = stroker.createStroke(p) + self._shape = outline + return self._shape + + def paint(self, p, *args): + state = self.getState() + w, h = state["size"] + p.setRenderHint(QtGui.QPainter.Antialiasing) + p.setPen(fn.mkPen(width=5, color=[200, 200, 100])) + p.drawRect(0, 0, w, h) + + p.setPen(fn.mkPen(width=3, color=[200, 100, 100])) + ppm = self._ppm + xs, ys = self._steps + w1, h1 = w / ppm, h / ppm # mm + nx = int(abs(w1) / xs) + ny = int(abs(h1) / ys) + sw = (xs) * ppm + sh = (ys) * ppm + # print((nx,ny), (w1, h1), (sw, sh)) + r = 10 + a, b = state["pos"] + if nx * ny > 1000: + return + for y in range(ny): + for x in range(nx): + p.drawEllipse((x * sw) - r, (y * sh) - r, 2 * r, 2 * r) + + def camera2gonio(self, x, y): + bx, by = self.parent.get_beam_mark_on_camera_xy() + ppm = self._ppm + dx, dy = (x - bx) / ppm, (y - by) / ppm + fx, fy = self._fx + dx, self._fy - dy + return fx, fy + + def gonio2camera(self, x, y): + ppm = self._ppm + bx, by = self.parent.get_beam_mark_on_camera_xy() + gx, gy = self._gx, self._gy # marker position in gonio coords + fx, fy = self._fx, self._fy + dx, dy = ( + ppm * (fx - gx), + ppm * (fy - gy), + ) # distance to beam in pixels for new ppm + x, y = bx - dx, by + dy + return x, y + + +class BeamMark(ROI): + """A crosshair ROI whose position is at the center of the crosshairs. By default, it is scalable, rotatable and translatable.""" + + def __init__(self, pos=None, size=None, parent=None, **kargs): + if size is None: + size = [20, 20] + if pos is None: + pos = [0, 0] + self._size = size + self._pos = pos + self._shape = None + ROI.__init__(self, pos, size, **kargs) + + parent.pixelsPerMillimeter.connect(self.my_update_ppm) + parent.beamCameraCoordinatesChanged.connect(lambda x, y: self.setPos((x, y))) + + self._scale_handler = None + self.sigRegionChanged.connect(self.invalidate) + self.aspectLocked = False + self.translatable = False + self.rotateAllowed = False + self.invertible = False + self.removable = False + self.scaleSnap = True + self.snapSize = 1 + self.sigRegionChangeFinished.connect(self.show_size) + self.flip_ud() + + def show_size(self): + try: + ppm = self._ppm + except: + return + w, h = self.getState()["size"] + # convert to micrometer + nw, nh = (w * 1000) / ppm, (h * 1000) / ppm + logger.debug("persisting new beam marker size: {}".format((nw, nh))) + settings.setValue("beam/size", (nw, nh)) + + def toggle_handle(self): + if self._scale_handler is None: + logger.debug("adding beam marker resize handle") + self._scale_handler = self.addScaleHandle([0.5, 0.5], [0, 0]) + else: + logger.debug("removing beam marker resize handle") + self.removeHandle(self._scale_handler) + self._scale_handler = None + + def get_camera_beam_position(self): + return self.getState()["pos"] + + def set_beam_size_marker_dialog(self): + w, h = settings.value("beam/size") + d = GenericDialog( + title="Beamsize", + message="Enter the size of the beam in microns", + inputs={ + "width": ("Width (\u00B5)", int(w), QSpinBox()), + "height": ("Height (\u00B5)", int(h), QSpinBox()), + }, + ) + if d.exec(): + results = d.results + logger.info("Updating beamsize to {}".format(results)) + w, h = results["width"], results["height"] + logger.debug("types {}".format(type(w))) + settings.setValue("beam/size", (w, h)) + self.set_beam_size((w, h)) + + def flip_ud(self): + """flip up-down""" + t = self.transform() + m11 = t.m11() + m12 = t.m12() + m13 = t.m13() + m21 = t.m21() + m22 = t.m22() + m23 = t.m23() + m31 = t.m31() + m32 = t.m32() + m33 = t.m33() + t.setMatrix(m11, m12, m13, m21, -m22, m23, m31, m32, m33) + self.setTransform(t) + + def set_beam_size(self, size=None): + if size is not None: + self._size = size + ppm = self._ppm + w, h = self._size + nw, nh = (w / 1000) * ppm, (h / 1000) * ppm + self.setSize((nw, nh)) + self.setToolTip("Beamsize (W x H): {:.1f}\u00B5 x {:.1f}\u00B5".format(w, h)) + + def my_update_ppm(self, ppm): + self._ppm = ppm + self.set_beam_size() + + def invalidate(self): + self._shape = None + self.prepareGeometryChange() + + def boundingRect(self): + return self.shape().boundingRect() + + def shape(self): + w, h = self.size() + if self._shape is None: + p = QtGui.QPainterPath() + p.moveTo(Point(0, -h)) + p.lineTo(Point(0, h)) + p.moveTo(Point(-w, 0)) + p.lineTo(Point(w, 0)) + # + p.moveTo(Point(-w, -h)) + p.lineTo(Point(w, -h)) + p.lineTo(Point(w, h)) + p.lineTo(Point(-w, -h)) + stroker = QtGui.QPainterPathStroker() + outline = stroker.createStroke(p) + self._shape = outline + + return self._shape + + def paint(self, p, *args): + w, h = self.getState()["size"] + + v1, v2 = -(h * 0.8) / 2, (h * 0.8) / 2 + h1, h2 = -(w * 0.8) / 2, (w * 0.8) / 2 + + p.setRenderHint(QtGui.QPainter.Antialiasing) + p.setPen(fn.mkPen(width=3, color=[200, 100, 100])) + p.drawLine(Point(0, v1), Point(0, v2)) + p.drawLine(Point(h1, 0), Point(h2, 0)) + + p.setPen(fn.mkPen(width=3, color=[200, 200, 100])) + p.drawRect(-w / 2, -h / 2, w, h) + # p.drawText(-w, -h, '{:.0f}x{:.0f}'.format(*self._size)) + + +class CrystalCircle(ROI): + """A crosshair ROI whose position is at the center of the crosshairs. By default, it is scalable, rotatable and translatable.""" + + def __init__( + self, + pos=None, + size=None, + parent=None, + gonio_xy=(0, 0), + model_row_index=1, + is_fiducial=False, + ppm=False, + **kargs + ): + if size is None: + size = [20, 20] + if pos is None: + pos = [0, 0] + self._size = size + self._shape = None + self.parent = parent + self._model_row_index = model_row_index + self._is_fiducial = is_fiducial + ROI.__init__(self, pos, size, **kargs) + + self._beam_position = parent.get_beam_mark_on_camera_xy() + self.set_gonio_xy(*gonio_xy) + + self.setToolTip(f"{'fiducial' if is_fiducial else 'crystal'} {model_row_index}") + + # parent.pixelsPerMillimeter.connect(self.update_pixels_per_millimeter) + # parent.beamCameraCoordinatesChanged.connect(self.update_beam_position) + # parent.fast_x_position.connect(self.follow_x) + # parent.fast_y_position.connect(self.follow_y) + self._ppm = ppm + self._original_ppm = ppm # marker only works at original zoom + # because zoom axis is not aligned + # with viewing axis + + # self.calculate_gonio_xy() + self.sigRegionChanged.connect(self.invalidate) + # self.sigRegionChangeFinished.connect(self.calculate_gonio_xy) + # self.sigHoverEvent.connect(self.my_hovering) + self.aspectLocked = False + self.translatable = True + self.rotateAllowed = False + self.invertible = False + self.deletable = False + + def enable_stage_tracking(self): + self.parent.fast_x_position.connect(self.follow_x) + self.parent.fast_y_position.connect(self.follow_y) + + def disable_stage_tracking(self): + self.parent.fast_x_position.disconnect(self.follow_x) + self.parent.fast_y_position.disconnect(self.follow_y) + + def follow_x(self, v): + self.follow_stage(fx=v) + + def follow_y(self, v): + self.follow_stage(fy=v) + + def my_hovering(self, event): + print("hovering: {}".format(self.mouseHovering)) + + def get_model_row(self): + return self._model_row_index + + def update_ppm(self, ppm): + self._ppm = ppm + self.recalc_camera_position() + + def follow_stage(self, fx=None, fy=None): + self._fx, self._fy = self.parent.getFastX(), self.parent.getFastY() + self.recalc_camera_position() + + def set_gonio_xy(self, x, y): + self._gx, self._gy = x, y + + def disconnect_signals(self): + self.parent.pixelsPerMillimeter.disconnect(self.update_ppm) + self.parent.beamCameraCoordinatesChanged.disconnect(self.update_beam_position) + self.parent.fast_x_position.disconnect(self.follow_x) + self.parent.fast_y_position.disconnect(self.follow_y) + + def reconnect_signals(self): + self.parent.pixelsPerMillimeter.connect(self.update_ppm) + self.parent.beamCameraCoordinatesChanged.connect(self.update_beam_position) + self.parent.fast_x_position.connect(self.follow_x) + self.parent.fast_y_position.connect(self.follow_y) + + def removeClicked(self): + self.disconnect_signals() + super(CrystalCircle, self).removeClicked() + + def recalc_camera_position(self): + idx = self._model_row_index + ppm = self._ppm + bx, by = self.parent.get_beam_mark_on_camera_xy() + gx, gy = self._gx, self._gy # marker position in gonio coords + fx, fy = self._fx, self._fy + # distance to beam in pixels for new ppm + dx, dy = (ppm * (fx - gx), ppm * (fy - gy)) + x, y = bx - dx, by + dy + # logger.debug(f"recalc {idx}: cam = {(x,y)}, gonio = {(gx, gy)}") + self.setPos(x, y) + + def update_beam_position(self, pos): + self._beam_position = pos + + def show_size(self): + try: + ppm = self._ppm + except: + return + w, h = self.getState()["size"] + # convert to micrometer + nw, nh = (w * 1000) / ppm, (h * 1000) / ppm + + def invalidate(self): + self._shape = None + self.prepareGeometryChange() + + def boundingRect(self): + return self.shape().boundingRect() + + def shape(self): + if self._shape is None: + radius = self.getState()["size"][1] + p = QtGui.QPainterPath() + p.moveTo(Point(0, -radius)) + p.lineTo(Point(0, radius)) + p.moveTo(Point(-radius, 0)) + p.lineTo(Point(radius, 0)) + stroker = QtGui.QPainterPathStroker() + stroker.setWidth(10) + outline = stroker.createStroke(p) + self._shape = outline + + return self._shape + + def paint(self, p, *args): + if abs(self._ppm - self._original_ppm) < 1.: + if self._is_fiducial: + pen = mkPen(color="r", width=2) + else: + pen = mkPen(color="y", width=2) + else: + pen = mkPen(color="#ffffaa88", width=2) + x, y = self.getState()["size"] + p.setRenderHint(QtGui.QPainter.Antialiasing) + p.setPen(pen) + + p.drawLine(Point(0, -x), Point(0, x)) + p.drawLine(Point(-x, 0), Point(x, 0)) + p.drawEllipse(-x, -y, 2 * x, 2 * y) + + +class Crosshair(ROI): + """A crosshair ROI whose position is at the center of the crosshairs. By default, it is scalable, rotatable and translatable.""" + + def __init__(self, pos=None, size=None, **kargs): + if size is None: + size = [10, 10] + if pos is None: + pos = [0, 0] + self._shape = None + ROI.__init__(self, pos, size, **kargs) + + self.sigRegionChanged.connect(self.invalidate) + self.aspectLocked = True + + def invalidate(self): + self._shape = None + self.prepareGeometryChange() + + def boundingRect(self): + return self.shape().boundingRect() + + def shape(self): + if self._shape is None: + radius = self.getState()["size"][1] + p = QtGui.QPainterPath() + p.moveTo(Point(0, -radius)) + p.lineTo(Point(0, radius)) + p.moveTo(Point(-radius, 0)) + p.lineTo(Point(radius, 0)) + # p = self.mapToDevice(p) + stroker = QtGui.QPainterPathStroker() + stroker.setWidth(10) + outline = stroker.createStroke(p) + # self._shape = self.mapFromDevice(outline) + self._shape = outline + + return self._shape + + def paint(self, p, *args): + radius = self.getState()["size"][1] + p.setRenderHint(QtGui.QPainter.Antialiasing) + p.setPen(self.currentPen) + + p.drawLine(Point(0, -radius), Point(0, radius)) + p.drawLine(Point(-radius, 0), Point(radius, 0)) diff --git a/app_config.py b/app_config.py new file mode 100644 index 0000000..ed004c0 --- /dev/null +++ b/app_config.py @@ -0,0 +1,51 @@ +import yaml +from PyQt5.QtCore import QSettings +from pathlib import Path + +import logging +logger = logging.getLogger(__name__) + + +settings = QSettings("Paul Scherrer Institut", "SwissMX") + +inst_folder = Path(__file__).absolute().parent + +config_file = inst_folder / "swissmx.yaml" +configs = yaml.load(config_file.read_text()) +endstation = configs["configure_for"] +appsconf = configs[endstation] + +simulated = appsconf.get("simulate", False) + +logger.info(f"configuring for endstation: {endstation.upper()}") + +if simulated: + logger.warning("SIMULATION is ACTIVE") +css_file = inst_folder / "swissmx.css" + + + +def font(name: str) -> str: + p = Path(__file__).absolute().parent / "fonts" / name + return str(p) + + +def logo(size: int = 0) -> str: + p = Path(__file__).absolute().parent / "logos" / "logo.png" + if size: + p = Path(__file__).absolute().parent / "logos" / f"tell_logo_{size}x{size}.png" + return str(p) + + +def option(key: str) -> bool: + try: + return settings.value(key, type=bool) + except: + logger.error(f"option {key} not known") + return False + + +def toggle_option(key: str): + v = settings.value(key, type=bool) + settings.setValue(key, not v) + settings.sync() diff --git a/app_utils.py b/app_utils.py new file mode 100644 index 0000000..701863b --- /dev/null +++ b/app_utils.py @@ -0,0 +1,104 @@ +import time + +import logging +import enum + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +from epics.ca import pend_event + +class DeltatauMotorStatus(enum.Flag): + """names are taken from motorx_all.ui""" + DIRECTION = enum.auto() + MOTION_IS_COMPLETE = enum.auto() + PLUS_LIMIT_SWITCH = enum.auto() + HOME_SWITCH = enum.auto() + UNUSED_1 = enum.auto() + CLOSED_LOOP_POSITION = enum.auto() + SLIP_STALL_DETECTED = enum.auto() + AT_HOME_POSITION = enum.auto() + ENCODER_IS_PRESENT = enum.auto() + PROBLEM = enum.auto() + MOVING = enum.auto() + GAIN_SUPPORT = enum.auto() + COMMUNICATION_ERROR = enum.auto() + MINUS_LIMIT_SWITCH = enum.auto() + MOTOR_HAS_BEEN_HOMED = enum.auto() + + AT_SWITCH = MINUS_LIMIT_SWITCH | PLUS_LIMIT_SWITCH + +class PositionsNotReached(Exception): + pass + +def assert_tweaker_positions(targets, timeout=60.0): + """ + wait for each of the given motors to have specified positions within tolerance + :param targets: [ + (motor, target_position, tolerance), + ... + ] + :return: None + :raises: PositionsNotReached + """ + num_motors = len(targets) + + timeisup = timeout + time.time() + + while time.time() < timeisup: + count = 0 + summary = [] + for i, m in enumerate(targets): + motor, target, tolerance = m + name = motor.short_name + pend_event() + cur = motor.get_position() + done = motor.is_done() + logger.debug("check {}[done={}]: {} == {}".format(name, done, cur, target)) + summary.append("{}[done={}]: {} == {}".format(name, done, cur, target)) + if done and tolerance >= abs(cur - target): + count += 1 + pend_event(0.1) + + if count == num_motors: + break + pend_event(0.1) + + if count != num_motors: + raise PositionsNotReached("failed to reach target positions: {}".format("#".join(summary))) + +def assert_motor_positions(targets, timeout=60.0): + """ + wait for each of the given motors to have specified positions within tolerance + :param targets: [ + (motor, target_position, tolerance), + ... + ] + :return: None + :raises: PositionsNotReached + """ + num_motors = len(targets) + + timeisup = timeout + time.time() + + while time.time() < timeisup: + count = 0 + summary = [] + for i, m in enumerate(targets): + motor, target, tolerance = m + name = motor._prefix + pend_event() + cur = motor.get_position(readback=True) + done = motor.done_moving + logger.debug("check {}[done={}]: {} == {}".format(name, done, cur, target)) + summary.append("{}[done={}]: {} == {}".format(name, done, cur, target)) + if done and tolerance >= abs(cur - target): + count += 1 + pend_event(0.1) + + if count == num_motors: + break + pend_event() + + if count != num_motors: + raise PositionsNotReached("failed to reach target positions: {}".format("#".join(summary))) diff --git a/epics_widgets/MotorTweak.py b/epics_widgets/MotorTweak.py new file mode 100644 index 0000000..571677c --- /dev/null +++ b/epics_widgets/MotorTweak.py @@ -0,0 +1,355 @@ +import math +import logging +from time import sleep +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QPainter, QBrush, QColor, QPainterPath, QPen, QDoubleValidator +from PyQt5.QtWidgets import QMenu, QInputDialog, QAction +from PyQt5.uic import loadUiType +from epics import Motor +from epics.ca import pend_event + +from app_utils import DeltatauMotorStatus, assert_motor_positions + +Ui_MotorTweak, QWidget = loadUiType('epics_widgets/MotorTweak.ui') +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +SPMG_STOP = 0 +SPMG_PAUSE = 1 +SPMG_MOVE = 2 +SPMG_GO = 3 + + +class MotorTweak(QWidget, Ui_MotorTweak): + event_val = pyqtSignal(str, dict) + event_readback = pyqtSignal(str, float, dict) + event_soft_limit = pyqtSignal(str, dict) + event_high_hard_limit = pyqtSignal(str, dict) + event_low_hard_limit = pyqtSignal(str, dict) + event_axis_fault = pyqtSignal(str, dict) + + def __init__(self, parent=None): + super(MotorTweak, self).__init__(parent=parent) + self.setupUi(self) + self._wheel_tweaks = True + self._auto_labels = True + self._locked = False + self._label_style = 'basic' + self._templates_source = { + 'basic': '{short_name} {{rbv:.{precision}f}} {units}', + 'small': '{short_name} {{rbv:.{precision}f}} {units}', + '2 lines': '{short_name}
{{rbv:.{precision}f}} {units}', + 'busy': '{short_name} {{rbv:.{precision}f}} {units}' + } + self._templates = {} + + + def connect_motor(self, motor_base, short_name=None, **kwargs): + m = Motor(motor_base) + m.get_position() + self._ignore_limits = m.HLM == m.LLM # if both high/low limits are equal they are meaningless + self.motor = m + if not short_name: + short_name = m.description + self.short_name = short_name + self._pvname = motor_base + + for attr in ['RTYP', 'JVEL', 'HLS', 'LLS', 'TWV', 'RBV', 'VAL', 'LVIO', 'HLM', 'LLM']: + # logger.debug('connecting to field {}'.format(attr)) + m.PV(attr, connect=True) + + self.set_motor_validator() + self._drive_val.setText(m.get('VAL', as_string=True)) + self._drive_val.returnPressed.connect(self.move_motor_to_position) + + tweak_min = kwargs.get("tweak_min", 0.0001) + tweak_max = kwargs.get("tweak_max", 10.0) + self._tweak_val.setText(m.get('TWV', as_string=True)) + self._tweak_val.setValidator(QDoubleValidator(tweak_min, tweak_max, 4, self._tweak_val)) + self._tweak_val.editingFinished.connect(lambda m=m: m.PV('TWV').put(self._tweak_val.text())) + + self.jog_reverse.hide() + self.jog_forward.hide() + # self.jog_forward.pressed.connect(lambda: self.jog('forward', True)) + # self.jog_reverse.pressed.connect(lambda: self.jog('reverse', True)) + # self.jog_forward.released.connect(lambda: self.jog('forward', False)) + # self.jog_reverse.released.connect(lambda: self.jog('reverse', False)) + + self.tweak_forward.clicked.connect(lambda m: self.motor.tweak('forward')) + self.tweak_reverse.clicked.connect(lambda m: self.motor.tweak('reverse')) + + self.bind_wheel() + + self.update_label_template() + m.add_callback('VAL', self.set_val) + m.add_callback('RBV', self.update_label) + m.set_callback('HLS', self.update_label) + m.set_callback('LLS', self.update_label) + + m.set_callback('VAL', self.emit_signals, {'source_field': 'VAL'}) + m.set_callback('RBV', self.emit_signals, {'source_field': 'RBV'}) + m.set_callback('HLS', self.emit_signals, {'source_field': 'HLS'}) + m.set_callback('LLS', self.emit_signals, {'source_field': 'LLS'}) + m.set_callback('LVIO', self.emit_signals, {'source_field': 'LVIO'}) + m.set_callback('STAT', self.emit_signals, {'source_field': 'STAT'}) + + m.set_callback('HLM', self.set_motor_validator) + + + def set_val(self, **kw): + v = kw['char_value'] + logger.debug('updating VAL = {}'.format(v)) + self._drive_val.setText(v) + + + def set_motor_validator(self, **kwargs): + m = self.motor + lineedit = self._drive_val + min, max = m.PV('LLM').get(), m.PV('HLM').get() + if min == max: + min = -1e6 + max = 1e6 + lineedit.setValidator(QDoubleValidator(min, max, m.PV('PREC').get(), lineedit)) + + + def move_relative(self, dist): + self.motor.move(dist, ignore_limits=True, relative=True) + + def is_done(self): + self.motor.refresh() + return 1 == self.motor.done_moving + + def get_position(self): + return self.motor.get_position(readback=True) + + def is_homed(self): + self.motor.refresh() + status = DeltatauMotorStatus(self.motor.motor_status) + return bool(DeltatauMotorStatus.MOTOR_HAS_BEEN_HOMED & status) + + def move_motor_to_position(self, drive=None, wait=False, assert_position=False): + if drive is None: + drive = float(self._drive_val.text()) + if assert_position: + wait=True + self.motor.move(drive, wait=wait, ignore_limits=True, relative=False) + if assert_position: + assert_motor_positions([(self.motor, drive, 0.1)], timeout=1) + + def emit_signals(self, **kw): + ''' + :param kw: info about the channel { + 'access': 'read-only', + 'char_value': '-0.105', + 'chid': 36984304, + 'count': 1, + 'enum_strs': None, + 'ftype': 20, + 'host': 'SAR-CPPM-EXPMX1.psi.ch:5064', + 'lower_alarm_limit': None, + 'lower_ctrl_limit': 0.0, + 'lower_disp_limit': 0.0, + 'lower_warning_limit': None, + 'motor_field': 'RBV', + 'nelm': 1, + 'precision': 3, + 'pvname': 'SAR-EXPMX:MOT_ROT_Y.RBV', + 'read_access': True, + 'severity': 0, + 'source_field': 'RBV', + 'status': 0, + 'timestamp': 1522314072.878331, + 'type': 'time_double', + 'typefull': 'time_double', + 'units': b'deg', + 'upper_alarm_limit': None, + 'upper_ctrl_limit': 0.0, + 'upper_disp_limit': 0.0, + 'upper_warning_limit': None, + 'value': -0.105, + 'write_access': False} + + + :return: + ''' + field = kw['motor_field'] + src = kw['source_field'] + kw['alias'] = self.short_name + if field != src: + return + if field == 'VAL': + self.event_val.emit(self._pvname, kw) + elif field == 'RBV': + self.event_readback.emit(kw['alias'], kw['value'], kw) + elif field == 'LVIO': + self.event_soft_limit.emit(self._pvname, kw) + elif field == 'HLS': + self.event_high_hard_limit.emit(self._pvname, kw) + self.event_axis_fault.emit(self._pvname, kw) + elif field == 'LVIO': + self.event_low_hard_limit.emit(self._pvname, kw) + self.event_axis_fault.emit(self._pvname, kw) + elif field == 'STAT': + self.event_axis_fault.emit(self._pvname, kw) + + def update_label(self, **kwargs): + m = self.motor + self.label.setText(self._templates[self._label_style].format(rbv=m.readback)) + self.jog_forward.setToolTip('jog forward at {:.3f} {}/s'.format(m.jog_speed, m.units)) + self.jog_reverse.setToolTip('jog reverse at {:.3f} {}/s'.format(m.jog_speed, m.units)) + self.tweak_forward.setToolTip('tweak forward by {:.3f} {}'.format(m.tweak_val, m.units)) + self.tweak_reverse.setToolTip('tweak reverse by {:.3f} {}'.format(m.tweak_val, m.units)) + + def update_jog_speed(self, event): + m = self.motor + speed = m.jog_speed + sign = math.copysign(1, event.angleDelta().y()) + m.jog_speed = speed + (sign * 0.1 * speed) + self.jog_forward.setToolTip('jog forward at {:.3f} {}/s'.format(m.jog_speed, m.units)) + self.jog_reverse.setToolTip('jog reverse at {:.3f} {}/s'.format(m.jog_speed, m.units)) + + def tweak_event(self, event): + m = self.motor + sign = event.angleDelta().y() + if sign < 0: + m.tweak_reverse = 1 + else: + m.tweak_forward = 1 + + def bind_wheel(self): + self.tweak_forward.wheelEvent = self.tweak_event + self.tweak_reverse.wheelEvent = self.tweak_event + + def jog(self, direction, enable): + m = self.motor + if 'forw' in direction: + m.jog_forward = int(enable) + else: + m.jog_reverse = int(enable) + + def contextMenuEvent(self, event): + m = self.motor + menu = QMenu(self) + menu.setTitle(self.short_name) + + lockmotor = QAction('lock motor', menu, checkable=True) + lockmotor.setChecked(self._locked) + menu.addAction(lockmotor) + + autolabelsAction = QAction('auto', menu, checkable=True) + autolabelsAction.setChecked(self._auto_labels) + menu.addAction(autolabelsAction) + + wheel_tweaks = QAction('Mouse wheel tweaks motor', menu, checkable=True) + wheel_tweaks.setChecked(self._wheel_tweaks) + menu.addAction(wheel_tweaks) + + stopmotorAction = QAction('Stopped', menu, checkable=True) + stopmotorAction.setChecked(SPMG_STOP == m.stop_go) + menu.addAction(stopmotorAction) + changeprecAction = menu.addAction("Change Precision") + changejogspeedAction = menu.addAction("Jog speed {:.3f} {}/s".format(m.jog_speed, m.units)) + changetweakstepAction = menu.addAction("Tweak step {:.3f} {}".format(m.tweak_val, m.units)) + + tozeroAction = menu.addAction("move to Zero") + + + action = menu.exec_(self.mapToGlobal(event.pos())) + if action == lockmotor: + self._locked = not self._locked + if self._locked: + self._controlbox.setDisabled(True) + else: + self._controlbox.setDisabled(False) + elif action == changeprecAction: + name = m.NAME + prec = m.PREC + msg = 'Precision for motor {}'.format(name) + logger.debug('prec before %d', prec) + prec, ok = QInputDialog.getInt(self, msg, msg, prec, 0, 10) + logger.debug('prec after (%d) %d', ok, prec) + if ok: + self.motor.put('PREC', prec, wait=True) + + elif action == changejogspeedAction: + name = m.NAME + speed = m.jog_speed + msg = 'Jog speed for motor {}'.format(name) + speed, ok = QInputDialog.getDouble(self, msg, msg, speed, 0.01, m.slew_speed, m.precision) + if ok: + self.motor.put('JVEL', speed, wait=True) + + elif action == changetweakstepAction: + name = m.NAME + tv = m.tweak_val + msg = 'Tweak step for motor {} at {} {}'.format(name, tv, m.units) + tv, ok = QInputDialog.getDouble(self, msg, msg, tv, pow(10, -m.precision), m.slew_speed, m.precision) + if ok: + self.motor.put('TWV', tv, wait=True) + + elif action == tozeroAction: + m.move(0.0, ignore_limits=True) + + elif action == stopmotorAction: + self.motor.stop_go = SPMG_STOP if m.stop_go == SPMG_GO else SPMG_GO + + elif action == autolabelsAction: + self._auto_labels = not self._auto_labels + + elif action == wheel_tweaks: + self._wheel_tweaks = not self._wheel_tweaks + self.bind_wheel() + + self.update_label_template() + + def resizeEvent(self, event): + return # FIXME disable for the time being + if not self._auto_labels: + return + + w, h = event.size().width(), event.size().height() + if w < 400: + self.jog_reverse.hide() + self.jog_forward.hide() + else: + self.jog_reverse.show() + self.jog_forward.show() + self.update_label() + + def update_label_template(self): + m = self.motor + source = self._templates_source + target = self._templates + + for k in source: + target[k] = source[k].format( + short_name=self.short_name, + precision=m.PREC, + units=m.units) + self.label.setText(target[self._label_style].format(rbv=m.readback)) + + def paintEvent(self, e): + qp = QPainter() + qp.begin(self) + qp.setRenderHint(QPainter.Antialiasing) + self._draw_limits(qp) + qp.end() + + def _draw_limits(self, qp): + m = self.motor + width, height = self.size().width(), self.size().height() + pad = 5 + rounding = 2 + size = 10 + if m.HLS: + x, y, w, h = width - size, pad, size, height - 2 * pad + elif m.LLS: + x, y, w, h = 0, pad, size, height - 2 * pad + else: + return + color = QColor('indianred') + qp.setBrush(QBrush(color, Qt.SolidPattern)) + qp.setPen(QPen(color)) + path = QPainterPath() + path.setFillRule(Qt.WindingFill) + path.addRoundedRect(x, y, w, h, rounding, rounding) + qp.drawPath(path) diff --git a/epics_widgets/MotorTweak.ui b/epics_widgets/MotorTweak.ui new file mode 100644 index 0000000..4e09bd8 --- /dev/null +++ b/epics_widgets/MotorTweak.ui @@ -0,0 +1,172 @@ + + + Frame + + + + 0 + 0 + 650 + 300 + + + + + 0 + 0 + + + + + 300 + 10 + + + + Frame + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 2 + 0 + + + + TextLabel + + + + + + + + 2 + + + 2 + + + 2 + + + 2 + + + 2 + + + + + + 2 + 0 + + + + + 80 + 0 + + + + 10 + + + + + + + + 1 + 0 + + + + + 35 + 16777215 + + + + + + + + + + + + 1 + 0 + + + + + 35 + 16777215 + + + + + + + + + + + + 2 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/epics_widgets/SmaractMotorTweak.py b/epics_widgets/SmaractMotorTweak.py new file mode 100644 index 0000000..2ebec06 --- /dev/null +++ b/epics_widgets/SmaractMotorTweak.py @@ -0,0 +1,334 @@ +import math +import logging +from time import sleep +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QPainter, QBrush, QColor, QPainterPath, QPen, QDoubleValidator +from PyQt5.QtWidgets import QMenu, QInputDialog, QAction +from PyQt5.uic import loadUiType +from epics import PV +from epics.ca import pend_event + +from app_utils import assert_tweaker_positions + +Ui_MotorTweak, QWidget = loadUiType('epics_widgets/MotorTweak.ui') +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +SPMG_STOP = 0 +SPMG_PAUSE = 1 +SPMG_MOVE = 2 +SPMG_GO = 3 + + + +class SmaractMotorTweak(QWidget, Ui_MotorTweak): + event_val = pyqtSignal(str, dict) + event_readback = pyqtSignal(str, dict) + event_soft_limit = pyqtSignal(str, dict) + event_high_hard_limit = pyqtSignal(str, dict) + event_low_hard_limit = pyqtSignal(str, dict) + event_axis_fault = pyqtSignal(str, dict) + + def __init__(self, parent=None): + super(SmaractMotorTweak, self).__init__(parent=parent) + self.setupUi(self) + self._wheel_tweaks = True + self._auto_labels = True + self._locked = False + self._label_style = 'basic' + self._templates_source = { + 'basic': '{short_name} {{rbv:.{precision}f}} {units}', + 'small': '{short_name} {{rbv:.{precision}f}} {units}', + '2 lines': '{short_name}
{{rbv:.{precision}f}} {units}', + 'busy': '{short_name} {{rbv:.{precision}f}} {units}' + } + self._templates = {} + + + def connect_motor(self, motor_base, short_name=None, *args, **kwargs): + self._pvname = motor_base+':DRIVE' + self._pv_name = PV(motor_base+':NAME') + self._pv_drive = PV(motor_base+':DRIVE') + self._pv_readback = PV(motor_base+':MOTRBV') + self._pv_tweak_r = PV(motor_base+':TWR.PROC') + self._pv_tweak_f = PV(motor_base+':TWF.PROC') + self._pv_tweak_val = PV(motor_base+':TWV') + self._pv_status = PV(motor_base + ':STATUS') + self._pv_home_f = PV(motor_base + ':FRM_FORW.PROC') + self._pv_home_b = PV(motor_base + ':FRM_BACK.PROC') + self._pv_is_homed = PV(motor_base + ':GET_HOMED') + self._pv_llm = PV(motor_base + ':LLM') + self._pv_hlm = PV(motor_base + ':HLM') + + self.label.setToolTip('{} => {}'.format(motor_base, self._pv_name.get())) + + try: + self._prec = kwargs['prec'] + except: + self._prec = 5 + + try: + self._units = kwargs['units'] + except: + self._units = 'mm' + + self._ignore_limits = self._pv_llm.get() == self._pv_hlm.get() # if both high/low limits are equal they are meaningless + if not short_name: + short_name = self._pv_name.value + self.short_name = short_name + + self.set_motor_validator() + self._drive_val.setText(self._pv_drive.get(as_string=True)) + self._drive_val.returnPressed.connect(self.move_motor_to_position) + + self.jog_forward.hide() + self.jog_reverse.hide() + + tweak_min = kwargs.get("tweak_min", 0.0001) + tweak_max = kwargs.get("tweak_max", 20.0) + self._tweak_val.setText(self._pv_tweak_val.get(as_string=True)) + self._tweak_val.setValidator(QDoubleValidator(tweak_min, tweak_max, 4, self._tweak_val)) + self._tweak_val.editingFinished.connect(lambda m=self._pv_tweak_val: m.put(self._tweak_val.text())) + + self.tweak_forward.clicked.connect(lambda m: self._pv_tweak_f.put(1)) + self.tweak_reverse.clicked.connect(lambda m: self._pv_tweak_r.put(1)) + + + self.bind_wheel() + + self.update_label_template() + + self._pv_readback.add_callback(self.update_label) + self._pv_status.add_callback(self.set_val_on_status_change) + + # self._pv_drive.add_callback(self.set_val) + self._pv_drive.add_callback(self.emit_signals, source_field='VAL') + self._pv_readback.add_callback(self.emit_signals, source_field='RBV') + self._pv_hlm.add_callback(self.emit_signals, source_field='HLS') + self._pv_llm.add_callback(self.emit_signals, source_field='LLS') + + # m.set_callback('HLM', self.set_motor_validator) + + + def set_motor_validator(self, **kwargs): + lineedit = self._drive_val + min, max = self._pv_llm.get(), self._pv_hlm.get() + if min == max: + min = -1e6 + max = 1e6 + lineedit.setValidator(QDoubleValidator(min, max, 5, lineedit)) + + + def move_relative(self, dist, delay=None): + cur = self._pv_readback.get() + target = cur + dist + if delay: + sleep(delay) + self._pv_drive.put(target) + + def get_position(self): + return self._pv_readback.get(use_monitor=False) + + def is_homed(self): + pend_event() + return 1 == self._pv_is_homed.get(use_monitor=False) + + def get_status(self, as_string=False): + """ + States: + 0 Stopped + 1 Stepping + 2 Scanning + 3 Holding + 4 Targeting + 5 Move Delay + 6 Calibrating + 7 Finding Ref + 8 Locked + :return: + """ + return self._pv_status.get(as_string=as_string, use_monitor=False) + + def wait(self): + pend_event(.01) + while not self.is_done(): + pend_event(0.2) + + def is_done(self): + return self._pv_status.get() in (0, 3) + + def move_motor_to_position(self, drive=None, wait=False, assert_position=False): + if assert_position: + wait=True + if drive is None: + logger.debug('{} abs target from widget'.format(self.short_name)) + drive = float(self._drive_val.text()) + logger.debug('{} abs move => {}'.format(self.short_name, drive)) + self._pv_drive.put(drive) + if wait: + self.wait() + if assert_position: + assert_tweaker_positions([(self, drive, 0.05)], timeout=3.) + + def emit_signals(self, **kw): + field = kw['source_field'] + if field == 'VAL': + self.event_val.emit(self._pvname, kw) + elif field == 'RBV': + self.event_readback.emit(self._pvname, kw) + elif field == 'LVIO': + self.event_soft_limit.emit(self._pvname, kw) + elif field == 'HLS': + self.event_high_hard_limit.emit(self._pvname, kw) + self.event_axis_fault.emit(self._pvname, kw) + elif field == 'LVIO': + self.event_low_hard_limit.emit(self._pvname, kw) + self.event_axis_fault.emit(self._pvname, kw) + elif field == 'STAT': + self.event_axis_fault.emit(self._pvname, kw) + + + def set_val_on_status_change(self, **kw): + ''' + 0 Stopped + 1 Stepping + 2 Scanning + 3 Holding + 4 Targeting + 5 Move Delay + 6 Calibrating + 7 Finding Ref + 8 Locked + :param kw: + :return: + ''' + status = kw['char_value'] + if status in ('Holding', 'Stopped'): + v = self._pv_readback.get(as_string=True) + logger.debug('updating VAL on status change to holding/stopped = {}'.format(v)) + self._drive_val.setText(v) + + def set_val(self, **kw): + v = kw['char_value'] + logger.debug('updating VAL = {}'.format(v)) + self._drive_val.setText(v) + + def update_label(self, **kwargs): + self.label.setText(self._templates[self._label_style].format(rbv=self._pv_readback.get())) + self.tweak_forward.setToolTip('tweak forward by {:.3f} {}'.format(self._pv_tweak_val.get(), self._units)) + self.tweak_reverse.setToolTip('tweak reverse by {:.3f} {}'.format(self._pv_tweak_val.get(), self._units)) + + def tweak_event(self, event): + sign = event.angleDelta().y() + if sign < 0: + self._pv_tweak_r.put(1) + else: + self._pv_tweak_f.put(1) + + def bind_wheel(self): + self.tweak_forward.wheelEvent = self.tweak_event + self.tweak_reverse.wheelEvent = self.tweak_event + + def contextMenuEvent(self, event): + name = self._pv_name.get() + unit = self._units + prec = self._prec + + menu = QMenu(self) + menu.setTitle(self.short_name) + + lockmotor = QAction('lock motor', menu, checkable=True) + lockmotor.setChecked(self._locked) + menu.addAction(lockmotor) + + autolabelsAction = QAction('auto', menu, checkable=True) + autolabelsAction.setChecked(self._auto_labels) + menu.addAction(autolabelsAction) + + wheel_tweaks = QAction('Mouse wheel tweaks motor', menu, checkable=True) + wheel_tweaks.setChecked(self._wheel_tweaks) + menu.addAction(wheel_tweaks) + + # stopmotorAction = QAction('Stopped', menu, checkable=True) + # stopmotorAction.setChecked(SPMG_STOP == m.stop_go) + # menu.addAction(stopmotorAction) + changeprecAction = menu.addAction("Change Precision") + changetweakstepAction = menu.addAction("Tweak step {:.3f} {}".format(self._pv_tweak_val.get(), unit)) + + tozeroAction = menu.addAction("move to Zero") + + + action = menu.exec_(self.mapToGlobal(event.pos())) + if action == lockmotor: + self._locked = not self._locked + if self._locked: + self._controlbox.setDisabled(True) + else: + self._controlbox.setDisabled(False) + elif action == changeprecAction: + msg = 'Precision for motor {}'.format(name) + logger.debug('prec before %d', prec) + prec, ok = QInputDialog.getInt(self, msg, msg, prec, 0, 10) + logger.debug('prec after (%d) %d', ok, prec) + if ok: + self._prec = prec + + elif action == changetweakstepAction: + tv = self._pv_tweak_val.get() + msg = 'Tweak step for motor {} at {} {}'.format(name, tv, unit) + tv, ok = QInputDialog.getDouble(self, msg, msg, tv, pow(10, -prec), 10.0, prec) + if ok: + self._pv_tweak_val.put(tv) + + elif action == tozeroAction: + self._pv_drive.put(0.0) + + elif action == autolabelsAction: + self._auto_labels = not self._auto_labels + + elif action == wheel_tweaks: + self._wheel_tweaks = not self._wheel_tweaks + self.bind_wheel() + + self.update_label_template() + + def resizeEvent(self, event): + pass + + def update_label_template(self): + source = self._templates_source + target = self._templates + + for k in source: + target[k] = source[k].format( + short_name=self.short_name, + precision=self._prec, + units=self._units) + self.label.setText(target[self._label_style].format(rbv=self._pv_readback.get())) + + def paintEvent(self, e): + qp = QPainter() + qp.begin(self) + qp.setRenderHint(QPainter.Antialiasing) + self._draw_limits(qp) + qp.end() + + def _draw_limits(self, qp): + pass + # width, height = self.size().width(), self.size().height() + # pad = 5 + # rounding = 2 + # size = 10 + # if m.HLS: + # x, y, w, h = width - size, pad, size, height - 2 * pad + # elif m.LLS: + # x, y, w, h = 0, pad, size, height - 2 * pad + # else: + # return + # color = QColor('indianred') + # qp.setBrush(QBrush(color, Qt.SolidPattern)) + # qp.setPen(QPen(color)) + # path = QPainterPath() + # path.setFillRule(Qt.WindingFill) + # path.addRoundedRect(x, y, w, h, rounding, rounding) + # qp.drawPath(path) diff --git a/epics_widgets/__init__.py b/epics_widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qutilities.py b/qutilities.py new file mode 100644 index 0000000..4902f91 --- /dev/null +++ b/qutilities.py @@ -0,0 +1,51 @@ +from PyQt5 import QtWidgets, QtGui +from PyQt5.QtWidgets import QWidget, QSizePolicy, QVBoxLayout, QDoubleSpinBox, QMessageBox + +from app_config import option, toggle_option + + +def toggle_warn(key): + toggle_option(key) + m = f"Option {key} => {option(key)}" + QMessageBox.warning(None, m, m) + +def horiz_spacer(): + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + return spacer + + +def toggle_visibility(widget, state): + if state: + widget.show() + else: + widget.hide() + + +def get_sep_line(): + line = QtWidgets.QFrame() + line.setFixedHeight(2) + line.setFrameShadow(QtWidgets.QFrame.Sunken) + line.setFrameShape(QtWidgets.QFrame.HLine) + return line + + +def check_font_by_id(id): + for family in QtGui.QFontDatabase.applicationFontFamilies(id): + print(family) + + +def toggle_groupbox(gbox, state): + widget = gbox.layout().itemAt(0).widget() + widget.setVisible(state) + +def add_item_to_toolbox(toolbox, label, widget_list=[]): + block = QWidget() + block.setAccessibleName(label) + block.setContentsMargins(0, 0, 0, 0) + block.setLayout(QVBoxLayout()) + for w in widget_list: + block.layout().addWidget(w) + block.layout().addStretch() + item = toolbox.addItem(block, label) + return item \ No newline at end of file