diff --git a/epics_widgets/MotorTweak.py b/epics_widgets/MotorTweak.py
new file mode 100644
index 0000000..f1c482e
--- /dev/null
+++ b/epics_widgets/MotorTweak.py
@@ -0,0 +1,405 @@
+import logging
+_log = logging.getLogger(__name__)
+
+import math
+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 app_utils import assert_tweaker_positions
+
+Ui_MotorTweak, QWidget = loadUiType('epics_widgets/MotorTweak.ui')
+SPMG_STOP = 0
+SPMG_PAUSE = 1
+SPMG_MOVE = 2
+SPMG_GO = 3
+
+class MotorTweak(QWidget, Ui_MotorTweak):
+ event_val = pyqtSignal(str, dict)
+ event_rbv = 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):
+ # TODO: DO NOT USE Motor base class, but reduce to the only really needed PV: s.a. class QopticZoom(object)
+ # TODO: have a own motor Class as class SimMotor:
+ 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']:
+ # _log.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_abs)
+
+ 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 x: m.tweak('forward'))
+ self.tweak_reverse.clicked.connect(lambda x: m.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']
+ _log.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_rel(self, dist):
+ try:
+ self._val+=dist
+ except AttributeError:
+ pass
+ self._motor.move(dist, ignore_limits=True, relative=True)
+
+ def is_done(self):
+ m=self._motor
+ m.refresh()
+ return 1 == m.done_moving
+
+ def get_rbv(self):
+ #return self._motor.get('RBV')
+ m=self._motor
+ return m.get_position(readback=True)
+
+ def get_val(self):
+ #return self._motor.get('VAL')
+ try:
+ v=self._val
+ except AttributeError:
+ m=self._motor
+ self._val=v=m.get('VAL')
+ return v
+
+ def move_abs(self, drive=None, wait=False, assert_position=False):
+ if drive is None:
+ drive = float(self._drive_val.text())
+ if assert_position:
+ wait=True
+ try:
+ self._val=drive
+ except AttributeError:
+ pass
+ m=self._motor
+ m.move(drive, wait=wait, ignore_limits=True, relative=False)
+ if assert_position:
+ assert_tweaker_positions([(self, 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_rbv.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)
+ _log.debug('prec before %d', prec)
+ prec, ok = QInputDialog.getInt(self, msg, msg, prec, 0, 10)
+ _log.debug('prec after (%d) %d', ok, prec)
+ if ok:
+ m.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:
+ m.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:
+ m.put('TWV', tv, wait=True)
+
+ elif action == tozeroAction:
+ m.move(0.0, ignore_limits=True)
+
+ elif action == stopmotorAction:
+ m.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)
+ try:
+ self.label.setText(target[self._label_style].format(rbv=m.readback))
+ except ValueError as e:
+ _log.error(e)
+ print(self._label_style)
+ print(m.readback)
+ print(m)
+
+ 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)
+
+
+
+if __name__ == "__main__":
+ logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(module)s:%(lineno)d:%(funcName)s:%(message)s ')
+ _log.debug('start')
+ def cb_rbv(*args,**kwargs):
+ _log.debug(f'RBV{args} {kwargs}')
+
+
+
+ import sys
+ from PyQt5.QtWidgets import QApplication, QWidget, QLabel
+ app=QApplication(sys.argv)
+ mot_widget=MotorTweak()
+
+ #mot_widget.event_val.connect(cb_rbv)
+ mot_widget.event_rbv.connect(cb_rbv)
+
+ pfx='SAR-EXPMX'
+ mot_widget.connect_motor(f"{pfx}:MOT_FY", alias="fast_y", label="fast Y")
+ mot_widget.setGeometry(50, 50, 520, 100)
+ mot_widget.setWindowTitle("PyQt5 Example")
+ mot_widget.show()
+ sys.exit(app.exec_())
+
+
+
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/SimMotorTweak.py b/epics_widgets/SimMotorTweak.py
new file mode 100644
index 0000000..0470aad
--- /dev/null
+++ b/epics_widgets/SimMotorTweak.py
@@ -0,0 +1,325 @@
+#!/usr/bin/env python
+# *-----------------------------------------------------------------------*
+# | |
+# | Copyright (c) 2022 by Paul Scherrer Institute (http://www.psi.ch) |
+# | Based on Zac great first implementation |
+# | Author Thierry Zamofing (thierry.zamofing@psi.ch) |
+# *-----------------------------------------------------------------------*
+
+# simulated motor tweaks ui:
+# same interface as MotorTweak and SmaractMotorTweaks, but mimes just a simulated motor
+
+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.ca import pend_event
+
+Ui_MotorTweak, QWidget = loadUiType('epics_widgets/MotorTweak.ui')
+_log = logging.getLogger(__name__)
+#logger.setLevel(logging.INFO)
+
+class SimMotor:
+ def __init__(self,rec_name, short_name):
+ self._llm = -10
+ self._hlm = 10
+ self._prec = 5
+ self._twv = 0.1
+ self._units = 'mm'
+ self._pos = 3.1415
+ self._short_name=short_name
+ self._rec_name=rec_name
+
+class SimMotorTweak(QWidget, Ui_MotorTweak):
+ event_val = pyqtSignal(str, dict)
+ event_rbv = 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(SimMotorTweak, 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, rec_name, short_name=None, *args, **kwargs):
+ self.label.setToolTip('{} => {}'.format(rec_name, short_name))
+ self._motor=m=SimMotor(rec_name, short_name)
+ self.set_motor_validator()
+ self._drive_val.setText(str(m._pos))
+ self._drive_val.returnPressed.connect(self.move_abs)
+
+ 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(str(m._twv))
+ 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.move_rel(float(self._tweak_val.text())))
+ self.tweak_reverse.clicked.connect(lambda m: self.move_rel(-float(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):
+ m=self._motor
+ lineedit = self._drive_val
+ min, max = m._llm, m._hlm
+ if min == max:
+ min = -1e6
+ max = 1e6
+ lineedit.setValidator(QDoubleValidator(min, max, 5, lineedit))
+
+
+ def move_rel(self, dist, delay=None):
+ m=self._motor
+ m._pos += dist
+ if delay:
+ sleep(delay)
+ _log.debug('{} rel move => {}'.format(m._short_name, dist))
+ self.update_label()
+ self.emit_signals(source_field='VAL')
+
+ def get_rbv(self):
+ return self._motor._pos
+
+ def get_val(self):
+ return self._motor._pos
+
+ def is_homed(self):
+ return True
+
+ 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 0
+
+ def wait(self):
+ pend_event(.01)
+ while not self.is_done():
+ pend_event(0.2)
+
+ def is_done(self):
+ return True # in (0, 3)
+
+ def move_abs(self, drive=None, wait=False, assert_position=False):
+ m=self._motor
+ if assert_position:
+ wait=True
+ if drive is None:
+ _log.debug('{} abs target from widget'.format(m._short_name))
+ drive = float(self._drive_val.text())
+ _log.debug('{} abs move => {}'.format(m._short_name, drive))
+ m._pos=drive
+ self.update_label()
+ self.emit_signals(source_field='VAL')
+
+ def emit_signals(self, **kw):
+ m=self._motor
+ field = kw['source_field']
+ if field == 'VAL':
+ self.event_val.emit(m._rec_name, kw)
+ elif field == 'RBV':
+ self.event_rbv.emit(m._rec_name, kw)
+ elif field == 'LVIO':
+ self.event_soft_limit.emit(m._rec_name, kw)
+ elif field == 'HLS':
+ self.event_high_hard_limit.emit(m._rec_name, kw)
+ self.event_axis_fault.emit(m._rec_name, kw)
+ elif field == 'LVIO':
+ self.event_low_hard_limit.emit(m._rec_name, kw)
+ self.event_axis_fault.emit(m._rec_name, kw)
+ elif field == 'STAT':
+ self.event_axis_fault.emit(m._rec_name, 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)
+ _log.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']
+ _log.debug('updating VAL = {}'.format(v))
+ self._drive_val.setText(v)
+
+ def update_label(self, **kwargs):
+ m=self._motor
+ self.label.setText(self._templates[self._label_style].format(rbv=m._pos))
+ self._drive_val.setText(f'{m._pos:.3f}')
+ twv=float(self._tweak_val.text())
+ self.tweak_forward.setToolTip('tweak forward by {:.3f} {}'.format(twv, m._units))
+ self.tweak_reverse.setToolTip('tweak reverse by {:.3f} {}'.format(twv, m._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)
+ _log.debug('prec before %d', prec)
+ prec, ok = QInputDialog.getInt(self, msg, msg, prec, 0, 10)
+ _log.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):
+ m=self._motor
+ source = self._templates_source
+ target = self._templates
+
+ for k in source:
+ target[k] = source[k].format(
+ short_name=m._short_name,
+ precision=m._prec,
+ units=m._units)
+ self.label.setText(target[self._label_style].format(rbv=m._pos))
+
+ 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/SmaractMotorTweak.py b/epics_widgets/SmaractMotorTweak.py
new file mode 100644
index 0000000..503823d
--- /dev/null
+++ b/epics_widgets/SmaractMotorTweak.py
@@ -0,0 +1,342 @@
+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_rbv = 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):
+ # TODO: DO NOT USE so many PVs, but reduce to the only really needed PV: s.a. class QopticZoom(object)
+ 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_abs)
+
+ 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_rel(self, dist, delay=None):
+ cur = self._pv_readback.get()
+ target = cur + dist
+ if delay:
+ sleep(delay)
+ self._pv_drive.put(target)
+
+ def get_rbv(self):
+ return self._pv_readback.get(use_monitor=False)
+
+ def get_val(self):
+ # return self._motor.get('VAL')
+ try:
+ v=self._val
+ except AttributeError:
+ self._val=v=self._pv_drive.get()
+ return v
+
+ 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_abs(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=10.)
+
+ def emit_signals(self, **kw):
+ field = kw['source_field']
+ if field == 'VAL':
+ self.event_val.emit(self._pvname, kw)
+ elif field == 'RBV':
+ self.event_rbv.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/furkaRIXS.py b/furkaRIXS.py
index c42393b..a78cd42 100755
--- a/furkaRIXS.py
+++ b/furkaRIXS.py
@@ -59,9 +59,9 @@ from PyQt5.QtWidgets import (
QMessageBox, QPlainTextEdit, QProgressBar, QProgressDialog, QPushButton, QShortcut, QSizePolicy, QSpinBox,
QSplashScreen, QTextBrowser, QToolBox, QVBoxLayout, QWidget,)
ts.log('Import part 4/8:')
-#from epics_widgets.MotorTweak import MotorTweak
-#from epics_widgets.SmaractMotorTweak import SmaractMotorTweak
-#from epics_widgets.SimMotorTweak import SimMotorTweak
+from epics_widgets.MotorTweak import MotorTweak
+from epics_widgets.SmaractMotorTweak import SmaractMotorTweak
+from epics_widgets.SimMotorTweak import SimMotorTweak
ts.log('Import part 5/8:')
import numpy as np
np.set_printoptions(suppress=True,linewidth=196)
@@ -88,6 +88,17 @@ def sigint_handler(*args):
app=QApplication.instance()
app.quit()
+def add_widgets_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
+
class StartupSplash:
def __init__(self):
@@ -121,11 +132,38 @@ class WndFurkaRIXS(QMainWindow, Ui_MainWindow):
title = "Furka RIXS - Furka Resonant inelastic X-ray scattering spectrodscopy"
self.setWindowTitle(title)
+ self._tweakers = {}
+
#self.init_settings()
- #self.setup_sliders()
+ self.setup_sliders()
self.init_graphics()
self.init_actions()
+ def setup_sliders(self):
+ cont = self._wdVert2
+ self._tweak_container = QWidget()
+ self._tweak_container.setLayout(QVBoxLayout())
+ cont.layout().addWidget(self._tweak_container)
+ layout = self._tweak_container.layout()
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ toolbox = QToolBox()
+ layout.addWidget(toolbox)
+ self.build_group_rawmotors(toolbox)
+
+ #self.build_group_faststage(toolbox)
+ # self.build_slits_group(toolbox)
+ #self.build_group_collimator(toolbox)
+ #self.build_group_posttube(toolbox)
+ #self.build_group_xeye(toolbox)
+ #self.build_cryo_group(toolbox)
+
+ # monitor all axis for an axis fault
+ #for key, tweaker in self.tweakers.items():
+ # tweaker.event_axis_fault.connect(self.cb_axis_fault)
+
+
def init_graphics(self):
app = QApplication.instance()
#cfg = app._cfg
@@ -218,6 +256,73 @@ class WndFurkaRIXS(QMainWindow, Ui_MainWindow):
self._actPreferences.triggered.connect(self.cb_modify_app_param)
self._actAbout.triggered.connect(self.cb_about)
+ def build_group_rawmotors(self, toolbox):
+ # dev | Abbr | Motion Axis | Unit |
+ #-------------------------------------------------------------------------------------------------------
+ # GCR.1 | MT | Mask translation | Grating Chamber |
+ # GCR.2 | GTZ | Grating translation along Z-axis (Grating along beam) | Grating Chamber |
+ # GCR.3 | GTY1 | Grating translation along Y-axis (Grating height) Wedge leveller 1 | Grating Chamber |
+ # GCR.4 | GTY2 | Grating translation along Y-axis (Grating height) Wedge leveller 2 | Grating Chamber |
+ # GCR.5 | GRX | Grating rotation around X-axis (pitch) | Grating Chamber |
+ # GCR.6 | GTX | Grating translation along X-axis (Grating exchange) | Grating Chamber |
+ #-------------------------------------------------------------------------------------------------------
+ # DUR.1 | ABT | Aperture Blade Top | Diagnostics unit |
+ # DUR.2 | ABB | Aperture Blade Bottom | Diagnostics unit |
+ # DUR.3 | ABL | Aperture Blade Left | Diagnostics unit |
+ # DUR.4 | ABR | Aperture Blade Right | Diagnostics unit |
+ # DUR.5 | FT | Filter and Screen translation | Diagnostics unit |
+ #-------------------------------------------------------------------------------------------------------
+ # DCR.1 | DTZ | Detector Translation along Z-axis (R2 horizontal) | Detector Chamber |
+ # DCR.2 | DTY1 | Detector Translation along Y-axis (R2 vert.) | Detector Chamber |
+ # DCR.3 | DTY2 | Detector Translation along Y-axis (R2 vert.) | Detector Chamber |
+ # DCR.4 | DRX | Detector Rotation around X-axis (ɣ) | Detector Chamber |
+ # DCR.5 | RDC1 | RIXS Deformation Compensation (wedge leveller 1) | Spectrometer |
+ # DCR.6 | RDC2 | RIXS Deformation Compensation (wedge leveller 2) | Spectrometer |
+ # DCR.7 | RRY1 | RIXS Rotation around Y-axis (2θ) | Spectrometer |
+ # DCR.8 | RRY2 | RIXS Rotation around Y-axis (2θ) | Spectrometer |
+
+
+
+# pfx=QApplication.instance()._cfg.value(AppCfg.GBL_DEV_PREFIX)[1]
+ pfx='SATES30-RX:MOT_'
+ #SATES30-RIXS:MOT_2TRY.
+ widgets=[
+ self.create_tweaker(f"{pfx}MT", alias="gc_mt", label="MT", mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}GTZ", alias="gc_gtz", label="GTZ", mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}GTY1",alias="gc_gty1",label="GTY1",mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}GTY2",alias="gc_gty2",label="GTY2",mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}GRX", alias="gc_grx", label="GRX", mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}GTX", alias="gc_gtx", label="GTX", mtype=SimMotorTweak),
+ ]
+ add_widgets_to_toolbox(toolbox,"Grating Chamber",widget_list=widgets)
+
+ widgets=[
+ self.create_tweaker(f"{pfx}ABT", alias="gc_mt", label="MT", mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}ABB", alias="gc_gtz", label="GTZ", mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}ABL",alias="gc_gty1",label="GTY1",mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}ABR",alias="gc_gty2",label="GTY2",mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}FT", alias="gc_grx", label="GRX", mtype=SimMotorTweak),
+ ]
+ add_widgets_to_toolbox(toolbox,"Diagnostics unit",widget_list=widgets)
+
+ widgets=[
+ self.create_tweaker(f"{pfx}DTZ", alias="gc_mt", label="MT", mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}DTY1", alias="gc_gtz", label="GTZ", mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}DTY2",alias="gc_gty1",label="GTY1",mtype=SimMotorTweak),
+ self.create_tweaker(f"{pfx}DRX",alias="gc_gty2",label="GTY2",mtype=SimMotorTweak),
+ ]
+ add_widgets_to_toolbox(toolbox,"Detector Chamber",widget_list=widgets)
+
+
+
+ def create_tweaker(self, rec, alias=None, label=None, mtype=SimMotorTweak, **kwargs):
+ m=mtype()
+ m.connect_motor(rec, label, **kwargs)
+ self._tweakers[alias] = m
+ return m
+
+
+
def cb_really_quit(self):
"""called when user Ctrl-Q the app"""
if QMessageBox.question(self, "", "Are you sure you want to quit?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No,) == QMessageBox.Yes:
diff --git a/opticsExample.py b/opticsExample.py
index 036c6e6..4331e06 100755
--- a/opticsExample.py
+++ b/opticsExample.py
@@ -13,168 +13,306 @@ Optical system design demo
"""
-#import initExample ## Add path to library (just for examples; you do not need this)
-
-
#sys.path.insert(0, "/home/myname/pythonfiles")
-
-from pyqtgraph.examples.optics import *
+#/usr/lib/python3/dist-packages/pyqtgraph/examples/optics/
+from pyoptic import *
import pyqtgraph as pg
import numpy as np
from pyqtgraph import Point
-app = pg.QtGui.QApplication([])
+class VLSSG(Optic): # variable line space sperical grating
+ def __init__(self, **params):
+ defaults = {
+ 'r1': 0,
+ 'r2': 0,
+ 'd': 0.01,
+ }
+ defaults.update(params)
+ d = defaults.pop('d')
+ defaults['x1'] = -d/2.
+ defaults['x2'] = d/2.
+ gitem = CircularSolid(brush=(100,100,100,255), **defaults)
+ Optic.__init__(self, gitem, **defaults)
-w = pg.GraphicsLayoutWidget(show=True, border=0.5)
-w.resize(1000, 900)
-w.show()
+ def propagateRay(self, ray):
+ """Refract, reflect, absorb, and/or scatter ray. This function may create and return new rays"""
+
+ surface = self.surfaces[0]
+ p1, ai = surface.intersectRay(ray)
+ if p1 is not None:
+ p1 = surface.mapToItem(ray, p1)
+ rd = ray['dir']
+ a1 = np.arctan2(rd[1], rd[0])
+ ar = a1 + np.pi - 2*ai
+ print(ray['wl'])
+ ar+=(ray['wl']-800)/5000
+ ray.setEnd(p1)
+ dp = Point(np.cos(ar), np.sin(ar))
+ ray = Ray(parent=ray, dir=dp)
+ else:
+ ray.setEnd(None)
+ return [ray]
+
+
+def demoRIXS2():
+ app = pg.QtGui.QApplication([])
+ w = pg.GraphicsLayoutWidget(show=True, border=0.5)
+ w.resize(1000, 900)
+ w.show()
+
+ ### Curved mirror demo
+
+ vb = w.addViewBox()
+ vb.setAspectLocked()
+ grid =pg.GridItem(pen=(0, 255, 0), textPen=(0, 255, 0)) # green grid and labels
+ vb.addItem(grid)
+ vb.setRange(pg.QtCore.QRectF(-50, -30, 100, 100))
+
+ optics = []
+ rays = []
+ #m1 = Mirror(r1=-100, pos=(5,0), d=5, angle=-15)
+ m1 = VLSSG(r1=-3120, r2=0, pos=(-3.459543, 10.6), d=5, angle=-68.38532441633484)
+ optics.append(m1)
+
+ allRays = []
+ #for y in np.linspace(-1, 10, 21):
+ # r = Ray(start=Point(-100, y))
+ # view.addItem(r)
+ # allRays.append(r)
+
+ for wl in np.linspace(355,1040, 25):
+ for y in [10,15]:
+ #d=Point(1,(y-12.5)/300)
+ d=Point(1,(y-12.5)/300)
+ #r = Ray(start=Point(-100, y+wl/500), wl=wl, dir=d)
+ r = Ray(start=Point(-100, y), wl=wl)
+ vb.addItem(r)
+ allRays.append(r)
+
+ for o in optics:
+ vb.addItem(o)
+
+ t1 = Tracer(allRays, optics)
+
+ if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
+ QtGui.QApplication.instance().exec_()
-### Curved mirror demo
-view = w.addViewBox()
-view.setAspectLocked()
-#grid = pg.GridItem()
-#view.addItem(grid)
-view.setRange(pg.QtCore.QRectF(-50, -30, 100, 100))
+def demoRIXS1():
+ app = pg.QtGui.QApplication([])
+ w = pg.GraphicsLayoutWidget(show=True, border=0.5)
+ w.resize(1000, 900)
+ w.show()
-optics = []
-rays = []
-m1 = Mirror(r1=-100, pos=(5,0), d=5, angle=-15)
-optics.append(m1)
-m2 = Mirror(r1=-70, pos=(-40, 30), d=6, angle=180-15)
-optics.append(m2)
+ ### Curved mirror demo
-allRays = []
-for y in np.linspace(-10, 10, 21):
- r = Ray(start=Point(-100, y))
- view.addItem(r)
- allRays.append(r)
+ vb = w.addViewBox()
+ vb.setAspectLocked()
+ grid =pg.GridItem(pen=(0, 255, 0), textPen=(0, 255, 0)) # green grid and labels
+ vb.addItem(grid)
+ vb.setRange(pg.QtCore.QRectF(-50, -30, 100, 100))
-for o in optics:
- view.addItem(o)
-
-t1 = Tracer(allRays, optics)
+ optics = []
+ rays = []
+ #m1 = Mirror(r1=-100, pos=(5,0), d=5, angle=-15)
+ m1 = Mirror(r1=-100, r2=0, pos=(-3.459543, 10.6), d=5, angle=-68.38532441633484)
+ optics.append(m1)
+ l1 = Lens(pos=(41, 52.9),r1=20, r2=20, d=10, angle=15, glass='Corning7980')
+ optics.append(l1)
+
+ allRays = []
+ #for y in np.linspace(-1, 10, 21):
+ # r = Ray(start=Point(-100, y))
+ # view.addItem(r)
+ # allRays.append(r)
+
+ for wl in np.linspace(355,1040, 25):
+ for y in [10,15]:
+ d=Point(1,(y-12.5)/300)
+ r = Ray(start=Point(-100, y+wl/500), wl=wl, dir=d)
+ #r = Ray(start=Point(-100, y), wl=wl)
+ vb.addItem(r)
+ allRays.append(r)
+
+ for o in optics:
+ vb.addItem(o)
+
+ t1 = Tracer(allRays, optics)
+
+ if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
+ QtGui.QApplication.instance().exec_()
+
+
+def demoOrig():
+ app = pg.QtGui.QApplication([])
+
+ w = pg.GraphicsLayoutWidget(show=True, border=0.5)
+ w.resize(1000, 900)
+ w.show()
-### Dispersion demo
+ ### Curved mirror demo
-optics = []
+ view = w.addViewBox()
+ view.setAspectLocked()
+ #grid = pg.GridItem()
+ #view.addItem(grid)
+ view.setRange(pg.QtCore.QRectF(-50, -30, 100, 100))
-view = w.addViewBox()
+ optics = []
+ rays = []
+ m1 = Mirror(r1=-100, pos=(5,0), d=5, angle=-15)
+ optics.append(m1)
+ m2 = Mirror(r1=-70, pos=(-40, 30), d=6, angle=180-15)
+ optics.append(m2)
-view.setAspectLocked()
-#grid = pg.GridItem()
-#view.addItem(grid)
-view.setRange(pg.QtCore.QRectF(-10, -50, 90, 60))
+ allRays = []
+ for y in np.linspace(-10, 10, 21):
+ r = Ray(start=Point(-100, y))
+ view.addItem(r)
+ allRays.append(r)
-optics = []
-rays = []
-l1 = Lens(r1=20, r2=20, d=10, angle=8, glass='Corning7980')
-optics.append(l1)
+ for o in optics:
+ view.addItem(o)
-allRays = []
-for wl in np.linspace(355,1040, 25):
- for y in [10]:
- r = Ray(start=Point(-100, y), wl=wl)
- view.addItem(r)
- allRays.append(r)
-
-for o in optics:
- view.addItem(o)
-
-t2 = Tracer(allRays, optics)
+ t1 = Tracer(allRays, optics)
-### Scanning laser microscopy demo
+ ### Dispersion demo
-w.nextRow()
-view = w.addViewBox(colspan=2)
+ optics = []
-optics = []
+ view = w.addViewBox()
+ view.setAspectLocked()
+ #grid = pg.GridItem()
+ #view.addItem(grid)
+ view.setRange(pg.QtCore.QRectF(-10, -50, 90, 60))
-#view.setAspectLocked()
-view.setRange(QtCore.QRectF(200, -50, 500, 200))
+ optics = []
+ rays = []
+ l1 = Lens(r1=20, r2=20, d=10, angle=8, glass='Corning7980')
+ optics.append(l1)
+
+ allRays = []
+ for wl in np.linspace(355,1040, 25):
+ for y in [10]:
+ r = Ray(start=Point(-100, y), wl=wl)
+ view.addItem(r)
+ allRays.append(r)
+
+ for o in optics:
+ view.addItem(o)
+
+ t2 = Tracer(allRays, optics)
-## Scan mirrors
-scanx = 250
-scany = 20
-m1 = Mirror(dia=4.2, d=0.001, pos=(scanx, 0), angle=315)
-m2 = Mirror(dia=8.4, d=0.001, pos=(scanx, scany), angle=135)
+ ### Scanning laser microscopy demo
-## Scan lenses
-l3 = Lens(r1=23.0, r2=0, d=5.8, pos=(scanx+50, scany), glass='Corning7980') ## 50mm UVFS (LA4148)
-l4 = Lens(r1=0, r2=69.0, d=3.2, pos=(scanx+250, scany), glass='Corning7980') ## 150mm UVFS (LA4874)
+ w.nextRow()
+ view = w.addViewBox(colspan=2)
-## Objective
-obj = Lens(r1=15, r2=15, d=10, dia=8, pos=(scanx+400, scany), glass='Corning7980')
+ optics = []
-IROptics = [m1, m2, l3, l4, obj]
+
+ #view.setAspectLocked()
+ view.setRange(QtCore.QRectF(200, -50, 500, 200))
-## Scan mirrors
-scanx = 250
-scany = 30
-m1a = Mirror(dia=4.2, d=0.001, pos=(scanx, 2*scany), angle=315)
-m2a = Mirror(dia=8.4, d=0.001, pos=(scanx, 3*scany), angle=135)
+ ## Scan mirrors
+ scanx = 250
+ scany = 20
+ m1 = Mirror(dia=4.2, d=0.001, pos=(scanx, 0), angle=315)
+ m2 = Mirror(dia=8.4, d=0.001, pos=(scanx, scany), angle=135)
-## Scan lenses
-l3a = Lens(r1=46, r2=0, d=3.8, pos=(scanx+50, 3*scany), glass='Corning7980') ## 100mm UVFS (LA4380)
-l4a = Lens(r1=0, r2=46, d=3.8, pos=(scanx+250, 3*scany), glass='Corning7980') ## 100mm UVFS (LA4380)
+ ## Scan lenses
+ l3 = Lens(r1=23.0, r2=0, d=5.8, pos=(scanx+50, scany), glass='Corning7980') ## 50mm UVFS (LA4148)
+ l4 = Lens(r1=0, r2=69.0, d=3.2, pos=(scanx+250, scany), glass='Corning7980') ## 150mm UVFS (LA4874)
-## Objective
-obja = Lens(r1=15, r2=15, d=10, dia=8, pos=(scanx+400, 3*scany), glass='Corning7980')
+ ## Objective
+ obj = Lens(r1=15, r2=15, d=10, dia=8, pos=(scanx+400, scany), glass='Corning7980')
-IROptics2 = [m1a, m2a, l3a, l4a, obja]
+ IROptics = [m1, m2, l3, l4, obj]
-for o in set(IROptics+IROptics2):
- view.addItem(o)
-
-IRRays = []
-IRRays2 = []
+ ## Scan mirrors
+ scanx = 250
+ scany = 30
+ m1a = Mirror(dia=4.2, d=0.001, pos=(scanx, 2*scany), angle=315)
+ m2a = Mirror(dia=8.4, d=0.001, pos=(scanx, 3*scany), angle=135)
-for dy in [-0.4, -0.15, 0, 0.15, 0.4]:
- IRRays.append(Ray(start=Point(-50, dy), dir=(1, 0), wl=780))
- IRRays2.append(Ray(start=Point(-50, dy+2*scany), dir=(1, 0), wl=780))
-
-for r in set(IRRays+IRRays2):
- view.addItem(r)
+ ## Scan lenses
+ l3a = Lens(r1=46, r2=0, d=3.8, pos=(scanx+50, 3*scany), glass='Corning7980') ## 100mm UVFS (LA4380)
+ l4a = Lens(r1=0, r2=46, d=3.8, pos=(scanx+250, 3*scany), glass='Corning7980') ## 100mm UVFS (LA4380)
-IRTracer = Tracer(IRRays, IROptics)
-IRTracer2 = Tracer(IRRays2, IROptics2)
+ ## Objective
+ obja = Lens(r1=15, r2=15, d=10, dia=8, pos=(scanx+400, 3*scany), glass='Corning7980')
-phase = 0.0
-def update():
- global phase
- if phase % (8*np.pi) > 4*np.pi:
- m1['angle'] = 315 + 1.5*np.sin(phase)
- m1a['angle'] = 315 + 1.5*np.sin(phase)
- else:
- m2['angle'] = 135 + 1.5*np.sin(phase)
- m2a['angle'] = 135 + 1.5*np.sin(phase)
- phase += 0.2
-
-timer = QtCore.QTimer()
-timer.timeout.connect(update)
-timer.start(40)
+ IROptics2 = [m1a, m2a, l3a, l4a, obja]
+ for o in set(IROptics+IROptics2):
+ view.addItem(o)
+
+ IRRays = []
+ IRRays2 = []
+
+ for dy in [-0.4, -0.15, 0, 0.15, 0.4]:
+ IRRays.append(Ray(start=Point(-50, dy), dir=(1, 0), wl=780))
+ IRRays2.append(Ray(start=Point(-50, dy+2*scany), dir=(1, 0), wl=780))
+
+ for r in set(IRRays+IRRays2):
+ view.addItem(r)
+
+ IRTracer = Tracer(IRRays, IROptics)
+ IRTracer2 = Tracer(IRRays2, IROptics2)
+
+ global phase
+ phase = 0.0
+ def update():
+ global phase
+ if phase % (8*np.pi) > 4*np.pi:
+ m1['angle'] = 315 + 1.5*np.sin(phase)
+ m1a['angle'] = 315 + 1.5*np.sin(phase)
+ else:
+ m2['angle'] = 135 + 1.5*np.sin(phase)
+ m2a['angle'] = 135 + 1.5*np.sin(phase)
+ phase += 0.2
+
+ timer = QtCore.QTimer()
+ timer.timeout.connect(update)
+ timer.start(40)
+ if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
+ QtGui.QApplication.instance().exec_()
## Start Qt event loop unless running in interactive mode or using pyside.
-if __name__ == '__main__':
- import sys
- if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
- QtGui.QApplication.instance().exec_()
+if __name__=='__main__':
+ import sys
+ import argparse
+ #(h, t)=os.path.split(sys.argv[0]);cmd='\n '+(t if len(h)>20 else sys.argv[0])+' '
+ #exampleCmd=('', '-m0xf -v0')
+ epilog=__doc__ #+'\nExamples:'+''.join(map(lambda s:cmd+s, exampleCmd))+'\n'
+ parser=argparse.ArgumentParser(epilog=epilog, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("--mode", "-m", type=lambda x: int(x,0), help="mode default=0x%(default)x", default=0)
+ args = parser.parse_args()
+
+ if args.mode==0:
+ demoOrig()
+ elif args.mode==1:
+ demoRIXS1()
+ elif args.mode==2:
+ demoRIXS2()
+ else:
+ demoRIXS()
+
diff --git a/pyoptic.py b/pyoptic.py
new file mode 100644
index 0000000..1ad65d5
--- /dev/null
+++ b/pyoptic.py
@@ -0,0 +1,563 @@
+# -*- coding: utf-8 -*-
+import pyqtgraph as pg
+from pyqtgraph.Qt import QtGui, QtCore
+import numpy as np
+import csv, gzip, os
+from pyqtgraph import Point
+
+class GlassDB:
+ """
+ Database of dispersion coefficients for Schott glasses
+ + Corning 7980
+ """
+ def __init__(self, fileName='schott_glasses.csv'):
+ path = os.path.dirname(__file__)
+ fh = gzip.open(os.path.join(path, 'schott_glasses.csv.gz'), 'rb')
+ r = csv.reader(map(str, fh.readlines()))
+ lines = [x for x in r]
+ self.data = {}
+ header = lines[0]
+ for l in lines[1:]:
+ info = {}
+ for i in range(1, len(l)):
+ info[header[i]] = l[i]
+ self.data[l[0]] = info
+ self.data['Corning7980'] = { ## Thorlabs UV fused silica--not in schott catalog.
+ 'B1': 0.68374049400,
+ 'B2': 0.42032361300,
+ 'B3': 0.58502748000,
+ 'C1': 0.00460352869,
+ 'C2': 0.01339688560,
+ 'C3': 64.49327320000,
+ 'TAUI25/250': 0.95, ## transmission data is fabricated, but close.
+ 'TAUI25/1400': 0.98,
+ }
+
+ for k in self.data:
+ self.data[k]['ior_cache'] = {}
+
+
+ def ior(self, glass, wl):
+ """
+ Return the index of refraction for *glass* at wavelength *wl*.
+
+ The *glass* argument must be a key in self.data.
+ """
+ info = self.data[glass]
+ cache = info['ior_cache']
+ if wl not in cache:
+ B = list(map(float, [info['B1'], info['B2'], info['B3']]))
+ C = list(map(float, [info['C1'], info['C2'], info['C3']]))
+ w2 = (wl/1000.)**2
+ n = np.sqrt(1.0 + (B[0]*w2 / (w2-C[0])) + (B[1]*w2 / (w2-C[1])) + (B[2]*w2 / (w2-C[2])))
+ cache[wl] = n
+ return cache[wl]
+
+ def transmissionCurve(self, glass):
+ data = self.data[glass]
+ keys = [int(x[7:]) for x in data.keys() if 'TAUI25' in x]
+ keys.sort()
+ curve = np.empty((2,len(keys)))
+ for i in range(len(keys)):
+ curve[0][i] = keys[i]
+ key = 'TAUI25/%d' % keys[i]
+ val = data[key]
+ if val == '':
+ val = 0
+ else:
+ val = float(val)
+ curve[1][i] = val
+ return curve
+
+
+GLASSDB = GlassDB()
+
+
+def wlPen(wl):
+ """Return a pen representing the given wavelength"""
+ l1 = 400
+ l2 = 700
+ hue = np.clip(((l2-l1) - (wl-l1)) * 0.8 / (l2-l1), 0, 0.8)
+ val = 1.0
+ if wl > 700:
+ val = 1.0 * (((700-wl)/700.) + 1)
+ elif wl < 400:
+ val = wl * 1.0/400.
+ #print hue, val
+ color = pg.hsvColor(hue, 1.0, val)
+ pen = pg.mkPen(color)
+ return pen
+
+
+class ParamObj(object):
+ # Just a helper for tracking parameters and responding to changes
+ def __init__(self):
+ self.__params = {}
+
+ def __setitem__(self, item, val):
+ self.setParam(item, val)
+
+ def setParam(self, param, val):
+ self.setParams(**{param:val})
+
+ def setParams(self, **params):
+ """Set parameters for this optic. This is a good function to override for subclasses."""
+ self.__params.update(params)
+ self.paramStateChanged()
+
+ def paramStateChanged(self):
+ pass
+
+ def __getitem__(self, item):
+ # bug in pyside 1.2.2 causes getitem to be called inside QGraphicsObject.parentItem:
+ return self.getParam(item) # PySide bug: https://bugreports.qt.io/browse/PYSIDE-671
+
+ def __len__(self):
+ # Workaround for PySide bug: https://bugreports.qt.io/browse/PYSIDE-671
+ return 0
+
+ def getParam(self, param):
+ return self.__params[param]
+
+
+class Optic(pg.GraphicsObject, ParamObj):
+
+ sigStateChanged = QtCore.Signal()
+
+
+ def __init__(self, gitem, **params):
+ ParamObj.__init__(self)
+ pg.GraphicsObject.__init__(self) #, [0,0], [1,1])
+
+ self.gitem = gitem
+ self.surfaces = gitem.surfaces
+ gitem.setParentItem(self)
+
+ self.roi = pg.ROI([0,0], [1,1])
+ self.roi.addRotateHandle([1, 1], [0.5, 0.5])
+ self.roi.setParentItem(self)
+
+ defaults = {
+ 'pos': Point(0,0),
+ 'angle': 0,
+ }
+ defaults.update(params)
+ self._ior_cache = {}
+ self.roi.sigRegionChanged.connect(self.roiChanged)
+ self.setParams(**defaults)
+
+ def updateTransform(self):
+ self.resetTransform()
+ self.setPos(0, 0)
+ self.translate(Point(self['pos']))
+ self.rotate(self['angle'])
+
+ def setParam(self, param, val):
+ ParamObj.setParam(self, param, val)
+
+ def paramStateChanged(self):
+ """Some parameters of the optic have changed."""
+ # Move graphics item
+ self.gitem.setPos(Point(self['pos']))
+ self.gitem.resetTransform()
+ self.gitem.rotate(self['angle'])
+
+ # Move ROI to match
+ try:
+ self.roi.sigRegionChanged.disconnect(self.roiChanged)
+ br = self.gitem.boundingRect()
+ o = self.gitem.mapToParent(br.topLeft())
+ self.roi.setAngle(self['angle'])
+ self.roi.setPos(o)
+ self.roi.setSize([br.width(), br.height()])
+ finally:
+ self.roi.sigRegionChanged.connect(self.roiChanged)
+
+ self.sigStateChanged.emit()
+
+ def roiChanged(self, *args):
+ pos = self.roi.pos()
+ # rotate gitem temporarily so we can decide where it will need to move
+ self.gitem.resetTransform()
+ self.gitem.rotate(self.roi.angle())
+ br = self.gitem.boundingRect()
+ o1 = self.gitem.mapToParent(br.topLeft())
+ self.setParams(angle=self.roi.angle(), pos=pos + (self.gitem.pos() - o1))
+ print(f'angle:{self.roi.angle()} pos:{self.roi.pos()}')
+
+ def boundingRect(self):
+ return QtCore.QRectF()
+
+ def paint(self, p, *args):
+ pass
+
+ def ior(self, wavelength):
+ return GLASSDB.ior(self['glass'], wavelength)
+
+
+
+class Lens(Optic):
+ def __init__(self, **params):
+ defaults = {
+ 'dia': 25.4, ## diameter of lens
+ 'r1': 50., ## positive means convex, use 0 for planar
+ 'r2': 0, ## negative means convex
+ 'd': 4.0,
+ 'glass': 'N-BK7',
+ 'reflect': False,
+ }
+ defaults.update(params)
+ d = defaults.pop('d')
+ defaults['x1'] = -d/2.
+ defaults['x2'] = d/2.
+
+ gitem = CircularSolid(brush=(100, 100, 130, 100), **defaults)
+ Optic.__init__(self, gitem, **defaults)
+
+ def propagateRay(self, ray):
+ """Refract, reflect, absorb, and/or scatter ray. This function may create and return new rays"""
+
+ """
+ NOTE:: We can probably use this to compute refractions faster: (from GLSL 120 docs)
+
+ For the incident vector I and surface normal N, and the
+ ratio of indices of refraction eta, return the refraction
+ vector. The result is computed by
+ k = 1.0 - eta * eta * (1.0 - dot(N, I) * dot(N, I))
+ if (k < 0.0)
+ return genType(0.0)
+ else
+ return eta * I - (eta * dot(N, I) + sqrt(k)) * N
+ The input parameters for the incident vector I and the
+ surface normal N must already be normalized to get the
+ desired results. eta == ratio of IORs
+
+
+ For reflection:
+ For the incident vector I and surface orientation N,
+ returns the reflection direction:
+ I – 2 ∗ dot(N, I) ∗ N
+ N must already be normalized in order to achieve the
+ desired result.
+ """
+ iors = [self.ior(ray['wl']), 1.0]
+ for i in [0,1]:
+ surface = self.surfaces[i]
+ ior = iors[i]
+ p1, ai = surface.intersectRay(ray)
+ if p1 is None:
+ ray.setEnd(None)
+ break
+ p1 = surface.mapToItem(ray, p1)
+
+ rd = ray['dir']
+ a1 = np.arctan2(rd[1], rd[0])
+ ar = a1 - ai + np.arcsin((np.sin(ai) * ray['ior'] / ior))
+ ray.setEnd(p1)
+ dp = Point(np.cos(ar), np.sin(ar))
+ ray = Ray(parent=ray, ior=ior, dir=dp)
+ return [ray]
+
+
+class Mirror(Optic):
+ def __init__(self, **params):
+ defaults = {
+ 'r1': 0,
+ 'r2': 0,
+ 'd': 0.01,
+ }
+ defaults.update(params)
+ d = defaults.pop('d')
+ defaults['x1'] = -d/2.
+ defaults['x2'] = d/2.
+ gitem = CircularSolid(brush=(100,100,100,255), **defaults)
+ Optic.__init__(self, gitem, **defaults)
+
+ def propagateRay(self, ray):
+ """Refract, reflect, absorb, and/or scatter ray. This function may create and return new rays"""
+
+ surface = self.surfaces[0]
+ p1, ai = surface.intersectRay(ray)
+ if p1 is not None:
+ p1 = surface.mapToItem(ray, p1)
+ rd = ray['dir']
+ a1 = np.arctan2(rd[1], rd[0])
+ ar = a1 + np.pi - 2*ai
+ ray.setEnd(p1)
+ dp = Point(np.cos(ar), np.sin(ar))
+ ray = Ray(parent=ray, dir=dp)
+ else:
+ ray.setEnd(None)
+ return [ray]
+
+
+class CircularSolid(pg.GraphicsObject, ParamObj):
+ """GraphicsObject with two circular or flat surfaces."""
+ def __init__(self, pen=None, brush=None, **opts):
+ """
+ Arguments for each surface are:
+ x1,x2 - position of center of _physical surface_
+ r1,r2 - radius of curvature
+ d1,d2 - diameter of optic
+ """
+ defaults = dict(x1=-2, r1=100, d1=25.4, x2=2, r2=100, d2=25.4)
+ defaults.update(opts)
+ ParamObj.__init__(self)
+ self.surfaces = [CircleSurface(defaults['r1'], defaults['d1']), CircleSurface(-defaults['r2'], defaults['d2'])]
+ pg.GraphicsObject.__init__(self)
+ for s in self.surfaces:
+ s.setParentItem(self)
+
+ if pen is None:
+ self.pen = pg.mkPen((220,220,255,200), width=1, cosmetic=True)
+ else:
+ self.pen = pg.mkPen(pen)
+
+ if brush is None:
+ self.brush = pg.mkBrush((230, 230, 255, 30))
+ else:
+ self.brush = pg.mkBrush(brush)
+
+ self.setParams(**defaults)
+
+ def paramStateChanged(self):
+ self.updateSurfaces()
+
+ def updateSurfaces(self):
+ self.surfaces[0].setParams(self['r1'], self['d1'])
+ self.surfaces[1].setParams(-self['r2'], self['d2'])
+ self.surfaces[0].setPos(self['x1'], 0)
+ self.surfaces[1].setPos(self['x2'], 0)
+
+ self.path = QtGui.QPainterPath()
+ self.path.connectPath(self.surfaces[0].path.translated(self.surfaces[0].pos()))
+ self.path.connectPath(self.surfaces[1].path.translated(self.surfaces[1].pos()).toReversed())
+ self.path.closeSubpath()
+
+ def boundingRect(self):
+ return self.path.boundingRect()
+
+ def shape(self):
+ return self.path
+
+ def paint(self, p, *args):
+ p.setRenderHints(p.renderHints() | p.Antialiasing)
+ p.setPen(self.pen)
+ p.fillPath(self.path, self.brush)
+ p.drawPath(self.path)
+
+
+class CircleSurface(pg.GraphicsObject):
+ def __init__(self, radius=None, diameter=None):
+ """center of physical surface is at 0,0
+ radius is the radius of the surface. If radius is None, the surface is flat.
+ diameter is of the optic's edge."""
+ pg.GraphicsObject.__init__(self)
+
+ self.r = radius
+ self.d = diameter
+ self.mkPath()
+
+ def setParams(self, r, d):
+ self.r = r
+ self.d = d
+ self.mkPath()
+
+ def mkPath(self):
+ self.prepareGeometryChange()
+ r = self.r
+ d = self.d
+ h2 = d/2.
+ self.path = QtGui.QPainterPath()
+ if r == 0: ## flat surface
+ self.path.moveTo(0, h2)
+ self.path.lineTo(0, -h2)
+ else:
+ ## half-height of surface can't be larger than radius
+ h2 = min(h2, abs(r))
+ arc = QtCore.QRectF(0, -r, r*2, r*2)
+ a1 = np.arcsin(h2/r) * 180. / np.pi
+ a2 = -2*a1
+ a1 += 180.
+ self.path.arcMoveTo(arc, a1)
+ self.path.arcTo(arc, a1, a2)
+ self.h2 = h2
+
+ def boundingRect(self):
+ return self.path.boundingRect()
+
+ def paint(self, p, *args):
+ return ## usually we let the optic draw.
+
+ def intersectRay(self, ray):
+ ## return the point of intersection and the angle of incidence
+ #print "intersect ray"
+ h = self.h2
+ r = self.r
+ p, dir = ray.currentState(relativeTo=self) # position and angle of ray in local coords.
+ #print " ray: ", p, dir
+ p = p - Point(r, 0) ## move position so center of circle is at 0,0
+ #print " adj: ", p, r
+
+ if r == 0:
+ #print " flat"
+ if dir[0] == 0:
+ y = 0
+ else:
+ y = p[1] - p[0] * dir[1]/dir[0]
+ if abs(y) > h:
+ return None, None
+ else:
+ return (Point(0, y), np.arctan2(dir[1], dir[0]))
+ else:
+ #print " curve"
+ ## find intersection of circle and line (quadratic formula)
+ dx = dir[0]
+ dy = dir[1]
+ dr = (dx**2 + dy**2) ** 0.5
+ D = p[0] * (p[1]+dy) - (p[0]+dx) * p[1]
+ idr2 = 1.0 / dr**2
+ disc = r**2 * dr**2 - D**2
+ if disc < 0:
+ return None, None
+ disc2 = disc**0.5
+ if dy < 0:
+ sgn = -1
+ else:
+ sgn = 1
+
+
+ br = self.path.boundingRect()
+ x1 = (D*dy + sgn*dx*disc2) * idr2
+ y1 = (-D*dx + abs(dy)*disc2) * idr2
+ if br.contains(x1+r, y1):
+ pt = Point(x1, y1)
+ else:
+ x2 = (D*dy - sgn*dx*disc2) * idr2
+ y2 = (-D*dx - abs(dy)*disc2) * idr2
+ pt = Point(x2, y2)
+ if not br.contains(x2+r, y2):
+ return None, None
+ raise Exception("No intersection!")
+
+ norm = np.arctan2(pt[1], pt[0])
+ if r < 0:
+ norm += np.pi
+ #print " norm:", norm*180/3.1415
+ dp = p - pt
+ #print " dp:", dp
+ ang = np.arctan2(dp[1], dp[0])
+ #print " ang:", ang*180/3.1415
+ #print " ai:", (ang-norm)*180/3.1415
+
+ #print " intersection:", pt
+ return pt + Point(r, 0), ang-norm
+
+
+class Ray(pg.GraphicsObject, ParamObj):
+ """Represents a single straight segment of a ray"""
+
+ sigStateChanged = QtCore.Signal()
+
+ def __init__(self, **params):
+ ParamObj.__init__(self)
+ defaults = {
+ 'ior': 1.0,
+ 'wl': 500,
+ 'end': None,
+ 'dir': Point(1,0),
+ }
+ self.params = {}
+ pg.GraphicsObject.__init__(self)
+ self.children = []
+ parent = params.get('parent', None)
+ if parent is not None:
+ defaults['start'] = parent['end']
+ defaults['wl'] = parent['wl']
+ self['ior'] = parent['ior']
+ self['dir'] = parent['dir']
+ parent.addChild(self)
+
+ defaults.update(params)
+ defaults['dir'] = Point(defaults['dir'])
+ self.setParams(**defaults)
+ self.mkPath()
+
+ def clearChildren(self):
+ for c in self.children:
+ c.clearChildren()
+ c.setParentItem(None)
+ self.scene().removeItem(c)
+ self.children = []
+
+ def paramStateChanged(self):
+ pass
+
+ def addChild(self, ch):
+ self.children.append(ch)
+ ch.setParentItem(self)
+
+ def currentState(self, relativeTo=None):
+ pos = self['start']
+ dir = self['dir']
+ if relativeTo is None:
+ return pos, dir
+ else:
+ trans = self.itemTransform(relativeTo)[0]
+ p1 = trans.map(pos)
+ p2 = trans.map(pos + dir)
+ return Point(p1), Point(p2-p1)
+
+ def setEnd(self, end):
+ self['end'] = end
+ self.mkPath()
+
+ def boundingRect(self):
+ return self.path.boundingRect()
+
+ def paint(self, p, *args):
+ #p.setPen(pg.mkPen((255,0,0, 150)))
+ p.setRenderHints(p.renderHints() | p.Antialiasing)
+ p.setCompositionMode(p.CompositionMode_Plus)
+ p.setPen(wlPen(self['wl']))
+ p.drawPath(self.path)
+
+ def mkPath(self):
+ self.prepareGeometryChange()
+ self.path = QtGui.QPainterPath()
+ self.path.moveTo(self['start'])
+ if self['end'] is not None:
+ self.path.lineTo(self['end'])
+ else:
+ self.path.lineTo(self['start']+500*self['dir'])
+
+
+def trace(rays, optics):
+ if len(optics) < 1 or len(rays) < 1:
+ return
+ for r in rays:
+ r.clearChildren()
+ o = optics[0]
+ r2 = o.propagateRay(r)
+ trace(r2, optics[1:])
+
+
+class Tracer(QtCore.QObject):
+ """
+ Simple ray tracer.
+
+ Initialize with a list of rays and optics;
+ calling trace() will cause rays to be extended by propagating them through
+ each optic in sequence.
+ """
+ def __init__(self, rays, optics):
+ QtCore.QObject.__init__(self)
+ self.optics = optics
+ self.rays = rays
+ for o in self.optics:
+ o.sigStateChanged.connect(self.trace)
+ self.trace()
+
+ def trace(self):
+ trace(self.rays, self.optics)
+