From a38b3b055a81f01a2e7e11d849418d67d5edd9f6 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 14 May 2025 16:27:42 +0200 Subject: [PATCH] Include everything in one repo, for better development --- .gitea/workflows/action.yaml | 19 +- .gitignore | 2 + common/pyproject.toml | 17 + {src => common/src/aaredaqlib}/__init__.py | 0 common/src/aaredaqlib/coordinate.py | 104 ++++ common/src/aaredaqlib/helical_geometry.py | 6 + common/src/aaredaqlib/models.py | 171 ++++++ common/src/aaredaqlib/raster_grid.py | 54 ++ common/src/aaredaqlib/rotation_scan.py | 13 + common/src/aaredaqlib/sample_geometry.py | 87 +++ common/src/aaredaqlib/scan_request.py | 15 + common/src/aaredaqlib/screening_scan.py | 11 + {src => daq}/pyproject.toml | 4 +- {src => daq/src}/aaredaq/__init__.py | 0 {src => daq/src}/aaredaq/aaredb.py | 0 {src => daq/src}/aaredaq/auth.py | 0 {src => daq/src}/aaredaq/beamline.py | 0 {src => daq/src}/aaredaq/best.pt | Bin {src => daq/src}/aaredaq/config.py | 0 {src => daq/src}/aaredaq/daq.py | 0 {src => daq/src}/aaredaq/detector.py | 0 {src => daq/src}/aaredaq/devices.py | 3 - {src => daq/src}/aaredaq/mlbox.py | 0 {src => daq/src}/aaredaq/server.py | 0 {src => daq/src}/aaredaq/workflows.py | 0 {src => daq/src}/mxlibs3/__init__.py | 0 {src => daq/src}/mxlibs3/aerotech.py | 0 {src => daq/src}/mxlibs3/area_detector.py | 0 {src => daq/src}/mxlibs3/enum_pv.py | 0 {src => daq/src}/mxlibs3/mx_lib.py | 0 {src => daq/src}/mxlibs3/my_motor.py | 0 {src => daq/src}/mxlibs3/non_standard.py | 0 {src => daq/src}/mxlibs3/pshell_client.py | 0 {src => daq/src}/mxlibs3/smargon.py | 0 {src => daq/src}/mxlibs3/tell_client.py | 0 {src => daq/src}/mxlibs3/workflow_tools.py | 0 gui/pyproject.toml | 20 + gui/src/aaregui/__init__.py | 0 gui/src/aaregui/main.py | 20 + gui/src/aaregui/main_window.py | 200 +++++++ gui/src/aaregui/models/__init__.py | 0 gui/src/aaregui/models/bookmark.py | 27 + gui/src/aaregui/models/sample_queue_model.py | 119 +++++ gui/src/aaregui/models/user_sample_model.py | 139 +++++ gui/src/aaregui/panels/__init__.py | 0 gui/src/aaregui/panels/abr_tweak_panel.py | 129 +++++ gui/src/aaregui/panels/beam_center_panel.py | 23 + gui/src/aaregui/panels/beamline_controls.py | 49 ++ .../aaregui/panels/beamline_state_panel.py | 20 + .../panels/data_collection_settings.py | 118 +++++ gui/src/aaregui/panels/file_path_panel.py | 21 + gui/src/aaregui/panels/helical_scan_panel.py | 115 ++++ gui/src/aaregui/panels/illumination_panel.py | 38 ++ .../aaregui/panels/loop_centering_panel.py | 18 + gui/src/aaregui/panels/omega_panel.py | 71 +++ .../aaregui/panels/raster_data_collection.py | 43 ++ gui/src/aaregui/panels/sample_queue_panel.py | 89 ++++ .../panels/screening_data_collection.py | 60 +++ gui/src/aaregui/panels/smargon_panel.py | 113 ++++ .../panels/standard_data_collection.py | 54 ++ gui/src/aaregui/panels/status_panel.py | 64 +++ gui/src/aaregui/panels/tell_sample_panel.py | 121 +++++ gui/src/aaregui/panels/zoom_panel.py | 33 ++ gui/src/aaregui/threads/__init__.py | 0 gui/src/aaregui/threads/camera_thread.py | 51 ++ gui/src/aaregui/threads/daq_worker.py | 229 ++++++++ gui/src/aaregui/widgets/__init__.py | 0 .../aaregui/widgets/button_with_payload.py | 16 + gui/src/aaregui/widgets/camera_image.py | 496 ++++++++++++++++++ gui/src/aaregui/widgets/clickable_label.py | 12 + gui/src/aaregui/widgets/login.py | 66 +++ .../aaregui/widgets/no_wheel_scroll_area.py | 10 + gui/src/aaregui/widgets/number_line_edit.py | 65 +++ gui/src/aaregui/widgets/pgroup_dialog.py | 37 ++ gui/src/aaregui/widgets/status_bar.py | 149 ++++++ gui/src/aaregui/widgets/status_label.py | 15 + gui/src/aaregui/widgets/title_label.py | 12 + src/.gitignore | 1 - 78 files changed, 3362 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 common/pyproject.toml rename {src => common/src/aaredaqlib}/__init__.py (100%) create mode 100644 common/src/aaredaqlib/coordinate.py create mode 100644 common/src/aaredaqlib/helical_geometry.py create mode 100644 common/src/aaredaqlib/models.py create mode 100644 common/src/aaredaqlib/raster_grid.py create mode 100644 common/src/aaredaqlib/rotation_scan.py create mode 100644 common/src/aaredaqlib/sample_geometry.py create mode 100644 common/src/aaredaqlib/scan_request.py create mode 100644 common/src/aaredaqlib/screening_scan.py rename {src => daq}/pyproject.toml (92%) rename {src => daq/src}/aaredaq/__init__.py (100%) rename {src => daq/src}/aaredaq/aaredb.py (100%) rename {src => daq/src}/aaredaq/auth.py (100%) rename {src => daq/src}/aaredaq/beamline.py (100%) rename {src => daq/src}/aaredaq/best.pt (100%) rename {src => daq/src}/aaredaq/config.py (100%) rename {src => daq/src}/aaredaq/daq.py (100%) rename {src => daq/src}/aaredaq/detector.py (100%) rename {src => daq/src}/aaredaq/devices.py (98%) rename {src => daq/src}/aaredaq/mlbox.py (100%) rename {src => daq/src}/aaredaq/server.py (100%) rename {src => daq/src}/aaredaq/workflows.py (100%) rename {src => daq/src}/mxlibs3/__init__.py (100%) rename {src => daq/src}/mxlibs3/aerotech.py (100%) rename {src => daq/src}/mxlibs3/area_detector.py (100%) rename {src => daq/src}/mxlibs3/enum_pv.py (100%) rename {src => daq/src}/mxlibs3/mx_lib.py (100%) rename {src => daq/src}/mxlibs3/my_motor.py (100%) rename {src => daq/src}/mxlibs3/non_standard.py (100%) rename {src => daq/src}/mxlibs3/pshell_client.py (100%) rename {src => daq/src}/mxlibs3/smargon.py (100%) rename {src => daq/src}/mxlibs3/tell_client.py (100%) rename {src => daq/src}/mxlibs3/workflow_tools.py (100%) create mode 100644 gui/pyproject.toml create mode 100644 gui/src/aaregui/__init__.py create mode 100644 gui/src/aaregui/main.py create mode 100644 gui/src/aaregui/main_window.py create mode 100644 gui/src/aaregui/models/__init__.py create mode 100644 gui/src/aaregui/models/bookmark.py create mode 100644 gui/src/aaregui/models/sample_queue_model.py create mode 100644 gui/src/aaregui/models/user_sample_model.py create mode 100644 gui/src/aaregui/panels/__init__.py create mode 100644 gui/src/aaregui/panels/abr_tweak_panel.py create mode 100644 gui/src/aaregui/panels/beam_center_panel.py create mode 100644 gui/src/aaregui/panels/beamline_controls.py create mode 100644 gui/src/aaregui/panels/beamline_state_panel.py create mode 100644 gui/src/aaregui/panels/data_collection_settings.py create mode 100644 gui/src/aaregui/panels/file_path_panel.py create mode 100644 gui/src/aaregui/panels/helical_scan_panel.py create mode 100644 gui/src/aaregui/panels/illumination_panel.py create mode 100644 gui/src/aaregui/panels/loop_centering_panel.py create mode 100644 gui/src/aaregui/panels/omega_panel.py create mode 100644 gui/src/aaregui/panels/raster_data_collection.py create mode 100644 gui/src/aaregui/panels/sample_queue_panel.py create mode 100644 gui/src/aaregui/panels/screening_data_collection.py create mode 100644 gui/src/aaregui/panels/smargon_panel.py create mode 100644 gui/src/aaregui/panels/standard_data_collection.py create mode 100644 gui/src/aaregui/panels/status_panel.py create mode 100644 gui/src/aaregui/panels/tell_sample_panel.py create mode 100644 gui/src/aaregui/panels/zoom_panel.py create mode 100644 gui/src/aaregui/threads/__init__.py create mode 100644 gui/src/aaregui/threads/camera_thread.py create mode 100644 gui/src/aaregui/threads/daq_worker.py create mode 100644 gui/src/aaregui/widgets/__init__.py create mode 100644 gui/src/aaregui/widgets/button_with_payload.py create mode 100644 gui/src/aaregui/widgets/camera_image.py create mode 100644 gui/src/aaregui/widgets/clickable_label.py create mode 100644 gui/src/aaregui/widgets/login.py create mode 100644 gui/src/aaregui/widgets/no_wheel_scroll_area.py create mode 100644 gui/src/aaregui/widgets/number_line_edit.py create mode 100644 gui/src/aaregui/widgets/pgroup_dialog.py create mode 100644 gui/src/aaregui/widgets/status_bar.py create mode 100644 gui/src/aaregui/widgets/status_label.py create mode 100644 gui/src/aaregui/widgets/title_label.py delete mode 100644 src/.gitignore diff --git a/.gitea/workflows/action.yaml b/.gitea/workflows/action.yaml index e946b384..ad87e88d 100644 --- a/.gitea/workflows/action.yaml +++ b/.gitea/workflows/action.yaml @@ -19,11 +19,28 @@ jobs: python3 -m venv venv # Create a virtual environment source venv/bin/activate pip install twine build + mkdir dist/ - - name: Build the Wheel + - name: Build the common wheel run: | source venv/bin/activate + cd common/ python -m build + mv dist/* ../dist + + - name: Build the GUI wheel + run: | + source venv/bin/activate + cd gui/ + python -m build + mv dist/* ../dist + + - name: Build the DAQ wheel + run: | + source venv/bin/activate + cd daq/ + python -m build + mv dist/* ../dist - name: Upload Package to Gitea PyPI env: diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3e8ecb46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +.venv/ \ No newline at end of file diff --git a/common/pyproject.toml b/common/pyproject.toml new file mode 100644 index 00000000..ecf06340 --- /dev/null +++ b/common/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "aaredaqlib" +version = "0.1.0" +description = "Libraries shared between AareDAQ and AareGUI" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "pydantic==2.11.4", + "numpy==2.2.5", +] + +[lint] +ignore = ["F401", "F541", "W503", "W504"] + +[build-system] +requires = ["setuptools>=75.6.0"] +build-backend = "setuptools.build_meta" diff --git a/src/__init__.py b/common/src/aaredaqlib/__init__.py similarity index 100% rename from src/__init__.py rename to common/src/aaredaqlib/__init__.py diff --git a/common/src/aaredaqlib/coordinate.py b/common/src/aaredaqlib/coordinate.py new file mode 100644 index 00000000..edbdebc4 --- /dev/null +++ b/common/src/aaredaqlib/coordinate.py @@ -0,0 +1,104 @@ +from typing import Optional + +import numpy as np +from pydantic import BaseModel + + +class Coordinate(BaseModel): + x: float = 0.0 + y: float = 0.0 + z: float = 0.0 + + # Overload + + def __add__(self, other: "Coordinate") -> "Coordinate": + if isinstance(other, Coordinate): + return Coordinate( + x=self.x + other.x, y=self.y + other.y, z=self.z + other.z + ) + return NotImplemented + + # Overload - + def __sub__(self, other: "Coordinate") -> "Coordinate": + if isinstance(other, Coordinate): + return Coordinate( + x=self.x - other.x, y=self.y - other.y, z=self.z - other.z + ) + return NotImplemented + + # Overload * + def __mul__(self, other): + if isinstance(other, (int, float)): # Scalar multiplication + return Coordinate(x=self.x * other, y=self.y * other, z=self.z * other) + elif isinstance(other, Coordinate): # Dot product + return self.x * other.x + self.y * other.y + self.z * other.z + return NotImplemented + + # Overload / + def __truediv__(self, scalar: float) -> "Coordinate": + if isinstance(scalar, (int, float)) and scalar != 0: # Avoid division by zero + return Coordinate(x=self.x / scalar, y=self.y / scalar, z=self.z / scalar) + elif scalar == 0: + raise ValueError("Cannot divide by zero") + return NotImplemented + + def normalize(self) -> "Coordinate": + magnitude = np.sqrt(self.x**2 + self.y**2 + self.z**2) + if magnitude == 0: + raise ValueError("Cannot normalize a zero-magnitude vector.") + return self / magnitude + + def rotate(self, angle_deg: float, axis: str) -> "Coordinate": + """ + Rotate the coordinate around a specified axis by a given angle in degrees. + + :param angle_deg: The angle of rotation in degrees. + :param axis: The axis to rotate around ('x', 'y', or 'z'). + :return: A new Coordinate after rotation. + """ + angle_rad = np.radians(angle_deg) # Convert angle to radians + c = np.cos(angle_rad) + s = np.sin(angle_rad) + + if axis == "x": + # Rotate around x-axis (affects y, z) + y_new = self.y * c - self.z * s + z_new = self.y * s + self.z * c + return Coordinate(x=self.x, y=y_new, z=z_new) + elif axis == "y": + # Rotate around y-axis (affects x, z) + x_new = self.x * c + self.z * s + z_new = -self.x * s + self.z * c + return Coordinate(x=x_new, y=self.y, z=z_new) + elif axis == "z": + # Rotate around z-axis (affects x, y) + x_new = self.x * c - self.y * s + y_new = self.x * s + self.y * c + return Coordinate(x=x_new, y=y_new, z=self.z) + else: + raise ValueError("Invalid axis. Choose 'x', 'y', or 'z'.") + + +class SmargonCoordinate(BaseModel): + sh_mm: Optional[Coordinate] = None # SH coordinate in Smargon + phi_deg: Optional[float] = None + chi_deg: Optional[float] = None + + def eq(self, other: "SmargonCoordinate", tol: float) -> bool: + return ( + abs(self.sh_mm.x - other.sh_mm.x) < tol + and abs(self.sh_mm.y - other.sh_mm.y) < tol + and abs(self.sh_mm.z - other.sh_mm.z) < tol + and abs(self.phi_deg - other.phi_deg) < tol + and abs(self.chi_deg - other.chi_deg) < tol + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, SmargonCoordinate): + return self.eq(other, tol=0.1) + return NotImplemented + + +def positive_coords(value: Coordinate) -> Coordinate: + if value.x <= 0 or value.y <= 0: + raise ValueError("Coordinates must be positive") + return value diff --git a/common/src/aaredaqlib/helical_geometry.py b/common/src/aaredaqlib/helical_geometry.py new file mode 100644 index 00000000..005970da --- /dev/null +++ b/common/src/aaredaqlib/helical_geometry.py @@ -0,0 +1,6 @@ +from aaredaqlib import SmargonCoordinate +from aaredaqlib.rotation_scan import RotationScanRequest + + +class HelicalScanRequest(RotationScanRequest): + end: SmargonCoordinate diff --git a/common/src/aaredaqlib/models.py b/common/src/aaredaqlib/models.py new file mode 100644 index 00000000..20c899e8 --- /dev/null +++ b/common/src/aaredaqlib/models.py @@ -0,0 +1,171 @@ +from enum import Enum +from typing import Annotated, Literal, Tuple, List, Optional + +from pydantic import BaseModel, Field + +from aaredaqlib import Coordinate +from aaredaqlib import SampleGeometryModel + +class TokenData(BaseModel): + sub: str # Username + pgroups: List[str] + session: int + staff: bool = False + +class DewarAddress(BaseModel): + segment: Literal["A", "B", "C", "D", "E", "F", "X", "R"] + pos: Annotated[int, Field(ge=1, le=5)] + + +class SampleDewarAddress(BaseModel): + puck: DewarAddress + pin: Annotated[int, Field(ge=1, le=16)] + + +# From the database for puck loading +class PuckInfo(BaseModel): + db_id: int + puck_name: str + dewar_name: str + user: str = "" + location: Optional[DewarAddress] = None + + +# From TELL to database after loading +class PuckLoadedInfo(BaseModel): + puck_name: str + location: DewarAddress + + +# From database to TELL after loading +class SampleShortInfo(BaseModel): + db_id: int + puck_name: str + dewar_name: str + sample_name: str + user: str = "" + pin: Annotated[int, Field(ge=1, le=16)] + location: DewarAddress | None = None + priority: Optional[float] = 1.0 + comment: Optional[str] = None + mount_count: int = 0 + def tell_address(self) -> SampleDewarAddress: + return SampleDewarAddress(puck=self.location, pin=self.pin) + + def loc_str(self) -> str: + if self.location is None: + return "-" + else: + return f"{self.location.segment}{self.location.pos}-{self.pin}" + + def loc_str_sort(self) -> str: + if self.location is None: + return "" + else: + return f"{self.location.segment}{self.location.pos}-{self.pin:02d}" + + +class SampleShortInfoList(BaseModel): + s: List[SampleShortInfo] + + +class BeamCenterCoeffModel(BaseModel): + """ + Model to calculate beam center for a given zoom level based on a quadratic approximation + For both x and y it contains tuple of coefficients a, b, c (a*zoom^2 + b*zoom + c) + Default is beam center in 1000,1000 at any zoom level + """ + + coeff_x: Tuple[float, float, float] = (0, 0, 1000.0) + coeff_y: Tuple[float, float, float] = (0, 0, 1000.0) + + def apply(self, zoom: float): + return Coordinate( + x=self.coeff_x[0] * zoom**2 + self.coeff_x[1] * zoom + self.coeff_x[2], + y=self.coeff_y[0] * zoom**2 + self.coeff_y[1] * zoom + self.coeff_y[2], + ) + + +class LoopCenteringZoomModelElem(BaseModel): + zoom_value: float + sam_cam_gain: float + sam_cam_exp: float + + +class LoopCenteringZoomModel(BaseModel): + z: List[LoopCenteringZoomModelElem] + + +# zoom gain exp (X06DA) +# 1 50 0.05 +# 200 50 0.05 +# 500 70 0.05 +# 700 150 0.05 +# 800 100 0.10 +# 1000 200 0.10 + + +def def_loop_centering_zoom() -> LoopCenteringZoomModel: + return LoopCenteringZoomModel( + z=[ + LoopCenteringZoomModelElem( + zoom_value=200, sam_cam_gain=50.0, sam_cam_exp=0.05 + ) + ] + ) + + +class BeamlineStateEnum(Enum): + Maintenance = 1 + SampleExchange = 2 + SampleAlignment = 3 + DataCollection = 4 + DewarTransfer = 5 + XrayFluorescence = 6 + BeamLocation = 7 + Moving = 8 + RobotSampleExchange = 9 + # Busy state is used whenever transition between two states happen or mounting/data collection procedure happens + + +class SessionsStateEnum(Enum): + OwnedByYou = 1 + OwnedByElse = 2 + Vacant = 3 + +class BeamlineStatus(BaseModel): + energy_keV: float + detector_distance_mm: float + ring_current_mA: float + light: Annotated[float, Field(ge=0.0, le=100.0)] + cryojet_K: float + +class SessionStatus(BaseModel): + session: SessionsStateEnum = SessionsStateEnum.Vacant + current_pgroup: str | None + staff: bool = False + +class DAQStatusModel(BaseModel): + state: BeamlineStateEnum + busy: bool + geom: SampleGeometryModel + bl: BeamlineStatus + sample: SampleShortInfo | None = None + session: SessionStatus + +class BeamlineSettingsModel(BaseModel): + # Float properties + cryojet_park_position: float = 12.0 + cryojet_measurement_position: float = 5.0 + dtz_collection: float = 130 + dtz_park: float = 150.0 + dtz_wash_sample_distance: float = 150.0 + dtz_bsz_safety_margin: float = 50.0 + bsz: float = 25.0 + + camera_max_magnification: float = 1.0 + camera_min_magnification: float = 500.0 + camera_translation_factor_a: float = 0.00253 + camera_translation_factor_b: float = 255.0 + + cryojet_in_use: bool = True diff --git a/common/src/aaredaqlib/raster_grid.py b/common/src/aaredaqlib/raster_grid.py new file mode 100644 index 00000000..534f2a05 --- /dev/null +++ b/common/src/aaredaqlib/raster_grid.py @@ -0,0 +1,54 @@ +import math +from typing import Annotated + +from pydantic import Field, AfterValidator + +from aaredaqlib import Coordinate, positive_coords, SmargonCoordinate +from aaredaqlib import SampleGeometryModel +from aaredaqlib import ScanRequest + + +def normalize_angle(angle_deg: float) -> float: + normalized_angle = math.fmod(angle_deg + 180, 360) - 180.0 + return normalized_angle + + +class RasterGridRequest(ScanRequest): + # Number of grid elements + n_x: Annotated[int, Field(ge=1)] + n_y: Annotated[int, Field(ge=1)] + + # Size of grid elements + grid_size_mm: Annotated[Coordinate, AfterValidator(positive_coords)] + + # Coordinates of top left corner in Smargon (SH...) coordinates + smargon: SmargonCoordinate + + # omega angle + omega_deg: float = 0.0 + + def get_image_number(self) -> int: + return self.n_x * self.n_y + + def grid_size_pxl(self, geom: SampleGeometryModel) -> Coordinate: + return self.grid_size_mm / geom.pixel_in_mm + + def start_pxl(self, geom: SampleGeometryModel) -> Coordinate: + return geom.smargon_to_beamline(self.smargon.sh_mm) + + def point_sample_belongs_to_grid( + self, geom: SampleGeometryModel, i: Coordinate + ) -> bool: + point = i - geom.smargon_to_beamline(self.smargon.sh_mm) + return (0 <= point.x < self.n_x * self.grid_size_mm.x) and ( + 0 <= point.y < self.n_y * self.grid_size_mm.y + ) + + def grid_visible(self, geom: SampleGeometryModel) -> bool: + if ( + abs(normalize_angle(self.omega_deg - geom.omega_deg)) < 1.0 + and abs(self.smargon.phi_deg - geom.smargon.phi_deg) < 1.0 + and abs(self.smargon.chi_deg - geom.smargon.chi_deg) < 1.0 + ): + return True + return False diff --git a/common/src/aaredaqlib/rotation_scan.py b/common/src/aaredaqlib/rotation_scan.py new file mode 100644 index 00000000..19f249d0 --- /dev/null +++ b/common/src/aaredaqlib/rotation_scan.py @@ -0,0 +1,13 @@ +from aaredaqlib import SmargonCoordinate +from aaredaqlib import ScanRequest + + +class RotationScanRequest(ScanRequest): + start: SmargonCoordinate | None = None # None = start where you are + + start_omega_deg: float = 0 + omega_incr_deg: float + steps: int + + def get_image_number(self) -> int: + return self.steps diff --git a/common/src/aaredaqlib/sample_geometry.py b/common/src/aaredaqlib/sample_geometry.py new file mode 100644 index 00000000..55e63664 --- /dev/null +++ b/common/src/aaredaqlib/sample_geometry.py @@ -0,0 +1,87 @@ +from typing import Annotated + +import numpy as np +from pydantic import BaseModel, Field, AfterValidator + +from aaredaqlib import Coordinate, SmargonCoordinate, positive_coords + + +class SampleGeometryModel(BaseModel): + # Beam location in the camera coordinates + beam_location_pxl: Coordinate + + # 1 pixel in mm + pixel_in_mm: Annotated[float, Field(gt=0.0)] + + aerotech: Coordinate + + aerotech_meas: Coordinate + # Current smargon coordinates/target + smargon: SmargonCoordinate + + omega_deg: float + + # Beam size in mm + beam_size_mm: Annotated[Coordinate, AfterValidator(positive_coords)] + + @property + def beam_size_pxl(self) -> Coordinate: + return self.beam_size_mm / self.pixel_in_mm + + # This goes from image coordinates to relative beam coordinates in image plane + def picture_to_sample(self, pic_pxl: Coordinate) -> Coordinate: + # Relative to beam location + beam_mm = (pic_pxl - self.beam_location_pxl) * self.pixel_in_mm + self.aerotech + return beam_mm + + # This goes from relative beam coordinates in image plane to image + def sample_to_picture(self, beam_mm: Coordinate) -> Coordinate: + beam_pxl = (beam_mm - self.aerotech) / self.pixel_in_mm + return beam_pxl + self.beam_location_pxl + + def smargon_nudge(self, coord: Coordinate) -> Coordinate: + phi = np.radians(np.around(self.smargon.phi_deg, decimals=1)) + chi = np.radians(np.around(self.smargon.chi_deg, decimals=1)) + omega = np.radians(np.around(self.omega_deg, decimals=1)) + + offset = Coordinate() + co = np.cos(omega) + so = np.sin(omega) + cp = np.cos(phi) + sp = np.sin(phi) + cc = np.cos(chi) + sc = np.sin(chi) + + offset.x = ( + -coord.z * co * sp + - coord.y * so * sp + + coord.x * cp * sc + - coord.y * co * cc * cp + + coord.z * so * cc * cp + ) + offset.y = ( + -coord.z * co * cp + - coord.y * so * cp + - coord.x * sc * sp + + coord.y * co * cc * sp + - coord.z * so * cc * sp + ) + offset.z = -coord.x * cc - coord.y * co * sc + coord.z * so * sc + return offset + + def beamline_to_smargon(self, coord: Coordinate) -> Coordinate: + return self.smargon.sh_mm + self.smargon_nudge(coord) + + def smargon_to_beamline(self, coord: Coordinate) -> Coordinate: + rel = coord - self.smargon.sh_mm + + vec_x = self.smargon_nudge(Coordinate(x=1)).normalize() + vec_y = self.smargon_nudge(Coordinate(y=1)).normalize() + + return Coordinate(x=rel * vec_x, y=rel * vec_y) + + def picture_to_smargon(self, coord: Coordinate) -> Coordinate: + return self.beamline_to_smargon(self.picture_to_sample(coord)) + + def smargon_to_picture(self, coord: Coordinate) -> Coordinate: + return self.sample_to_picture(self.smargon_to_beamline(coord)) diff --git a/common/src/aaredaqlib/scan_request.py b/common/src/aaredaqlib/scan_request.py new file mode 100644 index 00000000..d799670a --- /dev/null +++ b/common/src/aaredaqlib/scan_request.py @@ -0,0 +1,15 @@ +from abc import abstractmethod, ABC + +from pydantic import BaseModel + +class ScanRequest(ABC, BaseModel): + dtz: float + transmission: float + file_prefix: str + exp_time_s: float + + @abstractmethod + def get_image_number(self) -> int: + pass + + diff --git a/common/src/aaredaqlib/screening_scan.py b/common/src/aaredaqlib/screening_scan.py new file mode 100644 index 00000000..e5d93f43 --- /dev/null +++ b/common/src/aaredaqlib/screening_scan.py @@ -0,0 +1,11 @@ +from aaredaqlib import ScanRequest + + +class ScreeningScanRequest(ScanRequest): + omega_start_deg: float + omega_step_deg: float + steps: int + oscrange_deg: float + + def get_image_number(self) -> int: + return self.steps diff --git a/src/pyproject.toml b/daq/pyproject.toml similarity index 92% rename from src/pyproject.toml rename to daq/pyproject.toml index 3d26412a..fc54c20a 100644 --- a/src/pyproject.toml +++ b/daq/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aaredaq" -version = "0.0.1" +version = "0.1.0" description = "AareDAQ data acquisition server" readme = "README.md" requires-python = ">=3.11" @@ -14,7 +14,7 @@ dependencies = [ "uvicorn==0.34.2", "ultralytics==8.3.133", "aareDB==0.1.1a4", - "aaredaqlib==0.0.2", + "aaredaqlib==0.1.0", "jfjoch_client==1.0.0rc37" ] diff --git a/src/aaredaq/__init__.py b/daq/src/aaredaq/__init__.py similarity index 100% rename from src/aaredaq/__init__.py rename to daq/src/aaredaq/__init__.py diff --git a/src/aaredaq/aaredb.py b/daq/src/aaredaq/aaredb.py similarity index 100% rename from src/aaredaq/aaredb.py rename to daq/src/aaredaq/aaredb.py diff --git a/src/aaredaq/auth.py b/daq/src/aaredaq/auth.py similarity index 100% rename from src/aaredaq/auth.py rename to daq/src/aaredaq/auth.py diff --git a/src/aaredaq/beamline.py b/daq/src/aaredaq/beamline.py similarity index 100% rename from src/aaredaq/beamline.py rename to daq/src/aaredaq/beamline.py diff --git a/src/aaredaq/best.pt b/daq/src/aaredaq/best.pt similarity index 100% rename from src/aaredaq/best.pt rename to daq/src/aaredaq/best.pt diff --git a/src/aaredaq/config.py b/daq/src/aaredaq/config.py similarity index 100% rename from src/aaredaq/config.py rename to daq/src/aaredaq/config.py diff --git a/src/aaredaq/daq.py b/daq/src/aaredaq/daq.py similarity index 100% rename from src/aaredaq/daq.py rename to daq/src/aaredaq/daq.py diff --git a/src/aaredaq/detector.py b/daq/src/aaredaq/detector.py similarity index 100% rename from src/aaredaq/detector.py rename to daq/src/aaredaq/detector.py diff --git a/src/aaredaq/devices.py b/daq/src/aaredaq/devices.py similarity index 98% rename from src/aaredaq/devices.py rename to daq/src/aaredaq/devices.py index 21cae8bd..273cafa4 100644 --- a/src/aaredaq/devices.py +++ b/daq/src/aaredaq/devices.py @@ -1,7 +1,4 @@ -from xml.sax.handler import property_dom_node - from aaredaqlib.coordinate import Coordinate -from aaredaqlib.models import BeamlineStatus from epics import PV from aaredaq.aaredb import AareWrapper diff --git a/src/aaredaq/mlbox.py b/daq/src/aaredaq/mlbox.py similarity index 100% rename from src/aaredaq/mlbox.py rename to daq/src/aaredaq/mlbox.py diff --git a/src/aaredaq/server.py b/daq/src/aaredaq/server.py similarity index 100% rename from src/aaredaq/server.py rename to daq/src/aaredaq/server.py diff --git a/src/aaredaq/workflows.py b/daq/src/aaredaq/workflows.py similarity index 100% rename from src/aaredaq/workflows.py rename to daq/src/aaredaq/workflows.py diff --git a/src/mxlibs3/__init__.py b/daq/src/mxlibs3/__init__.py similarity index 100% rename from src/mxlibs3/__init__.py rename to daq/src/mxlibs3/__init__.py diff --git a/src/mxlibs3/aerotech.py b/daq/src/mxlibs3/aerotech.py similarity index 100% rename from src/mxlibs3/aerotech.py rename to daq/src/mxlibs3/aerotech.py diff --git a/src/mxlibs3/area_detector.py b/daq/src/mxlibs3/area_detector.py similarity index 100% rename from src/mxlibs3/area_detector.py rename to daq/src/mxlibs3/area_detector.py diff --git a/src/mxlibs3/enum_pv.py b/daq/src/mxlibs3/enum_pv.py similarity index 100% rename from src/mxlibs3/enum_pv.py rename to daq/src/mxlibs3/enum_pv.py diff --git a/src/mxlibs3/mx_lib.py b/daq/src/mxlibs3/mx_lib.py similarity index 100% rename from src/mxlibs3/mx_lib.py rename to daq/src/mxlibs3/mx_lib.py diff --git a/src/mxlibs3/my_motor.py b/daq/src/mxlibs3/my_motor.py similarity index 100% rename from src/mxlibs3/my_motor.py rename to daq/src/mxlibs3/my_motor.py diff --git a/src/mxlibs3/non_standard.py b/daq/src/mxlibs3/non_standard.py similarity index 100% rename from src/mxlibs3/non_standard.py rename to daq/src/mxlibs3/non_standard.py diff --git a/src/mxlibs3/pshell_client.py b/daq/src/mxlibs3/pshell_client.py similarity index 100% rename from src/mxlibs3/pshell_client.py rename to daq/src/mxlibs3/pshell_client.py diff --git a/src/mxlibs3/smargon.py b/daq/src/mxlibs3/smargon.py similarity index 100% rename from src/mxlibs3/smargon.py rename to daq/src/mxlibs3/smargon.py diff --git a/src/mxlibs3/tell_client.py b/daq/src/mxlibs3/tell_client.py similarity index 100% rename from src/mxlibs3/tell_client.py rename to daq/src/mxlibs3/tell_client.py diff --git a/src/mxlibs3/workflow_tools.py b/daq/src/mxlibs3/workflow_tools.py similarity index 100% rename from src/mxlibs3/workflow_tools.py rename to daq/src/mxlibs3/workflow_tools.py diff --git a/gui/pyproject.toml b/gui/pyproject.toml new file mode 100644 index 00000000..4703aaab --- /dev/null +++ b/gui/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "aaregui" +version = "0.1.0" +description = "Beamline control GUI" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "pyjwt==2.10.1", + "pyzmq==26.4.0", + "opencv-python-headless==4.11.0.86", + "PySide6==6.9.0", + "aaredaqlib==0.1.0" +] + +[lint] +ignore = ["F401", "F541", "W503", "W504"] + +[build-system] +requires = ["setuptools>=75.6.0"] +build-backend = "setuptools.build_meta" diff --git a/gui/src/aaregui/__init__.py b/gui/src/aaregui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gui/src/aaregui/main.py b/gui/src/aaregui/main.py new file mode 100644 index 00000000..abe59fff --- /dev/null +++ b/gui/src/aaregui/main.py @@ -0,0 +1,20 @@ +import sys + +from PySide6.QtWidgets import QApplication, QDialog + +from aaregui import MainWindow +from aaregui import LoginDialog + +if __name__ == "__main__": + app = QApplication(sys.argv) + + base_url = "http://0.0.0.0:5210" + dialog = LoginDialog(base_url=base_url) + + if dialog.exec() == QDialog.DialogCode.Accepted and dialog.token: + win = MainWindow(base_url=base_url, token=dialog.token) + win.show() + sys.exit(app.exec()) + else: + print("Cancelled") + sys.exit() diff --git a/gui/src/aaregui/main_window.py b/gui/src/aaregui/main_window.py new file mode 100644 index 00000000..91b0d74f --- /dev/null +++ b/gui/src/aaregui/main_window.py @@ -0,0 +1,200 @@ +import jwt +from PySide6.QtCore import Qt, Slot, Signal +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( + QMainWindow, + QWidget, + QSplitter, + QHBoxLayout, + QVBoxLayout, + QMessageBox, + QApplication, ) +from aaredaqlib.coordinate import Coordinate, SmargonCoordinate +from aaredaqlib.models import SampleShortInfoList, TokenData +from aaredaqlib.sample_geometry import SampleGeometryModel + +from aaregui import BeamlineControls +from aaregui import DataCollectionSettings +from aaregui import SampleQueuePanel +from aaregui import StatusPanel +from aaregui import TellSamplePanel +from aaregui import SampleCameraThread +from aaregui import DAQWorker +from aaregui import SampleCameraImageLabel +from aaregui import NoWheelScrollArea +from aaregui import StatusBar + + +# Subclass QMainWindow to define the behavior of your application +class MainWindow(QMainWindow): + sample_geometry = Signal(SampleGeometryModel) + + def __init__(self, base_url: str, token: str): + super().__init__() + self.__base_url = base_url + self.__token = token + + # Decode the JWT without signature verification + self.__decoded_token = TokenData(**jwt.decode(token, options={"verify_signature": False})) + print(self.__decoded_token) + + self.setStyleSheet("background-color: rgb(216, 228, 253);") + + top_widget = QWidget(parent=self) + top_widget_layout = QHBoxLayout(top_widget) + top_widget.setLayout(top_widget_layout) + + geom = SampleGeometryModel(beam_location_pxl=Coordinate(x=1000,y=1000), + pixel_in_mm=1, + aerotech=Coordinate(), + smargon=SmargonCoordinate(sh_mm=Coordinate(), phi_deg=0, chi_deg=0), + omega_deg=0, + beam_size_mm=Coordinate(x=1, y=1), + aerotech_meas=Coordinate() + ) + rasters = [] + + self.data_collection = DataCollectionSettings(s=geom, parent=top_widget) + top_widget_layout.addWidget(self.data_collection) + + self.camera_image = SampleCameraImageLabel( + geom=geom, rasters=rasters, parent=top_widget + ) + + top_widget_layout.addWidget(self.camera_image) + + beamline_controls_scroll = NoWheelScrollArea(top_widget) + + self.beamline = BeamlineControls(beamline_controls_scroll, staff=self.__decoded_token.staff) + top_widget_layout.addWidget(beamline_controls_scroll) + beamline_controls_scroll.setWidget(self.beamline) + beamline_controls_scroll.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10) + bottom_widget_splitter = QSplitter(Qt.Orientation.Horizontal, parent=self) + + bottom_widget = QWidget(self) + + self.tell_samples = TellSamplePanel(parent=bottom_widget, samples=SampleShortInfoList(s = [])) + bottom_widget_splitter.addWidget(self.tell_samples) + self.job_list_panel = SampleQueuePanel(parent=bottom_widget) + bottom_widget_splitter.addWidget(self.job_list_panel) + + self.status_panel = StatusPanel(parent=bottom_widget) + bottom_widget_splitter.addWidget(self.status_panel) + + bottom_widget_layout = QVBoxLayout(bottom_widget) + bottom_widget.setLayout(bottom_widget_layout) + bottom_widget_layout.addWidget(bottom_widget_splitter) + + splitter = QSplitter(Qt.Orientation.Vertical, parent=self) + splitter.addWidget(top_widget) + splitter.addWidget(bottom_widget) + + self.setCentralWidget(splitter) + + self.setWindowTitle("Aare macromolecular crystallography GUI") + self.create_menu_bar() + + self.status_bar = StatusBar(self.__decoded_token, parent=self) + self.setStatusBar(self.status_bar) + + self.daq = DAQWorker(base_url=self.__base_url, token=self.__token) + self.daq.spreadsheet.connect(self.tell_samples.new_sample_list) + self.daq.update.connect(self.beamline.omega_panel.update_daq_status) + self.daq.update.connect(self.beamline.smargon_panel.update_daq_status) + self.daq.update.connect(self.data_collection.helical.update_daq_status) + self.daq.update.connect(self.beamline.illumination_panel.update_daq_status) + self.daq.update.connect(self.status_panel.update_daq_status) + self.daq.update.connect(self.camera_image.update_daq_status) + self.daq.update.connect(self.tell_samples.update_daq_status) + self.daq.update.connect(self.beamline.abr_tweak.update_daq_status) + self.daq.update.connect(self.status_bar.update_daq_status) + self.beamline.state_panel.sample_exchange.clicked.connect(self.daq.sample_exchange) + self.beamline.state_panel.sample_alignment.clicked.connect(self.daq.sample_alignment) + self.beamline.state_panel.dewar_exchange.clicked.connect(self.daq.dewar_exchange) + self.beamline.loopctr.background.clicked.connect(self.daq.alc_background) + self.beamline.loopctr.find_tip.clicked.connect(self.daq.center_loop) + self.camera_image.smargon.connect(self.daq.move_smargon) + self.beamline.smargon_panel.smargon.connect(self.daq.move_smargon) + + self.beamline.omega_panel.set_omega.connect(self.daq.set_omega) + self.camera_image.set_omega.connect(self.daq.set_omega) + self.beamline.zoom_panel.zoom.connect(self.daq.zoom) + self.beamline.illumination_panel.light.connect(self.daq.light) + self.beamline.abr_tweak.abr_tweak.connect(self.daq.abr_tweak) + self.beamline.abr_tweak.abr_save.connect(self.daq.abr_save) + + self.beamline.beam_center.beam_center_clear.connect(self.daq.beam_center_clear) + self.camera_image.update_beam_center.connect(self.daq.beam_center_mark) + + self.camera_thread = SampleCameraThread(zmq_url="tcp://129.129.110.12:9089") + self.camera_thread.camera_image.connect(self.camera_image.update_pixmap) + self.camera_thread.start() + QApplication.instance().aboutToQuit.connect(self.cleanup) + + # +# self.data_collection.helical.helical_scan.connect(self.worker.helical_scan) + # self.data_collection.helical.update_bookmarks.connect( + # self.camera_image.update_bookmarks + # ) + # + + self.camera_image.run_raster.connect(self.daq.raster_scan) + self.data_collection.standard_data_collection.connect(self.daq.standard_scan) + self.data_collection.screening_data_collection.connect(self.daq.screening_scan) + + self.data_collection.helical.show_helical_start.connect(self.camera_image.show_helical_start) + self.data_collection.helical.show_helical_end.connect(self.camera_image.show_helical_end) + + self.camera_image.set_helical_start.connect(self.data_collection.helical.set_start_point) + self.camera_image.set_helical_end.connect(self.data_collection.helical.set_end_point) + self.job_list_panel.auto_scan.connect(self.daq.automated_scan) + self.daq.automated_scan_done.connect(self.job_list_panel.automated_scan_done) + self.job_list_panel.unmount.connect(self.daq.unmount) + + self.tell_samples.mount.connect(self.daq.mount) + self.tell_samples.unmount.connect(self.daq.unmount) + + self.data_collection.raster.grid_size_updated.connect( + self.camera_image.update_raster_grid_size + ) + self.status_bar.set_pgroup.connect(self.daq.set_pgroup) + self.status_bar.end_session.connect(self.daq.end_session) + self.status_bar.force_session.connect(self.daq.force_session) + self.status_bar.dewar_exchange.connect(self.daq.dewar_exchange) + self.status_bar.sample_exchange.connect(self.daq.sample_exchange) + self.status_bar.sample_alignment.connect(self.daq.sample_alignment) + + def create_menu_bar(self): + """Create a menu bar with File->Quit and Help->About.""" + # Main menu bar + menu_bar = self.menuBar() + + # File menu + file_menu = menu_bar.addMenu("File") + quit_action = QAction("&Quit", self) # Action for 'Quit' + quit_action.triggered.connect(self.close) # Connect to window's `close` method + file_menu.addAction(quit_action) + + # Help menu + help_menu = menu_bar.addMenu("Help") + about_action = QAction("About", self) # Action for 'About' + about_action.triggered.connect(self.show_about_dialog) + help_menu.addAction(about_action) + + def show_about_dialog(self): + QMessageBox.about( + self, + "About", + "Aare Macromolecular Crystallography GUI\nVersion: 1.0\nCopyright: Paul Scherrer Insitute 2024-2025", + ) + + @Slot(str) + def display_error(self, msg: str): + self.status_bar.setStyleSheet("color: red;") + self.status_bar.showMessage(f' {msg} ', 10000) + + def cleanup(self): + self.camera_thread.stop() diff --git a/gui/src/aaregui/models/__init__.py b/gui/src/aaregui/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gui/src/aaregui/models/bookmark.py b/gui/src/aaregui/models/bookmark.py new file mode 100644 index 00000000..eff95c59 --- /dev/null +++ b/gui/src/aaregui/models/bookmark.py @@ -0,0 +1,27 @@ +from typing import Literal + +from PySide6.QtGui import QColor + +from aaredaqlib.coordinate import SmargonCoordinate + + +class SmargonBookmark: + coord: SmargonCoordinate + color: Literal[ + "red", "green", "blue", "indigo", "lime" + ] # Restrict to specific colors + + def qt_color(self) -> QColor: + """Convert the color property to a QColor.""" + color_map = { + "red": QColor("red"), + "green": QColor("green"), + "blue": QColor("blue"), + "indigo": QColor("indigo"), + "lime": QColor("lime"), + } + return color_map[self.color] # Map the color string to QColor + + +class SmargonBookmarkList: + b: list[SmargonBookmark] diff --git a/gui/src/aaregui/models/sample_queue_model.py b/gui/src/aaregui/models/sample_queue_model.py new file mode 100644 index 00000000..58030236 --- /dev/null +++ b/gui/src/aaregui/models/sample_queue_model.py @@ -0,0 +1,119 @@ +from PySide6.QtCore import QAbstractTableModel, Qt, Slot +from PySide6.QtGui import QBrush, QColor + +from aaredaqlib.models import SampleShortInfo, SampleShortInfoList + + +def get_entry(sample: SampleShortInfo, column: int): + if column == 0: + return sample.user + elif column == 1: + return sample.puck_name + elif column == 2: + return sample.sample_name + + +class SampleQueueSpreadsheet(QAbstractTableModel): + def __init__( + self, + parent=None, + samples: list[SampleShortInfo] | None = None + ): + super().__init__(parent) + if samples is None: + samples = [] + self.__running = False + self.samples: list[SampleShortInfo] = samples + self.header = [ + "User", + "Puck", + "Sample", + ] + + def flags(self, index): + default_flags = super().flags(index) + return Qt.ItemFlag.ItemIsDropEnabled | default_flags + + def rowCount(self, parent=None): + return len(self.samples) + + def columnCount(self, parent=None): + return len(self.header) + + def data(self, index, role=None): + if role == Qt.ItemDataRole.DisplayRole: + return get_entry(self.samples[index.row()], index.column()) + elif role == Qt.ItemDataRole.TextAlignmentRole: # Align text to center + return Qt.AlignmentFlag.AlignCenter + elif role == Qt.ItemDataRole.BackgroundRole: + if index.row() == 0: + if self.__running: + return QBrush(QColor(255, 102, 0)) # Fluorescent orange + else: + return QBrush(QColor(114, 159, 207)) # Dark blue + return QBrush(QColor(255, 255, 255)) # White + return None # For other roles, return None + + def headerData(self, section, orientation, role=None): + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: # Column header + return self.header[section] if self.header else f"Column {section + 1}" + if orientation == Qt.Orientation.Vertical: # Row header + return str(section + 1) # Row numbers start from 1 + return None + + def updateData(self, samples: list[SampleShortInfo],): + self.beginResetModel() + self.samples = samples + self.endResetModel() + + def mimeTypes(self): + return ['text/plain'] + + def canDropMimeData(self, data, action, row, column, parent): + if data.hasText(): + return True + return False + + def dropMimeData(self, data, action, row, column, parent): + if not self.canDropMimeData(data, action, row, column, parent): + return False + + if parent.isValid(): + row = parent.row() + + try: + l = SampleShortInfoList.model_validate_json(data.text()) + self.beginResetModel() + for sample in l.s: + updated_row = row + updated_samples = [] + for i in range(len(self.samples)): + if sample != self.samples[i]: + updated_samples.append(self.samples[i]) + else: + if 0 < row < i: + updated_row -= 1 + if updated_row == -1: + updated_samples.append(sample) + else: + updated_samples.insert(updated_row, sample) + self.samples = updated_samples + self.endResetModel() + except Exception as e: + print(e) + return False + return True + + def set_running(self, running: bool): + self.__running = running + + def remove_sample(self, db_id: int): + self.beginResetModel() + self.samples = list(filter(lambda x: x.db_id != db_id, self.samples)) + self.endResetModel() + + def clearSamples(self): + self.beginResetModel() + self.samples = [] + self.endResetModel() \ No newline at end of file diff --git a/gui/src/aaregui/models/user_sample_model.py b/gui/src/aaregui/models/user_sample_model.py new file mode 100644 index 00000000..6c436a9e --- /dev/null +++ b/gui/src/aaregui/models/user_sample_model.py @@ -0,0 +1,139 @@ +from PySide6.QtCore import QAbstractTableModel, Qt, QMimeData +from PySide6.QtGui import QBrush, QColor + +from aaredaqlib.models import SampleShortInfo, SampleShortInfoList + + +def get_entry(sample: SampleShortInfo, column: int): + if column == 0: + return sample.sample_name + elif column == 1: + return sample.puck_name + elif column == 2: + return sample.dewar_name + elif column == 3: + return sample.loc_str() + elif column == 4: + return sample.priority + elif column == 5: + return sample.user + elif column == 6: + return sample.mount_count + elif column == 7: + return sample.comment + + + +class UserSampleSpreadsheet(QAbstractTableModel): + def __init__( + self, + parent=None, + samples: list[SampleShortInfo] | None = None, + current_puck: str | None = None, + current_sample: int | None = None, + ): + super().__init__(parent) + if samples is None: + samples = [] + self.samples: list[SampleShortInfo] = samples + self.header = [ + "Sample name", + "Puck", + "Dewar", + "Location", + "Priority", + "User", + "Mount count", + "Comment", + ] + self.current_sample = current_sample + self.current_puck = current_puck + + def rowCount(self, parent=None): + return len(self.samples) + + def columnCount(self, parent=None): + return len(self.header) + + def data(self, index, role=None): + if role == Qt.ItemDataRole.DisplayRole: + return get_entry(self.samples[index.row()], index.column()) + elif role == Qt.ItemDataRole.TextAlignmentRole: # Align text to center + return Qt.AlignmentFlag.AlignCenter + elif role == Qt.ItemDataRole.BackgroundRole: + if self.samples[index.row()].db_id == self.current_sample: + return QBrush(QColor(114, 159, 207)) # darker blue + if self.samples[index.row()].puck_name == self.current_puck: + return QBrush(QColor(216, 228, 253)) # light blue + return QBrush(QColor(255, 255, 255)) # White + return None # For other roles, return None + + def headerData(self, section, orientation, role=None): + if role == Qt.ItemDataRole.DisplayRole: + if orientation == Qt.Orientation.Horizontal: # Column header + return self.header[section] if self.header else f"Column {section + 1}" + if orientation == Qt.Orientation.Vertical: # Row header + return str(section + 1) # Row numbers start from 1 + return None + + def updateCurrentSample( + self, current_puck: str | None = None, current_sample: int | None = None + ): + self.current_puck = current_puck + self.current_sample = current_sample + + def updateData( + self, + samples: list[SampleShortInfo] + ): + if self.samples != samples: + self.beginResetModel() + self.samples = samples + self.endResetModel() + + def sort(self, column, order): + self.layoutAboutToBeChanged.emit() + + if column == 3: + self.samples = sorted( + self.samples, + key=lambda row: row.loc_str_sort(), + reverse=( + order == Qt.SortOrder.DescendingOrder + ), # Reverse for descending order + ) + else: + # Sort the samples based on the specified column and order + self.samples = sorted( + self.samples, + key=lambda row: get_entry(row, column), + reverse=( + order == Qt.SortOrder.DescendingOrder + ), # Reverse for descending order + ) + + # Notify views that the model layout has changed + self.layoutChanged.emit() + + def flags(self, index): + default_flags = super().flags(index) + if index.isValid(): + return Qt.ItemFlag.ItemIsDragEnabled | default_flags + return default_flags + + def mimeTypes(self): + return ['text/plain'] + + def mimeData(self, indexes): + mime_data = QMimeData() + + l = SampleShortInfoList(s=[]) + + for i in set(index.row() for index in indexes): + l.s.append(self.samples[i]) + + mime_data.setText(l.model_dump_json()) + return mime_data + + def get_id(self, row: int) -> SampleShortInfo: + return self.samples[row] diff --git a/gui/src/aaregui/panels/__init__.py b/gui/src/aaregui/panels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gui/src/aaregui/panels/abr_tweak_panel.py b/gui/src/aaregui/panels/abr_tweak_panel.py new file mode 100644 index 00000000..310f4bee --- /dev/null +++ b/gui/src/aaregui/panels/abr_tweak_panel.py @@ -0,0 +1,129 @@ +from PySide6.QtCore import Signal, Slot +from PySide6.QtGui import Qt +from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QPushButton +from aaredaqlib.coordinate import Coordinate +from aaredaqlib.models import DAQStatusModel + +from aaregui import ButtonWithPayload +from aaregui import NumberLineEdit +from aaregui import TitleLabel + + +class AbrTweakButtons(QWidget): + abr_tweak = Signal(Coordinate) + + def __init__(self, parent=None): + super().__init__(parent) + + self.__step = 0.1 + grid_layout = QGridLayout(self) + grid_layout.setColumnStretch(0, 1) + grid_layout.setColumnStretch(1, 0) + grid_layout.setColumnStretch(2, 0) + grid_layout.setColumnStretch(3, 3) + + grid_layout.addWidget(QLabel("GMX"), 0, 0) + button_gmx_minus = ButtonWithPayload("←", payload={"x": -1, "y": 0, "z": 0}) + button_gmx_plus = ButtonWithPayload("→", payload={"x": 1, "y": 0, "z": 0}) + grid_layout.addWidget(button_gmx_minus, 0, 1) + grid_layout.addWidget(button_gmx_plus, 0, 2) + + grid_layout.addWidget(QLabel("GMY"), 1, 0) + button_gmy_minus = ButtonWithPayload("←", payload={"x": 0, "y": -1, "z": 0}) + button_gmy_plus = ButtonWithPayload("→", payload={"x": 0, "y": 1, "z": 0}) + grid_layout.addWidget(button_gmy_minus, 1, 1) + grid_layout.addWidget(button_gmy_plus, 1, 2) + + grid_layout.addWidget(QLabel("GMZ"), 2, 0) + button_gmz_minus = ButtonWithPayload("←", payload={"x": 0, "y": 0, "z": -1}) + button_gmz_plus = ButtonWithPayload("→", payload={"x": 0, "y": 0, "z": 1}) + grid_layout.addWidget(button_gmz_minus, 2, 1) + grid_layout.addWidget(button_gmz_plus, 2, 2) + + button_gmx_minus.pressed.connect(self.abr_button) + button_gmx_plus.pressed.connect(self.abr_button) + button_gmy_minus.pressed.connect(self.abr_button) + button_gmy_plus.pressed.connect(self.abr_button) + button_gmz_minus.pressed.connect(self.abr_button) + button_gmz_plus.pressed.connect(self.abr_button) + + button_gmx_minus.setFixedWidth(32) + button_gmy_minus.setFixedWidth(32) + button_gmz_minus.setFixedWidth(32) + button_gmx_plus.setFixedWidth(32) + button_gmy_plus.setFixedWidth(32) + button_gmz_plus.setFixedWidth(32) + self.gmx_label = QLabel("0.000") + grid_layout.addWidget(self.gmx_label, 0, 3) + self.gmx_label.setAlignment(Qt.AlignmentFlag.AlignRight) + self.gmy_label = QLabel("0.000") + self.gmy_label.setAlignment(Qt.AlignmentFlag.AlignRight) + grid_layout.addWidget(self.gmy_label, 1, 3) + self.gmz_label = QLabel("0.000") + self.gmz_label.setAlignment(Qt.AlignmentFlag.AlignRight) + grid_layout.addWidget(self.gmz_label, 2, 3) + + @Slot(dict) + def abr_button(self, payload: dict): + self.abr_tweak.emit(Coordinate(x=self.__step * payload["x"], y=self.__step * payload["y"], z=self.__step * payload["z"])) + + @Slot(float) + def set_step(self, val_mm: float): + print(self.__step) + print(val_mm) + self.__step = val_mm / 1000.0 + +class AbrTweakWidget(QWidget): + abr_tweak = Signal(Coordinate) + abr_save = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + grid_layout = QGridLayout(self) + grid_layout.setColumnStretch(0, 0) + grid_layout.setColumnStretch(1, 0) + grid_layout.setColumnStretch(2, 1) + + grid_layout.addWidget(TitleLabel("ABR meas. pos.", self), 0, 0, 1, 3) + + self.__abr_buttons = AbrTweakButtons(parent=self) + grid_layout.addWidget(self.__abr_buttons, 1, 0, 1 ,4) + self.__abr_buttons.abr_tweak.connect(self.abr_button_pressed) + + grid_layout.addWidget(QLabel("Step", parent=self), 2, 0) + self.__step = NumberLineEdit(1, 1000, 100, 0, parent=self) + grid_layout.addWidget(self.__step, 2, 1) + grid_layout.addWidget(QLabel("μm", parent=self), 2, 2) + self.__step.newValue.connect(self.__abr_buttons.set_step) + + save_button = QPushButton("Save ABR pos.") + grid_layout.addWidget(save_button, 3, 0, 1, 3) + save_button.pressed.connect(self.save_button_pressed) + + @Slot(Coordinate) + def abr_button_pressed(self, c: Coordinate): + self.abr_tweak.emit(c) + + @Slot() + def save_button_pressed(self): + self.abr_save.emit() + + @Slot(DAQStatusModel) + def update_daq_status(self, s: DAQStatusModel): + self.__abr_buttons.gmx_label.setText(f"{s.geom.aerotech_meas.x:.3f}") + if abs(s.geom.aerotech.x) >= 0.001: + self.__abr_buttons.gmx_label.setStyleSheet("color: rgb(255, 0, 0);") + else: + self.__abr_buttons.gmx_label.setStyleSheet("color: rgb(0, 0, 0);") + self.__abr_buttons.gmy_label.setText(f"{s.geom.aerotech_meas.y:.3f}") + if abs(s.geom.aerotech.y) >= 0.001: + self.__abr_buttons.gmy_label.setStyleSheet("color: rgb(255, 0, 0);") + else: + self.__abr_buttons.gmy_label.setStyleSheet("color: rgb(0, 0, 0);") + + self.__abr_buttons.gmz_label.setText(f"{s.geom.aerotech_meas.z:.3f}") + if abs(s.geom.aerotech.z) >= 0.001: + self.__abr_buttons.gmz_label.setStyleSheet("color: rgb(255, 0, 0);") + else: + self.__abr_buttons.gmz_label.setStyleSheet("color: rgb(0, 0, 0);") diff --git a/gui/src/aaregui/panels/beam_center_panel.py b/gui/src/aaregui/panels/beam_center_panel.py new file mode 100644 index 00000000..720befb1 --- /dev/null +++ b/gui/src/aaregui/panels/beam_center_panel.py @@ -0,0 +1,23 @@ +from PySide6.QtCore import Signal, Slot +from PySide6.QtWidgets import QWidget, QGridLayout, QPushButton + +from aaregui import TitleLabel + + +class BeamCenterWidget(QWidget): + beam_center_clear = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + grid_layout = QGridLayout(self) + + grid_layout.addWidget(TitleLabel("Beam center", self), 0, 0) + + clear_button = QPushButton("Clear marks") + grid_layout.addWidget(clear_button, 1, 0) + clear_button.pressed.connect(self.clear_button_pressed) + + @Slot() + def clear_button_pressed(self): + self.beam_center_clear.emit() diff --git a/gui/src/aaregui/panels/beamline_controls.py b/gui/src/aaregui/panels/beamline_controls.py new file mode 100644 index 00000000..12b08320 --- /dev/null +++ b/gui/src/aaregui/panels/beamline_controls.py @@ -0,0 +1,49 @@ +from PySide6.QtWidgets import QFrame, QVBoxLayout + +from aaregui import AbrTweakWidget +from aaregui import BeamCenterWidget +from aaregui import BeamlineStatePanel +from aaregui import IlluminationPanel +from aaregui import LoopCenteringPanel +from aaregui import OmegaPanel +from aaregui import SmargonPanel +from aaregui import ZoomPanel + + +class BeamlineControls(QFrame): + set_width = 250 + + def __init__(self, parent=None, staff : bool = True): + super().__init__(parent) + self.setFixedWidth(self.set_width) + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + + self.v_layout = QVBoxLayout(self) + self.zoom_panel = ZoomPanel(self) + self.v_layout.addWidget(self.zoom_panel) + + self.illumination_panel = IlluminationPanel(self) + self.v_layout.addWidget(self.illumination_panel) + + self.omega_panel = OmegaPanel(parent=self) + self.v_layout.addWidget(self.omega_panel) + + self.smargon_panel = SmargonPanel(parent=self) + self.v_layout.addWidget(self.smargon_panel) + + self.loopctr = LoopCenteringPanel(self) + self.v_layout.addWidget(self.loopctr) + + self.state_panel = BeamlineStatePanel(self) + self.v_layout.addWidget(self.state_panel) + + self.abr_tweak = AbrTweakWidget(self) + self.beam_center = BeamCenterWidget(self) + + if staff: + self.v_layout.addWidget(self.abr_tweak) + self.v_layout.addWidget(self.beam_center) + + self.v_layout.addStretch() + self.setLayout(self.v_layout) diff --git a/gui/src/aaregui/panels/beamline_state_panel.py b/gui/src/aaregui/panels/beamline_state_panel.py new file mode 100644 index 00000000..269691de --- /dev/null +++ b/gui/src/aaregui/panels/beamline_state_panel.py @@ -0,0 +1,20 @@ +from PySide6.QtWidgets import QWidget, QGridLayout, QPushButton + +from aaregui import TitleLabel + + +class BeamlineStatePanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + grid_layout = QGridLayout(self) + + grid_layout.addWidget(TitleLabel("Beamline state", self), 0, 0) + + self.sample_exchange = QPushButton("Sample exchange", parent=self) + grid_layout.addWidget(self.sample_exchange, 1, 0) + + self.sample_alignment = QPushButton("Sample alignment", parent=self) + grid_layout.addWidget(self.sample_alignment, 2, 0) + + self.dewar_exchange = QPushButton("Load/unload samples", parent=self) + grid_layout.addWidget(self.dewar_exchange, 3, 0) diff --git a/gui/src/aaregui/panels/data_collection_settings.py b/gui/src/aaregui/panels/data_collection_settings.py new file mode 100644 index 00000000..3f78866b --- /dev/null +++ b/gui/src/aaregui/panels/data_collection_settings.py @@ -0,0 +1,118 @@ +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QFrame, + QVBoxLayout, + QTabWidget, + QGridLayout, + QPushButton, + QWidget, +) +from aaredaqlib.helical_geometry import HelicalScanRequest +from aaredaqlib.rotation_scan import RotationScanRequest +from aaredaqlib.sample_geometry import SampleGeometryModel +from aaredaqlib.screening_scan import ScreeningScanRequest + +from aaregui import FilePathPanel +from aaregui import HelicalDataCollectionPanel +from aaregui import RasterDataCollectionPanel +from aaregui import ScreeningDataCollectionPanel +from aaregui import StandardDataCollectionPanel +from aaregui import TitleLabel + + +class DataCollectionSettings(QFrame): + standard_data_collection = Signal(RotationScanRequest) + helical_data_collection = Signal(HelicalScanRequest) + screening_data_collection = Signal(ScreeningScanRequest) + + def __init__(self, s: SampleGeometryModel, parent=None): + super().__init__(parent) + self.setFixedWidth(400) + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + + self.grid_layout = QGridLayout(self) + self.file_path_panel = FilePathPanel(self) + self.grid_layout.addWidget(self.file_path_panel, 0, 0, 1, 2) + + widget = QWidget(self) + v_layout = QVBoxLayout(widget) + widget.setLayout(v_layout) + + v_layout.addWidget(TitleLabel("Data collection settings")) + + self.tab_widget = QTabWidget(parent=widget) + + self.raster = RasterDataCollectionPanel(parent=self.tab_widget) + self.tab_widget.addTab(self.raster, "Raster") + + self.screening = ScreeningDataCollectionPanel(parent=self.tab_widget) + self.tab_widget.addTab(self.screening, "Screening") + + self.standard = StandardDataCollectionPanel(parent=self.tab_widget) + self.tab_widget.addTab(self.standard, "Standard") + + self.helical = HelicalDataCollectionPanel(s=s, parent=self.tab_widget) + self.tab_widget.addTab(self.helical, "Helical") + + v_layout.addWidget(self.tab_widget) + v_layout.addStretch() + self.grid_layout.addWidget(widget, 1, 0, 1, 2) + + self.start_button = QPushButton("Start") + self.start_button.setStyleSheet("color: rgb(78, 154, 6);") + self.grid_layout.addWidget(self.start_button, 3, 0) + self.start_button.clicked.connect(self.run_data_collection) + + self.abort_button = QPushButton("Abort") + self.abort_button.setStyleSheet("color: rgb(164, 0, 0);") + self.grid_layout.addWidget(self.abort_button, 3, 1) + + def run_data_collection(self): + current_tab = self.tab_widget.currentWidget() + + if current_tab == self.raster: + print("Raster tab is active.") + # Add specific action for Raster tab here + elif current_tab == self.screening: + data = self.screening.screening_type.itemData( + self.screening.screening_type.currentIndex() + ) + + if data is None: + print("No screening type selected.") + return + + s = ScreeningScanRequest( + + + + omega_start_deg=self.screening.start_angle.value, + exp_time_s=self.screening.image_time_enter.value, + omega_step_deg=data["omega_step_deg"], + steps=data["steps"], + oscrange_deg=0.1 + ) + self.screening_data_collection.emit(s) + elif current_tab == self.standard: + total_angle = self.standard.total_angle.value + image_angle = self.standard.image_angle.value + if total_angle <= 0 or total_angle < image_angle: + print("Total angle is not valid.") + return + + image_number = round(total_angle / image_angle) + total_time = image_number * self.standard.image_time_enter.value + + r = RotationScanRequest( + start_omega_deg=self.standard.start_angle.value, + total_omega_deg=total_angle, + total_time_sec=total_time + ) + self.standard_data_collection.emit(r) + elif current_tab == self.helical: + r = self.helical.get_request() + if r is not None: + self.helical_data_collection.emit(r) + else: + print("No matching tab found.") diff --git a/gui/src/aaregui/panels/file_path_panel.py b/gui/src/aaregui/panels/file_path_panel.py new file mode 100644 index 00000000..08fd494f --- /dev/null +++ b/gui/src/aaregui/panels/file_path_panel.py @@ -0,0 +1,21 @@ +from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QLineEdit + +from aaregui import TitleLabel + + +class FilePathPanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + grid_layout = QGridLayout(self) + + grid_layout.addWidget(TitleLabel("Dataset path", self), 0, 0, 1, 2) + + grid_layout.addWidget(QLabel("File prefix", parent=self), 1, 0) + self.file_prefix_edit = QLineEdit(parent=self) + self.file_prefix_edit.setStyleSheet("background-color: rgb(255, 255, 255);") + grid_layout.addWidget(self.file_prefix_edit, 1, 1) + + grid_layout.addWidget(QLabel("Directory", parent=self), 2, 0) + self.directory_edit = QLineEdit(parent=self) + self.directory_edit.setStyleSheet("background-color: rgb(255, 255, 255);") + grid_layout.addWidget(self.directory_edit, 2, 1) diff --git a/gui/src/aaregui/panels/helical_scan_panel.py b/gui/src/aaregui/panels/helical_scan_panel.py new file mode 100644 index 00000000..f1dab84e --- /dev/null +++ b/gui/src/aaregui/panels/helical_scan_panel.py @@ -0,0 +1,115 @@ +from PySide6.QtCore import Signal, Slot +from PySide6.QtWidgets import ( + QWidget, + QGridLayout, + QLabel, + QSizePolicy, + QSpacerItem, + QPushButton, +) +from aaredaqlib.coordinate import SmargonCoordinate +from aaredaqlib.helical_geometry import HelicalScanRequest +from aaredaqlib.models import DAQStatusModel +from aaredaqlib.sample_geometry import SampleGeometryModel + +from aaregui import NumberLineEdit + + +class HelicalDataCollectionPanel(QWidget): + helical_scan = Signal(HelicalScanRequest) + show_helical_start = Signal(SmargonCoordinate) + show_helical_end = Signal(SmargonCoordinate) + + def __init__(self, s: SampleGeometryModel, parent=None): + super().__init__(parent) + grid_layout = QGridLayout(self) + + self.__geom = s + self.__steps = 100 + + self.__image_time = 0.005 + self.__image_angle = 0.1 + self.__total_angle = 360.0 + + self.__start = SmargonCoordinate() + self.__end = SmargonCoordinate() + + start_button = QPushButton("Save start point", parent=self) + start_button.clicked.connect(self.set_start_point_curr) + grid_layout.addWidget(start_button, 0, 0, 1, 3) + + end_button = QPushButton("Save end point", parent=self) + end_button.clicked.connect(self.set_end_point_curr) + grid_layout.addWidget(end_button, 1, 0, 1, 3) + + grid_layout.addWidget(QLabel("Start angle", parent=self), 2, 0) + self.start_angle = NumberLineEdit(-720, 719.0, 0, decimals=3, parent=self) + grid_layout.addWidget(self.start_angle, 2, 1) + grid_layout.addWidget(QLabel("°", parent=self), 2, 2) + + grid_layout.addWidget(QLabel("Total angle", parent=self), 3, 0) + self.total_angle = NumberLineEdit( + 0, 999.0, self.__total_angle, decimals=3, parent=self + ) + grid_layout.addWidget(self.total_angle, 3, 1) + grid_layout.addWidget(QLabel("°", parent=self), 3, 2) + + grid_layout.addWidget(QLabel("Image angle", parent=self), 4, 0) + self.image_angle = NumberLineEdit( + 0, 10.0, self.__image_angle, decimals=3, parent=self + ) + grid_layout.addWidget(self.image_angle, 4, 1) + grid_layout.addWidget(QLabel("°", parent=self), 4, 2) + + grid_layout.addWidget(QLabel("Image time", parent=self), 5, 0) + self.image_time_enter = NumberLineEdit( + 0.0005, 10.0, self.__image_time, decimals=4, parent=self + ) + grid_layout.addWidget(self.image_time_enter, 5, 1) + grid_layout.addWidget(QLabel("s", parent=self), 5, 2) + + horizontal_spacer = QSpacerItem( + 40, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding + ) + grid_layout.addItem(horizontal_spacer, 6, 0, 1, 3) + + @Slot(DAQStatusModel) + def update_daq_status(self, s: DAQStatusModel): + self.__geom = s.geom + + @Slot() + def set_end_point_curr(self): + self.set_end_point(self.__geom.smargon) + + @Slot() + def set_start_point_curr(self): + self.set_start_point(self.__geom.smargon) + + @Slot(SmargonCoordinate) + def set_start_point(self, s: SmargonCoordinate): + self.__start = s + if s.sh_mm is not None: + self.start_angle.update_value(self.__geom.omega_deg) + self.show_helical_start.emit(self.__start) + + @Slot(SmargonCoordinate) + def set_end_point(self, s: SmargonCoordinate): + self.__end = s + self.show_helical_end.emit(self.__end) + + + def get_request(self) -> HelicalScanRequest | None: + if self.__start.sh_mm is None or self.__end.sh_mm is None: + return + + omega_step = self.total_angle.value / self.__steps + h = HelicalScanRequest( + start=self.__start, + end=self.__end, + omega_start=self.start_angle.value, + total_omega=self.total_angle.value, + time_sec=self.image_time_enter.value + * (self.total_angle.value / self.image_angle.value), + steps=self.__steps, + ) + return h diff --git a/gui/src/aaregui/panels/illumination_panel.py b/gui/src/aaregui/panels/illumination_panel.py new file mode 100644 index 00000000..04d4f7e3 --- /dev/null +++ b/gui/src/aaregui/panels/illumination_panel.py @@ -0,0 +1,38 @@ +from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtWidgets import QWidget, QGridLayout, QSlider +from aaredaqlib.models import DAQStatusModel + +from aaregui import TitleLabel + + +class IlluminationPanel(QWidget): + light = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + grid_layout = QGridLayout(self) + + grid_layout.addWidget(TitleLabel("Front light", self), 0, 0, 1, 2) + + self.is_sliding = False + + self.slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self) + self.slider.setRange(0, 100) + self.slider.sliderPressed.connect(self.on_slider_pressed) + self.slider.sliderReleased.connect(self.on_slider_released) + + grid_layout.addWidget(self.slider, 1, 0, 1, 2) + + @Slot() + def on_slider_pressed(self): + self.is_sliding = True + + @Slot() + def on_slider_released(self): + self.is_sliding = False + self.light.emit(self.slider.value()) + + @Slot(DAQStatusModel) + def update_daq_status(self, s: DAQStatusModel): + if not self.is_sliding: # Update only if not sliding + self.slider.setValue(round(s.light)) diff --git a/gui/src/aaregui/panels/loop_centering_panel.py b/gui/src/aaregui/panels/loop_centering_panel.py new file mode 100644 index 00000000..92c5f768 --- /dev/null +++ b/gui/src/aaregui/panels/loop_centering_panel.py @@ -0,0 +1,18 @@ +from PySide6.QtWidgets import QWidget, QGridLayout, QPushButton + +from aaregui import TitleLabel + + +class LoopCenteringPanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + grid_layout = QGridLayout(self) + + grid_layout.addWidget(TitleLabel("Loop centering", self), 0, 0, 1, 2) + grid_layout.setColumnStretch(0, 1) + grid_layout.setColumnStretch(1, 1) + + self.find_tip = QPushButton("Center", parent=self) + grid_layout.addWidget(self.find_tip, 1, 0) + self.background = QPushButton("Background", parent=self) + grid_layout.addWidget(self.background, 1, 1) diff --git a/gui/src/aaregui/panels/omega_panel.py b/gui/src/aaregui/panels/omega_panel.py new file mode 100644 index 00000000..1017ba2b --- /dev/null +++ b/gui/src/aaregui/panels/omega_panel.py @@ -0,0 +1,71 @@ +from PySide6.QtCore import Signal, Slot +from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QHBoxLayout +from aaredaqlib.models import DAQStatusModel + +from aaregui import ButtonWithPayload +from aaregui import NumberLineEdit +from aaregui import TitleLabel + + +class OmegaEntryWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + h_layout = QHBoxLayout(self) + h_layout.setContentsMargins(0, 0, 0, 0) + + self.edit = NumberLineEdit(-720, 720, 0, parent=self) + h_layout.addWidget(self.edit) + h_layout.addWidget(QLabel("°", parent=self)) + h_layout.addStretch() + + +class OmegaPanel(QWidget): + set_omega = Signal(float) + + def __init__(self, parent=None): + super().__init__(parent) + self.__omega = 0 + + grid_layout = QGridLayout(self) + + grid_layout.addWidget(TitleLabel("Omega", self), 0, 0, 1, 2) + grid_layout.setColumnStretch(0, 1) + grid_layout.setColumnStretch(1, 1) + omega_settings = [ + {"name": " 0°", "payload": {"abs": 0.0}}, + {"name": "+180°", "payload": {"rel": 180.0}}, + {"name": "-180°", "payload": {"rel": -180.0}}, + {"name": "+90°", "payload": {"rel": 90.0}}, + {"name": "-90°", "payload": {"rel": -90.0}}, + {"name": "+45°", "payload": {"rel": 45.0}}, + {"name": "-45°", "payload": {"rel": -45.0}}, + {"name": "+10°", "payload": {"rel": 10.0}}, + {"name": "-10°", "payload": {"rel": -10.0}}, + ] + + self.edit = OmegaEntryWidget(self) + self.edit.edit.newValue.connect(self.omega_value_entered) + grid_layout.addWidget(self.edit, 1, 0) + + i = 1 + for x in omega_settings: + button = ButtonWithPayload(x["name"], x["payload"], parent=self) + grid_layout.addWidget(button, int(i / 2 + 1), int(i % 2)) + i += 1 + button.pressed.connect(self.omega_button_pressed) + + @Slot(dict) + def omega_button_pressed(self, payload: dict): + if "rel" in payload: + self.set_omega.emit(self.__omega + payload["rel"]) + elif "abs" in payload: + self.set_omega.emit(payload["abs"]) + + @Slot(float) + def omega_value_entered(self, f: float): + self.set_omega.emit(f) + + @Slot(DAQStatusModel) + def update_daq_status(self, s: DAQStatusModel): + self.edit.edit.update_value(s.geom.omega_deg) + self.__omega = s.geom.omega_deg diff --git a/gui/src/aaregui/panels/raster_data_collection.py b/gui/src/aaregui/panels/raster_data_collection.py new file mode 100644 index 00000000..3f5e063f --- /dev/null +++ b/gui/src/aaregui/panels/raster_data_collection.py @@ -0,0 +1,43 @@ +from PySide6.QtCore import Signal, Slot +from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QSizePolicy, QSpacerItem + +from aaregui import NumberLineEdit + + +class RasterDataCollectionPanel(QWidget): + grid_size_updated = Signal(float, float) + + def __init__(self, parent=None): + super().__init__(parent) + grid_layout = QGridLayout(self) + grid_layout.addWidget(QLabel("Grid size", parent=self), 0, 0) + + self.width_enter = NumberLineEdit(5, 100, decimals=0, default=20, parent=self) + self.width_enter.newValue.connect(self.grid_size) + grid_layout.addWidget(self.width_enter, 0, 1) + + grid_layout.addWidget(QLabel(" x ", parent=self), 0, 2) + + self.height_enter = NumberLineEdit(5, 100, decimals=0, default=20, parent=self) + self.height_enter.newValue.connect(self.grid_size) + grid_layout.addWidget(self.height_enter, 0, 3) + + grid_layout.addWidget(QLabel("μm", parent=self), 0, 4) + + grid_layout.addWidget(QLabel("Image time", parent=self), 1, 0) + self.image_time_enter = NumberLineEdit( + 0.0005, 10.0, 0.005, decimals=4, parent=self + ) + grid_layout.addWidget(self.image_time_enter, 1, 1, 1, 3) + grid_layout.addWidget(QLabel("s", parent=self), 1, 4) + + horizontal_spacer = QSpacerItem( + 40, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding + ) + grid_layout.addItem(horizontal_spacer, 2, 0, 1, 4) + + @Slot(float) + def grid_size(self, _: float): + width = self.width_enter.value / 1000.0 + height = self.height_enter.value / 1000.0 + self.grid_size_updated.emit(width, height) diff --git a/gui/src/aaregui/panels/sample_queue_panel.py b/gui/src/aaregui/panels/sample_queue_panel.py new file mode 100644 index 00000000..d3af5fbd --- /dev/null +++ b/gui/src/aaregui/panels/sample_queue_panel.py @@ -0,0 +1,89 @@ +from PySide6.QtCore import Signal, Slot +from PySide6.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QPushButton, QHeaderView, QSizePolicy, \ + QTableView +from aaredaqlib.models import SampleShortInfoList, SampleShortInfo + +from aaregui import SampleQueueSpreadsheet +from aaregui import TitleLabel + + +class SampleQueuePanel(QFrame): + auto_scan = Signal(SampleShortInfo) + unmount = Signal() + + def __init__(self, parent=None, samples: SampleShortInfoList | None = None): + super().__init__(parent) + self.__pause = True + self.__set_to_pause = False + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + + layout = QVBoxLayout(self) + self.setLayout(layout) + + layout.addWidget(TitleLabel("Sample queue", self)) + # Create the table + self.table_view = QTableView(self) + + # Create the custom table model + self.table_model = SampleQueueSpreadsheet() + # Create the QTableView and set the model + self.table_view.setModel(self.table_model) + # Enable automatic column resizing + header = self.table_view.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch) # Columns stretch to fill the table + + # Set the table's size policy to expand + self.table_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.table_view.setAcceptDrops(True) + self.table_view.setDropIndicatorShown(True) + + layout.addWidget(self.table_view) # Add table to the layout + + # Create a horizontal layout for the buttons + button_layout = QHBoxLayout() + self.play_button = QPushButton("▶ Run", self) + self.play_button.clicked.connect(self.run) + self.clear_button = QPushButton("✖ Clear list", self) + self.clear_button.clicked.connect(self.clear) + + self.clear_button.clicked.connect(self.table_model.clearSamples) + + button_layout.addWidget(self.play_button) + button_layout.addWidget(self.clear_button) + + # Add the button layout to the main layout + layout.addLayout(button_layout) + + + def run(self): + if self.__pause: + if len(self.table_model.samples) > 0: + self.table_model.set_running(True) + self.__set_to_pause = False + self.__pause = False + self.play_button.setText("⏸ Pause") + self.auto_scan.emit(self.table_model.samples[0]) + else: + self.__set_to_pause = True + self.__pause = True + self.play_button.setText("▶ Run") + + def clear(self): + self.table_model.clearSamples() + + @Slot(int, bool) + def automated_scan_done(self, db_id: int, success: bool): + if success: + self.table_model.remove_sample(db_id) + if not self.__pause and len(self.table_model.samples) > 0: + self.auto_scan.emit(self.table_model.samples[0]) + else: + self.table_model.set_running(False) + self.__pause = True + self.play_button.setText("▶ Run") + self.unmount.emit() + else: + self.table_model.set_running(False) + self.__pause = True + self.play_button.setText("▶ Run") diff --git a/gui/src/aaregui/panels/screening_data_collection.py b/gui/src/aaregui/panels/screening_data_collection.py new file mode 100644 index 00000000..7c1d64ed --- /dev/null +++ b/gui/src/aaregui/panels/screening_data_collection.py @@ -0,0 +1,60 @@ +from PySide6.QtWidgets import ( + QWidget, + QGridLayout, + QLabel, + QComboBox, + QSizePolicy, + QSpacerItem, +) + +from aaregui import NumberLineEdit + + +class ScreeningDataCollectionPanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + grid_layout = QGridLayout(self) + + grid_layout.addWidget(QLabel("High resolution", parent=self), 0, 0) + self.high_res_enter = NumberLineEdit( + 0.5, 10, decimals=2, default=2.0, parent=self + ) + grid_layout.addWidget(self.high_res_enter, 0, 1) + grid_layout.addWidget(QLabel("Å", parent=self), 0, 2) + grid_layout.addWidget(QLabel("Detector distance", parent=self), 1, 0) + self.dtz_enter = NumberLineEdit(60, 1000, decimals=2, default=120, parent=self) + grid_layout.addWidget(self.dtz_enter, 1, 1) + grid_layout.addWidget(QLabel("mm", parent=self), 1, 2) + + grid_layout.addWidget(QLabel("Beam transmission", parent=self), 2, 0) + self.transmission_enter = NumberLineEdit( + 0, 1.0, decimals=4, default=1.0, parent=self + ) + grid_layout.addWidget(self.transmission_enter, 2, 1) + + grid_layout.addWidget(QLabel("Start angle", parent=self), 3, 0) + self.start_angle = NumberLineEdit(-720, 720.0, 0.0, decimals=3, parent=self) + grid_layout.addWidget(self.start_angle, 3, 1) + grid_layout.addWidget(QLabel("°", parent=self), 3, 2) + + grid_layout.addWidget(QLabel("Image time", parent=self), 4, 0) + self.image_time_enter = NumberLineEdit( + 0.0005, 10.0, 0.005, decimals=4, parent=self + ) + grid_layout.addWidget(self.image_time_enter, 4, 1) + grid_layout.addWidget(QLabel("s", parent=self), 4, 2) + + self.screening_type = QComboBox(parent=self) + self.screening_type.addItem("1 image", {"steps": 1, "omega_step_deg": 0}) + self.screening_type.addItem("2 images every 90°", {"steps": 2, "omega_step_deg": 90}) + self.screening_type.addItem("4 images every 90°", {"steps": 4, "omega_step_deg": 90}) + self.screening_type.addItem("3 images every 60°", {"steps": 3, "omega_step_deg": 60}) + self.screening_type.addItem("2 images every 45°", {"steps": 2, "omega_step_deg": 45}) + self.screening_type.addItem("4 images every 45°", {"steps": 4, "omega_step_deg": 45}) + + grid_layout.addWidget(self.screening_type, 5, 0, 1, 3) + + horizontal_spacer = QSpacerItem( + 40, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding + ) + grid_layout.addItem(horizontal_spacer, 6, 0, 1, 3) diff --git a/gui/src/aaregui/panels/smargon_panel.py b/gui/src/aaregui/panels/smargon_panel.py new file mode 100644 index 00000000..883ca2d3 --- /dev/null +++ b/gui/src/aaregui/panels/smargon_panel.py @@ -0,0 +1,113 @@ +from PySide6.QtCore import Signal, Slot +from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QPushButton + +from aaredaqlib.models import DAQStatusModel +from aaregui import ButtonWithPayload +from aaregui import NumberLineEdit +from aaregui import TitleLabel +from aaredaqlib.coordinate import SmargonCoordinate, Coordinate +from aaredaqlib.sample_geometry import SampleGeometryModel + + +class SmargonMoveWidget(QWidget): + smargon_rel = Signal(Coordinate) + + def __init__(self, parent=None): + super().__init__(parent) + + grid_layout = QGridLayout(self) + grid_layout.setColumnStretch(0, 1) + grid_layout.setColumnStretch(1, 1) + grid_layout.setColumnStretch(2, 1) + + self.button_left = ButtonWithPayload("←", payload={"x": 1, "y": 0}) + self.button_right = ButtonWithPayload("→", payload={"x": -1, "y": 0}) + self.button_up = ButtonWithPayload("↑", payload={"x": 0, "y": 1}) + self.button_down = ButtonWithPayload("↓", payload={"x": 0, "y": -1}) + + self.button_left.pressed.connect(self.smargon_button) + self.button_right.pressed.connect(self.smargon_button) + self.button_up.pressed.connect(self.smargon_button) + self.button_down.pressed.connect(self.smargon_button) + + grid_layout.addWidget(self.button_up, 0, 1) + grid_layout.addWidget(self.button_left, 1, 0) + grid_layout.addWidget(self.button_right, 1, 2) + grid_layout.addWidget(self.button_down, 2, 1) + + @Slot(dict) + def smargon_button(self, payload: dict): + self.smargon_rel.emit(Coordinate(x=payload["x"], y=payload["y"])) + + +class SmargonPanel(QWidget): + smargon = Signal(SmargonCoordinate) + + def __init__(self, parent=None): + super().__init__(parent) + self.__geom: SampleGeometryModel | None = None + self.__step = 100 + + grid_layout = QGridLayout(self) + + grid_layout.addWidget(TitleLabel("Smargon", self), 0, 0, 1, 6) + + grid_layout.addWidget(QLabel("Chi", parent=self), 1, 0) + self.chi_enter = NumberLineEdit(-0.2, 40, decimals=1, parent=self) + self.chi_enter.newValue.connect(self.chi) + grid_layout.addWidget(self.chi_enter, 1, 1) + grid_layout.addWidget(QLabel("°", parent=self), 1, 2) + + grid_layout.addWidget(QLabel("Phi", parent=self), 1, 3) + self.phi_enter = NumberLineEdit(-0.2, 360, decimals=1, parent=self) + self.phi_enter.newValue.connect(self.phi) + grid_layout.addWidget(self.phi_enter, 1, 4) + + grid_layout.addWidget(QLabel("°", parent=self), 1, 5) + + self.home_button = QPushButton("Move home", parent=self) + grid_layout.addWidget(self.home_button, 2, 0, 1, 6) + self.home_button.clicked.connect(self.home) + + self.move_panel = SmargonMoveWidget(parent=self) + grid_layout.addWidget(self.move_panel, 3, 0, 1, 6) + self.move_panel.smargon_rel.connect(self.smargon_rel) + + grid_layout.addWidget(QLabel("Step", parent=self), 4, 0) + self.step = NumberLineEdit(1, 1000, 100, 0, parent=self) + grid_layout.addWidget(self.step, 4, 1) + grid_layout.addWidget(QLabel("μm", parent=self), 4, 2) + self.step.newValue.connect(self.step_changed) + + @Slot(float) + def step_changed(self, f: float): + self.__step = f + + @Slot() + def home(self): + self.smargon.emit(Smargon.SMARGON_HOME) + + @Slot(float) + def phi(self, f: float): + self.smargon.emit(SmargonCoordinate(phi_deg=f)) + + @Slot(float) + def chi(self, f: float): + self.smargon.emit(SmargonCoordinate(chi_deg=f)) + + @Slot(DAQStatusModel) + def update_daq_status(self, s: DAQStatusModel): + self.chi_enter.update_value(s.geom.smargon.chi_deg) + self.phi_enter.update_value(s.geom.smargon.phi_deg) + self.__geom = s.geom + + @Slot(Coordinate) + def smargon_rel(self, c: Coordinate): + if self.__geom is None: + return + + self.smargon.emit( + SmargonCoordinate( + sh_mm=self.__geom.beamline_to_smargon(c * self.__step / 1000.0) + ) + ) diff --git a/gui/src/aaregui/panels/standard_data_collection.py b/gui/src/aaregui/panels/standard_data_collection.py new file mode 100644 index 00000000..2bc315fe --- /dev/null +++ b/gui/src/aaregui/panels/standard_data_collection.py @@ -0,0 +1,54 @@ +from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QSpacerItem, QSizePolicy + +from aaregui import NumberLineEdit + + +class StandardDataCollectionPanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + grid_layout = QGridLayout(self) + + grid_layout.addWidget(QLabel("High resolution", parent=self), 0, 0) + self.high_res_enter = NumberLineEdit( + 0.5, 10, decimals=2, default=2.0, parent=self + ) + grid_layout.addWidget(self.high_res_enter, 0, 1) + grid_layout.addWidget(QLabel("Å", parent=self), 0, 2) + grid_layout.addWidget(QLabel("Detector distance", parent=self), 1, 0) + self.dtz_enter = NumberLineEdit(60, 1000, decimals=2, default=120, parent=self) + grid_layout.addWidget(self.dtz_enter, 1, 1) + grid_layout.addWidget(QLabel("mm", parent=self), 1, 2) + + grid_layout.addWidget(QLabel("Beam transmission", parent=self), 2, 0) + self.transmission_enter = NumberLineEdit( + 0, 1.0, decimals=4, default=1.0, parent=self + ) + grid_layout.addWidget(self.transmission_enter, 2, 1) + + grid_layout.addWidget(QLabel("Start angle", parent=self), 3, 0) + self.start_angle = NumberLineEdit(-720, 720.0, 0.0, decimals=3, parent=self) + grid_layout.addWidget(self.start_angle, 3, 1) + grid_layout.addWidget(QLabel("°", parent=self), 3, 2) + + grid_layout.addWidget(QLabel("Total angle", parent=self), 4, 0) + self.total_angle = NumberLineEdit(0, 999.0, 180.0, decimals=3, parent=self) + grid_layout.addWidget(self.total_angle, 4, 1) + grid_layout.addWidget(QLabel("°", parent=self), 4, 2) + + grid_layout.addWidget(QLabel("Image angle", parent=self), 5, 0) + self.image_angle = NumberLineEdit(0, 10.0, 0.1, decimals=3, parent=self) + grid_layout.addWidget(self.image_angle, 5, 1) + grid_layout.addWidget(QLabel("°", parent=self), 5, 2) + + grid_layout.addWidget(QLabel("Image time", parent=self), 6, 0) + self.image_time_enter = NumberLineEdit( + 0.0005, 10.0, 0.005, decimals=4, parent=self + ) + grid_layout.addWidget(self.image_time_enter, 6, 1) + grid_layout.addWidget(QLabel("s", parent=self), 6, 2) + + + horizontal_spacer = QSpacerItem( + 40, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding + ) + grid_layout.addItem(horizontal_spacer, 6, 0, 1, 3) diff --git a/gui/src/aaregui/panels/status_panel.py b/gui/src/aaregui/panels/status_panel.py new file mode 100644 index 00000000..de52627c --- /dev/null +++ b/gui/src/aaregui/panels/status_panel.py @@ -0,0 +1,64 @@ +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QFrame, QGridLayout, QLabel, QSpacerItem, QSizePolicy +from aaredaqlib.models import DAQStatusModel +from aaredaqlib.sample_geometry import SampleGeometryModel + +from aaregui import StatusLabel +from aaregui import TitleLabel + + +class StatusPanel(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + + layout = QGridLayout(self) + self.setLayout(layout) + + layout.addWidget(TitleLabel("Beamline status", self), 0, 0, 1, 5) + + layout.addWidget(QLabel("Ring current", parent=self), 2, 1) + self.ring_current = StatusLabel(val=400) + layout.addWidget(self.ring_current, 2, 2) + layout.addWidget(QLabel("mA", parent=self), 2, 3) + + layout.addWidget(QLabel("Flux", parent=self), 3, 1) + self.flux = StatusLabel(val=45300) + layout.addWidget(self.flux, 3, 2) + layout.addWidget(QLabel("109 ph/s", parent=self), 3, 3) + + layout.addWidget(QLabel("Wavelength", parent=self), 4, 1) + self.wavelength = StatusLabel(val=1.00, decimals=3) + layout.addWidget(self.wavelength, 4, 2) + layout.addWidget(QLabel("Å", parent=self), 4, 3) + + layout.addWidget(QLabel("Beam size", parent=self), 5, 1) + self.beam_size = QLabel("20 x 20") + self.beam_size.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.beam_size, 5, 2) + layout.addWidget(QLabel("µm", parent=self), 5, 3) + + layout.addWidget(QLabel("Omega", parent=self), 6, 1) + self.omega = StatusLabel(val=0.1, decimals=2) + layout.addWidget(self.omega, 6, 2) + layout.addWidget(QLabel("°", parent=self), 6, 3) + + horizontal_spacer_1 = QSpacerItem( + 40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + layout.addItem(horizontal_spacer_1, 1, 0, 1, 1) + + horizontal_spacer_2 = QSpacerItem( + 40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + + layout.addItem(horizontal_spacer_2, 7, 4, 1, 1) + + @Slot(QPixmap, SampleGeometryModel) + def update_daq_status(self, s: DAQStatusModel): + self.omega.new_value(s.geom.omega_deg) + self.beam_size.setText( + f"{s.geom.beam_size_mm.x * 1000.0} x {s.geom.beam_size_mm.y * 1000.0}" + ) diff --git a/gui/src/aaregui/panels/tell_sample_panel.py b/gui/src/aaregui/panels/tell_sample_panel.py new file mode 100644 index 00000000..207d45e5 --- /dev/null +++ b/gui/src/aaregui/panels/tell_sample_panel.py @@ -0,0 +1,121 @@ +from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QTableView, + QHeaderView, + QMenu, + QLabel, + QPushButton, + QAbstractItemView, +) +from aaredaqlib.models import BeamlineStateEnum, SampleShortInfo, SampleShortInfoList, DAQStatusModel + +from aaregui import UserSampleSpreadsheet +from aaregui import TitleLabel + + +class TellSamplePanel(QFrame): + mount = Signal(SampleShortInfo) + unmount = Signal() + state = Signal(BeamlineStateEnum) + + def __init__(self, samples: SampleShortInfoList | None = None, parent=None): + + super().__init__(parent) + + if samples is None: + samples = SampleShortInfoList(s=[]) + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + + grid_layout = QGridLayout(self) + self.setLayout(grid_layout) + + grid_layout.addWidget(TitleLabel("TELL sample changer", self), 0, 0, 1, 4) + + self.table_view = QTableView() + grid_layout.addWidget(self.table_view, 1, 0, 1, 4) + + self.curr_sample_label = QLabel("No sample mounted", parent=self) + grid_layout.addWidget(self.curr_sample_label, 2, 0) + + self.unmount_button = QPushButton("Unmount", parent=self) + grid_layout.addWidget(self.unmount_button, 2, 1) + self.unmount_button.clicked.connect(self.unmount_button_clicked) + + grid_layout.setColumnStretch(0, 1) + grid_layout.setColumnStretch(1, 0) + + # Create the custom table model + self.table_model = UserSampleSpreadsheet(samples=samples.s) + # Create the QTableView and set the model + self.table_view.setModel(self.table_model) + self.table_view.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Stretch + ) + + # Enable the custom context menu + self.table_view.setSortingEnabled(True) + self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.table_view.customContextMenuRequested.connect(self.context_menu) + self.table_view.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows + ) + self.table_view.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + + self.table_view.setEditTriggers(QTableView.EditTrigger.NoEditTriggers) + self.table_view.setDragEnabled(True) + header = self.table_view.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) + header.setStretchLastSection(True) # Optional: Make last column stretch to fit + self.table_view.verticalHeader().setVisible(True) + + def unmount_button_clicked(self): + self.unmount.emit() + + @Slot(SampleShortInfoList) + def new_sample_list(self, samples: SampleShortInfoList): + self.table_model.updateData(samples=samples.s) + + def context_menu(self, position): + # Determine the row where the user clicked + index = self.table_view.indexAt(position) + if not index.isValid(): + return # Do nothing if user didn't click on a valid item + + row = index.row() + sample = self.table_model.get_id(row) + + if sample.location is None: + return # Do nothing if sample not mounted + + # Create the context menu + menu = QMenu() + mount_action = menu.addAction("Mount") # Add "Mount" to the menu + + # Display the menu at the cursor position + action = menu.exec_(self.table_view.viewport().mapToGlobal(position)) + + # Handle the result of the selected action + if action == mount_action: + self.mount.emit(sample) + + @Slot(DAQStatusModel) + def update_daq_status(self, status: DAQStatusModel): + sample = status.sample + if sample is None: + self.curr_sample_label.setText("No sample mounted") + self.table_model.updateCurrentSample(current_puck=None, current_sample=None) + else: + try: + self.curr_sample_label.setText( + f"Current sample: {sample.sample_name} ({sample.location.segment}{sample.location.pos}-{sample.pin})" + ) + self.table_model.updateCurrentSample( + current_puck=sample.puck_name, current_sample=sample.db_id + ) + except Exception as e: + self.curr_sample_label.setText(f"Confusing information :/ {e}") diff --git a/gui/src/aaregui/panels/zoom_panel.py b/gui/src/aaregui/panels/zoom_panel.py new file mode 100644 index 00000000..dcf4434f --- /dev/null +++ b/gui/src/aaregui/panels/zoom_panel.py @@ -0,0 +1,33 @@ +from PySide6.QtCore import Slot, Signal +from PySide6.QtWidgets import QWidget, QGridLayout + +from aaregui import ButtonWithPayload +from aaregui import TitleLabel + + +class ZoomPanel(QWidget): + zoom = Signal(float) + + def __init__(self, parent=None): + super().__init__(parent) + + grid_layout = QGridLayout(self) + zoom_settings = [ + {"name": "1x", "value": 1}, + {"name": "2.0x", "value": 280}, + {"name": "3.5x", "value": 500}, + {"name": "5.8x", "value": 700}, + {"name": "7.5x", "value": 800}, + {"name": "12.5x", "value": 1000}, + ] + grid_layout.addWidget(TitleLabel("Zoom", self), 0, 0, 1, 2) + i = 2 + for z in zoom_settings: + button = ButtonWithPayload(z["name"], {"zoom": z["value"]}) + grid_layout.addWidget(button, int(i / 2), int(i % 2)) + i += 1 + button.pressed.connect(self.set_zoom) + + @Slot(dict) + def set_zoom(self, payload: dict): + self.zoom.emit(payload["zoom"]) diff --git a/gui/src/aaregui/threads/__init__.py b/gui/src/aaregui/threads/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gui/src/aaregui/threads/camera_thread.py b/gui/src/aaregui/threads/camera_thread.py new file mode 100644 index 00000000..c1a70967 --- /dev/null +++ b/gui/src/aaregui/threads/camera_thread.py @@ -0,0 +1,51 @@ +import json + +import cv2 +import numpy as np +import zmq +from PySide6.QtCore import QThread, Signal +from PySide6.QtGui import QImage, QPixmap + + +class SampleCameraThread(QThread): + # Define a signal to communicate messages from the thread to the main GUI + camera_image = Signal(QPixmap) + + def __init__(self, zmq_url: str, parent=None): + super().__init__(parent) + context = zmq.Context() + self.__socket = context.socket(zmq.SUB) + self.__socket.setsockopt(zmq.SUBSCRIBE, b"") + self.__socket.connect(zmq_url) + + self.running = True + + def run(self): + while self.running: + try: + r = self.__socket.recv_multipart() + if len(r) != 2: + continue + meta, data = r + header = json.loads(meta) + header_shape = header["shape"] + + if header["type"] == "uint8" and len(header_shape) == 2: + bayer_image = np.frombuffer(data, dtype=np.uint8) + bayer_image = bayer_image.reshape(header_shape) + rgb_image = cv2.cvtColor(bayer_image, cv2.COLOR_BAYER_GB2RGB) + rgb_image = rgb_image[:, ::-1, :].copy() + qimage = QImage(rgb_image.data, header_shape[0], header_shape[1], header_shape[0]*3, QImage.Format.Format_RGB888) + self.camera_image.emit(QPixmap.fromImage(qimage)) + else: + print("Sample camera image has wrong dimensions") + except Exception as e: + print(f"Error in sample camera thread {e}") + + def stop(self): + # Signal the thread to stop + self.running = False + + if self.__socket: + self.__socket.close() + self.wait() diff --git a/gui/src/aaregui/threads/daq_worker.py b/gui/src/aaregui/threads/daq_worker.py new file mode 100644 index 00000000..18ffa0d0 --- /dev/null +++ b/gui/src/aaregui/threads/daq_worker.py @@ -0,0 +1,229 @@ +from PySide6.QtCore import Signal, QUrl, Slot, QTimer, QObject, QByteArray +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply + +from aaredaqlib.models import DAQStatusModel, SampleShortInfoList, SampleShortInfo +from aaredaqlib.coordinate import SmargonCoordinate, Coordinate +from aaredaqlib.helical_geometry import HelicalScanRequest +from aaredaqlib.raster_grid import RasterGridRequest +from aaredaqlib.rotation_scan import RotationScanRequest +from aaredaqlib.screening_scan import ScreeningScanRequest + +SPREADHSEET_FREQUENCY = 25 # Every 5 seconds + +class DAQWorker(QObject): + update = Signal(DAQStatusModel) + spreadsheet = Signal(SampleShortInfoList) + http_error = Signal(str) + auth_error = Signal() + automated_scan_done = Signal(int, bool) # sample ID, success + + def __init__(self, base_url: str, token: str, parent=None): + super().__init__(parent) + self.__token = token + self.__base_url = base_url + self.__net_manager = QNetworkAccessManager() + self.__timer = QTimer() + self.__timer.setInterval(200) + self.__timer.timeout.connect(self.regular_update) + self.__timer.start() + self.__counter = 0 + + + @Slot() + def regular_update(self): + if self.__counter % SPREADHSEET_FREQUENCY == 0: + self.load_spreadsheet() + self.__counter = (self.__counter + 1) % SPREADHSEET_FREQUENCY + self.send_status_request() + + def send_status_request(self): + request = QNetworkRequest(QUrl(f"{self.__base_url}/status")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + reply = self.__net_manager.get(request) + reply.finished.connect(lambda: self.handle_status_response(reply)) + + @staticmethod + def handle_response(reply: QNetworkReply): + if reply.error() == QNetworkReply.NetworkError.NoError: + response_data = reply.readAll().data().decode("utf-8") + reply.deleteLater() + return response_data + else: + reply.deleteLater() + raise RuntimeError(reply.errorString()) + + @Slot(QNetworkReply) + def handle_status_response(self, reply: QNetworkReply): + try: + response_data = self.handle_response(reply) + parsed_response = DAQStatusModel.model_validate_json(response_data) + self.update.emit(parsed_response) + except Exception as e: + print(e) + self.http_error.emit(str(e)) + + @Slot(QNetworkReply) + def handle_spreadsheet_response(self, reply: QNetworkReply): + try: + response_data = self.handle_response(reply) + parsed_response = SampleShortInfoList.model_validate_json(response_data) + self.spreadsheet.emit(parsed_response) + except Exception as e: + print(e) + self.http_error.emit(str(e)) + + def handle_req_response(self, reply: QNetworkReply): + if reply.error() != QNetworkReply.NetworkError.NoError: + if reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) == 401: + print(reply.errorString()) + self.auth_error.emit() + else: + print(reply.errorString()) + self.http_error.emit(reply.errorString()) + reply.deleteLater() + + def generic_post(self, url: str, body: str = ""): + request = QNetworkRequest(QUrl(f"{self.__base_url}/{url}")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + if str: + request.setRawHeader(b"Content-Type", b"application/json") + reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8"))) + reply.finished.connect(lambda: self.handle_req_response(reply)) + + def generic_put(self, url: str, body: str = ""): + request = QNetworkRequest(QUrl(f"{self.__base_url}/{url}")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + if str: + request.setRawHeader(b"Content-Type", b"application/json") + reply = self.__net_manager.put(request, QByteArray(body.encode("utf-8"))) + reply.finished.connect(lambda: self.handle_req_response(reply)) + + def generic_delete(self, url: str): + request = QNetworkRequest(QUrl(f"{self.__base_url}/{url}")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + reply = self.__net_manager.deleteResource(request) + reply.finished.connect(lambda: self.handle_req_response(reply)) + + @Slot(float) + def set_omega(self, f: float): + self.generic_put(f"beamline/omega?val={f:.3f}") + + @Slot(float) + def zoom(self, f: float): + self.generic_put(f"beamline/zoom?val={f:.3f}") + + @Slot(int) + def light(self, v: int): + self.generic_put(f"beamline/light?val={v:d}") + + @Slot() + def alc_background(self): + self.generic_post("alc/background") + + @Slot() + def center_loop(self): + self.generic_post("alc/center_loop") + + @Slot() + def force_session(self): + self.generic_post("access/force_current_session") + + @Slot() + def end_session(self): + self.generic_post("access/end_session") + + @Slot() + def dewar_exchange(self): + self.generic_post("state/dewar_exchange") + + @Slot() + def sample_exchange(self): + self.generic_post("state/sample_exchange") + + @Slot() + def sample_alignment(self): + self.generic_post("state/sample_alignment") + + @Slot(str) + def set_pgroup(self, val: str): + if val == "": + self.generic_delete("access/pgroup") + else: + self.generic_put(f"access/pgroup?val={val}") + + @Slot(SmargonCoordinate) + def move_smargon(self, coord: SmargonCoordinate): + self.generic_put("beamline/smargon", coord.model_dump_json()) + + @Slot(RotationScanRequest) + def standard_scan(self, r: RotationScanRequest): + self.generic_post("scan/rotation", r.model_dump_json()) + + @Slot(ScreeningScanRequest) + def screening_scan(self, r: ScreeningScanRequest): + self.generic_post("scan/screening", r.model_dump_json()) + + @Slot(HelicalScanRequest) + def helical_scan(self, h: HelicalScanRequest): + self.generic_post("scan/helical", h.model_dump_json()) + + @Slot(RasterGridRequest) + def raster_scan(self, r: RasterGridRequest): + self.generic_post("scan/raster", r.model_dump_json()) + + @Slot() + def load_spreadsheet(self): + request = QNetworkRequest(QUrl(f"{self.__base_url}/tell/spreadsheet")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + reply = self.__net_manager.get(request) + reply.finished.connect(lambda: self.handle_spreadsheet_response(reply)) + + def handle_auto_scan_response(self, reply, sample_id: int): + if reply.error() == QNetworkReply.NetworkError.NoError: + resp = reply.readAll().data().decode("utf-8") + print(f"Sample time {resp} s") + self.automated_scan_done.emit(sample_id, True) + else: + if reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) == 401: + print(reply.errorString()) + self.auth_error.emit() + else: + print(reply.errorString()) + self.http_error.emit(reply.errorString()) + self.automated_scan_done.emit(sample_id, False) + reply.deleteLater() + + @Slot(SampleShortInfo) + def automated_scan(self, s: SampleShortInfo): + request = QNetworkRequest(QUrl(f"{self.__base_url}/scan/auto")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + request.setRawHeader(b"Content-Type", b"application/json") + body = s.model_dump_json() + reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8"))) + reply.finished.connect(lambda: self.handle_auto_scan_response(reply, s.db_id)) + + @Slot(Coordinate) + def abr_tweak(self, c: Coordinate): + self.generic_post("beamline/tweak_abr_meas_pos", c.model_dump_json()) + + @Slot() + def abr_save(self): + self.generic_post("beamline/save_abr_meas_pos") + + + @Slot(float, float) + def beam_center_mark(self, x: float, y: float): + print(f"beam_center/mark?x={x}&y={y}") + self.generic_post(f"beam_center/mark?x={x}&y={y}") + + @Slot() + def beam_center_clear(self): + self.generic_post(f"beam_center/clear") + + @Slot() + def unmount(self): + self.generic_post("tell/unmount") + + @Slot(SampleShortInfo) + def mount(self, s: SampleShortInfo): + self.generic_post(f"tell/mount?dbid={s.db_id}") diff --git a/gui/src/aaregui/widgets/__init__.py b/gui/src/aaregui/widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gui/src/aaregui/widgets/button_with_payload.py b/gui/src/aaregui/widgets/button_with_payload.py new file mode 100644 index 00000000..5e53a137 --- /dev/null +++ b/gui/src/aaregui/widgets/button_with_payload.py @@ -0,0 +1,16 @@ +from PySide6.QtCore import Signal, Slot +from PySide6.QtWidgets import QPushButton + + +class ButtonWithPayload(QPushButton): + pressed = Signal(dict) + + def __init__(self, text: str, payload: dict, parent=None): + super().__init__(parent) + self.payload = payload + self.setText(text) + self.clicked.connect(self.button_pressed) + + @Slot() + def button_pressed(self): + self.pressed.emit(self.payload) diff --git a/gui/src/aaregui/widgets/camera_image.py b/gui/src/aaregui/widgets/camera_image.py new file mode 100644 index 00000000..c7a340b9 --- /dev/null +++ b/gui/src/aaregui/widgets/camera_image.py @@ -0,0 +1,496 @@ +from typing import List + +from PySide6.QtCore import Qt, QTimer, QRect, QPoint, Signal, Slot, QPointF +from PySide6.QtGui import ( + QPixmap, + QPainter, + QPen, + QColor, + QWheelEvent, + QTransform, + QCursor, + QLinearGradient, +) +from PySide6.QtWidgets import ( + QMenu, + QToolTip, + QGraphicsView, + QGraphicsScene, + QGraphicsPixmapItem, + QFileDialog, + QFrame, +) + +from aaredaqlib.models import DAQStatusModel +from aaregui import SmargonBookmarkList +from aaredaqlib.coordinate import Coordinate, SmargonCoordinate +from aaredaqlib.raster_grid import RasterGridRequest +from aaredaqlib.sample_geometry import SampleGeometryModel + + +class SampleCameraImageLabel(QGraphicsView): + smargon = Signal(SmargonCoordinate) + run_raster = Signal(RasterGridRequest) + zoom_change = Signal(int) + set_omega = Signal(float) + set_helical_start = Signal(SmargonCoordinate) + set_helical_end = Signal(SmargonCoordinate) + update_beam_center = Signal(float, float) + + def __init__( + self, + geom: SampleGeometryModel, + rasters: List[RasterGridRequest], + parent=None, + ): + super().__init__(parent) + + if rasters is None: + self.__raster = [] + else: + self.__raster = rasters + + self.__pixmap = QPixmap("/home/leonarski_f/loop200.jpg") + self.__geom = geom + self.__bookmarks: SmargonBookmarkList = SmargonBookmarkList(b=[]) + self.__autoscale = True + self.__show_coords = False + self.__helical_start = SmargonCoordinate() + self.__helical_end = SmargonCoordinate() + + self.drawing = False # Indicates whether the mouse is currently drawing + self.moving_raster_grid = None + self.right_click_grid = None + self.active_grid = None + self.drawn_grid = None + + self.start_point = None # Starting point of the rectangle + self.end_point = None # Ending point of the rectangle + self.rectangles = [] # List of all drawn rectangles + + self.scene = QGraphicsScene(self) + self.setScene(self.scene) + + self.setMouseTracking(True) + + pixmap = QPixmap("/home/leonarski_f/loop200.jpg") + self.pixmap_item = QGraphicsPixmapItem(pixmap) + self.scene.addItem(self.pixmap_item) + + self.setFrameShape(QFrame.Shape.NoFrame) + self.setRenderHints(QPainter.RenderHint.Antialiasing) + + # Create a timer for throttling wheel events + self.wheel_event_timer = QTimer() + self.wheel_event_timer.setSingleShot(True) + self.wheel_event_threshold = 500 # Minimum delay between wheel events in ms + self.last_wheel_event = 0 # Timestamp of the last processed wheel event + + self.click_timer = QTimer() # Timer to detect click vs hold + self.click_timer.setSingleShot(True) # The timer runs only once + self.left_click_hold_threshold = 200 + self.right_click_hold_threshold = 200 + + # Default raster grid size + self.raster_grid_x = 0.02 + self.raster_grid_y = 0.02 + + def drawForeground(self, painter, rect): + self.__draw_beam_center(painter) + self.__draw_grids(painter) + self.__draw_helical(painter) + + def resizeEvent(self, event): + # Call the base class implementation of resizeEvent to maintain default behavior + super().resizeEvent(event) + self.__scaling() + + def mousePressEvent(self, event): + pos = self.mapToScene(event.pos()) + if event.button() == Qt.MouseButton.RightButton: + self.__start_drawing(pos) + self.click_timer.start(self.right_click_hold_threshold) + elif event.button() == Qt.MouseButton.LeftButton: + self.__start_moving(pos) + self.click_timer.start(self.left_click_hold_threshold) + + def mouseMoveEvent(self, event): + mouse_pos = self.mapToScene(event.pos()) + if self.__show_coords: + x, y = mouse_pos.x(), mouse_pos.y() + QToolTip.showText( + self.mapToGlobal(event.pos()), f"{x:.0f}, {y:.0f} pxl", self + ) + self.end_point = mouse_pos + if self.drawing and not self.click_timer.isActive(): + self.__update_drawn_grid() + self.update() + elif self.moving_raster_grid is not None and not self.click_timer.isActive(): + self.__update_moving_raster_grid() + self.update() + + def mouseReleaseEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + if self.click_timer.isActive(): + self.click_timer.stop() + self.__left_single_click(event) + self.__end_moving(event, self.click_timer.isActive()) + + if event.button() == Qt.MouseButton.RightButton: + if self.click_timer.isActive(): + self.click_timer.stop() + self.__right_click_menu(event) + else: + self.__end_drawing(event) + + def __right_click_menu(self, event): + self.drawing = False + + menu = QMenu(self) + + scale_action = menu.addAction("Scale to width") + scale_action.setCheckable(True) + scale_action.setChecked(self.__autoscale) + + show_coord_action = menu.addAction("Show coordinates") + show_coord_action.setCheckable(True) + show_coord_action.setChecked(self.__show_coords) + + grab_action = menu.addAction("Grab") + grab_with_overlay_action = menu.addAction("Grab with overlay") + + beam_mark_action = menu.addAction("Mark beam center") + + delete_action = None + evaluate_action = None + + self.active_grid = self.right_click_grid + + if self.right_click_grid is not None: + menu.addSection("Grid") + delete_action = menu.addAction("Delete") + evaluate_action = menu.addAction("Evaluate") + + action = menu.exec_(self.mapToGlobal(event.pos())) + + if action == show_coord_action: + self.__show_coords = not self.__show_coords + elif action == scale_action: + self.__autoscale = not self.__autoscale + self.__scaling() + elif self.right_click_grid is not None and action == delete_action: + self.__raster.remove(self.right_click_grid) + self.right_click_grid = None + elif self.right_click_grid is not None and action == evaluate_action: + self.run_raster.emit(self.right_click_grid) + elif action == grab_action: + self.__screenshot_with_dialog(overlay=False) + elif action == grab_with_overlay_action: + self.__screenshot_with_dialog(overlay=True) + elif action == beam_mark_action: + c = self.mapToScene(event.pos()) + self.update_beam_center.emit(c.x(), c.y()) + self.active_grid = None + self.update() + + def __screenshot_with_dialog(self, overlay: bool): + file_path, _ = QFileDialog.getSaveFileName( + self, + "Save View As", + "", + "JPEG Files (*.jpg; *.jpeg);;All Files (*)", + ) + + if file_path: + self.__screenshot(file_path=file_path, overlay=overlay) + + def __screenshot(self, file_path: str, overlay: bool): + scene_rect = self.scene.sceneRect() + pixmap = QPixmap(scene_rect.size().toSize()) + painter = QPainter(pixmap) + self.scene.render(painter) + if overlay: + self.drawForeground(painter, scene_rect) + painter.end() + pixmap.save(file_path, "JPEG") + + def __scaling(self): + if not self.__autoscale: + self.setTransform(QTransform()) + return + + if self.pixmap_item.boundingRect().width() == 0: + ratio = 1.0 + else: + ratio = ( + self.viewport().size().width() / self.pixmap_item.boundingRect().width() + ) + + if ratio < 0.2: + ratio = 0.2 + + if ratio >= 1.0: + self.setTransform(QTransform()) + else: + matrix = QTransform() + matrix.scale(ratio, ratio) + self.setTransform(matrix) + + @Slot(float, float) + def update_raster_grid_size(self, grid_size_x: float, grid_size_y: float): + self.raster_grid_x = grid_size_x + self.raster_grid_y = grid_size_y + + @Slot(QPixmap) + def update_pixmap(self, pixmap: QPixmap): + if self.pixmap_item is not None: # Ensure pixmap_item exists + self.pixmap_item.setPixmap(pixmap) # Update the pixmap in the item + else: + # If no pixmap item exists (rare case), create one + self.pixmap_item = QGraphicsPixmapItem(pixmap) + self.scene.addItem(self.pixmap_item) + self.update() # Request an update to redraw the view + + @Slot(DAQStatusModel) + def update_daq_status(self, s: DAQStatusModel): + self.__geom = s.geom + # View is redrawn when new camera image will arrive + + def __point_is_part_of_raster(self, pos) -> RasterGridRequest | None: + c = self.__geom.picture_to_sample(Coordinate(x=pos.x(), y=pos.y())) + + for i in self.__raster: + if i is not None and i.point_sample_belongs_to_grid(self.__geom, c): + return i + return None + + def __update_moving_raster_grid(self): + delta_pxl = Coordinate( + x=self.end_point.x() - self.start_point.x(), + y=self.end_point.y() - self.start_point.y(), + ) + self.start_point = self.end_point + + delta_mm = self.__geom.smargon_nudge(delta_pxl * self.__geom.pixel_in_mm) + + self.moving_raster_grid.smargon = SmargonCoordinate( + sh_mm=self.moving_raster_grid.smargon.sh_mm + delta_mm, + phi_deg=self.__geom.smargon.phi_deg, + chi_deg=self.__geom.smargon.chi_deg, + ) + + def __left_single_click(self, event): + self.end_point = self.mapToScene(event.pos()) + + sample_coord = self.__geom.picture_to_sample( + Coordinate(x=self.end_point.x(), y=self.end_point.y()) + ) + smargon_coord = SmargonCoordinate( + sh_mm=self.__geom.beamline_to_smargon(sample_coord) + ) + + self.smargon.emit(smargon_coord) + + def __start_moving(self, pos): + self.moving_raster_grid = self.__point_is_part_of_raster(pos) + self.active_grid = self.moving_raster_grid + self.start_point = pos # Record the starting position + self.end_point = self.start_point + + def __end_moving(self, event, too_short: bool): + self.end_point = self.mapToScene(event.pos()) + self.active_grid = None + if self.moving_raster_grid is not None and not too_short: + self.__update_moving_raster_grid() + self.moving_raster_grid = None + self.update() + + def __start_drawing(self, pos): + self.drawing = True + self.right_click_grid = self.__point_is_part_of_raster(pos) + if self.right_click_grid is not None: + # Starting position is top-left corner of the old raster + old_start_pos = self.__geom.sample_to_picture( + self.right_click_grid.start_pxl(geom=self.__geom) + ) + self.start_point = QPoint(int(old_start_pos.x), int(old_start_pos.y)) + else: + self.start_point = pos + self.end_point = pos + + def __end_drawing(self, event): + self.drawing = False + self.end_point = self.mapToScene(event.pos()) + + self.__update_drawn_grid() + + if self.right_click_grid is not None: + self.__raster.remove(self.right_click_grid) + if self.drawn_grid is not None: + self.__raster.append(self.drawn_grid) + + self.active_grid = None + self.right_click_grid = None + self.drawn_grid = None + + self.update() # Repaint to permanently draw the rectangle + + def __draw_beam_center(self, painter: QPainter): + beam_size_pxl = self.__geom.beam_size_pxl + + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(QColor(245, 121, 0), 3, Qt.PenStyle.SolidLine)) + painter.drawRect( + QRect( + int(self.__geom.beam_location_pxl.x - beam_size_pxl.x / 2), + int(self.__geom.beam_location_pxl.y - beam_size_pxl.y / 2), + int(beam_size_pxl.x), + int(beam_size_pxl.y), + ) + ) + + @staticmethod + def __draw_circle(painter, coord: Coordinate, color: QColor, radius=10): + painter.setPen(QPen(Qt.PenStyle.NoPen)) + painter.setBrush(color) + painter.drawEllipse(coord.x - radius, coord.y - radius, 2 * radius, 2 * radius) + + @staticmethod + def __draw_arrow(painter: QPainter, start: Coordinate, end: Coordinate): + gradient = QLinearGradient() + gradient.setStart(QPointF(start.x, start.y)) # Start of the gradient (green) + gradient.setFinalStop(QPointF(end.x, end.y)) # End of the gradient (red) + + gradient.setColorAt(0.0, QColor("green")) # Start color + gradient.setColorAt(1.0, QColor("red")) # End color + + pen = QPen() + pen.setBrush(gradient) # Use gradient as the brush for the pen + pen.setWidth(4) # Set pen width + + painter.setPen(pen) + + painter.drawLine(QPointF(start.x, start.y), QPointF(end.x, end.y)) + + def __draw_helical(self, painter: QPainter): + if self.__helical_start.sh_mm is not None: + start_pxl = self.__geom.smargon_to_picture(self.__helical_start.sh_mm) + self.__draw_circle(painter, start_pxl, QColor("green")) + else: + start_pxl = None + + if self.__helical_end.sh_mm is not None: + end_pxl = self.__geom.smargon_to_picture(self.__helical_end.sh_mm) + self.__draw_circle(painter, end_pxl, QColor("red")) + else: + end_pxl = None + + if start_pxl is not None and end_pxl is not None: + self.__draw_arrow(painter, start_pxl, end_pxl) + + def __draw_grid(self, painter: QPainter, r: RasterGridRequest): + painter.setBrush(Qt.BrushStyle.NoBrush) + if r == self.active_grid: + painter.setPen(QPen(QColor(114, 159, 207), 2, Qt.PenStyle.SolidLine)) + else: + painter.setPen(QPen(QColor(255, 0, 255), 2, Qt.PenStyle.SolidLine)) + + g = r.grid_size_pxl(self.__geom) + c0 = r.start_pxl(self.__geom) + for x in range(r.n_x): + for y in range(r.n_y): + c = self.__geom.sample_to_picture( + c0 + Coordinate(x=x * r.grid_size_mm.x, y=y * r.grid_size_mm.y) + ) + painter.drawRect(QRect(int(c.x), int(c.y), int(g.x), int(g.y))) + + def __draw_grids(self, painter): + for i in self.__raster: + if i is not None and i.grid_visible(self.__geom): + self.__draw_grid(painter, i) + + if self.drawn_grid is not None: + self.__draw_grid(painter, self.drawn_grid) + + def __update_drawn_grid(self): + if self.right_click_grid is None: + grid_mm = Coordinate(x=self.raster_grid_x, y=self.raster_grid_y) + else: + grid_mm = self.right_click_grid.grid_size_mm + + delta_x = self.end_point.x() - self.start_point.x() + delta_y = self.end_point.y() - self.start_point.y() + n_x = round(abs(delta_x * self.__geom.pixel_in_mm / grid_mm.x)) + n_y = round(abs(delta_y * self.__geom.pixel_in_mm / grid_mm.y)) + + c = self.__geom.picture_to_sample( + Coordinate(x=self.start_point.x(), y=self.start_point.y()) + ) + + if delta_x < 0: + c.x -= n_x * grid_mm.x + if delta_y < 0: + c.y -= n_y * grid_mm.y + + if n_x > 0 and n_y > 0: + self.drawn_grid = RasterGridRequest( + n_x=n_x, + n_y=n_y, + smargon=SmargonCoordinate( + sh_mm=self.__geom.beamline_to_smargon(c), + phi_deg=self.__geom.smargon.phi_deg, + chi_deg=self.__geom.smargon.chi_deg, + ), + grid_size_mm=grid_mm, + omega_deg=self.__geom.omega_deg, + ) + self.active_grid = self.drawn_grid + else: + self.drawn_grid = None + + @Slot(SmargonCoordinate) + def show_helical_start(self, pos: SmargonCoordinate): + self.__helical_start = pos + self.update() + + @Slot(SmargonCoordinate) + def show_helical_end(self, pos: SmargonCoordinate): + self.__helical_end = pos + self.update() + + def keyPressEvent(self, event): + mouse_global_pos = QCursor.pos() + mouse_view_pos = self.mapFromGlobal(mouse_global_pos) + mouse_scene_pos = self.mapToScene(mouse_view_pos) + smargon_coord = SmargonCoordinate( + sh_mm=self.__geom.picture_to_smargon( + Coordinate(x=mouse_scene_pos.x(), y=mouse_scene_pos.y()) + )) + + if event.key() == Qt.Key.Key_1: + self.set_helical_start.emit(smargon_coord) + + elif event.key() == Qt.Key.Key_2: + self.set_helical_end.emit(smargon_coord) + + super().keyPressEvent(event) + + def wheelEvent(self, event: QWheelEvent): + # Only process the wheel event if the timer is not active or enough time has passed + if not self.wheel_event_timer.isActive(): + delta_y = event.angleDelta().y() + + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + val = 180 + elif event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + val = 10 + else: + val = 90 + + if delta_y > 0: + self.set_omega.emit(self.__geom.omega_deg + val) + else: + self.set_omega.emit(self.__geom.omega_deg - val) + + # Start the timer to throttle further events + self.wheel_event_timer.start(self.wheel_event_threshold) diff --git a/gui/src/aaregui/widgets/clickable_label.py b/gui/src/aaregui/widgets/clickable_label.py new file mode 100644 index 00000000..e4182bd5 --- /dev/null +++ b/gui/src/aaregui/widgets/clickable_label.py @@ -0,0 +1,12 @@ +from PySide6.QtCore import Signal, Qt +from PySide6.QtWidgets import QLabel + + +class ClickableLabel(QLabel): + clicked = Signal() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + else: + super().mousePressEvent(event) \ No newline at end of file diff --git a/gui/src/aaregui/widgets/login.py b/gui/src/aaregui/widgets/login.py new file mode 100644 index 00000000..9036637d --- /dev/null +++ b/gui/src/aaregui/widgets/login.py @@ -0,0 +1,66 @@ +import json +import os + +from PySide6.QtCore import Slot, QByteArray, QUrl, QUrlQuery +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLineEdit, QPushButton, QLabel, QHBoxLayout +) + + +class LoginDialog(QDialog): + def __init__(self, base_url: str): + super().__init__() + self.token = "" + self.setWindowTitle("User Authentication") + self.setMinimumWidth(400) + self.setStyleSheet("background-color: rgb(216, 228, 253);") + self.__base_url = base_url + self.__reply = None + + # Layout + layout = QVBoxLayout(self) + + # Username + self.name_label = QLabel("Username:") + self.name_entry = QLineEdit() + user = os.getlogin() + self.name_entry.setText(user) + self.name_entry.setPlaceholderText("Enter username") + layout.addWidget(self.name_label) + layout.addWidget(self.name_entry) + + # Buttons + button_layout = QHBoxLayout() + self.ok_button = QPushButton("OK") + self.ok_button.clicked.connect(self.authenticate) # Close dialog with accept status + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) # Close dialog with reject status + button_layout.addWidget(self.ok_button) + button_layout.addWidget(self.cancel_button) + + layout.addLayout(button_layout) + + @Slot() + def authenticate(self): + network_manager = QNetworkAccessManager(self) + request = QNetworkRequest(QUrl(f"{self.__base_url}/token")) + request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/x-www-form-urlencoded") + + payload = QUrlQuery() + payload.addQueryItem("username", f"{self.name_entry.text()}") + payload.addQueryItem("password", "") + payload_string = payload.toString() + self.__reply = network_manager.post(request, QByteArray.fromStdString(payload_string)) + self.__reply.finished.connect(self.handle_token_response) + + + @Slot() + def handle_token_response(self): + if self.__reply is not None and self.__reply.error() == QNetworkReply.NetworkError.NoError: + response_data = self.__reply.readAll().data() + response_json = json.loads(response_data.decode("utf-8")) + if "access_token" in response_json: + self.token = response_json["access_token"] + self.__reply.deleteLater() + self.accept() diff --git a/gui/src/aaregui/widgets/no_wheel_scroll_area.py b/gui/src/aaregui/widgets/no_wheel_scroll_area.py new file mode 100644 index 00000000..c57c15f4 --- /dev/null +++ b/gui/src/aaregui/widgets/no_wheel_scroll_area.py @@ -0,0 +1,10 @@ +from PySide6.QtWidgets import QScrollArea + + +class NoWheelScrollArea(QScrollArea): + def __init__(self, parent=None): + super().__init__(parent) + + def wheelEvent(self, event): + # Override the wheelEvent and do nothing + pass diff --git a/gui/src/aaregui/widgets/number_line_edit.py b/gui/src/aaregui/widgets/number_line_edit.py new file mode 100644 index 00000000..64146ce8 --- /dev/null +++ b/gui/src/aaregui/widgets/number_line_edit.py @@ -0,0 +1,65 @@ +from PySide6.QtCore import Signal, Slot, Qt +from PySide6.QtGui import QDoubleValidator +from PySide6.QtWidgets import QLineEdit + + +class NumberLineEdit(QLineEdit): + newValue = Signal(float) + + def __init__( + self, + min_val: float, + max_val: float, + default: float = 0.0, + decimals: int = 2, + parent=None, + ): + super().__init__(parent) + + self.setStyleSheet("background-color: rgb(255, 255, 255);") + + # Use a QDoubleValidator to only allow valid floating point numbers + self.validator = QDoubleValidator() + self.validator.setNotation(QDoubleValidator.Notation.StandardNotation) + self.decimal_count = decimals + self.validator.setRange(min_val, max_val, self.decimal_count) + self.setValidator(self.validator) + self.setAlignment(Qt.AlignmentFlag.AlignRight) + self.setToolTip( + "Minimum: {:s}\nMaximum: {:s}".format( + self.to_string(min_val), self.to_string(max_val) + ) + ) + + # Connect the textChanged signal to a custom slot to check validity + self.textChanged.connect(self.on_text_changed) + self.editingFinished.connect(self.on_editing_finished) + self.setText(self.to_string(default)) + self.saved_value = default + + def to_string(self, i: float) -> str: + format_string = f"{{:.{self.decimal_count}f}}" + return format_string.format(i) + + @Slot(str) + def on_text_changed(self, text: str): + if self.validator.validate(text, 0)[0] == QDoubleValidator.State.Acceptable: + self.setStyleSheet("background-color: rgb(255, 255, 255);") + else: + self.setStyleSheet("background-color: rgb(255, 213, 213);") + + @Slot() + def on_editing_finished(self): + val = float(self.text()) + self.newValue.emit(val) + self.saved_value = self.saved_value + + @property + def value(self) -> float: + return float(self.text()) + + @Slot(float) + def update_value(self, val: float): + if abs(val - self.saved_value) > 0.001: + self.saved_value = val + self.setText(self.to_string(val)) diff --git a/gui/src/aaregui/widgets/pgroup_dialog.py b/gui/src/aaregui/widgets/pgroup_dialog.py new file mode 100644 index 00000000..20ef8548 --- /dev/null +++ b/gui/src/aaregui/widgets/pgroup_dialog.py @@ -0,0 +1,37 @@ +from PySide6.QtWidgets import QDialog, QLineEdit, QVBoxLayout, QPushButton, QLabel + + +class PGroupDialog(QDialog): + def __init__(self, curr_pgroup: str | None = None, parent=None): + super().__init__(parent) + self.setWindowTitle("Change current p-group") + self.setMinimumWidth(300) + + # Create a layout + layout = QVBoxLayout(self) + + # Add a label + self.label = QLabel("Set p-group:", self) + layout.addWidget(self.label) + + # Add a text entry field + self.text_entry = QLineEdit(self) + if curr_pgroup is not None: + self.text_entry.setText(curr_pgroup) + layout.addWidget(self.text_entry) + + # Create buttons + self.ok_button = QPushButton("OK", self) + self.cancel_button = QPushButton("Cancel", self) + + # Add buttons to the layout + layout.addWidget(self.ok_button) + layout.addWidget(self.cancel_button) + + # Connect button signals + self.ok_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + + def get_input(self): + """Return the input text when the dialog is accepted.""" + return self.text_entry.text() \ No newline at end of file diff --git a/gui/src/aaregui/widgets/status_bar.py b/gui/src/aaregui/widgets/status_bar.py new file mode 100644 index 00000000..f20d194b --- /dev/null +++ b/gui/src/aaregui/widgets/status_bar.py @@ -0,0 +1,149 @@ +from PySide6.QtCore import Signal, Slot, QPoint +from PySide6.QtWidgets import QStatusBar, QDialog, QMenu + +from aaredaqlib.models import TokenData, BeamlineStateEnum, DAQStatusModel, SessionsStateEnum +from aaregui import ClickableLabel +from aaregui import PGroupDialog + + +class StatusBar(QStatusBar): + set_pgroup = Signal(str) + dewar_exchange = Signal() + sample_alignment = Signal() + sample_exchange = Signal() + + force_session = Signal() + end_session = Signal() + + def __init__(self, token: TokenData, parent=None): + super().__init__(parent) + self.__decoded_token = token + + self.pgroup_label = ClickableLabel(self) + self.pgroup_label.clicked.connect(self.show_pgroup_menu) + + self.state_label = ClickableLabel(parent=self) + self.state_label.clicked.connect(self.show_state_menu) + + self.busy_label = ClickableLabel(parent=self) + + self.session_label = ClickableLabel(parent=self) + self.session_label.clicked.connect(self.show_session_menu) + + self.addPermanentWidget(self.pgroup_label) + self.addPermanentWidget(self.state_label) + self.addPermanentWidget(self.busy_label) + self.addPermanentWidget(self.session_label) + + + @Slot(DAQStatusModel) + def update_daq_status(self, status: DAQStatusModel): + if status.current_pgroup is not None: + self.pgroup_label.setText(f"""p-group: {status.current_pgroup} """) + else: + self.pgroup_label.setText(f"Inactive p-group ") + + state = status.state + if state == BeamlineStateEnum.SampleExchange: + state = "Sample exchange" + elif state == BeamlineStateEnum.SampleAlignment: + state = "Sample alignment" + elif state == BeamlineStateEnum.DataCollection: + state = "Data collection" + elif state == BeamlineStateEnum.DewarTransfer: + state = "Dewar transfer" + elif state == BeamlineStateEnum.Maintenance: + state = "Maintenance" + elif state == BeamlineStateEnum.Moving: + state = "Moving" + elif state == BeamlineStateEnum.RobotSampleExchange: + state = "Robot sample exchange" + else: + state = "-" + + self.state_label.setText(f"""State: {state} """) + + if status.busy: + busy_flag = """ Busy ⬤ """ + else: + busy_flag = """ Idle ⬤ """ + + html_content = f"""Beamline: {busy_flag} """ + + self.busy_label.setText(html_content) + + session_flag = "" + if status.session == SessionsStateEnum.Vacant: + session_flag = """ Vacant ⬤ """ + elif status.session == SessionsStateEnum.OwnedByYou: + session_flag = """ Owned ⬤ """ + elif status.session == SessionsStateEnum.OwnedByElse: + session_flag = """ Other ⬤ """ + + html_content_session = f"""Session: {session_flag}""" + self.session_label.setText(html_content_session) + + def show_session_menu(self): + menu = QMenu(self) + + action_1 = menu.addAction("Grab") + action_1.triggered.connect(self.grab_session_clicked) + action_2 = menu.addAction("End") + action_2.triggered.connect(self.end_session_clicked) + + label_geometry = self.session_label.geometry() + menu.move(self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height())) + + menu.setFixedWidth(label_geometry.width()) + + menu.exec() + + def show_pgroup_menu(self): + if self.__decoded_token.staff: + menu = QMenu(self) + + action_1 = menu.addAction("Change") + action_1.triggered.connect(self.show_change_dialog) + + label_geometry = self.pgroup_label.geometry() + menu.move(self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height())) + menu.setFixedWidth(label_geometry.width()) + + menu.exec() + + def show_state_menu(self): + menu = QMenu(self) + + action_1 = menu.addAction("Sample alignment") + action_1.triggered.connect(self.sa) + action_2 = menu.addAction("Sample exchange") + action_2.triggered.connect(self.se) + action_3 = menu.addAction("Dewar load/unload") + action_3.triggered.connect(self.dl) + + label_geometry = self.state_label.geometry() + menu.move(self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height())) + menu.setFixedWidth(label_geometry.width()) + + menu.exec() + + def grab_session_clicked(self): + self.force_session.emit() + + def end_session_clicked(self): + self.end_session.emit() + + def sa(self): + self.sample_alignment.emit() + + def se(self): + self.sample_exchange.emit() + + def dl(self): + self.dewar_exchange.emit() + + def show_change_dialog(self): + dialog = PGroupDialog() + if dialog.exec() == QDialog.DialogCode.Accepted: + entered_text = dialog.get_input() + self.set_pgroup.emit(entered_text) diff --git a/gui/src/aaregui/widgets/status_label.py b/gui/src/aaregui/widgets/status_label.py new file mode 100644 index 00000000..613e2fa8 --- /dev/null +++ b/gui/src/aaregui/widgets/status_label.py @@ -0,0 +1,15 @@ +from PySide6.QtCore import Qt, Slot +from PySide6.QtWidgets import QLabel + + +class StatusLabel(QLabel): + def __init__(self, val: float, decimals: int = 0, parent=None): + super().__init__(parent) + + self.format_string = f"{{:.{decimals}f}}" + self.setText(self.format_string.format(val)) + self.setAlignment(Qt.AlignmentFlag.AlignRight) + + @Slot() + def new_value(self, val: float): + self.setText(self.format_string.format(val)) diff --git a/gui/src/aaregui/widgets/title_label.py b/gui/src/aaregui/widgets/title_label.py new file mode 100644 index 00000000..44e7a55c --- /dev/null +++ b/gui/src/aaregui/widgets/title_label.py @@ -0,0 +1,12 @@ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QLabel + + +class TitleLabel(QLabel): + def __init__(self, text: str, parent=None): + super().__init__(parent) + self.setText(f"

{text}

") + self.setStyleSheet("background-color: #4B0082; color: #ffffff;") + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.setFixedHeight(50) diff --git a/src/.gitignore b/src/.gitignore deleted file mode 100644 index 62c89355..00000000 --- a/src/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.idea/ \ No newline at end of file