style: format with ruff
Build and Publish / test (pull_request) Failing after 1m23s
Build and Publish / build (pull_request) Skipped
Build and Publish / Build and Deploy Docs (pull_request) Skipped

This commit is contained in:
2026-07-06 11:53:49 +02:00
parent 13bddd43c7
commit b0863e5ddb
181 changed files with 2237 additions and 5272 deletions
+38 -48
View File
@@ -5,29 +5,29 @@
import os
import sys
sys.path.insert(0, os.path.abspath('../src'))
sys.path.insert(0, os.path.abspath('../src/aare'))
sys.path.insert(0, os.path.abspath('../src/aare/daq'))
sys.path.insert(0, os.path.abspath('../src/aare/common'))
sys.path.insert(0, os.path.abspath('../src/aare/gui'))
sys.path.insert(0, os.path.abspath("../src"))
sys.path.insert(0, os.path.abspath("../src/aare"))
sys.path.insert(0, os.path.abspath("../src/aare/daq"))
sys.path.insert(0, os.path.abspath("../src/aare/common"))
sys.path.insert(0, os.path.abspath("../src/aare/gui"))
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'AareDAQ'
copyright = '2026, Paul Scherrer Institute'
author = 'Martin Appleby, Guillaume Gotthard, Filip Leonarski and Dawn (Jiaxin) Daun'
release = '0.3.1'
master_doc = 'index'
root_doc = 'index'
project = "AareDAQ"
copyright = "2026, Paul Scherrer Institute"
author = "Martin Appleby, Guillaume Gotthard, Filip Leonarski and Dawn (Jiaxin) Daun"
release = "0.3.1"
master_doc = "index"
root_doc = "index"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
autodoc_mock_imports = [
'aaredb',
'jfjoch_client',
'numpy',
'requests',
"aaredb",
"jfjoch_client",
"numpy",
"requests",
"pyJW",
"pyzmq",
"opencv-python-headless",
@@ -43,16 +43,16 @@ autodoc_mock_imports = [
"psi-pshell",
]
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
extensions = [
# 'fuse_search',
'sphinx.ext.intersphinx',
'myst_parser',
'sphinx_immaterial',
'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
'sphinx.ext.viewcode',
"sphinx.ext.intersphinx",
"myst_parser",
"sphinx_immaterial",
"sphinx.ext.autodoc",
"sphinx.ext.napoleon",
"sphinx.ext.viewcode",
]
# Optional: allow includes in Markdown
@@ -63,11 +63,11 @@ myst_enable_extensions = [
"html_image",
"linkify",
"substitution",
'smartquotes',
"smartquotes",
"tasklist",
]
templates_path = ['_templates']
templates_path = ["_templates"]
myst_heading_anchor = 3
@@ -86,10 +86,10 @@ myst_heading_anchor = 3
# ]
# }
#Static Files
html_static_path = ['_static']
# Static Files
html_static_path = ["_static"]
html_css_files = [
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css',
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
# 'css/fuse-search.css',
]
# html_js_files = [
@@ -100,11 +100,11 @@ html_css_files = [
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_immaterial'
html_theme = "sphinx_immaterial"
html_theme_options = {
"icon": {
"repo": "fontawesome/brands/github", # or gitlab/gitea icon
"repo": "fontawesome/brands/github" # or gitlab/gitea icon
},
"site_url": "https://gitea.psi.ch/mx/aaredaq",
"repo_url": "https://gitea.psi.ch/mx/aaredaq",
@@ -114,7 +114,7 @@ html_theme_options = {
"icon": "fontawesome/brands/github",
"link": "https://gitea.psi.ch/mx/aaredaq",
"name": "AareDAQ on Gitea",
},
}
],
"palette": [
{
@@ -122,20 +122,14 @@ html_theme_options = {
"scheme": "default",
"primary": "indigo",
"accent": "lime",
"toggle": {
"icon": "material/lightbulb-outline",
"name": "Switch to dark mode",
},
"toggle": {"icon": "material/lightbulb-outline", "name": "Switch to dark mode"},
},
{
"media": "(prefers-color-scheme: dark)",
"scheme": "slate",
"primary": "indigo",
"accent": "lime",
"toggle": {
"icon": "material/lightbulb",
"name": "Switch to light mode",
},
"toggle": {"icon": "material/lightbulb", "name": "Switch to light mode"},
},
],
"features": [
@@ -152,14 +146,10 @@ html_theme_options = {
"globaltoc_collapse": False,
"toc_title": "On this page",
"toc_title_container": True,
"font": {
"code": "JetBrains Mono",
},
"font": {"code": "JetBrains Mono"},
}
html_theme_options["google_fonts"] = []
html_sidebars = {
"**": ["logo-text.html", "globaltoc.html", "searchbox.html"]
}
html_sidebars = {"**": ["logo-text.html", "globaltoc.html", "searchbox.html"]}
html_show_sourcelink = False
@@ -167,8 +157,8 @@ html_show_sourcelink = False
# Optional
autodoc_default_options = {
'members': True,
'undoc-members': True,
'show-inheritance': True,
'member-order': 'bysource',
"members": True,
"undoc-members": True,
"show-inheritance": True,
"member-order": "bysource",
}
+15
View File
@@ -65,3 +65,18 @@ markers = [
[build-system]
requires = ["setuptools>=75.6.0"]
build-backend = "setuptools.build_meta"
[tool.black]
line-length = 100
skip-magic-trailing-comma = true
[tool.isort]
profile = "black"
line_length = 100
multi_line_output = 3
[tool.ruff]
line-length = 100
[tool.ruff.format]
skip-magic-trailing-comma = true
+19 -11
View File
@@ -1,6 +1,9 @@
import cv2
#%%
bkg_old = cv2.imread("/home/leonarski_f/aaredaq/daq/src/aaredaq/alc_test_images/bkg280_50_50_old.jpg")
# %%
bkg_old = cv2.imread(
"/home/leonarski_f/aaredaq/daq/src/aaredaq/alc_test_images/bkg280_50_50_old.jpg"
)
bkg_new = cv2.imread("/home/leonarski_f/aaredaq/daq/src/aaredaq/alc_test_images/bkg280_50_50.jpg")
grey_old = cv2.cvtColor(bkg_old, cv2.COLOR_RGB2GRAY)
grey_new = cv2.cvtColor(bkg_new, cv2.COLOR_RGB2GRAY)
@@ -8,17 +11,22 @@ diff_image = cv2.absdiff(grey_old, grey_new)
_, thresh_diff = cv2.threshold(diff_image, 1, 255, cv2.THRESH_BINARY)
# Display the results
cv2.imshow('Original Image 1', bkg_old)
cv2.imshow('Original Image 2', bkg_new)
cv2.imshow('Grayscale 1', grey_old)
cv2.imshow('Grayscale 2', grey_new)
cv2.imshow('Difference Image', diff_image)
cv2.imshow('Thresholded Difference', thresh_diff)
cv2.imshow("Original Image 1", bkg_old)
cv2.imshow("Original Image 2", bkg_new)
cv2.imshow("Grayscale 1", grey_old)
cv2.imshow("Grayscale 2", grey_new)
cv2.imshow("Difference Image", diff_image)
cv2.imshow("Thresholded Difference", thresh_diff)
# Save the difference image
cv2.imwrite('/home/leonarski_f/aaredaq/daq/src/aaredaq/alc_test_images/bkg_difference_image.jpg', diff_image)
cv2.imwrite('/home/leonarski_f/aaredaq/daq/src/aaredaq/alc_test_images/bkg_thresholded_difference.jpg', thresh_diff)
cv2.imwrite(
"/home/leonarski_f/aaredaq/daq/src/aaredaq/alc_test_images/bkg_difference_image.jpg", diff_image
)
cv2.imwrite(
"/home/leonarski_f/aaredaq/daq/src/aaredaq/alc_test_images/bkg_thresholded_difference.jpg",
thresh_diff,
)
# Wait for a key press and close all windows
cv2.waitKey(0)
cv2.destroyAllWindows()
cv2.destroyAllWindows()
+114 -90
View File
@@ -58,10 +58,16 @@ class ImageStatsReceiver:
x_coords = np.arange(len(x_projection))
y_coords = np.arange(len(y_projection))
x_centroid = np.sum(x_coords * x_projection) / np.sum(x_projection) if np.sum(x_projection) > 0 else len(
x_projection) / 2
y_centroid = np.sum(y_coords * y_projection) / np.sum(y_projection) if np.sum(y_projection) > 0 else len(
y_projection) / 2
x_centroid = (
np.sum(x_coords * x_projection) / np.sum(x_projection)
if np.sum(x_projection) > 0
else len(x_projection) / 2
)
y_centroid = (
np.sum(y_coords * y_projection) / np.sum(y_projection)
if np.sum(y_projection) > 0
else len(y_projection) / 2
)
# Calculate FWHM (Full Width at Half Maximum) approximation
def calculate_fwhm(profile):
@@ -76,20 +82,20 @@ class ImageStatsReceiver:
y_fwhm = calculate_fwhm(y_projection)
return {
'x_projection': x_projection.tolist(),
'y_projection': y_projection.tolist(),
'x_projection_max': x_projection_max.tolist(),
'y_projection_max': y_projection_max.tolist(),
'x_peak_position': int(x_peak_idx),
'y_peak_position': int(y_peak_idx),
'x_peak_value': float(x_peak_value),
'y_peak_value': float(y_peak_value),
'x_centroid': float(x_centroid),
'y_centroid': float(y_centroid),
'x_fwhm': float(x_fwhm),
'y_fwhm': float(y_fwhm),
'x_coords': list(range(len(x_projection))),
'y_coords': list(range(len(y_projection)))
"x_projection": x_projection.tolist(),
"y_projection": y_projection.tolist(),
"x_projection_max": x_projection_max.tolist(),
"y_projection_max": y_projection_max.tolist(),
"x_peak_position": int(x_peak_idx),
"y_peak_position": int(y_peak_idx),
"x_peak_value": float(x_peak_value),
"y_peak_value": float(y_peak_value),
"x_centroid": float(x_centroid),
"y_centroid": float(y_centroid),
"x_fwhm": float(x_fwhm),
"y_fwhm": float(y_fwhm),
"x_coords": list(range(len(x_projection))),
"y_coords": list(range(len(y_projection))),
}
def calculate_radial_integration(self, image: np.ndarray, num_bins: int = 50) -> Dict[str, Any]:
@@ -104,7 +110,7 @@ class ImageStatsReceiver:
center_y, center_x = np.array(gray_image.shape) // 2
# Create coordinate grids
y_coords, x_coords = np.ogrid[:gray_image.shape[0], :gray_image.shape[1]]
y_coords, x_coords = np.ogrid[: gray_image.shape[0], : gray_image.shape[1]]
# Calculate distance from center for each pixel
distances = np.sqrt((x_coords - center_x) ** 2 + (y_coords - center_y) ** 2)
@@ -137,13 +143,13 @@ class ImageStatsReceiver:
pixel_counts.append(0)
return {
'r_centers': r_centers.tolist(),
'radial_profile': radial_profile,
'radial_std': radial_std,
'pixel_counts': pixel_counts,
'max_radius': float(max_radius),
'center': [int(center_x), int(center_y)],
'num_bins': num_bins
"r_centers": r_centers.tolist(),
"radial_profile": radial_profile,
"radial_std": radial_std,
"pixel_counts": pixel_counts,
"max_radius": float(max_radius),
"center": [int(center_x), int(center_y)],
"num_bins": num_bins,
}
def calculate_image_stats(self, image: np.ndarray) -> Dict[str, Any]:
@@ -151,30 +157,30 @@ class ImageStatsReceiver:
image_float = image.astype(np.float64)
stats = {
'timestamp': time.time(),
'shape': image.shape,
'dtype': str(image.dtype),
'mean': float(np.mean(image_float)),
'std': float(np.std(image_float)),
'median': float(np.median(image_float)),
'min': float(np.min(image_float)),
'max': float(np.max(image_float)),
'message_count': self.message_count
"timestamp": time.time(),
"shape": image.shape,
"dtype": str(image.dtype),
"mean": float(np.mean(image_float)),
"std": float(np.std(image_float)),
"median": float(np.median(image_float)),
"min": float(np.min(image_float)),
"max": float(np.max(image_float)),
"message_count": self.message_count,
}
# Calculate per-channel stats if color image
if len(image.shape) == 3 and image.shape[2] > 1:
for channel in range(image.shape[2]):
channel_data = image_float[:, :, channel]
stats[f'mean_ch{channel}'] = float(np.mean(channel_data))
stats[f'std_ch{channel}'] = float(np.std(channel_data))
stats[f'median_ch{channel}'] = float(np.median(channel_data))
stats[f"mean_ch{channel}"] = float(np.mean(channel_data))
stats[f"std_ch{channel}"] = float(np.std(channel_data))
stats[f"median_ch{channel}"] = float(np.median(channel_data))
radial_data = self.calculate_radial_integration(image)
stats['radial'] = radial_data
stats["radial"] = radial_data
#projection_data = self.calculate_projections(image)
#stats['projections'] = projection_data
# projection_data = self.calculate_projections(image)
# stats['projections'] = projection_data
return stats
@@ -200,7 +206,7 @@ class ImageStatsReceiver:
rgb_image = rgb_image[:, ::-1, :].copy()
stats = self.calculate_image_stats(rgb_image)
stats['image_type'] = 'rgb_from_bayer'
stats["image_type"] = "rgb_from_bayer"
with self.stats_lock:
self.latest_stats = stats
@@ -213,7 +219,7 @@ class ImageStatsReceiver:
rgb_image = rgb_image.reshape(header_shape)
stats = self.calculate_image_stats(rgb_image)
stats['image_type'] = 'rgb_direct'
stats["image_type"] = "rgb_direct"
with self.stats_lock:
self.latest_stats = stats
@@ -245,45 +251,53 @@ class ImageStatsReceiver:
def print_formatted_stats(self, stats: Dict[str, Any]):
"""Print formatted statistics in a compact terminal format"""
timestamp = time.strftime('%H:%M:%S', time.localtime(stats['timestamp']))
timestamp = time.strftime("%H:%M:%S", time.localtime(stats["timestamp"]))
# Compact one-line format
print(f"[{timestamp}] #{stats['message_count']:4d} | "
f"Shape: {stats['shape']} | "
f"Mean: {stats['mean']:6.1f} | "
f"Std: {stats['std']:6.1f} | "
f"Median: {stats['median']:6.1f} | "
f"Range: [{stats['min']:3.0f}-{stats['max']:3.0f}]",
flush=True)
print(
f"[{timestamp}] #{stats['message_count']:4d} | "
f"Shape: {stats['shape']} | "
f"Mean: {stats['mean']:6.1f} | "
f"Std: {stats['std']:6.1f} | "
f"Median: {stats['median']:6.1f} | "
f"Range: [{stats['min']:3.0f}-{stats['max']:3.0f}]",
flush=True,
)
if 'radial' in stats:
radial = stats['radial']
center_intensity = radial['radial_profile'][0] if radial['radial_profile'] else 0
edge_intensity = radial['radial_profile'][-1] if radial['radial_profile'] else 0
peak_radius_idx = np.argmax(radial['radial_profile']) if radial['radial_profile'] else 0
peak_radius = radial['r_centers'][peak_radius_idx] if radial['r_centers'] else 0
if "radial" in stats:
radial = stats["radial"]
center_intensity = radial["radial_profile"][0] if radial["radial_profile"] else 0
edge_intensity = radial["radial_profile"][-1] if radial["radial_profile"] else 0
peak_radius_idx = np.argmax(radial["radial_profile"]) if radial["radial_profile"] else 0
peak_radius = radial["r_centers"][peak_radius_idx] if radial["r_centers"] else 0
print(f"{'':21} Radial: Center={center_intensity:.1f} | "
f"Edge={edge_intensity:.1f} | "
f"Peak@r={peak_radius:.1f} | "
f"Center=({radial['center'][0]},{radial['center'][1]})",
flush=True)
print(
f"{'':21} Radial: Center={center_intensity:.1f} | "
f"Edge={edge_intensity:.1f} | "
f"Peak@r={peak_radius:.1f} | "
f"Center=({radial['center'][0]},{radial['center'][1]})",
flush=True,
)
# Print projection summary
if 'projections' in stats:
proj = stats['projections']
print(f"{'':21} X-Profile: Peak@{proj['x_peak_position']}({proj['x_peak_value']:.1f}) | "
f"Centroid={proj['x_centroid']:.1f} | FWHM={proj['x_fwhm']:.1f}",
flush=True)
print(f"{'':21} Y-Profile: Peak@{proj['y_peak_position']}({proj['y_peak_value']:.1f}) | "
f"Centroid={proj['y_centroid']:.1f} | FWHM={proj['y_fwhm']:.1f}",
flush=True)
if "projections" in stats:
proj = stats["projections"]
print(
f"{'':21} X-Profile: Peak@{proj['x_peak_position']}({proj['x_peak_value']:.1f}) | "
f"Centroid={proj['x_centroid']:.1f} | FWHM={proj['x_fwhm']:.1f}",
flush=True,
)
print(
f"{'':21} Y-Profile: Peak@{proj['y_peak_position']}({proj['y_peak_value']:.1f}) | "
f"Centroid={proj['y_centroid']:.1f} | FWHM={proj['y_fwhm']:.1f}",
flush=True,
)
# Optional: Print per-channel stats if available
if any(k.startswith('mean_ch') for k in stats.keys()):
if any(k.startswith("mean_ch") for k in stats.keys()):
channels = []
i = 0
while f'mean_ch{i}' in stats:
while f"mean_ch{i}" in stats:
channels.append(f"Ch{i}({stats[f'mean_ch{i}']:.1f})")
i += 1
if channels:
@@ -292,16 +306,23 @@ class ImageStatsReceiver:
def get_radial_profile_summary(self):
"""Get a summary of the current radial profile"""
with self.stats_lock:
if self.latest_stats and 'radial' in self.latest_stats:
radial = self.latest_stats['radial']
if self.latest_stats and "radial" in self.latest_stats:
radial = self.latest_stats["radial"]
return {
'r_centers': radial['r_centers'],
'radial_profile': radial['radial_profile'],
'center_intensity': radial['radial_profile'][0] if radial['radial_profile'] else 0,
'edge_intensity': radial['radial_profile'][-1] if radial['radial_profile'] else 0,
'max_intensity_radius': radial['r_centers'][np.argmax(radial['radial_profile'])] if radial[
'radial_profile'] else 0,
'max_intensity_value': max(radial['radial_profile']) if radial['radial_profile'] else 0
"r_centers": radial["r_centers"],
"radial_profile": radial["radial_profile"],
"center_intensity": radial["radial_profile"][0]
if radial["radial_profile"]
else 0,
"edge_intensity": radial["radial_profile"][-1]
if radial["radial_profile"]
else 0,
"max_intensity_radius": radial["r_centers"][np.argmax(radial["radial_profile"])]
if radial["radial_profile"]
else 0,
"max_intensity_value": max(radial["radial_profile"])
if radial["radial_profile"]
else 0,
}
return None
@@ -311,21 +332,23 @@ class ImageStatsReceiver:
filename = f"radial_profile_{int(time.time())}.txt"
with self.stats_lock:
if self.latest_stats and 'radial' in self.latest_stats:
radial = self.latest_stats['radial']
if self.latest_stats and "radial" in self.latest_stats:
radial = self.latest_stats["radial"]
with open(filename, 'w') as f:
with open(filename, "w") as f:
f.write("# Radial Integration Profile\n")
f.write(f"# Timestamp: {time.ctime(self.latest_stats['timestamp'])}\n")
f.write(f"# Image shape: {self.latest_stats['shape']}\n")
f.write(f"# Center: {radial['center']}\n")
f.write("# Radius(pixels)\tMean_Intensity\tStd_Intensity\tPixel_Count\n")
for i in range(len(radial['r_centers'])):
f.write(f"{radial['r_centers'][i]:.2f}\t"
f"{radial['radial_profile'][i]:.2f}\t"
f"{radial['radial_std'][i]:.2f}\t"
f"{radial['pixel_counts'][i]}\n")
for i in range(len(radial["r_centers"])):
f.write(
f"{radial['r_centers'][i]:.2f}\t"
f"{radial['radial_profile'][i]:.2f}\t"
f"{radial['radial_std'][i]:.2f}\t"
f"{radial['pixel_counts'][i]}\n"
)
print(f"Radial profile saved to {filename}")
return filename
@@ -419,6 +442,7 @@ def save_current_radial_profile(filename: str = None):
return stats_receiver.save_radial_profile_to_file(filename)
return None
# # Auto-start when daq.py is imported/run
# if __name__ == "__main__":
# # If running daq.py directly
@@ -444,4 +468,4 @@ def save_current_radial_profile(filename: str = None):
#
# else:
# # If daq.py is imported as a module, auto-start the receiver
# start_image_stats_receiver()
# start_image_stats_receiver()
+2 -1
View File
@@ -1,4 +1,5 @@
from aaredaq.config import BeamlineConfig
from aaredaqlib.beamline import MXBeamline
c = BeamlineConfig(MXBeamline.X06DA)
c.state_busy = False
c.state_busy = False
+43 -42
View File
@@ -1,13 +1,17 @@
from PySide6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QFrame, QPushButton, QScrollArea, QStackedWidget,
QApplication,
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QFrame,
QPushButton,
QScrollArea,
QStackedWidget,
QSizePolicy,
)
from PySide6.QtCore import Qt, QPointF, QRectF
from PySide6.QtGui import (
QPainter, QColor, QPen, QLinearGradient,
QFont, QFontMetrics,
)
from PySide6.QtGui import QPainter, QColor, QPen, QLinearGradient, QFont, QFontMetrics
import sys
import math
@@ -15,15 +19,15 @@ import math
# ---------------------------------------------------------------------------
# Colour palette
# ---------------------------------------------------------------------------
BG = "#071018"
CARD_BG = "#0E1A26"
ACCENT = "#62D8C8"
ACCENT_DIM = "#1A3A36"
TEXT = "#F5F7FA"
SUBTEXT = "#8A9BB0"
BUTTON_BG = "#132131"
LED_OFF = "#1C2E3E"
LED_ON = ACCENT
BG = "#071018"
CARD_BG = "#0E1A26"
ACCENT = "#62D8C8"
ACCENT_DIM = "#1A3A36"
TEXT = "#F5F7FA"
SUBTEXT = "#8A9BB0"
BUTTON_BG = "#132131"
LED_OFF = "#1C2E3E"
LED_ON = ACCENT
ACTIVE_STEP = "#FFFFFF"
@@ -147,10 +151,7 @@ class LEDStages(QWidget):
line_color = QColor(ACCENT) if i < self._active else QColor(LED_OFF)
pen = QPen(line_color, 2)
p.setPen(pen)
p.drawLine(
QPointF(cx + led_r + 3, cy),
QPointF(next_cx - led_r - 3, cy),
)
p.drawLine(QPointF(cx + led_r + 3, cy), QPointF(next_cx - led_r - 3, cy))
# --- LED circle ---
p.setPen(Qt.NoPen)
@@ -162,14 +163,8 @@ class LEDStages(QWidget):
pen = QPen(QColor(BG), 2)
pen.setCapStyle(Qt.RoundCap)
p.setPen(pen)
p.drawLine(
QPointF(cx - 4, cy),
QPointF(cx - 1, cy + 3),
)
p.drawLine(
QPointF(cx - 1, cy + 3),
QPointF(cx + 4, cy - 3),
)
p.drawLine(QPointF(cx - 4, cy), QPointF(cx - 1, cy + 3))
p.drawLine(QPointF(cx - 1, cy + 3), QPointF(cx + 4, cy - 3))
elif i == self._active:
# active — bright with glow ring
glow_pen = QPen(QColor(ACCENT + "55"), 4)
@@ -194,9 +189,7 @@ class LEDStages(QWidget):
fm = QFontMetrics(font)
text_w = fm.horizontalAdvance(name)
p.drawText(
QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2),
Qt.AlignCenter,
name,
QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2), Qt.AlignCenter, name
)
@@ -317,7 +310,9 @@ class QueueItemCard(QFrame):
text_col.setContentsMargins(0, 0, 0, 0)
title_lbl = QLabel(self._title)
title_lbl.setStyleSheet(f"color: {TEXT}; font-size: 13px; font-weight: 600; background: transparent;")
title_lbl.setStyleSheet(
f"color: {TEXT}; font-size: 13px; font-weight: 600; background: transparent;"
)
sub_lbl = QLabel(self._subtitle)
sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; background: transparent;")
@@ -399,7 +394,9 @@ class SampleCamera(QWidget):
# ── Title ──────────────────────────────────────────────────────
title = QLabel("S A M C A M E R A")
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet(f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 600;")
title.setStyleSheet(
f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 600;"
)
layout.addWidget(title)
# ── Camera card ────────────────────────────────────────────────
@@ -422,7 +419,7 @@ class SampleCamera(QWidget):
layout.addWidget(sub_lbl)
# ── LED step indicator ─────────────────────────────────────────
self._leds = LEDStages(active_step=1) # 0=Mount done, 1=Centre active
self._leds = LEDStages(active_step=1) # 0=Mount done, 1=Centre active
layout.addWidget(self._leds)
# ── Transport controls ─────────────────────────────────────────
@@ -435,7 +432,7 @@ class SampleCamera(QWidget):
buttons = [
("", False),
("", False),
("", True), # primary / highlighted
("", True), # primary / highlighted
("", False),
("", False),
]
@@ -449,7 +446,9 @@ class SampleCamera(QWidget):
# ── Up next header ─────────────────────────────────────────────
up_next_row = QHBoxLayout()
up_next_lbl = QLabel("UP NEXT")
up_next_lbl.setStyleSheet(f"color: {ACCENT}; font-size: 11px; letter-spacing: 2px; font-weight: 700;")
up_next_lbl.setStyleSheet(
f"color: {ACCENT}; font-size: 11px; letter-spacing: 2px; font-weight: 700;"
)
samples_lbl = QLabel("5 SAMPLES")
samples_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; letter-spacing: 1px;")
up_next_row.addWidget(up_next_lbl)
@@ -459,11 +458,11 @@ class SampleCamera(QWidget):
# ── Queue preview cards ────────────────────────────────────────
queue_items = [
("Crystal Plate 14 · Well C8", "Serial MX · 1 kHz", 4100, 6000, True),
("Crystal Plate 15 · Grid Scan", "Raster · 10 Hz", 0, 2000, False),
("Crystal Plate 13 · Well A1", "Serial MX · 1 kHz", 0, 5000, False),
("Crystal Plate 13 · Well B3", "Serial MX · 1 kHz", 0, 4500, False),
("Crystal Plate 14 · Well D5", "Serial MX · 1 kHz", 0, 6000, False),
("Crystal Plate 14 · Well C8", "Serial MX · 1 kHz", 4100, 6000, True),
("Crystal Plate 15 · Grid Scan", "Raster · 10 Hz", 0, 2000, False),
("Crystal Plate 13 · Well A1", "Serial MX · 1 kHz", 0, 5000, False),
("Crystal Plate 13 · Well B3", "Serial MX · 1 kHz", 0, 4500, False),
("Crystal Plate 14 · Well D5", "Serial MX · 1 kHz", 0, 6000, False),
]
queue_widget = QWidget()
@@ -502,7 +501,9 @@ class SampleCamera(QWidget):
title = QLabel("SAMPLE QUEUE")
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet(f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 700;")
title.setStyleSheet(
f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 700;"
)
layout.addWidget(title)
scroll = QScrollArea()
@@ -583,4 +584,4 @@ if __name__ == "__main__":
app = QApplication(sys.argv)
ui = SampleCamera()
ui.show()
sys.exit(app.exec())
sys.exit(app.exec())
+11 -4
View File
@@ -175,7 +175,8 @@ class PShellClient:
"""
return self._get_response(
requests.put(
url=self.url + "/run", json={"script": script, "pars": pars, "background": background, "async": False}
url=self.url + "/run",
json={"script": script, "pars": pars, "background": background, "async": False},
)
)
@@ -191,7 +192,9 @@ class PShellClient:
Command id (int), which is used to retrieve command execution status/result (get_result).
"""
statement = quote(statement)
return int(self._get_response(requests.get(url=self.url + "/evalAsync/" + statement), False))
return int(
self._get_response(requests.get(url=self.url + "/evalAsync/" + statement), False)
)
def start_run(self, script, pars=None, background=False):
"""Starts execution of a script in the interpreter.
@@ -279,9 +282,13 @@ class PShellClient:
if type == "json":
return self._get_response(requests.get(url=self.url + "/data-json/" + path), True)
elif type == "bin":
return self._get_binary_response(requests.get(url=self.url + "/data-bin/" + path, stream=True))
return self._get_binary_response(
requests.get(url=self.url + "/data-bin/" + path, stream=True)
)
return self._get_response(requests.get(url=self.url + "/data" + ("" if path is None else ("/" + path))), False)
return self._get_response(
requests.get(url=self.url + "/data" + ("" if path is None else ("/" + path))), False
)
def print_logs(self):
for log_line in self.get_logs():
+3 -8
View File
@@ -99,15 +99,10 @@ def main(argv=None):
help="Key pattern for SCAN (e.g., 'user:*'). If omitted, all keys are scanned.",
)
parser.add_argument(
"--scan-count",
type=int,
default=1000,
help="Hint for SCAN per iteration (not a limit).",
"--scan-count", type=int, default=1000, help="Hint for SCAN per iteration (not a limit)."
)
parser.add_argument(
"--include-ttl",
action="store_true",
help="Include TTL (seconds) for each key.",
"--include-ttl", action="store_true", help="Include TTL (seconds) for each key."
)
args = parser.parse_args(argv)
@@ -125,4 +120,4 @@ def main(argv=None):
if __name__ == "__main__":
sys.exit(main())
sys.exit(main())
+17 -56
View File
@@ -22,11 +22,7 @@ from aarecommon.models.models import (
SampleShortInfo,
ScanResultPayloadModel,
)
from aarecommon.models.raster_grid import (
CenterOfMassModel,
RasterGridRequest,
RasterPayloadModel,
)
from aarecommon.models.raster_grid import CenterOfMassModel, RasterGridRequest, RasterPayloadModel
from aarecommon.models.rotation_scan import RotationScanRequest
from aareDB import (
BeamlineParametersInput,
@@ -48,19 +44,13 @@ logger = setup_logger("aareDAQ")
class AareWrapper:
def __init__(
self,
bl: MXBeamline,
host: str = "https://mx-aaredb-dmz-01.psi.ch/dispatcher",
):
def __init__(self, bl: MXBeamline, host: str = "https://mx-aaredb-dmz-01.psi.ch/dispatcher"):
configuration = aareDB.Configuration(host=host)
# --- mTLS & SSL CONFIGURATION ---
# 1. Trust the Server (CA that signed mx-aaredb-dmz-01)
configuration.verify_ssl = True
configuration.ssl_ca_cert = (
"/etc/ssl/certs/secrets/mx-aaredb-dmz-01_Full_Chain_CA.pem"
)
configuration.ssl_ca_cert = "/etc/ssl/certs/secrets/mx-aaredb-dmz-01_Full_Chain_CA.pem"
# 2. Present Machine Identity (The certs that worked in curl)
beamline_name = bl.value.lower()
@@ -75,9 +65,7 @@ class AareWrapper:
self.client = aareDB.ApiClient(configuration)
# Identity Forwarding (Optional now that mTLS is active, but safe to keep)
self.client.default_headers["X-Shared-Password"] = os.getenv(
"AAREDB_SHARED_PASSWORD"
)
self.client.default_headers["X-Shared-Password"] = os.getenv("AAREDB_SHARED_PASSWORD")
self.__host = host
self.__tell_api = aareDB.TellsRunnerApi(self.client)
self.__sample_api = aareDB.SamplesRunnerApi(self.client)
@@ -95,15 +83,11 @@ class AareWrapper:
for i in input_list:
t = SetTellPosition(
puck_name=i.puck_name,
segment=i.location.segment,
puck_in_segment=i.location.pos,
puck_name=i.puck_name, segment=i.location.segment, puck_in_segment=i.location.pos
)
o.append(t)
payload = SetTellPositionRequest(pucks=o, tell=self.__bl.value.upper())
ret = self.__tell_api.set_tell_positions(
set_tell_position_request=payload,
)
ret = self.__tell_api.set_tell_positions(set_tell_position_request=payload)
logger.debug(ret)
@log_timing(logger, "AareDB call")
@@ -111,9 +95,7 @@ class AareWrapper:
from aareDB.models import ManualSampleCreate
manual_sample = ManualSampleCreate(
pgroup=s.user,
sample_name=s.sample_name,
data_collection_parameters=s.aaredb_params,
pgroup=s.user, sample_name=s.sample_name, data_collection_parameters=s.aaredb_params
)
try:
@@ -123,16 +105,11 @@ class AareWrapper:
@log_timing(logger, "AareDB call")
def send_sample_event(
self,
sample_id: StrictInt,
event_type: SampleEventType,
comment: Optional[str] = None,
self, sample_id: StrictInt, event_type: SampleEventType, comment: Optional[str] = None
) -> None:
if sample_id is None or sample_id < 0:
if sample_id is None:
logger.debug(
f"Sample db_id is None, skipping sample event {event_type!s}"
)
logger.debug(f"Sample db_id is None, skipping sample event {event_type!s}")
elif sample_id < 0:
logger.debug(
f"Sample db_id is invalid ({sample_id}), skipping sample event {event_type!s}"
@@ -141,20 +118,14 @@ class AareWrapper:
try:
self.__sample_api.create_sample_event(
sample_id=sample_id,
sample_event_create=SampleEventCreate(
event_type=event_type, comment=comment
),
sample_event_create=SampleEventCreate(event_type=event_type, comment=comment),
)
except Exception as e:
logger.error(f"Error sending sample event {event_type!s} to db: {e}")
@log_timing(logger, "AareDB call")
def upload_image(
self,
sample_id: int,
filename: str,
bgr_image: np.ndarray,
message: Optional[str] = None,
self, sample_id: int, filename: str, bgr_image: np.ndarray, message: Optional[str] = None
):
_, buffer = cv2.imencode(".jpg", bgr_image)
jpeg_bytes = io.BytesIO(buffer)
@@ -176,9 +147,7 @@ class AareWrapper:
logger.debug(f"Response status code: {response.status_code}")
@log_timing(logger, "AareDB call")
def upload_jpg(
self, sample_id: int, filename: str, jpg_image, message: Optional[str] = None
):
def upload_jpg(self, sample_id: int, filename: str, jpg_image, message: Optional[str] = None):
logger.debug(f"jppg_image of type: {type(jpg_image)}")
url = f"{self.__host}/protected_router/sample_runner/{sample_id}/upload-images"
headers = {
@@ -226,9 +195,7 @@ class AareWrapper:
)
characterization = None
dataset = Datasets(
filepath=r.file_prefix,
status="written",
written_at=datetime.datetime.now(),
filepath=r.file_prefix, status="written", written_at=datetime.datetime.now()
)
detector_data = DetectorParameters(
manufacturer="DECTRIS",
@@ -265,8 +232,7 @@ class AareWrapper:
type="standard", beamline_parameters=beamline_params, sample_id=s.db_id
)
response = self.__sample_api.create_experiment_parameters_for_sample(
sample_id=s.db_id,
experiment_parameters_create=experiment_params_payload,
sample_id=s.db_id, experiment_parameters_create=experiment_params_payload
)
# logger.debug("Experiment parameters created:", response)
except Exception as e:
@@ -292,9 +258,7 @@ class AareWrapper:
exposureTime_s=round(r.exp_time_s, 4),
)
dataset = Datasets(
filepath=r.file_prefix,
status="written",
written_at=datetime.datetime.now(),
filepath=r.file_prefix, status="written", written_at=datetime.datetime.now()
)
detector_data = DetectorParameters(
manufacturer="DECTRIS",
@@ -330,8 +294,7 @@ class AareWrapper:
type="standard", beamline_parameters=beamline_params, sample_id=s.db_id
)
response = self.__sample_api.create_experiment_parameters_for_sample(
sample_id=s.db_id,
experiment_parameters_create=experiment_params_payload,
sample_id=s.db_id, experiment_parameters_create=experiment_params_payload
)
# logger.info("Experiment parameters created:", response)
except Exception as e:
@@ -416,9 +379,7 @@ class AareWrapper:
for img in raster_result.images
]
except Exception as e:
logger.warning(
f"raster score computation failed, sending null score: {e}"
)
logger.warning(f"raster score computation failed, sending null score: {e}")
score = None
payload = RasterPayloadModel(
+12 -46
View File
@@ -14,12 +14,7 @@ from aarecommon.errors.exception_handler import (
AuthErrorCode,
UserRightsException,
)
from aarecommon.models.auth import (
BatonRequest,
BatonRequestStatus,
BatonStatus,
BatonTransferQueue,
)
from aarecommon.models.auth import BatonRequest, BatonRequestStatus, BatonStatus, BatonTransferQueue
from aarecommon.models.models import SessionsStateEnum
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
@@ -55,9 +50,7 @@ class TokenData(BaseModel):
def create_access_token(token: TokenData):
to_encode = token.model_dump()
to_encode.update(
{"exp": datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)}
)
to_encode.update({"exp": datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
@@ -82,9 +75,7 @@ def authenticate_from_proxy_header(request: Request) -> str:
only the Apache proxy running on the same host can supply it.
"""
client_host = request.client.host if request.client else None
logger.debug(
f"[auth] /token client_host={client_host!r} request.client={request.client!r}"
)
logger.debug(f"[auth] /token client_host={client_host!r} request.client={request.client!r}")
if not _is_loopback(client_host):
logger.warning(
f"[auth] Rejecting X-Remote-User: client_host {client_host!r} is not loopback"
@@ -114,13 +105,9 @@ def authenticate_user(cfg: BeamlineConfig, username: str) -> str:
super_user = username in SUPER_USERS
pgroups = [group for group in supplementary_groups if group.startswith("p")]
staff = (
"unx-mxgroup" in supplementary_groups
or "unx-sls_mx" in supplementary_groups
or super_user
)
token = TokenData(
sub=username, pgroups=pgroups, staff=staff, session=cfg.generate_session()
"unx-mxgroup" in supplementary_groups or "unx-sls_mx" in supplementary_groups or super_user
)
token = TokenData(sub=username, pgroups=pgroups, staff=staff, session=cfg.generate_session())
return create_access_token(token)
@@ -169,9 +156,7 @@ def check_jwt_rw(cfg: BeamlineConfig, data: TokenData) -> None:
def check_jwt_staff_only(data: TokenData) -> None:
if not data.staff:
raise UserRightsException(
message="Not member of the MX staff.",
status_code=403,
code=AuthErrorCode.NOT_STAFF,
message="Not member of the MX staff.", status_code=403, code=AuthErrorCode.NOT_STAFF
)
@@ -201,9 +186,7 @@ def force_current_sesion(cfg: BeamlineConfig, data: TokenData) -> None:
)
def _finalize_expired_baton_request(
cfg: BeamlineConfig, pending: BatonRequest
) -> BatonStatus:
def _finalize_expired_baton_request(cfg: BeamlineConfig, pending: BatonRequest) -> BatonStatus:
"""
Resolve an expired baton request in one place.
@@ -357,19 +340,10 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict:
to_pgroup=cfg.pgroup,
expiry_sec=SESSION_EXPIRE_SECONDS,
)
return {
"granted": True,
"override": True,
"message": "Staff override - baton acquired",
}
return {"granted": True, "override": True, "message": "Staff override - baton acquired"}
# Policy check: non-staff requesting from staff
if (
holder
and holder.is_staff
and not data.staff
and not cfg.allow_non_staff_request_from_staff
):
if holder and holder.is_staff and not data.staff and not cfg.allow_non_staff_request_from_staff:
return {
"error": True,
"message": "Requesting baton from staff is disabled by backend policy.",
@@ -402,9 +376,7 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict:
msg = f"Request sent to {holder.username if holder else 'current holder'}"
if is_busy:
msg += (
" (Note: beamline is currently busy, transfer will be queued if accepted)"
)
msg += " (Note: beamline is currently busy, transfer will be queued if accepted)"
return {
"pending": True,
@@ -415,9 +387,7 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict:
}
def respond_to_baton_request(
cfg: BeamlineConfig, data: TokenData, accept: bool
) -> dict:
def respond_to_baton_request(cfg: BeamlineConfig, data: TokenData, accept: bool) -> dict:
"""
Current baton holder responds to a pending request.
"""
@@ -441,11 +411,7 @@ def respond_to_baton_request(
to_pgroup=cfg.pgroup,
expiry_sec=SESSION_EXPIRE_SECONDS,
)
return {
"accepted": True,
"transferred": True,
"message": "Baton transferred",
}
return {"accepted": True, "transferred": True, "message": "Baton transferred"}
else:
# Beamline busy, queue the transfer
cfg.queued_baton_transfer = BatonTransferQueue(
+4 -2
View File
@@ -2,7 +2,9 @@ import cv2
import numpy as np
def calculate_focus_measure(image: np.ndarray, center_x: float, center_y: float, radius: float) -> float:
def calculate_focus_measure(
image: np.ndarray, center_x: float, center_y: float, radius: float
) -> float:
"""
Calculate the focus measure within a circular region around a point.
@@ -22,7 +24,7 @@ def calculate_focus_measure(image: np.ndarray, center_x: float, center_y: float,
# Create a circular mask
height, width = gray.shape
y, x = np.ogrid[:height, :width]
mask = ((x - center_x) ** 2 + (y - center_y) ** 2 <= radius ** 2)
mask = (x - center_x) ** 2 + (y - center_y) ** 2 <= radius**2
# Apply Laplacian operator
laplacian = cv2.Laplacian(gray, cv2.CV_64F)
+19 -14
View File
@@ -2,6 +2,7 @@ import numpy as np
import cv2
from scipy.optimize import curve_fit
class Gaussian2Dfit:
center_x: float
center_y: float
@@ -10,6 +11,7 @@ class Gaussian2Dfit:
rotation_angle: float
peak_intensity: float
def gaussian_2d(xy, x0, y0, sigma_x, sigma_y, theta, A, offset):
x, y = xy
x_rot = (x - x0) * np.cos(theta) + (y - y0) * np.sin(theta)
@@ -17,7 +19,8 @@ def gaussian_2d(xy, x0, y0, sigma_x, sigma_y, theta, A, offset):
gaussian = A * np.exp(-(x_rot**2 / (2 * sigma_x**2) + y_rot**2 / (2 * sigma_y**2))) + offset
return gaussian.ravel()
def beamcenter_fit(image:np.ndarray) -> Gaussian2Dfit:
def beamcenter_fit(image: np.ndarray) -> Gaussian2Dfit:
"""
Takes a np.ndarray image and returns a Gaussian2Dfit object containing the fitted parameters
Returns None if the fit did not converge
@@ -25,7 +28,9 @@ def beamcenter_fit(image:np.ndarray) -> Gaussian2Dfit:
"""
# Preprocess the image
blurred_image = cv2.GaussianBlur(image, (5, 5), 0)
normalized_image = cv2.normalize(blurred_image, None, 0, 255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8U)
normalized_image = cv2.normalize(
blurred_image, None, 0, 255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8U
)
# Threshold to isolate the brightest spot (main blob)
_, binary_mask = cv2.threshold(normalized_image, 180, 255, cv2.THRESH_BINARY)
@@ -43,7 +48,7 @@ def beamcenter_fit(image:np.ndarray) -> Gaussian2Dfit:
new_h = min(image.shape[0] - new_y, int(h * (1 + enlargement_factor)))
# Update the ROI
roi = normalized_image[new_y:new_y + new_h, new_x:new_x + new_w]
roi = normalized_image[new_y : new_y + new_h, new_x : new_x + new_w]
# Fit the 2D Gaussian
X, Y = np.meshgrid(np.arange(roi.shape[1]), np.arange(roi.shape[0]))
@@ -51,13 +56,13 @@ def beamcenter_fit(image:np.ndarray) -> Gaussian2Dfit:
# Initial guess for Gaussian parameters
initial_guess = (
new_w / 2, # x0
new_h / 2, # y0
new_w / 4, # sigma_x
new_h / 4, # sigma_y
0, # theta
np.max(roi), # A (peak intensity)
np.min(roi) # offset
new_w / 2, # x0
new_h / 2, # y0
new_w / 4, # sigma_x
new_h / 4, # sigma_y
0, # theta
np.max(roi), # A (peak intensity)
np.min(roi), # offset
)
# Flatten ROI for curve fitting
@@ -71,10 +76,10 @@ def beamcenter_fit(image:np.ndarray) -> Gaussian2Dfit:
# Extract fitted parameters
x0, y0, sigma_x, sigma_y, ini_theta, intensity, _ = popt
result = Gaussian2Dfit()
result.center_x = x0+new_x
result.center_y = y0+new_y
result.center_x = x0 + new_x
result.center_y = y0 + new_y
result.sigma_x = sigma_x
result.sigma_y = sigma_y
result.rotation_angle = np.degrees(ini_theta)%360
result.rotation_angle = np.degrees(ini_theta) % 360
result.peak_intensity = intensity
return result
return result
+36 -117
View File
@@ -130,9 +130,7 @@ class BeamlineConfig:
return f"{self.__bl}:gui_session:{session}"
def _write_gui_session(
self,
payload: OpenGuiSessionInfo,
expiry_sec: int | None = None,
self, payload: OpenGuiSessionInfo, expiry_sec: int | None = None
) -> None:
expiry = int(expiry_sec or self.GUI_SESSION_EXPIRE_SECONDS)
@@ -163,12 +161,7 @@ class BeamlineConfig:
return None
def touch_gui_session(
self,
*,
session: int,
username: str,
staff: bool = False,
expiry_sec: int,
self, *, session: int, username: str, staff: bool = False, expiry_sec: int
) -> OpenGuiSessionInfo:
existing = self._read_gui_session(session)
holder = self.baton_holder
@@ -179,19 +172,11 @@ class BeamlineConfig:
username=username,
staff=staff,
last_seen_ts=time.time(),
last_interaction_ts=existing.last_interaction_ts
if existing is not None
else None,
last_interaction_ts=existing.last_interaction_ts if existing is not None else None,
close_requested=existing.close_requested if existing is not None else False,
close_requested_by=existing.close_requested_by
if existing is not None
else None,
close_requested_at=existing.close_requested_at
if existing is not None
else None,
close_grace_seconds=existing.close_grace_seconds
if existing is not None
else None,
close_requested_by=existing.close_requested_by if existing is not None else None,
close_requested_at=existing.close_requested_at if existing is not None else None,
close_grace_seconds=existing.close_grace_seconds if existing is not None else None,
holds_baton=holds_baton,
)
@@ -213,11 +198,7 @@ class BeamlineConfig:
return payload
def request_gui_close(
self,
*,
session: int,
requested_by: str,
grace_seconds: int = 60,
self, *, session: int, requested_by: str, grace_seconds: int = 60
) -> OpenGuiSessionInfo | None:
payload = self._read_gui_session(session)
if payload is None:
@@ -293,9 +274,7 @@ class BeamlineConfig:
return None
holder = self.baton_holder
payload.holds_baton = bool(
holder is not None and holder.session == payload.session
)
payload.holds_baton = bool(holder is not None and holder.session == payload.session)
return payload
# Session and authentication management
@@ -331,9 +310,7 @@ class BeamlineConfig:
return baton.session
def session_status(self, session: int) -> SessionStatus:
return SessionStatus(
session=self.session_state(session), current_pgroup=self.pgroup
)
return SessionStatus(session=self.session_state(session), current_pgroup=self.pgroup)
def session_state(self, session: int) -> SessionsStateEnum:
holder = self.baton_holder
@@ -362,9 +339,7 @@ class BeamlineConfig:
return SessionsStateEnum.OwnedByElse
def try_set_active_session(self, session: int, expiry_sec: int) -> None:
with redis_lock.Lock(
self.__client, f"{self.__bl}:active_session_lock", expire=10
):
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
active = self.active_session
if active is None:
self.__client.set(f"{self.__bl}:active_session", session)
@@ -376,14 +351,10 @@ class BeamlineConfig:
# TODO finish setting this up!
def try_extend_active_session(self, session: int, expiry_sec: int) -> None:
with redis_lock.Lock(
self.__client, f"{self.__bl}:active_session_lock", expire=10
):
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
active = self.active_session
if active is None:
raise Exception(
"There is no active session with given id. Try again later."
)
raise Exception("There is no active session with given id. Try again later.")
# if active == session:
# self.__client.expire(f"{self.__bl}:active_session", expiry_sec, gt=True)
@@ -402,9 +373,7 @@ class BeamlineConfig:
)
def end_active_session(self, session: int) -> None:
with redis_lock.Lock(
self.__client, f"{self.__bl}:active_session_lock", expire=10
):
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
active = self.active_session
if active is None:
return
@@ -414,9 +383,7 @@ class BeamlineConfig:
def force_set_active_session(self, session: int, expiry_sec: int) -> None:
# Ensure that there is no active try-set for active session
with redis_lock.Lock(
self.__client, f"{self.__bl}:active_session_lock", expire=10
):
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
self.__client.set(f"{self.__bl}:active_session", session)
self.__client.expire(f"{self.__bl}:active_session", expiry_sec)
@@ -481,9 +448,7 @@ class BeamlineConfig:
if transfer is None:
self.__client.delete(f"{self.__bl}:baton_transfer_queue")
else:
self.__client.set(
f"{self.__bl}:baton_transfer_queue", transfer.model_dump_json()
)
self.__client.set(f"{self.__bl}:baton_transfer_queue", transfer.model_dump_json())
def can_transfer_baton_now(self) -> bool:
"""Check if baton can be transferred (beamline not mid-operation)."""
@@ -507,16 +472,11 @@ class BeamlineConfig:
Atomically transfer the baton to a new holder.
Use existing active_session_lock for consistency.
"""
with redis_lock.Lock(
self.__client, f"{self.__bl}:active_session_lock", expire=10
):
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
self.__client.set(f"{self.__bl}:active_session", to_session)
self.__client.expire(f"{self.__bl}:active_session", expiry_sec)
self.baton_holder = BatonHolderInfo(
username=to_username,
session=to_session,
is_staff=to_is_staff,
pgroup=to_pgroup,
username=to_username, session=to_session, is_staff=to_is_staff, pgroup=to_pgroup
)
# Clear any pending request or queued transfer
self.clear_pending_baton_request()
@@ -635,14 +595,11 @@ class BeamlineConfig:
def pixel_to_mm(self, zoom: float) -> float:
cfg = self.settings
base_pixel_in_mm = 1.0 / (
cfg.camera_translation_factor_b
* np.exp(cfg.camera_translation_factor_a * zoom)
cfg.camera_translation_factor_b * np.exp(cfg.camera_translation_factor_a * zoom)
)
# Apply lens magnification correction relative to the default 10x lens.
# A lower magnification lens (e.g. 5x) makes each pixel cover more physical space.
lens_magnification = cfg_get(
"daq.hardware.lens_magnification", DEFAULT_LENS_MAGNIFICATION
)
lens_magnification = cfg_get("daq.hardware.lens_magnification", DEFAULT_LENS_MAGNIFICATION)
try:
lens_magnification = float(lens_magnification)
except (TypeError, ValueError):
@@ -659,15 +616,11 @@ class BeamlineConfig:
z = ln(lens_factor / (b * target)) / a
"""
if target_pixel_in_mm <= 0:
raise ValueError(
f"target_pixel_in_mm must be > 0, got {target_pixel_in_mm}"
)
raise ValueError(f"target_pixel_in_mm must be > 0, got {target_pixel_in_mm}")
cfg = self.settings
a = cfg.camera_translation_factor_a
b = cfg.camera_translation_factor_b
lens_magnification = cfg_get(
"daq.hardware.lens_magnification", DEFAULT_LENS_MAGNIFICATION
)
lens_magnification = cfg_get("daq.hardware.lens_magnification", DEFAULT_LENS_MAGNIFICATION)
try:
lens_magnification = float(lens_magnification)
except (TypeError, ValueError):
@@ -733,9 +686,7 @@ class BeamlineConfig:
def settings(self, data: BeamlineSettingsModel):
with redis_lock.Lock(self.__client, f"{self.__bl}:settings_lock", expire=10):
current = self.__get_settings()
updated_data = current.model_copy(
update=data.model_dump(exclude_unset=True)
)
updated_data = current.model_copy(update=data.model_dump(exclude_unset=True))
self.__client.set(f"{self.__bl}:settings", updated_data.model_dump_json())
@property
@@ -752,14 +703,10 @@ class BeamlineConfig:
self.__client.set(f"{self.__bl}:cryojet_settings", data.model_dump_json())
def get_alc_bkg(self, zoom: float, exp: float, gain: float) -> np.ndarray | None:
return base64_to_numpy(
self.__client.get(f"{self.__bl}:bkg{zoom:.1f}_{exp:.2f}_{gain:.1f}")
)
return base64_to_numpy(self.__client.get(f"{self.__bl}:bkg{zoom:.1f}_{exp:.2f}_{gain:.1f}"))
def put_alc_bkg(self, zoom: float, exp: float, gain: float, data: np.ndarray):
self.__client.set(
f"{self.__bl}:bkg{zoom:.1f}_{exp:.2f}_{gain:.1f}", numpy_to_base64(data)
)
self.__client.set(f"{self.__bl}:bkg{zoom:.1f}_{exp:.2f}_{gain:.1f}", numpy_to_base64(data))
@property
def spreadsheet(self) -> SampleShortInfoList:
@@ -881,15 +828,10 @@ class BeamlineConfig:
mode = self.zoom_mode
if not mode or not isinstance(mode, ZoomModeEnum):
raise Exception("incorrect zoom settings mode used")
self.__client.set(
f"{self.__bl}:{self.zoom_setting_string(mode)}", data.model_dump_json()
)
self.__client.set(f"{self.__bl}:{self.zoom_setting_string(mode)}", data.model_dump_json())
def save_zoom_camera_setting(
self,
zoom_value: float,
settings: SampleCameraSettings,
mode: ZoomModeEnum | None = None,
self, zoom_value: float, settings: SampleCameraSettings, mode: ZoomModeEnum | None = None
) -> None:
"""Insert/replace the camera settings for a single zoom stop in a given
mode's table (persisted to Redis under that mode's key). Defaults to the
@@ -897,11 +839,7 @@ class BeamlineConfig:
mode = mode or self.zoom_mode
key = f"{self.__bl}:{self.zoom_setting_string(mode)}"
tmp = self.__client.get(key)
model = (
ZoomModel(**json.loads(tmp))
if tmp is not None
else zoom_manager(mode, self.__mxb)
)
model = ZoomModel(**json.loads(tmp)) if tmp is not None else zoom_manager(mode, self.__mxb)
model.z[zoom_value] = settings
self.__client.set(key, model.model_dump_json())
@@ -960,8 +898,7 @@ class BeamlineConfig:
def mark_beam(self, x_pxl: float, y_pxl: float, zoom: float):
self.__client.hset(
f"{self.__bl}:beam_mark",
mapping={f"{zoom}": json.dumps({"x": x_pxl, "y": y_pxl})},
f"{self.__bl}:beam_mark", mapping={f"{zoom}": json.dumps({"x": x_pxl, "y": y_pxl})}
)
vals = self.__client.hgetall(f"{self.__bl}:beam_mark")
@@ -1061,9 +998,7 @@ class BeamlineConfig:
if input_params is None:
self.__client.delete(f"{self.__bl}:simple_input_params")
else:
self.__client.set(
f"{self.__bl}:simple_input_params", input_params.model_dump_json()
)
self.__client.set(f"{self.__bl}:simple_input_params", input_params.model_dump_json())
@property
def auto_params(self) -> SimpleScanParameters | None:
@@ -1102,20 +1037,13 @@ class BeamlineConfig:
seq = int(seq_raw) if seq_raw is not None else 0
progress = json.loads(payload_raw) if payload_raw else None
return {
"seq": seq,
"progress": progress,
}
return {"seq": seq, "progress": progress}
def set_automation_progress_state(
self, progress: AutomationProgress | dict
) -> dict:
def set_automation_progress_state(self, progress: AutomationProgress | dict) -> dict:
def _json_default(value):
if isinstance(value, datetime):
return value.isoformat()
raise TypeError(
f"Object of type {type(value).__name__} is not JSON serializable"
)
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
if isinstance(progress, dict):
payload = dict(progress)
@@ -1124,9 +1052,7 @@ class BeamlineConfig:
elif is_dataclass(progress):
payload = asdict(progress)
else:
raise TypeError(
f"Unsupported automation progress type: {type(progress).__name__}"
)
raise TypeError(f"Unsupported automation progress type: {type(progress).__name__}")
next_seq = int(self.__client.incr(self._automation_progress_seq_key()))
self.__client.set(
@@ -1134,10 +1060,7 @@ class BeamlineConfig:
json.dumps(payload, separators=(",", ":"), default=_json_default),
)
return {
"seq": next_seq,
"progress": payload,
}
return {"seq": next_seq, "progress": payload}
@property
def failed_mount_count(self) -> int:
@@ -1262,12 +1185,8 @@ class BeamlineConfig:
def set_detector_metadata(self, payload: dict) -> dict:
safe_payload = dict(payload or {})
safe_payload["dtz_low"] = self._coerce_optional_float(
safe_payload.get("dtz_low")
)
safe_payload["dtz_high"] = self._coerce_optional_float(
safe_payload.get("dtz_high")
)
safe_payload["dtz_low"] = self._coerce_optional_float(safe_payload.get("dtz_low"))
safe_payload["dtz_high"] = self._coerce_optional_float(safe_payload.get("dtz_high"))
safe_payload["pixel_size_mm"] = self._coerce_optional_float(
safe_payload.get("pixel_size_mm")
)
+2 -1
View File
@@ -1,5 +1,6 @@
from pydantic import BaseModel, Field
class LocalContactConfigModel(BaseModel):
mount_to_center_sleep_s: float = Field(
default=0.0,
@@ -26,4 +27,4 @@ class LocalContactConfigModel(BaseModel):
"the ML prediction is loop_all. 0.5 means add 50% of the detected "
"cell count above and below."
),
)
)
+143 -453
View File
File diff suppressed because it is too large Load Diff
+8 -33
View File
@@ -76,9 +76,7 @@ class BeamlineDevices:
self.__ringcurrent = self.bec_worker.ring_current
self.__zoom = SetGetPV(
name=f"zoom",
setpv=f"{BEAMLINE}-ES-MS:ZOOM.VAL",
getpv=f"{BEAMLINE}-ES-MS:ZOOM.RBV",
name=f"zoom", setpv=f"{BEAMLINE}-ES-MS:ZOOM.VAL", getpv=f"{BEAMLINE}-ES-MS:ZOOM.RBV"
)
self.__cryojet_pos = EnumPV(
@@ -88,9 +86,7 @@ class BeamlineDevices:
timeout=10.0,
)
self.__cryojet_x = MyMotor(
f"{BEAMLINE}-ES-CS:TRX"
) # currently in is 5 out is 15?
self.__cryojet_x = MyMotor(f"{BEAMLINE}-ES-CS:TRX") # currently in is 5 out is 15?
self.__cryojet_temperature_get = PV(f"{BEAMLINE}-ES-CS:TEMP_RBV")
self.__cryojet_temperature_set = PV(f"{BEAMLINE}-ES-CS:TEMP.VAL")
@@ -121,9 +117,7 @@ class BeamlineDevices:
try:
self.bec_worker.shutdown_client()
except Exception as e:
logger.warning(
f"Failed to shutdown previous BEC worker cleanly: {e}"
)
logger.warning(f"Failed to shutdown previous BEC worker cleanly: {e}")
finally:
beamline = MXBeamline.SIMULATED if simulated else self._beamline
logger.info(f"Restarting BEC worker with simulated={simulated}")
@@ -251,8 +245,7 @@ class BeamlineDevices:
@property
def samcam_settings(self) -> SampleCameraSettings:
return SampleCameraSettings(
gain=self.__sample_cam.gain_rbv.value,
exposure=self.__sample_cam.expo_rbv.value,
gain=self.__sample_cam.gain_rbv.value, exposure=self.__sample_cam.expo_rbv.value
)
@samcam_settings.setter
@@ -314,17 +307,9 @@ class BeamlineDevices:
@aerotech_pos.setter
def aerotech_pos(
self,
coord: AerotechCoordinate,
/,
wait: bool = True,
incremental: bool = False,
self, coord: AerotechCoordinate, /, wait: bool = True, incremental: bool = False
):
self.aerotech.position(
coord,
wait=wait,
incremental=incremental,
)
self.aerotech.position(coord, wait=wait, incremental=incremental)
@property
def aerotech_omega(self) -> float:
@@ -334,19 +319,9 @@ class BeamlineDevices:
def aerotech_omega(self, val: float):
self.set_aerotech_omega(val, wait=True)
def set_aerotech_omega(
self,
val: float,
/,
wait: bool = True,
incremental: bool = False,
):
def set_aerotech_omega(self, val: float, /, wait: bool = True, incremental: bool = False):
target = AerotechCoordinate(omega_deg=val)
return self.aerotech.position(
target,
wait=wait,
incremental=incremental,
)
return self.aerotech.position(target, wait=wait, incremental=incremental)
def aerotech_stop(self):
# TODO link cancel in GUI to cancel in aerotech if not already done
+23 -96
View File
@@ -7,12 +7,7 @@ import numpy as np
from aarecommon.config.logger import setup_logger
from aarecommon.config.logger_events import log_timing
from aarecommon.models.beamline import MXBeamline
from aarecommon.models.models import (
BoundingBoxModel,
MLBoxModel,
MLBoxType,
MLOutputModel,
)
from aarecommon.models.models import BoundingBoxModel, MLBoxModel, MLBoxType, MLOutputModel
from aarelcinfer_client.models import LatestPredictionModel
logger = setup_logger("aareDAQ")
@@ -118,9 +113,7 @@ class MlBox:
def _prediction_score(predictions: MLOutputModel | None) -> tuple[int, float]:
if predictions is None or not predictions.boxes:
return (0, 0.0)
confs = [
float(m.conf or 0.0) for m in predictions.boxes.values() if m is not None
]
confs = [float(m.conf or 0.0) for m in predictions.boxes.values() if m is not None]
return (len(confs), max(confs) if confs else 0.0)
def _fetch_prediction_bundle(self):
@@ -128,9 +121,7 @@ class MlBox:
for attempt in range(1, self.RETRY_COUNT + 1):
try:
bundle = self.__wrapper.get_latest_prediction_bundle()
logger.debug(
f"Fetched prediction bundle on attempt {attempt}/{self.RETRY_COUNT}"
)
logger.debug(f"Fetched prediction bundle on attempt {attempt}/{self.RETRY_COUNT}")
return bundle
except Exception as e:
last_error = e
@@ -165,9 +156,7 @@ class MlBox:
if isinstance(raw, (list, tuple)) and len(raw) >= 2:
return float(raw[0]), float(raw[1])
except Exception as e:
logger.warning(
f"Failed to parse target_point from prediction metadata: {e}"
)
logger.warning(f"Failed to parse target_point from prediction metadata: {e}")
return None
@@ -182,9 +171,7 @@ class MlBox:
if raw_focus is not None:
focus = float(raw_focus)
except Exception as e:
logger.warning(
f"Failed to parse focus_score from prediction metadata: {e}"
)
logger.warning(f"Failed to parse focus_score from prediction metadata: {e}")
return MLBundleMeta(target_point=target_point, focus=focus)
@@ -242,13 +229,9 @@ class MlBox:
if best_predictions is None:
logger.info("No valid prediction bundle metadata was available")
elif not best_predictions.boxes:
logger.info(
"Prediction bundle candidates contained no supported detections"
)
logger.info("Prediction bundle candidates contained no supported detections")
return MLBundle(
predictions=best_predictions, image=best_image, bundle_meta=best_meta
)
return MLBundle(predictions=best_predictions, image=best_image, bundle_meta=best_meta)
def _get_latest_bundle_image(self) -> np.ndarray | None:
bundle = self._fetch_prediction_bundle()
@@ -264,18 +247,8 @@ class MlBox:
# Extract (x1,y1,x2,y2) from MLBoxModel/BoundingBoxModel
if not box0 or not box0.box or not box1 or not box1.box:
raise ValueError("Invalid boxes passed to check_box_relation")
x10, y10, x20, y20 = (
box0.box.top_x,
box0.box.top_y,
box0.box.bottom_x,
box0.box.bottom_y,
)
x11, y11, x21, y21 = (
box1.box.top_x,
box1.box.top_y,
box1.box.bottom_x,
box1.box.bottom_y,
)
x10, y10, x20, y20 = (box0.box.top_x, box0.box.top_y, box0.box.bottom_x, box0.box.bottom_y)
x11, y11, x21, y21 = (box1.box.top_x, box1.box.top_y, box1.box.bottom_x, box1.box.bottom_y)
left, top, overlap_x, overlap_y = False, False, False, False
# Compute centers for robustness
@@ -298,12 +271,7 @@ class MlBox:
else:
overlap_y = True
return {
"left": left,
"top": top,
"overlap_x": overlap_x,
"overlap_y": overlap_y,
}
return {"left": left, "top": top, "overlap_x": overlap_x, "overlap_y": overlap_y}
def box_relation(self, boxes: MLOutputModel, classes: list[str] | None = None):
pin: MLBoxModel | None = boxes.get_best_for_class(MLBoxType.PIN)
@@ -314,11 +282,7 @@ class MlBox:
box_relative_to_pin: dict[str, bool] = {}
for box_type in classes:
# Ensure the entry exists and is an MLBoxModel
model = (
boxes.get(box_type)
if hasattr(boxes, "get")
else boxes.boxes.get(box_type)
)
model = boxes.get(box_type) if hasattr(boxes, "get") else boxes.boxes.get(box_type)
if model:
box_relative_to_pin = self.check_box_relation(pin, model)
@@ -428,15 +392,9 @@ class MlBox:
logger.debug(f"Failed to parse bundle detection: {e}")
continue
out.add_box(
cls=cls,
box_tuple=(x1, y1, x2, y2),
conf=conf,
)
out.add_box(cls=cls, box_tuple=(x1, y1, x2, y2), conf=conf)
logger.debug(
f"Parsed {len(out.boxes)} supported detections from bundle metadata"
)
logger.debug(f"Parsed {len(out.boxes)} supported detections from bundle metadata")
return out
def get_best_detections(self, results) -> MLOutputModel | None:
@@ -464,21 +422,14 @@ class MlBox:
Best MLBoxModel according to preferences
"""
if preferred_class is None:
order = (
MLBoxType.CRYSTAL,
MLBoxType.LOOP_FACE,
MLBoxType.LOOP_ALL,
MLBoxType.PIN,
)
order = (MLBoxType.CRYSTAL, MLBoxType.LOOP_FACE, MLBoxType.LOOP_ALL, MLBoxType.PIN)
else:
if isinstance(preferred_class, MLBoxType):
order = (preferred_class,)
elif isinstance(preferred_class, int):
order = (MLBoxType(preferred_class),)
else:
order = tuple(
MLBoxType(c) if isinstance(c, int) else c for c in preferred_class
)
order = tuple(MLBoxType(c) if isinstance(c, int) else c for c in preferred_class)
# Get best box from each class
best_boxes = {}
@@ -515,25 +466,17 @@ class MlBox:
@staticmethod
def get_preferred_class_box(
boxes: MLOutputModel,
preferred_class: Optional[Iterable[int] | int | MLBoxType] = None,
boxes: MLOutputModel, preferred_class: Optional[Iterable[int] | int | MLBoxType] = None
) -> Optional[MLBoxModel]:
if preferred_class is None:
order = (
MLBoxType.CRYSTAL,
MLBoxType.LOOP_FACE,
MLBoxType.LOOP_ALL,
MLBoxType.PIN,
)
order = (MLBoxType.CRYSTAL, MLBoxType.LOOP_FACE, MLBoxType.LOOP_ALL, MLBoxType.PIN)
else:
if isinstance(preferred_class, MLBoxType):
order = (preferred_class,)
elif isinstance(preferred_class, int):
order = (MLBoxType(preferred_class),)
else:
order = tuple(
MLBoxType(c) if isinstance(c, int) else c for c in preferred_class
)
order = tuple(MLBoxType(c) if isinstance(c, int) else c for c in preferred_class)
for cls in order:
m = boxes.get_best_for_class(cls)
@@ -548,12 +491,7 @@ class MlBox:
confidence_min: float | None = None,
return_image: bool = False,
return_bundle_meta: bool = False,
) -> (
None
| MLBoxModel
| tuple[MLBoxModel | None, np.ndarray | None]
| MLBoxPredictionResult
):
) -> None | MLBoxModel | tuple[MLBoxModel | None, np.ndarray | None] | MLBoxPredictionResult:
ml_bundle = self._collect_best_bundle()
predictions, image, bundle_meta = (
ml_bundle.predictions,
@@ -620,11 +558,7 @@ class MlBox:
| MLBoxPredictionsResult
):
ml_bundle = self._collect_best_bundle()
best, image, bundle_meta = (
ml_bundle.predictions,
ml_bundle.image,
ml_bundle.bundle_meta,
)
best, image, bundle_meta = (ml_bundle.predictions, ml_bundle.image, ml_bundle.bundle_meta)
if not best:
logger.debug(f"No best predictions from ML bundle: {best}")
if return_bundle_meta:
@@ -654,10 +588,7 @@ class MlBox:
overlap_parameter: float | None = None,
confidence_filter: float | None = None,
return_image: bool = False,
) -> (
dict[str, list[MLBoxModel]]
| tuple[dict[str, list[MLBoxModel]], np.ndarray | None]
):
) -> dict[str, list[MLBoxModel]] | tuple[dict[str, list[MLBoxModel]], np.ndarray | None]:
"""
Return a dict keyed by '<MLBoxTypeName>_<ordinal>' -> [MLBoxModel, ...] for each detection.
The ordinal is the running count per class (1-based) in the order they appear after filtering.
@@ -673,9 +604,7 @@ class MlBox:
return ({}, image) if return_image else {}
self._filter_predictions(
grouped,
overlap_with_pin=overlap_parameter,
confidence_min=confidence_filter,
grouped, overlap_with_pin=overlap_parameter, confidence_min=confidence_filter
)
per_class_counter: dict[MLBoxType, int] = {}
@@ -730,9 +659,7 @@ class MlBox:
logger.debug(f"Frame {frame_idx + 1}/{n_frames}: no detection")
except Exception as e:
logger.warning(
f"Frame {frame_idx + 1}/{n_frames}: prediction error: {e}"
)
logger.warning(f"Frame {frame_idx + 1}/{n_frames}: prediction error: {e}")
continue
if best_box is None:
@@ -41,12 +41,7 @@ def _box_tuple(model) -> BoxTuple | None:
def _box_extends_beyond(inner: BoxTuple, outer: BoxTuple) -> bool:
return (
inner[0] < outer[0]
or inner[1] < outer[1]
or inner[2] > outer[2]
or inner[3] > outer[3]
)
return inner[0] < outer[0] or inner[1] < outer[1] or inner[2] > outer[2] or inner[3] > outer[3]
def _box_union(a: BoxTuple, b: BoxTuple) -> BoxTuple:
@@ -114,9 +109,7 @@ def get_ml_bounding_box(
sample_id = getattr(sample, "db_id", None)
prediction_result: MLBoxPredictionResult = mlbox.predict(
preferred_class=(3, 0),
return_image=True,
return_bundle_meta=True,
preferred_class=(3, 0), return_image=True, return_bundle_meta=True
)
m = prediction_result.box
bundle_image = prediction_result.image
@@ -145,9 +138,7 @@ def get_ml_bounding_box(
if filename is not None and bundle_image is not None:
annotated_image = bundle_image.copy()
cv2.rectangle(
annotated_image, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2
)
cv2.rectangle(annotated_image, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2)
upload_image(sample_id, filename, annotated_image)
geom = sample_geometry
@@ -155,10 +146,7 @@ def get_ml_bounding_box(
"ML bounding box selected",
extra=merge_log_context(
sample_log_context(sample),
{
"sample_id": sample_id,
"ml_image_name": filename,
},
{"sample_id": sample_id, "ml_image_name": filename},
geom_log_context(geom),
{
"box_x1": x1,
@@ -214,9 +202,7 @@ def _box_to_raster_request(
# of the n_y scan) can be padded more than the top.
frac_x = float(cfg_get("daq.auto_raster.grid_padding_fraction_x", 0.15))
frac_y_top = float(cfg_get("daq.auto_raster.grid_padding_fraction_y", 0.15))
frac_y_bottom = float(
cfg_get("daq.auto_raster.grid_padding_fraction_y_bottom", frac_y_top)
)
frac_y_bottom = float(cfg_get("daq.auto_raster.grid_padding_fraction_y_bottom", frac_y_top))
pad_x = max(1, int(ceil(frac_x * n_x)))
pad_y_top = max(1, int(ceil(frac_y_top * n_y)))
pad_y_bottom = max(1, int(ceil(frac_y_bottom * n_y)))
@@ -307,9 +293,7 @@ def _box_to_raster_request(
exp_time_s=0.01,
transmission=1.0,
smargon_top_left=SmargonCoordinate(
chi_deg=geom.smargon.chi_deg,
phi_deg=geom.smargon.phi_deg,
sh_mm=start_coord,
chi_deg=geom.smargon.chi_deg, phi_deg=geom.smargon.phi_deg, sh_mm=start_coord
),
n_x=n_x,
n_y=n_y,
@@ -343,8 +327,7 @@ def build_ml_raster_plan(
sample_id = getattr(sample, "db_id", None)
result: MLBoxPredictionsResult = mlbox.predict_all_best(
return_image=True,
return_bundle_meta=True,
return_image=True, return_bundle_meta=True
)
predictions = result.predictions
bundle_image = result.image
@@ -356,12 +339,8 @@ def build_ml_raster_plan(
focus=result.focus,
)
loop_all = (
predictions.get_best_for_class(MLBoxType.LOOP_ALL) if predictions else None
)
loop_face = (
predictions.get_best_for_class(MLBoxType.LOOP_FACE) if predictions else None
)
loop_all = predictions.get_best_for_class(MLBoxType.LOOP_ALL) if predictions else None
loop_face = predictions.get_best_for_class(MLBoxType.LOOP_FACE) if predictions else None
# Grid box: prefer loop_face, else loop_all (matches the legacy (3, 0) order).
grid_model = loop_face if loop_face is not None else loop_all
@@ -398,18 +377,12 @@ def build_ml_raster_plan(
x1, y1, x2, y2 = _box_union((x1, y1, x2, y2), cbox)
logger.info(
"Extended ML raster grid to include crystal outside the loop box",
extra={
"sample_id": sample_id,
"ml_image_name": filename,
"crystal_box": cbox,
},
extra={"sample_id": sample_id, "ml_image_name": filename, "crystal_box": cbox},
)
if filename is not None and bundle_image is not None:
annotated_image = bundle_image.copy()
cv2.rectangle(
annotated_image, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2
)
cv2.rectangle(annotated_image, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2)
upload_image(sample_id, filename, annotated_image)
grid_request = _box_to_raster_request(
+2 -1
View File
@@ -24,6 +24,7 @@ class BeamlineDependencies:
cfg: BeamlineConfig
devs: BeamlineDevices
@dataclass
class OperationResult:
success: bool
@@ -52,4 +53,4 @@ class BaseOperationContext(Generic[DepsT, SettingsT]):
@property
def status(self):
return self.runtime.status
return self.runtime.status
+8 -7
View File
@@ -28,7 +28,9 @@ class SampleEventSender(Protocol):
class ScanIngestor(Protocol):
def ingest_scan(self, *, sample, result, geom, beam_mark_pxl) -> None: ...
def ingest_gridscan(self, *, sample, raster_result, raster_request, geom, com, beam_mark_pxl) -> None: ...
def ingest_gridscan(
self, *, sample, raster_result, raster_request, geom, com, beam_mark_pxl
) -> None: ...
class DatacollectionSetupRunner(Protocol):
@@ -81,13 +83,12 @@ class ScanIngestionService:
def ingest_scan(self, *, sample, result, geom, beam_mark_pxl) -> None:
self.ingestor.ingest_scan(
sample=sample,
result=result,
geom=geom,
beam_mark_pxl=beam_mark_pxl,
sample=sample, result=result, geom=geom, beam_mark_pxl=beam_mark_pxl
)
def ingest_gridscan(self, *, sample, raster_result, raster_request, geom, com, beam_mark_pxl) -> None:
def ingest_gridscan(
self, *, sample, raster_result, raster_request, geom, com, beam_mark_pxl
) -> None:
self.ingestor.ingest_gridscan(
sample=sample,
raster_result=raster_result,
@@ -115,4 +116,4 @@ class OperationServices:
face_detection_progress: FaceDetectionProgressEmitter | None = None
events: SampleEventPublisher | None = None
ingestion: ScanIngestionService | None = None
datacollection: DataCollectionPreparer | None = None
datacollection: DataCollectionPreparer | None = None
@@ -6,11 +6,7 @@ from jfjoch_client import ScanResult, ScanResultImagesInner
def build_fake_scan_result(
*,
file_prefix: str | None,
image_count: int,
rotation: bool = False,
start_angle: float = 0.0,
*, file_prefix: str | None, image_count: int, rotation: bool = False, start_angle: float = 0.0
) -> ScanResult:
total_images = max(1, image_count)
angle_step = 360.0 / total_images if rotation else 0.0
@@ -33,9 +29,7 @@ def build_fake_scan_result(
def build_fake_rotation_result(
request: RotationScanRequest,
*,
start_angle: float = 0.0,
request: RotationScanRequest, *, start_angle: float = 0.0
) -> CompletedRotationScan:
result = build_fake_scan_result(
file_prefix=request.file_prefix,
@@ -43,20 +37,13 @@ def build_fake_rotation_result(
rotation=True,
start_angle=start_angle,
)
return CompletedRotationScan(
request=copy.deepcopy(request),
result=result,
)
return CompletedRotationScan(request=copy.deepcopy(request), result=result)
def build_fake_raster_result(request: RasterGridRequest) -> CompletedRasterGridElem:
result = build_fake_scan_result(
file_prefix=request.file_prefix,
image_count=request.n_x * request.n_y,
rotation=False,
file_prefix=request.file_prefix, image_count=request.n_x * request.n_y, rotation=False
)
return CompletedRasterGridElem(
request=copy.deepcopy(request),
result=result,
centre_of_mass=None,
request=copy.deepcopy(request), result=result, centre_of_mass=None
)
@@ -1,11 +1,4 @@
from aare.daq.operations.face_detection.models import (
FaceDetectionContext,
FaceDetectionResult,
)
from aare.daq.operations.face_detection.models import FaceDetectionContext, FaceDetectionResult
from aare.daq.operations.face_detection.service import FaceDetectionService
__all__ = [
"FaceDetectionContext",
"FaceDetectionResult",
"FaceDetectionService",
]
__all__ = ["FaceDetectionContext", "FaceDetectionResult", "FaceDetectionService"]
@@ -1,10 +1,7 @@
from dataclasses import dataclass
from aare.daq.mlbox import MlBox
from aare.daq.operations.common.models import (
BaseOperationContext,
BeamlineDependencies,
)
from aare.daq.operations.common.models import BaseOperationContext, BeamlineDependencies
@dataclass
@@ -20,9 +17,7 @@ class FaceDetectionSettings:
@dataclass
class FaceDetectionContext(
BaseOperationContext[FaceDetectionDependencies, FaceDetectionSettings]
):
class FaceDetectionContext(BaseOperationContext[FaceDetectionDependencies, FaceDetectionSettings]):
pass
@@ -31,4 +26,4 @@ class FaceDetectionResult:
success: bool
payload: dict
error: Exception | None = None
comment: str | None = None
comment: str | None = None
@@ -6,10 +6,7 @@ from aarecommon.models.models import MLBoxModel, ZoomModeEnum
import aare.daq.operations.face_detection.utils as fd
from aare.daq.mlbox import MLBoxPredictionResult
from aare.daq.operations.face_detection.models import (
FaceDetectionContext,
FaceDetectionResult,
)
from aare.daq.operations.face_detection.models import FaceDetectionContext, FaceDetectionResult
class FaceDetectionService:
@@ -20,9 +17,7 @@ class FaceDetectionService:
def _progress_emitter(self):
emitter = self.ctx.services.face_detection_progress
if emitter is None:
raise RuntimeError(
"FaceDetectionService requires services.face_detection_progress"
)
raise RuntimeError("FaceDetectionService requires services.face_detection_progress")
return emitter
def _log_warning(self, message: str) -> None:
@@ -36,10 +31,7 @@ class FaceDetectionService:
fallback(message)
def _emit_running_progress(
self,
*,
angle: int,
boxes_face: dict[int, tuple[float, float, float, float]],
self, *, angle: int, boxes_face: dict[int, tuple[float, float, float, float]]
) -> None:
self._progress_emitter().emit_progress(
{
@@ -52,12 +44,7 @@ class FaceDetectionService:
)
def _emit_empty_result(self) -> dict:
payload = {
"running": False,
"samples": [],
"height_fit": {},
"area_fit": {},
}
payload = {"running": False, "samples": [], "height_fit": {}, "area_fit": {}}
self._progress_emitter().emit_progress(payload)
return payload
@@ -87,9 +74,7 @@ class FaceDetectionService:
steps = self.ctx.settings.steps if steps is None else steps
step_size = self.ctx.settings.step_size if step_size is None else step_size
face_min_ratio = (
self.ctx.settings.face_min_ratio
if face_min_ratio is None
else face_min_ratio
self.ctx.settings.face_min_ratio if face_min_ratio is None else face_min_ratio
)
try:
self.ctx.deps.cfg.zoom_mode = ZoomModeEnum.LoopCenter
@@ -119,9 +104,7 @@ class FaceDetectionService:
)
prediction_result: MLBoxPredictionResult = self.ctx.deps.mlbox.predict(
preferred_class=(3, 0),
return_image=True,
return_bundle_meta=True,
preferred_class=(3, 0), return_image=True, return_bundle_meta=True
)
model = prediction_result.box
log_ml_bundle_meta(
@@ -167,9 +150,7 @@ class FaceDetectionService:
return FaceDetectionResult(success=True, payload=payload)
total_detections = len(boxes_face) + len(boxes_loop)
face_ratio = (
len(boxes_face) / total_detections if total_detections > 0 else 0.0
)
face_ratio = len(boxes_face) / total_detections if total_detections > 0 else 0.0
if boxes_face and face_ratio >= face_min_ratio:
boxes = boxes_face
@@ -183,13 +164,9 @@ class FaceDetectionService:
)
else:
boxes = boxes_face
self.logger.debug(
f"using loop_face boxes (only source, {len(boxes_face)} entries)"
)
self.logger.debug(f"using loop_face boxes (only source, {len(boxes_face)} entries)")
best_fit_angle_area, area_params = fd.get_flat_face(
boxes, start_angle, end_angle, True
)
best_fit_angle_area, area_params = fd.get_flat_face(boxes, start_angle, end_angle, True)
best_fit_angle_height, height_params = fd.get_flat_face(
boxes, start_angle, end_angle, False
)
@@ -237,10 +214,7 @@ class FaceDetectionService:
self.logger.error(f"error in face detection sequence {e}")
payload = self._emit_empty_result()
return FaceDetectionResult(
success=False,
payload=payload,
error=e,
comment="Face detection sequence failed",
success=False, payload=payload, error=e, comment="Face detection sequence failed"
)
finally:
self.ctx.deps.cfg.zoom_mode = ZoomModeEnum.User
@@ -33,15 +33,11 @@ def prepare_samples(
return samples
def cos_model(
theta_deg: float | np.ndarray, A: float, B: float, phi_rad: float, C: float
):
def cos_model(theta_deg: float | np.ndarray, A: float, B: float, phi_rad: float, C: float):
return A + B * np.cos(C * np.deg2rad(theta_deg) - phi_rad)
def mad_filter(
samples: List[Tuple[float, float]], k: float = 3.5
) -> List[Tuple[float, float]]:
def mad_filter(samples: List[Tuple[float, float]], k: float = 3.5) -> List[Tuple[float, float]]:
if not samples:
return samples
ys = [y for _, y in samples]
@@ -75,15 +71,7 @@ def fit_cosine(samples: List[Tuple[float, float]]) -> dict:
samples = mad_filter(samples, k=3.5)
if len(samples) < 3:
A = sum(y for _, y in samples) / max(1, len(samples))
return {
"A": A,
"B": 1.0,
"phi_rad": 0.0,
"C": 1.0,
"rmse": None,
"mae": None,
"r2": None,
}
return {"A": A, "B": 1.0, "phi_rad": 0.0, "C": 1.0, "rmse": None, "mae": None, "r2": None}
degs = np.array([d for d, _ in samples], dtype=float)
ys = np.array([y for _, y in samples], dtype=float)
@@ -119,15 +107,7 @@ def fit_cosine(samples: List[Tuple[float, float]]) -> dict:
B = max(0.0, B)
yhat = cos_model(degs, A, B, phi, C)
rmse, mae, r2 = fit_metrics(ys, yhat)
return {
"A": A,
"B": B,
"phi_rad": phi,
"C": C,
"rmse": rmse,
"mae": mae,
"r2": r2,
}
return {"A": A, "B": B, "phi_rad": phi, "C": C, "rmse": rmse, "mae": mae, "r2": r2}
except Exception as e:
# Fallback to initial
@@ -150,9 +130,7 @@ def get_samples_out(boxes):
for deg, box in boxes.items():
h = box_height_from_tuple(box)
a = box_area_from_tuple(box)
samples_out.append(
{"angle_deg": float(deg), "height": float(h), "area": float(a)}
)
samples_out.append({"angle_deg": float(deg), "height": float(h), "area": float(a)})
samples_out.sort(key=lambda x: x["angle_deg"])
return samples_out
@@ -224,9 +202,7 @@ def get_flat_face(
return best_fit_angle, parameters
def chose_best_angle(
boxes: dict[int, tuple[float, float, float, float]], fit_results
) -> int:
def chose_best_angle(boxes: dict[int, tuple[float, float, float, float]], fit_results) -> int:
measured_angles = list(boxes.keys())
choose_best_fit(fit_results)
candidates = [
@@ -236,10 +212,7 @@ def chose_best_angle(
]
if measured_angles and candidates:
chosen = min(
candidates,
key=lambda a: min(
abs(((a - m + 180) % 360) - 180) for m in measured_angles
),
candidates, key=lambda a: min(abs(((a - m + 180) % 360) - 180) for m in measured_angles)
)
else:
chosen = candidates[0] if candidates else 0
@@ -14,4 +14,4 @@ __all__ = [
"LoopCenteringSettings",
"LoopCenteringService",
"LoopCenteringAnalyzer",
]
]
@@ -2,10 +2,7 @@ from aarecommon.config.logger_events import log_ml_bundle_meta
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
from aarecommon.models.models import MLBoxType
from aare.daq.operations.loop_centering.models import (
AngleAnalysis,
LoopCenteringContext,
)
from aare.daq.operations.loop_centering.models import AngleAnalysis, LoopCenteringContext
class LoopCenteringAnalyzer:
@@ -17,11 +14,7 @@ class LoopCenteringAnalyzer:
if not classes:
return False
ignore_classes = {
MLBoxType.PIN.value,
MLBoxType.NEEDLE.value,
MLBoxType.ICE.value,
}
ignore_classes = {MLBoxType.PIN.value, MLBoxType.NEEDLE.value, MLBoxType.ICE.value}
found = {int(cls) for cls in classes if cls is not None}
return bool(found) and found.issubset(ignore_classes)
@@ -78,18 +71,14 @@ class LoopCenteringAnalyzer:
return calculated_target
def _extract_prediction_target(
self,
*,
target_point: tuple[float, float] | None,
self, *, target_point: tuple[float, float] | None
) -> SmargonCoordinate | None:
if target_point is None:
return None
geom = self.ctx.runtime.sample_geometry
return SmargonCoordinate(
sh_mm=geom.picture_to_smargon(
Coordinate(x=target_point[0], y=target_point[1])
)
sh_mm=geom.picture_to_smargon(Coordinate(x=target_point[0], y=target_point[1]))
)
def _interpret_ml_loop_centre_box(
@@ -109,8 +98,7 @@ class LoopCenteringAnalyzer:
pin = box
best_box = self.ctx.deps.mlbox.get_preferred_class_box(
boxes,
(MLBoxType.CRYSTAL, MLBoxType.LOOP_FACE, MLBoxType.LOOP_ALL, MLBoxType.PIN),
boxes, (MLBoxType.CRYSTAL, MLBoxType.LOOP_FACE, MLBoxType.LOOP_ALL, MLBoxType.PIN)
)
if best_box is None or best_box.box is None or best_box.cls is None:
return None, None, classes
@@ -155,11 +143,7 @@ class LoopCenteringAnalyzer:
return SmargonCoordinate(sh_mm=coord), cls, classes
def analyze_angle(
self,
*,
angle_deg: int,
zoom_value: float,
sample_id: int | None,
self, *, angle_deg: int, zoom_value: float, sample_id: int | None
) -> AngleAnalysis:
if self.ctx.services.predictions is None:
raise RuntimeError("LoopCenteringAnalyzer requires services.predictions")
@@ -173,7 +157,7 @@ class LoopCenteringAnalyzer:
)
predicted_target = self._extract_prediction_target(
target_point=prediction_result.target_point,
target_point=prediction_result.target_point
)
calculated_target, selected_class, classes = self._interpret_ml_loop_centre_box(
prediction_result.predictions
@@ -189,7 +173,6 @@ class LoopCenteringAnalyzer:
analysis.ignore_only = self.is_ignore_only_classes(analysis.classes)
analysis.has_valid_target = self.has_valid_target_classes(analysis.classes)
analysis.final_target = self.select_smargon_target(
calculated_target=analysis.calculated_target,
predicted_target=analysis.predicted_target,
calculated_target=analysis.calculated_target, predicted_target=analysis.predicted_target
)
return analysis
@@ -3,10 +3,7 @@ from dataclasses import dataclass, field
from aarecommon.math.coordinate import SmargonCoordinate
from aare.daq.mlbox import MlBox
from aare.daq.operations.common.models import (
BaseOperationContext,
BeamlineDependencies,
)
from aare.daq.operations.common.models import BaseOperationContext, BeamlineDependencies
@dataclass
@@ -45,7 +42,5 @@ class LoopCenteringSettings:
@dataclass
class LoopCenteringContext(
BaseOperationContext[LoopCenteringDependencies, LoopCenteringSettings]
):
class LoopCenteringContext(BaseOperationContext[LoopCenteringDependencies, LoopCenteringSettings]):
pass
@@ -40,9 +40,7 @@ class LoopCenteringService:
)
analysis = self.analyzer.analyze_angle(
angle_deg=angle,
zoom_value=zoom_value,
sample_id=sample_id,
angle_deg=angle, zoom_value=zoom_value, sample_id=sample_id
)
if (
@@ -63,13 +61,10 @@ class LoopCenteringService:
if sample_id is not None and trace_all_alc_moves:
self.ctx.services.traces.append_smargon_trace(
sample_id=sample_id,
event=f"alc_move_zoom_{zoom_value:.0f}_angle_{angle}",
sample_id=sample_id, event=f"alc_move_zoom_{zoom_value:.0f}_angle_{angle}"
)
else:
self.logger.debug(
f"Skipping move at angle {angle}; classes={analysis.classes}"
)
self.logger.debug(f"Skipping move at angle {angle}; classes={analysis.classes}")
if sample_id is not None and analysis.selected_class in (
MLBoxType.LOOP_ALL.value,
@@ -77,9 +72,7 @@ class LoopCenteringService:
MLBoxType.CRYSTAL.value,
):
self.ctx.services.screenshots.save_to_db(
sample_id,
f"{sample_id}_{angle}_{zoom_value:.0f}",
wait_screenshot_sleep_sec,
sample_id, f"{sample_id}_{angle}_{zoom_value:.0f}", wait_screenshot_sleep_sec
)
return analysis
@@ -117,9 +110,7 @@ class LoopCenteringService:
)
for attempt_number in range(1, max_attempts + 1):
self.logger.info(
f"Starting ALC attempt {attempt_number}/{max_attempts}"
)
self.logger.info(f"Starting ALC attempt {attempt_number}/{max_attempts}")
attempt = AttemptSummary(attempt_number=attempt_number)
for angle in angles:
@@ -133,9 +124,7 @@ class LoopCenteringService:
attempt.first_pass.append(result)
for c in result.classes:
found_classes_count[int(c)] = (
found_classes_count.get(int(c), 0) + 1
)
found_classes_count[int(c)] = found_classes_count.get(int(c), 0) + 1
if attempt_number == 1:
valid_seen_first_pass = any(
@@ -159,9 +148,7 @@ class LoopCenteringService:
attempt.correction_pass.append(result)
for c in result.classes:
found_classes_count[int(c)] = (
found_classes_count.get(int(c), 0) + 1
)
found_classes_count[int(c)] = found_classes_count.get(int(c), 0) + 1
attempts.append(attempt)
@@ -219,8 +206,4 @@ class LoopCenteringService:
self.logger.error(f"ALC exception: {alc_comment}")
self.logger.error(traceback.format_exc())
self.logger.error(f"Error in loop centering: {e}")
return LoopCenteringResult(
success=False,
comment=alc_comment,
error=e,
)
return LoopCenteringResult(success=False, comment=alc_comment, error=e)
+1 -5
View File
@@ -1,8 +1,4 @@
from aare.daq.operations.mounting.models import MountingContext, MountingResult
from aare.daq.operations.mounting.service import MountingService
__all__ = [
"MountingContext",
"MountingResult",
"MountingService",
]
__all__ = ["MountingContext", "MountingResult", "MountingService"]
+5 -14
View File
@@ -43,9 +43,7 @@ class MountingService:
self.logger.error(
"Goniometer didn't reach position based on magnet position sensor readout"
)
raise Exception(
"Goniometer is not in position based on magnet position sensor readout"
)
raise Exception("Goniometer is not in position based on magnet position sensor readout")
def _handle_consecutive_mount_failure(self) -> None:
count = self.ctx.deps.cfg.increment_mount_failure_streak()
@@ -59,21 +57,16 @@ class MountingService:
self.logger.exception(f"Failed to dry after mount failure: {e}")
if count >= STOP_AFTER_FAIL_COUNT:
self.logger.error(
f"Mount failed {count} times in a row, stopping automation"
)
self.logger.error(f"Mount failed {count} times in a row, stopping automation")
self.logger.error("Unmounting sample and drying")
try:
self._unmount_current_sample(timeout=60.0)
self.dry(park=True)
except Exception as e:
self.logger.exception(
f"Failed to clean up after repeated mount failure: {e}"
)
self.logger.exception(f"Failed to clean up after repeated mount failure: {e}")
raise MountingFailed(
f"Mount failed {count} times in a row, stopping automation.",
critical=True,
f"Mount failed {count} times in a row, stopping automation.", critical=True
)
def _mount_handler(self, target) -> None:
@@ -135,9 +128,7 @@ class MountingService:
"""
pss = self.ctx.deps.devs.pss
if not pss.is_prohibited():
self.logger.error(
"Hutch is not in prohibited state; robot motion not permitted"
)
self.logger.error("Hutch is not in prohibited state; robot motion not permitted")
raise DoorSafetyError(
"Door safety could not be activated: close the hutch doors and "
"complete the safety search before mounting."
+1 -5
View File
@@ -1,8 +1,4 @@
from aare.daq.operations.raster.models import RasterContext, RasterBoundingBoxResult
from aare.daq.operations.raster.service import RasterService
__all__ = [
"RasterBoundingBoxResult",
"RasterContext",
"RasterService",
]
__all__ = ["RasterBoundingBoxResult", "RasterContext", "RasterService"]
+1 -4
View File
@@ -4,10 +4,7 @@ from typing import TYPE_CHECKING
from aarecommon.models.raster_grid import RasterGridRequest
from aare.daq.mlbox import MlBox
from aare.daq.operations.common.models import (
BaseOperationContext,
BeamlineDependencies,
)
from aare.daq.operations.common.models import BaseOperationContext, BeamlineDependencies
if TYPE_CHECKING:
from aare.daq.aaredb import AareWrapper
+48 -148
View File
@@ -11,10 +11,7 @@ from aarecommon.config.logger_events import (
raster_request_log_context,
sample_log_context,
)
from aarecommon.errors.exception_handler import (
AutoRasterSampleSkipped,
RasterScanException,
)
from aarecommon.errors.exception_handler import AutoRasterSampleSkipped, RasterScanException
from aarecommon.math.coordinate import AerotechCoordinate, Coordinate, SmargonCoordinate
from aarecommon.math.find_xtal import (
compute_crystal_score_array,
@@ -57,10 +54,7 @@ class RasterService:
@staticmethod
def _grid_image_id_from_centre_offset(
*,
x_mm: float,
y_mm: float,
request: RasterGridRequest,
*, x_mm: float, y_mm: float, request: RasterGridRequest
) -> int:
if request.grid_size_mm.x <= 0 or request.grid_size_mm.y <= 0:
raise ValueError("grid_size_mm must be positive")
@@ -73,11 +67,7 @@ class RasterService:
grid_x = min(max(1, grid_x), request.n_x)
grid_y = min(max(1, grid_y), request.n_y)
return grid_to_image_id(
grid_x=grid_x,
grid_y=grid_y,
number_of_cols=request.n_x,
)
return grid_to_image_id(grid_x=grid_x, grid_y=grid_y, number_of_cols=request.n_x)
def _upload_raster_diffraction_preview(
self,
@@ -96,10 +86,7 @@ class RasterService:
extra=merge_log_context(
sample_log_context(self.ctx.sample),
raster_request_log_context(request),
{
"diffraction_image_id": image_id,
"image_count": image_count,
},
{"diffraction_image_id": image_id, "image_count": image_count},
),
)
return
@@ -114,10 +101,7 @@ class RasterService:
extra=merge_log_context(
sample_log_context(self.ctx.sample),
raster_request_log_context(request),
{
"diffraction_image_id": image_id,
"image_count": image_count,
},
{"diffraction_image_id": image_id, "image_count": image_count},
),
exc_info=True,
)
@@ -140,14 +124,10 @@ class RasterService:
except Exception:
pass
self.ctx.deps.aare.upload_jpg(
sample_id, filename, diffraction_image, message=comment
)
self.ctx.deps.aare.upload_jpg(sample_id, filename, diffraction_image, message=comment)
def ml_bounding_box(
self,
sample_id: int | None = None,
filename: str | None = None,
self, sample_id: int | None = None, filename: str | None = None
) -> RasterGridRequest | None:
return get_ml_bounding_box(
mlbox=self.ctx.deps.mlbox,
@@ -162,9 +142,7 @@ class RasterService:
)
def ml_raster_plan(
self,
sample_id: int | None = None,
filename: str | None = None,
self, sample_id: int | None = None, filename: str | None = None
) -> MLRasterPlan | None:
return build_ml_raster_plan(
mlbox=self.ctx.deps.mlbox,
@@ -185,12 +163,7 @@ class RasterService:
if width is None or height is None:
return False
x1, y1, x2, y2 = box
return (
x1 <= margin
or y1 <= margin
or x2 >= width - margin
or y2 >= height - margin
)
return x1 <= margin or y1 <= margin or x2 >= width - margin or y2 >= height - margin
def _zoom_to_fit_box(self, plan: MLRasterPlan) -> tuple | None:
"""Pick the box that drives zoom-to-fit: loop_all if detected and not
@@ -261,15 +234,11 @@ class RasterService:
beam_x_pxl = geom.beam_location_pxl.x
beam_y_pxl = geom.beam_location_pxl.y
line_scan_centre = geom.picture_to_smargon(
Coordinate(x=beam_x_pxl, y=beam_y_pxl)
)
line_scan_centre = geom.picture_to_smargon(Coordinate(x=beam_x_pxl, y=beam_y_pxl))
n_y = default_n_y
prediction_result: MLBoxPredictionResult = self.ctx.deps.mlbox.predict(
preferred_class=(3, 0),
return_image=False,
return_bundle_meta=True,
preferred_class=(3, 0), return_image=False, return_bundle_meta=True
)
log_ml_bundle_meta(
self.logger,
@@ -285,7 +254,9 @@ class RasterService:
prediction_type = getattr(prediction_box, "type", None)
if prediction_type == MLBoxType.LOOP_FACE:
y_padding_fraction_each_side = local_contact_config.line_scan_loop_face_y_padding_fraction_each_side
y_padding_fraction_each_side = (
local_contact_config.line_scan_loop_face_y_padding_fraction_each_side
)
elif prediction_type == MLBoxType.LOOP_ALL:
y_padding_fraction_each_side = (
local_contact_config.line_scan_loop_all_y_padding_fraction_each_side
@@ -303,9 +274,7 @@ class RasterService:
y_retarget_threshold_mm = geom.beam_size_mm.y * 2.0
if y_delta_mm > y_retarget_threshold_mm:
line_scan_centre = geom.picture_to_smargon(
Coordinate(x=beam_x_pxl, y=target_y_pxl)
)
line_scan_centre = geom.picture_to_smargon(Coordinate(x=beam_x_pxl, y=target_y_pxl))
self.logger.info(
"Using ML target y for second auto-center raster",
extra=merge_log_context(
@@ -352,16 +321,10 @@ class RasterService:
),
)
if (
prediction_box is not None
and prediction_box.box is not None
and grid_size_mm.y > 0
):
if prediction_box is not None and prediction_box.box is not None and grid_size_mm.y > 0:
box_height_pxl = abs(prediction_box.box.bottom_y - prediction_box.box.top_y)
padded_height_mm = (
box_height_pxl
* geom.pixel_in_mm
* (1.0 + 2.0 * y_padding_fraction_each_side)
box_height_pxl * geom.pixel_in_mm * (1.0 + 2.0 * y_padding_fraction_each_side)
)
n_y = max(1, int(ceil(padded_height_mm / grid_size_mm.y)))
self.logger.info(
@@ -396,10 +359,7 @@ class RasterService:
),
)
offset = Coordinate(
x=-grid_size_mm.x / 2.0,
y=-(n_y - 1) * grid_size_mm.y / 2.0,
)
offset = Coordinate(x=-grid_size_mm.x / 2.0, y=-(n_y - 1) * grid_size_mm.y / 2.0)
top_left = SmargonCoordinate(
sh_mm=line_scan_centre + geom.smargon_nudge(offset),
phi_deg=geom.smargon.phi_deg,
@@ -427,9 +387,7 @@ class RasterService:
return top_left, n_y
def execute(
self,
request: RasterGridRequest,
wait_for_screenshot: float | None = None,
self, request: RasterGridRequest, wait_for_screenshot: float | None = None
) -> CompletedRasterGridElem:
total_time = request.exp_time_s * request.n_x * request.n_y + request.n_y * 0.3
@@ -466,9 +424,7 @@ class RasterService:
if not self.ctx.deps.cfg.simulated_detector:
self.ctx.deps.jfjoch.wait_till_running(timeout=60.0)
else:
self.logger.info(
"Simulated detector mode enabled; faking jfjoch intilalisation."
)
self.logger.info("Simulated detector mode enabled; faking jfjoch intilalisation.")
self.logger.debug(
f"Starting grid scan with {request.n_x}x{request.n_y} points, exp time {request.exp_time_s}s"
@@ -496,8 +452,7 @@ class RasterService:
else:
coord = self.ctx.deps.cfg.abr_meas_pos.at_mm
self.ctx.deps.devs.aerotech_pos = AerotechCoordinate(
at_mm=coord,
omega_deg=self.ctx.deps.devs.aerotech_omega,
at_mm=coord, omega_deg=self.ctx.deps.devs.aerotech_omega
)
self.ctx.deps.devs.aerotech.wait_till_done(timeout=60)
@@ -505,9 +460,7 @@ class RasterService:
y = None
if self.ctx.deps.cfg.simulated_detector:
self.logger.info(
"Simulated detector mode enabled; using fake raster result."
)
self.logger.info("Simulated detector mode enabled; using fake raster result.")
scan_result = generate_no_beam_scan_result(request)
com = raster_highest_score(scan_result.images)
target_coor = com.get_com_mm(request)
@@ -528,9 +481,7 @@ class RasterService:
{"exp_time_s": request.exp_time_s},
),
)
raise RasterScanException(
"JFJoch returned no ScanResult for raster"
)
raise RasterScanException("JFJoch returned no ScanResult for raster")
com = raster_highest_score(scan_result.images)
if com is None:
@@ -556,24 +507,16 @@ class RasterService:
"centre_offset_z_mm": target_coor_offset.z,
"grid_half_width_x_mm": x,
"grid_half_height_y_mm": y,
"top_left_x_mm": getattr(
request.smargon_top_left.sh_mm, "x", None
),
"top_left_y_mm": getattr(
request.smargon_top_left.sh_mm, "y", None
),
"top_left_z_mm": getattr(
request.smargon_top_left.sh_mm, "z", None
),
"top_left_x_mm": getattr(request.smargon_top_left.sh_mm, "x", None),
"top_left_y_mm": getattr(request.smargon_top_left.sh_mm, "y", None),
"top_left_z_mm": getattr(request.smargon_top_left.sh_mm, "z", None),
},
),
)
else:
self.logger.info("Calcualted COM is not None, procedding")
target_coor = com.get_com_mm(request)
target_coor_offset = self.ctx.sample_geometry.smargon_nudge(
target_coor
)
target_coor_offset = self.ctx.sample_geometry.smargon_nudge(target_coor)
self.logger.info(
"Calculated raster centre offset",
extra=merge_log_context(
@@ -585,22 +528,14 @@ class RasterService:
"centre_offset_z_mm": target_coor_offset.z,
"grid_half_width_x_mm": x,
"grid_half_height_y_mm": y,
"top_left_x_mm": getattr(
request.smargon_top_left.sh_mm, "x", None
),
"top_left_y_mm": getattr(
request.smargon_top_left.sh_mm, "y", None
),
"top_left_z_mm": getattr(
request.smargon_top_left.sh_mm, "z", None
),
"top_left_x_mm": getattr(request.smargon_top_left.sh_mm, "x", None),
"top_left_y_mm": getattr(request.smargon_top_left.sh_mm, "y", None),
"top_left_z_mm": getattr(request.smargon_top_left.sh_mm, "z", None),
},
),
)
self.logger.info(
f"moving Smargon to grid centre offset {target_coor_offset}"
)
self.logger.info(f"moving Smargon to grid centre offset {target_coor_offset}")
target_smargon = SmargonCoordinate(
sh_mm=request.smargon_top_left.sh_mm + target_coor_offset,
phi_deg=request.smargon_top_left.phi_deg,
@@ -631,9 +566,7 @@ class RasterService:
else None
)
if sample_id:
self.logger.debug(
"moving to XtalSnapshot to take a screenshot of the sample"
)
self.logger.debug("moving to XtalSnapshot to take a screenshot of the sample")
if self.ctx.services.state is None:
raise RuntimeError("RasterService requires services.state")
self.ctx.services.state.set_state(BeamlineStateEnum.XtalSnapshot)
@@ -642,8 +575,7 @@ class RasterService:
time.sleep(wait_for_screenshot)
self.ctx.services.screenshots.save_to_db(
sample_id,
f"{sample_id}_post_raster_{int(request.omega_deg)}deg",
sample_id, f"{sample_id}_post_raster_{int(request.omega_deg)}deg"
)
if self.ctx.services.ingestion is not None:
@@ -653,9 +585,7 @@ class RasterService:
raster_request=request,
geom=self.ctx.sample_geometry,
com=None,
beam_mark_pxl=self.ctx.deps.cfg.get_beam_mark(
self.ctx.deps.devs.zoom
),
beam_mark_pxl=self.ctx.deps.cfg.get_beam_mark(self.ctx.deps.devs.zoom),
)
else:
self.ctx.deps.aare.ingest_gridscan(
@@ -664,9 +594,7 @@ class RasterService:
raster_request=request,
geom=self.ctx.sample_geometry,
com=None,
beam_mark_pxl=self.ctx.deps.cfg.get_beam_mark(
self.ctx.deps.devs.zoom
),
beam_mark_pxl=self.ctx.deps.cfg.get_beam_mark(self.ctx.deps.devs.zoom),
)
if com is not None and com.max_image is not None:
@@ -677,9 +605,7 @@ class RasterService:
f"{self.ctx.sample.db_id}_diffraction_image_near_grid_scan"
)
diffraction_image_id = self._grid_image_id_from_centre_offset(
x_mm=x,
y_mm=y,
request=request,
x_mm=x, y_mm=y, request=request
)
self._upload_raster_diffraction_preview(
@@ -708,10 +634,7 @@ class RasterService:
extra=merge_log_context(
sample_log_context(self.ctx.sample),
raster_request_log_context(request),
{
"scan_result_is_none": scan_result is None,
"sample_id": sample_id,
},
{"scan_result_is_none": scan_result is None, "sample_id": sample_id},
),
)
self.ctx.deps.cfg.crystal_size = get_xtal_size(
@@ -719,16 +642,10 @@ class RasterService:
compute_crystal_score_array(scan_result.images),
r=request,
)
self.ctx.deps.cfg.last_best_res = get_best_res(
result_list=scan_result.images
)
self.ctx.deps.cfg.last_best_b_factor = get_best_b_factor(
result_list=scan_result.images
)
self.ctx.deps.cfg.last_best_res = get_best_res(result_list=scan_result.images)
self.ctx.deps.cfg.last_best_b_factor = get_best_b_factor(result_list=scan_result.images)
return CompletedRasterGridElem(
request=copy.deepcopy(request),
result=scan_result,
centre_of_mass=com,
request=copy.deepcopy(request), result=scan_result, centre_of_mass=com
)
except Exception as e:
@@ -742,9 +659,7 @@ class RasterService:
)
raise
def execute_auto_center(
self, request: RasterGridRequest
) -> CompletedRasterGrid | None:
def execute_auto_center(self, request: RasterGridRequest) -> CompletedRasterGrid | None:
sample = self.ctx.sample
if sample is None:
raise Exception("Sample must be mounted to auto center")
@@ -787,17 +702,12 @@ class RasterService:
"No ML bounding box found at primary angle during auto-center raster",
extra=merge_log_context(
sample_log_context(sample),
{
"omega_deg": geom.omega_deg,
"file_prefix": old_prefix,
},
{"omega_deg": geom.omega_deg, "file_prefix": old_prefix},
),
)
self.ctx.deps.devs.aerotech_omega = geom.omega_deg + 90.0
time.sleep(0.2)
plan = self.ml_raster_plan(
sample.db_id, f"ml_{geom.omega_deg + 90.0:.2f}deg"
)
plan = self.ml_raster_plan(sample.db_id, f"ml_{geom.omega_deg + 90.0:.2f}deg")
if plan is None:
self.logger.error(
@@ -856,9 +766,7 @@ class RasterService:
self.ctx.deps.jfjoch.measure_raster(grid, status)
self.logger.info("detector initialised")
else:
self.logger.info(
"Simulated detector mode enabled; using fake raster result."
)
self.logger.info("Simulated detector mode enabled; using fake raster result.")
if self.ctx.services.state is None:
raise RuntimeError("RasterService requires services.state")
@@ -896,18 +804,13 @@ class RasterService:
grid.n_x = 1
grid.file_prefix = f"{old_prefix}_{grid.omega_deg}deg"
geom = self.ctx.sample_geometry
grid.grid_size_mm = Coordinate(
x=geom.beam_size_mm.x, y=geom.beam_size_mm.y * 0.25
)
grid.grid_size_mm = Coordinate(x=geom.beam_size_mm.x, y=geom.beam_size_mm.y * 0.25)
grid.smargon_top_left, grid.n_y = self.auto_center_line_scan_top_left(
omega_deg=grid.omega_deg,
file_prefix=grid.file_prefix,
grid_size_mm=grid.grid_size_mm,
default_n_y=50,
y_retarget_threshold_mm=max(
geom.beam_size_mm.y * 2.0,
grid.grid_size_mm.y * 4.0,
),
y_retarget_threshold_mm=max(geom.beam_size_mm.y * 2.0, grid.grid_size_mm.y * 4.0),
)
self.logger.info(
@@ -929,9 +832,7 @@ class RasterService:
self.ctx.deps.jfjoch.measure_raster(grid, status)
self.logger.info("detector initialised")
else:
self.logger.info(
"Simulated detector mode enabled; using fake raster result."
)
self.logger.info("Simulated detector mode enabled; using fake raster result.")
if self.ctx.services.state is None:
raise RuntimeError("RasterService requires services.state")
@@ -940,8 +841,7 @@ class RasterService:
self.logger.info(
"Running second auto-center raster",
extra=merge_log_context(
sample_log_context(self.ctx.sample),
raster_request_log_context(grid),
sample_log_context(self.ctx.sample), raster_request_log_context(grid)
),
)
res2 = self.execute(grid)
+2 -5
View File
@@ -1,10 +1,7 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
from aare.daq.operations.common.models import (
BaseOperationContext,
BeamlineDependencies,
)
from aare.daq.operations.common.models import BaseOperationContext, BeamlineDependencies
if TYPE_CHECKING:
from aare.daq.aaredb import AareWrapper
@@ -24,4 +21,4 @@ class RotationSettings:
@dataclass
class RotationContext(BaseOperationContext[RotationDependencies, RotationSettings]):
pass
pass
+10 -34
View File
@@ -20,11 +20,7 @@ class RotationService:
self.logger.info("Simulated detector mode enabled; skipping JFJoch start.")
return
self.ctx.deps.jfjoch.measure_rotation(
request,
self.ctx.status,
self.ctx.deps.cfg.xrf,
)
self.ctx.deps.jfjoch.measure_rotation(request, self.ctx.status, self.ctx.deps.cfg.xrf)
def _execute_scan(self, request: RotationScanRequest) -> CompletedRotationScan:
omega_start = self.ctx.deps.devs.aerotech_omega
@@ -66,9 +62,7 @@ class RotationService:
if request.start is not None and request.end is not None:
smargon_time_step = request.exp_time_s / float(request.steps)
pos_step = (request.end.sh_mm - request.start.sh_mm) * (
1.0 / float(request.steps)
)
pos_step = (request.end.sh_mm - request.start.sh_mm) * (1.0 / float(request.steps))
for i in range(request.steps):
self.ctx.deps.devs.smargon.target = SmargonCoordinate(
@@ -76,22 +70,15 @@ class RotationService:
)
time.sleep(smargon_time_step)
self.ctx.deps.devs.aerotech.wait_till_done(
timeout=int(round(total_time + 60, 0))
)
self.ctx.deps.devs.aerotech.wait_till_done(timeout=int(round(total_time + 60, 0)))
self.ctx.deps.devs.aerotech_omega = omega_start
if self.ctx.deps.cfg.simulated_detector:
self.logger.warning(
"Detector in simulation mode, returning fake zero rotation result."
)
self.logger.warning("Detector in simulation mode, returning fake zero rotation result.")
return build_fake_rotation_result(request, start_angle=float(omega_start))
scan_result = self.ctx.deps.jfjoch.wait_till_done(60)
return CompletedRotationScan(
request=copy.deepcopy(request),
result=scan_result,
)
return CompletedRotationScan(request=copy.deepcopy(request), result=scan_result)
def _ingest_screening_diffraction(self, sample, scan_result) -> None:
"""Pull each screening wedge's diffraction image from JFJoch (with spot
@@ -110,9 +97,7 @@ class RotationService:
)
except Exception:
self.logger.warning(
"Screening diffraction image %s unavailable; skipping",
image_id,
exc_info=True,
"Screening diffraction image %s unavailable; skipping", image_id, exc_info=True
)
continue
@@ -141,11 +126,7 @@ class RotationService:
self.ctx.services.datacollection.prepare(request)
self._prepare_detector(request)
if (
sample is not None
and sample.db_id is not None
and self.ctx.services.events is not None
):
if sample is not None and sample.db_id is not None and self.ctx.services.events is not None:
self.ctx.services.events.send(sample.db_id, SampleEventType.COLLECTING)
if self.ctx.services.state is not None:
@@ -159,16 +140,13 @@ class RotationService:
if sample is not None and sample.db_id is not None:
if self.ctx.services.screenshots is not None:
self.ctx.services.screenshots.save_to_db(
sample.db_id,
self.ctx.settings.preview_filename,
sample.db_id, self.ctx.settings.preview_filename
)
# For screening runs, ingest the per-wedge diffraction images. Done
# before the COLLECTED event so they pin to the same sample event the
# run is bound to (the COLLECTING event), matching the preview above.
self.logger.debug(
f"Ingesting screening diffraction images: {request.screening}"
)
self.logger.debug(f"Ingesting screening diffraction images: {request.screening}")
if request.screening and not self.ctx.deps.cfg.simulated_detector:
self._ingest_screening_diffraction(sample, result.result)
@@ -180,9 +158,7 @@ class RotationService:
sample=sample,
result=result.result,
geom=self.ctx.sample_geometry,
beam_mark_pxl=self.ctx.deps.cfg.get_beam_mark(
self.ctx.deps.devs.zoom
),
beam_mark_pxl=self.ctx.deps.cfg.get_beam_mark(self.ctx.deps.devs.zoom),
)
return result
+7 -29
View File
@@ -15,11 +15,7 @@ from aare.devices.mx_lib import clean_filename
class NonCriticalRunner(Protocol):
def __call__(
self,
action,
*,
description: str,
sample: SampleShortInfo | None = None,
self, action, *, description: str, sample: SampleShortInfo | None = None
) -> object | None: ...
@@ -68,9 +64,7 @@ class ScreenshotService:
self.logger.debug(f"saving screenshot {filename} from inference image")
cv2.imwrite(f"{self.output_dir}/{filename}.jpg", bgr_image)
def save_to_db(
self, sample_id: int, filename: str, settle_time_s: float = 0.2
) -> None:
def save_to_db(self, sample_id: int, filename: str, settle_time_s: float = 0.2) -> None:
time.sleep(settle_time_s)
sample = self._current_sample()
bgr_image = self._get_inference_image()
@@ -78,18 +72,10 @@ class ScreenshotService:
def _upload() -> None:
self.aare.upload_image(sample_id, filename, bgr_image)
self.run_noncritical(
_upload,
description=f"screenshot upload '{filename}'",
sample=sample,
)
self.run_noncritical(_upload, description=f"screenshot upload '{filename}'", sample=sample)
def send_to_db(
self,
*,
filename: str | None = None,
message: str | None = None,
default_message: str,
self, *, filename: str | None = None, message: str | None = None, default_message: str
) -> None:
sample = self._current_sample()
if sample is None or sample.db_id is None or sample.db_id < 0:
@@ -106,9 +92,7 @@ class ScreenshotService:
"No active pgroup set; cannot save screenshot to photos directory."
)
photos_dir = (
Path(self.photos_root) / pgroup / "raw" / "photos" / str(sample_id)
)
photos_dir = Path(self.photos_root) / pgroup / "raw" / "photos" / str(sample_id)
photos_dir.mkdir(parents=True, exist_ok=True)
photo_path = photos_dir / f"{safe_filename}.jpeg"
cv2.imwrite(str(photo_path), bgr_image)
@@ -119,12 +103,6 @@ class ScreenshotService:
final_message = (message or "").strip() or default_message
def _upload() -> None:
self.aare.upload_image(
sample_id, upload_name, bgr_image, message=final_message
)
self.aare.upload_image(sample_id, upload_name, bgr_image, message=final_message)
self.run_noncritical(
_upload,
description=f"send screenshot '{upload_name}'",
sample=sample,
)
self.run_noncritical(_upload, description=f"send screenshot '{upload_name}'", sample=sample)
+52 -180
View File
@@ -77,10 +77,7 @@ _face_detection_state: dict = {
}
_face_detection_state_lock = asyncio.Lock()
_automation_progress_state: dict = {
"seq": 0,
"progress": None,
}
_automation_progress_state: dict = {"seq": 0, "progress": None}
_automation_progress_state_lock = asyncio.Lock()
@@ -172,8 +169,7 @@ def _validate_recovery_code(confirmation_code: str) -> None:
if not hmac.compare_digest(provided, expected):
logger.warning("Invalid recovery confirmation code.")
raise HTTPException(
status_code=api_status.HTTP_403_FORBIDDEN,
detail="Invalid confirmation code.",
status_code=api_status.HTTP_403_FORBIDDEN, detail="Invalid confirmation code."
)
@@ -200,10 +196,7 @@ def _push_face_detection_progress(payload: dict) -> None:
global _face_detection_state
try:
next_seq = int(_face_detection_state.get("seq", 0)) + 1
_face_detection_state = {
"seq": next_seq,
**payload,
}
_face_detection_state = {"seq": next_seq, **payload}
except Exception as e:
logger.warning(f"Failed to update face detection progress: {e}")
@@ -346,18 +339,14 @@ async def status(token: str = Depends(oauth2_scheme)) -> DAQStatusModel:
full.sample = sample if in_ro and sample_view_allowed else None
full.box = getattr(full, "box", None) if in_ro else None
full.last_best_res = getattr(full, "last_best_res", None) if in_ro else None
full.last_best_b_factor = (
getattr(full, "last_best_b_factor", None) if in_ro else None
)
full.last_best_b_factor = getattr(full, "last_best_b_factor", None) if in_ro else None
full.crystal_size = (
getattr(full, "crystal_size", CrystalSize(x=0, y=0, z=0))
if in_ro
else CrystalSize(x=0, y=0, z=0)
)
full.session = SessionStatus(
current_pgroup=cfg.pgroup,
session=cfg.session_state(data.session),
staff=data.staff,
current_pgroup=cfg.pgroup, session=cfg.session_state(data.session), staff=data.staff
)
if data.staff:
@@ -369,9 +358,7 @@ async def status(token: str = Depends(oauth2_scheme)) -> DAQStatusModel:
@app.get("/beamline/geometry")
async def sample_geometry(
token: str = Depends(oauth2_scheme),
) -> SampleGeometryModel:
async def sample_geometry(token: str = Depends(oauth2_scheme)) -> SampleGeometryModel:
"""
Get the sample geometry information.
@@ -494,9 +481,7 @@ async def mono_pitch_scan(plot: bool = False, token: str = Depends(oauth2_scheme
@app.put("/beamline/change_energy")
async def change_energy(
value: float, plot: bool = False, token: str = Depends(oauth2_scheme)
):
async def change_energy(value: float, plot: bool = False, token: str = Depends(oauth2_scheme)):
"""
Change monochromator energy. Staff only.
@@ -534,9 +519,7 @@ async def smargon(val: SmargonCoordinate, token: str = Depends(oauth2_scheme)):
@app.post("/beamline/tweak_abr_meas_pos")
async def tweak_abr_meas_pos(
val: AerotechCoordinate, token: str = Depends(oauth2_scheme)
):
async def tweak_abr_meas_pos(val: AerotechCoordinate, token: str = Depends(oauth2_scheme)):
"""
Tweak the Aerotech measurement position. Staff only.
@@ -615,10 +598,7 @@ async def initialise_smargon(token: str = Depends(oauth2_scheme)) -> dict:
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.initialise_smargon()
return {
"ok": True,
"message": "Smargon initialised.",
}
return {"ok": True, "message": "Smargon initialised."}
@app.post("/bec/load_user_macros")
@@ -629,10 +609,7 @@ async def bec_load_user_macros(token: str = Depends(oauth2_scheme)) -> dict:
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.bec_load_user_macros()
return {
"ok": True,
"message": "BEC user macros loaded.",
}
return {"ok": True, "message": "BEC user macros loaded."}
@app.get("/bec/user_macros")
@@ -657,8 +634,7 @@ async def bec_list_all_devices(token: str = Depends(oauth2_scheme)) -> list:
@app.post("/bec/reinitialise_planner_and_position_devices")
async def bec_reinitialise_planner_and_position_devices(
method: str = "auto",
token: str = Depends(oauth2_scheme),
method: str = "auto", token: str = Depends(oauth2_scheme)
) -> dict:
"""
Reinitialise BEC planner and position devices. Staff only.
@@ -689,10 +665,7 @@ async def bec_save_current_bs_pos(token: str = Depends(oauth2_scheme)) -> dict:
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.bec_save_current_bs_pos()
return {
"ok": True,
"message": "Saved current BEC beamstop work position.",
}
return {"ok": True, "message": "Saved current BEC beamstop work position."}
@app.post("/bec/save_current_collimator_pos")
@@ -703,26 +676,18 @@ async def bec_save_current_collimator_pos(token: str = Depends(oauth2_scheme)) -
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.bec_save_current_collimator_pos()
return {
"ok": True,
"message": "Saved current BEC collimator work position.",
}
return {"ok": True, "message": "Saved current BEC collimator work position."}
@app.post("/bec/save_current_aerotech_position")
async def bec_save_current_aerotech_position(
token: str = Depends(oauth2_scheme),
) -> dict:
async def bec_save_current_aerotech_position(token: str = Depends(oauth2_scheme)) -> dict:
"""
Save the current BEC aerotech work position and reload device config. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.bec_save_current_aerotech_position()
return {
"ok": True,
"message": "Saved current BEC aerotech work position and reloaded devices.",
}
return {"ok": True, "message": "Saved current BEC aerotech work position and reloaded devices."}
def initialise_aerotech(self):
@@ -790,9 +755,7 @@ async def local_contact_links(token: str = Depends(oauth2_scheme)) -> dict:
@app.post("/local_contact/simulate/{device}")
async def local_contact_set_simulation(
device: str,
enabled: bool,
token: str = Depends(oauth2_scheme),
device: str, enabled: bool, token: str = Depends(oauth2_scheme)
) -> dict:
"""
Enable or disable runtime simulation for a backend device and restart its wrapper. Staff only.
@@ -805,10 +768,7 @@ async def local_contact_set_simulation(
@app.post("/local_contact/restart/{device}")
async def local_contact_restart_device(
device: str,
token: str = Depends(oauth2_scheme),
) -> dict:
async def local_contact_restart_device(device: str, token: str = Depends(oauth2_scheme)) -> dict:
"""
Restart a Local Contact backend wrapper. Staff only.
"""
@@ -837,9 +797,7 @@ async def local_contact_restart_device(
@app.post("/local_contact/resync/detector_metadata")
async def local_contact_resync_detector_metadata(
token: str = Depends(oauth2_scheme),
) -> dict:
async def local_contact_resync_detector_metadata(token: str = Depends(oauth2_scheme)) -> dict:
"""
Refresh cached detector metadata and DTZ limits. Staff only.
"""
@@ -847,17 +805,11 @@ async def local_contact_resync_detector_metadata(
auth.check_jwt_staff_only(data)
payload = daq.refresh_detector_metadata_cache()
return {
"ok": True,
"message": "Hardware metadata cache resynced.",
"payload": payload,
}
return {"ok": True, "message": "Hardware metadata cache resynced.", "payload": payload}
@app.get("/local_contact/config")
async def local_contact_config(
token: str = Depends(oauth2_scheme),
) -> LocalContactConfigModel:
async def local_contact_config(token: str = Depends(oauth2_scheme)) -> LocalContactConfigModel:
"""
Return Local Contact config values. Staff only.
"""
@@ -868,8 +820,7 @@ async def local_contact_config(
@app.put("/local_contact/config")
async def local_contact_set_config(
payload: LocalContactConfigModel,
token: str = Depends(oauth2_scheme),
payload: LocalContactConfigModel, token: str = Depends(oauth2_scheme)
) -> LocalContactConfigModel:
"""
Update Local Contact config values. Staff only.
@@ -1054,9 +1005,7 @@ async def get_image(token: str = Depends(oauth2_scheme)):
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
_, encoded_image = cv2.imencode(
".jpg", daq.camera_image
) # Encodes the image in JPEG format
_, encoded_image = cv2.imencode(".jpg", daq.camera_image) # Encodes the image in JPEG format
image_bytes = io.BytesIO(
encoded_image.tobytes()
) # Convert OpenCV byte format to a file-like object
@@ -1108,10 +1057,7 @@ async def park_and_dry(token: str = Depends(oauth2_scheme)):
auth.check_jwt_rw(cfg, auth.parse_token(token))
logger.debug("Executing unmount, dry and park")
daq.park_and_dry(park=True, unmount=True)
return {
"ok": True,
"message": "TELL has been dried and parked",
}
return {"ok": True, "message": "TELL has been dried and parked"}
@app.post("/tell/dry")
@@ -1129,10 +1075,7 @@ async def tell_dry(token: str = Depends(oauth2_scheme)) -> dict:
auth.check_jwt_rw(data)
logger.debug("Executing dry and return to dewar")
daq.park_and_dry(park=False, unmount=False)
return {
"ok": True,
"message": "TELL dry cycle completed.",
}
return {"ok": True, "message": "TELL dry cycle completed."}
@app.post("/tell/toggle_blower")
@@ -1149,16 +1092,11 @@ async def tell_toggle_blower(token: str = Depends(oauth2_scheme)) -> dict:
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.blower_control()
return {
"ok": True,
"message": "TELL blower toggled.",
}
return {"ok": True, "message": "TELL blower toggled."}
@app.post("/sample/mount")
async def mount(
dbid: int, token: str = Depends(oauth2_scheme), reference: bool = False
):
async def mount(dbid: int, token: str = Depends(oauth2_scheme), reference: bool = False):
"""
Mount a sample from the spreadsheet onto the goniometer.
@@ -1244,10 +1182,7 @@ async def sample_resync(token: str = Depends(oauth2_scheme)) -> dict:
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.sync_current_sample_from_tell(force=True)
logger.info("TELL sample cache resynced via API request.")
return {
"ok": True,
"message": "TELL sample cache resynced.",
}
return {"ok": True, "message": "TELL sample cache resynced."}
def get_spreadsheet(data: TokenData) -> SampleShortInfoList:
@@ -1569,16 +1504,13 @@ async def take_over_beamline(
_validate_recovery_code(payload.confirmation_code)
auth.force_current_sesion(cfg, data)
logger.warning(
"Beamline session forcefully taken over.",
extra={"session": getattr(data, "session", None)},
"Beamline session forcefully taken over.", extra={"session": getattr(data, "session", None)}
)
return "OK"
@app.post("/state/free_beamline")
async def free_beamline(
payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)
) -> str:
async def free_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> str:
"""
Clear the beamline busy flag. Staff only.
@@ -1680,11 +1612,7 @@ async def recovery_unmount_sample(
sample_mounted = _sample_is_mounted()
if not sample_mounted:
return {
"ok": True,
"sample_mounted": False,
"message": "No sample appears to be mounted.",
}
return {"ok": True, "sample_mounted": False, "message": "No sample appears to be mounted."}
prev_state = cfg.state
daq.recovery_unmount_sample()
@@ -1709,9 +1637,7 @@ async def recovery_unmount_sample(
# Scans
@app.post("/scan/raster")
async def raster(
val: RasterGridRequest,
auto_center: bool = False,
token: str = Depends(oauth2_scheme),
val: RasterGridRequest, auto_center: bool = False, token: str = Depends(oauth2_scheme)
) -> CompletedRasterGrid:
"""
Execute a raster scan.
@@ -1775,9 +1701,7 @@ async def auto(s: SampleShortInfo, token: str = Depends(oauth2_scheme)):
@app.post("/scan/smart_params")
async def set_smart_params(
p: SimpleScanParameters, token: str = Depends(oauth2_scheme)
) -> str:
async def set_smart_params(p: SimpleScanParameters, token: str = Depends(oauth2_scheme)) -> str:
"""
Set the 'smart' scan parameters.
@@ -1825,9 +1749,7 @@ async def alc_center_loop(token: str = Depends(oauth2_scheme)) -> str:
@app.post("/alc/ml_bounding_box")
async def alc_ml_bounding_box(
token: str = Depends(oauth2_scheme),
) -> RasterGridRequest | None:
async def alc_ml_bounding_box(token: str = Depends(oauth2_scheme)) -> RasterGridRequest | None:
"""
Request an ML-based bounding box for the sample.
@@ -1859,13 +1781,7 @@ async def face_detection_run(
logger.debug(f"Face detection run: {steps} steps, {step_size} step size")
auth.check_jwt_rw(cfg, auth.parse_token(token))
_push_face_detection_progress(
{
"running": True,
"status": "starting",
"samples": [],
"height_fit": {},
"area_fit": {},
}
{"running": True, "status": "starting", "samples": [], "height_fit": {}, "area_fit": {}}
)
result = daq.face_detection(steps=steps, step_size=step_size)
return result
@@ -2006,9 +1922,7 @@ async def get_gui_sessions(token: str = Depends(oauth2_scheme)) -> list[dict]:
@app.post("/admin/gui_sessions/{session_id}/request_close")
async def request_gui_close(
session_id: int,
grace_seconds: int = 60,
token: str = Depends(oauth2_scheme),
session_id: int, grace_seconds: int = 60, token: str = Depends(oauth2_scheme)
) -> dict:
"""
Staff-only request for a remote GUI to close gracefully.
@@ -2017,28 +1931,18 @@ async def request_gui_close(
auth.check_jwt_staff_only(data)
payload = cfg.request_gui_close(
session=session_id,
requested_by=data.sub,
grace_seconds=grace_seconds,
session=session_id, requested_by=data.sub, grace_seconds=grace_seconds
)
if payload is None:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND,
detail="GUI session not found.",
status_code=api_status.HTTP_404_NOT_FOUND, detail="GUI session not found."
)
return {
"ok": True,
"session": session_id,
"grace_seconds": grace_seconds,
}
return {"ok": True, "session": session_id, "grace_seconds": grace_seconds}
@app.delete("/admin/gui_sessions/{session_id}")
async def force_remove_gui_session(
session_id: int,
token: str = Depends(oauth2_scheme),
) -> dict:
async def force_remove_gui_session(session_id: int, token: str = Depends(oauth2_scheme)) -> dict:
"""
Staff-only hard removal of a GUI session from Redis.
@@ -2052,18 +1956,11 @@ async def force_remove_gui_session(
cfg.end_active_session(session_id)
cfg.remove_gui_session(session_id)
return {
"ok": True,
"session": session_id,
"message": "GUI session force removed.",
}
return {"ok": True, "session": session_id, "message": "GUI session force removed."}
@app.post("/admin/gui_sessions/{session_id}/interaction")
async def update_gui_interaction(
session_id: int,
token: str = Depends(oauth2_scheme),
) -> dict:
async def update_gui_interaction(session_id: int, token: str = Depends(oauth2_scheme)) -> dict:
"""
GUI-side activity heartbeat.
This is distinct from /status so idle timeout ignores status polling.
@@ -2075,13 +1972,10 @@ async def update_gui_interaction(
detail="Cannot update interaction for another session.",
)
payload = cfg.update_gui_interaction(
session=session_id, last_interaction_ts=time.time()
)
payload = cfg.update_gui_interaction(session=session_id, last_interaction_ts=time.time())
if payload is None:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND,
detail="GUI session not found.",
status_code=api_status.HTTP_404_NOT_FOUND, detail="GUI session not found."
)
return {"ok": True}
@@ -2359,9 +2253,7 @@ async def put_settings(s: BeamlineSettingsModel, token: str = Depends(oauth2_sch
@app.get("/beamline/cryo_settings")
async def get_cryo_settings(
token: str = Depends(oauth2_scheme),
) -> CryojetSettingsModel:
async def get_cryo_settings(token: str = Depends(oauth2_scheme)) -> CryojetSettingsModel:
"""
Get the current cryojet settings. Staff only.
@@ -2377,9 +2269,7 @@ async def get_cryo_settings(
@app.put("/beamline/cryo_settings")
async def put_cryo_settings(
s: CryojetSettingsModel, token: str = Depends(oauth2_scheme)
):
async def put_cryo_settings(s: CryojetSettingsModel, token: str = Depends(oauth2_scheme)):
"""
Update the cryojet settings. Staff only.
@@ -2456,9 +2346,7 @@ async def fluorimeter_spectrum(
@app.post("/fluorimeter/start")
async def fluorimeter_start(
erase: bool = False, token: str = Depends(oauth2_scheme)
) -> str:
async def fluorimeter_start(erase: bool = False, token: str = Depends(oauth2_scheme)) -> str:
"""
Start the fluorimeter measurement.
@@ -2521,9 +2409,7 @@ async def fluorimeter_data(token: str = Depends(oauth2_scheme)) -> list[int] | N
@app.get("/fluorimeter/background")
async def fluorimeter_background(
token: str = Depends(oauth2_scheme),
) -> list[int] | None:
async def fluorimeter_background(token: str = Depends(oauth2_scheme)) -> list[int] | None:
"""
Get the fluorimeter background data.
@@ -2559,24 +2445,14 @@ async def fluorimeter_stream() -> AsyncGenerator[str, None]:
data = (
d.tolist()
if hasattr(d, "tolist")
else (
list(d)
if isinstance(d, (tuple, list))
else ([] if d is None else [d])
)
else (list(d) if isinstance(d, (tuple, list)) else ([] if d is None else [d]))
)
bkg = (
b.tolist()
if hasattr(b, "tolist")
else (
list(b)
if isinstance(b, (tuple, list))
else ([] if b is None else [b])
)
)
logger.debug(
f"fluorimeter data: {data[0]}. fluorimeter background: {bkg[0]}"
else (list(b) if isinstance(b, (tuple, list)) else ([] if b is None else [b]))
)
logger.debug(f"fluorimeter data: {data[0]}. fluorimeter background: {bkg[0]}")
payload = json.dumps(
{"status": s, "data": data, "background": bkg}, separators=(",", ":")
)
@@ -2622,9 +2498,7 @@ async def sse_fluorimeter(token: str = Depends(oauth2_scheme)):
@app.post("/samcam/send_screenshot_db")
async def send_screenshot_db(
filename: str | None = None,
message: str | None = None,
token: str = Depends(oauth2_scheme),
filename: str | None = None, message: str | None = None, token: str = Depends(oauth2_scheme)
) -> str:
"""
Capture a screenshot and send it to the database.
@@ -2684,9 +2558,7 @@ async def maintenance(token: str = Depends(oauth2_scheme)) -> str:
auth.check_jwt_staff(cfg, auth.parse_token(token))
cfg.state = BeamlineStateEnum.Maintenance
logger.warning("Beamline state set to Maintenance via protected endpoint.")
raise MaintenanceStateException(
"Beamline was set to Maintenance. Automation must stop."
)
raise MaintenanceStateException("Beamline was set to Maintenance. Automation must stop.")
def main():
+8 -30
View File
@@ -86,10 +86,7 @@ def _extract_context(exc: Exception) -> dict[str, Any]:
def _error_body(
exc: Exception,
*,
code_override: str | None = None,
critical_override: bool | None = None,
exc: Exception, *, code_override: str | None = None, critical_override: bool | None = None
) -> dict[str, Any]:
"""Build the unified error response body for any exception.
@@ -125,9 +122,7 @@ def register_exception_handlers(app) -> None:
"""
@app.exception_handler(AutomationError)
async def _handle_automation_error(
request: Request, exc: AutomationError
) -> JSONResponse:
async def _handle_automation_error(request: Request, exc: AutomationError) -> JSONResponse:
body = _error_body(exc)
log = logger.error if exc.critical else logger.warning
log(
@@ -159,10 +154,7 @@ def register_exception_handlers(app) -> None:
**body["context"],
},
)
return JSONResponse(
status_code=api_status.HTTP_400_BAD_REQUEST,
content=body,
)
return JSONResponse(status_code=api_status.HTTP_400_BAD_REQUEST, content=body)
@app.exception_handler(AareAuthError)
async def _handle_auth_error(request: Request, exc: AareAuthError) -> JSONResponse:
@@ -189,26 +181,16 @@ def register_exception_handlers(app) -> None:
"status_code": status,
},
)
return JSONResponse(
status_code=status,
content=body,
headers=getattr(exc, "headers", None),
)
return JSONResponse(status_code=status, content=body, headers=getattr(exc, "headers", None))
@app.exception_handler(HTTPException)
async def _handle_http_exception(
request: Request, exc: HTTPException
) -> JSONResponse:
async def _handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
"""HTTPException isn't in the AareException hierarchy but FastAPI raises
it internally (e.g. validation, route-not-found) and routes raise it
manually. Adapt to the new body shape so clients see one schema."""
detail = exc.detail
log = logger.error if exc.status_code >= 500 else logger.warning
log(
"HTTPException %s: %s",
exc.status_code,
detail,
)
log("HTTPException %s: %s", exc.status_code, detail)
# Preserve legacy callers that passed a {code, message} dict
if isinstance(detail, dict) and "code" in detail and "message" in detail:
legacy_code = str(detail.get("code"))
@@ -220,9 +202,7 @@ def register_exception_handlers(app) -> None:
"code": legacy_code,
"exception_class": "HTTPException",
"message": legacy_message,
"context": {
k: v for k, v in detail.items() if k not in ("code", "message")
},
"context": {k: v for k, v in detail.items() if k not in ("code", "message")},
},
headers=exc.headers,
)
@@ -245,8 +225,6 @@ def register_exception_handlers(app) -> None:
return JSONResponse(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_body(
exc,
code_override=str(AareErrorCode.INTERNAL_ERROR),
critical_override=True,
exc, code_override=str(AareErrorCode.INTERNAL_ERROR), critical_override=True
),
)
+4 -11
View File
@@ -27,9 +27,7 @@ def set_spreadsheet_in_redis(spreadsheet):
"[REDIS][DEBUG] Data to write:", json.dumps(spreadsheet, indent=4)
) # Pretty-print the data
print("[REDIS][INFO] Writing spreadsheet to Redis...")
config.__client.set(
f"{config._BeamlineConfig__bl}:spreadsheet", json.dumps(spreadsheet)
)
config.__client.set(f"{config._BeamlineConfig__bl}:spreadsheet", json.dumps(spreadsheet))
def on_message(ws, message):
@@ -39,9 +37,7 @@ def on_message(ws, message):
try:
data = json.loads(message)
pucks_data = (
data["samples"] if isinstance(data, dict) and "samples" in data else data
)
pucks_data = data["samples"] if isinstance(data, dict) and "samples" in data else data
pucks = [PuckWithTellPosition(**item) for item in pucks_data]
print(f"INFO: Received pucks: {[p.puck_name for p in pucks]}")
@@ -56,8 +52,7 @@ def on_message(ws, message):
# Reference tool
if isinstance(p.tell_position, str) and p.tell_position.startswith("X"):
dewar_address = DewarAddress(
segment="X",
pos=int(p.tell_position[1:]) if len(p.tell_position) > 1 else 1,
segment="X", pos=int(p.tell_position[1:]) if len(p.tell_position) > 1 else 1
)
target_list = reference_short_infos
@@ -69,9 +64,7 @@ def on_message(ws, message):
target_list = normal_short_infos
else:
print(
f"[WARN] Skipping unknown tell_position format: {p.tell_position}"
)
print(f"[WARN] Skipping unknown tell_position format: {p.tell_position}")
continue
info = SampleShortInfo(
+10 -38
View File
@@ -46,9 +46,7 @@ def _update_state(
mount_success: bool | None = None,
mount_error: str | None = None,
) -> TellStateModel:
updates = {
"last_update_ts": _utc_now_iso(),
}
updates = {"last_update_ts": _utc_now_iso()}
if activity is not None:
updates["activity"] = activity
@@ -72,9 +70,7 @@ def _update_state(
return state.model_copy(update=updates)
def _ready_state(
state: TellStateModel, event_name: str, event_value: str | None
) -> TellStateModel:
def _ready_state(state: TellStateModel, event_name: str, event_value: str | None) -> TellStateModel:
return _update_state(
state,
activity=TellActivityEnum.IDLE,
@@ -88,9 +84,7 @@ def _ready_state(
def advance_tell_state(
state: TellStateModel,
event_name: str,
event_value: str | None,
state: TellStateModel, event_name: str, event_value: str | None
) -> TellStateModel:
value = (event_value or "").strip()
@@ -120,20 +114,14 @@ def advance_tell_state(
if event_name == "shell":
if value == "true":
return _update_state(
state,
event_name=event_name,
event_value=value,
)
return _update_state(state, event_name=event_name, event_value=value)
if value.startswith("mount("):
sample_position = _parse_sample_position_from_mount_command(value)
return _update_state(
state,
activity=TellActivityEnum.MOUNTING,
message=f"Mounting {sample_position}"
if sample_position
else "Mounting sample",
message=f"Mounting {sample_position}" if sample_position else "Mounting sample",
event_name=event_name,
event_value=value,
operation="mount",
@@ -201,11 +189,7 @@ def advance_tell_state(
event_value=value,
)
return _update_state(
state,
event_name=event_name,
event_value=value,
)
return _update_state(state, event_name=event_name, event_value=value)
if value == "got result False":
if state.operation == "mount" and state.phase in {
@@ -260,11 +244,7 @@ def advance_tell_state(
mount_error="",
)
return _update_state(
state,
event_name=event_name,
event_value=value,
)
return _update_state(state, event_name=event_name, event_value=value)
if event_name == "Motion Sync":
if value == "Sample get from Gonio":
@@ -304,9 +284,7 @@ def advance_tell_state(
event_value=value,
operation=next_operation,
phase=next_phase,
mount_success=True
if state.operation == "unmount"
else state.mount_success,
mount_success=True if state.operation == "unmount" else state.mount_success,
mount_error="" if state.operation == "unmount" else state.mount_error,
)
@@ -366,15 +344,9 @@ def advance_tell_state(
if value == "Busy":
return _update_state(
state,
message=state.message
if state.operation not in {None, "idle"}
else "Busy",
message=state.message if state.operation not in {None, "idle"} else "Busy",
event_name=event_name,
event_value=value,
)
return _update_state(
state,
event_name=event_name,
event_value=value,
)
return _update_state(state, event_name=event_name, event_value=value)
+15 -62
View File
@@ -12,10 +12,7 @@ import sseclient
import websocket
from aarecommon.config.beamline import mx_beamline
from aarecommon.config.logger import setup_logger
from aarecommon.errors.exception_handler import (
TellCommunicationError,
TellConnectionException,
)
from aarecommon.errors.exception_handler import TellCommunicationError, TellConnectionException
from aarecommon.models.beamline import MXBeamline
from aarecommon.models.tell import TellStateModel
from aareDB.exceptions import ApiException
@@ -42,16 +39,10 @@ last_pucks_sent = []
DEWAR_CONTENT_UPDATE_EVENT = "DewarContentUpdate"
TRACKED_TELL_EVENT_VALUES = {
"Gripper detection": {
"Pin still in Gripper",
"No Pin in Gripper",
"Pin is lost",
},
"Gripper detection": {"Pin still in Gripper", "No Pin in Gripper", "Pin is lost"}
}
TRACKED_MOTION_EVENT_VALUES = {
"Motion Task": {"dry"},
}
TRACKED_MOTION_EVENT_VALUES = {"Motion Task": {"dry"}}
TRACKED_MOTION_SYNC_EVENTS = {
"Motion Sync": {
"Robot Clear after mount",
@@ -60,7 +51,7 @@ TRACKED_MOTION_SYNC_EVENTS = {
"Sample get from Gonio",
"Sample put on Puck",
"Sample get on Puck",
},
}
}
TRACKED_STATE_EVENTS = {"state": {"Ready", "Busy"}}
@@ -71,16 +62,8 @@ TELL_JOURNAL_PREFIX = "[TELL][JOURNAL]"
TELL_EVENTS_REDIS_KEY_SUFFIX = "tell_events"
TELL_STATE_REDIS_KEY_SUFFIX = "tell_state"
SSE_RECONNECT_DELAY_S = 5
SSE_RECOVERABLE_EXCEPTIONS = (
RequestException,
OSError,
ssl.SSLError,
)
WS_RECOVERABLE_EXCEPTIONS = (
OSError,
ssl.SSLError,
websocket.WebSocketException,
)
SSE_RECOVERABLE_EXCEPTIONS = (RequestException, OSError, ssl.SSLError)
WS_RECOVERABLE_EXCEPTIONS = (OSError, ssl.SSLError, websocket.WebSocketException)
class TellSsePayload(BaseModel):
@@ -92,13 +75,7 @@ class TellSsePayload(BaseModel):
@property
def normalized_value(self) -> str | None:
for candidate in (
self.value,
self.eventValue,
self.data,
self.message,
self.state,
):
for candidate in (self.value, self.eventValue, self.data, self.message, self.state):
if candidate is None:
continue
stripped = candidate.strip()
@@ -113,11 +90,7 @@ class TellEventRecord(BaseModel):
event: str
def redis_dict(self) -> dict[str, str]:
return {
"timestamp": self.timestamp,
"class": self.class_,
"event": self.event,
}
return {"timestamp": self.timestamp, "class": self.class_, "event": self.event}
def _get_redis_context() -> tuple[Any | None, str | None]:
@@ -128,9 +101,7 @@ def _get_redis_context() -> tuple[Any | None, str | None]:
redis_client = getattr(config, "_BeamlineConfig__client", None)
beamline_key = getattr(config, "_BeamlineConfig__bl", None)
if redis_client is None or beamline_key is None:
logger.error(
"[REDIS] BeamlineConfig internals unavailable; skipping TELL redis write"
)
logger.error("[REDIS] BeamlineConfig internals unavailable; skipping TELL redis write")
return None, None
return cast(Any, redis_client), str(beamline_key)
@@ -200,26 +171,16 @@ def extract_tracked_tell_event(event_name, event_data):
def set_tell_events_in_redis(events: list[dict[str, str]]) -> None:
_set_json_in_redis(
TELL_EVENTS_REDIS_KEY_SUFFIX,
events,
log_label="TELL events",
)
_set_json_in_redis(TELL_EVENTS_REDIS_KEY_SUFFIX, events, log_label="TELL events")
def set_tell_state_in_redis(state: TellStateModel) -> None:
_set_json_in_redis(
TELL_STATE_REDIS_KEY_SUFFIX,
state.model_dump(),
log_label="TELL state",
)
_set_json_in_redis(TELL_STATE_REDIS_KEY_SUFFIX, state.model_dump(), log_label="TELL state")
def record_tell_event(event_name, event_value):
event_record = TellEventRecord(
timestamp=datetime.now(timezone.utc).isoformat(),
class_=event_name,
event=event_value,
timestamp=datetime.now(timezone.utc).isoformat(), class_=event_name, event=event_value
)
latest_tell_events[event_name] = event_value
@@ -252,9 +213,7 @@ def listen_to_sse():
# mTLS path
# ca_root = "/etc/ssl/certs/secrets/mx-db-01_Full_Chain_CA.pem"
ca_root = "/etc/ssl/certs/secrets/mx-aaredb-dmz-01_Full_Chain_CA.pem"
response = requests.get(
sse_url, stream=True, cert=cert_pair, verify=ca_root
)
response = requests.get(sse_url, stream=True, cert=cert_pair, verify=ca_root)
response.raise_for_status()
client = sseclient.SSEClient(response)
else:
@@ -271,9 +230,7 @@ def listen_to_sse():
# print(f"event = {event.event} with data: {event.data}")
on_sse_event(event)
except SSE_RECOVERABLE_EXCEPTIONS:
logger.exception(
"[SSE] Recoverable failure while consuming TELL event stream"
)
logger.exception("[SSE] Recoverable failure while consuming TELL event stream")
logger.info(f"[SSE] Reconnecting in {SSE_RECONNECT_DELAY_S} seconds")
time.sleep(SSE_RECONNECT_DELAY_S)
@@ -326,11 +283,7 @@ def on_sse_event(event):
logger.debug(f"[EVENT] SSE event={event.event} data={event.data!r}")
normalized_data = _normalize_event_data(event.data)
current_tell_state = advance_tell_state(
current_tell_state,
event.event,
normalized_data,
)
current_tell_state = advance_tell_state(current_tell_state, event.event, normalized_data)
set_tell_state_in_redis(current_tell_state)
tracked_event = extract_tracked_tell_event(event.event, normalized_data)
+3 -9
View File
@@ -13,9 +13,7 @@ logger = setup_logger("aareDAQ")
SAFE_POSITION = 600
def _move_detector_to_safe_position_if_needed(
devs: BeamlineDevices, cfg: BeamlineConfig
):
def _move_detector_to_safe_position_if_needed(devs: BeamlineDevices, cfg: BeamlineConfig):
safe_position = cfg.dtz_safe_position
current_dtz = devs.dtz
@@ -157,9 +155,7 @@ def sa2dc(devs: BeamlineDevices, cfg: BeamlineConfig):
start = time.perf_counter()
det_z_pos = cfg.dtz
status = devs.bec_worker.det_z(value=det_z_pos)
logger.info(
f"start detector move to {cfg.dtz} at {time.perf_counter() - start:.2f}"
)
logger.info(f"start detector move to {cfg.dtz} at {time.perf_counter() - start:.2f}")
devs.samcam_auto(AutoEnum.AUTO)
logger.info(f"sam cam auto exposure in {time.perf_counter() - start:.2f}")
devs.bec_worker.move_to(BeamlineState.DATA_COLLECTION)
@@ -264,9 +260,7 @@ def common2dh(devs: BeamlineDevices, cfg: BeamlineConfig):
devs.samcam_auto(AutoEnum.ONCE)
if devs.tell.is_position("pPark") and devs.tell.get_mounted_sample() is None:
logger.info(
"TELL already in pPark; skipping dry/park preparation for dewar transfer"
)
logger.info("TELL already in pPark; skipping dry/park preparation for dewar transfer")
return
if devs.tell.get_mounted_sample() is not None:
+5 -24
View File
@@ -49,10 +49,7 @@ class AerotechController(object):
self.__api = DefaultApi(self.__client)
def __make_aerotech_target(
self,
coord: AerotechCoordinate,
wait: bool = False,
incremental: bool = False,
self, coord: AerotechCoordinate, wait: bool = False, incremental: bool = False
) -> Target:
at_mm = coord.at_mm
@@ -96,12 +93,7 @@ class AerotechController(object):
def get_position(self) -> AerotechCoordinate:
status = self.status()
return AerotechCoordinate(
at_mm=Coordinate(
x=status.x.pos,
y=status.y.pos,
z=status.z.pos,
),
omega_deg=status.u.pos,
at_mm=Coordinate(x=status.x.pos, y=status.y.pos, z=status.z.pos), omega_deg=status.u.pos
)
def status(self) -> Status:
@@ -162,10 +154,7 @@ class AerotechController(object):
return self.__api.home_post()
except Exception as e:
raise AerotechCommunicationError(
"Aerotech home failed",
endpoint="home_post",
base_url=self.__base,
operation="POST",
"Aerotech home failed", endpoint="home_post", base_url=self.__base, operation="POST"
) from e
def wait_till_done(self, timeout=60):
@@ -179,19 +168,11 @@ class AerotechController(object):
operation="POST",
) from e
def position(
self,
target: AerotechCoordinate,
/,
wait: bool = True,
incremental: bool = False,
):
def position(self, target: AerotechCoordinate, /, wait: bool = True, incremental: bool = False):
if self.__simulated:
return self.__pos
payload = self.__make_aerotech_target(
target, wait=wait, incremental=incremental
)
payload = self.__make_aerotech_target(target, wait=wait, incremental=incremental)
try:
return self.__api.position_post(payload)
except Exception as e:
+6 -3
View File
@@ -4,11 +4,13 @@ from enum import Enum
import epics
import numpy as np
class AutoEnum(Enum):
MANUAL = 0
ONCE = 1
AUTO = 2
@dataclasses.dataclass
class AutoExposureSettings:
aoi_offset_x: int = 500
@@ -18,6 +20,7 @@ class AutoExposureSettings:
gain_max: float = 5.000
exp_max: float = 30000.000
class epicsAD(object):
def __init__(self, prefix, cam="cam1:", image="image1:"):
self.img = None
@@ -70,8 +73,8 @@ class epicsAD(object):
self.gain_max.put(settings.gain_max)
self.exp_max.put(settings.exp_max)
self.set_auto(AutoEnum.ONCE)
#TODO add a white_balance button to GUI or set it so it happens with backlight in and on
#self.auto_white_balance.put(1)
# TODO add a white_balance button to GUI or set it so it happens with backlight in and on
# self.auto_white_balance.put(1)
self.acquire.put(1)
def get_image(self, gray=True) -> np.ndarray:
@@ -117,4 +120,4 @@ class epicsAD(object):
self.acquire.put(0, wait=True)
self.gain_mode.put(state.value) # automatic gain control
self.expo_mode.put(state.value) # automatic exposure control
self.acquire.put(1)
self.acquire.put(1)
+15 -45
View File
@@ -83,9 +83,7 @@ class BECClientWorker:
service_config.config["log_writer"]["base_path"] = "/tmp/logs"
# service_config.config["user_macros"]["base_path"]=f'/sls/{BEAMLINE}/config/bec/production/pxiii_bec/pxiii_bec'
# print(service_config.config)
self.client = BECIPythonClient(
config=service_config, mode=OperationMode.Procedure
)
self.client = BECIPythonClient(config=service_config, mode=OperationMode.Procedure)
self.client.start()
# self.client.config.update_session_with_file("/sls/x10sa/config/bec/production/bec/bec_lib/bec_lib/config_helper.py")
self.dev = self.client.device_manager.devices
@@ -164,11 +162,7 @@ class BECClientWorker:
exception=exc,
) from exc
raise BECCommunicationError(
message,
operation=operation,
exception=exc,
) from exc
raise BECCommunicationError(message, operation=operation, exception=exc) from exc
def __set_scilog_tags(self, tags: Optional[List[str]] = None):
try:
@@ -243,9 +237,7 @@ class BECClientWorker:
except Exception as e:
self._raise_bec_error(e, operation=f"run_macro:{macro_name}")
def run_macro_blocked(
self, macro_name: str, *args, queue: str = "default", **kwargs
):
def run_macro_blocked(self, macro_name: str, *args, queue: str = "default", **kwargs):
if self.simulated:
logger.debug(f"Simulating macro {macro_name}")
return None
@@ -308,9 +300,7 @@ class BECClientWorker:
return []
try:
self.__list_all_macros()
raw_macros = [
name for name, _ in self.client.macros._update_handler.macros.items()
]
raw_macros = [name for name, _ in self.client.macros._update_handler.macros.items()]
if raw_macros is None:
logger.warning("BEC returned no user macros; treating as empty list")
return []
@@ -353,23 +343,18 @@ class BECClientWorker:
List of position device names after reinitialisation.
"""
if self.simulated:
logger.debug(
f"Simulating reinitialise_planner_and_position_devices(method={method})"
)
logger.debug(f"Simulating reinitialise_planner_and_position_devices(method={method})")
return []
try:
self.client.config.update_session_with_file(
f"/sls/{self.beamline}/config/bec/production/{self._beamline_name}_bec/{self._beamline_name}_bec/device_configs/{self._beamline_name}-devices.yaml"
)
self.__init_beamline_environment()
logger.info(
f"Reinitialised BEC planner and position devices using method={method}"
)
logger.info(f"Reinitialised BEC planner and position devices using method={method}")
return self.list_position_devices()
except Exception as e:
self._raise_bec_error(
e,
operation=f"reinitialise_planner_and_position_devices:{method}",
e, operation=f"reinitialise_planner_and_position_devices:{method}"
)
def shutdown_client(self):
@@ -379,17 +364,11 @@ class BECClientWorker:
try:
mono_pitch_scan(plot)
except Exception as e:
self._raise_bec_error(
e, operation="mono_pitch_scan", tags=["mono_pitch_scan"]
)
self._raise_bec_error(e, operation="mono_pitch_scan", tags=["mono_pitch_scan"])
if self.beamline is MXBeamline.X06DA:
addtional_text = [
f"New dcm_pitch position: {self.dev.dcm_pitch.position:5f}"
]
addtional_text = [f"New dcm_pitch position: {self.dev.dcm_pitch.position:5f}"]
else:
addtional_text = [
f"New dcm_theta2 position: {self.dev.dccm_theta2.position:5f}"
]
addtional_text = [f"New dcm_theta2 position: {self.dev.dccm_theta2.position:5f}"]
self.scilog_msg(
message="Mono pitch scan completed",
bold=True,
@@ -407,16 +386,13 @@ class BECClientWorker:
def change_energy(self, value: float | int, plot: bool = False):
current_energy = self.check_current_energy()
logger.info(f"Current energy: {current_energy:.1f} eV")
logger.info(
f"Change energy requested: from {current_energy:.1f} to {value:.1f} eV"
)
logger.info(f"Change energy requested: from {current_energy:.1f} to {value:.1f} eV")
try:
bl_energy(value, move_gap=False, mono_scan=True, plot=plot)
except Exception as e:
self._raise_bec_error(
e,
operation=f"Requested energy change from:"
f"{current_energy:.1f} to {value} eV",
operation=f"Requested energy change from:{current_energy:.1f} to {value} eV",
tags=["energy_change"],
)
@@ -462,9 +438,7 @@ class BECClientWorker:
status.wait(timeout=timeout)
return status
except Exception as e:
self._raise_bec_error(
e, operation=f"scans.mv:det_z:{value}", tags=["det_z"]
)
self._raise_bec_error(e, operation=f"scans.mv:det_z:{value}", tags=["det_z"])
def get_det_y(self):
try:
@@ -480,9 +454,7 @@ class BECClientWorker:
status.wait(timeout=timeout)
return status
except Exception as e:
self._raise_bec_error(
e, operation=f"scans.mv:det_y:{value}", tags=["det_z"]
)
self._raise_bec_error(e, operation=f"scans.mv:det_y:{value}", tags=["det_z"])
@property
def backlight_brightness(self) -> BrightnessEnum:
@@ -506,9 +478,7 @@ class BECClientWorker:
try:
self.__backlight_brightness.move(value)
except Exception as e:
self._raise_bec_error(
e, operation=f"backlight_brightness:{value}", tags=["backlight"]
)
self._raise_bec_error(e, operation=f"backlight_brightness:{value}", tags=["backlight"])
raise
def get_backlight_pos(self) -> BrightnessEnum:
+2 -1
View File
@@ -3,6 +3,7 @@ from typing import Any
from aare.devices.set_get_pv import SetGetPV, MoveResult
class EnumPV(SetGetPV):
def __init__(self, name: str, setpv: str, getpv: str, **kwargs):
super().__init__(name, setpv, getpv, **kwargs)
@@ -32,4 +33,4 @@ class EnumPV(SetGetPV):
return MoveResult(target=s, name=s)
raise ValueError(f"'{x}' not in {list(self.setpoint_pv.enum_strs)}")
raise TypeError(f"Unsupported enum command type: {type(x).__name__}")
raise TypeError(f"Unsupported enum command type: {type(x).__name__}")
+1 -3
View File
@@ -50,6 +50,4 @@ class FilterTransmission:
timeisup = time.time() > timeout
poll(0.1)
if timeisup and self._fail_on_timeout:
raise RuntimeError(
"timeout waiting for filters to achieve requested transmission."
)
raise RuntimeError("timeout waiting for filters to achieve requested transmission.")
+2 -6
View File
@@ -13,9 +13,7 @@ class Fluorimeter(object):
self.__start = PV(f"{BEAMLINE}-ES-SiD:mca1Start") # 0 done ,1 start
self.__stop = PV(f"{BEAMLINE}-ES-SiD:mca1Stop") # 0 done ,1 stop
self.__erase_and_start = PV(
f"{BEAMLINE}-ES-SiD:mca1EraseStart"
) # 0 done,1 start
self.__erase_and_start = PV(f"{BEAMLINE}-ES-SiD:mca1EraseStart") # 0 done,1 start
self.__erase = PV(f"{BEAMLINE}-ES-SiD:mca1Erase") # 0 done,1 erase
self.__preset_mode = PV(f"{BEAMLINE}-ES-SiD:dxp1:PresetMode") # set mode
@@ -25,9 +23,7 @@ class Fluorimeter(object):
self.__live_time = PV(f"{BEAMLINE}-ES-SiD:mca1.PLTM") # float
self.__elapsed_real_time = PV(f"{BEAMLINE}-ES-SiD:mca1.ERTM") # float
self.__elapsed_live_time = PV(f"{BEAMLINE}-ES-SiD:mca1.ELTM") # float
self.__elapsed_trigger_live_time = PV(
f"{BEAMLINE}-ES-SiD:dxp1:ElapsedTriggerLiveTime"
)
self.__elapsed_trigger_live_time = PV(f"{BEAMLINE}-ES-SiD:dxp1:ElapsedTriggerLiveTime")
self.__instant_dead_time = PV(f"{BEAMLINE}-ES-SiD:mca1.IDTIM")
self.__average_dead_time = PV(f"{BEAMLINE}-ES-SiD:mca1.DTIM")
+9 -32
View File
@@ -30,9 +30,7 @@ class JFJochWrapper:
self.__api = None
return
self.__client = jfjoch_client.ApiClient(
jfjoch_client.Configuration(host=self.__url)
)
self.__client = jfjoch_client.ApiClient(jfjoch_client.Configuration(host=self.__url))
self.__api = jfjoch_client.DefaultApi(self.__client)
self.cancel()
# if not self.is_idle():
@@ -52,12 +50,7 @@ class JFJochWrapper:
return None
def _raise_jfjoch_error(
self,
message: str,
*,
error: Exception,
operation: str,
endpoint: str,
self, message: str, *, error: Exception, operation: str, endpoint: str
) -> None:
raise JFJochCommunicationError(
message,
@@ -72,10 +65,7 @@ class JFJochWrapper:
self.__api.initialize_post()
except Exception as e:
self._raise_jfjoch_error(
"JFJoch initialize failed",
error=e,
operation="POST",
endpoint="initialize_post",
"JFJoch initialize failed", error=e, operation="POST", endpoint="initialize_post"
)
def cancel(self):
@@ -83,10 +73,7 @@ class JFJochWrapper:
self.__api.cancel_post()
except Exception as e:
self._raise_jfjoch_error(
"JFJoch cancel failed",
error=e,
operation="POST",
endpoint="cancel_post",
"JFJoch cancel failed", error=e, operation="POST", endpoint="cancel_post"
)
def is_idle(self) -> bool:
@@ -213,9 +200,7 @@ class JFJochWrapper:
f: FluorescenceSpectrumOutputModel | None = None,
async_start: bool = True,
):
dataset_settings = self.__format_dataset_settings(
r, s, f, async_start=async_start
)
dataset_settings = self.__format_dataset_settings(r, s, f, async_start=async_start)
try:
self.__api.start_post(dataset_settings=dataset_settings)
except Exception as e:
@@ -238,18 +223,14 @@ class JFJochWrapper:
else:
self.__start_scan(ScanTypeEnum.ROTATION, r, s, f, async_start=async_start)
def measure_raster(
self, r: RasterGridRequest, s: DAQStatusModel, async_start: bool = True
):
def measure_raster(self, r: RasterGridRequest, s: DAQStatusModel, async_start: bool = True):
self.__start_scan(ScanTypeEnum.RASTER, r, s, async_start=async_start)
def wait_till_running(self, timeout: int | float = 60):
if self.__simulated:
return None
try:
self.__api.wait_until_running_post_with_http_info(
timeout=math.ceil(timeout)
)
self.__api.wait_until_running_post_with_http_info(timeout=math.ceil(timeout))
return True
except Exception as e:
self._raise_jfjoch_error(
@@ -259,9 +240,7 @@ class JFJochWrapper:
endpoint="wait_until_running_post",
)
def wait_till_done(
self, timeout: int | float
) -> jfjoch_client.models.ScanResult | None:
def wait_till_done(self, timeout: int | float) -> jfjoch_client.models.ScanResult | None:
if self.__simulated:
return None
try:
@@ -385,9 +364,7 @@ if __name__ == "__main__":
pixel_in_mm=0.001,
aerotech=Coordinate(x=0, y=0),
aerotech_meas=Coordinate(x=0, y=0),
smargon=SmargonCoordinate(
sh_mm=Coordinate(x=0, y=0, z=0), phi_deg=0, chi_deg=0
),
smargon=SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=0), phi_deg=0, chi_deg=0),
omega_deg=10.5,
beam_size_mm=Coordinate(x=0.02, y=0.01),
),
+43 -14
View File
@@ -4,6 +4,7 @@ from typing import Callable, Union, Any
from epics import PV, Motor, poll
def wait_for_movement_to_finish(*motors):
"""
Wait for all {motors} passed in argument to finish movement.
@@ -38,8 +39,15 @@ class ValueWaitTimeout(Exception):
pass
def pv_wait(pv: PV | Motor, value: Any, *, timeout: float = 60.0,
polling: float = 0.1, tolerance: float | None=None, verbose: bool =False):
def pv_wait(
pv: PV | Motor,
value: Any,
*,
timeout: float = 60.0,
polling: float = 0.1,
tolerance: float | None = None,
verbose: bool = False,
):
"""wait until an epics.PV reaches a value
pv: epics.PV | epics.Motor
the PV on which you want to wait on
@@ -62,14 +70,16 @@ def pv_wait(pv: PV | Motor, value: Any, *, timeout: float = 60.0,
tolerance = m.get("RDBD")
if tolerance is None:
print(f"WARNING: motor {m._prefix[:-1]} has no RDBD, using 0.001")
tolerance = 0.001 #FIXME
tolerance = 0.001 # FIXME
if target is None:
target = m.drive
diff = abs(target - m.readback)
return m.done_moving and diff < tolerance
wait_motor_position(pv, checker, target=value, tolerance=tolerance, timeout=timeout, polling=polling)
wait_motor_position(
pv, checker, target=value, tolerance=tolerance, timeout=timeout, polling=polling
)
elif is_epics_type(pv, "double") or is_epics_type(pv, "time_double"):
wait_float_condition(pv, value, tolerance=tolerance, timeout=timeout, polling=polling)
@@ -91,7 +101,9 @@ def is_epics_type(pv: PV, pv_type: str) -> bool:
return pv_type == pv.type
def wait_string_condition(pv: PV, target: Union[str, re.Pattern], *, timeout: float = 60.0, polling: float = 0.1):
def wait_string_condition(
pv: PV, target: Union[str, re.Pattern], *, timeout: float = 60.0, polling: float = 0.1
):
"""wait until an epics.PV of type string reaches target
:pv: epics.PV
PV should be of type string
@@ -116,11 +128,19 @@ def wait_string_condition(pv: PV, target: Union[str, re.Pattern], *, timeout: fl
poll(polling)
if time.time() > tout:
raise TimeoutError(f"timeout waiting for string {pv.pvname} == {target}; actual value == {pv.char_value}")
raise TimeoutError(
f"timeout waiting for string {pv.pvname} == {target}; actual value == {pv.char_value}"
)
def wait_float_condition(pv: PV, value:float, *, timeout: float = 60.0,
polling: float = 0.1, tolerance: float| None = None):
def wait_float_condition(
pv: PV,
value: float,
*,
timeout: float = 60.0,
polling: float = 0.1,
tolerance: float | None = None,
):
"""wait until an epics.PV of type double reaches value
pv: epics.PV
the PV enum on which you want to wait on
@@ -168,7 +188,9 @@ def wait_float_condition(pv: PV, value:float, *, timeout: float = 60.0,
raise TimeoutError(f"timeout waiting for enum {pv.pvname} == {value}")
def wait_motor_position(motor: Motor, tester: Callable, *, timeout: float = 50.0, polling: float = 0.1, **kwargs):
def wait_motor_position(
motor: Motor, tester: Callable, *, timeout: float = 50.0, polling: float = 0.1, **kwargs
):
"""wait until an epics.Motor reaches a condition
motor: epics.Motor
the motor on which you want to wait on
@@ -202,10 +224,14 @@ def wait_motor_position(motor: Motor, tester: Callable, *, timeout: float = 50.0
poll(polling)
if time.time() > tout:
raise TimeoutError(f"timeout waiting for a condition on {motor} {motor.drive} != {motor.readback}")
raise TimeoutError(
f"timeout waiting for a condition on {motor} {motor.drive} != {motor.readback}"
)
def wait_enum_condition(pv: PV, value: Union[str, int, re.Pattern], *, timeout: float = 60.0, polling=0.1):
def wait_enum_condition(
pv: PV, value: Union[str, int, re.Pattern], *, timeout: float = 60.0, polling=0.1
):
"""wait until an epics.PV enum reaches value
pv: epics.PV
the PV enum on which you want to wait on
@@ -244,12 +270,15 @@ def wait_enum_condition(pv: PV, value: Union[str, int, re.Pattern], *, timeout:
poll(polling)
if time.time() > tout:
raise TimeoutError(f"timeout waiting for enum {pv.pvname} == {value},"
f"current value is {pv.get(as_string=True)}")
raise TimeoutError(
f"timeout waiting for enum {pv.pvname} == {value},"
f"current value is {pv.get(as_string=True)}"
)
def clean_filename(filename: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]", "_", filename.strip())
cleaned = cleaned.strip("._-")
if not cleaned:
raise ValueError("Filename is empty after sanitization.")
return cleaned
return cleaned
+10 -8
View File
@@ -5,18 +5,19 @@ from epics import Motor
class MyMotor(Motor):
"""Wrapper for the EPICS motor PV."""
def __init__(self, name, timeout=5.0):
super().__init__(name.upper(), timeout=timeout)
@property
def speed(self):
"""Gets the current motor.slew_speed value"""
return self.get('VELO')
return self.get("VELO")
@speed.setter
def speed(self, v):
"""Sets the motor slew speed"""
self.put('VELO', v)
self.put("VELO", v)
@property
def position(self):
@@ -40,7 +41,7 @@ class MyMotor(Motor):
@property
def moving(self):
"""Returns True if the motor is moving"""
return bool(self.get('MOVN'))
return bool(self.get("MOVN"))
@property
def units(self):
@@ -50,14 +51,14 @@ class MyMotor(Motor):
@property
def limits(self):
"""Returns (low_limit, high_limit)"""
return self.get('HLM'), self.get('LLM')
return self.get("HLM"), self.get("LLM")
@limits.setter
def limits(self, limits):
"""Sets (low_limit, high_limit)"""
low, high = limits
self.put('LLM', low)
self.put('HLM', high)
self.put("LLM", low)
self.put("HLM", high)
def move_motor(self, val, relative=False, wait=False, timeout=300.0):
"""
@@ -70,12 +71,12 @@ class MyMotor(Motor):
return self.move(val, relative=relative, wait=wait, timeout=timeout)
def home(self, direction='forward', wait=False):
def home(self, direction="forward", wait=False):
"""
Homes the motor.
:param direction: 'forward' or 'reverse'
"""
field = 'HOMF' if direction == 'forward' else 'HOMR'
field = "HOMF" if direction == "forward" else "HOMR"
self.put(field, 1)
if wait:
self.wait_for_stop()
@@ -95,6 +96,7 @@ class MyMotor(Motor):
Asynchronous wait until the motor stops moving.
"""
import asyncio
start_time = time.time()
while self.moving:
await asyncio.sleep(poll_rate)
+20 -3
View File
@@ -12,6 +12,7 @@ ResolverValue = Union[
tuple[Callable[..., RawValue], tuple[Any, ...]], # (func, args) pattern you already use
]
@dataclass
class MoveResult:
target: RawValue
@@ -19,7 +20,15 @@ class MoveResult:
class SetGetPV:
def __init__(self, name: str, setpv: str, getpv: str, *, timeout: float = 60.0, tolerance: float | None = None):
def __init__(
self,
name: str,
setpv: str,
getpv: str,
*,
timeout: float = 60.0,
tolerance: float | None = None,
):
self.name = name
self.setpoint_pv = PV(setpv)
self.readback_pv = PV(getpv)
@@ -46,10 +55,18 @@ class SetGetPV:
def wait(self, *, timeout: float | None = None):
if self._last_target is None:
return
pv_wait(self.readback_pv, self._last_target, timeout=timeout or self.default_timeout, tolerance=self.tolerance)
pv_wait(
self.readback_pv,
self._last_target,
timeout=timeout or self.default_timeout,
tolerance=self.tolerance,
)
class PredefinedPV(SetGetPV):
def __init__(self, name: str, setpv: str, getpv: str, predefs: Mapping[str, ResolverValue], **kwargs):
def __init__(
self, name: str, setpv: str, getpv: str, predefs: Mapping[str, ResolverValue], **kwargs
):
super().__init__(name, setpv, getpv, **kwargs)
self._predefs = dict(predefs)
+5 -13
View File
@@ -16,22 +16,16 @@ class SmargonMode(Enum):
class Smargon(object):
SMARGON_HOME = SmargonCoordinate(
sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0
)
SMARGON_HOME = SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0)
AERO_HOME = AerotechCoordinate(x=0, y=0, z=0, omega=0)
def __init__(self, bl: MXBeamline):
if bl == MXBeamline.X06DA:
self.__simulated = False
self.__base = cfg_get(
"daq.hardware.smargon_url", "http://x06da-smargopolo.psi.ch:3000"
)
self.__base = cfg_get("daq.hardware.smargon_url", "http://x06da-smargopolo.psi.ch:3000")
elif bl == MXBeamline.X10SA:
self.__simulated = False
self.__base = cfg_get(
"daq.hardware.smargon_url", "http://x10sa-smargopolo.psi.ch:3000"
)
self.__base = cfg_get("daq.hardware.smargon_url", "http://x10sa-smargopolo.psi.ch:3000")
elif bl == MXBeamline.X06SA:
raise NotImplementedError("Not implemented smargon url for X06SA")
elif bl == MXBeamline.SIMULATED:
@@ -158,8 +152,7 @@ class Smargon(object):
acs = self.gonget("readbackAEROTECH")
return AerotechCoordinate(
at_mm=Coordinate(x=acs["GMX"], y=acs["GMY"], z=acs["GMZ"]),
omega_deg=acs["GMU"],
at_mm=Coordinate(x=acs["GMX"], y=acs["GMY"], z=acs["GMZ"]), omega_deg=acs["GMU"]
)
@property
@@ -199,8 +192,7 @@ class Smargon(object):
acs = self.gonget("targetAEROTECH") # targetAEROTECH, #targetOMEGA
return AerotechCoordinate(
at_mm=Coordinate(x=acs["GMX"], y=acs["GMY"], z=acs["GMZ"]),
omega_deg=acs["GMU"],
at_mm=Coordinate(x=acs["GMX"], y=acs["GMY"], z=acs["GMZ"]), omega_deg=acs["GMU"]
)
@target_aerotech.setter
+11 -38
View File
@@ -49,9 +49,7 @@ class TellBackend(Protocol):
def start_eval(self, expr: str) -> int: ...
def run(
self, path: str, pars: list[str] | None = None, background: bool = False
) -> None: ...
def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None: ...
def abort(self) -> None: ...
@@ -88,9 +86,7 @@ class PShellTellBackend:
def _resolve_url(bl: MXBeamline) -> str:
beamline = bl.value.lower()
if bl == MXBeamline.X06DA:
return cfg_get(
"daq.hardware.tell_url", f"http://{beamline}-tell.psi.ch:22222"
)
return cfg_get("daq.hardware.tell_url", f"http://{beamline}-tell.psi.ch:22222")
if bl == MXBeamline.X10SA:
return cfg_get("daq.hardware.tell_url", "http://PC17488:22222")
if bl == MXBeamline.X06SA:
@@ -124,11 +120,7 @@ class PShellTellBackend:
if e.response is not None and e.response.text:
msg = e.response.text.strip()
raise TellCommunicationError(
msg,
base_url=self._url,
endpoint=endpoint,
operation=operation,
critical=True,
msg, base_url=self._url, endpoint=endpoint, operation=operation, critical=True
) from e
def eval(self, expr: str):
@@ -142,9 +134,7 @@ class PShellTellBackend:
def start_eval(self, expr: str) -> int:
return self._pshell.start_eval(expr)
def run(
self, path: str, pars: list[str] | None = None, background: bool = False
) -> None:
def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None:
self._pshell.run(path, pars=pars, background=background)
def abort(self) -> None:
@@ -159,10 +149,7 @@ class SimTellBackend:
self._mounted_sample = ""
self._settings: dict[str, str] = {"mounted_sample_position": ""}
self._results: dict[int, dict[str, Any]] = {}
self._robot_status: dict[str, Any] = {
"powered": True,
"pos": POSITION_PARK,
}
self._robot_status: dict[str, Any] = {"powered": True, "pos": POSITION_PARK}
self._current_mA = 30.0
self._pin_offset = 0.0
self._detected_pucks: list[dict[str, Any]] = []
@@ -259,9 +246,7 @@ class SimTellBackend:
return str(self._current_mA)
if expr.startswith("smart_magnet.set_current("):
match = re.match(
r"smart_magnet\.set_current\(([-+]?\d+(?:\.\d+)?)\)&", expr
)
match = re.match(r"smart_magnet\.set_current\(([-+]?\d+(?:\.\d+)?)\)&", expr)
if match:
self._current_mA = float(match.group(1))
return None
@@ -322,9 +307,7 @@ class SimTellBackend:
self._set_ready_soon()
return cmd_id
def run(
self, path: str, pars: list[str] | None = None, background: bool = False
) -> None:
def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None:
_ = background
if path == "data/set_samples_info" and pars:
@@ -349,9 +332,7 @@ class SimTellBackend:
class LazyTellBackend:
def __init__(
self, factory: Callable[[], TellBackend], *, retry_interval_s: float = 2.0
):
def __init__(self, factory: Callable[[], TellBackend], *, retry_interval_s: float = 2.0):
self._factory = factory
self._backend: TellBackend | None = None
self._retry_interval_s = float(retry_interval_s)
@@ -363,10 +344,7 @@ class LazyTellBackend:
return self._backend
now = time.monotonic()
if (
now - self._last_attempt_ts < self._retry_interval_s
and self._last_error is not None
):
if now - self._last_attempt_ts < self._retry_interval_s and self._last_error is not None:
raise self._last_error
self._last_attempt_ts = now
@@ -378,10 +356,7 @@ class LazyTellBackend:
self._last_error = e
raise
except Exception as e:
wrapped = TellCommunicationError(
"TELL connection failed",
operation="CONNECT",
)
wrapped = TellCommunicationError("TELL connection failed", operation="CONNECT")
self._last_error = wrapped
raise wrapped from e
@@ -410,9 +385,7 @@ class LazyTellBackend:
def start_eval(self, expr: str) -> int:
return self._get_backend().start_eval(expr)
def run(
self, path: str, pars: list[str] | None = None, background: bool = False
) -> None:
def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None:
self._get_backend().run(path, pars=pars, background=background)
def abort(self) -> None:
+12 -32
View File
@@ -14,11 +14,7 @@ from aarecommon.errors.exception_handler import (
TellConnectionException,
)
from aarecommon.models.beamline import MXBeamline
from aarecommon.models.models import (
DewarAddress,
PuckLoadedInfo,
SampleDewarAddress,
)
from aarecommon.models.models import DewarAddress, PuckLoadedInfo, SampleDewarAddress
from aareDB import PuckWithTellPosition
from aare.devices.tell_backend import (
@@ -150,8 +146,7 @@ class TellClient:
if reasons:
raise TellCommunicationError(
"Mount can't start: " + "; ".join(reasons),
operation="mount_precheck",
"Mount can't start: " + "; ".join(reasons), operation="mount_precheck"
)
def validate_door_closed(self) -> None:
@@ -162,13 +157,11 @@ class TellClient:
door_closed = self.is_door_closed()
except Exception as e:
raise TellCommunicationError(
f"Mount can't start: failed to check door status: {e}",
operation="mount_precheck",
f"Mount can't start: failed to check door status: {e}", operation="mount_precheck"
)
if not door_closed:
raise TellCommunicationError(
"Mount can't start: TELL doors are open",
operation="mount_precheck",
"Mount can't start: TELL doors are open", operation="mount_precheck"
)
def set_samples_info(self, info: List[PuckWithTellPosition]):
@@ -351,9 +344,7 @@ class TellClient:
logger.info(f"{TellEventValueEnum.ROBOT_CLEAR_AFTER_MOUNT.value}")
return TellEventValueEnum.ROBOT_CLEAR_AFTER_MOUNT
else:
logger.info(
f"Unexpected event: {event} occurred with value: {value}"
)
logger.info(f"Unexpected event: {event} occurred with value: {value}")
logger.info("Checking command completed okay anyway")
self.check_command_ok(
timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: "
@@ -361,9 +352,7 @@ class TellClient:
return TellEventValueEnum.UNKNOWN
elif wait and segment == "X":
logger.info("Loading an auxiliary puck")
self.check_command_ok(
timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: "
)
self.check_command_ok(timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: ")
logger.info("post waiting")
return TellEventValueEnum.SUCCESS
except TellCommunicationError:
@@ -371,8 +360,7 @@ class TellClient:
except Exception as e:
logger.error(f"Exception occurred: {e}")
raise TellCommunicationError(
message=f"Error during mount {segment}{puck}-{sample}: {e}",
critical=True,
message=f"Error during mount {segment}{puck}-{sample}: {e}", critical=True
)
def unmount(self, force=False, wait=False, timeout=360.0):
@@ -476,10 +464,9 @@ class TellClient:
PuckLoadedInfo(
puck_name=i["puckBarcode"],
location=DewarAddress(
segment=puck_address[0],
pos=int(puck_address[1]),
segment=puck_address[0], pos=int(puck_address[1])
),
),
)
)
return output
@@ -543,18 +530,14 @@ class TellClient:
self.backend.eval("smart_magnet.set_supress(True)&")
self.backend.eval("smart_magnet.state&")
if self.get_mounted_sample() is None:
logger.warning(
"Check mount: A manually mounted sample is detected."
)
logger.warning("Check mount: A manually mounted sample is detected.")
logger.warning("Remove before mounting with the robot.")
raise ManualMountException
return True
elif state == "Ready":
logger.debug("No sample detected, ready to mount")
if self.get_mounted_sample():
logger.error(
"Check mount: No sample detected, but robot thinks is mounted"
)
logger.error("Check mount: No sample detected, but robot thinks is mounted")
raise SmartMagnetFaultException
return False
elif state == "Paused":
@@ -573,10 +556,7 @@ def make_tell_client(bl: MXBeamline) -> TellClient:
if bl == MXBeamline.SIMULATED:
backend = SimTellBackend()
else:
backend = LazyTellBackend(
factory=lambda: PShellTellBackend(bl),
retry_interval_s=2.0,
)
backend = LazyTellBackend(factory=lambda: PShellTellBackend(bl), retry_interval_s=2.0)
return TellClient(bl, backend=backend)
+7 -2
View File
@@ -14,7 +14,9 @@ def wait_position(motor, target, tolerance=None, timeout=60.0):
elif isinstance(position, (bytes, str)):
tst = "'%s' == '%s'"
if isinstance(position, bytes):
ltst = lambda x, y, z: x.lower() == y.lower().encode() if isinstance(y, str) else x.lower() == y.lower()
ltst = lambda x, y, z: (
x.lower() == y.lower().encode() if isinstance(y, str) else x.lower() == y.lower()
)
else:
ltst = lambda x, y, z: x.lower() == y.lower()
else:
@@ -29,7 +31,10 @@ def wait_position(motor, target, tolerance=None, timeout=60.0):
n = n + 1
if n > 20:
n = 0
print("waiting_position test: %s (%s, %s, %s)" % (tst, str(motor.readback), str(target), str(tolerance)))
print(
"waiting_position test: %s (%s, %s, %s)"
% (tst, str(motor.readback), str(target), str(tolerance))
)
timeisup = timeout < time.time()
condition = ltst(motor.readback, target, tolerance)
+1 -3
View File
@@ -12,9 +12,7 @@ logger = setup_logger("aareGUI")
def auth(base_url: str | None, cert_path: str | None) -> str:
curr_user = get_user()
if base_url is None:
token_data = TokenData(
sub=curr_user, staff=True, session=15, pgroups=["p16371", "p22233"]
)
token_data = TokenData(sub=curr_user, staff=True, session=15, pgroups=["p16371", "p22233"])
return jwt.encode(token_data.model_dump(), "ABC123")
# Single call: Kerberos SPNEGO through Apache, which proxies to the DAQ server.
+10 -30
View File
@@ -47,12 +47,8 @@ def main():
# TODO if zmq and pred stream come from same source, do not need images from both streams, can combine
match mx_beamline():
case MXBeamline.X06DA:
default_url = cfg_get(
"gui.daq.daq_url", "https://mx-x06da-queue-01.psi.ch"
)
default_cert_path = cfg_get(
"gui.daq.cert_path", "/sls/x06da/misc/.cert/6d.crt"
)
default_url = cfg_get("gui.daq.daq_url", "https://mx-x06da-queue-01.psi.ch")
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x06da/misc/.cert/6d.crt")
default_zmq_addr = cfg_get(
"gui.cameras.sample_camera_zmq_url", "tcp://x06da-pserv-01:9089"
)
@@ -67,12 +63,8 @@ def main():
)
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 3))
case MXBeamline.X10SA:
default_url = cfg_get(
"gui.daq.daq_url", "https://mx-x10sa-queue-01.psi.ch"
)
default_cert_path = cfg_get(
"gui.daq.cert_path", "/sls/x10sa/misc/.cert/10s.crt"
)
default_url = cfg_get("gui.daq.daq_url", "https://mx-x10sa-queue-01.psi.ch")
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x10sa/misc/.cert/10s.crt")
default_zmq_addr = cfg_get(
"gui.cameras.sample_camera_zmq_url", "tcp://x10sa-spark-01:9091"
)
@@ -87,17 +79,11 @@ def main():
)
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 1))
case MXBeamline.X06SA:
default_url = cfg_get(
"gui.daq.daq_url", "https://mx-x06sa-queue-01.psi.ch"
)
default_cert_path = cfg_get(
"gui.daq.cert_path", "/sls/x06sa/misc/.cert/6s.crt"
)
default_url = cfg_get("gui.daq.daq_url", "https://mx-x06sa-queue-01.psi.ch")
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x06sa/misc/.cert/6s.crt")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "")
default_beamline_cam_addr = cfg_get(
"gui.cameras.beamline_camera_url", ""
)
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "")
default_gonio_cam_addr = cfg_get("gui.cameras.gonio_camera_url", "")
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 1))
case _:
@@ -105,16 +91,12 @@ def main():
default_cert_path = cfg_get("gui.daq.cert_path", "")
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "")
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "")
default_beamline_cam_addr = cfg_get(
"gui.cameras.beamline_camera_url", ""
)
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "")
default_gonio_cam_addr = cfg_get("gui.cameras.gonio_camera_url", "")
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 1))
# Add custom options as needed
urlOption = QCommandLineOption(
["u", "aaredaq-url"], "Base AareDAQ URL", "url", default_url
)
urlOption = QCommandLineOption(["u", "aaredaq-url"], "Base AareDAQ URL", "url", default_url)
parser.addOption(urlOption)
certPath = QCommandLineOption(
@@ -126,9 +108,7 @@ def main():
parser.addOption(certPath)
defaultImage = QCommandLineOption(
["i", "image"],
"Default image to display in absence of the ZMQ stream",
"image",
["i", "image"], "Default image to display in absence of the ZMQ stream", "image"
)
parser.addOption(defaultImage)
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -6,9 +6,7 @@ from PySide6.QtGui import QColor
class SmargonBookmark:
coord: SmargonCoordinate
color: Literal[
"red", "green", "blue", "indigo", "lime"
] # Restrict to specific colors
color: Literal["red", "green", "blue", "indigo", "lime"] # Restrict to specific colors
def qt_color(self) -> QColor:
"""Convert the color property to a QColor."""
+1 -2
View File
@@ -21,7 +21,6 @@ class UIStateManager:
def save_model(self, key: str, model):
try:
data = model.to_list()
self.settings.setValue(key, json.dumps(data))
@@ -45,4 +44,4 @@ class UIStateManager:
self.settings.setValue(key, value)
def load_value(self, key: str, default=None, value_type=None):
return self.settings.value(key, default, type=value_type)
return self.settings.value(key, default, type=value_type)
+6 -20
View File
@@ -25,10 +25,7 @@ def get_entry(sample: SampleShortInfo, column: int, *, show_user: bool = False):
class SampleQueueSpreadsheet(QAbstractTableModel):
def __init__(
self,
parent=None,
samples: list[SampleShortInfo] | None = None,
show_user: bool = False,
self, parent=None, samples: list[SampleShortInfo] | None = None, show_user: bool = False
):
super().__init__(parent)
if samples is None:
@@ -37,9 +34,7 @@ class SampleQueueSpreadsheet(QAbstractTableModel):
self._show_user = bool(show_user)
self.samples: list[SampleShortInfo] = samples
self.header = (
["User", "Dewar", "Puck", "Sample"]
if self._show_user
else ["Dewar", "Puck", "Sample"]
["User", "Dewar", "Puck", "Sample"] if self._show_user else ["Dewar", "Puck", "Sample"]
)
def flags(self, index):
@@ -54,9 +49,7 @@ class SampleQueueSpreadsheet(QAbstractTableModel):
def data(self, index, role=None):
if role == Qt.ItemDataRole.DisplayRole:
return get_entry(
self.samples[index.row()], index.column(), show_user=self._show_user
)
return get_entry(self.samples[index.row()], index.column(), show_user=self._show_user)
elif role == Qt.ItemDataRole.TextAlignmentRole:
return Qt.AlignmentFlag.AlignCenter
elif role == Qt.ItemDataRole.BackgroundRole:
@@ -76,10 +69,7 @@ class SampleQueueSpreadsheet(QAbstractTableModel):
return str(section + 1)
return None
def updateData(
self,
samples: list[SampleShortInfo],
):
def updateData(self, samples: list[SampleShortInfo]):
self.beginResetModel()
self.samples = list(samples)
self.endResetModel()
@@ -148,15 +138,11 @@ class SampleQueueSpreadsheet(QAbstractTableModel):
self.endResetModel()
def to_state(self) -> dict:
return {
"samples": [s.model_dump() for s in self.samples],
}
return {"samples": [s.model_dump() for s in self.samples]}
def from_state(self, state: dict):
self.beginResetModel()
self.samples = [
SampleShortInfo.model_validate(d) for d in state.get("samples", [])
]
self.samples = [SampleShortInfo.model_validate(d) for d in state.get("samples", [])]
self.endResetModel()
+2 -6
View File
@@ -304,9 +304,7 @@ class UserSampleSpreadsheet(QAbstractTableModel):
items = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
return [k for k, _ in items[:limit]]
def suggested_prefixes_for_location(
self, limit: int = 200
) -> tuple[list[str], list[str]]:
def suggested_prefixes_for_location(self, limit: int = 200) -> tuple[list[str], list[str]]:
"""Get location prefixes from currently filtered samples (excluding column 3 filter)."""
# Get currently filtered samples, excluding the location filter
temp_filter = self.__filters.pop(3, None)
@@ -326,7 +324,5 @@ class UserSampleSpreadsheet(QAbstractTableModel):
if seg:
seg_seen.add(seg)
segs = sorted(seg_seen)
segpos = sorted(
segpos_seen, key=lambda x: (x[0], int(x[1:]) if x[1:].isdigit() else 0)
)
segpos = sorted(segpos_seen, key=lambda x: (x[0], int(x[1:]) if x[1:].isdigit() else 0))
return (segs[:limit], segpos[:limit])
+3 -13
View File
@@ -149,15 +149,9 @@ class RuntimeNotificationWidget(QFrame):
timeout = 5000 if auto_clear_ms is None else int(auto_clear_ms)
self._auto_clear_timer.start(timeout)
def show_waiting(
self, *, title: str, message: str, auto_clear_ms: int | None = None
) -> None:
def show_waiting(self, *, title: str, message: str, auto_clear_ms: int | None = None) -> None:
self.show_notification(
title=title,
message=message,
level="warning",
sticky=True,
auto_clear_ms=auto_clear_ms,
title=title, message=message, level="warning", sticky=True, auto_clear_ms=auto_clear_ms
)
@Slot()
@@ -225,11 +219,7 @@ class LogDock(QDockWidget):
self.setVisible(True)
self.raise_()
self.notification.show_notification(
title=title,
message=message,
level=level,
sticky=sticky,
auto_clear_ms=auto_clear_ms,
title=title, message=message, level=level, sticky=sticky, auto_clear_ms=auto_clear_ms
)
def show_waiting_notification(self, *, title: str, message: str) -> None:
+10 -37
View File
@@ -5,17 +5,9 @@ import time
from datetime import datetime
from aarecommon.config.logger import setup_logger
from aarecommon.models.automation import (
AutomationProgress,
StepStatus,
WorkflowStateKind,
)
from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind
from PySide6.QtCore import QTimer, Slot
from PySide6.QtWidgets import (
QLabel,
QVBoxLayout,
QWidget,
)
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
logger = setup_logger("aareGUI")
@@ -128,10 +120,7 @@ class AutomationProgressWidget(QWidget):
)
if status == StepStatus.SUCCESS:
return (
base
+ " background-color: #ECFDF3; color: #166534; border-color: #A7F3D0;"
)
return base + " background-color: #ECFDF3; color: #166534; border-color: #A7F3D0;"
if status == StepStatus.RUNNING:
return (
base
@@ -148,13 +137,8 @@ class AutomationProgressWidget(QWidget):
+ " background-color: #FFF7ED; color: #C2410C; font-weight: 700; border-color: #FED7AA;"
)
if status == StepStatus.SKIPPED:
return (
base
+ " background-color: #F8FAFC; color: #475569; border-color: #E2E8F0;"
)
return (
base + " background-color: #F8FAFC; color: #64748B; border-color: #E2E8F0;"
)
return base + " background-color: #F8FAFC; color: #475569; border-color: #E2E8F0;"
return base + " background-color: #F8FAFC; color: #64748B; border-color: #E2E8F0;"
@staticmethod
def _format_duration(seconds: float | None) -> str:
@@ -209,8 +193,7 @@ class AutomationProgressWidget(QWidget):
progress = copy.deepcopy(self._progress)
final_step = next(
(step for step in progress.steps if step.step == WorkflowStateKind.FINAL),
None,
(step for step in progress.steps if step.step == WorkflowStateKind.FINAL), None
)
if self._is_paused:
@@ -245,13 +228,9 @@ class AutomationProgressWidget(QWidget):
if self._stats_label:
measured_avg_seconds = (
progress.avg_time_per_sample
if progress.avg_time_per_sample > 0
else None
)
displayed_avg_seconds = (
measured_avg_seconds or self.DEFAULT_SAMPLE_ESTIMATE_S
progress.avg_time_per_sample if progress.avg_time_per_sample > 0 else None
)
displayed_avg_seconds = measured_avg_seconds or self.DEFAULT_SAMPLE_ESTIMATE_S
current_sample = progress.current_sample_name or "None"
samples_left = (
@@ -260,11 +239,7 @@ class AutomationProgressWidget(QWidget):
else max(0, int(progress.samples_in_queue or 0))
)
if (
progress.finished
or self._is_paused
or not self._has_active_sample(progress)
):
if progress.finished or self._is_paused or not self._has_active_sample(progress):
queue_remaining = displayed_avg_seconds * samples_left
else:
queue_remaining = displayed_avg_seconds * max(0, samples_left)
@@ -291,9 +266,7 @@ class AutomationProgressWidget(QWidget):
duration_str = ""
if step_state.started_at is not None:
end = step_state.completed_at or time.time()
duration_str = (
f" ({self._format_duration(end - step_state.started_at)})"
)
duration_str = f" ({self._format_duration(end - step_state.started_at)})"
if step_state.status == StepStatus.FAILED and step_state.message:
message = f" — Failed: {step_state.message}"
+3 -7
View File
@@ -80,12 +80,8 @@ class AxisVideoPanel(QWidget):
else:
self._status_label.setText(style.text)
self._status_container.setProperty("busyState", "active")
self._status_dot.setStyleSheet(
f"background-color: {style.accent_dot};"
)
self._status_label.setStyleSheet(
f"color: {style.badge_fg};"
)
self._status_dot.setStyleSheet(f"background-color: {style.accent_dot};")
self._status_label.setStyleSheet(f"color: {style.badge_fg};")
self._refresh_status_style()
self._status_container.show()
@@ -94,4 +90,4 @@ class AxisVideoPanel(QWidget):
view.set_busy_overlay_style(style)
def set_status_text(self, text: str) -> None:
self._status_label.setText(text or "")
self._status_label.setText(text or "")
+1 -1
View File
@@ -15,7 +15,7 @@ from aare.gui.panels.zoom_panel import ZoomPanel
class BeamlineControls(QFrame):
set_width = 250
def __init__(self, parent=None, staff : bool = True):
def __init__(self, parent=None, staff: bool = True):
super().__init__(parent)
self.setFixedWidth(self.set_width)
self.setFrameShape(QFrame.Shape.StyledPanel)
+6 -17
View File
@@ -28,8 +28,7 @@ class RecoveryPanel(QWidget):
layout.setSpacing(8)
self._warning_primary = QLabel(
"⚠ Recovery actions are staff-only and intentionally dangerous.",
self,
"⚠ Recovery actions are staff-only and intentionally dangerous.", self
)
self._warning_primary.setWordWrap(True)
self._warning_primary.setStyleSheet(
@@ -151,10 +150,7 @@ class RecoveryPanel(QWidget):
def _prompt_recovery_code(self, action_name: str) -> str | None:
code, ok = QInputDialog.getText(
self,
action_name,
"Enter recovery confirmation code:",
QLineEdit.EchoMode.Password,
self, action_name, "Enter recovery confirmation code:", QLineEdit.EchoMode.Password
)
if not ok:
return None
@@ -163,9 +159,7 @@ class RecoveryPanel(QWidget):
def _sample_appears_mounted(self) -> bool:
try:
return (
self._last_status is not None and self._last_status.sample is not None
)
return self._last_status is not None and self._last_status.sample is not None
except Exception:
return False
@@ -188,15 +182,11 @@ class RecoveryPanel(QWidget):
self._free_beamline_btn.setEnabled(beamline_busy)
self._free_beamline_btn.setToolTip(
""
if beamline_busy
else "Disabled because beamline does not currently appear busy."
"" if beamline_busy else "Disabled because beamline does not currently appear busy."
)
self._resync_sample_btn.setEnabled(True)
self._resync_sample_btn.setToolTip(
"Force a one-shot sample reconciliation against TELL."
)
self._resync_sample_btn.setToolTip("Force a one-shot sample reconciliation against TELL.")
def _confirm(self, title: str, msg: str) -> bool:
reply = QMessageBox.warning(
@@ -229,8 +219,7 @@ class RecoveryPanel(QWidget):
@Slot()
def _free_beamline(self) -> None:
if not self._confirm(
"Free beamline",
"This will clear the beamline busy flag.\n\nDo you want to continue?",
"Free beamline", "This will clear the beamline busy flag.\n\nDo you want to continue?"
):
return
code = self._prompt_recovery_code("Free beamline")
+10 -37
View File
@@ -150,12 +150,7 @@ class BeamlineStatePanel(QFrame):
"Sample centring and alignment mode",
),
StationSpec(
BeamlineStateEnum.BeamLocation,
"Beam location",
54,
284,
True,
"Beam location mode",
BeamlineStateEnum.BeamLocation, "Beam location", 54, 284, True, "Beam location mode"
),
StationSpec(
BeamlineStateEnum.BeamstopAlignment,
@@ -190,12 +185,7 @@ class BeamlineStatePanel(QFrame):
"Crystal snapshot mode",
),
StationSpec(
BeamlineStateEnum.XrayFluorescence,
"XRF",
54,
464,
True,
"X-ray fluorescence mode",
BeamlineStateEnum.XrayFluorescence, "XRF", 54, 464, True, "X-ray fluorescence mode"
),
]
@@ -252,8 +242,7 @@ class BeamlineStatePanel(QFrame):
return tuple(sorted((a, b), key=lambda state: state.value))
def _build_graph(
self,
segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]],
self, segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]]
) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]:
graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {}
for a, b in segments:
@@ -265,9 +254,7 @@ class BeamlineStatePanel(QFrame):
return {station.state: station for station in self._stations}
def _path_segments_between(
self,
start: BeamlineStateEnum | None,
end: BeamlineStateEnum | None,
self, start: BeamlineStateEnum | None, end: BeamlineStateEnum | None
) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]:
if start is None or end is None:
return set()
@@ -317,9 +304,7 @@ class BeamlineStatePanel(QFrame):
self._current_state == BeamlineStateEnum.Moving
and self._pending_target_state is not None
):
return self._path_segments_between(
self._last_stable_state, self._pending_target_state
)
return self._path_segments_between(self._last_stable_state, self._pending_target_state)
return set()
@@ -330,9 +315,7 @@ class BeamlineStatePanel(QFrame):
widget.setFlat(True)
widget.setCursor(Qt.CursorShape.PointingHandCursor)
widget.clicked.connect(
lambda _checked=False, state=station.state: self._emit_for_state(
state
)
lambda _checked=False, state=station.state: self._emit_for_state(state)
)
else:
widget = HoverableLabel(station.label, self)
@@ -543,17 +526,14 @@ class BeamlineStatePanel(QFrame):
return self._line_hover
current_path = self._path_segments_between(
BeamlineStateEnum.DewarTransfer,
self._last_stable_state or self._current_state,
BeamlineStateEnum.DewarTransfer, self._last_stable_state or self._current_state
)
if segment in current_path:
return self._line_current
return self._line_color
def _draw_segment(
self, painter: QPainter, start: QPoint, end: QPoint, color: QColor
) -> None:
def _draw_segment(self, painter: QPainter, start: QPoint, end: QPoint, color: QColor) -> None:
pen = QPen(color, 3)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
@@ -625,9 +605,7 @@ class BeamlineStatePanel(QFrame):
tell_state = status.tell_state
tell_text = f"Tell: {tell_state.activity.display_name()}"
tell_phase = (
tell_state.phase.display_name() if tell_state.phase is not None else ""
)
tell_phase = tell_state.phase.display_name() if tell_state.phase is not None else ""
tell_message = (tell_state.message or "").strip()
if tell_phase:
@@ -637,12 +615,7 @@ class BeamlineStatePanel(QFrame):
if tell_state.activity.value == "error":
tell_color = "red"
elif tell_state.activity.value in {
"mounting",
"unmounting",
"drying",
"cooling",
}:
elif tell_state.activity.value in {"mounting", "unmounting", "drying", "cooling"}:
tell_color = "orange"
else:
tell_color = "green"
@@ -103,9 +103,7 @@ class CompactAutomationPanel(QFrame):
self.annotation_button = QToolButton(self)
self.annotation_button.setObjectName("compactSecondaryButton")
self.annotation_button.setText("Annotate")
self.annotation_button.setPopupMode(
QToolButton.ToolButtonPopupMode.InstantPopup
)
self.annotation_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
annotation_menu = QMenu(self.annotation_button)
for label in ["Heart", "Thumbs Up", "Thumbs Down", "Eyes", "Scan Again"]:
@@ -180,11 +178,7 @@ class CompactAutomationPanel(QFrame):
layout.addWidget(title_label)
layout.addWidget(value_label)
return {
"frame": frame,
"title": title_label,
"value": value_label,
}
return {"frame": frame, "title": title_label, "value": value_label}
@Slot(bool)
def set_running(self, running: bool) -> None:
@@ -2,12 +2,7 @@ from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.math.sample_geometry import SampleGeometryModel
from aarecommon.models.models import DAQStatusModel
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import (
QFrame,
QPushButton,
QTabWidget,
QVBoxLayout,
)
from PySide6.QtWidgets import QFrame, QPushButton, QTabWidget, QVBoxLayout
from aare.gui.panels.file_path_panel import FilePathPanel
from aare.gui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel
@@ -45,9 +40,7 @@ class DataCollectionSettings(QFrame):
)
self.__tab_widget.addTab(self.raster, "Raster scan")
self.screening = RotationDataCollectionPanel(
parent=self, diffraction=diffraction
)
self.screening = RotationDataCollectionPanel(parent=self, diffraction=diffraction)
self.__tab_widget.addTab(self.screening, "Rotation")
self.simple = SimpleRotationSettingsPanel(parent=self)
+22 -71
View File
@@ -57,9 +57,7 @@ class DeveloperHelpDialog(QDialog):
self._banner = QLabel(self)
self._banner.setVisible(self._is_staff)
self._banner.setWordWrap(True)
self._banner.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self._banner.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._banner.setStyleSheet(
"QLabel {"
" background: #f6f6f6;"
@@ -78,9 +76,7 @@ class DeveloperHelpDialog(QDialog):
top.addWidget(QLabel("Filter:", self))
self._filter = QLineEdit(self)
self._filter.setPlaceholderText(
"Type to filter (matches name, value, or help)…"
)
self._filter.setPlaceholderText("Type to filter (matches name, value, or help)…")
self._filter.setClearButtonEnabled(True)
self._filter.setMinimumHeight(28)
self._filter.setStyleSheet(
@@ -124,16 +120,12 @@ class DeveloperHelpDialog(QDialog):
self._open_gui_log_btn = QPushButton("Open GUI log", self)
self._open_gui_log_btn.setVisible(self._is_staff)
self._open_gui_log_btn.clicked.connect(
lambda: self._open_log_file_for_logger("aareGUI")
)
self._open_gui_log_btn.clicked.connect(lambda: self._open_log_file_for_logger("aareGUI"))
top.addWidget(self._open_gui_log_btn)
self._open_daq_log_btn = QPushButton("Open DAQ log", self)
self._open_daq_log_btn.setVisible(self._is_staff)
self._open_daq_log_btn.clicked.connect(
lambda: self._open_log_file_for_logger("aareDAQ")
)
self._open_daq_log_btn.clicked.connect(lambda: self._open_log_file_for_logger("aareDAQ"))
top.addWidget(self._open_daq_log_btn)
self._tabs = QTabWidget(self)
@@ -144,9 +136,7 @@ class DeveloperHelpDialog(QDialog):
self._codes_table.setHorizontalHeaderLabels(["Name"])
self._codes_table.setSortingEnabled(True)
self._codes_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self._codes_table.setSelectionBehavior(
QTableWidget.SelectionBehavior.SelectRows
)
self._codes_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self._codes_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self._codes_table.itemSelectionChanged.connect(self._update_code_details)
self._codes_table.horizontalHeader().setStretchLastSection(True)
@@ -154,11 +144,7 @@ class DeveloperHelpDialog(QDialog):
self._details_frame = QFrame(self)
self._details_frame.setFrameShape(QFrame.Shape.StyledPanel)
self._details_frame.setStyleSheet(
"QFrame {"
" background: #fafafa;"
" border: 1px solid #d0d0d0;"
" border-radius: 6px;"
"}"
"QFrame { background: #fafafa; border: 1px solid #d0d0d0; border-radius: 6px;}"
)
details_layout = QVBoxLayout(self._details_frame)
@@ -166,23 +152,17 @@ class DeveloperHelpDialog(QDialog):
details_layout.setSpacing(8)
form = QFormLayout()
form.setLabelAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
details_layout.addLayout(form)
self._detail_name = QLabel("-", self)
self._detail_name.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self._detail_name.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
form.addRow("Name:", self._detail_name)
value_row = QHBoxLayout()
value_row.setSpacing(8)
self._detail_value = QLabel("-", self)
self._detail_value.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self._detail_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._copy_value_btn = QPushButton("Copy value", self)
self._copy_value_btn.clicked.connect(self._copy_selected_value)
value_row.addWidget(self._detail_value, 1)
@@ -193,9 +173,7 @@ class DeveloperHelpDialog(QDialog):
self._detail_help = QLabel("-", self)
self._detail_help.setWordWrap(True)
self._detail_help.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._detail_help.setStyleSheet(
"QLabel {"
" background: white;"
@@ -219,9 +197,7 @@ class DeveloperHelpDialog(QDialog):
self._payload_summary = QLabel(self)
self._payload_summary.setVisible(self._is_staff)
self._payload_summary.setWordWrap(True)
self._payload_summary.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self._payload_summary.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._payload_text = QTextEdit(self)
self._payload_text.setReadOnly(True)
@@ -284,15 +260,9 @@ class DeveloperHelpDialog(QDialog):
"Requested by",
]
)
self._gui_sessions_table.setEditTriggers(
QTableWidget.EditTrigger.NoEditTriggers
)
self._gui_sessions_table.setSelectionBehavior(
QTableWidget.SelectionBehavior.SelectRows
)
self._gui_sessions_table.setSelectionMode(
QTableWidget.SelectionMode.SingleSelection
)
self._gui_sessions_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self._gui_sessions_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self._gui_sessions_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self._gui_sessions_table.setSortingEnabled(True)
self._gui_sessions_table.horizontalHeader().setStretchLastSection(True)
@@ -305,9 +275,7 @@ class DeveloperHelpDialog(QDialog):
self._gui_close_grace_spin.setSuffix(" s")
self._gui_request_close_btn = QPushButton("Request Close", self)
self._gui_request_close_btn.clicked.connect(
self._request_close_selected_gui
)
self._gui_request_close_btn.clicked.connect(self._request_close_selected_gui)
self._gui_force_remove_btn = QPushButton("Force Remove", self)
self._gui_force_remove_btn.clicked.connect(self._force_remove_selected_gui)
@@ -424,9 +392,7 @@ class DeveloperHelpDialog(QDialog):
except Exception:
pretty = str(p)
blocks.append(f"#{i}\n{pretty}")
self._payloads_text.setPlainText(
"\n\n".join(blocks) if blocks else "(none captured yet)"
)
self._payloads_text.setPlainText("\n\n".join(blocks) if blocks else "(none captured yet)")
@Slot(str)
def _append_error_log_line(self, line: str) -> None:
@@ -445,10 +411,7 @@ class DeveloperHelpDialog(QDialog):
if isinstance(body, dict):
code = body.get("code")
msg = body.get("message")
return (
str(code) if code is not None else None,
str(msg) if msg is not None else None,
)
return (str(code) if code is not None else None, str(msg) if msg is not None else None)
return (None, None)
def _update_banner(self) -> None:
@@ -520,11 +483,7 @@ class DeveloperHelpDialog(QDialog):
self._update_code_details()
# If enabled, re-try selection after the table content changes
if (
self._is_staff
and self._always_highlight_last_error
and not self._freeze_payload
):
if self._is_staff and self._always_highlight_last_error and not self._freeze_payload:
self._select_code_from_last_error()
def _selected_code(self) -> tuple[str | None, str | None]:
@@ -550,9 +509,7 @@ class DeveloperHelpDialog(QDialog):
self._detail_name.setText(name)
self._detail_value.setText(value)
self._detail_help.setText(
error_code_help(value) or "(no help text defined yet)"
)
self._detail_help.setText(error_code_help(value) or "(no help text defined yet)")
self._copy_value_btn.setEnabled(True)
@Slot()
@@ -599,9 +556,7 @@ class DeveloperHelpDialog(QDialog):
return
self._gui_sessions = [
s
if isinstance(s, OpenGuiSessionInfo)
else OpenGuiSessionInfo.model_validate(s)
s if isinstance(s, OpenGuiSessionInfo) else OpenGuiSessionInfo.model_validate(s)
for s in sessions
]
self._gui_sessions_table.setSortingEnabled(False)
@@ -647,9 +602,7 @@ class DeveloperHelpDialog(QDialog):
def _request_close_selected_gui(self) -> None:
session_id = self._selected_gui_session_id()
if session_id is None:
QMessageBox.information(
self, "Active GUIs", "Please select a GUI session first."
)
QMessageBox.information(self, "Active GUIs", "Please select a GUI session first.")
return
grace_seconds = int(self._gui_close_grace_spin.value())
@@ -668,9 +621,7 @@ class DeveloperHelpDialog(QDialog):
def _force_remove_selected_gui(self) -> None:
session_id = self._selected_gui_session_id()
if session_id is None:
QMessageBox.information(
self, "Active GUIs", "Please select a GUI session first."
)
QMessageBox.information(self, "Active GUIs", "Please select a GUI session first.")
return
answer = QMessageBox.warning(
+3 -13
View File
@@ -27,13 +27,7 @@ class FaceDetectionPanel(QWidget):
self.step_size = int(val)
self.fig = Figure(figsize=(5, 4))
self.fig.subplots_adjust(
left=0.18,
right=0.97,
bottom=0.10,
top=0.95,
hspace=0.45,
)
self.fig.subplots_adjust(left=0.18, right=0.97, bottom=0.10, top=0.95, hspace=0.45)
self.canvas = FigureCanvas(self.fig)
self.ax1 = self.fig.add_subplot(2, 1, 1)
self.ax2 = self.fig.add_subplot(2, 1, 2)
@@ -130,9 +124,7 @@ class FaceDetectionPanel(QWidget):
height_fit = A + B * np.cos(C * np.deg2rad(ang_grid) - phi)
self.ax1.plot(ang_grid, height_fit, color="tab:orange", label="Height fit")
if "best_angle_deg" in hf:
self.ax1.axvline(
hf["best_angle_deg"], color="tab:orange", ls="--", alpha=0.6
)
self.ax1.axvline(hf["best_angle_deg"], color="tab:orange", ls="--", alpha=0.6)
af = data.get("area_fit", {}) or {}
if {"A", "B", "phi_rad", "C"} <= af.keys():
@@ -140,9 +132,7 @@ class FaceDetectionPanel(QWidget):
area_fit = A2 + B2 * np.cos(C2 * np.deg2rad(ang_grid) - phi2)
self.ax2.plot(ang_grid, area_fit, color="tab:red", label="Area fit")
if "best_angle_deg" in af:
self.ax2.axvline(
af["best_angle_deg"], color="tab:red", ls="--", alpha=0.6
)
self.ax2.axvline(af["best_angle_deg"], color="tab:red", ls="--", alpha=0.6)
self.ax1.set_xlabel("Angle (deg)")
self.ax1.set_ylabel("Height")
+5 -23
View File
@@ -4,14 +4,7 @@ from pathlib import Path
from aarecommon.models.models import DAQStatusModel, SampleShortInfo
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import (
QGridLayout,
QLabel,
QLineEdit,
QMessageBox,
QSpinBox,
QWidget,
)
from PySide6.QtWidgets import QGridLayout, QLabel, QLineEdit, QMessageBox, QSpinBox, QWidget
from aare.gui.widgets.title_label import TitleLabel
@@ -203,19 +196,14 @@ class FilePathPanel(QWidget):
self.__puck_pos = sample.pin
self.run_number_edit.setValue(1)
if (
sample.aaredb_params is not None
and sample.aaredb_params.directory is not None
):
if sample.aaredb_params is not None and sample.aaredb_params.directory is not None:
self.directory_edit.setText(f"{sample.aaredb_params.directory}")
elif sample.location is not None:
self.directory_edit.setText(
f"{self.__formatted_date}/{self.__puck_name}/{self.__puck_pos:02d}"
)
else:
self.directory_edit.setText(
f"{self.__formatted_date}/manual/{self.__sample_name}"
)
self.directory_edit.setText(f"{self.__formatted_date}/manual/{self.__sample_name}")
self.update_filename()
@Slot(DAQStatusModel)
@@ -234,15 +222,9 @@ class FilePathPanel(QWidget):
def next_free_run_from(self, start_rn: int) -> tuple[int, str]:
# Compute next free run number and updated base
dir_name = self.directory_edit.text().replace(
"{prefix}", self.file_prefix_edit.text()
)
dir_name = self.directory_edit.text().replace("{prefix}", self.file_prefix_edit.text())
base = dir_name if dir_name.endswith("/") or dir_name == "" else dir_name + "/"
base += (
"run"
if self.file_prefix_edit.text() == ""
else self.file_prefix_edit.text()
)
base += "run" if self.file_prefix_edit.text() == "" else self.file_prefix_edit.text()
rn = start_rn
while rn <= self.run_number_edit.maximum():
@@ -15,9 +15,7 @@ class FluorescenceDataCollectionPanel(QWidget):
# Beam transmission (0..1)
lay.addWidget(QLabel("Beam transmission", self), 0, 0)
self.transmission = NumberLineEdit(
0.0, 1.0, decimals=4, default=0.1, parent=self
)
self.transmission = NumberLineEdit(0.0, 1.0, decimals=4, default=0.1, parent=self)
lay.addWidget(self.transmission, 0, 1)
# Exposure time (seconds)
@@ -27,9 +25,7 @@ class FluorescenceDataCollectionPanel(QWidget):
lay.addWidget(QLabel("s", self), 1, 2)
# Accumulate checkbox (reverse of erase)
self.accumulate_cb = QCheckBox(
"Accumulate", self
) # accumulate=True => erase=False
self.accumulate_cb = QCheckBox("Accumulate", self) # accumulate=True => erase=False
self.accumulate_cb.setChecked(False)
lay.addWidget(self.accumulate_cb, 2, 0, 1, 3)
@@ -49,7 +45,5 @@ class FluorescenceDataCollectionPanel(QWidget):
exp = float(self.exposure.value)
erase = not self.accumulate_cb.isChecked()
self.fluo_scan.emit(
FluorescenceSpectrumParameterModel(
acq_time_s=exp, transmission=t, erase=erase
)
FluorescenceSpectrumParameterModel(acq_time_s=exp, transmission=t, erase=erase)
)
+6 -15
View File
@@ -78,10 +78,7 @@ class FluorescencePanel(QWidget):
def eventFilter(self, obj, event):
try:
if (
obj is self.chart_view.viewport()
and event.type() == QEvent.Type.MouseMove
):
if obj is self.chart_view.viewport() and event.type() == QEvent.Type.MouseMove:
pos = event.position() if hasattr(event, "position") else event.pos()
p = QPointF(pos.x(), pos.y())
plot = self.chart.plotArea()
@@ -109,9 +106,7 @@ class FluorescencePanel(QWidget):
best_i = i
pt = self.series.at(best_i)
self.chart_view.setToolTip(
f"Energy {pt.x():.3f} keV counts {pt.y():.3f}"
)
self.chart_view.setToolTip(f"Energy {pt.x():.3f} keV counts {pt.y():.3f}")
self._update_vline(pt.x())
return False
except Exception as e:
@@ -134,11 +129,7 @@ class FluorescencePanel(QWidget):
# Prefer cached arrays; fall back to reading from series
x_vals = self._last_x_keV
y_vals = self._last_y_counts
if (
x_vals is None
or y_vals is None
or len(x_vals) != self.series.count()
):
if x_vals is None or y_vals is None or len(x_vals) != self.series.count():
x_vals = [self.series.at(i).x() for i in range(self.series.count())]
y_vals = [self.series.at(i).y() for i in range(self.series.count())]
@@ -170,9 +161,9 @@ class FluorescencePanel(QWidget):
try:
ymin = self.axis_y.min()
ymax = self.axis_y.max()
self._vline.replace(
0, x_val, ymin
) if self._vline.count() > 0 else self._vline.append(x_val, ymin)
self._vline.replace(0, x_val, ymin) if self._vline.count() > 0 else self._vline.append(
x_val, ymin
)
if self._vline.count() == 1:
self._vline.append(x_val, ymax)
else:
+2 -6
View File
@@ -20,9 +20,7 @@ class IlluminationPanel(QWidget):
grid_layout.addWidget(front_label, 1, 0, 1, 2)
self.is_sliding = False
self.front_light_slider = QSlider(
orientation=Qt.Orientation.Horizontal, parent=self
)
self.front_light_slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self)
self.front_light_slider.setRange(0, 100)
self.front_light_slider.sliderPressed.connect(self.on_slider_pressed)
self.front_light_slider.sliderReleased.connect(self.on_front_slider_released)
@@ -32,9 +30,7 @@ class IlluminationPanel(QWidget):
back_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
grid_layout.addWidget(back_label, 3, 0, 1, 2)
self.back_light_slider = QSlider(
orientation=Qt.Orientation.Horizontal, parent=self
)
self.back_light_slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self)
self.back_light_slider.setRange(0, 100)
self.back_light_slider.sliderPressed.connect(self.on_slider_pressed)
self.back_light_slider.sliderReleased.connect(self.on_back_slider_released)
+37 -134
View File
@@ -98,8 +98,7 @@ class LocalContactPanel(QFrame):
layout.addWidget(TitleLabel("Local Contact", parent=self))
self._info_label = QLabel(
"Staff tools for beamline recovery and local-contact operations.",
self,
"Staff tools for beamline recovery and local-contact operations.", self
)
self._info_label.setWordWrap(True)
layout.addWidget(self._info_label)
@@ -141,16 +140,12 @@ class LocalContactPanel(QFrame):
self._tabs.addTab(self._build_detector_tab(), self.TAB_DETECTOR)
self._tabs.addTab(self._build_config_tab(), self.TAB_CONFIG)
self._daq.local_contact_simulation_state_loaded.connect(
self._apply_simulation_state
)
self._daq.local_contact_simulation_state_loaded.connect(self._apply_simulation_state)
self._daq.local_contact_device_state_loaded.connect(self._apply_device_state)
self._daq.local_contact_links_loaded.connect(self._apply_links)
self._daq.local_contact_config_loaded.connect(self._apply_local_contact_config)
self._daq.local_contact_config_saved.connect(self._apply_local_contact_config)
self._daq.local_contact_transfer_error.connect(
self._show_local_contact_transfer_error
)
self._daq.local_contact_transfer_error.connect(self._show_local_contact_transfer_error)
self._daq.bec_user_macros_loaded.connect(self._show_bec_user_macros)
self._daq.bec_devices_loaded.connect(self._show_bec_devices)
self._daq.update.connect(self._update_from_status)
@@ -162,17 +157,12 @@ class LocalContactPanel(QFrame):
def set_active_tab(self, tab_name: str) -> None:
for index in range(self._tabs.count()):
if (
self._tabs.tabText(index).strip().lower()
== str(tab_name).strip().lower()
):
if self._tabs.tabText(index).strip().lower() == str(tab_name).strip().lower():
self._tabs.setCurrentIndex(index)
return
logger.warning(f"Unknown Local Contact tab requested: {tab_name}")
def _register_status_widget(
self, widget: LocalContactStatusWidget
) -> LocalContactStatusWidget:
def _register_status_widget(self, widget: LocalContactStatusWidget) -> LocalContactStatusWidget:
self._status_widgets.append(widget)
if self._last_status is not None:
widget.set_daq_status(self._last_status)
@@ -181,17 +171,10 @@ class LocalContactPanel(QFrame):
return widget
def _make_status_widget(
self,
*,
title: str,
summary: str,
fields: tuple[str, ...],
self, *, title: str, summary: str, fields: tuple[str, ...]
) -> LocalContactStatusWidget:
widget = LocalContactStatusWidget(
title=title,
visible_fields=fields,
summary=summary,
parent=self,
title=title, visible_fields=fields, summary=summary, parent=self
)
return self._register_status_widget(widget)
@@ -263,13 +246,7 @@ class LocalContactPanel(QFrame):
self._make_status_widget(
title="Recovery status",
summary="Recovery-relevant DAQ state.",
fields=(
"beamline_state",
"busy",
"sample",
"tell_connected",
"tell_state",
),
fields=("beamline_state", "busy", "sample", "tell_connected", "tell_state"),
)
)
layout.addWidget(RecoveryPanel(daq=self._daq, parent=tab), 1)
@@ -303,37 +280,25 @@ class LocalContactPanel(QFrame):
row += 1
grid.addWidget(
self._make_button(
"Unmount", self._daq.unmount, "Requesting sample unmount."
),
row,
0,
)
grid.addWidget(
self._make_button("Dry", self._daq.tell_dry, "Requesting TELL dry."), row, 1
self._make_button("Unmount", self._daq.unmount, "Requesting sample unmount."), row, 0
)
grid.addWidget(self._make_button("Dry", self._daq.tell_dry, "Requesting TELL dry."), row, 1)
row += 1
grid.addWidget(
self._make_button(
"Park and dry", self._daq.park_and_dry, "Requesting park and dry."
),
self._make_button("Park and dry", self._daq.park_and_dry, "Requesting park and dry."),
row,
0,
)
grid.addWidget(
self._make_button(
"Toggle blower", self._daq.tell_toggle_blower, "Toggling blower."
),
self._make_button("Toggle blower", self._daq.tell_toggle_blower, "Toggling blower."),
row,
1,
)
row += 1
grid.addWidget(self._make_button("Anneal", self._anneal_from_dialog), row, 0)
grid.addWidget(
self._make_button("TELL access info", self._show_tell_access_info), row, 1
)
grid.addWidget(self._make_button("TELL access info", self._show_tell_access_info), row, 1)
wrapper = QGroupBox("TELL actions", tab)
wrapper_layout = QVBoxLayout(wrapper)
@@ -356,14 +321,7 @@ class LocalContactPanel(QFrame):
self._make_status_widget(
title="BEC status",
summary="BEC-focused state and related backend status.",
fields=(
"bec",
"busy",
"beamline_state",
"detector",
"aerotech",
"smargon",
),
fields=("bec", "busy", "beamline_state", "detector", "aerotech", "smargon"),
)
)
@@ -376,9 +334,7 @@ class LocalContactPanel(QFrame):
tools_layout.addWidget(
self._make_button(
"Load BEC user macros",
self._daq.bec_load_user_macros,
"Loading BEC user macros.",
"Load BEC user macros", self._daq.bec_load_user_macros, "Loading BEC user macros."
)
)
tools_layout.addWidget(
@@ -390,9 +346,7 @@ class LocalContactPanel(QFrame):
)
tools_layout.addWidget(
self._make_button(
"Show BEC position devices",
self._daq.bec_list_all_devices,
"Listing BEC devices.",
"Show BEC position devices", self._daq.bec_list_all_devices, "Listing BEC devices."
)
)
tools_layout.addWidget(
@@ -445,15 +399,7 @@ class LocalContactPanel(QFrame):
self._make_status_widget(
title="Hardware status",
summary="Hardware-related backend and beamline status.",
fields=(
"aerotech",
"smargon",
"detector",
"bec",
"tell",
"busy",
"beamline_state",
),
fields=("aerotech", "smargon", "detector", "bec", "tell", "busy", "beamline_state"),
)
)
@@ -508,9 +454,7 @@ class LocalContactPanel(QFrame):
"Initialising detector.",
),
self._make_button(
"Initialise Smargon",
self._daq.initialise_smargon,
"Initialising Smargon.",
"Initialise Smargon", self._daq.initialise_smargon, "Initialising Smargon."
),
self._make_button(
"Initialise Aerotech",
@@ -555,18 +499,14 @@ class LocalContactPanel(QFrame):
grid.addWidget(
self._make_button(
"Cancel current state",
self._daq.cancel,
"Cancelling current detector/scan state.",
"Cancel current state", self._daq.cancel, "Cancelling current detector/scan state."
),
row,
0,
)
grid.addWidget(
self._make_button(
"Take pedestal",
self._daq.detector_take_pedestal,
"Requesting detector pedestal.",
"Take pedestal", self._daq.detector_take_pedestal, "Requesting detector pedestal."
),
row,
1,
@@ -596,10 +536,7 @@ class LocalContactPanel(QFrame):
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(8)
description = QLabel(
"Staff-only tuning values used by automation and recovery flows.",
tab,
)
description = QLabel("Staff-only tuning values used by automation and recovery flows.", tab)
description.setWordWrap(True)
layout.addWidget(description)
@@ -622,12 +559,8 @@ class LocalContactPanel(QFrame):
form_layout.addWidget(label, 0, 0)
form_layout.addWidget(self._mount_to_center_sleep_s, 0, 1)
loop_face_padding_label = QLabel(
"Line scan loop_face Y padding / side", form_box
)
self._line_scan_loop_face_y_padding_fraction_each_side = QDoubleSpinBox(
form_box
)
loop_face_padding_label = QLabel("Line scan loop_face Y padding / side", form_box)
self._line_scan_loop_face_y_padding_fraction_each_side = QDoubleSpinBox(form_box)
self._line_scan_loop_face_y_padding_fraction_each_side.setRange(0.0, 5.0)
self._line_scan_loop_face_y_padding_fraction_each_side.setDecimals(2)
self._line_scan_loop_face_y_padding_fraction_each_side.setSingleStep(0.05)
@@ -647,13 +580,9 @@ class LocalContactPanel(QFrame):
)
form_layout.addWidget(loop_face_padding_label, 1, 0)
form_layout.addWidget(
self._line_scan_loop_face_y_padding_fraction_each_side, 1, 1
)
form_layout.addWidget(self._line_scan_loop_face_y_padding_fraction_each_side, 1, 1)
form_layout.addWidget(loop_all_padding_label, 2, 0)
form_layout.addWidget(
self._line_scan_loop_all_y_padding_fraction_each_side, 2, 1
)
form_layout.addWidget(self._line_scan_loop_all_y_padding_fraction_each_side, 2, 1)
button_row = QWidget(tab)
button_layout = QHBoxLayout(button_row)
@@ -760,12 +689,9 @@ class LocalContactPanel(QFrame):
@Slot(list)
def _show_bec_user_macros(self, items: list) -> None:
if self._bec_macros_dialog is None:
self._bec_macros_dialog = TextListDialog(
title="BEC user macros", parent=self
)
self._bec_macros_dialog = TextListDialog(title="BEC user macros", parent=self)
self._bec_macros_dialog.set_items(
[str(item) for item in items],
empty_message="No BEC user macros found.",
[str(item) for item in items], empty_message="No BEC user macros found."
)
self._bec_macros_dialog.show()
self._bec_macros_dialog.raise_()
@@ -774,12 +700,9 @@ class LocalContactPanel(QFrame):
@Slot(list)
def _show_bec_devices(self, items: list) -> None:
if self._bec_devices_dialog is None:
self._bec_devices_dialog = TextListDialog(
title="BEC position devices", parent=self
)
self._bec_devices_dialog = TextListDialog(title="BEC position devices", parent=self)
self._bec_devices_dialog.set_items(
[str(item) for item in items],
empty_message="No BEC devices found.",
[str(item) for item in items], empty_message="No BEC devices found."
)
self._bec_devices_dialog.show()
self._bec_devices_dialog.raise_()
@@ -797,9 +720,7 @@ class LocalContactPanel(QFrame):
url = self._links_payload.get(key)
if not url:
QMessageBox.information(
self,
"Link unavailable",
f"No configured link is available for {key}.",
self, "Link unavailable", f"No configured link is available for {key}."
)
return
QDesktopServices.openUrl(QUrl(str(url)))
@@ -807,37 +728,23 @@ class LocalContactPanel(QFrame):
@Slot()
def _show_tell_access_info(self) -> None:
message = (
self._links_payload.get("tell_hint")
or "Please check TELL status via Remmina / VNC."
self._links_payload.get("tell_hint") or "Please check TELL status via Remmina / VNC."
)
QMessageBox.information(self, "TELL access", str(message))
def _make_button(
self,
text: str,
callback: Callable[[], None],
log_message: str | None = None,
self, text: str, callback: Callable[[], None], log_message: str | None = None
) -> QPushButton:
button = QPushButton(text, self)
if log_message is None:
button.clicked.connect(callback)
else:
button.clicked.connect(
lambda: self._run_logged_action(log_message, callback)
)
button.clicked.connect(lambda: self._run_logged_action(log_message, callback))
return button
@Slot()
def _anneal_from_dialog(self) -> None:
seconds, ok = QInputDialog.getDouble(
self,
"Anneal",
"Anneal time (s):",
1.0,
0.1,
60.0,
1,
)
seconds, ok = QInputDialog.getDouble(self, "Anneal", "Anneal time (s):", 1.0, 0.1, 60.0, 1)
if not ok:
return
self._daq.anneal(seconds)
@@ -853,9 +760,7 @@ class LocalContactPanel(QFrame):
self._mount_to_center_sleep_s.blockSignals(True)
self._mount_to_center_sleep_s.setValue(
float(
self._local_contact_config_payload.get("mount_to_center_sleep_s", 0.0)
)
float(self._local_contact_config_payload.get("mount_to_center_sleep_s", 0.0))
)
self._mount_to_center_sleep_s.blockSignals(False)
@@ -863,8 +768,7 @@ class LocalContactPanel(QFrame):
self._line_scan_loop_face_y_padding_fraction_each_side.setValue(
float(
self._local_contact_config_payload.get(
"line_scan_loop_face_y_padding_fraction_each_side",
0.5,
"line_scan_loop_face_y_padding_fraction_each_side", 0.5
)
)
)
@@ -874,8 +778,7 @@ class LocalContactPanel(QFrame):
self._line_scan_loop_all_y_padding_fraction_each_side.setValue(
float(
self._local_contact_config_payload.get(
"line_scan_loop_all_y_padding_fraction_each_side",
0.5,
"line_scan_loop_all_y_padding_fraction_each_side", 0.5
)
)
)
+1 -8
View File
@@ -1,14 +1,7 @@
from aarecommon.models.models import DAQStatusModel, SampleShortInfo
from aareDB import DataCollectionParameters
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import (
QCheckBox,
QGridLayout,
QLabel,
QLineEdit,
QPushButton,
QWidget,
)
from PySide6.QtWidgets import QCheckBox, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
+19 -81
View File
@@ -3,20 +3,9 @@ from __future__ import annotations
import math
from aarecommon.config.logger import setup_logger
from aarecommon.models.automation import (
AutomationProgress,
StepStatus,
WorkflowStateKind,
)
from aarecommon.models.automation import AutomationProgress, StepStatus, WorkflowStateKind
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal, Slot
from PySide6.QtGui import (
QColor,
QFont,
QFontMetrics,
QLinearGradient,
QPainter,
QPen,
)
from PySide6.QtGui import QColor, QFont, QFontMetrics, QLinearGradient, QPainter, QPen
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -109,10 +98,7 @@ class LEDStages(QWidget):
line_color = QColor(ACCENT) if i < self._active else QColor(LED_OFF)
pen = QPen(line_color, 2)
p.setPen(pen)
p.drawLine(
QPointF(cx + led_r + 3, cy),
QPointF(next_cx - led_r - 3, cy),
)
p.drawLine(QPointF(cx + led_r + 3, cy), QPointF(next_cx - led_r - 3, cy))
p.setPen(Qt.NoPen)
if i < self._active:
@@ -144,9 +130,7 @@ class LEDStages(QWidget):
fm = QFontMetrics(font)
text_w = fm.horizontalAdvance(name)
p.drawText(
QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2),
Qt.AlignCenter,
name,
QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2), Qt.AlignCenter, name
)
@@ -197,12 +181,7 @@ class PlayPauseButton(QPushButton):
# ---------------------------------------------------------------------------
class QueueItemCard(QFrame):
def __init__(
self,
index: int | str,
title: str,
subtitle: str,
is_next: bool = False,
parent=None,
self, index: int | str, title: str, subtitle: str, is_next: bool = False, parent=None
):
super().__init__(parent)
self.setFixedHeight(72)
@@ -245,9 +224,7 @@ class QueueItemCard(QFrame):
)
title_lbl.setWordWrap(False)
sub_lbl = QLabel(subtitle)
sub_lbl.setStyleSheet(
f"color: {SUBTEXT}; font-size: 11px; background: transparent;"
)
sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; background: transparent;")
text_col.addWidget(title_lbl)
text_col.addWidget(sub_lbl)
layout.addLayout(text_col, stretch=1)
@@ -350,9 +327,7 @@ class PortraitModePanel(QWidget):
# Camera card — wraps the real compact_sample_camera
cam_card = QFrame()
cam_card.setStyleSheet(
f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}"
)
cam_card.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}")
cam_card_layout = QVBoxLayout(cam_card)
cam_card_layout.setContentsMargins(4, 4, 4, 4)
cam_card_layout.setSpacing(0)
@@ -363,9 +338,7 @@ class PortraitModePanel(QWidget):
# Sample name labels
self._name_lbl = QLabel("")
self._name_lbl.setStyleSheet(
f"color: {TEXT}; font-size: 18px; font-weight: 700;"
)
self._name_lbl.setStyleSheet(f"color: {TEXT}; font-size: 18px; font-weight: 700;")
self._sub_lbl = QLabel("No sample queued")
self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 12px;")
layout.addWidget(self._name_lbl)
@@ -377,9 +350,7 @@ class PortraitModePanel(QWidget):
# Play/Pause button (single, centred)
ctrl_frame = QFrame()
ctrl_frame.setStyleSheet(
f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}"
)
ctrl_frame.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}")
ctrl_layout = QHBoxLayout(ctrl_frame)
ctrl_layout.setContentsMargins(12, 12, 12, 12)
@@ -497,9 +468,7 @@ class PortraitModePanel(QWidget):
layout.addWidget(self._queue_scroll, stretch=1)
back_btn = self._accent_button("← BACK TO CAMERA")
back_btn.clicked.connect(
lambda: self._stack.setCurrentWidget(self._player_page)
)
back_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._player_page))
layout.addWidget(back_btn)
return page
@@ -578,12 +547,7 @@ class PortraitModePanel(QWidget):
for i, s in enumerate(suggested):
name = str(getattr(s, "sample_name", "") or f"Sample {i + 1}")
puck = str(getattr(s, "puck_name", "") or "")
card = QueueItemCard(
index=i + 1,
title=name,
subtitle=puck,
is_next=False,
)
card = QueueItemCard(index=i + 1, title=name, subtitle=puck, is_next=False)
self._preview_layout.addWidget(card)
return
@@ -591,10 +555,7 @@ class PortraitModePanel(QWidget):
name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}")
puck = str(getattr(sample, "puck_name", "") or "")
card = QueueItemCard(
index="" if i == 0 else i + 1,
title=name,
subtitle=puck,
is_next=(i == 0),
index="" if i == 0 else i + 1, title=name, subtitle=puck, is_next=(i == 0)
)
self._preview_layout.addWidget(card)
@@ -636,12 +597,7 @@ class PortraitModePanel(QWidget):
for i, sample in enumerate(samples):
name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}")
puck = str(getattr(sample, "puck_name", "") or "")
card = QueueItemCard(
index=i + 1,
title=name,
subtitle=puck,
is_next=False,
)
card = QueueItemCard(index=i + 1, title=name, subtitle=puck, is_next=False)
self._queue_inner_layout.addWidget(card)
# ------------------------------------------------------------------
@@ -727,9 +683,7 @@ class PortraitModePanel(QWidget):
self._alert_toast.setVisible(False)
self._alert_toast_label.clear()
def _flush_portrait_alerts_to_banners(
self, primary_banner, secondary_banner
) -> None:
def _flush_portrait_alerts_to_banners(self, primary_banner, secondary_banner) -> None:
"""
Called when returning to main view replay any error alerts that
arrived during portrait mode so the operator doesn't miss them.
@@ -762,27 +716,17 @@ class PortraitModePanel(QWidget):
return
# Timeout protection
if (
self._loop_restart_deadline is not None
and self._loop_restart_deadline.hasExpired()
):
if self._loop_restart_deadline is not None and self._loop_restart_deadline.hasExpired():
self._loop_restart_timer.stop()
self._loop_enabled = False
self._loop_btn.setChecked(False)
self.show_portrait_alert(
"Loop stopped: beamline remained busy too long",
True,
)
self.show_portrait_alert("Loop stopped: beamline remained busy too long", True)
return
# Still busy, wait
if getattr(
self._job_list_panel,
"_SampleQueuePanel__busy",
False,
):
if getattr(self._job_list_panel, "_SampleQueuePanel__busy", False):
return
# Safe to restart
@@ -805,10 +749,7 @@ class PortraitModePanel(QWidget):
if self._loop_remaining <= 0:
return False
self._job_list_panel.queue_samples(
list(self._loop_samples),
replace=True,
)
self._job_list_panel.queue_samples(list(self._loop_samples), replace=True)
self._loop_remaining -= 1
@@ -847,7 +788,4 @@ class PortraitModePanel(QWidget):
)
if ordered:
self._job_list_panel.queue_samples(
ordered,
replace=True,
)
self._job_list_panel.queue_samples(ordered, replace=True)
@@ -137,8 +137,7 @@ class ConfidenceHistogramWidget(QWidget):
self.chart.legend().setVisible(False)
categories = [
f"{self.BINS[i]:.1f}-{self.BINS[i + 1]:.1f}"
for i in range(len(self.BINS) - 1)
f"{self.BINS[i]:.1f}-{self.BINS[i + 1]:.1f}" for i in range(len(self.BINS) - 1)
]
self.axis_x = QBarCategoryAxis()
self.axis_x.append(categories)
@@ -216,9 +215,7 @@ class ObjectCountWidget(QWidget):
# Count label with matching color
count_label = QLabel("0")
count_label.setStyleSheet(
f"color: {color}; font-size: 14px; font-weight: bold;"
)
count_label.setStyleSheet(f"color: {color}; font-size: 14px; font-weight: bold;")
count_label.setAlignment(Qt.AlignmentFlag.AlignRight)
row = i // 2
@@ -289,11 +286,7 @@ class TimingStatsWidget(QWidget):
layout.addWidget(group)
def update_stats(
self,
frame_time_ms: float,
fps: float,
detection_count: int,
mean_confidence: float,
self, frame_time_ms: float, fps: float, detection_count: int, mean_confidence: float
):
"""Update timing statistics display."""
self.frame_time_label.setText(f"{frame_time_ms:.1f} ms")
@@ -650,10 +643,7 @@ class PredictionMetricsPanel(QWidget):
super().showEvent(event)
if self._refresh_timer is not None and not self._refresh_timer.isActive():
self._refresh_timer.start()
if (
self._chart_refresh_timer is not None
and not self._chart_refresh_timer.isActive()
):
if self._chart_refresh_timer is not None and not self._chart_refresh_timer.isActive():
self._chart_refresh_timer.start()
self._dirty = True
@@ -758,9 +748,7 @@ class PredictionMetricsPanel(QWidget):
# Update timing stats
avg_frame_time = (
sum(self._frame_times) / len(self._frame_times)
if self._frame_times
else 0.0
sum(self._frame_times) / len(self._frame_times) if self._frame_times else 0.0
)
fps = 1000.0 / avg_frame_time if avg_frame_time > 0 else 0.0
@@ -781,10 +769,7 @@ class PredictionMetricsPanel(QWidget):
return
path, _ = QFileDialog.getSaveFileName(
self,
"Export Prediction Metrics",
"prediction_metrics.csv",
"CSV files (*.csv)",
self, "Export Prediction Metrics", "prediction_metrics.csv", "CSV files (*.csv)"
)
if not path:
return
+15 -45
View File
@@ -36,9 +36,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
default_transmission: float = 1.0,
parent=None,
):
super().__init__(
diffraction, raster_mgr.active_grid.dtz, default_transmission, parent
)
super().__init__(diffraction, raster_mgr.active_grid.dtz, default_transmission, parent)
self._previous_sample_was_none_raster = True
@@ -71,11 +69,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self._layout.addWidget(QLabel("Image time", parent=self), 4, 0)
self.image_time_enter = DbOverrideLineEdit(
0.0005,
10.0,
default=raster_mgr.active_grid.exp_time_s,
decimals=4,
parent=self,
0.0005, 10.0, default=raster_mgr.active_grid.exp_time_s, decimals=4, parent=self
)
self._layout.addWidget(self.image_time_enter, 4, 1, 1, 3)
self._layout.addWidget(QLabel("s", parent=self), 4, 4)
@@ -110,26 +104,18 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self._layout.addWidget(QLabel("Metric", parent=self), 7, 0)
self.metric_combo.addItem("Raster score", RasterGridMetric.RASTER_SCORE)
self.metric_combo.addItem(
"Spot count (low res.)", RasterGridMetric.SPOTS_LOW_RES
)
self.metric_combo.addItem("Spot count (low res.)", RasterGridMetric.SPOTS_LOW_RES)
self.metric_combo.addItem("Spot count", RasterGridMetric.SPOTS)
self.metric_combo.addItem(
"Spot count (indexed)", RasterGridMetric.SPOTS_INDEXED
)
self.metric_combo.addItem("Spot count (indexed)", RasterGridMetric.SPOTS_INDEXED)
self.metric_combo.addItem("Spot count (ice)", RasterGridMetric.SPOTS_ICE)
self.metric_combo.addItem(
"Spot ratio (ice/low res.)", RasterGridMetric.SPOTS_ICE_LOW_RES
)
self.metric_combo.addItem("Spot ratio (ice/low res.)", RasterGridMetric.SPOTS_ICE_LOW_RES)
self.metric_combo.addItem("Background estimate", RasterGridMetric.BKG)
self.metric_combo.addItem("Indexing result", RasterGridMetric.INDEXING)
self.metric_combo.addItem("Profile Radius", RasterGridMetric.PR)
self.metric_combo.addItem("B-factor", RasterGridMetric.BFACTOR)
self.metric_combo.addItem("Resolution (ML est.)", RasterGridMetric.RES)
self.metric_combo.setCurrentIndex(
self.metric_combo.findData(RasterGridMetric.RASTER_SCORE)
)
self.metric_combo.setCurrentIndex(self.metric_combo.findData(RasterGridMetric.RASTER_SCORE))
self.metric_combo.currentIndexChanged.connect(self.metric_changed)
self._layout.addWidget(self.metric_combo, 7, 1, 1, 3)
@@ -138,9 +124,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self)
slider.setRange(0, 255)
slider.setValue(127)
slider.valueChanged.connect(
lambda: self.raster_alpha_changed.emit(255 - slider.value())
)
slider.valueChanged.connect(lambda: self.raster_alpha_changed.emit(255 - slider.value()))
self._layout.addWidget(slider, 8, 1, 1, 3)
self._table = RasterGridTable(raster_mgr)
@@ -153,9 +137,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self._layout.addWidget(QLabel("Measurement time", parent=self), 11, 0)
self.total_time = QLabel(f"{self.__total_time} min 0 s")
self.total_time.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.total_time, 11, 1, 1, 3)
self.calculate_total_time()
@@ -185,9 +167,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self.calculate_total_time()
@Slot(int, int, float, float)
def grid_scan_size_change(
self, n_x: int, n_y: int, size_x_mm: float, size_y_mm: float
):
def grid_scan_size_change(self, n_x: int, n_y: int, size_x_mm: float, size_y_mm: float):
self.__size_x = size_x_mm * 1000.0
self.__size_y = size_y_mm * 1000.0
self.__n_x = n_x
@@ -235,7 +215,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
def get_parameter_mappings(self):
"""Return raster-specific parameter mappings."""
return [
("exposure", self.image_time_enter, None),
("exposure", self.image_time_enter, None)
# Add other raster-specific parameters here as needed
]
@@ -251,9 +231,7 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
if self.__n_x <= 0 or self.__n_y <= 0 or self.image_time_enter.value < 0:
self.__total_time = 0.0
else:
self.__total_time = (
self.__n_x * self.__n_y * self.image_time_enter.value * 1.3
)
self.__total_time = self.__n_x * self.__n_y * self.image_time_enter.value * 1.3
# 30% buffer added
# Show minutes
self.update_total_time_label()
@@ -261,12 +239,8 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
@Slot()
def _on_evaluate_clicked(self):
if self.__beamline_state != BeamlineStateEnum.SampleAlignment:
logger.error(
f"Beamline state {self.__beamline_state} is not Sample Alignment"
)
QMessageBox.critical(
None, "Error", "Beamline state is not Sample Alignment"
)
logger.error(f"Beamline state {self.__beamline_state} is not Sample Alignment")
QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
return
if self.check_before_run(scan_kind="raster"):
self.evaluate_grid.emit()
@@ -274,12 +248,8 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
@Slot()
def _on_evaluate_auto_clicked(self):
if self.__beamline_state != BeamlineStateEnum.SampleAlignment:
logger.error(
f"Beamline state {self.__beamline_state} is not Sample Alignment"
)
QMessageBox.critical(
None, "Error", "Beamline state is not Sample Alignment"
)
logger.error(f"Beamline state {self.__beamline_state} is not Sample Alignment")
QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
return
if self.check_before_run(scan_kind="raster"):
self.evaluate_grid_auto.emit()
+6 -22
View File
@@ -2,11 +2,7 @@
from typing import Optional
from aarecommon.config.logger import setup_logger
from aarecommon.models.models import (
DAQStatusModel,
SampleShortInfo,
SampleShortInfoList,
)
from aarecommon.models.models import DAQStatusModel, SampleShortInfo, SampleShortInfoList
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import (
@@ -96,11 +92,7 @@ class ReferenceToolsModel(QAbstractTableModel):
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal:
return (
self.header[section]
if section < len(self.header)
else f"Column {section + 1}"
)
return self.header[section] if section < len(self.header) else f"Column {section + 1}"
return str(section + 1)
def update_rows(self, rows: list[SampleShortInfo]):
@@ -137,16 +129,12 @@ class ReferenceToolsModel(QAbstractTableModel):
elif self.__sort_col == 2:
# Numeric sort for Mount count; place None last on ascending, first on descending
none_sentinel = (
float("inf")
if self.__sort_order == Qt.SortOrder.AscendingOrder
else float("-inf")
float("inf") if self.__sort_order == Qt.SortOrder.AscendingOrder else float("-inf")
)
self.__sorted_samples = sorted(
self.samples,
key=lambda row: (
row.mount_count
if isinstance(row.mount_count, (int, float))
else none_sentinel
row.mount_count if isinstance(row.mount_count, (int, float)) else none_sentinel
),
reverse=(self.__sort_order == Qt.SortOrder.DescendingOrder),
)
@@ -220,9 +208,7 @@ class ReferenceToolsPanel(QFrame):
# initialize model with provided samples
self.table_model = ReferenceToolsModel(rows=samples.s)
self.table_view.setModel(self.table_model)
self.table_view.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.table_view.setEditTriggers(QTableView.EditTrigger.NoEditTriggers)
logger.debug("Setting up table header")
@@ -237,9 +223,7 @@ class ReferenceToolsPanel(QFrame):
logger.debug("Setting up table view context menu")
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.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection)
def _selected_item(self) -> Optional[SampleShortInfo]:
+18 -55
View File
@@ -8,11 +8,7 @@ from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton
from aare.gui.panels.scan_settings_panel import ScanSettingsPanel
from aare.gui.widgets.number_line_edit import (
CheckedLineEdit,
DbOverrideLineEdit,
NumberLineEdit,
)
from aare.gui.widgets.number_line_edit import CheckedLineEdit, DbOverrideLineEdit, NumberLineEdit
logger = setup_logger("aareGUI")
@@ -69,36 +65,22 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
)
self._layout.addWidget(QLabel("Image angle", parent=self), 5, 0)
self.screening_image_angle = NumberLineEdit(
0, 90.0, 0.5, decimals=3, parent=self
)
self.screening_image_angle = NumberLineEdit(0, 90.0, 0.5, decimals=3, parent=self)
self._layout.addWidget(self.screening_image_angle, 5, 1, 1, 3)
self._layout.addWidget(QLabel("°", parent=self), 5, 4)
self._layout.addWidget(QLabel("Image time", parent=self), 6, 0)
self.screening_image_time_enter = NumberLineEdit(
0.0005, 10.0, 0.1, decimals=4, parent=self
)
self.screening_image_time_enter = NumberLineEdit(0.0005, 10.0, 0.1, decimals=4, parent=self)
self._layout.addWidget(self.screening_image_time_enter, 6, 1, 1, 3)
self._layout.addWidget(QLabel("s", parent=self), 6, 4)
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}
)
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})
self._layout.addWidget(self.screening_type, 7, 0, 1, 6)
@@ -112,17 +94,13 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
)
self._layout.addWidget(QLabel("Total angle", parent=self), 10, 0)
self.total_angle = DbOverrideLineEdit(
0, 9999.0, default=360.0, decimals=3, parent=self
)
self.total_angle = DbOverrideLineEdit(0, 9999.0, default=360.0, decimals=3, parent=self)
self._layout.addWidget(self.total_angle, 10, 1, 1, 3)
self._layout.addWidget(QLabel("°", parent=self), 10, 4)
self._register_override_field(self.total_angle)
self._layout.addWidget(QLabel("Image angle", parent=self), 11, 0)
self.image_angle = DbOverrideLineEdit(
0, 10.0, default=0.2, decimals=3, parent=self
)
self.image_angle = DbOverrideLineEdit(0, 10.0, default=0.2, decimals=3, parent=self)
self._layout.addWidget(self.image_angle, 11, 1, 1, 3)
self._layout.addWidget(QLabel("°", parent=self), 11, 4)
self._register_override_field(self.image_angle)
@@ -137,9 +115,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
self._layout.addWidget(QLabel("Total measurement time", parent=self), 13, 0)
self.total_time = QLabel(f"{self.__total_time} min 0 s")
self.total_time.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.total_time, 13, 1, 1, 3)
self.total_angle.valueChanged.connect(self.calculate_measurement_time)
@@ -150,9 +126,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
self._layout.addWidget(QLabel("Dose", parent=self), 14, 0)
self.dose = QLabel(f"{self.__dose_mgy}")
self.dose.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.dose.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.dose, 14, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 14, 4)
@@ -168,12 +142,8 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
@Slot()
def run_screening(self):
if self.__beamline_state != BeamlineStateEnum.SampleAlignment:
logger.error(
f"Beamline state {self.__beamline_state} is not Sample Alignment"
)
QMessageBox.critical(
None, "Error", "Beamline state is not Sample Alignment"
)
logger.error(f"Beamline state {self.__beamline_state} is not Sample Alignment")
QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
return
if not self.check_before_run(scan_kind="screening"):
logger.error("Cannot run measurement because of check")
@@ -181,8 +151,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
screening_settings = self.screening_type.currentData()
is_single_image = (
screening_settings["steps"] == 1
and screening_settings["omega_step_deg"] == 0
screening_settings["steps"] == 1 and screening_settings["omega_step_deg"] == 0
)
if is_single_image:
@@ -216,12 +185,8 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
@Slot()
def run_measurement(self):
if self.__beamline_state != BeamlineStateEnum.SampleAlignment:
logger.error(
f"Beamline state {self.__beamline_state} is not Sample Alignment"
)
QMessageBox.critical(
None, "Error", "Beamline state is not Sample Alignment"
)
logger.error(f"Beamline state {self.__beamline_state} is not Sample Alignment")
QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
return
if not self.check_before_run(scan_kind="rotation"):
logger.error("Cannot run measurement because of check")
@@ -318,9 +283,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
beam_area = s.geom.beam_size_mm.x * s.geom.beam_size_mm.y * 1e6
time = self.image_number() * self.image_time_enter.value
self.__dose_mgy = (time * s.bl.flux_ph_s * self._transmission) / (
beam_area * kdose
)
self.__dose_mgy = (time * s.bl.flux_ph_s * self._transmission) / (beam_area * kdose)
self.dose.setText(f"{(self.__dose_mgy / 1e6):.1f}")
self.__beamline_state = s.state
+8 -23
View File
@@ -43,9 +43,7 @@ class SamcamPanel(QWidget):
self.exposure_spinbox = QDoubleSpinBox()
self.exposure_spinbox.setRange(0, 1.0) # Adjust range as needed
self.exposure_spinbox.setSingleStep(0.001)
self.exposure_spinbox.setStyleSheet(
"QDoubleSpinBox { background-color: white; }"
)
self.exposure_spinbox.setStyleSheet("QDoubleSpinBox { background-color: white; }")
self.exposure_spinbox.setDecimals(3)
self.exposure_spinbox.valueChanged.connect(self.__changed)
@@ -67,17 +65,13 @@ class SamcamPanel(QWidget):
# Persist the current gain/exposure as the beam-location preset for the
# current zoom (only meaningful in beam-location mode).
self.save_beam_location_button = QPushButton("Save samcam settings (bl)")
self.save_beam_location_button.clicked.connect(
self.save_beam_location_setting.emit
)
self.save_beam_location_button.clicked.connect(self.save_beam_location_setting.emit)
screenshot_filename_layout = QHBoxLayout()
screenshot_filename_label = QLabel("Filename:")
self.screenshot_filename_edit = QLineEdit()
self.screenshot_filename_edit.setPlaceholderText("optional")
self.screenshot_filename_edit.setStyleSheet(
"QLineEdit { background-color: white; }"
)
self.screenshot_filename_edit.setStyleSheet("QLineEdit { background-color: white; }")
screenshot_filename_layout.addWidget(screenshot_filename_label)
screenshot_filename_layout.addWidget(self.screenshot_filename_edit)
@@ -85,9 +79,7 @@ class SamcamPanel(QWidget):
screenshot_message_label = QLabel("Message:")
self.screenshot_message_edit = QLineEdit()
self.screenshot_message_edit.setPlaceholderText("optional")
self.screenshot_message_edit.setStyleSheet(
"QLineEdit { background-color: white; }"
)
self.screenshot_message_edit.setStyleSheet("QLineEdit { background-color: white; }")
screenshot_message_layout.addWidget(screenshot_message_label)
screenshot_message_layout.addWidget(self.screenshot_message_edit)
@@ -114,9 +106,7 @@ class SamcamPanel(QWidget):
target_point_layout = QHBoxLayout()
self.show_target_point_checkbox = QCheckBox("Show target point")
self.show_target_point_checkbox.setChecked(True)
self.show_target_point_checkbox.toggled.connect(
self.show_target_point_changed.emit
)
self.show_target_point_checkbox.toggled.connect(self.show_target_point_changed.emit)
target_point_layout.addWidget(self.show_target_point_checkbox)
# Show target coordinates checkbox
@@ -132,9 +122,7 @@ class SamcamPanel(QWidget):
legend_layout = QHBoxLayout()
self.show_overlay_legend_checkbox = QCheckBox("Show overlay legend")
self.show_overlay_legend_checkbox.setChecked(True)
self.show_overlay_legend_checkbox.toggled.connect(
self.show_overlay_legend_changed.emit
)
self.show_overlay_legend_checkbox.toggled.connect(self.show_overlay_legend_changed.emit)
legend_layout.addWidget(self.show_overlay_legend_checkbox)
# Compact legend checkbox
@@ -152,9 +140,7 @@ class SamcamPanel(QWidget):
self.target_color_combo = QComboBox()
self.target_color_combo.addItems(["Cyan", "Dark Blue", "Dark Red"])
self.target_color_combo.setCurrentText("Cyan")
self.target_color_combo.currentTextChanged.connect(
self.target_color_changed.emit
)
self.target_color_combo.currentTextChanged.connect(self.target_color_changed.emit)
target_color_layout.addWidget(target_color_label)
target_color_layout.addWidget(self.target_color_combo)
@@ -183,8 +169,7 @@ class SamcamPanel(QWidget):
def __request_screenshot(self):
self.screenshot_requested.emit(
self.screenshot_filename_edit.text(),
self.screenshot_message_edit.text(),
self.screenshot_filename_edit.text(), self.screenshot_message_edit.text()
)
def apply_overlay_settings(
+13 -41
View File
@@ -37,10 +37,7 @@ class SampleQueuePanel(QFrame):
step_through_changed = Signal(bool)
def __init__(
self,
parent=None,
samples: SampleShortInfoList | None = None,
show_user: bool = False,
self, parent=None, samples: SampleShortInfoList | None = None, show_user: bool = False
):
super().__init__(parent)
self.__baton_holder = None
@@ -76,25 +73,17 @@ class SampleQueuePanel(QFrame):
header = self.table_view.horizontalHeader()
header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.table_view.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.table_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.table_view.setAcceptDrops(True)
self.table_view.setDropIndicatorShown(True)
self.table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self.delete_shortcut = QShortcut(
QKeySequence.StandardKey.Delete, self.table_view
)
self.delete_shortcut = QShortcut(QKeySequence.StandardKey.Delete, self.table_view)
self.delete_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
self.delete_shortcut.activated.connect(self.remove_selected_samples)
self.run_pause_shortcut = QShortcut(
QKeySequence(Qt.Key.Key_Space), self.table_view
)
self.run_pause_shortcut.setContext(
Qt.ShortcutContext.WidgetWithChildrenShortcut
)
self.run_pause_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Space), self.table_view)
self.run_pause_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
self.run_pause_shortcut.activated.connect(self.run)
layout.addWidget(self.table_view)
@@ -109,9 +98,7 @@ class SampleQueuePanel(QFrame):
self.clear_button = QPushButton("✖ Clear list", self)
self.clear_button.clicked.connect(self.clear)
self.park_and_dry_when_cleared = QCheckBox(
"Park and dry when automation finishes", self
)
self.park_and_dry_when_cleared = QCheckBox("Park and dry when automation finishes", self)
self.park_and_dry_when_cleared.setChecked(True)
self.pause_on_conditions_cb = QCheckBox(
@@ -160,9 +147,7 @@ class SampleQueuePanel(QFrame):
next_next_sample = samples[2] if len(samples) > 2 else None
return current, next_sample, next_next_sample
def queue_samples(
self, samples: list[SampleShortInfo], replace: bool = True
) -> None:
def queue_samples(self, samples: list[SampleShortInfo], replace: bool = True) -> None:
if replace:
self.table_model.updateData(list(samples))
else:
@@ -229,11 +214,7 @@ class SampleQueuePanel(QFrame):
"""Beamline conditions that currently block/should pause automation."""
problems: list[str] = []
if self.ring_current is None or self.ring_current < LOW_CURRENT_THRESHOLD:
rc = (
"unknown"
if self.ring_current is None
else f"{round(self.ring_current, 2)} mA"
)
rc = "unknown" if self.ring_current is None else f"{round(self.ring_current, 2)} mA"
problems.append(f"beam (ring current {rc})")
if not self._experiment_shutter_state:
problems.append("experiment shutter closed")
@@ -263,9 +244,7 @@ class SampleQueuePanel(QFrame):
self.__warning_msg_box = QMessageBox(self)
self.__warning_msg_box.setIcon(QMessageBox.Icon.Warning)
self.__warning_msg_box.setWindowTitle("TELL Warning")
self.__warning_msg_box.setText(
"TELL reported a warning. Please see console for details."
)
self.__warning_msg_box.setText("TELL reported a warning. Please see console for details.")
self.__warning_msg_box.setInformativeText(
"Automation paused for 10 minutes to allow sufficient dry time."
"\nClick 'Continue Now' to resume immediately, or wait for auto-resume."
@@ -340,14 +319,11 @@ class SampleQueuePanel(QFrame):
return
if self.__busy:
logger.error(
f"Cannot run automation while beamline is busy. Busy flag = {self.__busy}"
)
logger.error(f"Cannot run automation while beamline is busy. Busy flag = {self.__busy}")
self.show_error_dialog(
title="Beamline is busy",
msg="Cannot run automation while beamline is busy",
info="Please wait until beamline is idle "
"or contact your local contact for support",
info="Please wait until beamline is idle or contact your local contact for support",
)
return
@@ -385,9 +361,7 @@ class SampleQueuePanel(QFrame):
if checks_enabled:
bad = self._bad_conditions()
if bad:
logger.warning(
f"Cannot start automation; beamline not ready: {bad}"
)
logger.warning(f"Cannot start automation; beamline not ready: {bad}")
self.show_error_dialog(
title="Beamline not ready",
msg="Cannot start automation:\n- " + "\n- ".join(bad),
@@ -450,9 +424,7 @@ class SampleQueuePanel(QFrame):
msg = "Automation paused — beamline not ready:\n- " + "\n- ".join(bad)
if conditions_auto_check(self, msg, self._conditions_ok):
logger.debug(
"Conditions recovered or user chose to continue; resuming automation"
)
logger.debug("Conditions recovered or user chose to continue; resuming automation")
self.table_model.set_running(True)
self.__pause = False
self.play_button.setText("⏸ Pause")
+5 -14
View File
@@ -103,13 +103,9 @@ class ScanSettingsPanel(QWidget):
self._register_override_field(self.transmission_enter)
self.reload_params_button = QPushButton("Reload DB params")
self.reload_params_button.setToolTip(
"Reload data collection parameters from database"
)
self.reload_params_button.setToolTip("Reload data collection parameters from database")
self.reload_params_button.clicked.connect(self.reload_parameters)
self.reload_params_button.setVisible(
False
) # Child classes should make it visible
self.reload_params_button.setVisible(False) # Child classes should make it visible
# -- source toggle -----------------------------------------------------
def _build_source_toggle(self) -> QWidget:
@@ -176,8 +172,7 @@ class ScanSettingsPanel(QWidget):
self._experiment_shutter_state = s.bl.exp_shutter_open
self._door_prohibited = getattr(s.bl, "pss_prohibited", None)
can_edit = (not s.busy) and (
s.session.session
in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou)
s.session.session in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou)
)
self._can_edit_params = can_edit
# Lock/unlock the override fields
@@ -187,9 +182,7 @@ class ScanSettingsPanel(QWidget):
# Update sample and parameters
if s.sample is not None:
self._sample = s.sample
self._params = (
s.sample.aaredb_params if hasattr(s.sample, "aaredb_params") else None
)
self._params = s.sample.aaredb_params if hasattr(s.sample, "aaredb_params") else None
# Enable reload button only if sample has parameters
self._previous_sample_was_none = False
self.reload_params_button.setEnabled(self._params is not None)
@@ -304,9 +297,7 @@ class ScanSettingsPanel(QWidget):
# Handle transmission with conversion (common to all panels)
if (transmission := getattr(self._params, "transmission", None)) is not None:
transmission_value = (
transmission / 100.0 if transmission > 1.0 else transmission
)
transmission_value = transmission / 100.0 if transmission > 1.0 else transmission
self._transmission = transmission_value
self.transmission_enter.set_db_value(transmission_value)
+3 -9
View File
@@ -45,9 +45,7 @@ class SmargonMoveWidget(QWidget):
@Slot(dict)
def smargon_button(self, payload: dict):
self.smargon_rel.emit(
Coordinate(x=payload["x"], y=payload["y"], z=payload["z"])
)
self.smargon_rel.emit(Coordinate(x=payload["x"], y=payload["y"], z=payload["z"]))
class SmargonPanel(QWidget):
@@ -96,9 +94,7 @@ class SmargonPanel(QWidget):
@Slot()
def home(self):
# TODO move SMARGON_HOME to REDIS, allow GUI to read this value
self.smargon.emit(
SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0)
)
self.smargon.emit(SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0))
@Slot(float)
def phi(self, f: float):
@@ -120,7 +116,5 @@ class SmargonPanel(QWidget):
return
self.smargon.emit(
SmargonCoordinate(
sh_mm=self.__geom.beamline_to_smargon(c * self.__step / 1000.0)
)
SmargonCoordinate(sh_mm=self.__geom.beamline_to_smargon(c * self.__step / 1000.0))
)
+48 -49
View File
@@ -150,8 +150,7 @@ class SmargonTracePanel(QWidget):
self._last_distances = []
self._last_lengths = []
self._status.setText(
"No trace file found. Tried: "
+ ", ".join(str(p) for p in self._candidate_paths())
"No trace file found. Tried: " + ", ".join(str(p) for p in self._candidate_paths())
)
self._summary.setText(
f"Home position: "
@@ -214,8 +213,7 @@ class SmargonTracePanel(QWidget):
dz = [value * unit_scale for value in dz_mm]
distances_mm = [
sqrt(dx0**2 + dy0**2 + dz0**2)
for dx0, dy0, dz0 in zip(dx_mm, dy_mm, dz_mm)
sqrt(dx0**2 + dy0**2 + dz0**2) for dx0, dy0, dz0 in zip(dx_mm, dy_mm, dz_mm)
]
distances = [value * unit_scale for value in distances_mm]
@@ -340,49 +338,49 @@ class SmargonTracePanel(QWidget):
unit_name, unit_scale = self._unit_settings()
file_path, _ = QFileDialog.getSaveFileName(
self,
"Export Smargon Trace Table",
"smargon_trace_export.csv",
"CSV Files (*.csv)",
self, "Export Smargon Trace Table", "smargon_trace_export.csv", "CSV Files (*.csv)"
)
if not file_path:
return
with open(file_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"point",
"timestamp",
"event",
"sample_id",
f"shx_{unit_name}",
f"shy_{unit_name}",
f"shz_{unit_name}",
f"distance_{unit_name}",
f"length_{unit_name}",
"omega_deg",
"phi_deg",
"chi_deg",
])
writer.writerow(
[
"point",
"timestamp",
"event",
"sample_id",
f"shx_{unit_name}",
f"shy_{unit_name}",
f"shz_{unit_name}",
f"distance_{unit_name}",
f"length_{unit_name}",
"omega_deg",
"phi_deg",
"chi_deg",
]
)
for idx, (row, distance_value, length_value) in enumerate(
zip(self._last_rows, self._last_distances, self._last_lengths),
start=1,
zip(self._last_rows, self._last_distances, self._last_lengths), start=1
):
writer.writerow([
idx,
row["timestamp"],
row["event"],
row["sample_id"],
float(row["shx_mm"]) * unit_scale,
float(row["shy_mm"]) * unit_scale,
float(row["shz_mm"]) * unit_scale,
distance_value,
length_value,
row["omega_deg"],
row["phi_deg"],
row["chi_deg"],
])
writer.writerow(
[
idx,
row["timestamp"],
row["event"],
row["sample_id"],
float(row["shx_mm"]) * unit_scale,
float(row["shy_mm"]) * unit_scale,
float(row["shz_mm"]) * unit_scale,
distance_value,
length_value,
row["omega_deg"],
row["phi_deg"],
row["chi_deg"],
]
)
def _redraw_plots(
self,
@@ -431,7 +429,13 @@ class SmargonTracePanel(QWidget):
ax.legend(loc="best")
elif plot_name == "distance":
ax.plot(x, distances, marker="o", color=self.COLOR_DISTANCE, label=f"Total distance [{unit_name}]")
ax.plot(
x,
distances,
marker="o",
color=self.COLOR_DISTANCE,
label=f"Total distance [{unit_name}]",
)
ax.set_title("Total distance from home")
ax.set_ylabel(f"Distance [{unit_name}]")
ax.grid(True, alpha=0.3)
@@ -451,9 +455,9 @@ class SmargonTracePanel(QWidget):
unit_name: str,
unit_scale: float,
) -> None:
recent_rows = rows[-self.TABLE_MAX_ROWS:]
recent_distances = distances[-self.TABLE_MAX_ROWS:]
recent_lengths = lengths[-self.TABLE_MAX_ROWS:]
recent_rows = rows[-self.TABLE_MAX_ROWS :]
recent_distances = distances[-self.TABLE_MAX_ROWS :]
recent_lengths = lengths[-self.TABLE_MAX_ROWS :]
self._table.setRowCount(len(recent_rows))
self._table.setHorizontalHeaderLabels(
@@ -580,12 +584,7 @@ class SmargonTracePanel(QWidget):
return sqrt(beam_x**2 + beam_y**2)
def _smargon_nudge_basis(
self,
*,
axis: str,
omega_deg: float,
phi_deg: float,
chi_deg: float,
self, *, axis: str, omega_deg: float, phi_deg: float, chi_deg: float
) -> np.ndarray:
phi = np.radians(np.around(phi_deg, decimals=1))
chi = np.radians(np.around(chi_deg, decimals=1))
@@ -637,4 +636,4 @@ class SmargonTracePanel(QWidget):
ax.set_xticks([])
ax.set_yticks([])
self._figure.tight_layout()
self._canvas.draw_idle()
self._canvas.draw_idle()
+18 -61
View File
@@ -4,14 +4,7 @@ from aarecommon.config.logger import setup_logger
from aarecommon.models.models import CrystalSize, DAQStatusModel, SimpleScanParameters
from aarecommon.models.rotation_scan import RotationScanRequest
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import (
QGridLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QWidget,
)
from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QSizePolicy, QSpacerItem, QWidget
from aare.gui.panels.rotation_data_collection import add_data_to_path
from aare.gui.widgets.number_line_edit import NumberLineEdit
@@ -52,17 +45,13 @@ class SimpleRotationSettingsPanel(QWidget):
# Visible resolution (entry)
self._layout.addWidget(QLabel("Visible resolution", parent=self), 0, 0)
self.visible_res_enter = NumberLineEdit(
0.8, 10.0, decimals=2, default=2.0, parent=self
)
self.visible_res_enter = NumberLineEdit(0.8, 10.0, decimals=2, default=2.0, parent=self)
self._layout.addWidget(self.visible_res_enter, 0, 1, 1, 3)
self._layout.addWidget(QLabel("Å", parent=self), 0, 4)
self.visible_res_enter.newValue.connect(self.set_visible_resolution)
self._layout.addWidget(QLabel("Start angle", parent=self), 1, 0)
self.start_angle_enter = NumberLineEdit(
-720, 720.0, 0.0, decimals=3, parent=self
)
self.start_angle_enter = NumberLineEdit(-720, 720.0, 0.0, decimals=3, parent=self)
self._layout.addWidget(self.start_angle_enter, 1, 1, 1, 3)
self._layout.addWidget(QLabel("°", parent=self), 1, 4)
@@ -81,17 +70,13 @@ class SimpleRotationSettingsPanel(QWidget):
self.visible_res_enter.newValue.connect(self.set_total_angle)
self._layout.addWidget(QLabel("Image angle", parent=self), 3, 0)
self.image_angle_enter = NumberLineEdit(
0.001, 1.000, decimals=3, default=0.2, parent=self
)
self.image_angle_enter = NumberLineEdit(0.001, 1.000, decimals=3, default=0.2, parent=self)
self._layout.addWidget(self.image_angle_enter, 3, 1, 1, 3)
self._layout.addWidget(QLabel("°", parent=self), 3, 4)
self.image_angle_enter.newValue.connect(self.set_image_angle)
self._layout.addWidget(QLabel("Temperature", parent=self), 4, 0)
self.temp_enter = NumberLineEdit(
80, 330, decimals=2, default=100.0, parent=self
)
self.temp_enter = NumberLineEdit(80, 330, decimals=2, default=100.0, parent=self)
self._layout.addWidget(self.temp_enter, 4, 1, 1, 3)
self._layout.addWidget(QLabel("K", parent=self), 4, 4)
self.temp_enter.newValue.connect(self.set_temperature)
@@ -123,9 +108,7 @@ class SimpleRotationSettingsPanel(QWidget):
self._layout.addWidget(QLabel("Detector distance", parent=self), 8, 0)
self.dtz_label = QLabel(f"--", parent=self)
self.dtz_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.dtz_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.dtz_label, 8, 1, 1, 3)
self._layout.addWidget(QLabel("mm", parent=self), 8, 4)
@@ -155,31 +138,23 @@ class SimpleRotationSettingsPanel(QWidget):
self._layout.addWidget(QLabel("Crystal Size x", parent=self), 12, 0)
self.xtal_x_label = QLabel(f"--", parent=self)
self.xtal_x_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.xtal_x_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_x_label, 12, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 12, 4)
self._layout.addWidget(QLabel("Crystal Size y", parent=self), 13, 0)
self.xtal_y_label = QLabel(f"--", parent=self)
self.xtal_y_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.xtal_y_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_y_label, 13, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 13, 4)
self._layout.addWidget(QLabel("Crystal Size z", parent=self), 14, 0)
self.xtal_z_label = QLabel(f"--", parent=self)
self.xtal_z_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.xtal_z_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_z_label, 14, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 14, 4)
self._layout.addWidget(
QLabel("Calculated Dose (xtal size)", parent=self), 15, 0
)
self._layout.addWidget(QLabel("Calculated Dose (xtal size)", parent=self), 15, 0)
self.xtal_size_dose_label = QLabel(f"--", parent=self)
self.xtal_size_dose_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
@@ -197,13 +172,9 @@ class SimpleRotationSettingsPanel(QWidget):
self._layout.addWidget(QLabel("Flux", parent=self), 17, 0)
self.flux_label = QLabel(f"--", parent=self)
self.flux_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.flux_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.flux_label, 17, 1, 1, 3)
self._layout.addWidget(
QLabel("x 10<sup>9</sup> ph s<sup>-1</sup>", parent=self), 17, 4
)
self._layout.addWidget(QLabel("x 10<sup>9</sup> ph s<sup>-1</sup>", parent=self), 17, 4)
self._layout.addWidget(QLabel("Beam Size", parent=self), 18, 0)
self.beam_size_label = QLabel(f"--", parent=self)
@@ -223,18 +194,12 @@ class SimpleRotationSettingsPanel(QWidget):
self._layout.addWidget(QLabel("Total measurement time", parent=self), 20, 0)
self.total_time = QLabel(f"{self.total_time_s} min 0 s")
self.total_time.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.total_time, 20, 1, 1, 3)
# add vertical stretch between detector distance and the run button
self._layout.addItem(
QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding),
21,
0,
1,
6,
QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 21, 0, 1, 6
)
# Run rotation button
@@ -283,9 +248,7 @@ class SimpleRotationSettingsPanel(QWidget):
if s.diffraction.wavelength_angstrom is None:
self.wavelength_label.setText("<b>N/A</b>")
else:
self.wavelength_label.setText(
f"<b>{s.diffraction.wavelength_angstrom:.3f}</b>"
)
self.wavelength_label.setText(f"<b>{s.diffraction.wavelength_angstrom:.3f}</b>")
self.update_calculated_labels()
@Slot(float)
@@ -330,9 +293,7 @@ class SimpleRotationSettingsPanel(QWidget):
total_angle = self.angular_range_enter.value
d_vis = self.visible_res_enter.value
image_angle = self.image_angle_enter.value
d_vis = (
d_vis - 0.2
) # additional fudge factor that weights more towards high_res
d_vis = d_vis - 0.2 # additional fudge factor that weights more towards high_res
if d_vis <= 0.0:
d_vis = 1.3
d_tar = 1 / (1 / d_vis + 0.1)
@@ -356,9 +317,7 @@ class SimpleRotationSettingsPanel(QWidget):
multiplier_1 = max(self.xtal_y, beam_size_um_y)
multiplier_2 = max(self.xtal_z, beam_size_um_y)
new_beam_um_y = math.sqrt(multiplier_1 * multiplier_2)
self.xtal_size_dose_rate_MGy_s = (
flux / (beam_size_um_x * new_beam_um_y * Kdose)
) / 1e6
self.xtal_size_dose_rate_MGy_s = (flux / (beam_size_um_x * new_beam_um_y * Kdose)) / 1e6
else:
self.xtal_size_dose_rate_MGy_s = self.dose_rate_MGy_s
@@ -398,9 +357,7 @@ class SimpleRotationSettingsPanel(QWidget):
if self.dtz <= 0.0:
self.dtz_label.setText(f"""<span style="color: red ; ">-</span>""")
elif self.dtz < self.__d.bl.dtz_min:
self.dtz_label.setText(
f"""<span style="color: red ; ">{self.dtz:.2f}</span>"""
)
self.dtz_label.setText(f"""<span style="color: red ; ">{self.dtz:.2f}</span>""")
self.dtz = self.__d.bl.dtz_min
else:
self.dtz_label.setText(f"{self.dtz:.2f}")
+49 -164
View File
@@ -215,52 +215,31 @@ class TargetStabilityPanel(QWidget):
self.help_button.clicked.connect(self._show_metrics_help)
self.score_basis_combo = QComboBox()
self.score_basis_combo.addItems(
[self.SCORE_FROM_STEP_XY, self.SCORE_FROM_SIGMA_XY]
)
self.score_basis_combo.addItems([self.SCORE_FROM_STEP_XY, self.SCORE_FROM_SIGMA_XY])
self.score_basis_combo.setCurrentText(self.SCORE_FROM_STEP_XY)
self.score_basis_combo.currentTextChanged.connect(self._on_score_basis_changed)
self.wheel_mode_combo = QComboBox()
self.wheel_mode_combo.addItems(
[
self.WHEEL_X,
self.WHEEL_LEFT_Y,
self.WHEEL_RIGHT_Y,
self.WHEEL_SCORE,
]
[self.WHEEL_X, self.WHEEL_LEFT_Y, self.WHEEL_RIGHT_Y, self.WHEEL_SCORE]
)
self.wheel_mode_combo.setCurrentText(self.WHEEL_X)
self.wheel_mode_combo.currentTextChanged.connect(
lambda _text: self._update_status_label()
)
self.wheel_mode_combo.currentTextChanged.connect(lambda _text: self._update_status_label())
self.pan_mode_combo = QComboBox()
self.pan_mode_combo.addItems(
[
self.PAN_ALL,
self.PAN_X,
self.PAN_LEFT_Y,
self.PAN_RIGHT_Y,
self.PAN_SCORE,
]
[self.PAN_ALL, self.PAN_X, self.PAN_LEFT_Y, self.PAN_RIGHT_Y, self.PAN_SCORE]
)
self.pan_mode_combo.setCurrentText(self.PAN_ALL)
self.pan_mode_combo.currentTextChanged.connect(
lambda _text: self._update_status_label()
)
self.pan_mode_combo.currentTextChanged.connect(lambda _text: self._update_status_label())
self.status_label = QLabel("Waiting for target-point data...")
self.status_label.setAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
self.status_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.metrics_label = QLabel(
"Target: (-, -) | Beam: (-, -) | Distance: - px | Std dev: - px"
)
self.metrics_label.setAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
self.metrics_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.metrics_label.setTextFormat(Qt.TextFormat.RichText)
self.metrics_label.setWordWrap(True)
@@ -346,9 +325,7 @@ class TargetStabilityPanel(QWidget):
self.chart.addSeries(series)
self.chart.legend().setVisible(True)
self.chart.setTitle(
"Target stability, distance, score, and step motion (last 60 s)"
)
self.chart.setTitle("Target stability, distance, score, and step motion (last 60 s)")
self.axis_x = QValueAxis()
self.axis_x.setTitleText("Time [s ago]")
@@ -371,12 +348,7 @@ class TargetStabilityPanel(QWidget):
self.chart.addAxis(self.axis_y_distance, Qt.AlignmentFlag.AlignRight)
self.chart.addAxis(self.axis_y_score, Qt.AlignmentFlag.AlignRight)
for series in (
self.series,
self.sigma_x_series,
self.sigma_y_series,
self.step_series,
):
for series in (self.series, self.sigma_x_series, self.sigma_y_series, self.step_series):
series.attachAxis(self.axis_x)
series.attachAxis(self.axis_y)
@@ -486,9 +458,7 @@ class TargetStabilityPanel(QWidget):
)
def _connect_series_selection(self, series: QLineSeries, target_name: str) -> None:
series.clicked.connect(
lambda _point, target=target_name: self._select_trace_target(target)
)
series.clicked.connect(lambda _point, target=target_name: self._select_trace_target(target))
def _try_select_series_at_point(self, view_pos: QPoint) -> bool:
"""Check if click is near a visible series point and select it. Returns True if found."""
@@ -545,9 +515,7 @@ class TargetStabilityPanel(QWidget):
def _set_paused(self, paused: bool) -> None:
self._paused = paused
if paused:
self.pause_button.setText(
"Resume (Live)" if self._collected_samples else "Resume"
)
self.pause_button.setText("Resume (Live)" if self._collected_samples else "Resume")
else:
self._unfreeze_plot()
self.pause_button.setText("Pause")
@@ -613,11 +581,7 @@ class TargetStabilityPanel(QWidget):
def _current_sigma_stats(self) -> dict[str, float]:
n = self._rolling_count
if n < 2:
return {
"std_dx": 0.0,
"std_dy": 0.0,
"sigma": 0.0,
}
return {"std_dx": 0.0, "std_dy": 0.0, "sigma": 0.0}
mean_dx = self._rolling_sum_dx / n
mean_dy = self._rolling_sum_dy / n
@@ -628,30 +592,16 @@ class TargetStabilityPanel(QWidget):
std_dy = math.sqrt(var_dy)
sigma = math.hypot(std_dx, std_dy)
return {
"std_dx": std_dx,
"std_dy": std_dy,
"sigma": sigma,
}
return {"std_dx": std_dx, "std_dy": std_dy, "sigma": sigma}
def _current_step_jitter_stats(self) -> dict[str, float]:
if self._rolling_step_count <= 0:
return {
"rms_step_dx": 0.0,
"rms_step_dy": 0.0,
"rms_step_xy": 0.0,
}
return {"rms_step_dx": 0.0, "rms_step_dy": 0.0, "rms_step_xy": 0.0}
return {
"rms_step_dx": math.sqrt(
self._rolling_step_dx2_sum / self._rolling_step_count
),
"rms_step_dy": math.sqrt(
self._rolling_step_dy2_sum / self._rolling_step_count
),
"rms_step_xy": math.sqrt(
self._rolling_step_xy2_sum / self._rolling_step_count
),
"rms_step_dx": math.sqrt(self._rolling_step_dx2_sum / self._rolling_step_count),
"rms_step_dy": math.sqrt(self._rolling_step_dy2_sum / self._rolling_step_count),
"rms_step_xy": math.sqrt(self._rolling_step_xy2_sum / self._rolling_step_count),
}
def _current_score_value(self) -> float:
@@ -664,10 +614,7 @@ class TargetStabilityPanel(QWidget):
def _update_metrics_label(self, force: bool = False) -> None:
now = time.monotonic()
if (
not force
and (now - self._last_metrics_update_ts) < self._metrics_update_interval_s
):
if not force and (now - self._last_metrics_update_ts) < self._metrics_update_interval_s:
return
self._last_metrics_update_ts = now
@@ -849,12 +796,7 @@ class TargetStabilityPanel(QWidget):
dy = ty - by
distance = math.hypot(dx, dy)
sample = {
"ts": ts,
"dx": dx,
"dy": dy,
"distance": distance,
}
sample = {"ts": ts, "dx": dx, "dy": dy, "distance": distance}
should_finish_collection = False
with self._data_lock:
@@ -1033,9 +975,7 @@ class TargetStabilityPanel(QWidget):
while self._plot_points and float(self._plot_points[0]["ts"]) < cutoff:
self._plot_points.popleft()
def _data_limits(
self,
) -> tuple[float, float, float, float, float, float, float, float]:
def _data_limits(self) -> tuple[float, float, float, float, float, float, float, float]:
left_values: list[float] = []
right_values: list[float] = []
@@ -1048,13 +988,7 @@ class TargetStabilityPanel(QWidget):
float(point["step_xy"]),
]
)
right_values.extend(
[
float(point["distance"]),
float(point["dx"]),
float(point["dy"]),
]
)
right_values.extend([float(point["distance"]), float(point["dx"]), float(point["dy"])])
left_min = min(left_values, default=0.0)
left_max = max(left_values, default=1.0)
@@ -1064,12 +998,8 @@ class TargetStabilityPanel(QWidget):
left_span = max(1.0, left_max - left_min)
right_span = max(1.0, right_max - right_min)
score_min = min(
(float(point["score"]) for point in self._plot_points), default=0.0
)
score_max = max(
(float(point["score"]) for point in self._plot_points), default=100.0
)
score_min = min((float(point["score"]) for point in self._plot_points), default=0.0)
score_max = max((float(point["score"]) for point in self._plot_points), default=100.0)
score_lo = max(0.0, score_min - 5.0)
score_hi = min(100.0, max(score_lo + 10.0, score_max + 5.0))
@@ -1140,9 +1070,7 @@ class TargetStabilityPanel(QWidget):
self._clamp_axes()
self._update_status_label()
def _pan_selected_axes(
self, dx_pixels: int, dy_pixels: int, width: int, height: int
) -> None:
def _pan_selected_axes(self, dx_pixels: int, dy_pixels: int, width: int, height: int) -> None:
if width <= 0 or height <= 0:
return
@@ -1166,9 +1094,7 @@ class TargetStabilityPanel(QWidget):
mode = self.pan_mode_combo.currentText()
if mode == self.PAN_X:
self.axis_x.setRange(
self.axis_x.min() + x_shift, self.axis_x.max() + x_shift
)
self.axis_x.setRange(self.axis_x.min() + x_shift, self.axis_x.max() + x_shift)
elif mode == self.PAN_LEFT_Y:
if (
@@ -1177,9 +1103,7 @@ class TargetStabilityPanel(QWidget):
or self.show_sigma_y_cb.isChecked()
or self.show_step_cb.isChecked()
):
self.axis_y.setRange(
self.axis_y.min() + y_shift, self.axis_y.max() + y_shift
)
self.axis_y.setRange(self.axis_y.min() + y_shift, self.axis_y.max() + y_shift)
elif mode == self.PAN_RIGHT_Y:
if (
@@ -1188,21 +1112,17 @@ class TargetStabilityPanel(QWidget):
or self.show_dy_cb.isChecked()
):
self.axis_y_distance.setRange(
self.axis_y_distance.min() + y2_shift,
self.axis_y_distance.max() + y2_shift,
self.axis_y_distance.min() + y2_shift, self.axis_y_distance.max() + y2_shift
)
elif mode == self.PAN_SCORE:
if self.show_score_cb.isChecked():
self.axis_y_score.setRange(
self.axis_y_score.min() + y3_shift,
self.axis_y_score.max() + y3_shift,
self.axis_y_score.min() + y3_shift, self.axis_y_score.max() + y3_shift
)
else:
self.axis_x.setRange(
self.axis_x.min() + x_shift, self.axis_x.max() + x_shift
)
self.axis_x.setRange(self.axis_x.min() + x_shift, self.axis_x.max() + x_shift)
if (
self.show_sigma_cb.isChecked()
@@ -1210,9 +1130,7 @@ class TargetStabilityPanel(QWidget):
or self.show_sigma_y_cb.isChecked()
or self.show_step_cb.isChecked()
):
self.axis_y.setRange(
self.axis_y.min() + y_shift, self.axis_y.max() + y_shift
)
self.axis_y.setRange(self.axis_y.min() + y_shift, self.axis_y.max() + y_shift)
if (
self.show_distance_cb.isChecked()
@@ -1220,14 +1138,12 @@ class TargetStabilityPanel(QWidget):
or self.show_dy_cb.isChecked()
):
self.axis_y_distance.setRange(
self.axis_y_distance.min() + y2_shift,
self.axis_y_distance.max() + y2_shift,
self.axis_y_distance.min() + y2_shift, self.axis_y_distance.max() + y2_shift
)
if self.show_score_cb.isChecked():
self.axis_y_score.setRange(
self.axis_y_score.min() + y3_shift,
self.axis_y_score.max() + y3_shift,
self.axis_y_score.min() + y3_shift, self.axis_y_score.max() + y3_shift
)
self._clamp_axes()
@@ -1273,11 +1189,7 @@ class TargetStabilityPanel(QWidget):
self._update_metrics_label()
return
if (
self._paused
and self._collecting_until is None
and self._frozen_plot_points is None
):
if self._paused and self._collecting_until is None and self._frozen_plot_points is None:
self._update_status_label()
self._update_metrics_label()
return
@@ -1292,36 +1204,28 @@ class TargetStabilityPanel(QWidget):
self._chart_dirty = False
sigma_points = [
QPointF(float(point["ts"]) - ref_time, float(point["sigma"]))
for point in plot_data
QPointF(float(point["ts"]) - ref_time, float(point["sigma"])) for point in plot_data
]
sigma_x_points = [
QPointF(float(point["ts"]) - ref_time, float(point["sigma_x"]))
for point in plot_data
QPointF(float(point["ts"]) - ref_time, float(point["sigma_x"])) for point in plot_data
]
sigma_y_points = [
QPointF(float(point["ts"]) - ref_time, float(point["sigma_y"]))
for point in plot_data
QPointF(float(point["ts"]) - ref_time, float(point["sigma_y"])) for point in plot_data
]
distance_points = [
QPointF(float(point["ts"]) - ref_time, float(point["distance"]))
for point in plot_data
QPointF(float(point["ts"]) - ref_time, float(point["distance"])) for point in plot_data
]
dx_points = [
QPointF(float(point["ts"]) - ref_time, float(point["dx"]))
for point in plot_data
QPointF(float(point["ts"]) - ref_time, float(point["dx"])) for point in plot_data
]
dy_points = [
QPointF(float(point["ts"]) - ref_time, float(point["dy"]))
for point in plot_data
QPointF(float(point["ts"]) - ref_time, float(point["dy"])) for point in plot_data
]
score_points = [
QPointF(float(point["ts"]) - ref_time, float(point["score"]))
for point in plot_data
QPointF(float(point["ts"]) - ref_time, float(point["score"])) for point in plot_data
]
step_points = [
QPointF(float(point["ts"]) - ref_time, float(point["step_xy"]))
for point in plot_data
QPointF(float(point["ts"]) - ref_time, float(point["step_xy"])) for point in plot_data
]
self.series.replace(sigma_points)
@@ -1334,16 +1238,9 @@ class TargetStabilityPanel(QWidget):
self.step_series.replace(step_points)
if self._auto_scale_enabled:
(
xmin,
xmax,
left_ymin,
left_ymax,
right_ymin,
right_ymax,
score_lo,
score_hi,
) = self._data_limits()
(xmin, xmax, left_ymin, left_ymax, right_ymin, right_ymax, score_lo, score_hi) = (
self._data_limits()
)
self.axis_x.setRange(xmin, xmax)
self.axis_y.setRange(left_ymin, left_ymax)
self.axis_y_distance.setRange(right_ymin, right_ymax)
@@ -1358,9 +1255,7 @@ class TargetStabilityPanel(QWidget):
seconds = float(self.seconds_spin.value())
now = time.monotonic()
rows = [s for s in self._samples if float(s["ts"]) >= now - seconds]
self._save_rows(
rows, suggested_name=f"target_stability_last_{self._seconds_text()}s.csv"
)
self._save_rows(rows, suggested_name=f"target_stability_last_{self._seconds_text()}s.csv")
def _collect_next_x_seconds(self) -> None:
if self._paused:
@@ -1413,14 +1308,8 @@ class TargetStabilityPanel(QWidget):
if temp_rolling_count >= 2:
mean_dx = temp_rolling_sum_dx / temp_rolling_count
mean_dy = temp_rolling_sum_dy / temp_rolling_count
var_dx = max(
0.0,
(temp_rolling_sum_dx2 / temp_rolling_count) - (mean_dx * mean_dx),
)
var_dy = max(
0.0,
(temp_rolling_sum_dy2 / temp_rolling_count) - (mean_dy * mean_dy),
)
var_dx = max(0.0, (temp_rolling_sum_dx2 / temp_rolling_count) - (mean_dx * mean_dx))
var_dy = max(0.0, (temp_rolling_sum_dy2 / temp_rolling_count) - (mean_dy * mean_dy))
std_dx = math.sqrt(var_dx)
std_dy = math.sqrt(var_dy)
sigma = math.hypot(std_dx, std_dy)
@@ -1474,8 +1363,7 @@ class TargetStabilityPanel(QWidget):
# This avoids blocking while any locks might be held
file_name = f"target_stability_collected_{self._seconds_text()}s.csv"
QTimer.singleShot(
0,
lambda: self._save_rows(self._collected_samples, suggested_name=file_name),
0, lambda: self._save_rows(self._collected_samples, suggested_name=file_name)
)
self._update_status_label()
@@ -1491,10 +1379,7 @@ class TargetStabilityPanel(QWidget):
return
path, _ = QFileDialog.getSaveFileName(
self,
"Save Target Stability Data",
suggested_name,
"CSV files (*.csv)",
self, "Save Target Stability Data", suggested_name, "CSV files (*.csv)"
)
if not path:
return
+7 -21
View File
@@ -59,19 +59,13 @@ class TellSamplePanel(QFrame):
self.table_model = UserSampleSpreadsheet(samples=samples.s)
self.table_view.setModel(self.table_model)
self.table_view.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
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.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.table_view.setEditTriggers(QTableView.EditTrigger.NoEditTriggers)
self.table_view.setDragEnabled(True)
@@ -187,9 +181,7 @@ class TellSamplePanel(QFrame):
toggle_all.setChecked(self.table_model.show_all_pgroups)
def _toggle_all():
self.table_model.set_show_all_pgroups(
not self.table_model.show_all_pgroups
)
self.table_model.set_show_all_pgroups(not self.table_model.show_all_pgroups)
toggle_all.triggered.connect(_toggle_all)
@@ -200,9 +192,7 @@ class TellSamplePanel(QFrame):
if action == set_filter_action:
from PySide6.QtWidgets import QInputDialog
text, ok = QInputDialog.getText(
self, "Set filter", f"Filter for '{col_name}':"
)
text, ok = QInputDialog.getText(self, "Set filter", f"Filter for '{col_name}':")
if ok:
self.table_model.set_column_filter(logical_index, text)
elif action == clear_filter_action:
@@ -218,9 +208,7 @@ class TellSamplePanel(QFrame):
tell_details = ""
if tell_state is not None:
activity = tell_state.activity.display_name()
phase = (
tell_state.phase.display_name() if tell_state.phase is not None else ""
)
phase = tell_state.phase.display_name() if tell_state.phase is not None else ""
message = (tell_state.message or "").strip()
tell_parts = [activity]
@@ -237,9 +225,7 @@ class TellSamplePanel(QFrame):
else:
try:
if sample.location is None:
base_text = (
f"Current sample: <b>{sample.sample_name} (Manual mount)</b>"
)
base_text = f"Current sample: <b>{sample.sample_name} (Manual mount)</b>"
else:
base_text = (
f"Current sample: <b>{sample.sample_name} "
+1 -3
View File
@@ -43,8 +43,6 @@ class ZoomPanel(QWidget):
self.__zoom = status.bl.zoom
for z in self.__zoom_settings:
if z["value"] == self.__zoom:
self.__buttons[self.__zoom_settings.index(z)].setStyleSheet(
"font-weight: bold;"
)
self.__buttons[self.__zoom_settings.index(z)].setStyleSheet("font-weight: bold;")
else:
self.__buttons[self.__zoom_settings.index(z)].setStyleSheet("")
+32 -113
View File
@@ -136,9 +136,7 @@ class RasterGridManager(QObject):
n_x=0,
n_y=0,
smargon_top_left=self.__geom.smargon,
grid_size_mm=Coordinate(
x=0.8 * self.__beam_size_mm.x, y=0.8 * self.__beam_size_mm.y
),
grid_size_mm=Coordinate(x=0.8 * self.__beam_size_mm.x, y=0.8 * self.__beam_size_mm.y),
omega_deg=self.__geom.omega_deg,
exp_time_s=0.02,
transmission=1.0,
@@ -150,9 +148,7 @@ class RasterGridManager(QObject):
# Each entry is (QImage, backing ndarray); the ndarray must be kept alive
# because QImage shares its buffer without copying. Rebuilt only when the
# data or metric changes, not on every repaint (sample move / zoom).
self.__heatmap_cache: dict[
tuple[int, "RasterGridMetric"], tuple[QImage, np.ndarray]
] = {}
self.__heatmap_cache: dict[tuple[int, "RasterGridMetric"], tuple[QImage, np.ndarray]] = {}
@property
def active_grid(self) -> RasterGridRequest:
@@ -194,12 +190,7 @@ class RasterGridManager(QObject):
return None
start_x, start_y, cell_w, cell_h = geo
return QRectF(
start_x,
start_y,
cell_w * grid.n_x,
cell_h * grid.n_y,
)
return QRectF(start_x, start_y, cell_w * grid.n_x, cell_h * grid.n_y)
def _visible_index_range(
self,
@@ -214,13 +205,9 @@ class RasterGridManager(QObject):
return 0, grid.n_x, 0, grid.n_y
min_x = max(0, int(math.floor((visible_rect.left() - start_x) / cell_w)) - 1)
max_x = min(
grid.n_x, int(math.ceil((visible_rect.right() - start_x) / cell_w)) + 1
)
max_x = min(grid.n_x, int(math.ceil((visible_rect.right() - start_x) / cell_w)) + 1)
min_y = max(0, int(math.floor((visible_rect.top() - start_y) / cell_h)) - 1)
max_y = min(
grid.n_y, int(math.ceil((visible_rect.bottom() - start_y) / cell_h)) + 1
)
max_y = min(grid.n_y, int(math.ceil((visible_rect.bottom() - start_y) / cell_h)) + 1)
return min_x, max_x, min_y, max_y
@@ -246,9 +233,7 @@ class RasterGridManager(QObject):
self.clear_active_grid()
if self.__beam_size_mm != s.geom.beam_size_mm:
self.__beam_size_mm = s.geom.beam_size_mm
self.update_grid_size(
0.8 * self.__beam_size_mm.x, 0.8 * self.__beam_size_mm.y
)
self.update_grid_size(0.8 * self.__beam_size_mm.x, 0.8 * self.__beam_size_mm.y)
self.__geom = s.geom
def resize_active_grid(self, end_point: QPointF):
@@ -306,15 +291,10 @@ class RasterGridManager(QObject):
self.__active_grid.n_x = 0
self.__active_grid.n_y = 0
self.grid_scan_size_changed.emit(
0,
0,
self.__active_grid.grid_size_mm.x,
self.__active_grid.grid_size_mm.y,
0, 0, self.__active_grid.grid_size_mm.x, self.__active_grid.grid_size_mm.y
)
def get_grid_coord(
self, grid: RasterGridRequest, point: QPointF
) -> Tuple[int, int]:
def get_grid_coord(self, grid: RasterGridRequest, point: QPointF) -> Tuple[int, int]:
point_bl = self.__geom.picture_to_sample(Coordinate(x=point.x(), y=point.y()))
delta = point_bl - self.__geom.smargon_to_beamline(grid.smargon_top_left.sh_mm)
@@ -363,9 +343,7 @@ class RasterGridManager(QObject):
delta = point_bl - self.__geom.smargon_to_beamline(
self.__active_grid.smargon_top_left.sh_mm
)
return (
0 <= delta.x < self.__active_grid.n_x * self.__active_grid.grid_size_mm.x
) and (
return (0 <= delta.x < self.__active_grid.n_x * self.__active_grid.grid_size_mm.x) and (
0 <= delta.y < self.__active_grid.n_y * self.__active_grid.grid_size_mm.y
)
@@ -432,9 +410,7 @@ class RasterGridManager(QObject):
grid.result.images[cell].pr is not None
and grid.result.images[cell].pr >= 0
):
txt += (
f"Profile Radius {grid.result.images[cell].pr:.2f}<br/>"
)
txt += f"Profile Radius {grid.result.images[cell].pr:.2f}<br/>"
if (
grid.result.images[cell].nx is not None
and grid.result.images[cell].ny is not None
@@ -537,15 +513,11 @@ class RasterGridManager(QObject):
case RasterGridMetric.INDEXING:
v = [obj.index for obj in i.result.images]
case RasterGridMetric.PR:
v = [
obj.index / max(obj.spots_low_res, 1) for obj in i.result.images
]
v = [obj.index / max(obj.spots_low_res, 1) for obj in i.result.images]
case RasterGridMetric.RASTER_SCORE:
score = compute_crystal_score_array(i.result.images)
v = [
score[obj.nx, obj.ny]
if obj.nx is not None and obj.ny is not None
else None
score[obj.nx, obj.ny] if obj.nx is not None and obj.ny is not None else None
for obj in i.result.images
]
case RasterGridMetric.BFACTOR:
@@ -558,9 +530,7 @@ class RasterGridManager(QObject):
v = [self.spot_ice_ratio(obj) for obj in i.result.images]
case RasterGridMetric.SPOTS_INDEXED:
v = [obj.spots_indexed for obj in i.result.images]
self._draw_completed_heatmap(
painter, i, v, alpha, visible_rect, (id(i), self.__metric)
)
self._draw_completed_heatmap(painter, i, v, alpha, visible_rect, (id(i), self.__metric))
def _invalidate_heatmap_cache(self) -> None:
"""Drop all cached heatmap bitmaps. Call whenever the completed-grid data
@@ -568,10 +538,7 @@ class RasterGridManager(QObject):
self.__heatmap_cache.clear()
def _heatmap_image(
self,
cache_key: tuple,
grid: RasterGridRequest,
values: List[float] | List[int],
self, cache_key: tuple, grid: RasterGridRequest, values: List[float] | List[int]
) -> QImage | None:
"""Return a cached n_x*n_y heatmap bitmap for this grid, building it once
on a cache miss. One pixel per cell; colours baked at full opacity with
@@ -589,10 +556,7 @@ class RasterGridManager(QObject):
return built[0]
def _build_heatmap_image(
self,
n_x: int,
n_y: int,
values: List[float] | List[int],
self, n_x: int, n_y: int, values: List[float] | List[int]
) -> tuple[QImage, np.ndarray] | None:
if n_x <= 0 or n_y <= 0:
return None
@@ -601,10 +565,7 @@ class RasterGridManager(QObject):
# Cell values are laid out row-major: idx = x + y * n_x (matches the
# per-cell loop and load_image()/get_grid_coord() hit-testing).
flat = np.array(
[np.nan if v is None else v for v in values[:count]],
dtype=np.float64,
)
flat = np.array([np.nan if v is None else v for v in values[:count]], dtype=np.float64)
if flat.size < count:
flat = np.concatenate([flat, np.full(count - flat.size, np.nan)])
@@ -673,9 +634,7 @@ class RasterGridManager(QObject):
painter.drawImage(bounds, image)
painter.setOpacity(1.0)
painter.setPen(
QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)
)
painter.setPen(QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(bounds)
painter.restore()
@@ -715,9 +674,7 @@ class RasterGridManager(QObject):
return
if values is not None and (cell_w < 3.0 or cell_h < 3.0):
if self._draw_completed_grid_fast(
painter, grid, values, alpha, visible_rect
):
if self._draw_completed_grid_fast(painter, grid, values, alpha, visible_rect):
return
painter.save()
@@ -725,12 +682,8 @@ class RasterGridManager(QObject):
if values is not None:
painter.setPen(Qt.PenStyle.NoPen)
min_value = min(
(x for x in values if x is not None and not math.isnan(x)), default=0
)
max_value = max(
(x for x in values if x is not None and not math.isnan(x)), default=1
)
min_value = min((x for x in values if x is not None and not math.isnan(x)), default=0)
max_value = max((x for x in values if x is not None and not math.isnan(x)), default=1)
diff = 1 if min_value == max_value else (max_value - min_value)
else:
painter.setPen(QPen(QColor(114, 159, 207), 1, Qt.PenStyle.SolidLine))
@@ -749,9 +702,7 @@ class RasterGridManager(QObject):
if values is None:
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(
QRect(round(px), round(py), round(cell_w), round(cell_h))
)
painter.drawRect(QRect(round(px), round(py), round(cell_w), round(cell_h)))
continue
idx = x + y * grid.n_x
@@ -763,22 +714,14 @@ class RasterGridManager(QObject):
):
continue
brush = float_to_viridis_brush(
(values[idx] - min_value) / diff, alpha=alpha
)
painter.fillRect(
QRectF(px, py, max(1.0, cell_w), max(1.0, cell_h)), brush
)
brush = float_to_viridis_brush((values[idx] - min_value) / diff, alpha=alpha)
painter.fillRect(QRectF(px, py, max(1.0, cell_w), max(1.0, cell_h)), brush)
if values is None:
painter.setBrush(Qt.BrushStyle.NoBrush)
else:
painter.setPen(
QPen(
QColor(114, 159, 207, min(255, alpha + 40)),
1,
Qt.PenStyle.SolidLine,
)
QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)
)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(bounds)
@@ -786,10 +729,7 @@ class RasterGridManager(QObject):
painter.restore()
def _draw_active_grid_fast(
self,
painter: QPainter,
grid: RasterGridRequest,
visible_rect: QRectF | None,
self, painter: QPainter, grid: RasterGridRequest, visible_rect: QRectF | None
) -> bool:
geo = self._grid_pixel_geometry(grid)
if geo is None:
@@ -869,9 +809,7 @@ class RasterGridManager(QObject):
):
return True
valid_values = [
x for x in values if x is not None and not math.isnan(x) and x >= 0
]
valid_values = [x for x in values if x is not None and not math.isnan(x) and x >= 0]
min_value = min(valid_values, default=0)
max_value = max(valid_values, default=1)
diff = 1 if min_value == max_value else (max_value - min_value)
@@ -897,12 +835,7 @@ class RasterGridManager(QObject):
draw_w = max(1.0, cell_w * stride_x)
value = self._block_value(
values,
grid.n_x,
x,
min(x + stride_x, grid.n_x),
y,
min(y + stride_y, grid.n_y),
values, grid.n_x, x, min(x + stride_x, grid.n_x), y, min(y + stride_y, grid.n_y)
)
if value is None:
continue
@@ -910,9 +843,7 @@ class RasterGridManager(QObject):
brush = float_to_viridis_brush((value - min_value) / diff, alpha=alpha)
painter.fillRect(QRectF(px, py, draw_w, draw_h), brush)
painter.setPen(
QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)
)
painter.setPen(QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(bounds)
@@ -920,13 +851,7 @@ class RasterGridManager(QObject):
return True
def _block_value(
self,
values: List[float] | List[int],
grid_nx: int,
x0: int,
x1: int,
y0: int,
y1: int,
self, values: List[float] | List[int], grid_nx: int, x0: int, x1: int, y0: int, y1: int
) -> float | None:
best = None
for y in range(y0, y1):
@@ -1004,12 +929,8 @@ class RasterGridManager(QObject):
self.omega.emit(self.__completed_grids[row].request.omega_deg)
self.smargon.emit(
SmargonCoordinate(
phi_deg=self.__completed_grids[
row
].request.smargon_top_left.phi_deg,
chi_deg=self.__completed_grids[
row
].request.smargon_top_left.chi_deg,
phi_deg=self.__completed_grids[row].request.smargon_top_left.phi_deg,
chi_deg=self.__completed_grids[row].request.smargon_top_left.chi_deg,
)
)
@@ -1019,9 +940,7 @@ class RasterGridManager(QObject):
src = self.__completed_grids[row].request
self.__active_grid.n_x = src.n_x
self.__active_grid.n_y = src.n_y
self.__active_grid.grid_size_mm = Coordinate(
x=src.grid_size_mm.x, y=src.grid_size_mm.y
)
self.__active_grid.grid_size_mm = Coordinate(x=src.grid_size_mm.x, y=src.grid_size_mm.y)
self.__active_grid.smargon_top_left = SmargonCoordinate(
sh_mm=Coordinate(
x=src.smargon_top_left.sh_mm.x,
+1 -1
View File
@@ -490,4 +490,4 @@ def _portrait_stylesheet() -> str:
QWidget#portraitRoot QScrollBar::sub-line:vertical {
height: 0px;
}
"""
"""
+12 -9
View File
@@ -41,13 +41,14 @@ class VideoThread(QThread):
if response.status_code != 200:
self.error_occurred.emit(
f"Could not connect to camera at {self.camera_ip}. Status: {response.status_code}")
f"Could not connect to camera at {self.camera_ip}. Status: {response.status_code}"
)
return
self.running = True
# Buffer to accumulate data
buffer = b''
buffer = b""
# MJPEG boundary detection
boundary = None
@@ -61,9 +62,9 @@ class VideoThread(QThread):
# Find boundary on first iteration
if boundary is None:
boundary_start = buffer.find(b'--')
boundary_start = buffer.find(b"--")
if boundary_start != -1:
boundary_end = buffer.find(b'\r\n', boundary_start)
boundary_end = buffer.find(b"\r\n", boundary_start)
if boundary_end != -1:
boundary = buffer[boundary_start:boundary_end]
@@ -97,17 +98,17 @@ class VideoThread(QThread):
continue
# Find the start of JPEG data (after headers)
jpeg_start = part.find(b'\xff\xd8') # JPEG SOI marker
jpeg_start = part.find(b"\xff\xd8") # JPEG SOI marker
if jpeg_start == -1:
continue
# Find the end of JPEG data
jpeg_end = part.find(b'\xff\xd9', jpeg_start) # JPEG EOI marker
jpeg_end = part.find(b"\xff\xd9", jpeg_start) # JPEG EOI marker
if jpeg_end == -1:
continue
# Extract JPEG data
jpeg_data = part[jpeg_start:jpeg_end + 2]
jpeg_data = part[jpeg_start : jpeg_end + 2]
try:
# Decode JPEG using OpenCV
@@ -121,7 +122,9 @@ class VideoThread(QThread):
# Convert to QImage
height, width, channel = rgb_frame.shape
bytes_per_line = 3 * width
qt_image = QImage(rgb_frame.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)
qt_image = QImage(
rgb_frame.data, width, height, bytes_per_line, QImage.Format.Format_RGB888
)
# Store as last good frame
self.last_good_frame = qt_image
@@ -153,4 +156,4 @@ class VideoThread(QThread):
try:
self.wait(5000)
except Exception:
pass
pass
+6 -18
View File
@@ -107,8 +107,7 @@ class SampleCameraThread(QThread):
bgr = cv2.imdecode(encoded, cv2.IMREAD_COLOR)
if bgr is None:
self.__set_camera_available(
False,
"Sample camera feed unavailable: failed to decode JPEG frame",
False, "Sample camera feed unavailable: failed to decode JPEG frame"
)
continue
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
@@ -127,10 +126,7 @@ class SampleCameraThread(QThread):
if self.__measure_focus:
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
if (
self.__focus_mask is None
or self.__focus_mask.shape != gray.shape
):
if self.__focus_mask is None or self.__focus_mask.shape != gray.shape:
height, width = gray.shape
y, x = np.ogrid[:height, :width]
self.__focus_mask = (x - self.__beam_x) ** 2 + (
@@ -141,16 +137,12 @@ class SampleCameraThread(QThread):
self.focus_measure.emit(sharpness)
qimage = QImage(
rgb.data,
rgb.shape[1],
rgb.shape[0],
QImage.Format.Format_RGB888,
rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888
).copy()
self.camera_image.emit(QPixmap.fromImage(qimage))
else:
self.__set_camera_available(
False,
"Sample camera feed unavailable: no frame header in zmq stream",
False, "Sample camera feed unavailable: no frame header in zmq stream"
)
except zmq.Again: # Timeout occurred
now = time.perf_counter()
@@ -165,14 +157,10 @@ class SampleCameraThread(QThread):
self.__fps_frame_count = 0
if no_frames_long:
self.__set_camera_available(
False, "Sample camera feed unavailable"
)
self.__set_camera_available(False, "Sample camera feed unavailable")
continue # Check self.running again
except Exception as e:
self.__set_camera_available(
False, f"Sample camera feed unavailable: {e}"
)
self.__set_camera_available(False, f"Sample camera feed unavailable: {e}")
self.running = False
def stop(self):
+66 -200
View File
@@ -48,12 +48,7 @@ from aarecommon.recurrence_watcher import (
)
from jfjoch_client import ScanResult, ScanResultImagesInner
from PySide6.QtCore import QByteArray, QObject, QTimer, QUrl, Signal, Slot
from PySide6.QtNetwork import (
QNetworkAccessManager,
QNetworkReply,
QNetworkRequest,
QSslError,
)
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest, QSslError
logger = setup_logger("aareGUI")
@@ -168,10 +163,7 @@ class DAQWorker(QObject):
self._device_error_log_min_interval_s = 10.0
self._last_device_error_log_ts: dict[str, float] = {"tell": 0.0, "smargon": 0.0}
self._last_device_error_log_key: dict[str, str | None] = {
"tell": None,
"smargon": None,
}
self._last_device_error_log_key: dict[str, str | None] = {"tell": None, "smargon": None}
self._last_status_error = None
self._smargon_error_active = False
@@ -256,9 +248,7 @@ class DAQWorker(QObject):
self._last_detector_is_error = is_error
self.detector_error.emit(msg, is_error)
def _emit_status_if_changed(
self, key: str | None, message: str | None, is_error: bool
) -> None:
def _emit_status_if_changed(self, key: str | None, message: str | None, is_error: bool) -> None:
"""
Emit polled device status to the primary alert banner.
Used for Server/Tell/Smargon/Aerotech connection status.
@@ -428,11 +418,7 @@ class DAQWorker(QObject):
if disconnected:
if len(disconnected) == 3:
return (
"all-devices-down",
"TELL, Smargon, and Aerotech disconnected.",
True,
)
return ("all-devices-down", "TELL, Smargon, and Aerotech disconnected.", True)
if len(disconnected) == 2:
return (
"+".join(sorted(d.lower() for d in disconnected)) + "-down",
@@ -440,11 +426,7 @@ class DAQWorker(QObject):
True,
)
device = disconnected[0]
return (
f"{device.lower()}-down",
f"{device} disconnected.",
True,
)
return (f"{device.lower()}-down", f"{device} disconnected.", True)
if restored:
if len(restored) == 3:
@@ -468,9 +450,7 @@ class DAQWorker(QObject):
parsed_response = DAQStatusModel.model_validate_json(response_data)
self.update.emit(parsed_response)
pss_alarm = bool(
getattr(getattr(parsed_response, "bl", None), "pss_alarm", False)
)
pss_alarm = bool(getattr(getattr(parsed_response, "bl", None), "pss_alarm", False))
if pss_alarm != self._last_pss_alarm:
self._last_pss_alarm = pss_alarm
self.pss_alarm_changed.emit(pss_alarm)
@@ -499,9 +479,7 @@ class DAQWorker(QObject):
tell_err_text = None if tell_err is None else str(tell_err).strip()
smargon_err_text = None if smargon_err is None else str(smargon_err).strip()
aerotech_err_text = (
None if aerotech_err is None else str(aerotech_err).strip()
)
aerotech_err_text = None if aerotech_err is None else str(aerotech_err).strip()
# Skip device status processing if we just reconnected from server down
# (we already showed "Server reconnected")
@@ -520,8 +498,7 @@ class DAQWorker(QObject):
return
tell_changed = (
self._last_tell_connected != tell_conn
or self._last_tell_error != tell_err_text
self._last_tell_connected != tell_conn or self._last_tell_error != tell_err_text
)
smargon_changed = (
self._last_smargon_connected != smargon_conn
@@ -554,27 +531,19 @@ class DAQWorker(QObject):
if not smargon_conn:
self._log_device_error_throttled(device="smargon", message=smargon_err)
if not aerotech_conn:
self._log_device_error_throttled(
device="aerotech", message=aerotech_err
)
self._log_device_error_throttled(device="aerotech", message=aerotech_err)
except Exception as e:
status = None
err_msg = str(e)
try:
status = reply.attribute(
QNetworkRequest.Attribute.HttpStatusCodeAttribute
)
status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
raw_body = reply.readAll().data().decode("utf-8")
if raw_body:
body_json = json.loads(raw_body)
if isinstance(body_json, dict):
err_msg = (
body_json.get("message")
or body_json.get("detail")
or err_msg
)
err_msg = body_json.get("message") or body_json.get("detail") or err_msg
if body_json.get("code") == "AEROTECH_UNAVAILABLE":
extra = body_json.get("extra") or {}
if isinstance(extra, dict):
@@ -594,18 +563,14 @@ class DAQWorker(QObject):
self._has_seen_disconnection = True
self._server_was_disconnected = True
self._emit_status_if_changed(
"server-down",
f"Aerotech unavailable: {err_msg}",
True,
"server-down", f"Aerotech unavailable: {err_msg}", True
)
else:
if self._server_connected is not False:
self._has_seen_disconnection = True
self._server_was_disconnected = True
self._emit_status_if_changed(
"server-down",
"Server disconnected. Reconnecting...",
True,
"server-down", "Server disconnected. Reconnecting...", True
)
self._server_connected = False
@@ -686,9 +651,7 @@ class DAQWorker(QObject):
else:
logger.error(f"{error_info.message}")
title = self._operation_error_title(error_info.exception_class)
self.operation_failed.emit(
title, error_info.message, error_info.critical
)
self.operation_failed.emit(title, error_info.message, error_info.critical)
reply.deleteLater()
@@ -732,9 +695,7 @@ class DAQWorker(QObject):
logger.error(f"Hardware metadata resync failed: {e}")
self.http_error.emit(str(e))
def _handle_recovery_action_response(
self, reply: QNetworkReply, default_message: str
):
def _handle_recovery_action_response(self, reply: QNetworkReply, default_message: str):
try:
response_data = self.handle_response(reply)
payload = json.loads(response_data) if response_data else {}
@@ -905,9 +866,7 @@ class DAQWorker(QObject):
body = json.dumps({"confirmation_code": confirmation_code})
reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8")))
reply.finished.connect(
lambda: self._handle_recovery_action_response(
reply, "Beamline busy flag cleared."
)
lambda: self._handle_recovery_action_response(reply, "Beamline busy flag cleared.")
)
@Slot(str)
@@ -922,9 +881,7 @@ class DAQWorker(QObject):
body = json.dumps({"confirmation_code": confirmation_code})
reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8")))
reply.finished.connect(
lambda: self._handle_recovery_action_response(
reply, "Beamline session taken over."
)
lambda: self._handle_recovery_action_response(reply, "Beamline session taken over.")
)
@Slot(str)
@@ -956,9 +913,7 @@ class DAQWorker(QObject):
body = json.dumps({"confirmation_code": confirmation_code})
reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8")))
reply.finished.connect(
lambda: self._handle_recovery_action_response(
reply, "Recovery unmount completed."
)
lambda: self._handle_recovery_action_response(reply, "Recovery unmount completed.")
)
@Slot(str)
@@ -1022,13 +977,8 @@ class DAQWorker(QObject):
if self._is_detector_state_failure_message(err_msg):
self._emit_detector_message(f"JFJoch: {err_msg}", is_error=True)
else:
code = (
body_json.get("code", "") if isinstance(body_json, dict) else ""
)
if (
code == AareErrorCode.JF_JOCH_COMMUNICATION_ERROR
or status == 503
):
code = body_json.get("code", "") if isinstance(body_json, dict) else ""
if code == AareErrorCode.JF_JOCH_COMMUNICATION_ERROR or status == 503:
self._emit_detector_message(f"JFJoch: {err_msg}", is_error=True)
if self._is_critical_detector_failure(status, body_json, err_msg):
@@ -1038,15 +988,12 @@ class DAQWorker(QObject):
f"Details: {err_msg}"
)
self._emit_detector_message(
f"Detector error during manual collection: {err_msg}",
is_error=True,
f"Detector error during manual collection: {err_msg}", is_error=True
)
self._emit_manual_collection_critical_failure(critical_msg)
short_msg = (
err_msg.split("input':", 1)[0].strip()
if "input':" in err_msg
else err_msg
err_msg.split("input':", 1)[0].strip() if "input':" in err_msg else err_msg
)
logger.error(f"Rotation scan failed: {short_msg}")
self.http_error.emit(short_msg)
@@ -1088,13 +1035,8 @@ class DAQWorker(QObject):
if self._is_detector_state_failure_message(err_msg):
self._emit_detector_message(f"JFJoch: {err_msg}", is_error=True)
else:
code = (
body_json.get("code", "") if isinstance(body_json, dict) else ""
)
if (
code == AareErrorCode.JF_JOCH_COMMUNICATION_ERROR
or status == 503
):
code = body_json.get("code", "") if isinstance(body_json, dict) else ""
if code == AareErrorCode.JF_JOCH_COMMUNICATION_ERROR or status == 503:
self._emit_detector_message(f"JFJoch: {err_msg}", is_error=True)
if self._is_critical_detector_failure(status, body_json, err_msg):
@@ -1104,8 +1046,7 @@ class DAQWorker(QObject):
f"Details: {err_msg}"
)
self._emit_detector_message(
f"Detector error during manual collection: {err_msg}",
is_error=True,
f"Detector error during manual collection: {err_msg}", is_error=True
)
self._emit_manual_collection_critical_failure(critical_msg)
@@ -1159,9 +1100,7 @@ class DAQWorker(QObject):
self.raster_scan_completed.emit(reply)
return
request = QNetworkRequest(
QUrl(f"{self.__base_url}/scan/raster?auto_center=false")
)
request = QNetworkRequest(QUrl(f"{self.__base_url}/scan/raster?auto_center=false"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
body = r.model_dump_json()
@@ -1203,9 +1142,7 @@ class DAQWorker(QObject):
self.raster_scan_completed.emit(reply)
return
request = QNetworkRequest(
QUrl(f"{self.__base_url}/scan/raster?auto_center=true")
)
request = QNetworkRequest(QUrl(f"{self.__base_url}/scan/raster?auto_center=true"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
body = r.model_dump_json()
@@ -1256,9 +1193,7 @@ class DAQWorker(QObject):
for watcher in self._recurrence_watchers:
watcher.reset()
def _observe_recurrence(
self, exception_class_name: str | None
) -> WatcherTrip | None:
def _observe_recurrence(self, exception_class_name: str | None) -> WatcherTrip | None:
exception_class = resolve_exception_class(exception_class_name)
for watcher in self._recurrence_watchers:
trip = watcher.maybe_trip(exception_class)
@@ -1273,9 +1208,7 @@ class DAQWorker(QObject):
self.automation_critical_failure.emit(message)
@staticmethod
def _extract_reply_error_details(
reply: QNetworkReply,
) -> tuple[int | None, str, dict | None]:
def _extract_reply_error_details(reply: QNetworkReply) -> tuple[int | None, str, dict | None]:
status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
err_msg = reply.errorString()
body_json = None
@@ -1310,23 +1243,17 @@ class DAQWorker(QObject):
@staticmethod
def _classify_reply_error(
status: int | None,
err_msg: str,
body_json: dict | None,
status: int | None, err_msg: str, body_json: dict | None
) -> ErrorInfo:
code = body_json.get("code") if isinstance(body_json, dict) else None
exception_class = (
body_json.get("exception_class") if isinstance(body_json, dict) else None
)
exception_class = body_json.get("exception_class") if isinstance(body_json, dict) else None
context = body_json.get("context") if isinstance(body_json, dict) else {}
if not isinstance(context, dict):
context = {}
return ErrorInfo(
critical=DAQWorker._is_critical(body_json),
code=str(code) if code is not None else None,
exception_class=str(exception_class)
if exception_class is not None
else None,
exception_class=str(exception_class) if exception_class is not None else None,
message=str(err_msg or ""),
context=context,
)
@@ -1351,9 +1278,7 @@ class DAQWorker(QObject):
@staticmethod
def _is_critical_detector_failure(
status: int | None,
body_json: dict | None = None,
err_msg: str | None = None,
status: int | None, body_json: dict | None = None, err_msg: str | None = None
) -> bool:
try:
status_int = int(status) if status is not None else None
@@ -1397,9 +1322,7 @@ class DAQWorker(QObject):
watcher_trip = self._observe_recurrence(error_info.exception_class)
if self._is_detector_state_failure_message(error_info.message):
self._emit_detector_message(
f"JFJoch: {error_info.message}", is_error=True
)
self._emit_detector_message(f"JFJoch: {error_info.message}", is_error=True)
if self._is_auth_error_code(error_info.code) or status == 401:
logger.error(f"Error in auto scan: {error_info.message}")
@@ -1500,15 +1423,11 @@ class DAQWorker(QObject):
logger.info("POST /local_contact/resync/detector_metadata")
return
request = QNetworkRequest(
QUrl(f"{self.__base_url}/local_contact/resync/detector_metadata")
)
request = QNetworkRequest(QUrl(f"{self.__base_url}/local_contact/resync/detector_metadata"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
reply = self.__net_manager.post(request, QByteArray(b""))
reply.finished.connect(
lambda: self._handle_detector_metadata_resync_response(reply)
)
reply.finished.connect(lambda: self._handle_detector_metadata_resync_response(reply))
@Slot(float)
def anneal(self, time_s: float):
@@ -1566,14 +1485,10 @@ class DAQWorker(QObject):
)
return
request = QNetworkRequest(
QUrl(f"{self.__base_url}/local_contact/simulation_state")
)
request = QNetworkRequest(QUrl(f"{self.__base_url}/local_contact/simulation_state"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
reply = self.__net_manager.get(request)
reply.finished.connect(
lambda: self._handle_local_contact_simulation_state_response(reply)
)
reply.finished.connect(lambda: self._handle_local_contact_simulation_state_response(reply))
def _handle_local_contact_simulation_state_response(self, reply: QNetworkReply):
try:
@@ -1602,9 +1517,7 @@ class DAQWorker(QObject):
request = QNetworkRequest(QUrl(f"{self.__base_url}/local_contact/device_state"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
reply = self.__net_manager.get(request)
reply.finished.connect(
lambda: self._handle_local_contact_device_state_response(reply)
)
reply.finished.connect(lambda: self._handle_local_contact_device_state_response(reply))
def _handle_local_contact_device_state_response(self, reply: QNetworkReply):
try:
@@ -1665,9 +1578,7 @@ class DAQWorker(QObject):
request = QNetworkRequest(QUrl(f"{self.__base_url}/local_contact/config"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
reply = self.__net_manager.get(request)
reply.finished.connect(
lambda: self._handle_local_contact_config_response(reply)
)
reply.finished.connect(lambda: self._handle_local_contact_config_response(reply))
def _handle_local_contact_config_response(self, reply: QNetworkReply):
try:
@@ -1695,9 +1606,7 @@ class DAQWorker(QObject):
request.setRawHeader(b"Content-Type", b"application/json")
body = QByteArray(json.dumps(payload).encode("utf-8"))
reply = self.__net_manager.put(request, body)
reply.finished.connect(
lambda: self._handle_set_local_contact_config_response(reply)
)
reply.finished.connect(lambda: self._handle_set_local_contact_config_response(reply))
def _handle_set_local_contact_config_response(self, reply: QNetworkReply):
try:
@@ -1710,17 +1619,14 @@ class DAQWorker(QObject):
self.status_message.emit("Local Contact config saved.", False)
except Exception as e:
message = (
"Error transferring information from DAQ while saving Local Contact config.\n\n"
f"{e}"
f"Error transferring information from DAQ while saving Local Contact config.\n\n{e}"
)
logger.error(message)
self.local_contact_transfer_error.emit(message)
@Slot(str, bool)
def set_local_contact_simulation(self, device: str, enabled: bool):
self.generic_post(
f"local_contact/simulate/{device}?enabled={str(enabled).lower()}"
)
self.generic_post(f"local_contact/simulate/{device}?enabled={str(enabled).lower()}")
@Slot(str)
def restart_local_contact_device(self, device: str):
@@ -1778,9 +1684,7 @@ class DAQWorker(QObject):
@Slot(str)
def bec_reinitialise_planner_and_position_devices(self, method: str = "auto"):
self.generic_post(
f"bec/reinitialise_planner_and_position_devices?method={method}"
)
self.generic_post(f"bec/reinitialise_planner_and_position_devices?method={method}")
@Slot()
def bec_save_current_bs_pos(self):
@@ -1804,9 +1708,7 @@ class DAQWorker(QObject):
@Slot()
def initialise_aerotech(self):
logger.info(
"initisalisation does not initisalise aareSCAN but runs homing script"
)
logger.info("initisalisation does not initisalise aareSCAN but runs homing script")
self.generic_post("aerotech/initialize")
@Slot()
@@ -1892,9 +1794,7 @@ class DAQWorker(QObject):
status = None
if reply is not None:
try:
status = reply.attribute(
QNetworkRequest.Attribute.HttpStatusCodeAttribute
)
status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
except Exception:
status = None
@@ -1972,19 +1872,15 @@ class DAQWorker(QObject):
finished=bool(progress_payload.get("finished", False)),
success=progress_payload.get("success"),
samples_in_queue=int(progress_payload.get("samples_in_queue", 0) or 0),
avg_time_per_sample=float(
progress_payload.get("avg_time_per_sample", 0.0) or 0.0
),
avg_time_per_sample=float(progress_payload.get("avg_time_per_sample", 0.0) or 0.0),
current_sample_name=str(progress_payload.get("current_sample_name") or ""),
)
@staticmethod
def _log_level_for_event(level: str) -> int:
return {
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
}.get(str(level).upper(), logging.INFO)
return {"ERROR": logging.ERROR, "WARNING": logging.WARNING, "INFO": logging.INFO}.get(
str(level).upper(), logging.INFO
)
def _emit_automation_progress_events(self, progress: AutomationProgress) -> None:
for event in progress.events:
@@ -2036,8 +1932,8 @@ class DAQWorker(QObject):
def _process_automation_progress_buffer(self) -> None:
while "\n\n" in self._automation_progress_buffer:
event_data, self._automation_progress_buffer = (
self._automation_progress_buffer.split("\n\n", 1)
event_data, self._automation_progress_buffer = self._automation_progress_buffer.split(
"\n\n", 1
)
data_lines: list[str] = []
@@ -2070,9 +1966,7 @@ class DAQWorker(QObject):
status = None
if reply is not None:
try:
status = reply.attribute(
QNetworkRequest.Attribute.HttpStatusCodeAttribute
)
status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
except Exception:
status = None
@@ -2136,9 +2030,7 @@ class DAQWorker(QObject):
return
request = QNetworkRequest(
QUrl(
f"{self.__base_url}/face_detection/run?steps={steps}&step_size={step_size}"
)
QUrl(f"{self.__base_url}/face_detection/run?steps={steps}&step_size={step_size}")
)
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
@@ -2165,12 +2057,8 @@ class DAQWorker(QObject):
data = json.loads(response_data) if response_data else []
if emit_status:
# fetch status and bkg in parallel (simple sequential here)
status_req = QNetworkRequest(
QUrl(f"{self.__base_url}/fluorimeter/status")
)
status_req.setRawHeader(
b"Authorization", f"Bearer {self.__token}".encode("utf-8")
)
status_req = QNetworkRequest(QUrl(f"{self.__base_url}/fluorimeter/status"))
status_req.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
status_reply = self.__net_manager.get(status_req)
status_reply.finished.connect(
lambda: self._handle_fluorimeter_status_and_emit(data, status_reply)
@@ -2186,13 +2074,9 @@ class DAQWorker(QObject):
s_payload = self.handle_response(status_reply)
s = int(s_payload) if s_payload not in ("", "null") else -1
b_req = QNetworkRequest(QUrl(f"{self.__base_url}/fluorimeter/background"))
b_req.setRawHeader(
b"Authorization", f"Bearer {self.__token}".encode("utf-8")
)
b_req.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
b_reply = self.__net_manager.get(b_req)
b_reply.finished.connect(
lambda: self._emit_fluorimeter_with_bkg(data, s, b_reply)
)
b_reply.finished.connect(lambda: self._emit_fluorimeter_with_bkg(data, s, b_reply))
except Exception as e:
logger.error(f"Fluorimeter status error: {e}")
self.http_error.emit(str(e))
@@ -2222,9 +2106,7 @@ class DAQWorker(QObject):
def _handle_fluorimeter_spectrum(self, reply: QNetworkReply):
try:
response_data = self.handle_response(reply)
parsed_response = FluorescenceSpectrumOutputModel.model_validate_json(
response_data
)
parsed_response = FluorescenceSpectrumOutputModel.model_validate_json(response_data)
self.fluorimeter_spectrum_update.emit(parsed_response)
except Exception as e:
logger.error(f"Exception from fluorimeter spectrum: {e}")
@@ -2237,9 +2119,7 @@ class DAQWorker(QObject):
req = QNetworkRequest(QUrl(f"{self.__base_url}/fluorimeter/data"))
req.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
reply = self.__net_manager.get(req)
reply.finished.connect(
lambda: self._handle_fluorimeter_data(reply, emit_status=True)
)
reply.finished.connect(lambda: self._handle_fluorimeter_data(reply, emit_status=True))
# Optional: SSE listener for live updates
def start_fluorimeter_stream(self):
@@ -2336,9 +2216,7 @@ class DAQWorker(QObject):
@Slot(str, str)
def send_screenshot_db(self, filename: str = "", message: str = ""):
if self.__base_url is None:
logger.info(
f"POST /samcam/send_screenshot_db?filename={filename}&message={message}"
)
logger.info(f"POST /samcam/send_screenshot_db?filename={filename}&message={message}")
return
from urllib.parse import quote
@@ -2530,10 +2408,7 @@ class DAQWorker(QObject):
- clear the active beamline session if this GUI owns it
"""
try:
if (
hasattr(self, "_baton_timeout_timer")
and self._baton_timeout_timer is not None
):
if hasattr(self, "_baton_timeout_timer") and self._baton_timeout_timer is not None:
self._baton_timeout_timer.stop()
self.end_session()
@@ -2585,9 +2460,7 @@ class DAQWorker(QObject):
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
reply = self.__net_manager.post(request, QByteArray(b""))
reply.finished.connect(
lambda: self._handle_gui_session_mutation_response(reply)
)
reply.finished.connect(lambda: self._handle_gui_session_mutation_response(reply))
@Slot(int)
def force_remove_gui_session(self, session_id: int):
@@ -2595,14 +2468,10 @@ class DAQWorker(QObject):
logger.info(f"DELETE /admin/gui_sessions/{session_id}")
return
request = QNetworkRequest(
QUrl(f"{self.__base_url}/admin/gui_sessions/{session_id}")
)
request = QNetworkRequest(QUrl(f"{self.__base_url}/admin/gui_sessions/{session_id}"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
reply = self.__net_manager.deleteResource(request)
reply.finished.connect(
lambda: self._handle_gui_session_mutation_response(reply)
)
reply.finished.connect(lambda: self._handle_gui_session_mutation_response(reply))
def _handle_gui_session_mutation_response(self, reply: QNetworkReply):
try:
@@ -2637,10 +2506,7 @@ class DAQWorker(QObject):
self._cleanup_done = True
try:
if (
hasattr(self, "_baton_timeout_timer")
and self._baton_timeout_timer is not None
):
if hasattr(self, "_baton_timeout_timer") and self._baton_timeout_timer is not None:
self._baton_timeout_timer.stop()
except Exception as e:
logger.warning(f"Failed to stop _baton_timeout_timer: {e}")
+5 -16
View File
@@ -126,12 +126,7 @@ class PredictionSubscriber(QThread):
return None
def _rgb_to_pixmap(self, rgb: np.ndarray) -> QPixmap:
qimage = QImage(
rgb.data,
rgb.shape[1],
rgb.shape[0],
QImage.Format.Format_RGB888,
).copy()
qimage = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888).copy()
return QPixmap.fromImage(qimage)
def _emit_focus_measure_if_enabled(self, rgb: np.ndarray) -> None:
@@ -169,9 +164,7 @@ class PredictionSubscriber(QThread):
self._fps_frame_count = 0
if no_frames_long:
self._set_camera_available(
False, "Sample camera feed unavailable"
)
self._set_camera_available(False, "Sample camera feed unavailable")
continue
if not parts:
@@ -215,8 +208,7 @@ class PredictionSubscriber(QThread):
rgb = self._decode_rgb_image(header, image_bytes)
if rgb is None:
self._set_camera_available(
False,
"Sample camera feed unavailable: failed to decode frame",
False, "Sample camera feed unavailable: failed to decode frame"
)
else:
self._set_camera_available(True)
@@ -225,8 +217,7 @@ class PredictionSubscriber(QThread):
self.image.emit(self._rgb_to_pixmap(rgb))
elif self._emit_images:
self._set_camera_available(
False,
"Sample camera feed unavailable: no frame header in zmq stream",
False, "Sample camera feed unavailable: no frame header in zmq stream"
)
if detections and self.running:
@@ -237,9 +228,7 @@ class PredictionSubscriber(QThread):
except Exception as e:
if self.running:
self._set_camera_available(
False, f"Sample camera feed unavailable: {e}"
)
self._set_camera_available(False, f"Sample camera feed unavailable: {e}")
logger.exception(f"PredictionSubscriber error: {e}")
finally:
try:

Some files were not shown because too many files have changed in this diff Show More