Feat/gui polish #195

Merged
duan_j merged 7 commits from feat/gui-polish into main 2026-09-07 10:11:01 +02:00
10 changed files with 188 additions and 21 deletions
+19 -1
View File
@@ -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
+1 -1
View File
@@ -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")
+2 -1
View File
@@ -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
View File
@@ -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;
}
+14 -3
View File
@@ -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};
}}
+1 -1
View File
@@ -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
+1
View File
@@ -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
Generated
+5 -5
View File
@@ -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]]