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