Include everything in one repo, for better development
Build and Publish / build (push) Successful in 26s

This commit is contained in:
leonarski_f committed 2025-05-14 16:27:42 +02:00
1 parent 6be6ae1843
commit a38b3b055a
78 files changed
+3362 -7

No files matched your search

+18 -1
View File
@@ -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:
+2
View File
@@ -0,0 +1,2 @@
.idea/
.venv/
+17
View File
@@ -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"
File renamed without changes.
+104
View File
@@ -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
@@ -0,0 +1,6 @@
from aaredaqlib import SmargonCoordinate
from aaredaqlib.rotation_scan import RotationScanRequest
class HelicalScanRequest(RotationScanRequest):
end: SmargonCoordinate
+171
View File
@@ -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
+54
View File
@@ -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
+13
View File
@@ -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
+87
View File
@@ -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))
+15
View File
@@ -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
+11
View File
@@ -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
+2 -2
View File
@@ -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"
]
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+20
View File
@@ -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"
View File
Whitespace-only changes.
+20
View File
@@ -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()
+200
View File
@@ -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'<span style="color: red; "> {msg} </span>', 10000)
def cleanup(self):
self.camera_thread.stop()
View File
Whitespace-only changes.
+27
View File
@@ -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]
@@ -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()
+139
View File
@@ -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]
View File
Whitespace-only changes.
+129
View File
@@ -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);")
@@ -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()
@@ -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)
@@ -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)
@@ -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.")
+21
View File
@@ -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)
@@ -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
@@ -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))
@@ -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)
+71
View File
@@ -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
@@ -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)
@@ -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")
@@ -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)
+113
View File
@@ -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)
)
)
@@ -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)
+64
View File
@@ -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("10<sup>9</sup> 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("<b>20</b> x <b>20</b>")
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"<b>{s.geom.beam_size_mm.x * 1000.0}</b> x <b>{s.geom.beam_size_mm.y * 1000.0}</b>"
)
+121
View File
@@ -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: <b>{sample.sample_name} ({sample.location.segment}{sample.location.pos}-{sample.pin})</b>"
)
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}")
+33
View File
@@ -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"])
View File
Whitespace-only changes.
+51
View File
@@ -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()
+229
View File
@@ -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}")
View File
Whitespace-only changes.
@@ -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)
+496
View File
@@ -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)
@@ -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)
+66
View File
@@ -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()
@@ -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
@@ -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))
+37
View File
@@ -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()
+149
View File
@@ -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 = """ <span style="color: red; "> Busy ⬤ </span>"""
else:
busy_flag = """ <span style="color: green ; "> Idle ⬤ </span>"""
html_content = f"""Beamline: {busy_flag} """
self.busy_label.setText(html_content)
session_flag = ""
if status.session == SessionsStateEnum.Vacant:
session_flag = """<span style="color: yellow ; "> Vacant ⬤ </span>"""
elif status.session == SessionsStateEnum.OwnedByYou:
session_flag = """<span style="color: green ; "> Owned ⬤ </span>"""
elif status.session == SessionsStateEnum.OwnedByElse:
session_flag = """<span style="color: red ; "> Other ⬤ </span>"""
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)
+15
View File
@@ -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"<b>{{:.{decimals}f}}</b>"
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))
+12
View File
@@ -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"<H3>{text}</H3>")
self.setStyleSheet("background-color: #4B0082; color: #ffffff;")
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setFixedHeight(50)
-1
View File
@@ -1 +0,0 @@
.idea/