add epics widgets, CustomROI
This commit is contained in:
627
CustomROI.py
Normal file
627
CustomROI.py
Normal file
@@ -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))
|
||||
51
app_config.py
Normal file
51
app_config.py
Normal file
@@ -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()
|
||||
104
app_utils.py
Normal file
104
app_utils.py
Normal file
@@ -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)))
|
||||
355
epics_widgets/MotorTweak.py
Normal file
355
epics_widgets/MotorTweak.py
Normal file
@@ -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': '<b>{short_name}</b> <font color="#080">{{rbv:.{precision}f}} {units}</font>',
|
||||
'small': '<small>{short_name} <font size="small" color="#080">{{rbv:.{precision}f}} {units}</font><small>',
|
||||
'2 lines': '<b>{short_name}</b><br><font size="small" color="#080">{{rbv:.{precision}f}} {units}</font>',
|
||||
'busy': '<b>{short_name}</b> <font color="#080">{{rbv:.{precision}f}} {units}</font>'
|
||||
}
|
||||
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)
|
||||
172
epics_widgets/MotorTweak.ui
Normal file
172
epics_widgets/MotorTweak.ui
Normal file
@@ -0,0 +1,172 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Frame</class>
|
||||
<widget class="QFrame" name="Frame">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>650</width>
|
||||
<height>300</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>300</width>
|
||||
<height>10</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Frame</string>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::NoFrame</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Plain</enum>
|
||||
</property>
|
||||
<property name="lineWidth">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>2</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="_controlbox" native="true">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<property name="spacing">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="_drive_val">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>2</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maxLength">
|
||||
<number>10</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="tweak_reverse">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>35</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>↶</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="tweak_forward">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>35</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>↷</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="_tweak_val">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>2</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="jog_reverse">
|
||||
<property name="text">
|
||||
<string>◀</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="jog_forward">
|
||||
<property name="text">
|
||||
<string>▶</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
334
epics_widgets/SmaractMotorTweak.py
Normal file
334
epics_widgets/SmaractMotorTweak.py
Normal file
@@ -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': '<b>{short_name}</b> <font color="#080">{{rbv:.{precision}f}} {units}</font>',
|
||||
'small': '<small>{short_name} <font size="small" color="#080">{{rbv:.{precision}f}} {units}</font><small>',
|
||||
'2 lines': '<b>{short_name}</b><br><font size="small" color="#080">{{rbv:.{precision}f}} {units}</font>',
|
||||
'busy': '<b>{short_name}</b> <font color="#080">{{rbv:.{precision}f}} {units}</font>'
|
||||
}
|
||||
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)
|
||||
0
epics_widgets/__init__.py
Normal file
0
epics_widgets/__init__.py
Normal file
51
qutilities.py
Normal file
51
qutilities.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user