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) +