diff --git a/src/firefly/application.py b/src/firefly/application.py index 9c4dd6c7..724a646c 100644 --- a/src/firefly/application.py +++ b/src/firefly/application.py @@ -257,7 +257,9 @@ def setup_window_actions(self): plans = [ # (plan_name, text, display file) ("count", "&Count", "count.py"), + ("move_motor", "&Move motor", "move_motor_window.py"), ("line_scan", "&Line scan", "line_scan.py"), + ("grid_scan", "&Grid scan", "grid_scan.py"), ("xafs_scan", "&XAFS Scan", "xafs_scan.py"), ] self.plan_actions = [] diff --git a/src/firefly/plans/grid_scan.py b/src/firefly/plans/grid_scan.py new file mode 100644 index 00000000..aa7fed02 --- /dev/null +++ b/src/firefly/plans/grid_scan.py @@ -0,0 +1,188 @@ +import logging + +from bluesky_queueserver_api import BPlan +from qtpy import QtWidgets +from qtpy.QtGui import QDoubleValidator + +from firefly.application import FireflyApplication +from firefly.component_selector import ComponentSelector +from firefly.plans.line_scan import LineScanDisplay + +log = logging.getLogger() + + +class TitleRegion: + def __init__(self): + self.setup_ui() + + def setup_ui(self): + self.layout = QtWidgets.QGridLayout() + labels = ["Priority axis", "Motor", "Start", "Stop", "Snake", "Fly"] + Qlabels_all = {} + + # add labels in the first row + for i, label_i in enumerate(labels): + Qlabel_i = QtWidgets.QLabel(label_i) + self.layout.addWidget(Qlabel_i, 0, i) + Qlabels_all[label_i] = Qlabel_i + + # fix widths so the labels are aligned with GridScanRegions + Qlabels_all["Priority axis"].setFixedWidth(70) + Qlabels_all["Motor"].setFixedWidth(100) + Qlabels_all["Snake"].setFixedWidth(53) + Qlabels_all["Fly"].setFixedWidth(43) + + # add labels in the second row + label = QtWidgets.QLabel("fast -> slow") + self.layout.addWidget(label, 1, 0) + + +class GridScanRegion: + def __init__(self): + self.setup_ui() + + def setup_ui(self): + self.layout = QtWidgets.QHBoxLayout() + + # First item, motor No. + self.motor_label = QtWidgets.QLCDNumber() + self.motor_label.setStyleSheet( + "QLCDNumber { background-color: white; color: red; }" + ) + self.layout.addWidget(self.motor_label) + + # Second item, ComponentSelector + self.motor_box = ComponentSelector() + self.layout.addWidget(self.motor_box) + + # Third item, start point + self.start_line_edit = QtWidgets.QLineEdit() + self.start_line_edit.setValidator(QDoubleValidator()) # only takes floats + self.start_line_edit.setPlaceholderText("Start…") + self.layout.addWidget(self.start_line_edit) + + # Forth item, stop point + self.stop_line_edit = QtWidgets.QLineEdit() + self.stop_line_edit.setValidator(QDoubleValidator()) # only takes floats + self.stop_line_edit.setPlaceholderText("Stop…") + self.layout.addWidget(self.stop_line_edit) + + # Fifth item, snake checkbox + self.snake_checkbox = QtWidgets.QCheckBox() + self.snake_checkbox.setText("Snake") + self.snake_checkbox.setEnabled(True) + self.layout.addWidget(self.snake_checkbox) + + # Sixth item, fly checkbox # not available right now + self.fly_checkbox = QtWidgets.QCheckBox() + self.fly_checkbox.setText("Fly") + self.fly_checkbox.setEnabled(False) + self.layout.addWidget(self.fly_checkbox) + + +class GridScanDisplay(LineScanDisplay): + default_num_regions = 2 + + def __init__(self, parent=None, args=None, macros=None, ui_filename=None, **kwargs): + super().__init__(parent, args, macros, ui_filename, **kwargs) + + def customize_ui(self): + super().customize_ui() + # add title layout + self.title_region = TitleRegion() + self.ui.title_layout.addLayout(self.title_region.layout) + # reset button + self.ui.reset_pushButton.clicked.connect(self.reset_default_regions) + + def add_regions(self, num=1): + for i in range(num): + region = GridScanRegion() + self.ui.regions_layout.addLayout(region.layout) + # Save it to the list + self.regions.append(region) + + # the num of motor + num_motor_i = len(self.regions) + # region.motor_label.setText(str(num_motor_i)) # when using label + region.motor_label.display(num_motor_i) + + def time_calculate_method(self, detector_time): + num_points = self.ui.scan_pts_spin_box.value() + num_regions = len(self.regions) + total_time_per_scan = num_regions * detector_time * num_points + return total_time_per_scan + + def update_regions(self): + super().update_regions() + + # disable snake for the last region and enable the previous regions + self.regions[-1].snake_checkbox.setEnabled(False) + for region_i in self.regions[:-1]: + region_i.snake_checkbox.setEnabled(True) + + def queue_plan(self, *args, **kwargs): + """Execute this plan on the queueserver.""" + detectors, num_points, motor_args, repeat_scan_num, md = ( + self.get_scan_parameters() + ) + + # get snake axes, if all unchecked, set it None + snake_axes = [ + region_i.motor_box.current_component().name + for i, region_i in enumerate(self.regions) + if region_i.snake_checkbox.isChecked() + ] + + if snake_axes == []: + snake_axes = False + + if self.ui.relative_scan_checkbox.isChecked(): + scan_type = "rel_grid_scan" + else: + scan_type = "grid_scan" + + # # Build the queue item + item = BPlan( + scan_type, + detectors, + *motor_args, + num=num_points, + snake_axes=snake_axes, + md=md, + ) + + # Submit the item to the queueserver + app = FireflyApplication.instance() + log.info("Added line scan() plan to queue.") + # repeat scans + for i in range(repeat_scan_num): + app.add_queue_item(item) + + def ui_filename(self): + return "plans/grid_scan.ui" + + +# ----------------------------------------------------------------------------- +# :author: Juanjuan Huang +# :email: juanjuan.huang@anl.gov +# :copyright: Copyright © 2024, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/firefly/plans/grid_scan.ui b/src/firefly/plans/grid_scan.ui new file mode 100644 index 00000000..10656fdc --- /dev/null +++ b/src/firefly/plans/grid_scan.ui @@ -0,0 +1,588 @@ + + + Form + + + + 0 + 0 + 787 + 366 + + + + 2D Scan + + + + + + + + + + true + + + + + 0 + 0 + 377 + 162 + + + + + + + + + + + 1 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 20 + 0 + + + + + 0 + 0 + + + + Start... + + + + + + + + 0 + 0 + + + + Stop... + + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + Qt::Vertical + + + + + + + + + + 0 + 0 + + + + Detectors + + + + + + + <html><head/><body><p>Use <span style=" font-weight:600;">ctrl</span> to select multiple detectors</p></body></html> + + + QAbstractItemView::MultiSelection + + + + + + + + + + + + + Experiment purpose + + + + + + + Notes + + + + + + + Type this sample's name + + + Sample name + + + + + + + Sample name + + + + + + + + + total exposure time for a single scan + + + Exposure time each scan + + + + + + + total exposure time for a single scan + + + Total exposure time + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + h + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + min + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + s + + + Qt::AlignCenter + + + + + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + h + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + min + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + s + + + Qt::AlignCenter + + + + + + + + + + + Type the experimental purpose note for this scan + + + Purpose + + + + + + + true + + + + 0 + 0 + + + + + 16777215 + 60 + + + + 1 + + + 14 + + + 80 + + + Additional notes about the sample + + + + + + + Run + + + + + + + Do this scan by multiple times + + + Num. of scans + + + + + + + Do this scan by multiple times + + + 1 + + + 999 + + + + + + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + Num. Motors + + + + + + + true + + + false + + + false + + + QAbstractSpinBox::CorrectToNearestValue + + + 1 + + + 1 + + + 10 + + + + + + + Qt::Vertical + + + + + + + Relative + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Scan Points + + + + + + + 1 + + + 10000 + + + 2 + + + 10 + + + + + + + Reset to default values + + + + + + Reset Defaults + + + + + + + + + Qt::Horizontal + + + + + + + + + + QueueButton + QPushButton +
firefly.queue_button
+
+ + DetectorListView + QListWidget +
firefly.detector_list
+
+ + ComponentSelector + QWidget +
firefly.component_selector
+ 1 +
+
+ + +
diff --git a/src/firefly/plans/line_scan.py b/src/firefly/plans/line_scan.py index 309109e1..e837071a 100644 --- a/src/firefly/plans/line_scan.py +++ b/src/firefly/plans/line_scan.py @@ -2,9 +2,12 @@ from bluesky_queueserver_api import BPlan from qtpy import QtWidgets +from qtpy.QtGui import QDoubleValidator from firefly import display +from firefly.application import FireflyApplication from firefly.component_selector import ComponentSelector +from firefly.plans.util import is_valid_value, time_converter log = logging.getLogger() @@ -16,30 +19,28 @@ def __init__(self): def setup_ui(self): self.layout = QtWidgets.QHBoxLayout() - # First item, motor No. - # self.motor_label = QtWidgets.QLabel() - # self.motor_label.setText("1") - # self.layout.addWidget(self.motor_label) - - # Second item, ComponentSelector + # First item, ComponentSelector self.motor_box = ComponentSelector() - # self.stop_line_edit.setPlaceholderText("Stop…") self.layout.addWidget(self.motor_box) - # Third item, start point + # Second item, start point self.start_line_edit = QtWidgets.QLineEdit() + self.start_line_edit.setValidator(QDoubleValidator()) # only takes floats self.start_line_edit.setPlaceholderText("Start…") self.layout.addWidget(self.start_line_edit) - # Forth item, stop point + # Third item, stop point self.stop_line_edit = QtWidgets.QLineEdit() + self.stop_line_edit.setValidator(QDoubleValidator()) # only takes floats self.stop_line_edit.setPlaceholderText("Stop…") self.layout.addWidget(self.stop_line_edit) class LineScanDisplay(display.FireflyDisplay): + default_num_regions = 1 + def customize_ui(self): - # Remove the default XAFS layout from .ui file + # Remove the default layout from .ui file self.clearLayout(self.ui.region_template_layout) self.reset_default_regions() @@ -50,6 +51,13 @@ def customize_ui(self): self.ui.run_button.setEnabled(True) # for testing self.ui.run_button.clicked.connect(self.queue_plan) + # when selections of detectors changed update_total_time + self.ui.detectors_list.selectionModel().selectionChanged.connect( + self.update_total_time + ) + self.ui.spinBox_repeat_scan_num.valueChanged.connect(self.update_total_time) + self.ui.scan_pts_spin_box.valueChanged.connect(self.update_total_time) + def clearLayout(self, layout): if layout is not None: while layout.count(): @@ -58,11 +66,10 @@ def clearLayout(self, layout): item.widget().deleteLater() def reset_default_regions(self): - default_num_regions = 1 if not hasattr(self, "regions"): self.regions = [] - self.add_regions(default_num_regions) - self.ui.num_motor_spin_box.setValue(default_num_regions) + self.add_regions(self.default_num_regions) + self.ui.num_motor_spin_box.setValue(self.default_num_regions) self.update_regions() def add_regions(self, num=1): @@ -92,13 +99,49 @@ def update_regions(self): elif diff_region_num > 0: self.add_regions(diff_region_num) - def queue_plan(self, *args, **kwargs): - """Execute this plan on the queueserver.""" + def update_total_time(self): + # get default detector time + app = FireflyApplication.instance() + detectors = self.ui.detectors_list.selected_detectors() + detectors = [app.registry[name] for name in detectors] + detectors = [det for det in detectors if hasattr(det, "default_time_signal")] + + # to prevent detector list is empty + try: + detector_time = max([det.default_time_signal.get() for det in detectors]) + except ValueError: + detector_time = float("nan") + + # get scan num points to calculate total time + total_time_per_scan = self.time_calculate_method(detector_time) + + # calculate time for each scan + hrs, mins, secs = time_converter(total_time_per_scan) + self.ui.label_hour_scan.setText(str(hrs)) + self.ui.label_min_scan.setText(str(mins)) + self.ui.label_sec_scan.setText(str(secs)) + + # calculate time for entire plan + num_scan_repeat = self.ui.spinBox_repeat_scan_num.value() + total_time = num_scan_repeat * total_time_per_scan + hrs_total, mins_total, secs_total = time_converter(total_time) + + self.ui.label_hour_total.setText(str(hrs_total)) + self.ui.label_min_total.setText(str(mins_total)) + self.ui.label_sec_total.setText(str(secs_total)) + + def time_calculate_method(self, detector_time): + num_points = self.ui.scan_pts_spin_box.value() + total_time_per_scan = detector_time * num_points + return total_time_per_scan + + def get_scan_parameters(self): # Get scan parameters from widgets detectors = self.ui.detectors_list.selected_detectors() num_points = self.ui.scan_pts_spin_box.value() + repeat_scan_num = int(self.ui.spinBox_repeat_scan_num.value()) - # get paramters from each rows of line regions: + # Get paramters from each rows of line regions: motor_lst, start_lst, stop_lst = [], [], [] for region_i in self.regions: motor_lst.append(region_i.motor_box.current_component().name) @@ -111,6 +154,23 @@ def queue_plan(self, *args, **kwargs): for values in motor_i ] + # Get meta data info + md = { + "sample": self.ui.lineEdit_sample.text(), + "purpose": self.ui.lineEdit_purpose.text(), + "notes": self.ui.textEdit_notes.toPlainText(), + } + # Only include metadata that isn't an empty string + md = {key: val for key, val in md.items() if is_valid_value(val)} + + return detectors, num_points, motor_args, repeat_scan_num, md + + def queue_plan(self, *args, **kwargs): + """Execute this plan on the queueserver.""" + detectors, num_points, motor_args, repeat_scan_num, md = ( + self.get_scan_parameters() + ) + if self.ui.relative_scan_checkbox.isChecked(): if self.ui.log_scan_checkbox.isChecked(): scan_type = "rel_log_scan" @@ -122,11 +182,6 @@ def queue_plan(self, *args, **kwargs): else: scan_type = "scan" - md = { - "sample": self.ui.lineEdit_sample.text(), - "purpose": self.ui.lineEdit_purpose.text(), - } - # # Build the queue item item = BPlan( scan_type, @@ -138,11 +193,37 @@ def queue_plan(self, *args, **kwargs): ) # Submit the item to the queueserver - from firefly.application import FireflyApplication - app = FireflyApplication.instance() log.info("Added line scan() plan to queue.") - app.add_queue_item(item) + # repeat scans + for i in range(repeat_scan_num): + app.add_queue_item(item) def ui_filename(self): return "plans/line_scan.ui" + + +# ----------------------------------------------------------------------------- +# :author: Juanjuan Huang +# :email: juanjuan.huang@anl.gov +# :copyright: Copyright © 2024, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/firefly/plans/line_scan.ui b/src/firefly/plans/line_scan.ui index 814551e7..8d503dfc 100644 --- a/src/firefly/plans/line_scan.ui +++ b/src/firefly/plans/line_scan.ui @@ -6,8 +6,8 @@ 0 0 - 660 - 366 + 770 + 318 @@ -16,6 +16,132 @@ + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + Num. Motors + + + + + + + true + + + false + + + false + + + QAbstractSpinBox::CorrectToNearestValue + + + 1 + + + 1 + + + 10 + + + + + + + Qt::Vertical + + + + + + + Relative + + + + + + + Qt::Vertical + + + + + + + Log + + + + + + + Qt::Vertical + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Scan Points + + + + + + + 1 + + + 10000 + + + 2 + + + 10 + + + + + @@ -28,8 +154,8 @@ 0 0 - 372 - 234 + 377 + 114 @@ -136,6 +262,9 @@ <html><head/><body><p>Use <span style=" font-weight:600;">ctrl</span> to select multiple detectors</p></body></html> + + QAbstractItemView::MultiSelection + @@ -143,15 +272,39 @@ - - - + + + - Sample name + Notes - + + + + + + total exposure time for a single scan + + + Exposure time each scan + + + + + + + total exposure time for a single scan + + + Total exposure time + + + + + + Type this sample's name @@ -161,129 +314,202 @@ - - - - Experiment purpose - - - - - - - Type the experimental purpose note for this scan - - - Purpose - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - + + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + h + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + min + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + s + + + Qt::AlignCenter + + + + + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + h + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + min + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + s + + + Qt::AlignCenter + + + + + + - - - - Run + + + + true - - - - - - - - Qt::Horizontal - - - - - - - - + 0 0 - - Num. Motors - - - - - - - true - - - false - - - false - - - QAbstractSpinBox::CorrectToNearestValue + + + 16777215 + 60 + - + 1 - - 1 + + 14 - - 10 + + 80 - - - - - - Qt::Vertical + + Additional notes about the sample - - - - Relative + + + + Type the experimental purpose note for this scan + + + Purpose - - - - Qt::Vertical + + + + Run - - + + - Log + Sample name - - - - Qt::Vertical + + + + Experiment purpose - - + + Qt::Horizontal @@ -295,32 +521,26 @@ - - - - - 0 - 0 - + + + + Do this scan by multiple times - Scan Points + Num. of scans - - + + + + Do this scan by multiple times + 1 - 10000 - - - 2 - - - 10 + 999 diff --git a/src/firefly/plans/move_motor_window.py b/src/firefly/plans/move_motor_window.py new file mode 100644 index 00000000..866cb75b --- /dev/null +++ b/src/firefly/plans/move_motor_window.py @@ -0,0 +1,163 @@ +import logging + +from bluesky_queueserver_api import BPlan +from qtpy import QtWidgets +from qtpy.QtGui import QDoubleValidator + +from firefly import display +from firefly.application import FireflyApplication +from firefly.component_selector import ComponentSelector + +log = logging.getLogger() + + +class MotorRegion: + def __init__(self): + self.setup_ui() + + def setup_ui(self): + self.layout = QtWidgets.QHBoxLayout() + + # First item, ComponentSelector + self.motor_box = ComponentSelector() + self.layout.addWidget(self.motor_box) + + # Second item, position point + self.position_line_edit = QtWidgets.QLineEdit() + self.position_line_edit.setValidator(QDoubleValidator()) # only takes floats + self.position_line_edit.setPlaceholderText("Position…") + self.layout.addWidget(self.position_line_edit) + + +class MoveMotorDisplay(display.FireflyDisplay): + default_num_regions = 1 + + def customize_ui(self): + # Remove the default layout from .ui file + self.clearLayout(self.ui.region_template_layout) + self.reset_default_regions() + + # disable the line edits in spin box + self.ui.num_motor_spin_box.lineEdit().setReadOnly(True) + self.ui.num_motor_spin_box.valueChanged.connect(self.update_regions) + + self.ui.run_button.setEnabled(True) # for testing + self.ui.run_button.clicked.connect(self.queue_plan) + + def time_converter(self, total_seconds): + hours = round(total_seconds // 3600) + minutes = round((total_seconds % 3600) // 60) + seconds = round(total_seconds % 60) + if total_seconds == -1: + hours, minutes, seconds = "N/A", "N/A", "N/A" + return hours, minutes, seconds + + def clearLayout(self, layout): + if layout is not None: + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + def reset_default_regions(self): + if not hasattr(self, "regions"): + self.regions = [] + self.add_regions(self.default_num_regions) + self.ui.num_motor_spin_box.setValue(self.default_num_regions) + self.update_regions() + + def add_regions(self, num=1): + for i in range(num): + region = MotorRegion() + self.ui.regions_layout.addLayout(region.layout) + # Save it to the list + self.regions.append(region) + + def remove_regions(self, num=1): + for i in range(num): + layout = self.regions[-1].layout + # iterate/wait, and delete all widgets in the layout in the end + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self.regions.pop() + + def update_regions(self): + new_region_num = self.ui.num_motor_spin_box.value() + old_region_num = len(self.regions) + diff_region_num = new_region_num - old_region_num + + if diff_region_num < 0: + self.remove_regions(abs(diff_region_num)) + elif diff_region_num > 0: + self.add_regions(diff_region_num) + + def get_scan_parameters(self): + # get paramters from each rows of line regions: + motor_lst, position_lst = [], [] + for region_i in self.regions: + motor_lst.append(region_i.motor_box.current_component().name) + position_lst.append(float(region_i.position_line_edit.text())) + + motor_args = [ + values for motor_i in zip(motor_lst, position_lst) for values in motor_i + ] + + # get meta data info + md = { + "sample": self.ui.lineEdit_sample.text(), + "purpose": self.ui.lineEdit_purpose.text(), + } + + return motor_args, md + + def queue_plan(self, *args, **kwargs): + """Execute this plan on the queueserver.""" + motor_args, md = self.get_scan_parameters() + + if self.ui.relative_scan_checkbox.isChecked(): + scan_type = "mvr" + else: + scan_type = "mv" + + # # Build the queue item + item = BPlan( + scan_type, + *motor_args, + md=md, + ) + + # Submit the item to the queueserver + app = FireflyApplication.instance() + log.info("Added line scan() plan to queue.") + app.add_queue_item(item) + + def ui_filename(self): + return "plans/move_motor_window.ui" + + +# ----------------------------------------------------------------------------- +# :author: Juanjuan Huang +# :email: juanjuan.huang@anl.gov +# :copyright: Copyright © 2024, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/firefly/plans/move_motor_window.ui b/src/firefly/plans/move_motor_window.ui new file mode 100644 index 00000000..c7a400ff --- /dev/null +++ b/src/firefly/plans/move_motor_window.ui @@ -0,0 +1,286 @@ + + + Form + + + + 0 + 0 + 660 + 366 + + + + Move motor + + + + + + + + + + + 0 + 0 + + + + Num. Motors + + + + + + + true + + + false + + + false + + + QAbstractSpinBox::CorrectToNearestValue + + + 1 + + + 1 + + + 10 + + + + + + + Qt::Vertical + + + + + + + Relative + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Horizontal + + + + + + + Qt::Horizontal + + + + + + + + + Sample name + + + + + + + Type this sample's name + + + Sample name + + + + + + + Experiment purpose + + + + + + + Type the experimental purpose note for this scan + + + Purpose + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Run + + + + + + + + + + + true + + + + + 0 + 0 + 640 + 234 + + + + + + + + + + + 1 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 20 + 0 + + + + + 0 + 0 + + + + Positioner + + + + + + + + 0 + 0 + + + + + 20 + 0 + + + + + 0 + 0 + + + + Value + + + + + + + + + + + + + + + + + + + + + + + QueueButton + QPushButton +
firefly.queue_button
+
+ + ComponentSelector + QWidget +
firefly.component_selector
+ 1 +
+
+ + +
diff --git a/src/firefly/plans/util.py b/src/firefly/plans/util.py new file mode 100644 index 00000000..9c601113 --- /dev/null +++ b/src/firefly/plans/util.py @@ -0,0 +1,72 @@ +import numpy as np + +__all__ = ["time_converter"] + + +def time_converter(total_seconds): + """ + Convert time (in seconds) to a tuple of hours, minutes, seconds + + Parameters + ========== + total_seconds (float/int) + + Returns + ========== + tuple: hours, minutes, seconds + """ + + # use np.round instead round to prevent errors in round(float("nan")) + hours = np.round(total_seconds // 3600) + minutes = np.round((total_seconds % 3600) // 60) + seconds = np.round(total_seconds % 60) + return hours, minutes, seconds + + +def is_valid_value(value): + """ + Check if the value is considered valid for inclusion in metadata. + Valid values are non-None, and if they are str/list/tuple/dict, they should have a positive length. + + Parameters + ========== + value (any): The value to check. + + Returns + ========== + bool: True if the value is valid, False otherwise. + """ + # Check if the value is None + if value is None: + return False + # Check if the value is a collection with length + if isinstance(value, (str, list, tuple, dict)): + return len(value) > 0 + # All other non-None values are considered valid + return True + + +# ----------------------------------------------------------------------------- +# :author: Juanjuan Huang +# :email: juanjuan.huang@anl.gov +# :copyright: Copyright © 2024, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/firefly/plans/xafs_scan.py b/src/firefly/plans/xafs_scan.py index 465a68a0..4adc6aa0 100644 --- a/src/firefly/plans/xafs_scan.py +++ b/src/firefly/plans/xafs_scan.py @@ -4,11 +4,13 @@ import numpy as np from bluesky_queueserver_api import BPlan from qtpy import QtWidgets +from qtpy.QtCore import QObject, Signal from qtpy.QtGui import QDoubleValidator from xraydb.xraydb import XrayDB from firefly import display from firefly.application import FireflyApplication +from firefly.plans.util import is_valid_value, time_converter from haven.energy_ranges import ( E_step_to_k_step, ERange, @@ -61,9 +63,30 @@ def setup_ui(self): Qlabels_all["Exposure [s]"].setFixedWidth(68) -class XafsScanRegion: +class XafsScanRegion(QObject): + time_calculation_signal = Signal() + def __init__(self): + super().__init__() self.setup_ui() + self.kErange = None + self.xafs_region_time = ( + 0 # flag for whether time is calculated correctly, if not, will set to -1 + ) + + # List of widgets and their signals to connect to update_total_time + widgets_signals = [ + (self.start_line_edit, "textChanged"), + (self.stop_line_edit, "textChanged"), + (self.step_line_edit, "textChanged"), + (self.weight_spinbox, "valueChanged"), + (self.exposure_time_spinbox, "valueChanged"), + (self.k_space_checkbox, "stateChanged"), + ] + + # Connect all signals to the update_total_time method + for widget, signal_name in widgets_signals: + getattr(widget, signal_name).connect(self.update_total_time) def setup_ui(self): self.layout = QtWidgets.QHBoxLayout() @@ -88,8 +111,9 @@ def setup_ui(self): # Energy step box self.step_line_edit = QtWidgets.QLineEdit() self.step_line_edit.setValidator( - QDoubleValidator(0.0, float("inf"), 2) - ) # only takes positive floats + QDoubleValidator(0.0, float("inf"), 2) # the step is always bigger than 0 + ) + # only takes positive floats self.step_line_edit.setPlaceholderText("Step…") self.layout.addWidget(self.step_line_edit) @@ -97,6 +121,7 @@ def setup_ui(self): self.weight_spinbox = QtWidgets.QDoubleSpinBox() self.layout.addWidget(self.weight_spinbox) self.weight_spinbox.setDecimals(1) + self.weight_spinbox.setEnabled(False) # K-space checkbox self.k_space_checkbox = QtWidgets.QCheckBox() @@ -118,8 +143,9 @@ def update_line_edit_value(self, line_edit, conversion_func): converted_value = conversion_func(round(float(text), float_accuracy)) line_edit.setText(f"{converted_value:.6g}") - def update_wavenumber_energy(self): - is_k_checked = self.k_space_checkbox.isChecked() + def update_wavenumber_energy(self, is_k_checked): + # disable weight box when k is not selected + self.weight_spinbox.setEnabled(is_k_checked) # Define conversion functions conversion_funcs = { @@ -151,6 +177,42 @@ def update_wavenumber_energy(self): else: self.update_line_edit_value(line_edit, func) + def update_total_time(self): + weight = self.weight_spinbox.value() + exposure_time = self.exposure_time_spinbox.value() + + # prevent invalid inputs such as nan + try: + start = round(float(self.start_line_edit.text()), float_accuracy) + stop = round(float(self.stop_line_edit.text()), float_accuracy) + step = round(float(self.step_line_edit.text()), float_accuracy) + + # when the round doesn't work for nan values + except ValueError: + self.kErange = [] + start, stop, step = float("nan"), float("nan"), float("nan") + + if self.k_space_checkbox.isChecked(): + self.kErange = KRange( + k_min=start, + k_max=stop, + k_step=step, + k_weight=weight, + exposure=exposure_time, + ) + + else: + self.kErange = ERange( + E_min=start, + E_max=stop, + E_step=step, + weight=weight, + exposure=exposure_time, + ) + + # Emit the signal regardless of success or failure + self.time_calculation_signal.emit() + class XafsScanDisplay(display.FireflyDisplay): min_energy = 4000 @@ -202,6 +264,11 @@ def customize_ui(self): self.title_region.regions_all_checkbox.stateChanged.connect( self.on_regions_all_checkbox ) + # connect is_standard with a warning box + self.ui.checkBox_is_standard.clicked.connect(self.on_is_standard) + + # connect num. of scans with total_time + self.ui.spinBox_repeat_scan_num.valueChanged.connect(self.update_total_time) def on_region_checkbox(self): for region_i in self.regions: @@ -276,6 +343,9 @@ def reset_default_regions(self): region_i.stop_line_edit.setText(str(default_regions[i][1])) region_i.step_line_edit.setText(str(default_regions[i][2])) + # reset scan repeat num to 1 + self.ui.spinBox_repeat_scan_num.setValue(1) + def add_regions(self, num=1): for i in range(num): region = XafsScanRegion() @@ -283,6 +353,9 @@ def add_regions(self, num=1): self.regions.append(region) # disable/enabale regions when selected region.region_checkbox.stateChanged.connect(self.on_region_checkbox) + region.region_checkbox.stateChanged.connect(self.update_total_time) + # receive time signals from XafsRegion + region.time_calculation_signal.connect(self.update_total_time) def remove_regions(self, num=1): for i in range(num): @@ -304,6 +377,49 @@ def update_regions(self): elif diff_region_num > 0: self.add_regions(diff_region_num) + def update_total_time(self): + # Summing total_time for all checked regions directly within the sum function using a generator expression + kEranges_all = [ + region_i.kErange + for region_i in self.regions + if region_i.region_checkbox.isChecked() + ] + + # prevent end points are smaller than start points + try: + _, exposures = merge_ranges(*kEranges_all, sort=True) + total_time_per_scan = exposures.sum() + except ValueError: + total_time_per_scan = float("nan") + + # calculate time for each scan + hr, min, sec = time_converter(total_time_per_scan) + self.ui.label_hour_scan.setText(str(hr)) + self.ui.label_min_scan.setText(str(min)) + self.ui.label_sec_scan.setText(str(sec)) + + # calculate time for entire planf + num_scan_repeat = self.ui.spinBox_repeat_scan_num.value() + total_time = num_scan_repeat * total_time_per_scan + hr_total, min_total, sec_total = time_converter(total_time) + + self.ui.label_hour_total.setText(str(hr_total)) + self.ui.label_min_total.setText(str(min_total)) + self.ui.label_sec_total.setText(str(sec_total)) + + def on_is_standard(self, is_checked): + # if is_standard checked, warn that the data will be used for public + if is_checked: + response = QtWidgets.QMessageBox.warning( + self, + "Notice", + "When checking this option, this data will be used by public.", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if response != QtWidgets.QMessageBox.Yes: + self.ui.checkBox_is_standard.setChecked(False) + def queue_plan(self, *args, **kwargs): """Execute this plan on the queueserver.""" # Get parameters from each rows of line regions: @@ -354,11 +470,15 @@ def queue_plan(self, *args, **kwargs): energies = list(np.round(energies, float_accuracy)) exposures = list(np.round(exposures, float_accuracy)) detectors = self.ui.detectors_list.selected_detectors() + repeat_scan_num = int(self.ui.spinBox_repeat_scan_num.value()) md = { "sample": self.ui.lineEdit_sample.text(), "purpose": self.ui.lineEdit_purpose.text(), "is_standard": self.ui.checkBox_is_standard.isChecked(), + "notes": self.ui.textEdit_notes.toPlainText(), } + # Only include metadata that isn't an empty string + md = {key: val for key, val in md.items() if is_valid_value(val)} # Check that an absorption edge was selected if self.use_edge_checkbox.isChecked(): @@ -366,13 +486,14 @@ def queue_plan(self, *args, **kwargs): match = re.findall(r"\d+\.?\d*", self.edge_combo_box.currentText()) self.edge_value = round(float(match[-1]), float_accuracy) - except: + except IndexError: QtWidgets.QMessageBox.warning( self, "Error", "Please select an absorption edge." ) return None else: self.edge_value = 0 + # Build the queue item item = BPlan( "energy_scan", @@ -385,7 +506,9 @@ def queue_plan(self, *args, **kwargs): # Submit the item to the queueserver app = FireflyApplication.instance() log.info(f"Adding XAFS scan to queue.") - app.add_queue_item(item) + # repeat scans + for i in range(repeat_scan_num): + app.add_queue_item(item) def ui_filename(self): return "plans/xafs_scan.ui" @@ -393,7 +516,7 @@ def ui_filename(self): # ----------------------------------------------------------------------------- # :author: Juanjuan Huang -# :email: wolfman@anl.gov +# :email: juanjuan.huang@anl.gov # :copyright: Copyright © 2024, UChicago Argonne, LLC # # Distributed under the terms of the 3-Clause BSD License diff --git a/src/firefly/plans/xafs_scan.ui b/src/firefly/plans/xafs_scan.ui index 2446a1aa..c988f749 100644 --- a/src/firefly/plans/xafs_scan.ui +++ b/src/firefly/plans/xafs_scan.ui @@ -6,8 +6,8 @@ 0 0 - 840 - 302 + 901 + 365
@@ -109,6 +109,19 @@
+ + + + Reset to default values + + + + + + Reset Defaults + + +
@@ -131,7 +144,7 @@ 0 0 700 - 165 + 156 @@ -155,6 +168,19 @@ + + + + Qt::Vertical + + + + 20 + 40 + + + + @@ -292,62 +318,73 @@ - - - - - Reset to default values - - - - + + + - Reset Defaults + Sample name - - + + - Sample name + Notes - - + + - Type this sample's name + Type the experimental purpose note for this scan - Sample name + Purpose - - - - Is standard + + + + Type this sample's name + + + Sample name - + Experiment purpose - - - - Type the experimental purpose note for this scan - - - Purpose - - + + + + + + total exposure time for a single scan + + + Exposure time each scan + + + + + + + total exposure time for a single scan + + + Total exposure time + + + + - + Qt::Horizontal @@ -360,10 +397,203 @@ - + + + + Do this scan by multiple times + + + 1 + + + 999 + + + + + + + Do this scan by multiple times + + + Num. of scans + + + + + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + h + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + min + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + s + + + Qt::AlignCenter + + + + + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + h + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + min + + + Qt::AlignCenter + + + + + + + 0 + + + Qt::AlignCenter + + + + + + + s + + + Qt::AlignCenter + + + + + + + + + + + Is standard + + + + + + + true + + + + 0 + 0 + + + + + 16777215 + 60 + + + + 1 + + + 14 + + + 80 + + + Additional notes about the sample + + + + - Add to the queue + Run/Add to the queue # background-color: #28a745 diff --git a/src/firefly/tests/test_grid_scan_window.py b/src/firefly/tests/test_grid_scan_window.py new file mode 100644 index 00000000..1fc51828 --- /dev/null +++ b/src/firefly/tests/test_grid_scan_window.py @@ -0,0 +1,121 @@ +from unittest import mock + +import pytest +from bluesky_queueserver_api import BPlan +from ophyd.sim import make_fake_device +from qtpy import QtCore + +from firefly.application import FireflyApplication +from firefly.plans.grid_scan import GridScanDisplay +from haven.instrument import motor + + +@pytest.fixture +def fake_motors(sim_registry): + motor_names = ["motorA_m1", "motorA_m2"] + motors = [] + for name in motor_names: + this_motor = make_fake_device(motor.HavenMotor)(name=name, labels={"motors"}) + sim_registry.register(this_motor) + motors.append(this_motor) + return motors + + +def test_time_calculator(qtbot, sim_registry, fake_motors, dxp, I0): + app = FireflyApplication.instance() + display = GridScanDisplay() + qtbot.addWidget(display) + + # set up motor num + display.ui.num_motor_spin_box.setValue(2) + + # set up num of repeat scans + display.ui.spinBox_repeat_scan_num.setValue(6) + + # set up scan num of points + display.ui.scan_pts_spin_box.setValue(10) + + # set up detectors + display.ui.detectors_list.selected_detectors = mock.MagicMock( + return_value=["vortex_me4", "I0"] + ) + + # set up default timing for the detector + detectors = display.ui.detectors_list.selected_detectors() + detectors = {name: app.registry[name] for name in detectors} + detectors["I0"].default_time_signal.set(1).wait(2) + detectors["vortex_me4"].default_time_signal.set(0.5).wait(2) + + # Create empty QItemSelection objects + selected = QtCore.QItemSelection() + deselected = QtCore.QItemSelection() + + # emit the signal so that the time calculator is triggered + display.ui.detectors_list.selectionModel().selectionChanged.emit( + selected, deselected + ) + + # Check whether time is calculated correctly for a single scan + assert int(display.ui.label_hour_scan.text()) == 0 + assert int(display.ui.label_min_scan.text()) == 0 + assert int(display.ui.label_sec_scan.text()) == 20 + + # Check whether time is calculated correctly including the repeated scan + assert int(display.ui.label_hour_total.text()) == 0 + assert int(display.ui.label_min_total.text()) == 2 + assert int(display.ui.label_sec_total.text()) == 0 + + +def test_grid_scan_plan_queued(ffapp, qtbot, sim_registry, fake_motors): + display = GridScanDisplay() + display.ui.run_button.setEnabled(True) + display.ui.num_motor_spin_box.setValue(2) + display.update_regions() + + # set up a test motor 1 + display.regions[0].motor_box.combo_box.setCurrentText("motorA_m1") + display.regions[0].start_line_edit.setText("1") + display.regions[0].stop_line_edit.setText("111") + # select snake for the first motor + display.regions[0].snake_checkbox.setChecked(True) + + # set up a test motor 2 + display.regions[1].motor_box.combo_box.setCurrentText("motorA_m2") + display.regions[1].start_line_edit.setText("2") + display.regions[1].stop_line_edit.setText("222") + + # set up scan num of points + display.ui.scan_pts_spin_box.setValue(10) + + # set up detector list + display.ui.detectors_list.selected_detectors = mock.MagicMock( + return_value=["vortex_me4", "I0"] + ) + + # set up meta data + display.ui.lineEdit_sample.setText("sam") + display.ui.lineEdit_purpose.setText("test") + display.ui.textEdit_notes.setText("notes") + + expected_item = BPlan( + "grid_scan", + ["vortex_me4", "I0"], + "motorA_m1", + 1, + 111, + "motorA_m2", + 2, + 222, + num=10, + snake_axes=["motorA_m1"], + md={"sample": "sam", "purpose": "test", "notes": "notes"}, + ) + + def check_item(item): + return item.to_dict() == expected_item.to_dict() + + # Click the run button and see if the plan is queued + with qtbot.waitSignal( + ffapp.queue_item_added, timeout=1000, check_params_cb=check_item + ): + qtbot.mouseClick(display.ui.run_button, QtCore.Qt.LeftButton) diff --git a/src/firefly/tests/test_line_scan_window.py b/src/firefly/tests/test_line_scan_window.py index e3e6867d..30e4a570 100644 --- a/src/firefly/tests/test_line_scan_window.py +++ b/src/firefly/tests/test_line_scan_window.py @@ -5,6 +5,7 @@ from ophyd.sim import make_fake_device from qtpy import QtCore +from firefly.application import FireflyApplication from firefly.plans.line_scan import LineScanDisplay from haven.instrument import motor @@ -19,10 +20,58 @@ def fake_motors(sim_registry): return motors -def test_line_scan_plan_queued(ffapp, qtbot, sim_registry, fake_motors): +def test_time_calculator(qtbot, sim_registry, fake_motors, dxp, I0): + app = FireflyApplication.instance() + display = LineScanDisplay() + qtbot.addWidget(display) + + # set up motor num + display.ui.num_motor_spin_box.setValue(2) + + # set up num of repeat scans + display.ui.spinBox_repeat_scan_num.setValue(6) + + # set up scan num of points + display.ui.scan_pts_spin_box.setValue(10) + + # set up detectors + display.ui.detectors_list.selected_detectors = mock.MagicMock( + return_value=["vortex_me4", "I0"] + ) + + # set up default timing for the detector + detectors = display.ui.detectors_list.selected_detectors() + detectors = {name: app.registry[name] for name in detectors} + detectors["I0"].default_time_signal.set(1).wait(2) + detectors["vortex_me4"].default_time_signal.set(0.5).wait(2) + + # Create empty QItemSelection objects + selected = QtCore.QItemSelection() + deselected = QtCore.QItemSelection() + + # emit the signal so that the time calculator is triggered + display.ui.detectors_list.selectionModel().selectionChanged.emit( + selected, deselected + ) + + # Check whether time is calculated correctly for a single scan + assert int(display.ui.label_hour_scan.text()) == 0 + assert int(display.ui.label_min_scan.text()) == 0 + assert int(display.ui.label_sec_scan.text()) == 10 + + # Check whether time is calculated correctly including the repeated scan + assert int(display.ui.label_hour_total.text()) == 0 + assert int(display.ui.label_min_total.text()) == 1 + assert int(display.ui.label_sec_total.text()) == 0 + + +def test_line_scan_plan_queued(ffapp, qtbot, sim_registry, fake_motors, dxp, I0): display = LineScanDisplay() display.ui.run_button.setEnabled(True) + + # set up motor num display.ui.num_motor_spin_box.setValue(2) + display.update_regions() # set up a test motor 1 @@ -38,7 +87,7 @@ def test_line_scan_plan_queued(ffapp, qtbot, sim_registry, fake_motors): # set up scan num of points display.ui.scan_pts_spin_box.setValue(10) - # set up detector list + # time is calculated when the selection is changed display.ui.detectors_list.selected_detectors = mock.MagicMock( return_value=["vortex_me4", "I0"] ) @@ -46,18 +95,19 @@ def test_line_scan_plan_queued(ffapp, qtbot, sim_registry, fake_motors): # set up meta data display.ui.lineEdit_sample.setText("sam") display.ui.lineEdit_purpose.setText("test") + display.ui.textEdit_notes.setText("notes") expected_item = BPlan( "scan", ["vortex_me4", "I0"], "motorA_m1", - 1, - 111, + 1.0, + 111.0, "motorA_m2", - 2, - 222, + 2.0, + 222.0, num=10, - md={"sample": "sam", "purpose": "test"}, + md={"sample": "sam", "purpose": "test", "notes": "notes"}, ) def check_item(item): diff --git a/src/firefly/tests/test_move_motor_window.py b/src/firefly/tests/test_move_motor_window.py new file mode 100644 index 00000000..217f317b --- /dev/null +++ b/src/firefly/tests/test_move_motor_window.py @@ -0,0 +1,67 @@ +import pytest +from bluesky_queueserver_api import BPlan +from ophyd.sim import make_fake_device +from qtpy import QtCore + +from firefly.application import FireflyApplication +from firefly.plans.move_motor_window import MoveMotorDisplay +from haven.instrument import motor + + +@pytest.fixture +def fake_motors(sim_registry): + motor_names = ["motorA_m1", "motorA_m2"] + motors = [] + for name in motor_names: + this_motor = make_fake_device(motor.HavenMotor)(name=name, labels={"motors"}) + motors.append(this_motor) + return motors + + +def test_move_motor_plan_queued(ffapp, qtbot, sim_registry, fake_motors): + app = FireflyApplication.instance() + display = MoveMotorDisplay() + display.ui.run_button.setEnabled(True) + + # set up motor num + display.ui.num_motor_spin_box.setValue(2) + + # uncheck relative + display.ui.relative_scan_checkbox.setChecked(False) + + display.update_regions() + + # set up a test motor 1 + display.regions[0].motor_box.combo_box.setCurrentText("motorA_m1") + display.regions[0].position_line_edit.setText("111") + + # set up a test motor 2 + display.regions[1].motor_box.combo_box.setCurrentText("motorA_m2") + display.regions[1].position_line_edit.setText("222") + + # set up meta data + display.ui.lineEdit_sample.setText("sam") + display.ui.lineEdit_purpose.setText("test") + + expected_item = BPlan( + "mv", + "motorA_m1", + 111.0, + "motorA_m2", + 222.0, + md={ + "sample": "sam", + "purpose": "test", + }, + ) + + # print(item.to_dict()) + + def check_item(item): + return item.to_dict() == expected_item.to_dict() + + # Click the run button and see if the plan is queued + with qtbot.waitSignal( + ffapp.queue_item_added, timeout=1000, check_params_cb=check_item + ): + qtbot.mouseClick(display.ui.run_button, QtCore.Qt.LeftButton) diff --git a/src/firefly/tests/test_xafs_scan.py b/src/firefly/tests/test_xafs_scan.py index 4031ab6e..07bd852d 100644 --- a/src/firefly/tests/test_xafs_scan.py +++ b/src/firefly/tests/test_xafs_scan.py @@ -107,6 +107,8 @@ def test_xafs_scan_plan_queued_energies(ffapp, qtbot): display.ui.lineEdit_sample.setText("sam") display.ui.lineEdit_purpose.setText("test") display.ui.checkBox_is_standard.setChecked(True) + display.ui.lineEdit_purpose.setText("test") + display.ui.textEdit_notes.setText("sam_notes") expected_item = BPlan( "energy_scan", @@ -114,7 +116,12 @@ def test_xafs_scan_plan_queued_energies(ffapp, qtbot): exposure=exposures, E0=11500.8, detectors=["vortex_me4", "I0"], - md={"sample": "sam", "purpose": "test", "is_standard": True}, + md={ + "sample": "sam", + "purpose": "test", + "is_standard": True, + "notes": "sam_notes", + }, ) def check_item(item): @@ -154,7 +161,6 @@ def check_item(item): qtbot.mouseClick(display.ui.run_button, QtCore.Qt.LeftButton) -# TODO K end point should not include step def test_xafs_scan_plan_queued_energies_k_mixed(ffapp, qtbot): display = XafsScanDisplay() display.ui.regions_spin_box.setValue(2) @@ -178,6 +184,10 @@ def test_xafs_scan_plan_queued_energies_k_mixed(ffapp, qtbot): display.ui.detectors_list.selected_detectors = mock.MagicMock( return_value=["vortex_me4", "I0"] ) + + # set repeat scan num to 2 + display.ui.spinBox_repeat_scan_num.setValue(3) + energies = np.array( [ -20, @@ -195,9 +205,11 @@ def test_xafs_scan_plan_queued_energies_k_mixed(ffapp, qtbot): exposures = np.array( [1, 1, 1, 1, 1, 1, 1, 1, 5.665, 14.141] ) # k exposures kmin 3.62263 + # set up meta data display.ui.lineEdit_sample.setText("sam") - display.ui.lineEdit_purpose.setText("test") + display.ui.lineEdit_purpose.setText("") # invalid input should be removed from md + display.ui.textEdit_notes.setText("sam_notes") expected_item = BPlan( "energy_scan", @@ -205,7 +217,11 @@ def test_xafs_scan_plan_queued_energies_k_mixed(ffapp, qtbot): exposure=exposures, E0=11500.8, detectors=["vortex_me4", "I0"], - md={"sample": "sam", "purpose": "test", "is_standard": False}, + md={ + "sample": "sam", + "is_standard": False, + "notes": "sam_notes", + }, ) def check_item(item): @@ -213,6 +229,16 @@ def check_item(item): expected_dict = expected_item.to_dict()["kwargs"] try: + # Check whether time is calculated correctly for a single scan + assert int(float(display.ui.label_hour_scan.text())) == 0 + assert int(float(display.ui.label_min_scan.text())) == 0 + assert int(float(display.ui.label_sec_scan.text())) == 28 + + # Check whether time is calculated correctly including the repeated scan + assert int(float(display.ui.label_hour_total.text())) == 0 + assert int(float(display.ui.label_min_total.text())) == 1 + assert int(float(display.ui.label_sec_total.text())) == 23 + # Check energies & exposures within 3 decimals np.testing.assert_array_almost_equal( item_dict["energies"], expected_dict["energies"], decimal=2 @@ -231,6 +257,7 @@ def check_item(item): assert item_dict == expected_dict, "Non-array items do not match." except AssertionError as e: + print(e) return False return True diff --git a/src/haven/energy_ranges.py b/src/haven/energy_ranges.py index 7bf88ec9..556ee9b8 100644 --- a/src/haven/energy_ranges.py +++ b/src/haven/energy_ranges.py @@ -105,7 +105,9 @@ def energies(self): def exposures(self): """Convert the range to a sequence of exposure times, in seconds.""" - return self.exposure * self.energies() ** self.weight + # disable weight for now + # return self.exposure * self.energies() ** self.weight + return self.exposure * self.energies() ** 0 # do not consider weights for now @dataclass