From 63a0c519e3dc8f5635d0e04ec05ff1e4f4f3bb76 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 25 Nov 2025 09:05:20 +0100 Subject: [PATCH] GUI: rotation_data_collection.py and rast_grid added total measurement time and file path protection --- gui/src/aaregui/panels/file_path_panel.py | 135 +++++++++++++++--- .../aaregui/panels/raster_data_collection.py | 58 +++++++- .../panels/rotation_data_collection.py | 54 ++++++- 3 files changed, 218 insertions(+), 29 deletions(-) diff --git a/gui/src/aaregui/panels/file_path_panel.py b/gui/src/aaregui/panels/file_path_panel.py index c79eca87..89c69eae 100644 --- a/gui/src/aaregui/panels/file_path_panel.py +++ b/gui/src/aaregui/panels/file_path_panel.py @@ -1,14 +1,16 @@ import copy +import os from datetime import datetime +from pathlib import Path from PySide6.QtCore import Signal, Slot, Qt -from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QLineEdit, QSpinBox, QCheckBox +from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QLineEdit, QSpinBox, QCheckBox, QMessageBox -from aaredaqlib.models import SampleShortInfo +from aaredaqlib.models import SampleShortInfo, DAQStatusModel from aaregui.widgets.title_label import TitleLabel ## Logic for filenames: -## 1. For rasters 'raster/' subfolder is added at the top level of the path (managed by DAQ) - e.g. rasters/20250101/PX-456/01/dataset +## 1. For rasters 'raster/' subfolder is added at the top level of the path (managed by DAQ) - e.g. raster/20250101/PX-456/01/dataset ## 2. For screening 'screening/' subfolder is added at the last level of the path (managed by GUI) - e.g. 20250101/PX-456/01/dataset/screening ## 3. If sample is not registered in the database, it is by default placed in test/ ## 4. If sample is registered in the database and has puck information, it is by default placed in /// @@ -25,8 +27,11 @@ class FilePathPanel(QWidget): self.__dewar_pos = "None" self.__puck_name = "Manual" self.__puck_pos = 0 + self.__curr_pgroup = "p11206" self.__filename = "" + self.__scan_kind = "raster" # default: "rotation" | "screening" | "raster" + self.__formatted_date = datetime.now().strftime('%Y%m%d') grid_layout.addWidget(TitleLabel("Dataset path", self), 0, 0, 1, 2) @@ -73,6 +78,35 @@ class FilePathPanel(QWidget): self.run_number_edit.valueChanged.connect(self.set_run_number) self.update_filename() + def _expand_macros(self, base: str, rn: int) -> str: + name = f"{base}_{rn:03d}" + name = name.replace('{date}', self.__formatted_date) + name = name.replace('{sample}', self.__sample_name) + name = name.replace('{puck}', self.__puck_name) + name = name.replace('{pos}', f"{self.__puck_pos:02d}") + name = name.replace('{sample_id}', f"{self.__sample_id}") + return name + + def _effective_dataset_base(self, base_no_run: str) -> str: + root = Path("/sls/mx/data") / self.__curr_pgroup / "raw" + p = Path(base_no_run) + match self.__scan_kind: + case "rotation": + p = Path("data") / p + case "screening": + p = Path("screening") / p + case "raster": + p = Path("raster") / p + case _: + pass + return str(root / p) + + def _exists_for_run(self, expanded_base_with_run: str) -> bool: + # expanded_base_with_run is the base without scan-kind transforms yet + effective = self._effective_dataset_base(expanded_base_with_run) + # Consider master file and directory as taken + return os.path.exists(f"{effective}_master.h5") or os.path.exists(effective) + def update_filename(self): dir_name = self.directory_edit.text() file_prefix = self.file_prefix_edit.text() @@ -81,26 +115,37 @@ class FilePathPanel(QWidget): dir_name = dir_name.replace('{prefix}', file_prefix) if dir_name == "": - self.__filename = "" + base = "" elif dir_name[-1] == "/": - self.__filename = dir_name + base = dir_name else: - self.__filename = dir_name + "/" + base = dir_name + "/" - if file_prefix == "": - self.__filename += "run" - else: - self.__filename += file_prefix + base += "run" if file_prefix == "" else file_prefix - self.__filename += f"_{run_number:03d}" - self.__filename = self.__filename.replace('{date}', self.__formatted_date) - self.__filename = self.__filename.replace('{sample}', self.__sample_name) - self.__filename = self.__filename.replace('{puck}', self.__puck_name) - self.__filename = self.__filename.replace('{pos}', f"{self.__puck_pos:02d}") - self.__filename = self.__filename.replace('{sample_id}', f"{self.__sample_id}") + # Find next free run number using effective dataset path + rn = run_number + while True: + candidate_base = self._expand_macros(base, rn) + if not self._exists_for_run(candidate_base): + break + rn += 1 + if rn > self.run_number_edit.maximum(): + break - self.file_name_label.setText(self.__filename + "_master.h5") - self.file_name_label.setStyleSheet("color: rgb(0, 0, 0);") + if rn != run_number: + self.run_number_edit.blockSignals(True) + self.run_number_edit.setValue(rn) + self.run_number_edit.blockSignals(False) + + # Store the GUI’s base (without applying scan-kind transforms) for wiring into requests later + self.__filename = self._expand_macros(base, rn) + + # Preview label shows the effective path (what will be written) + effective = self._effective_dataset_base(self.__filename) + exists = os.path.exists(f"{effective}_master.h5") or os.path.exists(effective) + self.file_name_label.setText(effective + "_master.h5") + self.file_name_label.setStyleSheet("color: rgb(200, 0, 0);" if exists else "color: rgb(0, 0, 0);") self.path_updated.emit(self.__filename) @Slot() @@ -151,3 +196,57 @@ class FilePathPanel(QWidget): else: self.directory_edit.setText(f"{self.__formatted_date}/manual/{self.__sample_name}") self.update_filename() + + @Slot(DAQStatusModel) + def update_daq_status(self, status: DAQStatusModel): + if status.session.current_pgroup: + self.__curr_pgroup = status.session.current_pgroup + + @Slot(str) + def set_scan_kind(self, kind: str): + # kind in {"rotation","screening","raster"} + self.__scan_kind = kind + self.update_filename() + + def effective_path_for_base(self, base_with_run: str) -> str: + return self._effective_dataset_base(base_with_run) + + def next_free_run_from(self, start_rn: int) -> tuple[int, str]: + # Compute next free run number and updated base + dir_name = self.directory_edit.text().replace('{prefix}', self.file_prefix_edit.text()) + base = (dir_name if dir_name.endswith("/") or dir_name == "" else dir_name + "/") + base += ("run" if self.file_prefix_edit.text() == "" else self.file_prefix_edit.text()) + + rn = start_rn + while rn <= self.run_number_edit.maximum(): + candidate_base = self._expand_macros(base, rn) + if not self._exists_for_run(candidate_base): + return rn, candidate_base + rn += 1 + return start_rn, self._expand_macros(base, start_rn) + + def file_path_error_box(self, scan_kind: str): + self.set_scan_kind(scan_kind) + base = self.filename # same base we emit + effective = self.effective_path_for_base(base) + if os.path.exists(f"{effective}_master.h5") or os.path.exists(effective): + reply = QMessageBox.question( + self, + "File exists", + #f"This file already exists:\n{effective}_master.h5\nDo you wish to overwrite?", + f"This file already exists:\n{effective}_master.h5\n Updating run number", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + if reply == QMessageBox.StandardButton.No: + # bump run and update + curr = self.run_number_edit.value() + new_rn, _ = self.next_free_run_from(curr + 1) + self.run_number_edit.setValue(new_rn) + self.update_filename() + return False + else: + return False + return False + + return True \ No newline at end of file diff --git a/gui/src/aaregui/panels/raster_data_collection.py b/gui/src/aaregui/panels/raster_data_collection.py index 3a53d0c1..4af10dd6 100644 --- a/gui/src/aaregui/panels/raster_data_collection.py +++ b/gui/src/aaregui/panels/raster_data_collection.py @@ -8,7 +8,9 @@ from aaregui.panels.scan_settings_panel import ScanSettingsPanel from aaregui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric from aaregui.widgets.number_line_edit import NumberLineEdit from aaregui.widgets.raster_grid_table import RasterGridTable +from aaredaqlib.logger_config import setup_logger +logger = setup_logger("aareGUI") class RasterDataCollectionPanel(ScanSettingsPanel): grid_size_updated = Signal(float, float) @@ -30,6 +32,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.__n_y = raster_mgr.active_grid.n_y self.__size_x = raster_mgr.active_grid.grid_size_mm.x * 1000.0 self.__size_y = raster_mgr.active_grid.grid_size_mm.y * 1000.0 + self.__total_time = raster_mgr.active_grid.exp_time_s*self.__n_x*self.__n_y self._layout.addWidget(QLabel("Grid element size", parent=self), 3, 0) @@ -112,15 +115,24 @@ class RasterDataCollectionPanel(ScanSettingsPanel): ) self._layout.addItem(horizontal_spacer, 10, 0, 1, 5) + self._layout.addWidget(QLabel("Measurement time", parent=self), 11, 0) + self.total_time = QLabel(f"{self.__total_time}") + self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self._layout.addWidget(self.total_time, 11, 1, 1, 3) + self._layout.addWidget(QLabel("min", parent=self), 11, 4) + + self.image_time_enter.newValue.connect(self.exp_time_s) + self.calculate_total_time() + self.start_button = QPushButton("Evaluate grid") self.start_button.setStyleSheet("color: rgb(78, 154, 6);") - self.start_button.clicked.connect(self.evaluate_grid) - self._layout.addWidget(self.start_button, 11, 0, 1, 5) + self.start_button.clicked.connect(self._on_evaluate_clicked) + self._layout.addWidget(self.start_button, 12, 0, 1, 5) self.auto_button = QPushButton("Feeling lucky") self.auto_button.setStyleSheet("color: rgb(78, 154, 6);") - self.auto_button.clicked.connect(self.evaluate_grid_auto) - self._layout.addWidget(self.auto_button, 12, 0, 1, 5) + self.auto_button.clicked.connect(self._on_evaluate_auto_clicked) + self._layout.addWidget(self.auto_button, 13, 0, 1, 5) self.update_grid_scan_size() @@ -135,6 +147,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel): @Slot(float) def exp_time_s(self, inp: float): self.exp_time_updated.emit(inp) + self.calculate_total_time() @Slot(int, int, float, float) def grid_scan_size_change(self, n_x: int, n_y: int, size_x_mm: float, size_y_mm: float): @@ -142,6 +155,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.__size_y = size_y_mm * 1000.0 self.__n_x = n_x self.__n_y = n_y + self.calculate_total_time() self.update_grid_scan_size() @Slot(DAQStatusModel) @@ -174,6 +188,13 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.size_x_label.setText(f"{self.__size_x * self.__n_x:.1f}") self.n_y_label.setText(str(self.__n_y)) self.size_y_label.setText(f"{self.__size_y * self.__n_y:.1f}") + self.total_time.setText(f"{(self.__total_time / 60.0):.2f}") + try: + self.exp_time_s(self.image_time_enter.value) + except ValueError as e: + logger.warning(f"Invalid exposure time: {e} reseting to default") + self.exp_time_s(0.02) + def metric_changed(self, _: int): self.grid_metric_updated.emit(self.metric_combo.currentData()) @@ -184,3 +205,32 @@ class RasterDataCollectionPanel(ScanSettingsPanel): ('exposure', self.image_time_enter, None), # Add other raster-specific parameters here as needed ] + + def calculate_total_time(self): + if self.__n_x <= 0 or self.__n_y <= 0 or self.image_time_enter.value < 0: + self.__total_time = 0.0 + else: + self.__total_time = self.__n_x * self.__n_y * self.image_time_enter.value + # Show minutes + self.total_time.setText(f"{(self.__total_time/60.0):.2f}") + + + @Slot() + def _on_evaluate_clicked(self): + p = self.parent() + if hasattr(p, "file_path_panel"): + reply = p.file_path_panel.file_path_error_box("raster") + logger.debug(f"reply from file path panel: {reply}") + if not reply: + return + self.evaluate_grid.emit() + + @Slot() + def _on_evaluate_auto_clicked(self): + p = self.parent() + if hasattr(p, "file_path_panel"): + reply = p.file_path_panel.file_path_error_box("raster") + logger.debug(f"reply from file path panel: {reply}") + if not reply: + return + self.evaluate_grid_auto.emit() \ No newline at end of file diff --git a/gui/src/aaregui/panels/rotation_data_collection.py b/gui/src/aaregui/panels/rotation_data_collection.py index f7359ff4..e42696f0 100644 --- a/gui/src/aaregui/panels/rotation_data_collection.py +++ b/gui/src/aaregui/panels/rotation_data_collection.py @@ -1,7 +1,7 @@ from pathlib import Path from PySide6.QtCore import Slot, Signal, Qt -from PySide6.QtWidgets import QLabel, QComboBox, QPushButton +from PySide6.QtWidgets import QLabel, QComboBox, QPushButton, QMessageBox from aaredaqlib.diffraction_geometry import DiffractionGeometry from aaredaqlib.logger_config import setup_logger @@ -14,7 +14,7 @@ logger = setup_logger("aareGUI") def add_screening_to_path(path): p = Path(path) - return p.parent / "screening" / p.name + return "screening" / p def add_data_to_path(path): p = Path(path) @@ -33,11 +33,13 @@ class RotationDataCollectionPanel(ScanSettingsPanel): default_dtz=default_dtz, default_transmission=default_transmission) + self.__curr_pgroup = "p11206" self._filename = "" self._previous_sample_was_none_rotation = True self.__omega = 0 self.__dose_mgy = 0 + self.__total_time = 0.0 self._layout.addWidget(QLabel("Start angle", parent=self), 3, 0) self.start_angle = NumberLineEdit(-720, 720.0, 0.0, decimals=3, parent=self) @@ -97,23 +99,44 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self._layout.addWidget(self.image_time_enter, 12, 1, 1, 3) self._layout.addWidget(QLabel("s", parent=self), 12, 4) - self._layout.addWidget(QLabel("Dose", parent=self), 13, 0) + self._layout.addWidget(QLabel("Total measurement time", parent=self), 13, 0) + self.total_time = QLabel(f"{self.__total_time}") + self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self._layout.addWidget(self.total_time, 13, 1, 1, 3) + self._layout.addWidget(QLabel("min", parent=self), 13, 4) + + self.total_angle.newValue.connect(self.calculate_measurement_time) + self.image_angle.newValue.connect(self.calculate_measurement_time) + self.image_time_enter.newValue.connect(self.calculate_measurement_time) + # Initial compute + self.calculate_measurement_time() + + self._layout.addWidget(QLabel("Dose", parent=self), 14, 0) self.dose = QLabel(f"{self.__dose_mgy}") self.dose.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.dose, 13, 1, 1, 3) - self._layout.addWidget(QLabel("MGy", parent=self), 13, 4) + self._layout.addWidget(self.dose, 14, 1, 1, 3) + self._layout.addWidget(QLabel("MGy", parent=self), 14, 4) self._layout.removeWidget(self.reload_params_button) - self._layout.addWidget(self.reload_params_button, 14, 0, 1, 6) + self._layout.addWidget(self.reload_params_button, 15, 0, 1, 6) self.reload_params_button.setVisible(True) self.measurement_button = QPushButton("Run rotation") self.measurement_button.setStyleSheet("color: rgb(78, 154, 6);") self.measurement_button.clicked.connect(self.run_measurement) - self._layout.addWidget(self.measurement_button, 15, 0, 1, 6) + self._layout.addWidget(self.measurement_button, 16, 0, 1, 6) @Slot() def run_screening(self): + p = self.parent() + logger.debug("make box") + if hasattr(p, "file_path_panel"): + logger.debug("should make box") + reply = p.file_path_panel.file_path_error_box("screening") + logger.debug(f"reply from file path panel: {reply}") + if not reply: + return + logger.debug("no box") screening_settings = self.screening_type.currentData() r = RotationScanRequest( file_prefix=str(add_screening_to_path(self._filename)), @@ -130,6 +153,12 @@ class RotationDataCollectionPanel(ScanSettingsPanel): @Slot() def run_measurement(self): + p = self.parent() + if hasattr(p, "file_path_panel"): + reply = p.file_path_panel.file_path_error_box("rotation") + logger.debug(f"reply from file path panel: {reply}") + if not reply: + return r = RotationScanRequest( file_prefix=str(add_data_to_path(self._filename)), start_omega_deg=self.start_angle.value, @@ -154,6 +183,14 @@ class RotationDataCollectionPanel(ScanSettingsPanel): return round(total_angle / image_angle) + def calculate_measurement_time(self): + if self.image_angle.value <= 0 or self.total_angle.value <= 0 or self.image_time_enter.value < 0: + self.__total_time = 0.0 + self.total_time.setText("0.0") + return + self.__total_time = (self.total_angle.value / self.image_angle.value) * self.image_time_enter.value + self.total_time.setText(f"{self.__total_time / 60:.2f}") + @Slot(str) def update_filename(self, filename: str): self._filename = filename @@ -186,6 +223,9 @@ class RotationDataCollectionPanel(ScanSettingsPanel): elif s.sample is not None: self._previous_sample_was_none_rotation = False + self.calculate_measurement_time() + if s.session.current_pgroup: + self.__curr_pgroup = s.session.current_pgroup lambda_a = s.diffraction.wavelength_angstrom kdose = 2000 / (lambda_a * lambda_a) beam_area = s.geom.beam_size_mm.x * s.geom.beam_size_mm.y * 1e6