Feat/gui polish #195
@@ -597,6 +597,11 @@ class MainWindow(QMainWindow):
|
||||
self.quick_unmount_button = QPushButton("⏏ Unmount", dewar_tab)
|
||||
self.quick_unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested())
|
||||
|
||||
# main_window.py, next to quick_unmount_button (~L597)
|
||||
self.mount_next_button = QPushButton("⏭ Mount next", dewar_tab)
|
||||
self.mount_next_button.clicked.connect(self._mount_next_from_queue)
|
||||
# add to automation_row loop + self._queue_action_buttons as kind "next"
|
||||
|
||||
automation_row = QHBoxLayout()
|
||||
for w in (
|
||||
self.job_list_panel.play_button,
|
||||
@@ -1545,7 +1550,7 @@ class MainWindow(QMainWindow):
|
||||
settings = QSettings("PSI", "AareGUI")
|
||||
# bool()/str() wraps: QSettings.value is typed "object" even with
|
||||
# type=..., so the wraps are runtime no-ops for the pyright gate.
|
||||
show_detections = bool(settings.value("samcam/show_detections", True, type=bool))
|
||||
show_detections = bool(settings.value("samcam/show_detections", False, type=bool))
|
||||
show_detection_polygons = bool(
|
||||
settings.value("samcam/show_detection_polygons", True, type=bool)
|
||||
)
|
||||
@@ -2395,6 +2400,19 @@ class MainWindow(QMainWindow):
|
||||
return "The hutch safety alarm is active. Mounting is blocked until it clears."
|
||||
return None
|
||||
|
||||
def _mount_next_from_queue(self) -> None:
|
||||
"""Manual step-through: mount queue head; if head already on gonio, pop it and mount the following one.
|
||||
Pops only after /status confirms mount, so a failed robot move never loses a sample."""
|
||||
queue = self.job_list_panel.table_model.samples
|
||||
mounted = getattr(self._latest_daq_status, "sample", None)
|
||||
if queue and mounted is not None and mounted.db_id == queue[0].db_id:
|
||||
self.job_list_panel.table_model.remove_sample(mounted.db_id)
|
||||
queue = self.job_list_panel.table_model.samples
|
||||
if not queue:
|
||||
self._on_manual_unmount_requested() # or no-op; your call
|
||||
return
|
||||
self._on_manual_mount_requested(queue[0])
|
||||
|
||||
def _on_manual_mount_requested(self, sample, reference: bool = False) -> None:
|
||||
"""Pre-check the hutch before sending a manual mount to the server.
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
from aarecommon.config.beamline import cfg_get
|
||||
from aarecommon.config.beamline import cfg_get, mx_beamline
|
||||
from aarecommon.config.logger import setup_logger
|
||||
from aarecommon.math.diffraction_geometry import DiffractionGeometry
|
||||
from aarecommon.models.beamline import MXBeamline
|
||||
from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel
|
||||
from aarecommon.models.rotation_scan import RotationScanRequest
|
||||
from PySide6.QtCore import Qt, Signal, Slot
|
||||
@@ -15,6 +16,10 @@ from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit
|
||||
|
||||
logger = setup_logger(LOGGER_NAME)
|
||||
|
||||
MAX_OMEGA_SPEED_DEG_S = 500.0
|
||||
# 900 Hz at X06DA, 120 Hz at X10SA
|
||||
MIN_EXP_TIME_S = {MXBeamline.X06DA: 1 / 900, MXBeamline.X10SA: 1 / 120}
|
||||
|
||||
|
||||
def add_screening_to_path(path):
|
||||
p = Path(path)
|
||||
@@ -56,6 +61,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
|
||||
self._omega = 0
|
||||
self._dose_mgy = 0
|
||||
self._total_time = 0.0
|
||||
self._min_exp_time_s = MIN_EXP_TIME_S.get(mx_beamline(), 0.0005)
|
||||
|
||||
self._layout.addWidget(QLabel("Start angle", parent=self), 3, 0)
|
||||
self.start_angle = NumberLineEdit(
|
||||
@@ -97,7 +103,12 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
|
||||
"daq.data_collection_settings.default_screening_settings.exp_time_s", 0.1
|
||||
)
|
||||
self.screening_image_time_enter = NumberLineEdit(
|
||||
0.0005, 10.0, default_screening_exp_time, decimals=4, parent=self, track_pending=True
|
||||
self._min_exp_time_s,
|
||||
10.0,
|
||||
default_screening_exp_time,
|
||||
decimals=4,
|
||||
parent=self,
|
||||
track_pending=True,
|
||||
)
|
||||
self._layout.addWidget(self.screening_image_time_enter, 7, 1, 1, 3)
|
||||
self._layout.addWidget(QLabel("s", parent=self), 7, 4)
|
||||
@@ -140,13 +151,12 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
|
||||
self._layout.addWidget(self.image_angle, 12, 1, 1, 3)
|
||||
self._layout.addWidget(QLabel("°", parent=self), 12, 4)
|
||||
self._register_override_field(self.image_angle)
|
||||
# TODO add protection on X10SA to prevent too short exposure time/ too high detector rep rate
|
||||
self._layout.addWidget(QLabel("Image time", parent=self), 13, 0)
|
||||
default_image_exp_time = cfg_get(
|
||||
"daq.data_collection_settings.default_rotation_settings.exp_time_s", 0.01
|
||||
)
|
||||
self.image_time_enter = DbOverrideLineEdit(
|
||||
0.0005, 10.0, default=default_image_exp_time, decimals=4, parent=self
|
||||
self._min_exp_time_s, 10.0, default=default_image_exp_time, decimals=4, parent=self
|
||||
)
|
||||
self._layout.addWidget(self.image_time_enter, 13, 1, 1, 3)
|
||||
self._layout.addWidget(QLabel("s", parent=self), 13, 4)
|
||||
@@ -180,12 +190,44 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
|
||||
self._layout.addWidget(self.abort_button, 18, 0, 1, 6)
|
||||
self._reset_to_defaults()
|
||||
|
||||
# Speed cap couples each angle/time pair: recompute limits whenever either commits.
|
||||
self.screening_image_angle.newValue.connect(self._update_speed_limits)
|
||||
self.screening_image_time_enter.newValue.connect(self._update_speed_limits)
|
||||
self.image_angle.valueChanged.connect(self._update_speed_limits)
|
||||
self.image_time_enter.valueChanged.connect(self._update_speed_limits)
|
||||
self._update_speed_limits()
|
||||
|
||||
@Slot()
|
||||
def _update_speed_limits(self):
|
||||
for angle, time, angle_max in (
|
||||
(self.screening_image_angle, self.screening_image_time_enter, 90.0),
|
||||
(self.image_angle, self.image_time_enter, 10.0),
|
||||
):
|
||||
angle.update_limits(0, min(angle_max, MAX_OMEGA_SPEED_DEG_S * time.value))
|
||||
time.update_limits(max(self._min_exp_time_s, angle.value / MAX_OMEGA_SPEED_DEG_S), 10.0)
|
||||
|
||||
def _fields_out_of_range(self, *fields) -> bool:
|
||||
"""True (plus error box) when a field sits outside its current limits.
|
||||
Needed because QDoubleValidator only paints the field red; ``.value``
|
||||
still reads the raw text, so without this gate the request goes out."""
|
||||
if all(getattr(f, "editor", f).hasAcceptableInput() for f in fields):
|
||||
return False
|
||||
msg = (
|
||||
"Image angle / Image time outside limits "
|
||||
f"(max {MAX_OMEGA_SPEED_DEG_S:.0f} °/s, min image time {self._min_exp_time_s:.4f} s)"
|
||||
)
|
||||
logger.error(msg)
|
||||
QMessageBox.critical(self, "Error", msg)
|
||||
return True
|
||||
|
||||
@Slot()
|
||||
def run_screening(self):
|
||||
if self._beamline_state != BeamlineStateEnum.SampleAlignment:
|
||||
logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment")
|
||||
QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
|
||||
return
|
||||
if self._fields_out_of_range(self.screening_image_angle, self.screening_image_time_enter):
|
||||
return
|
||||
if not self.check_before_run(scan_kind="screening"):
|
||||
logger.error("Cannot run measurement because of check")
|
||||
return
|
||||
@@ -229,6 +271,8 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
|
||||
logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment")
|
||||
QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
|
||||
return
|
||||
if self._fields_out_of_range(self.image_angle, self.image_time_enter):
|
||||
return
|
||||
if not self.check_before_run(scan_kind="rotation"):
|
||||
logger.error("Cannot run measurement because of check")
|
||||
return
|
||||
|
||||
@@ -86,7 +86,7 @@ class SamcamPanel(QWidget):
|
||||
# Overlay checkboxes, two columns to save vertical space; related
|
||||
# toggles share a row.
|
||||
self.show_detections_checkbox = QCheckBox("Show ML detections")
|
||||
self.show_detections_checkbox.setChecked(True) # Default to checked
|
||||
self.show_detections_checkbox.setChecked(False) # Default to checked
|
||||
self.show_detections_checkbox.toggled.connect(self.show_detections_changed.emit)
|
||||
|
||||
self.show_detection_polygons_checkbox = QCheckBox("Show ML polygons")
|
||||
|
||||
@@ -64,7 +64,8 @@ class SmargonPanel(QWidget):
|
||||
)
|
||||
|
||||
grid_layout.addWidget(QLabel("Chi", parent=self), 1, 0)
|
||||
self.chi_enter = NumberLineEdit(-0.2, 40, decimals=1, parent=self)
|
||||
# Vincent says Chi is limited to 0 to 30
|
||||
self.chi_enter = NumberLineEdit(-0.2, 30, decimals=1, parent=self)
|
||||
grid_layout.addWidget(self.chi_enter, 1, 1)
|
||||
grid_layout.addWidget(QLabel("°", parent=self), 1, 2)
|
||||
|
||||
|
||||
+16
-5
@@ -117,7 +117,10 @@ BANNER_TAB_GAP = 6
|
||||
# one for SEPARATOR_HINT_DELAY_MS (or a drag starts) — then only the exact
|
||||
# separator under the cursor fills with SEPARATOR_HINT. The rest/drag gate
|
||||
# lives in MainWindow.event(); the QSS :hover part picks the one separator.
|
||||
SEPARATOR_HINT = "rgba(168, 178, 192, 20%)" # scrollbar-track grey @50%
|
||||
SEPARATOR_HINT = "rgba(168, 178, 192, 30%)" # scrollbar-track grey @50%
|
||||
# Idle fill so the resize line is findable before the hover rest — 10% of the
|
||||
# hover alpha. Any base QSS fill replaces the native dotted grip; accepted.
|
||||
SEPARATOR_IDLE = "rgba(168, 178, 192, 10%)"
|
||||
SEPARATOR_HINT_DELAY_MS = 66 # int, used in code, not QSS
|
||||
|
||||
# Theme-switch screenshot cross-fade duration (int ms, used in code).
|
||||
@@ -223,6 +226,7 @@ DARK_DISABLED = "#3c4a66" # disabled — disabled fills
|
||||
# Accents — gold is IDENTITY (titles, highlights), blue is ACTION (primary
|
||||
# buttons); the site keeps the two apart on purpose:
|
||||
DARK_ACCENT = "#e0913f" # gold
|
||||
DARK_SEPARATOR_IDLE = "rgba(224, 145, 63, 10%)" # DARK_ACCENT @10%, idle resize line
|
||||
DARK_ACCENT_HOVER = "#eaa253" # accent2 — brighter gold
|
||||
DARK_ACCENT_FILL = "#89b4fa" # action blue
|
||||
DARK_ACCENT_FILL_HOVER = "#9ec2fb" # +10% white, derived (site has no step)
|
||||
@@ -1092,9 +1096,12 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* No base ::separator rule ON PURPOSE: any QSS fill would replace the
|
||||
native dotted-grip drawing, and the dots (visible in the dark theme,
|
||||
which never styled separators) are wanted in both themes. */
|
||||
/* Idle resize lines: faint fill (10% of the hover alpha) so the drag
|
||||
target is findable without hunting. This base fill replaces the
|
||||
native dotted grip — traded away on purpose for discoverability. */
|
||||
QMainWindow::separator, QSplitter::handle {
|
||||
background: $separator_idle;
|
||||
}
|
||||
|
||||
/* Resize-line hint — the separatorHint property is flipped by
|
||||
MainWindow.event() after a 1s hover rest or on press; :hover limits
|
||||
@@ -1749,7 +1756,11 @@ def _sunset_stylesheet() -> str:
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Resize-line hint, dark flavor — see the light-theme note. */
|
||||
/* Idle + hover resize lines, dark flavor — see the light-theme note. */
|
||||
QMainWindow::separator, QSplitter::handle {
|
||||
background: $dark_separator_idle;
|
||||
}
|
||||
|
||||
QMainWindow[separatorHint="true"]::separator:hover {
|
||||
background: $dark_accent;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ from aare.gui.styles import (
|
||||
WHITE,
|
||||
)
|
||||
|
||||
# Tall touch targets for the three baton hand-over buttons (Accept / Refuse /
|
||||
# Cancel Request) — operators hit these under time pressure.
|
||||
_BUTTON_HEIGHT = "88px"
|
||||
|
||||
|
||||
class BatonRequestDialog(QDialog):
|
||||
"""
|
||||
@@ -122,14 +126,18 @@ class BatonRequestDialog(QDialog):
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(20)
|
||||
|
||||
# Height lives in QSS, not setMinimumHeight(): the app stylesheet caps
|
||||
# every QPushButton at 16px (styles.py) and Qt re-applies that cap via
|
||||
# setMin/MaxHeight at polish, overwriting any value set here in code.
|
||||
self.accept_btn = QPushButton("✓ Accept")
|
||||
self.accept_btn.setMinimumHeight(88)
|
||||
self.accept_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {BATON_OK_BG};
|
||||
color: {WHITE};
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
min-height: {_BUTTON_HEIGHT};
|
||||
max-height: {_BUTTON_HEIGHT};
|
||||
font-weight: bold;
|
||||
font-size: {FONT_LABEL};
|
||||
}}
|
||||
@@ -144,13 +152,14 @@ class BatonRequestDialog(QDialog):
|
||||
button_layout.addWidget(self.accept_btn)
|
||||
|
||||
self.refuse_btn = QPushButton("✗ Refuse")
|
||||
self.refuse_btn.setMinimumHeight(88)
|
||||
self.refuse_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {BATON_DANGER_BG};
|
||||
color: {WHITE};
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
min-height: {_BUTTON_HEIGHT};
|
||||
max-height: {_BUTTON_HEIGHT};
|
||||
font-weight: bold;
|
||||
font-size: {FONT_LABEL};
|
||||
}}
|
||||
@@ -304,13 +313,15 @@ class BatonPendingDialog(QDialog):
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.cancel_btn = QPushButton("✗ Cancel Request")
|
||||
self.cancel_btn.setMinimumHeight(88)
|
||||
# See accept_btn in BatonRequestDialog for why height is QSS-only.
|
||||
self.cancel_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {BATON_DANGER_BG};
|
||||
color: {WHITE};
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
min-height: {_BUTTON_HEIGHT};
|
||||
max-height: {_BUTTON_HEIGHT};
|
||||
font-weight: bold;
|
||||
font-size: {FONT_LABEL};
|
||||
}}
|
||||
|
||||
@@ -142,7 +142,7 @@ class SampleCameraImageLabel(QGraphicsView):
|
||||
self._helical_end = SmargonCoordinate()
|
||||
self._raster_alpha = 127
|
||||
self._bounding_box = None
|
||||
self._show_detections = True
|
||||
self._show_detections = False
|
||||
self._show_detection_polygons = True
|
||||
|
||||
self._show_target_point = True
|
||||
|
||||
@@ -126,6 +126,7 @@ class NumberLineEdit(QLineEdit):
|
||||
self.setToolTip(
|
||||
f"Minimum: {self.to_string(min_val):s}\nMaximum: {self.to_string(max_val):s}"
|
||||
)
|
||||
self.on_text_changed(self.text())
|
||||
|
||||
def validate(self, text) -> bool:
|
||||
return self.range_validator.validate(str(text), 0)[0] == QDoubleValidator.State.Acceptable
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Omega speed cap (500 deg/s) and per-beamline min image time on the rotation panel.
|
||||
|
||||
Why: QDoubleValidator only paints a field red, so the interlock lives in the
|
||||
linked limits plus the Run-button gate. These fail if either half breaks.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from aarecommon.math.diffraction_geometry import DiffractionGeometry
|
||||
from aarecommon.models.models import BeamlineStateEnum
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel
|
||||
|
||||
|
||||
def _panel(monkeypatch, beamline):
|
||||
# mx_beamline() reads the env on every call, so set it before construction
|
||||
monkeypatch.setenv("BEAMLINE", beamline)
|
||||
diffraction = DiffractionGeometry(
|
||||
energy_keV=12.0,
|
||||
dtz_mm=150.0,
|
||||
pixel_size_mm=0.075,
|
||||
beam_center_pxl=(1000.0, 1000.0),
|
||||
detector_size_pxl=(2000, 2000),
|
||||
detector_description="Eiger 16M",
|
||||
detector_serial_number="123",
|
||||
poni_rot1_rad=0.0,
|
||||
poni_rot2_rad=0.0,
|
||||
)
|
||||
return RotationDataCollectionPanel(diffraction=diffraction)
|
||||
|
||||
|
||||
def _editor(field):
|
||||
# NumberLineEdit is the editor itself; DbOverrideLineEdit wraps one in .editor
|
||||
return getattr(field, "editor", field)
|
||||
|
||||
|
||||
def _commit(field, text):
|
||||
_editor(field).setText(text)
|
||||
_editor(field).on_editing_finished()
|
||||
|
||||
|
||||
def _accepts(field, text) -> bool:
|
||||
_editor(field).setText(text)
|
||||
return _editor(field).hasAcceptableInput()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"beamline,too_short,ok", [("X06DA", "0.0010", "0.0012"), ("X10SA", "0.0050", "0.0090")]
|
||||
)
|
||||
def test_min_image_time_per_beamline(qapp, monkeypatch, beamline, too_short, ok):
|
||||
panel = _panel(monkeypatch, beamline)
|
||||
for field in (panel.image_time_enter, panel.screening_image_time_enter):
|
||||
assert not _accepts(field, too_short)
|
||||
assert _accepts(field, ok)
|
||||
|
||||
|
||||
def test_speed_cap_couples_angle_and_time(qapp, monkeypatch):
|
||||
panel = _panel(monkeypatch, "X06DA")
|
||||
|
||||
_commit(panel.image_angle, "1.000") # -> time floor 1/500 = 0.002 s
|
||||
assert not _accepts(panel.image_time_enter, "0.0010")
|
||||
assert _accepts(panel.image_time_enter, "0.0020")
|
||||
|
||||
_commit(panel.image_time_enter, "0.0100") # -> angle max 5 deg
|
||||
assert not _accepts(panel.image_angle, "6.000")
|
||||
assert _accepts(panel.image_angle, "4.000")
|
||||
|
||||
|
||||
def test_run_blocked_while_field_red(qapp, monkeypatch):
|
||||
panel = _panel(monkeypatch, "X06DA")
|
||||
boxes = []
|
||||
monkeypatch.setattr(QMessageBox, "critical", lambda *a, **k: boxes.append(a))
|
||||
emitted = []
|
||||
panel.rotation_scan.connect(emitted.append)
|
||||
panel._beamline_state = BeamlineStateEnum.SampleAlignment
|
||||
|
||||
_editor(panel.image_time_enter).setText("0.0010") # below 1/900 s, stays red
|
||||
panel.run_measurement()
|
||||
|
||||
assert emitted == []
|
||||
assert len(boxes) == 1
|
||||
@@ -31,7 +31,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aaredaq"
|
||||
version = "0.17.5"
|
||||
version = "0.21.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aarecommon" },
|
||||
@@ -93,7 +93,7 @@ requires-dist = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "jfjoch-client", specifier = "==1.0.0rc146" },
|
||||
{ name = "jfjoch-client", specifier = ">=1.0.0rc165" },
|
||||
{ name = "matplotlib", specifier = ">=3.10.3" },
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
@@ -1191,7 +1191,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "jfjoch-client"
|
||||
version = "1.0.0rc146"
|
||||
version = "1.0.0rc166"
|
||||
source = { registry = "https://gitea.psi.ch/api/packages/mx/pypi/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
@@ -1199,9 +1199,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/jfjoch-client/1.0.0rc146/jfjoch_client-1.0.0rc146.tar.gz", hash = "sha256:8e06d9671981d48b5ae0e60b0cbc0c32bdae7dfec72861d653e0338147da6fe8" }
|
||||
sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/jfjoch-client/1.0.0rc166/jfjoch_client-1.0.0rc166.tar.gz", hash = "sha256:697025dcbcf8bbce62c02948f0a3149a1198237260841dfd6951da7ec848fee1" }
|
||||
wheels = [
|
||||
{ url = "https://gitea.psi.ch/api/packages/mx/pypi/files/jfjoch-client/1.0.0rc146/jfjoch_client-1.0.0rc146-py3-none-any.whl", hash = "sha256:d9014d756308ebf484e2eb09b5a77d9f51ecfa1a185fb623315026c23617c389" },
|
||||
{ url = "https://gitea.psi.ch/api/packages/mx/pypi/files/jfjoch-client/1.0.0rc166/jfjoch_client-1.0.0rc166-py3-none-any.whl", hash = "sha256:b868a43f1713fb94db45b0f0d1192ce2182836c9ff1620cdf1bbbed91c14e863" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user